Exemples : Kafka, RabbitMQ, MQTT, Avro, NATS, AMQP 1.0
ExempleExploitationDepuis la version 1.5.0
Sur cette page
- Kafka : publier, diffuser, livrer
- RabbitMQ en AMQP 0-9-1 : canal par personne, flux public
- MQTT 5 : claim, attribut tenant, webhook
- Charges Avro sous schéma, registre de schémas
- NATS JetStream : publier depuis HTTP ou un outil MCP
- NATS : SSE et WebSocket liés au tenant, jokers, NATS Core
- NATS JetStream : webhook depuis un consommateur durable
- AMQP 1.0 : publier vers Azure Service Bus, livrer depuis un abonnement
- AMQP 1.0 : consommer RabbitMQ 4 en SSE et WebSocket
Guide d'exploitation : configurer, déployer et observer la passerelle. Ce que fait la capacité côté produit : guide produit ; l'appeler : guide développeur.
Kafka : publier, diffuser, livrer
Le parcours complet de Publier et consommer sur un courtier sur Kafka : une porte de publication, aussi
outil MCP ; un flux SSE en éventail ou WebSocket accusé, lié au tenant ; les évènements d'acme livrés en
webhook signé, le rebut dans orders.dead. La passerelle s'authentifie en SASL SCRAM sur TLS.
# Kafka behind the gateway's controls (1.5.0): an HTTP door and an MCP tool publish, a
# browser streams its tenant's events in SSE or WebSocket, a partner receives them as signed webhooks.
# flexway.yaml lists no channel - they come from the AsyncAPI contract.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml # AsyncAPI 3.0.0 or 3.1.0
upstreams:
events:
protocol: kafka
brokers: [kafka-0.kafka:9093, kafka-1.kafka:9093]
tls: { server_name: kafka.internal } # TLS, system roots; ca_file, cert_file and key_file for a private CA or mTLS
sasl: { mechanism: SCRAM-SHA-512, username: flexway, password: "file:/etc/flexway-secrets/kafka/password" }
timeout: 2s # one publication's budget, the acknowledgement included
max_in_flight: 512 # bounded buffer: past it, 503 overloaded
partner-hook:
protocol: webhook
url: https://hooks.partner.example/orders
secrets: ["file:/etc/flexway-secrets/hooks/current", "file:/etc/flexway-secrets/hooks/next"]
retries: { max: 8, max_delay: 10m }
auth:
issuers:
- name: customers
discovery: https://login.example.net/.well-known/openid-configuration
audience: api
tenant_claim: org # the tenant every channel below is bound to
agents:
mcp:
resources:
api.example.com: https://api.example.com/mcp# examples/async-kafka/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Orders events, version: 1.0.0 }
channels:
orderCreated:
address: "orders.{tenant}.created" # one topic per tenant
parameters:
tenant: { x-flexway-bind: tenant } # bound to the verified identity, never read from the request
messages:
created:
contentType: application/json
payload:
type: object
required: [orderId]
properties:
orderId: { type: string, minLength: 1 }
operations:
publishOrderCreated:
action: send
channel: { $ref: "#/channels/orderCreated" }
x-flexway-upstream: events
x-flexway-key: /orderId # ordered per key, and per key only
receiveOrderCreated:
action: receive
channel: { $ref: "#/channels/orderCreated" }
x-flexway-upstream: events
x-flexway-deliver: # acme's events to its webhook; what fails ends in orders.dead
- { upstream: partner-hook, tenant: acme, dead_letter: orders.dead }# examples/async-kafka/openapi.yaml
openapi: 3.1.0
info: { title: Orders events, version: "1.0.0" }
components:
securitySchemes:
customers: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/orders/events:
post:
operationId: emitOrderCreated
summary: Publish an order-created event for the caller's tenant
x-flexway-publish: { operation: publishOrderCreated, idempotency: required }
x-flexway-tool: { name: emit_order_created } # the same door, offered to agents as an MCP tool
security: [ { customers: [orders:write] } ]
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [orderId], properties: { orderId: { type: string, minLength: 1 } } }
responses: { "202": { description: Acknowledged by the broker } }
/orders/stream:
get:
operationId: streamOrderCreated
x-flexway-subscribe: { operation: receiveOrderCreated, transport: [sse, websocket], max_streams: 2, ack_timeout: 30s, buffer: 256, history: 64 }
security: [ { customers: [orders:read] } ]
responses: { "200": { description: The caller's tenant's events } }| Cas | Ce que fait la passerelle |
|---|---|
POST /orders/events ou l'outil emit_order_created |
202 après acks=all ; Idempotency-Key exigée (428 sinon), devenue l'id ; ordre par orderId |
GET /orders/stream en SSE |
Un consommateur par canal et par réplique ; Last-Event-ID repris dans les 64 derniers messages, 412 precondition_failed au-delà |
GET /orders/stream en WebSocket |
Groupe propre à l'abonné, {"ack": "<id>"} dans les 30 s, sinon fermeture 1008 et nouvelle livraison |
Livraison à partner-hook |
Groupe partagé par les répliques, Standard Webhooks, position validée après le 2xx, rebut orders.dead |
| mTLS au lieu de SASL | tls.cert_file et tls.key_file montés, sans sasl |
RabbitMQ en AMQP 0-9-1 : canal par personne, flux public
Chaque joueur publie et lit ses notifications - le canal est lié au sujet vérifié du jeton - ; le
tableau des scores est un flux public, lu sans jeton, repris sur 128 messages. Un producteur hors de la
passerelle y écrit en application/cloudevents+json : lu en mode structuré. La passerelle se connecte avec
son utilisateur, sur son hôte virtuel (/games, le chemin de l'URL).
# RabbitMQ in AMQP 0-9-1 behind the gateway's controls (1.5.0): each player reads its
# own notifications only (bound to the verified subject), anyone - anonymous included - reads the
# declared public feed. The gateway authenticates itself: its own user, its own virtual host.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
bus:
protocol: amqp # AMQP 0-9-1 (protocol_version: 0-9-1, the default)
brokers: ["amqps://rabbitmq.internal:5671/games"] # the path is the virtual host; never a password in the URL
sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/rabbitmq/password" }
timeout: 2s # publisher confirm included
auth:
issuers:
- name: players
discovery: https://login.example.net/.well-known/openid-configuration
audience: games
tenant_claim: org# examples/async-amqp/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Game events, version: 1.0.0 }
channels:
notifications:
# A topic exchange the broker already has: the gateway never declares one.
address: "players.{sub}.notifications"
parameters:
sub: { x-flexway-bind: subject } # the verified subject: a player reads its own channel only
bindings:
amqp: { is: routingKey, exchange: { name: notifications, type: topic } }
messages:
note:
contentType: application/json
payload: { type: object, required: [text], properties: { text: { type: string } } }
scoreboard:
address: scores.public # binds nothing, filters nothing: declared public below
bindings:
amqp: { is: routingKey, exchange: { name: scores, type: topic } }
messages:
# Written by a producer outside the gateway (an outbox) as application/cloudevents+json: read in
# structured mode; any other content type is read in binary mode.
score:
payload: { type: object, required: [match], properties: { match: { type: string }, score: { type: string } } }
operations:
publishNotification: { action: send, channel: { $ref: "#/channels/notifications" }, x-flexway-upstream: bus }
receiveNotifications: { action: receive, channel: { $ref: "#/channels/notifications" }, x-flexway-upstream: bus }
receiveScores:
action: receive
channel: { $ref: "#/channels/scoreboard" }
x-flexway-upstream: bus
x-flexway-public: true # read by anyone, anonymous included# examples/async-amqp/openapi.yaml
openapi: 3.1.0
info: { title: Game events, version: "1.0.0" }
components:
securitySchemes:
players: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/me/notifications:
post:
operationId: notifyMe
x-flexway-publish: { operation: publishNotification }
security: [ { players: [] } ]
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [text], properties: { text: { type: string } } }
responses: { "202": { description: Confirmed by the broker } }
get:
operationId: streamMyNotifications
# WebSocket: at least once - the client acknowledges {"ack": "<id>"} within ack_timeout.
x-flexway-subscribe: { operation: receiveNotifications, transport: [sse, websocket], max_streams: 3, ack_timeout: 20s }
security: [ { players: [] } ]
responses: { "200": { description: The caller's own notifications } }
/scores:
get:
operationId: streamScores
# SSE only; one consumer per replica whatever the audience, 128 messages kept for Last-Event-ID.
x-flexway-subscribe: { operation: receiveScores, transport: [sse], max_streams: 2, max_anonymous_streams: 500, history: 128 }
security: [] # anonymous door: only toward a public operation
responses: { "200": { description: The public scoreboard } }| Cas | Ce que fait la passerelle |
|---|---|
POST /me/notifications |
Publié sur players.<sub>.notifications de l'échange notifications, 202 après la confirmation |
GET /me/notifications |
Le seul canal du sujet ; une valeur hors des lettres, chiffres, _ et - est refusée (403) |
GET /scores sans jeton |
Public déclaré : jusqu'à 500 anonymes ensemble, 2 flux par adresse ; un abonné trop lent fermé, les autres continuent |
| Webhook | Même déclaration qu'en Kafka : publier, diffuser, livrer : une file nommée partagée par les répliques |
MQTT 5 : claim, attribut tenant, webhook
Un opérateur lit la télémétrie de son site - le canal est lié à la claim site du jeton -, sans reprise
(history: 0) ; les alertes de tous les tenants passent par un sujet commun, chacune portant l'attribut
tenant : la consommation est filtrée sur lui, et les alertes d'acme partent en webhook par un abonnement
partagé MQTT 5 (une réplique par message).
# An MQTT 5 broker behind the gateway's controls (1.5.0): an operator's screen reads
# the telemetry of its own site (bound to a claim of its token), alerts carry the tenant attribute
# and are delivered to the tenant's webhook through a shared subscription - one replica per message.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
plant:
protocol: mqtt
protocol_version: "5" # the default; 3.1.1 has no shared subscription, so no webhook
brokers: ["mqtts://mqtt.internal:8883"]
sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/mqtt/password" }
timeout: 2s # PUBACK at QoS 1 included
ops-hook:
protocol: webhook
url: https://ops.acme.example/flexway/alerts
secrets: ["file:/etc/flexway-secrets/hooks/ops"]
retries: { max: 5, max_delay: 5m }
auth:
issuers:
- name: operators
discovery: https://login.example.net/.well-known/openid-configuration
audience: plant
tenant_claim: org# examples/async-mqtt/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Plant telemetry, version: 1.0.0 }
channels:
siteTelemetry:
address: "sites/{site}/telemetry"
parameters:
site: { x-flexway-bind: "claim:site" } # a claim of the verified token: an operator reads its own site
messages:
reading:
payload: { type: object, required: [sensor, value], properties: { sensor: { type: string }, value: { type: number } } }
alerts:
address: plant/alerts # one topic for every tenant ...
messages:
alert:
headers: # ... kept apart by the tenant attribute every message declares
type: object
properties: { tenant: { type: string } }
payload: { type: object, required: [code], properties: { code: { type: string } } }
operations:
publishReading: { action: send, channel: { $ref: "#/channels/siteTelemetry" }, x-flexway-upstream: plant }
receiveReadings: { action: receive, channel: { $ref: "#/channels/siteTelemetry" }, x-flexway-upstream: plant }
publishAlert: { action: send, channel: { $ref: "#/channels/alerts" }, x-flexway-upstream: plant }
receiveAlerts:
action: receive
channel: { $ref: "#/channels/alerts" }
x-flexway-upstream: plant
x-flexway-deliver: # acme's alerts only; what cannot be delivered ends in plant/alerts-dead
- { upstream: ops-hook, tenant: acme, dead_letter: plant/alerts-dead }# examples/async-mqtt/openapi.yaml
openapi: 3.1.0
info: { title: Plant telemetry, version: "1.0.0" }
components:
securitySchemes:
operators: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/telemetry:
post:
operationId: postReading
x-flexway-publish: { operation: publishReading, idempotency: required }
security: [ { operators: [telemetry:write] } ]
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [sensor, value], properties: { sensor: { type: string }, value: { type: number } } }
responses: { "202": { description: Acknowledged by the broker (PUBACK) } }
get:
operationId: streamReadings
x-flexway-subscribe: { operation: receiveReadings, transport: [sse], history: 0 } # no resumption
security: [ { operators: [telemetry:read] } ]
responses: { "200": { description: The telemetry of the caller's site } }
/alerts:
post:
operationId: raiseAlert
x-flexway-publish: { operation: publishAlert }
security: [ { operators: [alerts:write] } ]
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [code], properties: { code: { type: string } } }
responses: { "202": { description: Acknowledged by the broker (PUBACK) } }| Cas | Ce que fait la passerelle |
|---|---|
POST /telemetry |
202 après le PUBACK QoS 1 ; Idempotency-Key exigée |
GET /telemetry |
Le canal sites/<site>/telemetry du jeton seulement ; sans claim site, refusé |
POST /alerts |
L'attribut tenant vient de l'identité, jamais du corps |
Livraison à ops-hook |
Abonnement partagé, filtre tenant = acme, rebut plant/alerts-dead ; refusée au chargement en MQTT 3.1.1 |
| mTLS au lieu du mot de passe | tls.cert_file et tls.key_file montés, sans sasl |
Charges Avro sous schéma, registre de schémas
Une boutique publie ses commandes en Avro depuis HTTP : le corps est décodé sous order.avsc,
compilé au chargement, avant d'atteindre Kafka ; un tableau de bord les lit en SSE en JSON, un
service les lit en WebSocket en Avro tel quel. Le canal d'audit porte le format filaire de
Confluent : chaque identifiant de schéma est lu dans le registre, et doit y être enregistré sous le
sujet du tenant (<tenant>.orders.audit-value) - un autre tenant ne publie jamais sous ce schéma.
# Avro orders through the asynchronous plane (1.6.0): an HTTP caller publishes an Avro
# body - checked under its schema, compiled at load -, a subscriber over SSE or a webhook reads it in
# JSON, a WebSocket that declares binary frames reads it as it is. The audit channel carries Confluent's
# wire format: each schema id is read in the registry and must be registered under the tenant's subject.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
orders-brokers:
protocol: kafka
brokers: [kafka-0.internal:9092, kafka-1.internal:9092]
schema_registry:
url: https://registry.internal:8081 # read, never written - the Confluent API
auth:
username: flexway
password: ${REGISTRY_PASSWORD} # a reference, resolved at each request
timeout: 2s # a lookup holds the message that needs it
max_schemas: 1000 # compiled once each, kept by id
auth:
issuers:
- name: customers
discovery: https://login.example.net/.well-known/openid-configuration
audience: api
tenant_claim: org# examples/async-binary/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Shop orders, version: "1.0.0" }
channels:
orders:
address: "{tenant}.orders"
parameters:
tenant: { x-flexway-bind: tenant }
messages:
order:
contentType: application/vnd.apache.avro+binary
payload: { schemaFormat: "application/vnd.apache.avro;version=1.9.0", schema: { $ref: "./order.avsc" } }
audit:
address: "{tenant}.orders.audit"
parameters:
tenant: { x-flexway-bind: tenant }
messages:
audited:
contentType: application/vnd.apache.avro+binary
payload: { schemaFormat: "application/vnd.apache.avro;version=1.9.0", schema: { $ref: "./order.avsc" } }
bindings:
kafka: { schemaIdLocation: payload, schemaIdPayloadEncoding: confluent, schemaLookupStrategy: TopicIdStrategy }
operations:
publishOrder:
action: send
channel: { $ref: "#/channels/orders" }
x-flexway-upstream: orders-brokers
x-flexway-validation: full # decode every body under order.avsc - opt-in for a binary message
x-flexway-key: /orderId # read in the payload's JSON form
receiveOrder:
action: receive
channel: { $ref: "#/channels/orders" }
x-flexway-upstream: orders-brokers
publishAudit:
action: send
channel: { $ref: "#/channels/audit" }
x-flexway-upstream: orders-brokers
x-flexway-validation: full # the schema id in the registry, under <tenant>.orders.audit-value
receiveAudit:
# Records of an ordinary Kafka producer (a Confluent serializer, no CloudEvents header) are read
# too: the channel is bound to the tenant and its only message is in the registry's wire format,
# so the gateway builds each envelope - id <topic>/<partition>/<offset>, the record's timestamp.
action: receive
channel: { $ref: "#/channels/audit" }
x-flexway-upstream: orders-brokers# examples/async-binary/openapi.yaml
openapi: 3.1.0
info: { title: Shop orders, version: "1.0.0" }
components:
securitySchemes:
customers: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
security: [{ customers: [] }]
paths:
/orders/events:
post:
operationId: emitOrder
x-flexway-publish: { operation: publishOrder, idempotency: required }
requestBody:
required: true
content: { application/vnd.apache.avro+binary: { schema: { type: string, format: binary } } }
responses: { "202": { description: published, after the broker's acknowledgement } }
/orders/audit:
post:
operationId: emitAudit
x-flexway-publish: { operation: publishAudit }
requestBody:
required: true
content: { application/vnd.apache.avro+binary: { schema: { type: string, format: binary } } }
responses: { "202": { description: published } }
/orders/stream:
get:
operationId: streamOrders
x-flexway-subscribe: { operation: receiveOrder, transport: [sse] }
responses: { "200": { description: the tenant's orders, in JSON } }
/orders/frames:
get:
operationId: framesOrders
x-flexway-subscribe: { operation: receiveOrder, transport: [websocket], frames: binary }
responses: { "200": { description: the tenant's orders, the Avro payload in binary frames } }
/orders/audit/stream:
get:
operationId: streamAudit
x-flexway-subscribe: { operation: receiveAudit, transport: [sse] }
responses: { "200": { description: the tenant's audit records in JSON, ordinary producers' included } }| Cas | Ce que fait la passerelle |
|---|---|
POST /orders/events, corps Avro conforme |
202 après l'accusé ; clé Kafka lue en /orderId de la forme JSON du message |
Corps qui ne se décode pas sous order.avsc |
422 invalid_request, la règle seule, jamais une valeur du message |
Autre Content-Type |
422 invalid_request (la porte ne déclare que l'Avro) ; une porte sans requestBody répond 415 unsupported_media_type |
POST /orders/audit, identifiant enregistré sous acme.orders.audit-value |
202 ; schéma lu une fois par identifiant, gardé en mémoire (max_schemas) |
| Identifiant inconnu, ou enregistré sous un autre sujet | 422 invalid_request |
| Registre injoignable ou en erreur | 503 upstream_unavailable avec Retry-After : jamais un message publié sans validation ; coupe-circuit propre au registre |
GET /orders/stream (SSE) |
Chaque évènement en JSON (encodage JSON d'Avro), datacontenttype: application/json ; un message qui ne se décode pas n'est pas remis (flexway_async_deliver_total{outcome="invalid"}) |
GET /orders/frames (WebSocket) |
Une trame texte (les attributs, dont l'id à accuser), puis une trame binaire : la charge Avro telle quelle |
Webhook (x-flexway-deliver) |
Corps CloudEvent en JSON transcodé ; registre injoignable : le message n'est ni livré ni accusé, il revient |
| Protobuf | schemaFormat: "application/vnd.google.protobuf;version=3" et schema: { $ref: "./reading.binpb#acme.Reading" } - un jeu de descripteurs compilé (protoc --descriptor_set_out --include_imports, buf build), le message par son nom ; une source .proto en ligne est refusée |
| CBOR | contentType: application/cbor et un schéma JSON : la charge est lue dans le modèle JSON de CBOR (RFC 8949 §6.1) et vérifiée contre ce schéma |
GET /orders/audit/stream, enregistrement d'un producteur Kafka ordinaire (format filaire, aucun en-tête CloudEvents) |
Remis dans l'enveloppe que construit la passerelle : id = acme.orders.audit/<partition>/<position> (stable d'une réplique à l'autre, c'est l'id SSE et le Last-Event-ID, l'id à accuser en WebSocket, le webhook-id), source = /flexway/orders-brokers/audit, type = audited, time = l'horodatage Kafka, datacontenttype: application/json après transcodage ; aucun attribut tenant : le tenant est celui du canal lié, jamais un en-tête ni le contenu du message |
Enregistrement sans attribut sur un autre canal, ou qui porte un attribut ce_* sans en être un |
Pas un évènement, comme en 1.5.0 : non remis (flexway_async_deliver_total{outcome="invalid"}) |
| Schéma d'écriture du registre différent du schéma du contrat | Résolu vers le schéma du contrat par les règles standard d'Avro (champ ajouté avec défaut, champ retiré, int -> long, alias, symbole d'énumération par défaut) : même JSON qu'un message écrit sous le schéma du contrat ; résolution une fois par paire (identifiant, schéma du contrat), max_schemas paires gardées. Schémas identiques (même forme canonique) : aucun coût de plus |
| Schéma d'écriture qui ne se résout pas vers celui du contrat (champ sans défaut, type incompatible) | Refusé entier, jamais livré en partie : 422 invalid_request à la publication ; à la consommation, non remis comme une charge qui ne se décode pas - SSE au plus une fois, WebSocket accusé, webhook mis au rebut -, la consommation continue ; compté outcome="incompatible" |
x-flexway-key sur un message binaire |
La clé est lue dans la forme JSON du message : un décodage par publication, même avec x-flexway-validation: off (une charge qui ne se décode pas est alors refusée en 422) - un coût à peser sur un débit élevé ; sans clé, une charge non validée est relayée sans être lue |
Refusé au chargement : un schemaFormat inconnu (RAML, OpenAPI, Avro 2.x), un schéma Avro
invalide, récursif ou dont un tableau contient des éléments sans octet, un message absent du jeu de
descripteurs, un $ref de schéma binaire hors du répertoire du document AsyncAPI, le format filaire
sans schema_registry ou sur un courtier qui n'est pas Kafka, x-flexway-validation: off sur un
message JSON (toujours vérifié), frames: binary sans WebSocket ou sur un message JSON, une
consommation d'un canal au format filaire isolée par le seul attribut tenant (ni liée, ni publique) :
un producteur ordinaire n'écrit aucun attribut, et le tenant n'est jamais lu dans un message.
Mesurer : flexway_async_payloads_total{operation, format, step, outcome} - step vaut validate
ou transcode, outcome ok, invalid, not_attached, incompatible ou unavailable ; jamais une valeur du
message ni un identifiant de schéma.
NATS JetStream : publier depuis HTTP ou un outil MCP
Disponible depuis
1.6.0; exemple validé par le binaire et épinglé par les tests.
Un service HTTP ou un agent publie dans un flux JetStream sans client NATS ; 202 seulement après
le PubAck du flux, l'Idempotency-Key devenue Nats-Msg-Id - le flux dédoublonne un renvoi après un 504.
La passerelle ne crée aucun flux : le sujet doit être capturé par un flux existant, sinon 502 upstream_error.
# NATS JetStream behind the gateway's controls (1.6.0): an HTTP door and an MCP tool publish
# to a stream without a NATS client - 202 only after the stream's PubAck, the Idempotency-Key as
# Nats-Msg-Id. The gateway creates no stream: a subject no stream captures answers 502.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
orders-nats:
protocol: nats
brokers: ["tls://nats-0.nats:4222", "tls://nats-1.nats:4222"] # nats:// or tls://, never a credential in the URL
tls: { server_name: nats.internal }
nats:
credentials: "file:/etc/flexway-secrets/nats/flexway.creds" # the gateway's NATS user: JWT and nkey seed
jetstream: true # 202 after the stream's PubAck; false (NATS Core) refuses a publication
timeout: 2s # one publication's budget, the PubAck included
max_in_flight: 512 # bounded buffer: past it, 503 overloaded
auth:
issuers:
- name: customers
discovery: https://login.example.net/.well-known/openid-configuration
audience: api
tenant_claim: org # the tenant every subject below is bound to
agents:
mcp:
resources:
api.example.com: https://api.example.com/mcp# examples/async-nats-jetstream/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Orders, version: 1.0.0 }
channels:
orderCreated:
address: "orders.{tenant}.created" # captured by the ORDERS stream (orders.>): the gateway creates no stream
parameters:
tenant: { x-flexway-bind: tenant }
messages:
created:
contentType: application/json
payload: { type: object, required: [orderId], properties: { orderId: { type: string } } }
operations:
publishOrderCreated:
action: send
channel: { $ref: "#/channels/orderCreated" }
x-flexway-upstream: orders-nats# examples/async-nats-jetstream/openapi.yaml
openapi: 3.1.0
info: { title: Orders, version: "1.0.0" }
components:
securitySchemes:
customers: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/orders/events:
post:
operationId: emitOrderCreated
x-flexway-publish: { operation: publishOrderCreated, idempotency: required } # Idempotency-Key -> Nats-Msg-Id
x-flexway-tool: { name: emit_order_created } # the same door, offered to agents as an MCP tool
security: [ { customers: [orders:write] } ]
requestBody:
required: true
content:
application/json: { schema: { type: object, required: [orderId], properties: { orderId: { type: string } } } }
responses: { "202": { description: Acknowledged by the stream } }| Ce qui se passe | NATS JetStream |
|---|---|
POST /orders/events |
Admission, corps vérifié, CloudEvents en mode binaire (en-têtes ce-*), publication sur orders.acme.created, 202 après le PubAck |
Le même appel en outil MCP (emit_order_created) |
Même décision, même accusé |
jetstream: false (NATS Core) |
Refusé au chargement : Core n'accuse rien, un 202 mentirait |
| Aucun flux ne capture le sujet | 502 upstream_error (« no responders »), jamais un 202 |
NATS : SSE et WebSocket liés au tenant, jokers, NATS Core
Disponible depuis
1.6.0; exemple validé par le binaire et épinglé par les tests.
Un tableau de bord lit tous les évènements de commande de son tenant d'un seul flux, grâce à un joker inclus dans le canal après le segment lié ; un opérateur lit les mesures de son site sur un NATS Core (SSE seul, au plus une fois) ; un écran public lit l'état sans jeton. Chaque message reçu est comparé au motif du canal, segment par segment : un courtier qui en enverrait trop ne fait jamais fuir un tenant (RG-70).
# NATS in server-sent events and WebSocket (1.6.0): a dashboard reads every order event of
# its tenant through a wildcard written in the channel after the bound segment; an operator reads its
# own site's readings from NATS Core (server-sent events only, at most once); a public status is read
# without a token. Two brokers, two identities.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
orders-nats:
protocol: nats
brokers: ["tls://nats-0.nats:4222"]
tls: { server_name: nats.internal }
nats:
token: "file:/etc/flexway-secrets/nats/token" # a token, by reference - over TLS only
jetstream: true
metrics-core:
protocol: nats
brokers: ["nats://nats-edge.plant:4222"]
sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/nats-edge/password" }
tls: { server_name: nats-edge.plant }
nats:
jetstream: false # NATS Core: server-sent events only - no publication, WebSocket nor webhook
auth:
issuers:
- name: operators
discovery: https://login.example.net/.well-known/openid-configuration
audience: api
tenant_claim: org# examples/async-nats-streams/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Orders and metrics, version: 1.0.0 }
channels:
orderFeed:
address: "orders.{tenant}.>" # the wildcard IN the channel, after the segment bound to the tenant
parameters:
tenant: { x-flexway-bind: tenant }
messages:
any:
payload: { type: object, required: [orderId], properties: { orderId: { type: string } } }
siteMetrics:
address: "sites.{site}.metrics.*" # bound to a claim of the token: an operator reads its own site
parameters:
site: { x-flexway-bind: "claim:site" }
messages:
# Written as application/cloudevents+json by a producer outside the gateway: read in structured mode.
reading:
payload: { type: object, required: [value], properties: { value: { type: number } } }
status:
address: status.public # binds nothing, filters nothing: declared public below
messages:
state:
payload: { type: object, required: [state], properties: { state: { type: string } } }
operations:
receiveOrders: { action: receive, channel: { $ref: "#/channels/orderFeed" }, x-flexway-upstream: orders-nats }
receiveMetrics: { action: receive, channel: { $ref: "#/channels/siteMetrics" }, x-flexway-upstream: metrics-core }
receiveStatus:
action: receive
channel: { $ref: "#/channels/status" }
x-flexway-upstream: orders-nats
x-flexway-public: true # read by anyone, anonymous included# examples/async-nats-streams/openapi.yaml
openapi: 3.1.0
info: { title: Orders and metrics, version: "1.0.0" }
components:
securitySchemes:
operators: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/orders/stream:
get:
operationId: streamOrders
# SSE: one ordered consumer per channel and replica; WebSocket: one durable consumer per subscriber, AckWait = ack_timeout
x-flexway-subscribe: { operation: receiveOrders, transport: [sse, websocket], max_streams: 2, ack_timeout: 30s, history: 64 }
security: [ { operators: [orders:read] } ]
responses: { "200": { description: The caller's tenant's orders } }
/metrics/stream:
get:
operationId: streamMetrics
x-flexway-subscribe: { operation: receiveMetrics, transport: [sse], history: 0 } # NATS Core: SSE only
security: [ { operators: [metrics:read] } ]
responses: { "200": { description: The readings of the caller's site } }
/status:
get:
operationId: streamStatus
x-flexway-subscribe: { operation: receiveStatus, transport: [sse], max_anonymous_streams: 1000 }
security: []
responses: { "200": { description: The public status } }| Règle | Comportement |
|---|---|
| Joker | * et > seulement dans un canal lu, après les segments liés, > en dernier - orders.>.{tenant} est refusé ; une valeur liée ne contient jamais ., * ni >. Même règle pour + et # en MQTT, et pour * et # dans la clé de liaison AMQP 0-9-1 d'un échange (audit.{tenant}.#, servi depuis 1.5.0) |
| SSE | Un consommateur ordonné JetStream (ou un abonnement Core) par canal et par réplique, quel que soit le nombre d'abonnés ; reprise Last-Event-ID dans l'historique du canal, id = séquence du flux |
| WebSocket | Un consommateur durable par abonné, AckWait = ack_timeout ; refusé au chargement sur NATS Core |
| CloudEvents | Binaire (ce-*) ou structuré selon le type de contenu ; l'attribut tenant filtré dans les en-têtes, jamais dans data |
NATS JetStream : webhook depuis un consommateur durable
Disponible depuis
1.6.0; exemple validé par le binaire et épinglé par les tests.
Un partenaire reçoit les factures de son tenant en webhook signé, sans accès à NATS : un consommateur
durable partagé par les répliques (une réplique par message), accusé après le 2xx, rebut déclaré au bout
des réessais. Aucune porte HTTP : le contrat OpenAPI n'est pas nécessaire.
# NATS JetStream delivered by webhook (1.6.0): a partner receives its tenant's invoices as
# signed webhooks, without access to NATS - a durable consumer the replicas share (one replica per
# message), acknowledged after the 2xx, the declared dead letter once the retries are spent. No HTTP
# door: no OpenAPI contract. Refused on NATS Core, which redelivers nothing.
version: 1
contracts:
asyncapi: ./asyncapi.yaml
upstreams:
billing-nats:
protocol: nats
brokers: ["tls://nats-0.nats:4222"]
tls: { server_name: nats.internal }
nats:
nkey_seed: "file:/etc/flexway-secrets/nats/flexway.nk" # an nkey seed alone, by reference
jetstream: true # a delivery needs JetStream: a durable consumer the replicas share
partner-hook:
protocol: webhook
url: https://hooks.partner.example/invoices
secrets: ["file:/etc/flexway-secrets/hooks/current"]
retries: { max: 8, max_delay: 10m }# examples/async-nats-webhook/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Invoices, version: 1.0.0 }
channels:
invoiceIssued:
address: "invoices.{tenant}.issued"
parameters:
tenant: { x-flexway-bind: tenant } # a delivery has no caller: bound to the tenant only
messages:
issued:
payload: { type: object, required: [invoiceId], properties: { invoiceId: { type: string } } }
operations:
receiveInvoices:
action: receive
channel: { $ref: "#/channels/invoiceIssued" }
x-flexway-upstream: billing-nats
x-flexway-deliver:
- { upstream: partner-hook, tenant: acme, dead_letter: invoices.dead } # a subject a stream capturesSur NATS Core, la même livraison est refusée au chargement : rien ne relivre un message que la cible n'a pas pris.
AMQP 1.0 : publier vers Azure Service Bus, livrer depuis un abonnement
Disponible depuis
1.6.0; exemple validé par le binaire et épinglé par les tests.
Un service HTTP publie dans le sujet Service Bus de son tenant (202 après la disposition accepted) ;
un partenaire reçoit, en webhook, ce que lit l'abonnement que l'exploitant a créé. Deux canaux, l'un pour
écrire, l'autre pour lire : aucune extension. La passerelle s'authentifie par une clé SAS en SASL PLAIN.
# AMQP 1.0 toward Azure Service Bus (1.6.0): an HTTP service publishes to its tenant's
# topic - 202 after the disposition accepted -, a partner receives by webhook what the subscription the
# operator created reads. Two channels, one to write, one to read: no extension. The gateway
# authenticates with a SAS key over SASL PLAIN.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
billing-bus:
protocol: amqp
protocol_version: "1.0" # AMQP 1.0; 0-9-1 stays the default
brokers: ["amqps://acme-billing.servicebus.windows.net"]
sasl: { mechanism: PLAIN, username: flexway-send, password: "file:/etc/flexway-secrets/servicebus/key" } # SAS key name and key
amqp1: { broker: servicebus } # publication and webhook; server-sent events and WebSocket refused
timeout: 3s # the disposition accepted included
partner-hook:
protocol: webhook
url: https://hooks.partner.example/invoices
secrets: ["file:/etc/flexway-secrets/hooks/current"]
auth:
issuers:
- name: customers
discovery: https://login.example.net/.well-known/openid-configuration
audience: api
tenant_claim: org# examples/async-amqp1-servicebus/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Invoices, version: 1.0.0 }
servers:
servicebus: { host: acme-billing.servicebus.windows.net, protocol: amqp, protocolVersion: "1.0" }
channels:
invoices:
address: "invoices-{tenant}" # the tenant's Service Bus topic, created by the operator
parameters:
tenant: { x-flexway-bind: tenant }
bindings: { amqp1: {} }
messages:
issued:
payload: { type: object, required: [invoiceId], properties: { invoiceId: { type: string } } }
invoicesForPartner:
address: "invoices-{tenant}/Subscriptions/flexway-partner" # the subscription, created by the operator
parameters:
tenant: { x-flexway-bind: tenant }
bindings: { amqp1: {} }
messages:
issued:
payload: { type: object, required: [invoiceId], properties: { invoiceId: { type: string } } }
operations:
publishInvoice: { action: send, channel: { $ref: "#/channels/invoices" }, x-flexway-upstream: billing-bus }
deliverInvoices:
action: receive
channel: { $ref: "#/channels/invoicesForPartner" }
x-flexway-upstream: billing-bus
x-flexway-deliver:
- { upstream: partner-hook, tenant: acme, dead_letter: invoices-dead } # a dead-letter queue of its own, with problemcode# examples/async-amqp1-servicebus/openapi.yaml
openapi: 3.1.0
info: { title: Invoices, version: "1.0.0" }
components:
securitySchemes:
customers: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/invoices:
post:
operationId: issueInvoice
x-flexway-publish: { operation: publishInvoice, idempotency: required } # Idempotency-Key -> the message-id
security: [ { customers: [invoices:write] } ]
requestBody:
required: true
content:
application/json: { schema: { type: object, required: [invoiceId], properties: { invoiceId: { type: string } } } }
responses: { "202": { description: Accepted by Service Bus } }| Ce qui se passe | AMQP 1.0 |
|---|---|
POST /invoices |
Message réglé par le courtier : accepted -> 202 ; rejected -> 502 upstream_error ; hors budget -> 504, issue inconnue |
| Livraison | Récepteurs concurrents sur l'abonnement (une réplique par message), accepted après le 2xx ; réessais épuisés : l'évènement dans invoices-dead avec problemcode, jamais le rebut propre au courtier |
| SSE ou WebSocket sur Service Bus | Refusés au chargement : aucune source non concurrente sans moyen propre au courtier |
| Jeton Entra ID (CBS) | Hors périmètre de 1.6.0 : clé SAS seulement |
AMQP 1.0 : consommer RabbitMQ 4 en SSE et WebSocket
Disponible depuis
1.6.0; exemple validé par le binaire et épinglé par les tests.
RabbitMQ 4 parle AMQP 1.0 nativement. Chaque joueur lit ses notifications (canal lié au sujet vérifié),
en SSE ou en WebSocket accusé ; tout le monde lit le tableau des scores, public. amqp1.broker: rabbitmq dit
comment ouvrir une source non concurrente : une file exclusive liée à l'échange, comme le fait déjà
l'adaptateur AMQP 0-9-1 ; une livraison par webhook lit une file /queues/... telle quelle. La passerelle s'authentifie
en SASL PLAIN sur TLS ; EXTERNAL prend son certificat client (tls.cert_file), ANONYMOUS n'est admis qu'avec mTLS ou
dans un maillage déclaré. SSE ou WebSocket sur une file /queues/... : refusés au chargement (ses consommateurs se la partagent).
# AMQP 1.0 toward RabbitMQ 4 (1.6.0): each player reads its own notifications (bound to the
# verified subject) in server-sent events or an acknowledged WebSocket; anyone reads the public
# scoreboard. amqp1.broker: rabbitmq says how to read without competing: a queue of the gateway's own,
# bound to the exchange through RabbitMQ 4's management node. The gateway authenticates as its own
# user (SASL PLAIN over TLS); EXTERNAL takes its client certificate instead.
version: 1
contracts:
openapi: ./openapi.yaml
asyncapi: ./asyncapi.yaml
upstreams:
games-bus:
protocol: amqp
protocol_version: "1.0"
brokers: ["amqps://rabbitmq.internal:5671"]
tls: { server_name: rabbitmq.internal } # cert_file and key_file with sasl EXTERNAL: the certificate is the identity
sasl: { mechanism: PLAIN, username: flexway, password: "file:/etc/flexway-secrets/rabbitmq/password" }
amqp1: { broker: rabbitmq } # an exclusive queue bound through the management node
auth:
issuers:
- name: players
discovery: https://login.example.net/.well-known/openid-configuration
audience: games
tenant_claim: org# examples/async-amqp1-rabbitmq/asyncapi.yaml
asyncapi: 3.0.0
info: { title: Game events, version: 1.0.0 }
channels:
notifications:
address: "/exchanges/notifications/players.{sub}" # a RabbitMQ 4 v2 address: exchange and routing key
parameters:
sub: { x-flexway-bind: subject } # a player reads its own channel, and only it
bindings: { amqp1: {} }
messages:
note:
payload: { type: object, required: [text], properties: { text: { type: string } } }
scores:
address: "/exchanges/scores/public"
bindings: { amqp1: {} }
messages:
# Written as application/cloudevents+json by an outbox: read in structured mode.
score:
payload: { type: object, required: [match], properties: { match: { type: string } } }
operations:
receiveNotifications: { action: receive, channel: { $ref: "#/channels/notifications" }, x-flexway-upstream: games-bus }
receiveScores:
action: receive
channel: { $ref: "#/channels/scores" }
x-flexway-upstream: games-bus
x-flexway-public: true # read by anyone, anonymous included# examples/async-amqp1-rabbitmq/openapi.yaml
openapi: 3.1.0
info: { title: Game events, version: "1.0.0" }
components:
securitySchemes:
players: { type: openIdConnect, openIdConnectUrl: https://login.example.net/.well-known/openid-configuration }
paths:
/me/notifications:
get:
operationId: streamMyNotifications
# WebSocket: settled accepted after {"ack": "<id>"}, released past ack_timeout - delivered again
x-flexway-subscribe: { operation: receiveNotifications, transport: [sse, websocket], max_streams: 3, ack_timeout: 20s }
security: [ { players: [] } ]
responses: { "200": { description: The caller's own notifications } }
/scores:
get:
operationId: streamScores
x-flexway-subscribe: { operation: receiveScores, transport: [sse], max_anonymous_streams: 500, history: 128 }
security: []
responses: { "200": { description: The public scoreboard } }Avec Artemis (broker: artemis, attache multicast) ou Solace (broker: solace, topic://), la même
déclaration ; avec generic ou servicebus, SSE et WebSocket sont refusés au chargement.