Pre-flight checklist
Problem Statement
ktestify-core currently only exposes one transport shape:
public interface RecordFetcher<V> extends AutoCloseable {
List<ConsumedRecord<V>> fetch() throws FetchException;
void close();
}
This models a background stream that you poll until something appears (Kafka topic, Azure Blob polling, IBM MQ queue watch). It works well for those transports, but it is the wrong shape for synchronous, caller-initiated transports like HTTP: an HTTP call does not "wait for a record to appear", it sends a request right now and gets an answer immediately.
This gap matters because a plugin is being planned (ktestify-plugin-http) to let users test content chains where part of the pipeline is triggered by an API call instead of Kafka. Building that plugin on top of RecordFetcher would be dishonest (forcing a poll-based contract onto a call/response transport), and building it with its own ad hoc action/validation services would mean the plugin stops following the core API, defeating the purpose of the shared three-layer architecture (transport / orchestration / assertion) that every other transport in this project follows.
Rather than fix this inside the plugin, the fix belongs in ktestify-core: add a second, sibling transport contract for synchronous request/response transports, so HTTP (and any future gRPC, SOAP, or synchronous MQ request/reply transport) can plug into the exact same assertion layer (RecordMatcher, MatchContext, MatchResult, RecordMatcherFactory) that Kafka and Azure Blob already use, with zero new matcher code needed for body comparisons.
Proposed Solution
Add the following, fully additive, backward-compatible pieces to ktestify-core:
-
io/core/RequestResponseClient.java (new)
Sibling contract to RecordFetcher<V>:
public interface RequestResponseClient<Req, V> extends AutoCloseable {
List<ConsumedRecord<V>> execute(Req request) throws FetchException;
void close();
}
Same return type as RecordFetcher.fetch(), so it feeds the exact same RecordMatcher<V> signature unchanged.
-
models/ConsumedRecord.java (edit)
Add a new attributes: Map<String,String> field (default empty map) for transport-specific structured metadata that does not belong under headers (real protocol headers). Examples: HTTP status code, elapsed time, a future gRPC status code, an MQ reason code. Must stay backward compatible with existing callers (fromKafkaRecord, AzureBlobRecordFetcher, tests), either via a @Builder plus a legacy constructor overload, or an additive constructor overload defaulting attributes to Collections.emptyMap().
-
match/MatchContext.java (edit)
Add expectedAttributes: Map<String,String> (default empty map), used by the new matcher below.
-
match/impl/AttributeRecordMatcher.java (new)
Generic, transport-agnostic matcher that asserts record.getAttributes() entries against context.getExpectedAttributes(). Works for any V since it never inspects the record value. Reused later by any future transport that populates attributes (status codes, reason codes, exit codes, etc.), so no per-transport matcher class is needed going forward.
-
match/RecordMatcherFactory.java (edit)
Add METHOD_MATCH_ATTRIBUTES constant and register AttributeRecordMatcher in forRaw(...). Update the ConsumerException message listing valid matchMethod values.
-
io/core/AbstractSynchronousConsumer.java (new)
Orchestration base mirroring io.github.ktestify.io.kafka.AbstractKafkaConsumer 1:1 in shape and Javadoc style, but wired to RequestResponseClient instead of a per-call KafkaRecordFetcher:
public abstract class AbstractSynchronousConsumer<Req, V> extends AbstractConsumer {
protected abstract Req buildRequest();
protected abstract MatchContext buildMatchContext();
public Boolean call() throws ConsumerException {
try {
List<ConsumedRecord<V>> records = client.execute(buildRequest());
return matcher.match(records, buildMatchContext()).isPassed();
} catch (FetchException e) {
throw new ConsumerException(e.getMessage());
}
}
}
Note: unlike AbstractKafkaConsumer, the RequestResponseClient is expected to be a longer-lived, connection-pooled client owned by the caller, not created and closed per invocation.
-
io/core/PollingRequestResponseClient.java (new, recommended in the same PR)
Generic RequestResponseClient decorator that retries execute(...) against a delegate client until a caller-supplied predicate on the result passes, or a timeout elapses. Gives every synchronous-transport plugin "eventually consistent" polling semantics for free, instead of every plugin hand-rolling its own sleep loop (as currently happens independently in AzureBlobRecordFetcher and the plugin skeleton).
Alternatives Considered
No response
Affected Component
Other
Example Usage / API Sketch
Additional Context
Originally posted by williamgarr August 6, 2026
Hello, in my app I have a content chain of consumers/producers. But a part of the chain is triggered with API calls instead of Kafka.
Ideally I want to test it with the rest of the content chain in my KTestify cucumber setup.
The first workaround tooling I thought of was :
- A generic script to launch API calls using curl
- The script is run by the KTestify cucumber run script step
That could work but it is IMO a little dirty.
An elegant solution could be :
- A dedicated HTTP client KTestify plugin
- Which adds specific steps to launch HTTP requests during the tests
- It could improve readability in the tests and also technical integration complexity due to custom scripts
- Also we can imagine more complex use cases in the future involving HTTP requests
Hope you like the idea ;)
Pre-flight checklist
Problem Statement
ktestify-corecurrently only exposes one transport shape:This models a background stream that you poll until something appears (Kafka topic, Azure Blob polling, IBM MQ queue watch). It works well for those transports, but it is the wrong shape for synchronous, caller-initiated transports like HTTP: an HTTP call does not "wait for a record to appear", it sends a request right now and gets an answer immediately.
This gap matters because a plugin is being planned (
ktestify-plugin-http) to let users test content chains where part of the pipeline is triggered by an API call instead of Kafka. Building that plugin on top ofRecordFetcherwould be dishonest (forcing a poll-based contract onto a call/response transport), and building it with its own ad hoc action/validation services would mean the plugin stops following the core API, defeating the purpose of the shared three-layer architecture (transport / orchestration / assertion) that every other transport in this project follows.Rather than fix this inside the plugin, the fix belongs in
ktestify-core: add a second, sibling transport contract for synchronous request/response transports, so HTTP (and any future gRPC, SOAP, or synchronous MQ request/reply transport) can plug into the exact same assertion layer (RecordMatcher,MatchContext,MatchResult,RecordMatcherFactory) that Kafka and Azure Blob already use, with zero new matcher code needed for body comparisons.Proposed Solution
Add the following, fully additive, backward-compatible pieces to
ktestify-core:io/core/RequestResponseClient.java(new)Sibling contract to
RecordFetcher<V>:Same return type as
RecordFetcher.fetch(), so it feeds the exact sameRecordMatcher<V>signature unchanged.models/ConsumedRecord.java(edit)Add a new
attributes: Map<String,String>field (default empty map) for transport-specific structured metadata that does not belong underheaders(real protocol headers). Examples: HTTP status code, elapsed time, a future gRPC status code, an MQ reason code. Must stay backward compatible with existing callers (fromKafkaRecord,AzureBlobRecordFetcher, tests), either via a@Builderplus a legacy constructor overload, or an additive constructor overload defaultingattributestoCollections.emptyMap().match/MatchContext.java(edit)Add
expectedAttributes: Map<String,String>(default empty map), used by the new matcher below.match/impl/AttributeRecordMatcher.java(new)Generic, transport-agnostic matcher that asserts
record.getAttributes()entries againstcontext.getExpectedAttributes(). Works for anyVsince it never inspects the record value. Reused later by any future transport that populatesattributes(status codes, reason codes, exit codes, etc.), so no per-transport matcher class is needed going forward.match/RecordMatcherFactory.java(edit)Add
METHOD_MATCH_ATTRIBUTESconstant and registerAttributeRecordMatcherinforRaw(...). Update theConsumerExceptionmessage listing validmatchMethodvalues.io/core/AbstractSynchronousConsumer.java(new)Orchestration base mirroring
io.github.ktestify.io.kafka.AbstractKafkaConsumer1:1 in shape and Javadoc style, but wired toRequestResponseClientinstead of a per-callKafkaRecordFetcher:Note: unlike
AbstractKafkaConsumer, theRequestResponseClientis expected to be a longer-lived, connection-pooled client owned by the caller, not created and closed per invocation.io/core/PollingRequestResponseClient.java(new, recommended in the same PR)Generic
RequestResponseClientdecorator that retriesexecute(...)against a delegate client until a caller-supplied predicate on the result passes, or a timeout elapses. Gives every synchronous-transport plugin "eventually consistent" polling semantics for free, instead of every plugin hand-rolling its own sleep loop (as currently happens independently inAzureBlobRecordFetcherand the plugin skeleton).Alternatives Considered
No response
Affected Component
Other
Example Usage / API Sketch
Additional Context
Discussed in https://github.com/orgs/ktestify/discussions/54
Originally posted by williamgarr August 6, 2026
Hello, in my app I have a content chain of consumers/producers. But a part of the chain is triggered with API calls instead of Kafka.
Ideally I want to test it with the rest of the content chain in my KTestify cucumber setup.
The first workaround tooling I thought of was :
That could work but it is IMO a little dirty.
An elegant solution could be :
Hope you like the idea ;)