ReferenceServerapigenie-serverserver

server

apigenie-server serves an OpenAPI 3.0, 3.1 or 3.2 document (or a Swagger 2.0 one, see Swagger 2.0 documents) as a mock API, the channels of an AsyncAPI 3.x document over WebSocket, HTTP and MQTT, the gRPC services of a .proto file, or a GraphQL schema as a GraphQL endpoint with subscriptions. It answers with the document’s examples, with stubs, or with data generated from its schemas, validates requests, responses and messages, logs every exchange, and can make the network slow or unreliable on purpose. In front of a real server (--proxy-to) it becomes a validating proxy that contract-tests the server’s traffic against the OpenAPI or Swagger document, the GraphQL schema, the AsyncAPI document (WebSocket, MQTT and HTTP channels) or the .proto file, with coverage and JSON or JUnit reports. It is installed next to apigenie by every package (archives, .deb, .rpm, npm, Homebrew) and has a Docker image of its own.

Usage

apigenie-server [OPTIONS] SPEC...
apigenie-server session export DIR --har FILE
apigenie-server openapi.yaml
curl http://127.0.0.1:4010/pets

The first line the server writes says where it listens:

Listening on http://127.0.0.1:4010/v1 (spec: openapi.yaml, 12 operations)
Admin API: http://127.0.0.1:4010/__apigenie
Dashboard: http://127.0.0.1:4010/__apigenie/ui

The dashboard shows the traffic as it happens, the operations and what answers them, the scenarios, the contract report, the data and, for an AsyncAPI document, the channels and their clients, in the browser.

A GraphQL schema (.graphql, .graphqls, .gql) is served at /graphql:

apigenie-server schema.graphql
curl -X POST http://127.0.0.1:4010/graphql -H 'Content-Type: application/json' \
  -d '{"query": "{ books { title author { name } } }"}'
Listening on http://127.0.0.1:4010/graphql (spec: schema.graphql, 12 operations)
GraphiQL: http://127.0.0.1:4010/graphql
Subscriptions: ws://127.0.0.1:4010/graphql
Admin API: http://127.0.0.1:4010/__apigenie
Dashboard: http://127.0.0.1:4010/__apigenie/ui

In front of a real server it checks the traffic against the document instead of mocking it (contract testing):

apigenie-server openapi.yaml --proxy-to http://localhost:8080
Listening on http://127.0.0.1:4010/v1 (spec: openapi.yaml, 12 operations)
Proxy: http://localhost:8080 (requests: log, responses: log, unknown routes: forward, mode: always)
Admin API: http://127.0.0.1:4010/__apigenie
Dashboard: http://127.0.0.1:4010/__apigenie/ui

With --log-format json it is a JSON object ({"event":"listening","url":...,"port":...}), and every request after it is logged as one JSON object per line, which suits tools and tests.

Several documents are served together on one port, each below a prefix of its own (several services):

apigenie-server pets.yaml orders.yaml events.asyncapi.yaml
Listening on http://127.0.0.1:4010 (3 services)
  pets    http://127.0.0.1:4010/pets/v1 (spec: pets.yaml, 12 operations)
  orders  http://127.0.0.1:4010/orders (spec: orders.yaml, 5 operations)
  events  http://127.0.0.1:4010/events (spec: events.asyncapi.yaml, 3 channels)
Admin API: http://127.0.0.1:4010/__apigenie
Dashboard: http://127.0.0.1:4010/__apigenie/ui

Options

Option Default Description
--host HOST 127.0.0.1 The address to listen on; 0.0.0.0 or :: for every interface. Also APIGENIE_SERVER_HOST.
--port, -p PORT 4010 The port; 0 picks a free one. Also APIGENIE_SERVER_PORT.
--base-path PATH the servers’ paths Serve the operations under this prefix instead of the paths of the document’s servers.
--config, -c FILE see below The configuration file.
--no-config Don’t look for a configuration file.
--watch, -w Reload when the document or the configuration changes. A broken version is reported and the previous one stays.
--seed N random Seed of generated data and simulated failures: the same seed repeats a run.
--dynamic, -d / --static --static Generate every response from its schema, or answer with examples first.
--strict Answer 500 when a response the mock makes doesn’t match the document. With --proxy-to: answer requests that don’t match the document or schema without the upstream (422 for OpenAPI, GraphQL errors for GraphQL) and replace upstream responses that don’t (502); AsyncAPI: messages that don’t match aren’t forwarded, the client gets a problem message instead; gRPC: requests and upstream messages are checked before they are forwarded, and a call that breaks the contract ends with INVALID_ARGUMENT or INTERNAL.
--log-format FORMAT stylish stylish or json.
--log-level LEVEL info debug, info, warn or error.
--log-file FILE Also write the log to a file, as JSON lines.
--log-bodies, --log-headers Log request and response bodies, request headers.
--quiet, -q Only the first line on the standard output.
--latency MS Delay every response: a number of milliseconds, or MIN-MAX.
--jitter MS A random extra delay of up to this many milliseconds.
--drop-rate RATE The share of responses (0 to 1) whose connection is reset.
--cors on|off on Answer CORS preflights and add CORS headers.
--admin on|off on The admin API under /__apigenie, and the dashboard at /__apigenie/ui.
--scenario NAME Switch on a scenario of the configuration or the document; can be repeated.
--script FILE A JavaScript module with hooks (see below); can be repeated.
--crud Serve the collection resources of an OpenAPI document (/things and /things/{id}) from the document store: what a POST creates, a GET lists. The same as db.crud: auto.
--db-file FILE Keep the document store in this JSON file: loaded at the start, written when the server stops. Also APIGENIE_SERVER_DB_FILE.
--asyncapi-server NAME every server AsyncAPI: serve this server of the document only.
--mqtt-port PORT 1883, 8883 with --tls-cert AsyncAPI: the port of the MQTT broker; 0 picks a free one. Also APIGENIE_SERVER_MQTT_PORT.
--proto-path, -I DIR the file’s directory gRPC: where imports are looked up, like protoc’s --proto_path; can be repeated. Also APIGENIE_SERVER_PROTO_PATH (separated by :, ; on Windows).
--stubs FILE|DIR A YAML or JSON stub file, or a directory of them; can be repeated. gRPC: stubs (see Stubs). OpenAPI: the exchanges --record-dir wrote (see Record and replay). Also APIGENIE_SERVER_STUBS.
--proxy-to TARGET OpenAPI: the real server (http[s]://host[:port][/prefix] or HOST:PORT) that gets every request, both checked against the document (see Contract testing). GraphQL: the real server’s endpoint (https://host/graphql), requests and answers checked against the schema (see GraphQL contract testing). AsyncAPI: the real WebSocket and HTTP server (ws[s]://host[:port][/prefix] or http[s]://...) and the real MQTT broker (mqtt[s]://host[:port]), one or both (repeat the option), the messages of both sides checked against the document (see WebSocket contract testing, MQTT contract testing and HTTP channels). gRPC: an upstream server for the calls no stub answers, HOST:PORT for cleartext HTTP/2 or https://host[:port] for HTTP/2 over TLS, its answers checked against the .proto file (see gRPC contract testing). https://, wss:// and mqtts:// upstreams are verified (see TLS). Also APIGENIE_SERVER_PROXY_TO (one target).
--tls-cert FILE Serve HTTPS, WSS, gRPC and MQTT over TLS with this PEM certificate chain (the server’s certificate first); needs --tls-key. See TLS. Also APIGENIE_SERVER_TLS_CERT.
--tls-key FILE The unencrypted PEM private key of --tls-cert. Also APIGENIE_SERVER_TLS_KEY.
--tls-client-ca FILE Require a client certificate issued by the authorities of this PEM file on every HTTPS, WSS, gRPC and MQTTS connection (mTLS); the subject goes into the log and the contract report. Needs --tls-cert. See Client certificates. Also APIGENIE_SERVER_TLS_CLIENT_CA.
--ca-cert FILE the system’s roots A PEM file of certificate authorities trusted for https://, wss:// and mqtts:// upstreams, besides the system’s; can be repeated. Also APIGENIE_SERVER_CA_CERT (separated by :, ; on Windows).
--insecure Don’t verify the certificates of https://, wss:// and mqtts:// upstreams; the traffic stays encrypted. Also APIGENIE_SERVER_INSECURE=1.
--client-cert FILE A PEM client certificate (chain) presented to https://, wss://, mqtts:// and gRPC TLS upstreams that ask for one (mTLS); needs --client-key. Also APIGENIE_SERVER_CLIENT_CERT.
--client-key FILE The unencrypted PEM private key of --client-cert. Also APIGENIE_SERVER_CLIENT_KEY.
--proxy-mode always|fallback OpenAPI and GraphQL always, gRPC fallback always: the upstream gets every request. fallback: OpenAPI: every request still goes to the server, and the mock answers when the server can’t be reached, doesn’t answer in time, or answers 404 or 501 for an operation of the document (see Fallback mode); GraphQL: the mock’s examples, scenarios and scripts answer first; gRPC: stubs answer first. An AsyncAPI document’s proxy relays every message.
--record-dir DIR OpenAPI: write the exchanges the server answered as stub files, replayed with --stubs. gRPC: write the proxied calls as stub files. GraphQL: write the proxied answers as examples. Needs --proxy-to. Also APIGENIE_SERVER_RECORD_DIR.
--record-har FILE OpenAPI: write every exchange through the proxy (the server’s answers and the mock’s own) as a HAR 1.2 file when the server stops. Needs --proxy-to. Also APIGENIE_SERVER_RECORD_HAR.
--record DIR Record the session: every service’s exchanges and messages in the order they happened, each as a file, and session.yaml; an exchange’s test is its X-Apigenie-Test header (see Session recording). Every document, with or without --proxy-to. The directory must be new or empty. Also APIGENIE_SERVER_RECORD.
--record-redact HEADER|/POINTER What --record hides besides Authorization, Cookie, Set-Cookie, Proxy-Authorization and X-Api-Key: a header name, or a JSON pointer (/password) to a value of JSON bodies and messages; can be repeated.
--replay DIR Answer from the session --record wrote: each request gets the next unused recorded exchange of its test (X-Apigenie-Test) that matches it, on every protocol (see Session replay). When the server stops, exit status 5 if a recorded exchange was never replayed or a request matched none. Also APIGENIE_SERVER_REPLAY.
--replay-ignore HEADER|/POINTER What --replay doesn’t compare besides dates, readOnly fields and volatile headers: a header name, or a JSON pointer (/id, /items/*/id) into JSON bodies and messages; can be repeated.
--replay-missing fail|generate|passthrough fail What a request no recorded exchange matches gets: a 502 problem naming the closest recording and how the request differs (fail), the mock’s own answer (generate), or the answer of --proxy-to (passthrough).
--replay-update With --replay-missing passthrough: record the run and, when the server stops, replace the --replay session with it.
--proxy-unknown forward|reject forward OpenAPI proxy: forward requests for no operation of the document (reported), or answer them with 404 or 405.
--report FILE Write the contract report (coverage and violations) as JSON when the server stops. Also APIGENIE_SERVER_REPORT.
--report-junit FILE Write the contract report as JUnit XML when the server stops. Also APIGENIE_SERVER_REPORT_JUNIT.
--fail-on none|errors|warnings none Exit with 5 when the server stops after violations of that severity.
--min-coverage PERCENT Exit with 5 when the server stops with less of the document covered (a GraphQL schema: its fields; an AsyncAPI document: its operations’ messages; a .proto file: its methods; several services: their combined coverage).
--credits, --license [LIBRARY], --license-list Like apigenie credits and apigenie license.
--help, -h / --version, -v Show the help (with the exit statuses and the per-request directives), or the version.

Exit status

Code Meaning
0 Stopped with Ctrl+C (or POST /__apigenie/shutdown)
2 Bad options, configuration, scripts or stub files, or an unexpected error
3 The document can’t be served (missing, unreadable, not OpenAPI 3, Swagger 2.0 or AsyncAPI 3, no channels the mock serves, a .proto file with errors, missing imports or no services)
4 The address can’t be used (the port is taken, …)
5 The contract report failed when the server stopped (--fail-on, --min-coverage), or the replay did (--replay: a recorded exchange was never replayed, or a request matched none)
130 Stopped at once with a second Ctrl+C

Swagger 2.0 documents

A Swagger 2.0 document (swagger: "2.0") is served, proxied and contract-tested like the OpenAPI 3.2 document apigenie convert --to openapi32 would make of it. The conversion happens in memory each time the document is read (at startup and on reloads); no file is written. Body and form parameters become request bodies, produces and consumes become media types, host, basePath and schemes become the servers, and definitions, parameters, responses and securityDefinitions move into components.

Locations stay those of the Swagger file: warnings about the document give its lines, the problems of x-apigenie-mock extensions its lines and JSON pointers, and the contract report’s pointer of each operation is a pointer into it. What the conversion can only approximate (a collectionFormat without counterpart, form parameters next to a body parameter, …) is told once at startup, on the standard error, as an info: line with the file’s line, column and JSON pointer:

info: pets.yaml:7:71: /paths/~1pets/get/parameters/0/collectionFormat: collectionFormat tsv has no counterpart and becomes style form (csv)

GET /__apigenie/spec returns the converted document, which is what is served. The document is converted alone: one that references other files can’t be served (exit status 3) until apigenie bundle has put them into it.

Responses

  • Routing. Paths are served under the path of the document’s first server (/v1 for https://api.example.com/v1) and as they are. Literal paths win over templated ones (/pets/mine over /pets/{id}). An unknown path is answered with 404, an unknown method with 405 and an Allow header, both as problem details. OpenAPI 3.2’s query method and the methods of additionalOperations (COPY, …) are routed like the others.
  • Status. The lowest 2xx response, else 2XX, else the lowest declared, else default.
  • Body. Content negotiation follows Accept (406 when nothing fits). Without an Accept, or with */*, the response takes the media type of the request’s body when it has it: a SOAP 1.2 request (application/soap+xml) to an operation answering both SOAP versions gets a SOAP 1.2 envelope. In the default static mode the media type’s example, its first named example, or the schema’s example answer; what has none is generated from the schema. Generated values respect types, the formats of the OpenAPI format registry (uuid, date-time, int8, decimal, http-date, …; see Formats), patterns, lengths, ranges, enum, const, allOf, oneOf, readOnly and writeOnly. A value for application/xml, text/xml or a +xml type is written as XML (see XML bodies); a string example is sent as it is.
  • Headers. Response headers are filled in from their examples or schemas. X-Apigenie-Mock-Operation and X-Apigenie-Mock-Source (example:cat, generated, scenario:outage, stub:getPet-1) say where a response came from.
  • Stubs. --stubs DIR (or stubs: in the configuration) answers with recorded exchanges (see Record and replay) before the document’s examples: a request’s Prefer and the rules of scenarios and overrides still come first.

Choosing a response per request

The Prefer header (or query parameters, which are removed before validation) asks for a particular answer:

Prefer Query Effect
code=404 __code=404 That status (500 when the operation doesn’t declare it).
example=cat __example=cat That named example.
dynamic=true __dynamic=true A generated body.
delay=1500 __delay=1500 Answer after 1.5 seconds.
drop=reset Reset the connection (close, timeout and truncate too).
scenario=outage Apply that scenario to this request.

Validation and security

Requests are checked against their operation: path, query, header and cookie parameters in every style, and JSON, XML, form and multipart bodies. A request that doesn’t match is answered with 422 and the problems, a body that can’t be parsed (JSON, XML, multipart) with 400, an unknown media type with 415. Security requirements are checked for presence (API keys, HTTP basic and bearer, OAuth2 and OpenID Connect tokens; 401 with WWW-Authenticate), and against configured credentials in enforce mode (403). Responses the mock makes are checked too: problems are logged and counted in X-Apigenie-Mock-Violations, or answered with 500 under --strict.

Schemas’ format values are checked as the OpenAPI format registry defines them, for the JSON types each format applies to (int8 ranges, http-date, decimal, language, …); validation.formats: false turns these checks off. Formats of your own, declared in openapi.custom_formats of the configuration file, are accepted as they are, and an entry there naming a registry format replaces that format’s type and its check.

XML bodies

Bodies of application/xml, text/xml and +xml media types are parsed as XML 1.0 with namespaces, mapped to a JSON value as the schema’s xml objects describe it, and checked against the schema like JSON bodies; the mock, the validating proxy and apigenie test do the same, and the mock and apigenie test --generate write XML the same way in reverse. For this schema

Order:
  type: object
  xml: {name: order, namespace: "urn:orders", prefix: o}
  properties:
    id: {type: integer, xml: {nodeType: attribute}}   # OpenAPI 3.0/3.1: attribute: true
    customer: {$ref: "#/components/schemas/Customer"} # Customer has xml: {name: Buyer}
    note: {type: string, xml: {nodeType: cdata}}
    tags: {type: array, items: {type: string, xml: {name: tag}}}
    lines:
      type: array
      xml: {name: Lines, nodeType: element}           # 3.0/3.1: wrapped: true
      items: {$ref: "#/components/schemas/Line"}      # Line has xml: {name: Line}

the body is

<o:order xmlns:o="urn:orders" id="7">
  <Buyer><name>Ann</name></Buyer>
  <![CDATA[Leave at the <door>]]>
  <tag>a</tag><tag>b</tag>
  <Lines><Line sku="x"><quantity>2</quantity></Line></Lines>
</o:order>
  • The root element is named by the schema’s xml.name, else by the component a $ref points to; another name is a xml-root violation. A schema without either names no root.
  • A property is a child element named by its xml.name (its own, or that of the schema it references), else by the property name. nodeType: attribute makes it an attribute, text and cdata the element’s character data.
  • An array’s items repeat in the parent element, named by the items’ xml.name or the property name; with nodeType: element (wrapped: true) they sit in a wrapper element.
  • A declared xml.namespace must match; without one, elements are matched by local name in any namespace. An element with a property’s local name in another namespace isn’t that property: it is kept as {namespace}name, so the property is missing and additionalProperties: false reports the element.
  • Child elements are written in the order the schema declares its properties, as XML Schema sequences require.
  • Text becomes an integer, number, boolean or null where the schema allows it (white space around such values is ignored); xsi:nil="true" is null. Elements and attributes the schema doesn’t declare are kept by local name, so additionalProperties: false reports them.
  • A body that isn’t well-formed is malformed-xml (request.body.malformed, answered with 400; response.body.malformed), with its line and column. Document type declarations are refused, so external entities and entity expansion are errors.

A SOAP service described in WSDL 1.1 is served after apigenie convert --to openapi31: the converted document describes the SOAP envelopes with these xml objects.

Contract testing (validating proxy)

With --proxy-to, an OpenAPI document is not mocked: every request goes to the real server, and the mock checks the request and the server’s response against the document. (A GraphQL schema and an AsyncAPI document’s WebSocket, MQTT and HTTP channels work the same way, with checks of their own: see GraphQL contract testing, WebSocket contract testing, MQTT contract testing and HTTP channels.) Point your tests, your frontend or a load test at the mock instead of the server, and the traffic becomes a contract test. To send the requests yourself, from .http files or generated from the document, with the same checks and reports, use apigenie test.

apigenie-server openapi.yaml --proxy-to http://localhost:8080 --report contract.json --fail-on errors

The upstream gets the request as it came, without the mock’s __ query parameters and X-Apigenie-Mock-* headers, with Via, X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, and Accept-Encoding: identity so that bodies can be checked. A path in the URL (http://localhost:8080/api) is put before the request’s path. The admin API, CORS preflights and answers of scripts’ onRequest stay local; the global network simulation, a request’s Prefer: delay= or drop=, and scripts’ onResponse apply to the upstream’s responses. Scenario rules and examples don’t: nothing is mocked, unless the server fails in fallback mode (below).

What is checked. Each problem is a violation with a check, a severity and a location:

Check Severity When
request.path-or-method.unknown warn The document has no operation for the method and path.
request.operation.deprecated, request.parameter.deprecated info A deprecated operation or parameter is used (the response gets Deprecation: true).
request.authorization.missing, request.authorization.rejected error No credentials for any security requirement; credentials other than the configured ones (security.mode: enforce).
request.authorization.oauth2-scheme, request.authorization.oauth2-scope warn apigenie test only: a request with an OAuth 2 token for an operation that has no OAuth 2 or OpenID Connect scheme; a token requested with scopes the operation doesn’t declare, or without those it requires.
request.path.incompatible, request.query.incompatible, request.header.incompatible, request.cookie.incompatible error A parameter is missing or doesn’t match its schema (every style).
request.query.unknown warn An undeclared query parameter (validation.unknown_query_parameters: error).
request.content-type.incompatible, request.body.malformed, request.body.incompatible error The body’s media type isn’t declared, the body can’t be parsed (JSON, XML, multipart), or doesn’t match its schema (for XML, see XML bodies).
response.status.unknown error for 2xx, else warn No response covers the status (exact code, then 2XX, then default).
response.status.default info Only default covers the status.
response.status.server-error info The server answered with 5xx.
response.content-type.incompatible error The response’s media type isn’t one of the declared ones.
response.body.incompatible, response.body.malformed error The body is missing, doesn’t match its schema (writeOnly properties aren’t required), or isn’t JSON or XML where the media type says so.
response.body.unexpected warn A body where the response declares no content.
response.body.unvalidatable info A body that can’t be checked: binary, compressed.
response.header.incompatible error A declared header is missing when required, or doesn’t match its schema.
response.header.undefined off A header the response doesn’t declare (common ones such as Date or ETag aside).

contract.severity in the configuration changes a check’s severity (error, warn, info) or turns it off (ignore).

Report or enforce. By default a proxy reports only (validation.request and validation.response are log unless the configuration says otherwise), since test suites send invalid requests on purpose to see the server refuse them. Every response then carries the violations of its exchange:

X-Apigenie-Mock-Source: proxy
X-Apigenie-Mock-Operation: getPet
X-Apigenie-Mock-Violations: 1
X-Apigenie-Mock-Violations-Detail: [{"location":["response","body","/id"],"check":"response.body.incompatible","code":"type","severity":"error","message":"expected type integer, found string"}]

The detail is cut at 8 KB, with a last item counting what was left out. The log has the same violations, upstreamStatus for each request, and fallback: true for the mock’s own answers in fallback mode.

--strict (or enforce in the configuration) turns errors into answers. A request with an error doesn’t reach the server: it gets 401 or 403 (security), 415 (media type), 400 (malformed body) or 422. A response with an error is replaced by a 502:

{
  "type": "https://apigenie.pl/mock/errors#invalid-upstream-response",
  "title": "The upstream response does not match the specification",
  "status": 502,
  "detail": "response.body/id: expected type integer, found string",
  "validation": [{"location": "response.body/id", "code": "type", "message": "expected type integer, found string",
                  "severity": "error", "check": "response.body.incompatible", "path": ["response", "body", "/id"]}],
  "upstream": {"status": 200, "contentType": "application/json"}
}

Warnings and notes never block anything. A server that can’t be reached is answered with 502 (upstream-unreachable, upstream-bad-response, upstream-response-too-large), one that doesn’t answer in time with 504 (upstream-timeout).

Fallback mode. With --proxy-mode fallback (or proxy.mode: fallback), every request still goes to the server first, but the mock answers itself, as it would without --proxy-to (examples, generated data, scenario rules, Prefer), when the server can’t be reached, doesn’t answer in time, or answers 404 or 501 for an operation of the document. A frontend can then work against a server that is down or only partly implemented. The answer says so:

X-Apigenie-Mock-Source: generated
X-Apigenie-Mock-Upstream-Status: 404

The log record has fallback: true and upstreamStatus (absent when the server didn’t answer). The server’s 404 or 501 isn’t checked against the document, so it isn’t a response.status.unknown violation. Requests for no operation of the document are not the mock’s to answer: the server’s 404 reaches the client. An answer that isn’t HTTP or is too large is still a 502: the server did answer.

Record and replay. --record-dir DIR writes each exchange the server answered as a stub file, getPet-1.yaml (GET_health-1.yaml for a route of no operation; numbers already taken are skipped), and --stubs DIR makes a mock without --proxy-to answer with them:

apigenie-server openapi.yaml --proxy-to http://localhost:8080 --record-dir recorded
apigenie-server openapi.yaml --stubs recorded
stubs:
  - name: getPet-1
    operationId: getPet
    request:
      method: GET
      path: /pets/1                # as the client sent it, without the proxy's prefix
      query: full=true             # without the mock's __ parameters
      headers: {Accept: application/json, Authorization: "[redacted]"}
    # match: {headers: [X-Tenant]} # these request headers must be equal too
    response:
      status: 200
      headers: {Content-Type: application/json, ETag: '"v1"'}
      body: {id: 1, name: Rex}     # JSON; text: for other text, base64: for bytes

A request matches a stub when its method, path, query (in any order) and body (JSON compared as JSON) are the same; headers count only when match.headers names them. The first stub that matches answers, files in name order; its source is stub:getPet-1. Requests still go through security and request validation; the answer isn’t checked again. Answers the mock gave or replaced (fallback, --strict) aren’t recorded. The values of Authorization, Cookie, Set-Cookie, Proxy-Authorization and X-Api-Key are written as [redacted] (proxy.redact sets the list), and what the connection or the proxy adds (Host, User-Agent, Via, X-Forwarded-*, Date, …) is left out. A recorded header that matters for matching has to be restored by hand.

HAR. --record-har session.har writes every exchange through the proxy as an HTTP Archive (HAR 1.2) when the server stops: the server’s answers and the mock’s own (fallback answers, rejected requests, replaced responses, the problems of a failed server). Each entry’s comment is proxy or mock, and _apigenie has the source, the operation, the server’s status, fallback and the violations. Headers of proxy.redact are written as [redacted]; binary bodies are in Base64, and bodies above 1 MiB are left out (their sizes stay). Browsers’ developer tools, Postman and HAR viewers read it.

Coverage. The mock counts what the traffic exercised: each operation, its request body (seen with a 2xx or 3xx answer) and each declared response (200, 4XX, default). An operation is covered when all of these were seen, partial, or not-tested; requests for no operation are listed as undocumented. Mocked traffic is counted too, without --proxy-to or in fallback mode; the report tells the two apart: summary.sources and each operation’s sources count the answers of the server (proxy) and of the mock (mock, also the problems of a failed server and rejected requests), mockedResponses the statuses the mock answered with, and, when the mock answered any, the summary printed at the end has a Sources: line.

Reports. GET /__apigenie/contract returns the report at any time, and DELETE starts it over (a reload does too). When the server stops (Ctrl+C or POST /__apigenie/shutdown) it prints a summary, writes --report (JSON) and --report-junit (one test case per operation, skipped when not tested, failed with error violations; one per undocumented route; one for the coverage threshold), and exits with 5 when --fail-on or --min-coverage isn’t met. In CI:

apigenie-server openapi.yaml --proxy-to http://localhost:8080 --port 4010 \
  --report contract.json --report-junit contract.xml --fail-on errors --min-coverage 80 &
API_URL=http://localhost:4010 npm test
curl -X POST http://localhost:4010/__apigenie/shutdown
wait $!   # 5 when the contract failed

The JSON report has summary (requests, their sources, violations by severity, coverage of branches and operations, the gate with its reasons), operations (the JSON pointer of the operation in the document, e.g. /paths/~1pets/get, requests, responses seen and missing, undeclared statuses, sources and the mock’s statuses, violations by check), undocumented and violations (each distinct one with its count and the first time it was seen). Its formatVersion is the version of the format, which changes only when a member is removed or changes meaning (see Compatibility); version, its former name, is deprecated and will be removed in the next release. The JSON Schema of the members the OpenAPI, AsyncAPI, GraphQL and gRPC reports share is apigenie-contract-report.json.

Network simulation

Latency (fixed, uniform, normal or log-normal), jitter, dropped connections (reset, closed without an answer, never answered, or truncated bodies) and bandwidth limits apply to every response, an operation’s, a scenario’s or one request’s.

Scenarios and extensions

A scenario is a named set of rules switched on and off together. A rule matches requests (by operationId, method, path, tag, headers or query) and sets the status, an example, a literal body, headers, the network simulation, or a probability. Scenarios come from the configuration, from x-apigenie-mock in the document, or from the admin API.

paths:
  /orders:
    get:
      x-apigenie-mock:
        delay: 200
        headers: {X-Region: eu}
        scenarios:
          empty: {body: []}
          broken: {status: 500, drop: {probability: 0.5, mode: reset}}

A schema can name the faker provider that generates its values with x-apigenie-mock-faker or x-faker: a name (person.firstName, internet.email, location.city…), {"number.int": [1, 10]} with arguments by position, {"string.alpha": {length: 6}} with arguments by name, or {provider: number.int, args: [1, 10], options: {...}}. Arguments are numbers, strings and booleans. Names of older faker.js versions (name.firstName, address.city, datatype.uuid) work too. An unknown name, or arguments the provider doesn’t take, are answered with 500 and faker’s message. Records (person.profile, location.address, finance.card…) are objects, or their JSON text for a string schema.

properties:
  owner: {type: string, x-faker: person.fullName}
  age: {type: integer, x-faker: {number.int: [18, 90]}}
  code: {type: string, x-faker: {string.alpha: {length: 6, casing: upper}}}
  customer: {type: object, x-faker: {person.profile: {country: PL}}}

With generation.smart_names, plain string properties called like email, first_name, city, zip_code or phone get such values without an extension. generation.null_probability sets how often a schema with OAS 3.0 nullable: true is answered with null.

x-apigenie-mock on an operation (or its path item) accepts status, example, dynamic, body, body_file, headers, latency, jitter, drop, bandwidth, delay, probability, validate_request, scenarios and resource (see CRUD resources); on a response, delay, latency, jitter and headers.

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/42 for rooms/{roomId}). It gets the messages of the channel’s send operations, 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 the receive operations: a match is answered with the operation’s reply, anything else with a problem details message.
  • HTTP. A receive operation takes messages with POST (or its HTTP binding’s method) and answers 202, or 200 with its reply; an invalid message gets 422. A send operation answers GET with 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 send operations’ messages with their MQTT binding’s QoS. Messages clients publish go to the other subscribers, as with any broker, once they match a receive operation; 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 has address: 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 of exchange.name (of exchange.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}.filing is customs.*.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. What apigenie test and 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 receive operation 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’s query schema (operation.query.invalid) and its header fields against the message’s headers and HTTP binding headers, its traits’ included (message.headers.invalid). A 2XX response’s body and header fields are checked against its reply.
  • A send operation 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.

Protobuf / gRPC

For a .proto file the mock serves its services over gRPC, on the same port as HTTP: clients connect with cleartext HTTP/2 (-plaintext in grpcurl, createInsecure() in grpc-js, --http2-prior-knowledge in buf curl), or over TLS when the mock has --tls-cert (grpcurl without -plaintext, createSsl() in grpc-js; see TLS). Imports are looked up in --proto-path directories, then next to the file, then among the embedded standard files: the well-known types, google/api/annotations.proto, google/rpc/status.proto, code.proto and error_details.proto.

apigenie-server greeter.proto --stubs stubs/
buf curl --protocol grpc --http2-prior-knowledge -d '{"name":"Ann"}' \
  http://127.0.0.1:4010/acme.greeter.v1.Greeter/SayHello
Listening on http://127.0.0.1:4010 (spec: greeter.proto, 1 service, 3 methods, gRPC over HTTP/2)

Unary, server-streaming, client-streaming and bidirectional methods are served. A call is answered by the first of:

  1. Its x-apigenie-mock-* metadata (below).
  2. A script’s onRequest.
  3. The rules of the active scenarios.
  4. The stubs.
  5. The upstream of --proxy-to.
  6. Messages generated from the response type, seeded by --seed: one, or one to three for a server stream; with grpc.generation: false, UNIMPLEMENTED. Fields marked (google.api.field_behavior) = REQUIRED are always set (not to their default value; lists and string- or bool-keyed maps get an item), except where that would make a message contain itself; fields marked INPUT_ONLY are left out.

The response metadata x-apigenie-mock-source (stub:hello-ann, generated, proxy, scenario:outage) and x-apigenie-mock-stub say which one it was.

Stubs

Stub files are YAML or JSON: a list, {stubs: [...]}, or a single stub. Their JSON Schema is apigenie-mock-grpc-stubs.json; add # yaml-language-server: $schema=https://apigenie.pl/schemas/apigenie-mock-grpc-stubs.json to a file for completion in editors.

stubs:
  - name: hello-ann
    method: acme.greeter.v1.Greeter/SayHello
    priority: 10
    request:
      equals: {name: Ann}
    response:
      headers: {x-greeting: formal}
      message: {message: "Hello Ann", sentAt: "2026-10-04T10:00:00Z", mood: MOOD_HAPPY}
      trailers: {x-cost: "1"}

  - name: hello-tenant
    method: acme.greeter.v1.Greeter/SayHello
    request:
      contains: {name: Bob}
      metadata:
        equals: {x-tenant: acme}
    response:
      message: {message: "Hello Bob of {{metadata.x-tenant}}"}

  - name: hello-empty
    method: acme.greeter.v1.Greeter/SayHello
    request:
      equals: {}
    response:
      status:
        code: INVALID_ARGUMENT
        message: name is required
        details:
          - "@type": type.googleapis.com/google.rpc.BadRequest
            fieldViolations: [{field: name, description: must not be empty}]

  - name: hello-all
    method: acme.greeter.v1.Greeter/SayHelloToAll
    response:
      each: {message: {message: "Hi {{message.name}}"}}
  • method is package.Service/Method (or method: Method with service: package.Service). An unknown method stops the start with exit status 2 and the methods there are.
  • request matches the request message, written as proto3 JSON: equals compares canonical forms, so default values may be left out; contains matches a subset, recursively (arrays as subsets, ignoreArrayOrder for any order); matches holds regular expressions for string fields. request.metadata matches the metadata the same three ways.
  • requests matches the messages of a client stream, after the client has finished sending: a list matched one by one, or {every: matcher}.
  • priority (higher first, then the order of the files) and times (how many calls a stub answers before it’s skipped) order the candidates.
  • response has headers, message or messages (a list of {message, delay} for a stream), each (bidirectional streams: one answer per message received), trailers, status (code as a name or a number, message, details as google.protobuf.Any JSON), delay (milliseconds), drop (reset, close, timeout, truncate) and generate: true (fill the fields the message doesn’t give).
  • Templates. String values can use {{request.name}}, {{requests[1].name}}, {{requests.length}}, {{message.name}} (the message each answers), {{metadata.x-tenant}} and {{fake:person.firstName}}. A value that is a template alone keeps the type of what it refers to.

Metadata directives

Metadata Effect
x-apigenie-mock-code: NOT_FOUND End the call with that status (a name or a number).
x-apigenie-mock-message: text The status message that goes with it.
x-apigenie-mock-stub: hello-ann Answer with that stub.
x-apigenie-mock-delay: 1500 Answer after 1.5 seconds.
x-apigenie-mock-drop: reset Reset the connection (close, timeout and truncate too).
x-apigenie-mock-scenario: outage Apply that scenario to this call.
x-apigenie-mock-dynamic: true Generate the answer.

What a gRPC server does

  • grpc-timeout sets a deadline: a call that would answer later ends with DEADLINE_EXCEEDED.
  • Messages larger than grpc.max_message_bytes (4 MiB) fail with RESOURCE_EXHAUSTED; compressed ones with UNIMPLEMENTED and grpc-accept-encoding: identity; undecodable ones with INVALID_ARGUMENT. An unknown method is UNIMPLEMENTED.
  • Requests are validated against their message type (grpc.validation.request): a violation is INVALID_ARGUMENT with a google.rpc.BadRequest in grpc-status-details-bin. Fields marked [(google.api.field_behavior) = REQUIRED] must be set (see gRPC contract testing).
  • Errors are sent as Trailers-Only responses; grpc-message is percent-encoded, -bin metadata base64-encoded.
  • grpc.reflection.v1 and v1alpha let grpcurl, buf curl, Postman, Kreya and Bruno list the services and fetch their descriptors; grpc.health.v1.Health answers Check and Watch with SERVING for every service and for "".
  • Scenario rules match operationId (acme.greeter.v1.Greeter/SayHello), path, or the tags grpc and the service’s name, and set grpc_status and grpc_message besides delays and drops:
scenarios:
  outage:
    rules:
      - match: {operationId: acme.greeter.v1.Greeter/SayHello}
        grpc_status: UNAVAILABLE
        grpc_message: the greeter is down

Proxy and record

apigenie-server greeter.proto --proxy-to greeter.internal:50051 --record-dir recorded/
apigenie-server greeter.proto --stubs recorded/

With --proxy-to, calls no stub answers go to the upstream server (every call with grpc.proxy.mode: always), with their metadata and deadline; an unreachable upstream is UNAVAILABLE. --record-dir writes each proxied call as a stub file (Greeter.SayHello-1.yaml, …) that matches its request and gives the upstream’s answer. Transport headers are left out, and so is the metadata of grpc.proxy.redact (authorization, cookie, x-api-key and proxy-authorization by default).

Contract testing of a gRPC API

The proxy checks both sides of every call it forwards against the .proto file. Point your clients, tests or a load test at the mock, and the traffic becomes a contract test of the server:

apigenie-server greeter.proto --proxy-to greeter.internal:50051 --report contract.json --report-junit contract.xml --fail-on errors

Messages are decoded with their method’s types and checked against them: their JSON Schema (the proto3 JSON form), the fields marked [(google.api.field_behavior) = REQUIRED] (proto3 has no required fields; a REQUIRED field without explicit presence must not have its default value), and deprecated fields and enum values. The status of the answer is checked against the gRPC protocol and the google.rpc conventions. Each violation has a check and a severity that contract.severity changes:

Check Severity What it means
request.method.unknown warn The call is for a method the file doesn’t declare; it is answered UNIMPLEMENTED, not forwarded
request.message.malformed error A request message can’t be decoded as the input type
request.message.incompatible error A request message doesn’t match its type, or misses a REQUIRED field. A field that came with another wire type than its declared type’s (a string where an int64 is declared) is dropped by decoding, as protobuf parsers do, and reported with the code wire-type
request.message.deprecated info A request sets a deprecated field or enum value
response.status.invalid error An HTTP status other than 200, a content type other than application/grpc, a missing or unknown grpc-status, a grpc-message that isn’t percent-encoded
response.status.details-incompatible error grpc-status-details-bin isn’t a base64 google.rpc.Status, or its code isn’t grpc-status; a warning for a detail of a type the file doesn’t know
response.status.server-error info The upstream answered UNKNOWN, INTERNAL, UNAVAILABLE, DATA_LOSS or UNIMPLEMENTED
response.message.malformed error A response message can’t be decoded as the output type
response.message.incompatible error A response message doesn’t match its type, or misses a REQUIRED field. A field that came with another wire type than its declared type’s (a string where an int64 is declared) is dropped by decoding, as protobuf parsers do, and reported with the code wire-type
response.message.count error A unary or client-streaming call ended OK without exactly one message; a warning for messages before an error status
response.message.deprecated info A response sets a deprecated field or enum value
response.metadata.invalid warn A -bin metadata value isn’t base64, or a metadata name isn’t lowercase letters, digits, -, _ and .

Locations are the message’s index and a JSON pointer, message[0]/items/2/sku; a message that can’t be decoded is located at the field the decoder stopped at.

Report or enforce. With --proxy-to, grpc.validation.request and grpc.validation.response are log unless the configuration sets them: violations are logged and reported, and calls go through as they are, since test suites send invalid calls on purpose. --strict (or enforce) checks each request message before it goes to the upstream (a violation is INVALID_ARGUMENT with a google.rpc.BadRequest) and each upstream message before it goes to the client: a violation ends the call with INTERNAL, the upstream's answer isn't a valid acme.greeter.v1.HelloReply: .... Enforcing holds each message until it is whole, so streams lag by one message. Without --proxy-to, requests are enforced as before.

Coverage and reports. The mock counts the calls of each method of the file (built-in reflection and health aside): covered when a call had no error violation, whatever its status, partial when every call had one, not-tested otherwise. Mocked calls count too, under sources. GET /__apigenie/contract is the report; when the server stops it prints a summary, writes --report and --report-junit (a test case per method, named by service and method, one per undocumented method and one for the coverage threshold) and exits with 5 when --fail-on or --min-coverage (the share of methods) isn’t met. The JSON report has "specification": "grpc", summary (calls, documented, undocumented, sources, messages, violations, coverage.methods, gate), services, methods (each with its kind, status, calls, statuses by status name, messages and violations by check), undocumented and violations (with the method and the direction, in or out).

The upstream is HOST:PORT for cleartext HTTP/2, or https://host[:port] for HTTP/2 over TLS (see TLS). Compressed messages (an encoding the client offered and the server chose) are forwarded unchecked. apigenie test sends GRPC requests of .http files with the same checks and reports.

Scripts and the log

Scripts see a gRPC call as a POST to /package.Service/Method whose operationId is package.Service/Method, with the metadata as headers and the message as a JSON body. In onResponse, the status is in the grpc-status and grpc-message headers and the body is the first message; onMessage gets each message of a stream, with direction in or out.

The log has a grpc event for each call with its status code, and grpc-in and grpc-out events for streamed messages.

GraphQL

A GraphQL schema is served as a GraphQL endpoint, /graphql under --base-path (graphql.path changes it). Every query, mutation and subscription the schema allows is answered, with no resolvers to write.

  • GraphQL over HTTP. POST with application/json ({query, variables, operationName, extensions}) or application/graphql, GET with the same as query parameters (queries only: a mutation over GET gets 405), and batches (a JSON array of requests, graphql.max_batch). Clients that accept application/graphql-response+json get 400 for requests that fail before execution; application/json clients get 200 for every well-formed request, as the specification asks.
  • Validation. Requests are checked against the schema with the rules of graphql-js and its messages (unknown fields and arguments, wrong values and variable types, fragments that can’t apply, fields that can’t be merged…). Errors have locations and extensions.code (GRAPHQL_PARSE_FAILED, GRAPHQL_VALIDATION_FAILED, BAD_USER_INPUT), and the log shows each as a violation.
  • Execution. Fragments, inline fragments, aliases, variables with defaults, @skip, @include and __typename behave as in a real server. Interfaces and unions return one of their object types. A field that fails is null and its error has the field’s path; a non-null field’s null goes up to the nearest nullable one.
  • Introspection. __schema and __type are complete, so GraphiQL, Apollo Sandbox and code generators work against the mock (graphql.introspection: false turns it off). The SDL is at /graphql/schema.graphql and GET /__apigenie/spec.
  • GraphiQL. A browser opening the endpoint gets GraphiQL, loaded from esm.sh (graphql.playground: false turns it off).

Generated data

Values follow the field’s type: enum values, list lengths between graphql.lists.min and max (or a first, last, limit, count… argument, the field’s own or its connection’s), nulls with graphql.null_probability, and realistic values for fields named like email, city, phone, url or firstName, for name, title and genre by the type they belong to (Author.name is a person’s, Book.title a book’s), and for custom scalars by their name (DateTime, Date, URL, UUID, JSON, Long, Decimal, EmailAddress…). --seed repeats a run.

The schema can say more, with graphql-faker’s directives or the mock’s own. They need no definitions in the schema and don’t show in introspection:

type User {
  name: String @fake(type: fullName)
  email: String @fake(type: email, options: {emailProvider: "example.com"})
  joined: String @fake(type: pastDate, options: {dateFormat: "YYYY-MM-DD"})
  role: Role @examples(values: ["ADMIN", "EDITOR"])
  tags: [String!] @listLength(min: 1, max: 3)
  score: Int @mock(min: 0, max: 100)
  code: String @mock(faker: "string.numeric", args: [6])
  nickname: String @mock(nullProbability: 0.5)
}
scalar Money @mock(min: 1, max: 500)
  • @fake(type:, options:) takes the 64 types of graphql-faker and the options most used (minMoney, maxMoney, decimalPlaces, minNumber, maxNumber, precisionNumber, dateFormat, dateFrom, dateTo, loremSize, imageSize, imageKeywords, passwordLength, emailProvider, useFullAddress). Data is English whatever the locale.
  • @examples(values:) picks one of the values; @listLength(min:, max:) sizes a list.
  • @mock takes faker (any faker provider, as x-faker) with args (a list by position or an object by name), value or values, min and max for numbers, length or minLength and maxLength for lists, and nullProbability. On a root field, delay (milliseconds) delays the response and errorRate with error makes it fail; on a subscription field, interval and count pace its events.

graphql.fields does the same from the configuration, for a field (Type.field) or a scalar, over the schema’s directives. A schema that defines a directive of the same name keeps it: the mock leaves it alone.

The store

Objects whose type has an id field are entities: once made, their fields keep their values for the next requests, so { user(id: "7") { name } } answers the same name every time. The usual root fields work on them, by their names and types:

Field What it does
user(id: ID!): User, node(id: ID!): Node The entity with that id; a new one when there is none (graphql.store.unknown_ids: null answers null).
users: [User!]! The users, made up to the list’s length once, then the same.
usersConnection(first:, after:, last:, before:) The users as a Relay connection: edges { cursor node }, nodes, pageInfo, totalCount.
createUser(input: ...), addUser(...), newUser, registerUser… A new user with the input’s fields; payload types get it in their field and clientMutationId back.
updateUser(id:, input:), editUser, setUser… The user, with the input’s fields.
deleteUser(id:), removeUser… Removes the user; returns it, true, its id or a payload.

--dynamic and graphql.store.enabled: false turn the store off; POST /__apigenie/seed empties it, and so does a reload. graphql.store.max_entities (10000) bounds it.

Examples

An example answers a request with data of its own. Operation examples match an operation’s name, its document (layout and comments don’t count) and some of its variables; field examples are the value of a field for some arguments, in any operation:

# schema_examples/users.yaml, next to schema.graphql: an example, or a list of them
- name: ada
  operation: GetUser
  variables: {id: "1"}
  response:
    data: {user: {id: "1", name: Ada Lovelace}}
- name: grace            # a field example
  field: Query.user
  arguments: {id: "2"}
  data: {name: Grace Hopper}

Example data is cut to the request’s selection set, and fields it lacks are generated, so one example serves every query of its operation. Examples come from graphql.examples in the configuration, from graphql.examples_files (files and directories of .json, .yaml and .yml, each an example or a list of them) and from the directory named after the schema (schema.graphql takes schema_examples/). Prefer: example=ada picks one by name; --dynamic and Prefer: dynamic=true leave them out. An example’s errors and extensions are added to the response, and status sets the HTTP status.

Scenarios and errors

Rules of scenarios and operations match GraphQL operations by the operation’s name (operationId: GetUser) or by a root field (operationId: Query.user, or tag: Query.user). Besides status, headers, body (the whole response), example, dynamic and the simulation, they take errors:

scenarios:
  users-down:
    rules:
      - match: {operationId: Query.user}
        errors:
          - message: The users service is down.
            path: [user]
            extensions: {code: UNAVAILABLE}
          - Partial results      # no path: added to the response's errors

An error with a path fails that field: it is null (or null up to the nearest nullable field) and the error has its locations. Prefer: code=, example=, dynamic=, delay=, drop= and scenario= apply as for OpenAPI.

Subscriptions

WebSocket clients of the endpoint speak graphql-transport-ws (the graphql-ws library: Apollo Client, urql, GraphiQL) or the legacy graphql-ws of subscriptions-transport-ws, by the subprotocol they offer. A subscription gets an event every graphql.subscriptions.interval (1000 ms), each a fresh execution of its operation, until count events (0: until the client completes it); a field’s @mock(interval:, count:) wins. Queries and mutations over WebSocket get one result. A request that accepts text/event-stream gets the same as server-sent events (next events, then complete: graphql-sse’s distinct connections mode). The log has ws-open, ws-in, ws-out and ws-close events; the server’s stop closes the clients with 1001 and a reload with 1012.

Contract testing of a GraphQL API

With --proxy-to, the GraphQL endpoint goes to a real GraphQL server, and the mock checks every request against the schema and every answer against the schema and the request’s selection set. Point your frontend, your tests or a load test at the mock, and the traffic becomes a contract test of the server:

apigenie-server schema.graphql --proxy-to http://localhost:4000/graphql --report contract.json --fail-on errors
Listening on http://127.0.0.1:4010/graphql (spec: schema.graphql, 12 operations)
Proxy: http://localhost:4000/graphql (requests: log, responses: log, unknown routes: forward, mode: always)

The URL’s path is the server’s endpoint (HOST:PORT alone keeps the mock’s, /graphql). The upstream gets the request as it came (a batch stays a batch), without the mock’s __ query parameters and X-Apigenie-Mock-* headers, with Via and X-Forwarded-*, and Accept-Encoding: identity. GraphiQL, /graphql/schema.graphql, the admin API and OPTIONS stay local. Requests for other paths are forwarded unchecked (request.path.unknown), or answered 404 with --proxy-unknown reject. A proxy makes up no subscription events: WebSocket and server-sent events subscriptions aren’t served.

What is checked. Requests as the mock checks them (graphql-js’s rules and messages), and each answer as graphql-js would have produced it, field by field:

Check Severity When
request.body.malformed error Not a GraphQL request: not JSON, no query, an unsupported content type.
request.query.malformed, request.operation.unknown error The query can’t be parsed; operationName names no operation, or is missing.
request.query.invalid, request.variables.incompatible error The operation isn’t valid against the schema; a variable doesn’t match its type.
request.method.incompatible error A mutation over GET.
request.field.deprecated info A deprecated field, argument, input field or enum value is used.
request.path.unknown warn A request for another path than the endpoint (forwarded unchecked).
response.status.incompatible error A 1xx or 3xx status, or a 4xx with data: an executed operation that says it failed.
response.status.server-error info The server answered with 5xx.
response.content-type.incompatible error Neither application/json nor application/graphql-response+json.
response.body.malformed error Not JSON, or a batch not answered with a list of as many results.
response.shape.incompatible error Not a map of data, errors and extensions: other keys, neither data nor errors, data: null without errors, empty errors.
response.errors.incompatible error An error without a string message, with locations that aren’t {line, column}, a path of other than names and indexes, extensions that aren’t an object.
response.errors.unexpected, response.errors.path-unknown warn An error’s entries outside extensions; a path that names no field the operation selects.
response.data.incompatible error A selected field (or alias) missing; null in a non-null position that no error’s path explains; a list, an object or a scalar where the type says otherwise; Int beyond 32 bits, a non-finite Float, a String, Boolean or ID of another kind, an unknown enum value; a __typename that isn’t a possible type.
response.data.unexpected warn A key the operation didn’t select.
response.data.typename-missing info An object of an interface or union came without __typename: only the selections outside type conditions are checked.
response.data.unvalidatable info Nesting deeper than 64, or more than 100 violations in one answer.
response.field-error warn The server ran the operation, but fields failed: errors next to data (null or partial), one per error, e.g. the field book failed: not implemented. Valid GraphQL, but a contract test shouldn’t pass it silently; --fail-on warnings (or contract.severity) fails on it.
response.request-error warn The server didn’t execute the operation (authentication, persisted queries…).
response.schema-drift error The server rejected an operation the schema allows (GRAPHQL_VALIDATION_FAILED, “Cannot query field…”): its schema differs.
schema.drift, schema.addition error, info The server’s schema (introspected) lacks or changed something of the schema; has more.
schema.unchecked info The server’s schema can’t be introspected.

Fragments, inline fragments, @skip and @include (with the request’s variables), aliases and merged fields are followed as the server must; custom scalars take any JSON value. contract.severity changes a check’s severity or turns it off, as for OpenAPI.

The server’s schema. With the first request it forwards, the proxy also sends the server the usual introspection query (with that request’s Authorization, Cookie and X-Api-Key), turns the answer into a schema and compares it with the served one, as apigenie diff does: what the server lacks or changed incompatibly is schema.drift (located by Type.field), what it adds schema.addition. The log has a server event with the result (schema of http://localhost:4000/graphql: 1 breaking difference, 2 additions), and the report a schema section. A server that isn’t up yet is asked again with the next request; POST /__apigenie/contract/schema-check asks now. proxy.schema_check: false turns it off.

Report or enforce. As for OpenAPI, the proxy reports by default: answers carry X-Apigenie-Mock-Violations and X-Apigenie-Mock-Violations-Detail (locations such as ["response", "body", "/data/book/title"], /2/data/... for the third result of a batch). With --strict, a request with errors isn’t forwarded: it gets the GraphQL errors the mock answers with (GRAPHQL_VALIDATION_FAILED…). An answer with errors is replaced by the invalid-upstream-response 502 problem. Warnings and notes never block.

Fallback mode. --proxy-mode fallback (proxy.mode) lets scenario rules, examples and the scripts’ onGraphQL answer first, as for a mock; what nothing answers goes to the server. A batch goes to the server whole as soon as one of its operations does.

Recording. --record-dir DIR writes the answer to each valid operation as an example file (GetBook-1.yaml: name, operation, query, variables, response, and status when not 200), which the mock serves as it is: point graphql.examples_files at the directory, or name it after the schema (schema_examples/). Bodies are recorded as they are.

Coverage and reports. The mock counts the schema’s fields that requests selected (every field of its object and interface types, as GraphQL Inspector’s coverage does) and its root fields (Query.book): covered, partial (some fields under it never selected) or not-tested. Mocked traffic counts too. GET /__apigenie/contract is the report; when the server stops it prints a summary, writes --report and --report-junit (a test case per root field, one for the server’s schema, one for the coverage threshold) and exits with 5 when --fail-on or --min-coverage (the share of fields) isn’t met. The JSON report has summary, schema, rootFields, fields, uncoveredFields, operations (by name), otherRoutes and violations.

Scripts

A script is an ES module whose default export has any of five hooks:

import { limit } from './settings.mjs';

export default {
  // before the document's operations: a response answers the request
  onRequest(req, ctx) {
    if (req.path === '/pets/13') {
      return ctx.response(404, { title: 'No such pet' });
    }
  },
  // the response about to be sent: change it in place
  onResponse(req, res, ctx) {
    if (req.operationId === 'listPets') {
      res.body = res.body.slice(0, limit);
      res.headers['x-total-count'] = '3';
      res.delay = 250;
    }
  },
  // AsyncAPI messages, in and out: change msg.payload, or return false to drop the message
  onMessage(msg, ctx) {
    if (msg.direction === 'out') msg.payload.sentAt = ctx.now();
  },
  // GraphQL operations (each subscription event too): a result answers, its data completed
  onGraphQL(operation, ctx) {
    if (operation.name === 'GetUser') {
      return { data: { user: { id: operation.variables.id, name: ctx.fake('person.fullName') } } };
    }
  },
  // after the scripts were loaded, at the start and after each reload
  onReload(ctx) {},
};
  • req has method, path, query (an object; repeated parameters are arrays), headers (lower-case names), body (parsed JSON, or the text), rawBody, operationId and route (in onResponse), remote and connectionId.
  • res has status, headers, body, source, and delay (milliseconds) and drop (reset, close, timeout, truncate) to set. A response returned by onRequest has the same fields.
  • msg has direction (in or out), channel, operation, message, contentType and payload.
  • operation has name, type (query, mutation, subscription), query, variables and rootFields (["Query.user"]). The result’s data is cut to the selection set and completed with generated values, like an example’s.
  • ctx has state (get, set, delete and keys of JSON values kept between requests, the admin API’s /state), db (collections of documents, see Document store), log(level, ...values), random() (seeded by --seed), now(), response(status, body, headers), fake(name, ...arguments) (the faker providers of x-faker, with arguments by position and a last object by name: fake('string.alpha', {length: 4})) and scenarios (activate, deactivate, active).

Scripts run in a sandbox: the language only, modules next to the script, no file system, network or timers. Each call has scripting.timeout_ms (1000) and scripting.memory_limit_mb (64); a hook that throws or runs out of time is logged, and answered with 500 when scripting.fail_mode is error. --watch reloads changed scripts, and a broken script keeps the previous version.

Session recording

--record DIR keeps a run whole: every exchange the server answered, of every service and every protocol, in the order it happened, each marked with the test that sent it, as files a replay can read and a reviewer can diff:

apigenie-server openapi.yaml --proxy-to http://localhost:8080 --record sessions/checkout
apigenie-server --config apigenie-server.yaml --record sessions/checkout   # several services
apigenie-server session export sessions/checkout --har checkout.har
sessions/checkout/
  session.yaml                     # the session and the exchanges in order
  pets/0001-getPet.yaml            # an HTTP exchange of the service pets (a stub)
  pets/0002-createPet.yaml
  events/0003-ws-in-sendMessage.yaml   # a WebSocket message from a client
  events/0004-ws-out-sendMessage.yaml  # a message to it
  chat/0005-Chat.Talk.yaml         # a gRPC call with its messages (a stub)

A server of one document writes the files in DIR itself (--stubs DIR and the GraphQL examples skip session.yaml). session.yaml names the session (the directory’s name unless record.name or /record/start says), the server, when it started and stopped, the services (name, document, specification, the SHA-256 of the document’s bytes, mount) and the exchanges:

name: checkout
server: apigenie-server 1.0.0
started: 2026-10-13T10:00:00.000Z
stopped: 2026-10-13T10:00:42.512Z
services:
  - {name: pets, spec: pets.yaml, specification: openapi, sha256: cfada1c0..., mount: /pets}
  - {name: events, spec: events.yaml, specification: asyncapi, sha256: 9b2f..., mount: /events}
exchanges:
  - {sequence: 1, id: 2, service: pets, test: checkout-happy, kind: http, time: 2026-10-13T10:00:01.555Z,
     durationMs: 3, source: proxy, summary: GET /pets/1 200, file: pets/0001-getPet.yaml}
  - {sequence: 3, id: 7, service: events, test: checkout-happy, kind: ws, time: ..., source: mock,
     summary: ws-in rooms/7 notice, file: events/0003-ws-in-sendMessage.yaml}

The exchange files. Each has a session note (the index’s line) next to the protocol’s own form, so what already reads that form reads the file:

  • http: {session, stubs: [stub]}, the stub form of --record-dir, so --stubs sessions/checkout/pets makes a mock answer with the service’s exchanges as before (the first matching stub answers, whatever the test). OpenAPI exchanges, AsyncAPI HTTP channels and GraphQL requests that aren’t one operation answered with JSON (batches, errors, GraphiQL). The answer is kept as the client got it, less the CORS headers the server adds to every answer. The request’s X-Apigenie-Test header is left out.

  • graphql: one operation and its answer, in the example form (name, operation, query, variables, response, status) the mock’s examples load; the note’s path is the endpoint.

  • grpc: {session, stubs: [stub]}, the stub form of a .proto file’s --record-dir, with the whole streams (at most 100 messages per side); the note’s order ([in, out, in, out]) says which side each message came from, code the status. The x-apigenie-test metadata is left out.

  • ws and mqtt: one file per message of either side:

    session: {sequence: 3, id: 7, service: events, test: checkout-happy, kind: ws, time: ..., source: mock}
    message:
      direction: in                  # from the client; out: to it
      channel: rooms/7               # the channel's address, and the route it matched
      route: rooms/{roomId}
      operation: sendMessage
      name: notice
      contentType: application/json
      connection: 4
      json: {text: hi}               # text: for other text, base64: for bytes

Tests. An exchange’s test is the X-Apigenie-Test header of the request or of the WebSocket handshake, or the x-apigenie-test metadata of a gRPC call; MQTT messages have none of their own. Without one, it is the current mark: POST /__apigenie/record/mark {"test": "checkout-happy"} names the test of the exchanges to come ({} clears it), for clients that can’t send the header. A Playwright suite sends the header with page.setExtraHTTPHeaders and marks the test in beforeEach.

Order. sequence numbers the exchanges in the order they completed, which is the log’s order; a request still in flight when a short one is answered comes after it. Each exchange keeps its time and durationMs (a gRPC call its started), so a tool can sort by arrival. What isn’t an exchange isn’t recorded: requests the client abandoned or the simulation dropped, CORS preflights, the admin API’s own traffic, the dashboard.

Redaction. The values of Authorization, Cookie, Set-Cookie, Proxy-Authorization and X-Api-Key are written as [redacted], as in --record-dir. --record-redact (or record.redact) adds header and metadata names, and JSON pointers, which start with /, to values of JSON bodies, message payloads, gRPC messages and GraphQL variables and answers: --record-redact X-Session --record-redact /owner/password. A pointer is matched exactly (/items/0/key is the first item’s key). A redacted request body no longer matches on replay.

Start and stop. --record records from the start and writes session.yaml when the server stops; the startup line says Recording the session to DIR (record in JSON). The admin API records a part of a run: POST /__apigenie/record/start {"dir": "sessions/run-7", "name": "run 7"} (dir defaults to record.dir; 409 while a session is on, 400 for a directory that isn’t empty), POST /__apigenie/record/stop (the session’s dir, name, exchanges, file; 409 without one), GET /__apigenie/record (recording, and the same while one is on, test for the mark). The dashboard’s Traffic screen has Record session and Save session. A session has at most 10 000 exchanges: past that it counts on but writes no more files, with one warning in the log and truncated: true in the index. When the server dies before writing session.yaml, the exchange files tell: session export rebuilds the index from their notes.

Export. apigenie-server session export DIR --har FILE writes the HTTP exchanges, GraphQL operations (as POST requests to the endpoint) and gRPC calls (as POST requests with application/grpc+json bodies, the messages as JSON arrays, and a grpc-status header) as a HAR 1.2 file, in the session’s order, each entry with _apigenie.service, test, sequence and kind; WebSocket and MQTT messages have no form in HAR and are left out (the command says how many). URLs keep the services’ mount prefixes.

The design is ADR-168.

Session replay

--replay DIR serves the services of a recorded session the way they answered when it was recorded, without the real servers: each request gets the next unused recorded exchange of its test that matches it, so a test suite replays the same run every time, and fails when the application asks for something the recording doesn’t have.

apigenie-server openapi.yaml --record sessions/checkout --proxy-to http://localhost:8080   # once
apigenie-server openapi.yaml --replay sessions/checkout                                    # every run
apigenie-server --config apigenie-server.yaml   # replay: {dir: sessions/checkout}, several services

Tests and order. A request’s test is its X-Apigenie-Test header (of a WebSocket handshake too) or a gRPC call’s x-apigenie-test metadata. It draws from the exchanges recorded under that test, in their order: the first GET /pets/1 of test checkout gets the first one recorded for it, the second the second, and a third matches none. Tests replay side by side, each with its own counters, so the parallel workers of a suite don’t take each other’s exchanges. Requests without a tag share one test that draws from the whole session in order, for suites that don’t tag their requests (and MQTT, whose clients send none). POST /__apigenie/replay/reset {"test": "checkout"} (or with the header) restarts a test’s counters, at the start of each test; without a test it restarts every test.

What is compared. The method and path; the query, in any order; the headers the recording kept (not Host, User-Agent, Accept-Encoding and the other headers a client changes from run to run: Date, Origin, Referer, Accept-Language, Cache-Control, the Sec-* headers, traceparent, X-Request-Id, …), a recorded [redacted] value standing for any; and the body, JSON regardless of key order and number formatting. Left out:

  • strings that are both RFC 3339 dates or date-times (2026-10-13, 2026-10-13T10:00:00Z), in bodies and query parameters;
  • the fields of an OpenAPI request body that its schema marks readOnly, which a client sends back as it got them (id, createdAt);
  • what --replay-ignore (or replay.ignore) names: headers, and JSON pointers into bodies, messages, gRPC messages and GraphQL variables (--replay-ignore /cartId --replay-ignore /items/*/addedAt; * stands for any index or key).

When nothing matches. --replay-missing fail, the default, answers with a 502 application/problem+json naming the closest recording (of the same test and kind; the same operation first, then the fewest differences) and how the request differs from it:

{
  "type": "https://apigenie.pl/mock/errors#replay-missing",
  "title": "No recorded exchange matches",
  "status": 502,
  "detail": "no recorded exchange of test 'checkout' matches POST /carts; the closest, pets/0004-createCart.yaml, differs at /items/0/quantity",
  "test": "checkout",
  "closest": {"file": "pets/0004-createCart.yaml", "sequence": 4, "summary": "POST /carts 201", "used": false},
  "differences": [{"where": "/items/0/quantity", "recorded": 1, "received": 2}]
}

used: true says the matching exchange was replayed already in that test (a request sent once more than when it was recorded). generate lets the mock answer (stubs, the document store, examples, generated values); passthrough forwards the request to --proxy-to, and with --replay-update records the run (replayed and forwarded exchanges alike) into DIR.update and puts it in place of DIR when the server stops: the way to refresh a recording after the application changed on purpose. A request that matched none is reported either way.

Every protocol.

  • HTTP (OpenAPI, AsyncAPI HTTP channels, GraphQL batches): the recorded status, headers and body, with X-Apigenie-Mock-Source: replay:0002-getPet.yaml.
  • GraphQL queries and mutations: by operation name, query (formatting aside) and variables; the recorded answer. One none matches gets a REPLAY_MISSING error (status 502) with the same members in extensions.replay.
  • GraphQL subscriptions (graphql-transport-ws, graphql-ws, SSE) and AsyncAPI WebSocket channels: a connection replays a recorded connection of its route and test as the server’s side: what the server sent first (connection_ack, the first scheduled messages) at once, then, after each message the client sends that is the recorded one, the messages that followed it. The subscriptions’ ids are the client’s own. A message that isn’t the recorded one gets the problem as a message (GraphQL: an error message). The scheduled messages and generated replies are off.
  • MQTT: a subscriber of a topic gets the messages a subscriber got before any client published; a published message that is the next recorded one gets the messages that followed it.
  • gRPC: unary and server-streaming calls by their metadata and first request, client streams by all their requests, bidirectional streams in their recorded interleaving (the session’s order): a message the client sends that isn’t the recorded one fails the call. A call none matches fails with FAILED_PRECONDITION, the problem in the apigenie-replay-problem trailer.

WebSocket and MQTT messages can’t be forwarded one at a time: under passthrough they are answered as under fail.

The report. When the server stops it prints how many recorded exchanges were replayed, the ones never replayed and the requests that matched none, with their tests (with --log-format json, one {"event": "replay", ...} line), and exits with status 5 if there are any; --replay-update prints the report but doesn’t fail. GET /__apigenie/replay gives the same report at any time, with passed and the exchanges each test used since its reset. The startup line says Replaying the session in DIR (N exchanges, missing: fail) (replay in JSON). A document that changed since the session was recorded (another SHA-256) and a service the session lacks are warned about at startup. --stubs, the GraphQL examples and the gRPC stubs stay: the per-exchange forms, where the first match answers whatever the test.

The design is ADR-169.

Replay in a test suite

A Playwright suite recorded against the real backend once replays it in every run, each test with its own exchanges. The server starts with the suite and every request of a test carries its name:

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  webServer: {
    command: "apigenie-server openapi.yaml --port 4010 --replay recordings/checkout",
    url: "http://127.0.0.1:4010/__apigenie",
    reuseExistingServer: false,
  },
  globalTeardown: "./replay-teardown.ts",
});
// checkout.spec.ts
import { expect, test } from "@playwright/test";

const admin = "http://127.0.0.1:4010/__apigenie";

test.beforeEach(async ({ page }, testInfo) => {
  const name = testInfo.titlePath.join(" > ");
  // every request of the page names the test, the API's included
  await page.setExtraHTTPHeaders({ "X-Apigenie-Test": name });
  // the test starts at its first recorded exchange, also when it is retried
  await fetch(`${admin}/replay/reset`, { method: "POST", body: JSON.stringify({ test: name }) });
});

test("checks out a cart", async ({ page }) => {
  await page.goto("/cart");
  await page.getByRole("button", { name: "Check out" }).click();
  await expect(page.getByText("Thank you")).toBeVisible();
});
// replay-teardown.ts: the run fails when the application drifted from the recording
export default async function teardown() {
  const report = await (await fetch("http://127.0.0.1:4010/__apigenie/replay")).json();
  if (!report.passed) {
    throw new Error(
      `the replay failed: ${report.neverReplayedCount} exchanges never replayed, ` +
        `${report.unmatchedCount} requests matched none: ${JSON.stringify(report.unmatched, null, 2)}`,
    );
  }
}

The server answers the CORS preflight of the header for a page served from another origin. To record the suite, run it once with --record recordings/checkout --proxy-to https://staging.example.com in place of --replay: the same header tags the recorded exchanges with the tests. Playwright stops its web servers without letting them finish, so such a recording has no session.yaml; the replay reads the index from the exchange files, as session export does. On CI, a request the recording lacks gets a 502, so the test that sent it fails at that step, with the closest recording and the differences in the response; the teardown fails the run when a test no longer sends what was recorded (exchanges never replayed). A job that runs the server itself sees the same as its exit status:

apigenie-server openapi.yaml --port 4010 --replay recordings/checkout --log-format json > replay.log &
server=$!
npx playwright test
curl -s -X POST http://127.0.0.1:4010/__apigenie/shutdown
wait $server   # 5 when the run drifted from the recording; replay.log has the report

Several services

One apigenie-server serves several documents at once: an application’s backends, a gateway’s services, the HTTP and the messaging side of a system. Each document is a service with a name and a mount, where its clients reach it. Several SPECs on the command line are services named after their files (pets.openapi.yaml is pets) and mounted at /<name>; a .proto file gets a port of its own. A single SPEC is served as before, without a prefix. The configuration’s services list says more:

version: "1.0"
server: {port: 4010}
validation: {request: enforce}   # the services' defaults
services:
  - name: pets
    spec: pets.yaml               # mounted at /pets
  - name: orders
    spec: orders.yaml
    mount: /shop/orders
    proxy: {target: "http://localhost:8080", mode: fallback, record_har: session.har}
    scenarios:
      outage: {rules: [{status: 503}]}
  - name: events
    spec: events.asyncapi.yaml
    mount: {host: events.localhost}
  - name: greeter
    spec: greeter.proto
    mount: {port: 50051}
  • What a service sets: name and spec (required), mount, and the settings of its document: proxy, stubs, scenarios, overrides, validation, security, operations, responses, generation, headers, asyncapi, graphql and grpc. These keys at the top of the file are every service’s defaults; a service’s stubs, scenarios, overrides and proxy replace the defaults rather than adding to them. server, cors, simulation, tls, admin, log, contract, scripts, db, request_overrides and asyncapi.mqtt_port are the server’s.
  • Mounts. A path prefix (/shop/orders, default /<name>) on the server’s port: the service gets the requests below it, without the prefix, so its own base path and routes are unchanged. {host: events.localhost} takes the requests whose Host header names that host (any port), and may add a prefix. {port: N} is a listener of the service’s own (0 picks a free one), which a .proto file’s service needs: gRPC clients can’t add a prefix. Requests the server’s port gets go to the services by host first, then to the one with the longest prefix (prefixes end at a /); the others get 404 no-service, which lists the mounts. Two services can’t share a name, nor can one’s mount hide another’s (/shop and /shop/orders), nor can a mount cover the admin API.
  • Proxies forward the path without the mount’s prefix, after their own (--proxy-to’s path). --proxy-to, --stubs, --record-dir and --record-har name one document’s upstream and recordings, so with several services they are refused: set them per service.
  • WebSocket: an AsyncAPI service’s channels and a GraphQL service’s subscriptions share the server’s port, below the service’s mount (ws://127.0.0.1:4010/events/v1/rooms/1).
  • MQTT: one broker for every service (--mqtt-port). Topics are served as the documents name them, unless the MQTT channels of two services can match the same topic (doors/+/open in both, or status and #): then the topics of each of those services start with its name (front/doors/7/open). The startup line shows the prefix (MQTT topics under front/). An mqtt:// upstream goes with one document.
  • The log names the service of each event ("service": "orders" in JSON, [orders] in the stylish log); event paths are within the service, without its prefix. Ids are unique across the services.
  • The admin API (/__apigenie) covers every service: GET /__apigenie/services lists them with their mounts and URLs, the items of /routes (full paths), /scenarios, /overrides, /log and the status’s documents name their service, and ?service=NAME narrows any endpoint to one service. An endpoint of one service’s resources (a scenario of two services, PUT /overrides, /state, /db, /spec) needs it when more than one service could be meant, and answers 400 admin-service-required otherwise; a service’s own endpoints (the gRPC ones, /contract/violations) answer 404 without it. PUT /simulation, POST /seed and POST /reload apply to every service unless one is named.
  • The contract report (--report, GET /__apigenie/contract) has a section per service in sections, each a whole report of its document with its name and mount, and a summary that counts them together: requests, violations and coverage.combined, the units of every service added up (OpenAPI branches, GraphQL fields, gRPC methods, AsyncAPI messages). --fail-on and --min-coverage apply to that summary: a violation of any service fails the run, and the combined coverage must reach the threshold (the reason names the service with the lowest). The JUnit report has a suite per service.
  • The HAR file of the proxies is one per server: every service that records names the same file, and each entry’s _apigenie.service names its service; URLs keep the mount’s prefix.
  • Reloads (--watch): a service reloads when its document, stubs or scripts change; a change of the configuration reloads every service. Adding or removing a service needs a restart.

Document store

Scripts keep documents in collections: ctx.db.<collection>, made the first time a script names it. Writes are kept in memory for the run (see --db-file), shared by every script and shown by the admin API’s /__apigenie/db.

export default {
  onRequest(req, ctx) {
    if (req.method === 'POST' && req.path === '/notes') {
      return { status: 201, body: ctx.db.notes.insert(req.body) };
    }
    if (req.path === '/notes') {
      return { body: ctx.db.notes.find({ tags: { $contains: 'home' } }, { sort: '-id', limit: 10 }) };
    }
  },
};
Method Returns
insert(document) The document as stored, with its id.
get(id) The document, or undefined. "3" finds 3.
find(where, {limit, offset, sort}) An array of documents.
count(where) A number.
all() Every document, in the order they were inserted.
update(id, patch) The document after a JSON merge patch (null removes a property), or undefined for an unknown id. The id stays.
replace(id, document) The new document with the old id, or undefined.
delete(id) true when there was such a document.
clear() How many documents went.

ctx.db.collections() lists the collections and ctx.db.reset() (reset('notes') for one) puts them back to their seeds.

  • Queries. where is an object of fields and values, all of which must match: {status: 'open'}, {'address.city': 'Kraków'} (a path), or operators: $eq, $ne, $in (an array of values), $gt, $gte, $lt, $lte (numbers and strings), $contains (a substring, or an item of an array): {weight: {$gte: 5, $lt: 20}}. Numbers and strings are never equal. sort is 'field', '-field' (descending), 'a,-b', an array of them or [{field, descending}]; missing values sort last. Anything fancier is a JavaScript filter on a limited result.
  • Ids. A document keeps its own id (a string or an integer), else gets the next one: 1, 2, 3… ("1", "2"… for a resource whose id is a string, a UUID made from the run’s --seed for format: uuid). An id in use is refused, and the script’s call throws.
  • Schemas. The collection of a resource is bound to its schema: a new document gets the readOnly properties it lacks (generated, like createdAt), and every write is checked. A document that doesn’t match is kept and reported in the log, or refused (422 for a request, a throw for a script) when validation.response is enforce (--strict).
  • Seeds. db.seed names a file of documents (a JSON or YAML array) per collection, or a directory of them (seed/shipments.json seeds shipments). A resource’s collection without one is seeded from its list operation’s example, else with db.generate (3) documents generated with the run’s seed. ctx.db.reset() and POST /__apigenie/db/reset go back to the seeds, not to nothing. A seed that doesn’t match the schema is kept and reported at the start; a file that can’t be read stops the server with exit status 2.
  • Tests. A request with an X-Apigenie-Test: <id> header reads and writes a namespace of its own, which starts as a copy of the seeds: parallel test runs don’t see each other’s documents. The admin API’s /db endpoints take the same header (or ?test=<id>).
  • Snapshots. PUT /__apigenie/db/snapshots/{name} copies the documents of every collection, POST /__apigenie/db/snapshots/{name}/restore puts them back.
  • Persistence. In memory by default, so a restart is a clean slate. With --db-file (or db.file), the store is read from that JSON file at the start (a file that can’t be read is reported, and the store starts empty) and written there when the server stops or on POST /__apigenie/db/save. The file holds formatVersion 1, the collections’ seeds, the documents of each namespace and the snapshots. Seed files don’t replace what the file holds.
  • Log. Every write is a db event: the collection, insert 3, update 3, delete 3, clear 2 documents, reset, restore <name>, seed 3 documents from file, who wrote (script:mock.mjs, admin) and the test’s namespace (test). onMessage and onGraphQL use the default namespace.
  • After a reload. The documents stay; collections are bound again, new ones seeded, and documents that no longer match their schema are reported.
  • Several services. Each service has a store of its own, which its scripts’ ctx.db and its resources use; db.seed seeds every service’s collections, and the admin API’s /db endpoints take ?service= when several could be meant. --db-file (and db.file) keep the store of a server of one document; with several services it exits with status 2.
  • Limits. A collection keeps up to db.max_documents (10,000) documents per namespace. The store is not a database: no transactions, relations or durability promises.

CRUD resources

With db.crud: auto (or --crud), a path and the path of its items, such as /shipments and /shipments/{id}, make a resource whose collection (shipments) its operations read and write; no script is needed. Without it (db.crud: explicit, the default), only the paths an x-apigenie-mock: {resource: ...} extension names are resources; db.crud: off serves none.

Operation Answer
GET /shipments The documents, paged and filtered by the operation’s query parameters.
POST /shipments The document stored, with its id and readOnly properties; 201 when the operation declares it.
GET /shipments/{id} The document, or 404.
PUT /shipments/{id} The document replaced (the id and readOnly properties stay), or 404.
PATCH /shipments/{id} The document after a JSON merge patch, or 404.
DELETE /shipments/{id} 204 when the operation declares it, else the document that went; 404 for an unknown id.
  • The schema is the list response’s items, or the items of its object’s array property (an envelope such as {data: [...], total: 3}: one array property, or the one called data, items, results or like the collection), else the item response’s or the request body’s. The id is the schema’s id property, else the path parameter when it is a property; integers count, strings with format: uuid are UUIDs. Answers leave writeOnly properties out.
  • Lists page by the list operation’s parameters: offset and limit, page (from 1) and per_page (page_size, pageSize, size, limit), or cursor (after, page_token) and a size, the size’s default when not given (20 without one). An envelope gets the page in its array property, and total (totalCount, total_count…), hasMore, nextCursor, page, offset and limit filled in when it declares them; its other members are generated. Other query parameters named like a property of the documents filter by it (?status=open), and sort (order_by) sorts: -createdAt, status:desc,id.
  • What comes first. Requests are validated before the store sees them (422 under validation.request: enforce). A request’s Prefer (or __ parameters), a scenario’s or an override’s rule, an extension that sets the status, an example or a body, and stubs answer instead of the store. The store’s answers are validated like any other, and say X-Apigenie-Mock-Source: db.
  • The extension, on a path item or an operation of either path: resource: shipments (the collection’s name), resource: false (not a resource, also with db.crud: auto) or resource: {name: parcels, id: code, schema: Parcel, list: entries} (the id property, the schema under components/schemas or as a #/ reference, the envelope’s array property).
  • Proxies. A document served with --proxy-to has no resources: the server’s state is the upstream’s.
paths:
  /shipments:
    x-apigenie-mock: {resource: {name: shipments, schema: Shipment}}
    get: ...
  /shipments/{id}:
    get: ...

Configuration

apigenie-server reads --config, else apigenie-server.yaml (.yml, .json), then apigenie-mock.yaml (.yml, .json; the name before the rename, still read) or .apigenie.yaml in the current directory, else the shared apigenie.yaml of the project (in the current directory or a parent, up to the root of the repository; see configuration). In a file of its own the settings are at the top level; in a shared configuration they are under commands.mock. The command line wins over the environment variables, which win over the file; apigenie config --for mock --origin shows which one set each value. The JSON Schema of a file of its own is https://apigenie.pl/schemas/apigenie-server-config/latest.json.

version: "1.0"
specs: openapi.yaml
server: {host: 127.0.0.1, port: 4010, base_path: /api, trailing_slash: ignore, watch: true}
                         # strict_base_path: serve the paths only under the prefix (true with base_path)
cors: {enabled: true, origins: ["*"], allow_credentials: true, expose_headers: ["*"], max_age: 600}
responses: {mode: static}
headers: {diagnostics: true}
generation:
  seed: 42
  max_depth: 8
  null_probability: 0
  smart_names: false
  limits: {min_items: 1, max_items: 5, min_length: 1, max_length: 32, min_properties: 0, max_properties: 10, minimum: 0, maximum: 1000}
validation: {request: enforce, response: log, formats: true, unknown_query_parameters: allow}
security:
  mode: check            # off, check (presence) or enforce (the credentials below)
  credentials: {api_keys: {apiKey: [secret]}, bearer_tokens: [token], basic: [{user: u, password: p}]}
request_overrides: {header: true, query_params: true}
simulation: {latency: 100-300, jitter: 20, drop: {probability: 0.01, mode: reset}}
operations:
  getPet: {status: 404}
  GET /pets: {latency: 1000}
stubs: [recorded]          # OpenAPI: recorded exchanges to answer with (--stubs)
scenarios:
  outage:
    description: the backend is down
    rules:
      - match: {tag: pets}
        status: 503
        headers: {Retry-After: "30"}
        body: {title: Service unavailable}
overrides: []
scripts: [mock.mjs]      # relative to the configuration file
scripting: {timeout_ms: 1000, memory_limit_mb: 64, fail_mode: log}
asyncapi:
  server: null           # the one server to serve
  publish: {enabled: true, interval: 1000, count: 0, jitter: 0}
  validate_incoming: enforce   # off, log or enforce
  mqtt_port: 1883        # 8883 when tls.cert serves MQTT over TLS
grpc:
  proto_path: [protos]   # relative to the configuration file
  stubs: [stubs]
  max_message_bytes: 4194304
  reflection: true
  health: true
  validation: {request: enforce, response: log}   # off, log or enforce; both log with --proxy-to unless set here
  generation: true       # false: UNIMPLEMENTED when nothing answers
  proxy: {target: null, mode: fallback, record_dir: null, redact: [authorization, cookie, x-api-key, proxy-authorization]}   # target: HOST:PORT (h2c) or https://host[:port] (TLS)
  history_size: 1000
graphql:
  path: /graphql         # under server.base_path
  introspection: true
  playground: true       # GraphiQL
  schema_endpoint: true  # /graphql/schema.graphql
  allow_get_mutations: false
  lists: {min: 1, max: 3}
  null_probability: 0
  max_depth: 0           # deeper operations are rejected; 0 for no limit
  max_batch: 10
  fields:
    User.email: {faker: internet.email}
    Book.pages: {min: 50, max: 900}
    Status: {values: [OPEN, CLOSED]}
  examples: []           # as the files of examples_files
  examples_files: [more_examples]
  discover_examples: true      # schema_examples/ next to schema.graphql
  store: {enabled: true, max_entities: 10000, unknown_ids: generate}
  subscriptions: {enabled: true, interval: 1000, count: 0, jitter: 0, legacy_protocol: true, init_timeout: 3000}
proxy:                   # OpenAPI, GraphQL: contract testing against a real server (gRPC: grpc.proxy)
  target: http://localhost:8080   # or https://, HOST:PORT, ws:// or wss:// (AsyncAPI); OpenAPI, AsyncAPI: a path is put before the requests' paths,
                                  # AsyncAPI: a list may add an mqtt:// or mqtts:// broker: [ws://localhost:9000, mqtt://localhost:1884]
                                  # GraphQL: the path is the server's endpoint
  unknown_routes: forward         # or reject (404, 405)
  mode: always                    # or fallback: OpenAPI: the mock answers when the server can't be
                                  # reached, times out or answers 404/501 for an operation;
                                  # GraphQL: examples, scenarios, scripts answer first
  schema_check: true              # GraphQL: introspect the server and compare the schemas
  record_dir: null                # OpenAPI: write the exchanges as stub files there;
                                  # GraphQL: write the answers as examples there
  record_har: null                # OpenAPI: write the session as a HAR file
  redact: [authorization, cookie, set-cookie, proxy-authorization, x-api-key]
  timeout_ms: 30000
  connect_timeout_ms: 5000
  max_in_flight: 64
  max_response_bytes: 16777216
contract:
  report: contract.json           # relative to the configuration file
  report_junit: contract.xml
  fail_on: none                   # none, errors or warnings: exit status 5
  min_coverage: 0                 # percent of operations, request bodies and responses (GraphQL: fields; AsyncAPI: messages; gRPC: methods)
  severity: {response.header.undefined: warn, response.status.default: ignore}   # GraphQL's, AsyncAPI's and gRPC's checks too
record:                   # see Session recording; the directory relative to the configuration file
  dir: null               # record the session there from the start (--record); new or empty
  name: null              # the session's name; the directory's when not given
  redact: []              # hidden besides proxy.redact: header names, and JSON pointers (/password) to values of bodies and messages
replay:                   # see Session replay; the directory relative to the configuration file
  dir: null               # answer from the session recorded there (--replay)
  missing: fail           # fail (502 problem), generate (the mock answers) or passthrough (proxy.target answers)
  ignore: []              # not compared besides dates, readOnly fields and volatile headers: header names, JSON pointers (/id, /items/*/id)
  update: false           # with missing: passthrough, record the run and replace the session with it at stop
admin: {enabled: true, path: /__apigenie, port: null, allow_remote: false, log_size: 1000}
log: {level: info, format: stylish, bodies: false, body_limit: 4096, headers: false, file: null}
tls:                      # see TLS; paths relative to the configuration file
  cert: null              # serve HTTPS with this certificate chain and key (both or neither)
  key: null
  client_ca: null         # require client certificates of these authorities (mTLS); needs cert
  ca_certs: []            # trusted for https://, wss:// and mqtts:// upstreams, besides the system's roots
  insecure: false         # don't verify upstream certificates
  client_cert: null       # presented to upstreams that ask for a client certificate (with client_key)
  client_key: null
db:                       # see Document store; paths relative to the configuration file
  crud: explicit          # auto: serve /things and /things/{id} from the store (--crud); explicit: only x-apigenie-mock resources; off
  seed: {notes: seed/notes.json}   # or a directory of <collection>.json/.yaml files: seed, seed/*.json
  generate: 3             # documents generated for a resource's collection nothing else seeds
  max_documents: 10000    # per collection and namespace; 0 for no limit
  file: null              # keep the store across runs (--db-file)

A mistake in the file stops the server with exit status 2, the position and the JSON pointer of the value; an unknown setting is reported with the closest known name.

Dashboard

http://127.0.0.1:4010/__apigenie/ui is a page served by the server itself, for a browser on the same machine: it follows the admin API’s rules (local clients only unless admin.allow_remote, on admin.port when the admin API has a port of its own, off with --admin off). The header on every screen names the documents and how they answer (mock, proxy, generated), where they listen, the seed, the uptime, the requests and violations so far, and has Reload and Stop; with several services a switcher narrows every screen to one.

Screen What it shows and does
Traffic Every exchange as it happens (the event stream), with filters; an exchange’s request and answer (headers and bodies, captured while the page is open) and its validation problems with where they point in the document. Copy as curl, Replay, Save as stub (POST /stubs), Add to .http (a file built up in the browser, to copy or download), Copy as YAML (the answer as an operations entry of the configuration). Record session and Save session: a session recording of what happens from here, written where the server runs.
Operations The operations by tag, with their hits, their last status and what answered them last (an example, a generated body, a scenario, a proxy). For one operation: a fixed answer (a declared status, one of its examples, a generated body, a dropped connection), a latency and whether its requests are validated, saved as an always-on override (PUT /overrides) until the server stops, with the Prefer header (and __ parameters) that do the same for one request.
Scenarios and network The scenarios of the configuration, the document and the admin API, switched on and off, with their rules and hits; a builder for a new one (by operation, tag, method and path, header or query parameter; status, example, latency, probability); the network simulation of every response (fixed, uniform, normal or log-normal latency, jitter, dropped connections, bandwidth, the seed) with the response times of the last requests.
Contract Whether the server would pass --fail-on and --min-coverage if it stopped now, the coverage, the violations by check (and the exchanges they were seen in), the operations never called, the report as JSON or JUnit to download, and Start over (DELETE /contract).
Data The document store’s collections with their documents, Reset to seed, snapshots to save and restore; and ctx.state, the values the scripts keep, to edit or reset.
Channels For an AsyncAPI document only (the screen is listed when a served document has channels): the channels by WebSocket, MQTT and HTTP, with their operations, the clients connected and the messages counted (GET /asyncapi/channels); for a channel, its messages in and out as the event stream brings them (with an exchange’s payload and validation problems, and Open in Traffic), its clients (GET /asyncapi/clients, followed through the ws-open, ws-close, mqtt-subscribe and mqtt-unsubscribe events), and Publish: a message sent as the application would (POST /asyncapi/publish), to every client of the channel or to one, with a payload started from the document’s examples or left to the server; a payload the message’s schema refuses shows the violations and goes nowhere, until Send anyway; Copy as curl.

Every panel copies its settings as the configuration’s YAML, or as x-apigenie-mock for the document, so what was tried in the dashboard is kept in a file (Channels changes no setting: it copies the publish as curl). The page follows the system’s light or dark theme, or the one chosen in the header.

The page is built from the web components package (src/app_web, the apg-server-* elements) and embedded in the binary; nothing is fetched from the network. The design is ADR-167.

The Traffic screen of the dashboard

Admin API

On by default for local clients only (admin.allow_remote opens it, admin.port moves it to a port of its own). The /db endpoints work on the namespace of an X-Apigenie-Test header (or a ?test= parameter), the default one without it:

Endpoint Description
GET /__apigenie Status: version, url (where clients reach the server), document (with its proxies and stubs; an AsyncAPI document’s mqttProxy and mqttClients), seed, uptime (uptimeSeconds, startedAt), requests (the clients’, not the admin API’s), active scenarios, simulation, logSize.
GET /__apigenie/ui/ The dashboard: the page and its files (GET /__apigenie/ui redirects to it).
GET /__apigenie/routes The operations served, with their hits, lastStatus, lastSource (what answered last) and lastAt; an OpenAPI operation’s tags, deprecated and responses (each status with its content types and the names of its examples).
DELETE /__apigenie/hits Start the hits of routes and scenarios over (a reload keeps them).
GET /__apigenie/services The services: name, mount, URL, document (see several services).
GET /__apigenie/scenarios, PUT / DELETE /__apigenie/scenarios/{name} List (with hits, lastStatus, lastSource and lastAt, and the rules as ruleList next to their count rules), add or remove scenarios.
POST /__apigenie/scenarios/{name}/activate, /deactivate Switch a scenario on or off.
GET, PUT /__apigenie/overrides The always-on rules, whole: what GET answers, PUT takes back.
GET, PUT /__apigenie/simulation The network simulation of every response.
POST /__apigenie/seed Reseed: {"seed": 42}.
GET, DELETE /__apigenie/log?limit=&after=&level= The recent events, with headers and bodies when they were captured (see log entries).
GET /__apigenie/events?type=&level=&service=&after= The events as they happen, as Server-Sent Events (see the event stream).
POST /__apigenie/stubs A stub file of a logged exchange: {"id": 42, "dir": "stubs", "name": "rex"} (see stubs from the log).
GET /__apigenie/record The session recording: recording, and the session’s dir, name, started, exchanges while one is on; test, the mark.
POST /__apigenie/record/start, /stop Record a session in {"dir": "sessions/run-7", "name": "run 7"} (dir defaults to record.dir), or stop it and write session.yaml.
POST /__apigenie/record/mark {"test": "checkout-happy"}: the test of the exchanges to come that carry no X-Apigenie-Test of their own; {} clears it.
GET /__apigenie/replay The session replay’s report so far: exchanges, replayed, neverReplayed, unmatched (with their tests and closest recordings), passed, and tests, the exchanges each test used since its reset.
POST /__apigenie/replay/reset Restart a test’s counters: {"test": "checkout"}, or the X-Apigenie-Test header, or ?test=; without one, every test’s. 204.
POST /__apigenie/reload, POST /__apigenie/shutdown Reload, or stop gracefully.
GET /__apigenie/spec The served document, bundled (converted to OpenAPI 3.2 for a Swagger 2.0 document; a .proto file with its imports, or a GraphQL schema, as text).
GET, PUT, DELETE /__apigenie/state/{key} Values kept between requests.
GET /__apigenie/db The document store: its file, namespaces, snapshots and collections (documents, seeds, id, and the schema and operations of a resource’s collection).
GET /__apigenie/db/{name}?limit=&offset= A collection’s documents (100 by default) and total.
PUT, DELETE /__apigenie/db/{name} Replace every document of a collection (a JSON array; all or nothing: 422 when the schema refuses one, 409 for an id used twice), or remove them.
POST /__apigenie/db/reset Back to the seeds: with X-Apigenie-Test, that namespace; without it, the default one, and the tests’ namespaces go. {"collections": ["notes"]} resets some.
GET /__apigenie/db/snapshots, PUT, DELETE /__apigenie/db/snapshots/{name} List, take (or replace) or remove a snapshot of the documents.
POST /__apigenie/db/snapshots/{name}/restore Put a snapshot’s documents back.
POST /__apigenie/db/save Write the store to --db-file now (501 without one).
DELETE /__apigenie/db/namespaces/{test} Forget a test’s namespace.
GET, DELETE /__apigenie/contract The contract report (coverage and violations), or start it over.
GET /__apigenie/contract.json, GET /__apigenie/contract.xml The contract report as --report (JSON) and --report-junit (JUnit XML) would write it now, to save.
GET /__apigenie/contract/violations?operation=&check=&limit= The violations seen, by operation (id or GET /pets/{id}; GraphQL: the operation’s name or a root field such as Query.book; AsyncAPI: the operation’s key; gRPC: package.Service/Method or its path) and check.
POST /__apigenie/contract/schema-check GraphQL proxy: introspect the server and compare its schema now (202; the result is logged and reported).
GET /__apigenie/asyncapi/channels AsyncAPI: the channels, their operations and messages (with the document’s examples), the clients connected, and the messages counted (see AsyncAPI channels).
GET /__apigenie/asyncapi/clients AsyncAPI: the WebSocket clients and MQTT subscriptions of the channels.
POST /__apigenie/asyncapi/publish AsyncAPI: send a message to a channel’s clients, or to one of them: {"channel": "rooms/42", "payload": {"text": "hi"}}, {"channel": "room", "client": 7, ...}.
GET /__apigenie/grpc/services gRPC: the services and their methods.
GET, POST, DELETE /__apigenie/grpc/stubs gRPC: list, add (one stub or a list) or remove the stubs added at run time (?scope=all: every stub).
GET, PUT, DELETE /__apigenie/grpc/stubs/{name} gRPC: one stub.
POST /__apigenie/grpc/stubs/reset, GET /__apigenie/grpc/stubs/schema gRPC: back to the stub files, and the stubs’ JSON Schema.
GET, DELETE /__apigenie/grpc/history?method=&limit=&after=&q= gRPC: the calls received, newest last.
POST /__apigenie/grpc/verify gRPC: count the calls that match {method, request, metadata, stub, code}.

A server of several services names the service of each item, and takes ?service=NAME on every endpoint.

The event stream

GET /__apigenie/events keeps the connection open and writes each event of the log as it happens, as Server-Sent Events: id: is the log entry’s id, event: its kind, and data: the same JSON object as a /log entry. A dashboard reads it with EventSource; a script with curl -N:

curl -N 'http://127.0.0.1:4010/__apigenie/events?type=request,violation'
: connected

id: 12
event: request
data: {"event":"request","id":12,"time":"2026-10-12T09:00:00.000Z","level":"info","method":"POST","path":"/pets","operationId":"addPet","status":201,...}

id: 13
event: request
data: {"event":"request","id":13,...,"status":422,"violations":[...]}

id: 13
event: violation
data: {"event":"request","id":13,...,"status":422,"violations":[...]}
Event When
request An exchange, request and answer together (there is no separate response event).
violation After an event with violations: the same id and data.
ws-open, ws-in, ws-out, ws-close, mqtt-*, grpc, grpc-in, grpc-out WebSocket, MQTT and gRPC traffic, as in the log.
scenario A scenario was added, replaced, removed, activated or deactivated through the admin API (message says which).
reload The documents were reloaded (POST /reload or --watch).
contract-updated The contract report’s coverage changed (at most once a second), or it started over (message: "reset", after DELETE /contract or a reload).
db A write to the document store: the collection, what changed, who wrote.
admin, script, server The admin API’s own exchanges, the scripts’ logs, the server’s errors.

?type= keeps the named events, ?level= those at a level and above (default debug), ?service= one service’s. ?after=ID, or the Last-Event-ID header a reconnecting EventSource sends, first replays the log’s later entries, so a client that reconnects misses nothing the log still holds. A comment every 15 seconds keeps the connection alive. A client that falls more than 1 MiB behind is disconnected. The stream follows the admin API’s rules: local clients only unless admin.allow_remote, on admin.port when the admin API has a port of its own.

Log entries

While at least one event stream is open, the server captures the headers and bodies of every exchange, as log.headers: true and log.bodies: true do. /log entries and stream events then carry headers and responseHeaders (arrays of [name, value]), requestBody and responseBody, and captured: {"headers": true, "bodies": true}. A body that isn’t UTF-8 is in Base64 with requestBodyEncoding (or responseBodyEncoding) "base64". A body is kept up to log.body_limit bytes (4096 by default); a longer one is cut and marked requestBodyTruncated or responseBodyTruncated, and requestSize and responseSize keep the whole sizes.

Stubs from the log

POST /__apigenie/stubs makes a stub file of a logged exchange of an OpenAPI document, as --record-dir writes them: the client’s headers without the connection’s, the hidden headers of proxy.redact as [redacted], and the query without the mock’s own __ parameters.

curl -X POST http://127.0.0.1:4010/__apigenie/stubs -d '{"id": 12, "dir": "stubs", "name": "new-pet"}'

With dir, or else the service’s proxy.record_dir, the stub is written to a new file there, <operationId>-N.yaml, and the answer (201) has the file and the stub. Without either the answer (200) is the file’s content, {"stubs": [...]}. The entry must still be in the log, and must have been captured whole (an open event stream, or log.headers and log.bodies, and a log.body_limit above its bodies’ sizes): else 404 or 409. The running mock answers with the new stub after POST /__apigenie/reload when dir is one of its stubs directories.

AsyncAPI channels from the admin API

GET /__apigenie/asyncapi/channels lists the channels: key, address, protocols, paths, messages (their names) and messageList (each message’s name, contentType, summary and examples, the document’s, each with its payload and headers: a starting point for a payload to publish), operations (with qos and retain for MQTT), the clients connected now and the contract report’s connections, messagesIn, messagesOut and unmatched counts. GET /__apigenie/asyncapi/clients lists the WebSocket clients and MQTT subscriptions: id, protocol, remote, channel, address, route, path (the WebSocket path, or the MQTT filter), clientId (MQTT), connectedAt and messagesOut.

POST /__apigenie/asyncapi/publish sends a message to the clients of a channel, as the schedule sends one: scripts see it, and each delivery is logged as ws-out or mqtt-out with source: "admin".

curl -X POST http://127.0.0.1:4010/__apigenie/asyncapi/publish \
  -d '{"channel": "rooms/42", "message": "notice", "payload": {"text": "maintenance at noon"}}'
{"channel": "room", "address": "rooms/42", "operation": "sendNotices", "message": "notice", "source": "admin",
 "contentType": "application/json", "payload": "{\"text\":\"maintenance at noon\"}", "delivered": 1,
 "webSocket": 1, "mqtt": 0, "clients": [7]}

With a Kafka tap, a Kafka channel’s message is produced to the cluster instead. The record’s key, headers (an object of strings) and partition may be given; without them the key and headers are made from the message’s Kafka binding and headers, and the partition is the key’s. The record is checked as the tap checks records (422 with the violations, nothing produced, unless "validate": false), produced, and answered with where it went; a cluster that can’t be reached or refuses it answers 502. The tap then reads it like any other record.

curl -X POST http://127.0.0.1:4010/__apigenie/asyncapi/publish \
  -d '{"channel": "orders", "payload": {"sku": "A-1"}, "key": "cust-1", "headers": {"correlationId": "c-1"}}'
{"channel": "orders", "operation": "placeOrder", "message": "order", "source": "admin",
 "contentType": "application/json", "payload": "{\"sku\":\"A-1\"}",
 "kafka": {"upstream": "kafka://localhost:9092", "topic": "shop.orders", "partition": 1, "offset": 42,
 "key": "cust-1", "headers": {"correlationId": "c-1"}}}

With an AMQP tap, an AMQP channel’s message is published to the broker: a queue channel’s through the default exchange, any other to its exchange with routingKey (else the address given, else the address with its parameters filled). Its properties (contentType, contentEncoding, deliveryMode, priority, correlationId, replyTo, expiration, messageId, timestamp, type, userId, appId) and headers may be given; without them they come from the message’s and the operation’s AMQP bindings and the message’s headers. The message is checked (422), published mandatory with publisher confirms, and answered with {"amqp": {"upstream", "exchange", "routingKey", "headers"}}; a message no queue takes answers 409 asyncapi-unroutable.

channel is a channel’s key (every client of the channel gets the message) or an address with its parameters (that address’s clients only); client, an id of /asyncapi/clients, narrows it to that one client. message names one of the channel’s messages; without it, the first message of its first send operation is sent. payload is the message as JSON, raw as text, and without either one is made as for the schedule. A payload that doesn’t match the message’s schema answers 422 with the violations and is sent to nobody, unless "validate": false. Unknown channels and messages answer 404, as does a client that isn’t connected to the channel (asyncapi-unknown-client). An admin publish isn’t retained by the MQTT broker. Only clients the mock matched to a channel get it. Clients of a validating proxy are counted in the status, not listed. A server of several services lists every AsyncAPI service’s channels and clients, and needs ?service= to publish when several services serve AsyncAPI.

TLS

apigenie-server serves TLS itself, and its proxies reach https:// and wss:// servers and mqtts:// brokers. TLS 1.2 and 1.3 come from Mbed TLS. It’s built with what modern servers use: AES-GCM and ChaCha20-Poly1305, ECDHE key exchange on x25519, P-256, P-384 and P-521, and RSA and ECDSA certificates. A server that offers only static RSA or DHE key exchange, or other ciphers, can’t be reached.

Serving. --tls-cert server.pem --tls-key server.key (or tls.cert and tls.key in the configuration) turns the port into HTTPS. WebSocket channels and GraphQL subscriptions become WSS, and clients that ask for h2 by ALPN get HTTP/2, which is how browsers and gRPC clients over TLS connect. The admin API on a port of its own (admin.port) uses the same certificate. The MQTT broker of an AsyncAPI document serves MQTT over TLS too, on port 8883 unless --mqtt-port says otherwise. The startup line shows https:// and mqtts:// URLs and, as JSON, "tls": true. The certificate file may hold the chain, the server’s certificate first. The key must be PEM and not encrypted. For local development, mkcert localhost 127.0.0.1 makes a pair your system trusts:

apigenie-server openapi.yaml --tls-cert localhost+1.pem --tls-key localhost+1-key.pem
curl https://localhost:4010/pets

Upstreams. --proxy-to https://api.example.com (OpenAPI, GraphQL, AsyncAPI HTTP channels), wss://... (AsyncAPI WebSocket channels), mqtts://... (AsyncAPI MQTT channels, port 8883 unless given) and https://host[:port] (gRPC, HTTP/2 over TLS) connect over TLS, port 443 unless given. Certificates are verified against the system’s roots:

  • macOS: the system keychain, and the certificates an administrator or the user marked as always trusted;
  • Windows: the ROOT store;
  • Linux: the first CA bundle found (/etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, …).

SSL_CERT_FILE and SSL_CERT_DIR replace them on every system. --ca-cert FILE adds authorities, such as a company’s own root or a test CA, and --insecure skips verification altogether. The host name of the URL is checked against the certificate (an IP address against its IP addresses). An upstream whose certificate is rejected gets the usual 502 upstream-unreachable, with the reason in the detail.

tls:
  cert: certs/server.pem     # relative to the configuration file
  key: certs/server.key
  ca_certs: [certs/company-root.pem]
  insecure: false

Client certificates (mTLS)

Requiring them. --tls-client-ca clients-ca.pem (or tls.client_ca) makes every HTTPS, WSS, gRPC and MQTTS connection present a client certificate issued by an authority of that PEM file, as a service behind mutual TLS does. It needs --tls-cert. The certificate’s extended key usage must allow client authentication when it has one.

  • A client without a certificate, or with one the authorities didn’t issue, fails the TLS handshake. The log gets a warning that says why, for example TLS handshake with 127.0.0.1 failed: the client presented no certificate.
  • The subject of an accepted certificate (CN=billing, O=Example) is in each event of the JSON log ("clientSubject"), on a ~ client line of the stylish log, and in the admin API’s log.
  • The contract report lists the clients: clients in JSON ([{"subject": "CN=billing, O=Example", "exchanges": 12}], counting HTTP requests, gRPC calls, WebSocket connections and MQTT messages), a client property per subject in JUnit, and a Clients: line in the summary.
  • The admin API asks for a certificate too, on the main port and on admin.port.
apigenie-server openapi.yaml --tls-cert server.pem --tls-key server.key --tls-client-ca clients-ca.pem
curl --cacert ca.pem --cert client.pem --key client.key https://localhost:4010/pets

Presenting one. --client-cert client.pem --client-key client.key (or tls.client_cert and tls.client_key) is the certificate the proxies present to https://, wss://, mqtts:// and gRPC TLS upstreams that ask for one. Upstreams that don’t ask never see it. It works with --insecure too. The key must be PEM and not encrypted. An upstream that refuses the proxy for want of a certificate gets the usual 502 upstream-unreachable, which says so in the detail (an MQTT client’s CONNECT gets return code 3).

tls:
  cert: certs/server.pem
  key: certs/server.key
  client_ca: certs/partners-ca.pem
  client_cert: certs/proxy.pem
  client_key: certs/proxy.key

Not supported. There is no session resumption: every connection does a full handshake. Neither certificate revocation lists (CRL) nor OCSP are checked, so a revoked client or upstream certificate is accepted as long as its chain is valid. Remove a revoked client’s authority from --tls-client-ca, or issue client certificates with short lifetimes.

Docker

docker run --rm -p 4010:4010 -v "$PWD:/mnt/data" mykulyak/apigenie-server openapi.yaml

It serves GraphQL schemas the same way (mykulyak/apigenie-server schema.graphql).

The image listens on every interface of the container (APIGENIE_SERVER_HOST=0.0.0.0) and exposes port 4010, and 1883 for the MQTT broker (-p 1883:1883). It has the CA certificates of Alpine Linux, so https:// upstreams are verified; mount a certificate and its key to serve HTTPS (-v "$PWD/certs:/certs" ... --tls-cert /certs/server.pem --tls-key /certs/server.key).

npm

npx --package @mykulyak/api-genie apigenie-server openapi.yaml

Deprecated names

apigenie-server was called apigenie-mock before. The old names keep working until 1.1, with a one-line notice on standard error:

  • apigenie-mock: a symlink to apigenie-server in the archives, .deb, .rpm and Homebrew, a copy in the Windows archive, and a command of the npm package.
  • APIGENIE_MOCK_* variables: read when the APIGENIE_SERVER_* variable of the same name is unset.
  • mykulyak/apigenie-mock: its latest tag only prints the new image name and exits with 2; the older tags still hold the old server.

What did not change: the apigenie-mock.yaml configuration file (still read, after apigenie-server.yaml), the commands.mock section of a shared configuration, the x-apigenie-mock extensions, the X-Apigenie-Mock-* headers and the /__apigenie admin API.

Limits

  • TLS: no session resumption, no revocation checks (OCSP, CRLs) of server or client certificates (see Client certificates). The OpenAPI and GraphQL proxies speak HTTP/1.1 to the server, a connection per request; the AsyncAPI one connects to WebSocket servers without extensions. The AsyncAPI proxy relays WebSocket messages up to 16 MiB; of MQTT 5 it relays the message properties, not those of CONNECT, SUBSCRIBE or DISCONNECT, nor subscription options and identifiers, and the broker publishes wills without their delay. Request and response bodies are buffered (16 MiB by default), so streams such as server-sent events don’t fit. A HAR file is kept in memory until the server stops. OpenAPI stubs match exact paths and queries, not templates.
  • gRPC: native gRPC over HTTP/2 (cleartext, or TLS with --tls-cert) only; gRPC-Web, the Connect protocol and REST transcoding (google.api.http) aren’t served, and messages can’t be compressed. Reflection doesn’t include custom options.
  • Several services: one MQTT broker for all of them, so an mqtt:// upstream goes with one document; one HAR file. Services can’t be added or removed without a restart.
  • Session recording: exchanges are numbered in the order they completed; a gRPC call keeps at most 100 messages per side, a session at most 10 000 exchanges; a .proto document’s hash covers the file, not its imports; MQTT messages keep neither QoS nor retain nor MQTT 5 properties; session export leaves WebSocket and MQTT messages out of the HAR file.
  • The document store is the OpenAPI document’s and the scripts’; the GraphQL mock keeps its own store, and AsyncAPI and gRPC mocks see the documents only through scripts. CRUD resources are a path and its items’ path: nested collections (/users/{id}/orders) are collections of their own, not filtered by the parent’s id, and readOnly properties are filled in at the top level only.
  • AsyncAPI: Kafka, AMQP and other protocols aren’t served; Avro and Protobuf payloads are sent from their examples only, and not validated. The MQTT broker keeps no sessions between connections and sends at most QoS 1.
  • XML bodies: no DTDs (and so no entities but the five predefined ones), UTF-8 and UTF-16 only; mixed content and xsi:type aren’t mapped, and nodeType: none is honoured on arrays only.
  • Callbacks, webhooks and links are not mocked. OpenAPI 3.2 streams described by itemSchema (server-sent events, JSON Lines) are answered with an empty body.
  • GraphQL: no federation (_entities, _service), @defer and @stream, persisted queries or file uploads. Scenarios don’t apply to subscriptions over WebSocket or server-sent events. The proxy doesn’t forward subscriptions, and its schema check leaves out what only recent servers introspect (@specifiedBy URLs, @oneOf, repeatable directives, deprecated arguments) and the directives applied in the schema.