Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions docs/extend/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ description: KTestify's three-layer architecture. Transport, Orchestration, and

KTestify is built on a strict **three-layer separation of concerns**. Each layer has one job and knows nothing about the other layers' implementation details.

Since version `1.1.1`, the transport layer has **two sibling contracts** instead of one. `RecordFetcher<V>` models a background stream you poll until a record appears (Kafka, Azure Blob). `RequestResponseClient<Req, V>` models a synchronous, caller-initiated call where you send a request right now and get an answer immediately (HTTP, gRPC, SOAP). Both return `List<ConsumedRecord<V>>`, so the orchestration and assertion layers below stay identical no matter which contract a given transport implements.

```mermaid
---
config:
Expand All @@ -16,40 +18,48 @@ config:
flowchart LR
subgraph TRANSPORT
A[KafkaRecordFetcher]
A2[RequestResponseClient&lt;Req,V&gt;]
end

subgraph ORCHESTRATION
B[AbstractKafkaConsumer]
C[RawKafkaConsumer]
D[AvroKafkaConsumer]
B2[AbstractSynchronousConsumer]
end

subgraph ASSERTION
E[RecordMatcher&lt;V&gt;]
F[FileRecordMatcher]
G[XmlRecordMatcher]
H[AvroFileRecordMatcher]
I[AttributeRecordMatcher]
end

A -->|List&lt;ConsumedRecord&gt;| B
B --> C
B --> D
B -->|List&lt;ConsumedRecord&gt;| E
A2 -->|List&lt;ConsumedRecord&gt;| B2
B2 -->|List&lt;ConsumedRecord&gt;| E
E --> F
E --> G
E --> H
E --> I

classDef transport fill:#E1F5EE,stroke:#0F6E56,color:#085041
classDef orchestration fill:#EEEDFE,stroke:#534AB7,color:#3C3489
classDef assertion fill:#FAC775,stroke:#854F0B,color:#412402

class A transport
class B,C,D orchestration
class E,F,G,H assertion
class A,A2 transport
class B,C,D,B2 orchestration
class E,F,G,H,I assertion
```

**`ConsumedRecord<V>` is the only data type that crosses layer boundaries.**

Synchronous transports additionally populate a new `attributes` map on `ConsumedRecord` (an HTTP status code, a future gRPC status code, an MQ reason code) for metadata that is not a protocol header. Asynchronous transports leave it empty. See [Core Concepts →](core-concepts) for the full field list.

---

## Layer responsibilities
Expand All @@ -72,6 +82,22 @@ Swapping Kafka for IBM MQ means writing a new `IbmMqRecordFetcher<V>`, nothing e

---

### Transport, synchronous - `RequestResponseClient<Req, V>`

Knows: how to send one request and turn the answer into `ConsumedRecord`.
Does NOT know: matchers, files, test frameworks, and does not block waiting for something to "appear".

```java
public interface RequestResponseClient<Req, V> extends AutoCloseable {
List<ConsumedRecord<V>> execute(Req request) throws FetchException;
void close();
}
```

This is the contract an HTTP client plugin implements. See [Synchronous Transports →](transports/synchronous-transports) for the full guide.

---

### Orchestration - `AbstractKafkaConsumer`

Knows: fetch → match → result wiring.
Expand All @@ -94,6 +120,27 @@ try {

---

### Orchestration, synchronous - `AbstractSynchronousConsumer<Req, V>`

Knows: buildRequest → execute → match → result wiring, for transports where the caller supplies the request explicitly instead of the fetcher blocking on a subscription.
Does NOT know: HTTP, gRPC, or any transport internals.

```java
// AbstractSynchronousConsumer.call(), simplified
try {
Req request = buildRequest();
List<ConsumedRecord<V>> records = client.execute(request); // transport
MatchResult result = matcher.match(records, buildMatchContext()); // assertion
return result.isPassed();
} catch (FetchException e) {
throw new ConsumerException(e.getMessage());
}
```

Unlike `AbstractKafkaConsumer`, the client is not closed in a `finally` block here. A `RequestResponseClient` is expected to be a longer lived, connection pooled client (like `java.net.http.HttpClient`), owned and closed by the plugin's shared scenario resources, not created and discarded per call.

---

### Assertion - `RecordMatcher<V>`

Knows: `ConsumedRecord`, expected values, comparison algorithm.
Expand All @@ -114,9 +161,12 @@ public interface RecordMatcher<V> {
ktestify-core ktestify-cucumber
─────────────────────────────── ──────────────────────────────────
RecordFetcher<V> BackgroundStepDefinition
RequestResponseClient<Req,V> ValidationStepDefinition
KafkaRecordFetcher ◄──────── ConsumerContext (config only)
AbstractKafkaConsumer ValidationStepDefinition
RecordMatcher<V> ConsumerValidationService
AbstractKafkaConsumer ConsumerValidationService
AbstractSynchronousConsumer
PollingRequestResponseClient
RecordMatcher<V>
MatchContext / MatchResult
ConsumedRecord<V>
```
Expand All @@ -138,21 +188,27 @@ flowchart TD
C["AbstractKafkaConsumer&lt;K, V&gt;"]
D["RawKafkaConsumer\nK=String, V=String"]
E["AvroKafkaConsumer\nK=String, V=GenericRecord"]
C2["AbstractSynchronousConsumer&lt;Req, V&gt;"]

A --> B --> C --> D
C --> E
B --> C2

F(["RecordFetcher&lt;V&gt; · interface"])
G["KafkaRecordFetcher&lt;K, V&gt;"]
F --> G

H(["RequestResponseClient&lt;Req,V&gt; · interface"])
I["PollingRequestResponseClient&lt;Req,V&gt;"]
H --> I

classDef abstract fill:#EEEDFE,stroke:#534AB7,color:#3C3489
classDef concrete fill:#E1F5EE,stroke:#0F6E56,color:#085041
classDef iface fill:#FAC775,stroke:#854F0B,color:#412402

class A,B,C abstract
class D,E,G concrete
class F iface
class A,B,C,C2 abstract
class D,E,G,I concrete
class F,H iface

```

Expand All @@ -167,6 +223,7 @@ RecordMatcher<V> (@FunctionalInterface)
├── FieldsRecordMatcher
├── FileKeyRecordMatcher
├── KeyRecordMatcher
├── AttributeRecordMatcher<V>
├── AvroFileRecordMatcher
├── AvroFileKeyRecordMatcher
├── AvroFieldsRecordMatcher
Expand All @@ -179,5 +236,6 @@ RecordMatcher<V> (@FunctionalInterface)
- [Core concepts →](core-concepts)
- [Transports, Kafka →](transports/kafka)
- [Adding a transport →](transports/adding-a-transport)
- [Synchronous transports →](transports/synchronous-transports)
- [Built-in matchers →](matchers/built-in-matchers)

36 changes: 31 additions & 5 deletions docs/extend/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,29 @@ description: Key domain types in ktestify-core, ConsumedRecord, MatchContext, Ma

## `ConsumedRecord<V>`

The **only** data type that crosses layer boundaries. It is the universal output of the transport layer and the universal input of the assertion layer.
The **only** data type that crosses layer boundaries. It is the universal output of the transport layer (`RecordFetcher<V>` for asynchronous transports, `RequestResponseClient<Req, V>` for synchronous ones since `1.1.1`) and the universal input of the assertion layer.

```java
@Value
public class ConsumedRecord<V> {
String source; // topic name
String source; // topic name, container name, request URL, ...
int partition;
long offset;
String key;
V value; // String for raw, GenericRecord for Avro
long timestamp;
Headers headers;
Instant timestamp;
Map<String, String> headers; // protocol headers (Kafka headers, HTTP response headers, ...)
Map<String, String> attributes; // NEW in 1.1.1, transport metadata, never null, defaults to emptyMap()

static <V> ConsumedRecord<V> fromKafkaRecord(ConsumerRecord<String, V> record) { ... }
MatchedRecord toMatchedRecord() { ... }
}
```

`attributes` holds structured transport metadata that does not belong under `headers`, for example an HTTP status code and elapsed time, a future gRPC status code, or an MQ reason code. Kafka and Azure Blob leave it empty. A synchronous transport plugin populates it, and `AttributeRecordMatcher` (see [Built-in matchers →](matchers/built-in-matchers)) asserts against it.

`ConsumedRecord` ships both a full constructor (accepting `attributes`) and a backward-compatible overload without it (defaults to `Collections.emptyMap()`), plus a `@Builder`, so existing transports keep compiling unchanged.

---

## `MatchedRecord`
Expand All @@ -38,7 +43,7 @@ Deduplication token, represents a record that has already been claimed by a cons

## `MatchContext`

Immutable context object passed to a `RecordMatcher`. Built by `AbstractKafkaConsumer.buildMatchContext()` from the `ConsumerContext`.
Immutable context object passed to a `RecordMatcher`. Built by `AbstractKafkaConsumer.buildMatchContext()` (or `AbstractSynchronousConsumer.buildMatchContext()` for synchronous transports) from the `ConsumerContext`.

```java
@Value @Builder
Expand All @@ -49,6 +54,7 @@ public class MatchContext {
boolean strictMatching;
String matchKey;
String matchValue;
Map<String,String> expectedAttributes; // NEW in 1.1.1, defaults to emptyMap(), used by AttributeRecordMatcher

// Convenience, for single-record matchers
public String getMatchFilePath() {
Expand All @@ -58,6 +64,8 @@ public class MatchContext {
}
```

`expectedAttributes` holds the key/value pairs to assert against a record's `attributes` map, for example `{"statusCode": "200"}`. It follows the same convention as `excludedFields`, always non-null, defaults to an empty map, and matchers check `isEmpty()` rather than `null`.

---

## `MatchResult`
Expand Down Expand Up @@ -125,12 +133,30 @@ RecordMatcherFactory.forRaw("matchFile") → FileRecordMatcher
RecordMatcherFactory.forAvro("matchFile") → AvroFileRecordMatcher
RecordMatcherFactory.forRaw("matchXML") → XmlRecordMatcher
RecordMatcherFactory.forAvro("matchXML") → throws ConsumerException ← not supported
RecordMatcherFactory.forRaw("methodMatchAttributes") → AttributeRecordMatcher<>() // NEW in 1.1.1, raw only
```

See [Built-in matchers →](matchers/built-in-matchers) for the full mapping table.

---

## Synchronous transports (`1.1.1`)

Alongside `RecordFetcher<V>` (async, poll until a record appears), `ktestify-core` now ships a sibling contract for synchronous, caller-initiated transports:

```java
public interface RequestResponseClient<Req, V> extends AutoCloseable {
List<ConsumedRecord<V>> execute(Req request) throws FetchException;
void close();
}
```

Its orchestration counterpart, `AbstractSynchronousConsumer<Req, V>`, mirrors `AbstractKafkaConsumer` (build a request, call `execute`, hand the result to a `RecordMatcher`, return `MatchResult.isPassed()`), and a generic decorator, `PollingRequestResponseClient<Req, V>`, adds retry-until-predicate-or-timeout semantics that any synchronous transport plugin can reuse instead of writing its own poll loop.

Full details, including the client lifecycle and how to implement a new synchronous transport, live on the [Synchronous transports →](transports/synchronous-transports) page.

---

## Dynamic variable system

All file reads go through `FileUtils.getFileContent(path)`, which transparently calls `DynamicVariableProcessor.process(content)` before returning. To add a new variable type:
Expand Down
18 changes: 16 additions & 2 deletions docs/extend/matchers/built-in-matchers.mdx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
sidebar_position: 1
title: Built-in Matchers
description: Reference for all 11 built-in RecordMatcher implementations and when to use each.
description: Reference for all built-in RecordMatcher implementations and when to use each.
---

# Built-in Matchers

KTestify ships 11 `RecordMatcher<V>` implementations, split into raw (String) and Avro (GenericRecord) variants. They are selected automatically by `RecordMatcherFactory` based on the `matchMethod` in `MatchContext`.
KTestify ships 12 `RecordMatcher<V>` implementations, split into raw (String) and Avro (GenericRecord) variants. They are selected automatically by `RecordMatcherFactory` based on the `matchMethod` in `MatchContext`.

---

Expand All @@ -20,6 +20,7 @@ KTestify ships 11 `RecordMatcher<V>` implementations, split into raw (String) an
| `methodMatchXML` | `XmlRecordMatcher` | ❌ `ConsumerException` |
| `methodMatchXPath` | `XPathRecordMatcher` | ❌ `ConsumerException` |
| `methodRecordKeyMatch` | `KeyRecordMatcher` | `AvroKeyRecordMatcher` |
| `methodMatchAttributes` | `AttributeRecordMatcher<V>` | ❌ `ConsumerException` |
| `null` / blank | `NoOpRecordMatcher<V>` | `NoOpRecordMatcher<V>` |

```java
Expand Down Expand Up @@ -89,6 +90,18 @@ Always returns `MatchResult.pass()`. Used when `matchMethod` is `null` or blank,

---

### `AttributeRecordMatcher<V>` (`1.1.1`)

Generic, transport agnostic matcher introduced alongside `RequestResponseClient<Req, V>` for synchronous transports. Asserts one or more entries of `ConsumedRecord.getAttributes()` against `MatchContext.getExpectedAttributes()`. Never inspects `getValue()`, so it works for any `V`, not only `String`.

- **MatchContext fields used:** `expectedAttributes` (map of key to expected value)
- **Matching rule:** every key in `expectedAttributes` must be present in the actual record's `attributes` with an exactly equal String value, only the first record in the list is used
- **On empty `expectedAttributes`:** returns `MatchResult.pass()` immediately, nothing to assert
- **Typical use:** HTTP status code assertions (`{"statusCode": "200"}`), reusable later by any future transport that populates `attributes` (gRPC status, MQ reason code, script exit code)
- **Not available for Avro**, `attributes` is populated by synchronous request/response clients, not Avro consumers

---

## Avro matchers (V = GenericRecord)

All Avro matchers first deserialise the `GenericRecord` to a JSON `Map` via `AvroUtils.toJsonMap()`, then delegate to comparison logic equivalent to their raw counterparts.
Expand Down Expand Up @@ -125,4 +138,5 @@ Avro equivalent of `FieldsRecordMatcher`. Extracts a character range from the JS
2. `matchFilePaths` is always a `List<String>`, single-record matchers use `get(0)`, batch matchers iterate by index.
3. XML/XPath matchers throw `ConsumerException` when called via `RecordMatcherFactory.forAvro()`.
4. `NoOpRecordMatcher` is the default when no match method is configured.
5. `expectedAttributes` defaults to `Collections.emptyMap()`, `AttributeRecordMatcher` checks `isEmpty()`, never `null`, same convention as `excludedFields`.

1 change: 1 addition & 0 deletions docs/extend/transports/adding-a-transport.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,5 @@ No rebuild of `ktestify-cucumber` required. The plugin is discovered and its ste

- [Plugin System →](../plugins/plugin-system), `KtestifyPlugin`, `PluginRegistry`, `PluginContext`
- [Azure Blob plugin →](../plugins/azureblob), a real example of the plugin pattern
- [Synchronous transports →](synchronous-transports), the `RequestResponseClient<Req, V>` contract for HTTP, gRPC, and other call/response transports
- [Architecture →](../architecture), how `RecordFetcher<V>` and `ConsumedRecord<V>` keep transports isolated
Loading