# WebSocket and MQTT requests

Source: https://apigenie.pl/docs/reference/cli/test/websocket-mqtt
Part of the reference of [test](https://apigenie.pl/docs/reference/cli/test.md).
Its other pages: [Request files](https://apigenie.pl/docs/reference/cli/test/requests.md), [Variables and OAuth 2](https://apigenie.pl/docs/reference/cli/test/variables.md), [Assertions and scripts](https://apigenie.pl/docs/reference/cli/test/assertions.md), [Validation](https://apigenie.pl/docs/reference/cli/test/validation.md), [Generated requests](https://apigenie.pl/docs/reference/cli/test/generate.md), [GraphQL requests](https://apigenie.pl/docs/reference/cli/test/graphql.md), [Kafka requests](https://apigenie.pl/docs/reference/cli/test/kafka.md), [AMQP requests](https://apigenie.pl/docs/reference/cli/test/amqp.md), [gRPC requests](https://apigenie.pl/docs/reference/cli/test/grpc.md), [Importing collections](https://apigenie.pl/docs/reference/cli/test/import.md), [httpYac compatibility](https://apigenie.pl/docs/reference/cli/test/httpyac.md).

A `WS` (or `WEBSOCKET`) region connects to a `ws://` or `wss://` URL, its headers going with the
handshake; an `MQTT` region connects to an `mqtt://host:port` broker, or `mqtts://host:port` (also
`ssl://`) over TLS (MQTT 5, or 3.1.1 when the broker refuses it). Without a port, `wss://` uses 443, `mqtt://` 1883 and `mqtts://`
8883. Over TLS the certificate is checked as for `https://`: `--ca-cert` and `--insecure` apply. The body is the message to
send; lines `===` separate several messages, and `=== wait-for-server` waits for a message of the
server before sending the next one, as in httpYac. Then the region waits for the server's messages
until it has `# @expect` of them or `# @timeout` (`--message-timeout`, 5000 ms) has passed. The
messages received are the response: its body is their JSON array, each message parsed when it is
JSON.

```http
### Request a pickup: the reply carries the request's id
# @expect 1
WS ws://localhost:4010/pickups

{"type":"pickup.request","requestId":"6f1c2b9e-7f8a-4c35-9a51-2d1e8b7f0c11","carrier":"dhl", ...}

?? body $[0].type == pickup.confirmed
?? body $[0].requestId == 6f1c2b9e-7f8a-4c35-9a51-2d1e8b7f0c11

### Report a courier's position
# @expect 0
# @property content-type application/json
# @property payload-format-indicator utf-8
MQTT mqtt://localhost:1883
publish: couriers/cou_4821/position
qos: 1

{"courierId":"cou_4821","at":"2026-10-06T07:41:02Z","position":{"type":"Point","coordinates":[21.0067,52.2319]}}

### Receive a job offer
# @channel courierJobOffers
# @expect 1
MQTT mqtt://localhost:1883
subscribe: couriers/+/offers

?? body $.length == 1
```

```text
PASS WS ws://localhost:4010/pickups -> 1 sent, 1 received (12 ms) requestPickup
PASS MQTT mqtt://localhost:1883 -> 1 sent, 0 received (3 ms) receiveCourierPositions
PASS MQTT mqtt://localhost:1883 -> 0 sent, 1 received (6 ms) offerPickupJob
```

The headers of an `MQTT` region are its settings:

| Header | Meaning |
| --- | --- |
| `topic` | The topic messages are published to, also subscribed to (unless `subscribe` is given) |
| `publish` | The topic messages are published to |
| `subscribe` | The topic filters subscribed to before publishing, comma-separated (`couriers/+/offers, hubs/#`) |
| `qos` | `0` (the default), `1` or `2`, for publishing and subscribing |
| `retain` | `true` to publish retained messages |
| `clientId`, `keepAlive`, `clean` | The connection's client id (a random one by default), keep-alive in seconds (60) and clean session (`true`) |
| `username`, `password` | The CONNECT's user name and password; without them, `APIGENIE_MQTT_USERNAME` and `APIGENIE_MQTT_PASSWORD` |
| `version` | `auto` (the default: MQTT 5, else 3.1.1 when the broker refuses it), `5` or `3.1.1` |

The client's own messages coming back on a topic it subscribed to are left out. Retained messages
of a subscription count as received.

Settings are substituted like any header, so a password can stay out of the file:
`password: {{$processEnv BROKER_PASSWORD}}`, or a variable of an environment file. A setting the
region doesn't give comes from `APIGENIE_MQTT_USERNAME` or `APIGENIE_MQTT_PASSWORD`. MQTT 5 sends
a password without a user name; MQTT 3.1.1 sends one only with a user name, so with `version:
3.1.1` a password alone is a warning and isn't sent. A broker that refuses them fails the request
with `the broker refused the connection (reason code 0x86) (bad user name or password)` (MQTT 5),
or `(return code 4) (bad user name or password)` (MQTT 3.1.1). Credentials never show in the output or the reports: wherever they
would, they read `[redacted]`, and a URL's `user:password@` is shown as `***@`. The user name and password of the URL itself aren't sent.

| In checks and scripts | Value |
| --- | --- |
| `?? body $.length == 2` | How many messages came (`.length` also gives the size of any array or string) |
| `?? body $[0].type == pickup.confirmed` | A value of the first message |
| `?? status == 101` | The handshake's status (WS); the CONNACK's return code, `0` (MQTT) |
| `response.messages[i]` | In scripts: `topic` (the topic, or the WebSocket's path), `body` (parsed when JSON), `rawBody`, `binary` |

## MQTT 5 properties

An `MQTT` region connects with MQTT 5. A broker that refuses it (reason code 0x84, a 3.1.1 broker's
return code 1, or a connection closed before the CONNACK) gets a second connection with MQTT 3.1.1,
and the request notes `the broker refused MQTT 5`. `version: 5` doesn't fall back; `version: 3.1.1`
doesn't try MQTT 5.

`# @property` gives the properties of every message the region publishes:

| Directive | Property |
| --- | --- |
| `# @property content-type application/json` | Content Type |
| `# @property response-topic clients/{{client}}/quotes` | Response Topic (no wildcards); the region also subscribes to it |
| `# @property correlation-data {{$uuid}}` | Correlation Data |
| `# @property payload-format-indicator utf-8` | Payload Format Indicator: `utf-8` or `1`, `bytes` or `0` |
| `# @property message-expiry-interval 60` | Message Expiry Interval, in seconds |
| `# @property user replyTo dispatch/d-1/replies` | A user property (repeat the line for more; the value may be empty) |

Values are substituted like headers. An unknown property, a missing value or a bad value is a
warning, and that property isn't sent. Over MQTT 3.1.1 none is sent, and the request notes it.

Over MQTT 5 the messages sent and received are checked with their properties, against the
message's and the operation's MQTT bindings (0.2.0), as `message.properties.invalid` (`error`):

| Binding | Checked |
| --- | --- |
| 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 |

A request whose reply address is in a header (`$message.header#/replyTo`) is answered on the topic
in that user property; one whose reply channel has `address: null` is answered on its Response
Topic. Either way the region waits for the reply on that topic, checks it as the reply, and
compares its correlation id (header or payload) and, when a binding declares `correlationData`, its
Correlation Data with the request's.

```http
### Ask for a quote: the reply comes to the Response Topic
# @expect 1
# @property content-type application/json
# @property response-topic clients/7/quotes
# @property correlation-data {{$uuid}}
# @property user correlationId c-42
MQTT mqtt://localhost:1883
publish: quotes
qos: 1

{"parcel":"P1"}

?? body $[0].price exists
```

A message whose bindings state properties counts in the coverage as checked (MQTT 5) or unchecked
(MQTT 3.1.1): `summary.properties` and `operations[].properties` in the report, `MQTT 5 properties:
checked on 2 messages, not on 0 (MQTT 3.1.1)` in the summary.
