AsyncAPI
For an AsyncAPI 3.x document the mock plays the application the document describes. Servers
with the ws, http and mqtt protocols are served (and wss, https and mqtts over TLS
with --tls-cert, cleartext without). Kafka and AMQP channels aren’t served, but are read from a
real cluster or broker with --proxy-to kafka:// (below)
and --proxy-to amqp:// (below); the others are listed as not
served.
- WebSocket. A client connects to a channel’s address under its server’s
pathname(ws://127.0.0.1:4010/v1/rooms/42forrooms/{roomId}). It gets the messages of the channel’ssendoperations, one after another, every second by default: the message’s examples in turn, or values generated from its payload schema (always with--dynamic). What it sends is checked against thereceiveoperations: a match is answered with the operation’sreply, anything else with a problem details message. - HTTP. A
receiveoperation takes messages withPOST(or its HTTP binding’s method) and answers 202, or 200 with its reply; an invalid message gets 422. Asendoperation answersGETwith a message. - MQTT. A broker on its own port takes subscriptions to the channels’ topics (wildcards
included: a parameter of an address takes its default, first enum value or example) and
publishes the
sendoperations’ messages with their MQTT binding’s QoS. Messages clients publish go to the other subscribers, as with any broker, once they match areceiveoperation; its reply is published to the reply channel’s topic. MQTT 3.1.1 and 5 clients are served; MQTT 5 subscribers also get the MQTT 5 properties of a message: its headers as user properties (the first example’s, else generated from the header schemas), and the payload format indicator, content type, correlation data and response topic of its MQTT binding (generated from their schemas when they are schemas) and the operation binding’s message expiry. An MQTT 5 client’s properties and user properties are checked as the MQTT proxy checks them; its request is answered on the topic of its reply address user property, or on its Response Topic when the reply channel hasaddress: null, with its correlation id and Correlation Data. Retained messages expire with their Message Expiry Interval.
x-apigenie-mock on a send operation sets interval (milliseconds), count (messages per
client, 0 for no limit), jitter, messages (the names to send) and publish: false.
operations:
sendNotices:
action: send
channel: {$ref: '#/channels/room'}
x-apigenie-mock: {interval: 500, count: 10}
The log has ws-open, ws-in, ws-out and ws-close events for WebSocket clients and
mqtt-subscribe, mqtt-in, mqtt-out and mqtt-unsubscribe ones for MQTT, with the channel’s
address, the operation and the message. When the server stops, WebSocket clients get the close
code 1001 and MQTT 5 clients a DISCONNECT; after a reload they reconnect to the new version.
Contract testing of a WebSocket API
With --proxy-to ws://host:port an AsyncAPI document’s WebSocket channels aren’t mocked: each
client of a channel gets a connection to the real server, and every message of either side is
checked against the document. Point a frontend or a test suite at the mock instead of the server.
apigenie-server chat.yaml --proxy-to ws://localhost:9000 --report contract.json --fail-on errors
The mock connects to the server first, with the client’s path under the URL’s prefix, its query,
Origin, cookies, Authorization and Sec-WebSocket-Protocol, plus Via and X-Forwarded-*;
the client gets the subprotocol the server chose. A server that can’t be reached answers the
client’s handshake with 502 (504 when it doesn’t answer in time), and a server that refuses it
(401, 404, …) has its answer passed on. What clients send is checked against the channel’s
receive operations, what the server sends against its send operations. A client’s message of
an operation with a reply on the same channel waits for its reply: the first message the server
sends that is one of the reply’s messages is checked as that reply (logged as name (reply) with
the request’s operation), not against the send operations. Messages are relayed
whole (fragments are put together first); pings are answered on each side; a close frame goes on
with its code. No WebSocket extension is negotiated (permessage-deflate is left out of the
handshake). HTTP channels go to the same server (see below);
the MQTT channels to the broker of a second --proxy-to mqtt://..., or they keep being mocked.
| Check | Severity | When |
|---|---|---|
message.incompatible |
error | The message matches none of the messages of the channel’s operations in its direction. |
message.malformed |
error | The message isn’t JSON where JSON is expected. |
message.frame.unexpected |
warn | A binary frame for a text or JSON message, or a text frame for a binary one (image/png, application/octet-stream, Protobuf, Avro, …). |
message.qos.unexpected |
warn | MQTT: a client published at a QoS other than the one of the operation’s MQTT binding. |
channel.parameter.invalid |
error | A parameter of the channel’s address (a path segment, a topic level) has a value outside the parameter’s enum. |
channel.receive.undeclared |
warn | A client sent a message on a channel the application has no receive operation for. |
channel.send.undeclared |
warn | The server sent a message on a channel it has no send operation for. |
message.reply.uncorrelated |
error | The reply’s correlationId (in the payload) isn’t the request’s, or is missing. |
operation.query.invalid |
error | HTTP channels: the request’s query doesn’t match the query of the operation’s HTTP binding. |
message.headers.invalid |
error | HTTP channels: the request’s or the reply’s header fields don’t match the message’s headers or its HTTP binding’s headers. |
validation.request sets what happens to the clients’ messages and validation.response to the
server’s: log by default for a proxy (reported, and forwarded), enforce with --strict. In
enforce mode a message with an error isn’t forwarded, and the client gets a problem message
instead (asyncapi.validate_incoming stays the mock’s setting):
{"type": "https://apigenie.pl/mock/errors#invalid-upstream-message", "title": "The upstream's message doesn't match the specification",
"status": 422, "channel": "rooms/1", "direction": "upstream", "validation": [{"location": "message/text", "code": "type",
"message": "expected type string, found integer", "check": "message.incompatible", "severity": "error"}]}
A client’s message gets #invalid-message with "direction": "client". The log has the
ws-open, ws-in, ws-out and ws-close events with the operation, the message’s name, the
violations and source: proxy (mock for what the proxy answered itself). contract.severity
changes the checks’ severities as for the other proxies.
Coverage and reports. Each message of each operation is a branch: it is covered once a valid
one went by. An operation is covered, partial or not-tested; a message that matches none is
counted for the operation it was checked against first. GET /__apigenie/contract returns the
report ("specification": "asyncapi", connections, messages in and out, channels, operations,
violations), --report and --report-junit write it when the server stops (a test case per
operation), and --fail-on and --min-coverage (the share of messages) make the exit status 5.
Contract testing of MQTT channels
With --proxy-to mqtt://host:port the mock’s broker relays to a real broker: each client that
connects to the mock gets a connection of its own to the broker, and every message published
either way is checked against the document.
apigenie-server devices.yaml --proxy-to mqtt://localhost:1884 --mqtt-port 1883 --report contract.json
apigenie-server events.yaml --proxy-to ws://localhost:9000 --proxy-to mqtt://localhost:1884
The upstream connection has the client’s MQTT version, client identifier (the one the mock
assigns when the client gives none), clean start, keep-alive, will, user name and password. The
client’s CONNECT gets the broker’s CONNACK, a refusal included; a broker that can’t be reached is
answered with “server unavailable” (return code 3, MQTT 5 0x88). Subscriptions are the broker’s:
the SUBACK is its answer, and the broker decides who gets which message. What clients publish is
checked against the topic’s channel’s receive operations, with the QoS of the operation’s MQTT
binding when it gives one (message.qos.unexpected); what the broker delivers against the send
operations. A subscriber also gets other clients’ messages, so on a channel without send
operations a delivery is checked against the receive operations. Topics of no channel are
relayed unchecked (debug level in the log).
In enforce mode a client’s message with an error isn’t forwarded (MQTT 5 clients get reason code
0x99 in their acknowledgement), and a broker’s message with an error isn’t delivered. A client
that disconnects is disconnected from the broker; one whose connection is lost has its upstream
connection dropped too, so the broker publishes its will; a client the broker disconnects is
disconnected. A reload keeps the clients connected. The log has mqtt-connect,
mqtt-subscribe, mqtt-in, mqtt-out, mqtt-unsubscribe and mqtt-disconnect events with
source: proxy (mock for what the proxy decided itself), and coverage and reports work as for
WebSocket.
MQTT 5 properties are relayed both ways: user properties, payload format indicator, content type, response topic, correlation data and message expiry (not the topic alias and subscription identifiers, which belong to one connection). An MQTT 5 message’s are checked against the MQTT bindings (0.2.0):
| Binding | Checked (message.properties.invalid, error) |
|---|---|
message payloadFormatIndicator |
The Payload Format Indicator is the same (absent counts as 0) |
message contentType |
The Content Type is the same, ignoring case |
message correlationData |
Correlation Data is present and matches the schema |
message responseTopic |
A Response Topic is present, the string given or matching the schema |
operation messageExpiryInterval |
A Message Expiry Interval is present and no longer |
Its user properties are its headers, checked against the message’s headers
(message.headers.invalid). MQTT 3.1.1 messages have no properties: nothing is checked for them.
The report counts the messages whose bindings state properties, checked or not:
summary.properties: {"checked": 3, "unchecked": 1}, properties of each operation, and a
summary line MQTT 5 properties: checked on 3 messages, not on 1 (MQTT 3.1.1). A client’s QoS 1
and 2 messages are acknowledged by the mock before the broker
acknowledges them, and the mock delivers at most QoS 1. An mqtts:// broker (port 8883 by default) is reached over TLS
and verified as https:// upstreams are (see TLS).
Contract testing of Kafka channels
There is no Kafka broker in the mock. With --proxy-to kafka://host:port (port 9092 by
default; kafkas://, port 9093, over TLS) the server is a client of the real cluster, a validating
tap: it reads the topics of the document’s Kafka channels and checks every record on them,
whoever produced it.
apigenie-server shop.yaml --proxy-to kafka://localhost:9092 --report contract.json
apigenie-server events.yaml --proxy-to mqtt://localhost:1883 --proxy-to kafka://localhost:9092
A channel with a kafka or kafka-secure server is a Kafka channel; its topic is the channel’s
Kafka binding topic, else its address. Parameters of the address match the topic’s segments, so
stock.{region} reads stock.eu and stock.us; the cluster’s topics are looked at again every
10 seconds for new ones. The tap reads every partition from the offset it had when the server
started, outside any consumer group, so it never takes partitions from the application’s consumers
and commits no offsets. A topic the cluster doesn’t have yet is reported once and read once it
appears; a lost connection is reported and tried again.
A record doesn’t say who produced it, so the channel’s operations tell. On a channel with only
send operations it is the application’s (a kafka-out event, checked against them); with only
receive operations it is a client’s (kafka-in); with both it is checked as the application’s,
and as a client’s when that doesn’t match. What is checked:
| What | Checked against | Check |
|---|---|---|
| The value | The operations’ messages | message.incompatible, message.malformed |
| The headers | The message’s headers (values read as the type the schema says) |
message.headers.invalid |
| The key | The message’s Kafka binding key (an error too when the record has none) |
message.key.invalid (error) |
| The partition | The channel’s Kafka binding partitions |
message.partition.invalid (warn) |
| The topic | The address’s parameters | channel.parameter.invalid |
Records are on the cluster before the tap sees them, so enforce mode reports them like log mode.
Kafka operations count in the coverage, GET /__apigenie/contract, --report and
--report-junit like any other, and the report’s upstream lists the kafka:// URL. The log has
kafka-in and kafka-out events with the topic as channel, the record’s partition and offset
in the message, and its key (as kafka-key) and headers as the event’s headers; what the tap
found or failed at are server events with source: kafka.
The SASL PLAIN user and password come from APIGENIE_KAFKA_USERNAME and
APIGENIE_KAFKA_PASSWORD (apigenie test reads them too), never from the URL. kafkas:// brokers
are verified as https:// upstreams are (see TLS). The cluster needs Kafka 2.3 or later.
Compressed record batches aren’t read: they are reported (3 record(s) at offset 40 compressed with zstd) and skipped. A kafka:// upstream goes with one document, not with
several services. See ADR-170 for what the client does and doesn’t do.
Contract testing of AMQP channels
There is no AMQP broker in the mock either. With --proxy-to amqp://host:port/vhost (port 5672 by
default; amqps://, port 5671, over TLS; the path is the virtual host, /%2Fcarpet-express for
/carpet-express, none for /) the server is an AMQP 0-9-1 client of the real broker, a
validating tap.
APIGENIE_AMQP_USERNAME=conformance APIGENIE_AMQP_PASSWORD=conformance \
apigenie-server customs.yaml --proxy-to amqp://localhost:5672/%2Fcarpet-express --report contract.json
A channel with an amqp or amqps server is an AMQP channel. Its AMQP channel binding says what it
is:
is: routingKey(the default): the address is a routing key ofexchange.name(ofexchange.type, topic by default). The tap binds a queue of its own (exclusive, deleted when it disconnects) to the exchange: for a topic exchange with the address’s parameters as*words (customs.{country}.filingiscustoms.*.filing), for a fanout or headers exchange with any key. Direct exchanges bind the address itself, so their addresses can’t have parameters. It reads that queue, so it gets a copy of every message the exchange routes there, and the application’s queues are left alone. An exchange the broker doesn’t have is reported, and looked for again every 10 seconds.is: queue, and routing keys of the default exchange: a queue the application consumes. Reading it would take the application’s messages, so the tap doesn’t, and says so when it starts. Whatapigenie testand the admin API send there is checked.
Messages are checked as the Kafka tap checks records, in the
same direction: their body and headers against the operations’ messages, and their properties
against the AMQP bindings (0.3.0) (message.properties.invalid, error):
| Binding | Checked |
|---|---|
operation deliveryMode, priority, userId, replyTo |
The property is present and the same |
operation expiration |
An expiration is present and no longer |
operation timestamp: true |
A timestamp is present |
message contentEncoding |
The content encoding is the same |
message messageType |
The type property is the same |
The log has amqp-in and amqp-out events with the routing key as channel and the exchange in the
message; coverage and reports work as for Kafka, and enforce mode reports too. The PLAIN user and
password come from APIGENIE_AMQP_USERNAME and APIGENIE_AMQP_PASSWORD (guest without them),
never from the URL. An amqp:// upstream goes with one document. See ADR-171.
Contract testing of HTTP channels
With --proxy-to http://host:port (or the ws:// server of the WebSocket channels) an AsyncAPI
document’s HTTP channels go to that server through the same transport as an OpenAPI document’s
proxy (Via, X-Forwarded-*, 502 and 504 problems), checked against the document instead of
an OpenAPI one:
- A
receiveoperation takes requests with its HTTP binding’s method (POST by default). The request’s body is checked against the operation’s messages, its query against the binding’squeryschema (operation.query.invalid) and its header fields against the message’sheadersand HTTP bindingheaders, its traits’ included (message.headers.invalid). A 2XX response’s body and header fields are checked against itsreply. - A
sendoperation is asked with GET, as the mock serves it; a 2XX response with a body is checked against its messages (not its header fields: they are the webhook’s). - Other paths get 404 and other methods 405, without the server.
Query parameters and header fields are read as the type their schema says (integer, number,
boolean, an array from repeated parameters); header fields the schemas don’t name are left
out. apigenie test checks the requests of HTTP channels with the same code and the same checks
(see Requests of HTTP channels).
In enforce mode an invalid request gets 422 (#invalid-message) without the server, and an
invalid response is replaced by 502 (#invalid-upstream-message). Responses carry
X-Apigenie-Mock-Source, X-Apigenie-Mock-Operation and X-Apigenie-Mock-Violations, and each
exchange counts as a message of its operation in the coverage. Responses other than 2XX and the
reply’s statusCode binding aren’t checked; fallback mode, --record-dir and --record-har are
for OpenAPI documents.