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 (
/v1forhttps://api.example.com/v1) and as they are. Literal paths win over templated ones (/pets/mineover/pets/{id}). An unknown path is answered with 404, an unknown method with 405 and anAllowheader, both as problem details. OpenAPI 3.2’squerymethod and the methods ofadditionalOperations(COPY, …) are routed like the others. - Status. The lowest 2xx response, else
2XX, else the lowest declared, elsedefault. - Body. Content negotiation follows
Accept(406 when nothing fits). Without anAccept, 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’sexample, 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,readOnlyandwriteOnly. A value forapplication/xml,text/xmlor a+xmltype 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-OperationandX-Apigenie-Mock-Source(example:cat,generated,scenario:outage,stub:getPet-1) say where a response came from. - Stubs.
--stubs DIR(orstubs:in the configuration) answers with recorded exchanges (see Record and replay) before the document’s examples: a request’sPreferand 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$refpoints to; another name is axml-rootviolation. 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: attributemakes it an attribute,textandcdatathe element’s character data. - An array’s items repeat in the parent element, named by the items’
xml.nameor the property name; withnodeType: element(wrapped: true) they sit in a wrapper element. - A declared
xml.namespacemust 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 andadditionalProperties: falsereports 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, soadditionalProperties: falsereports 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/42forrooms/{roomId}). It gets the messages of the channel’ssendoperations, one after another, every second by default: the message’s examples in turn, or values generated from its payload schema (always with--dynamic). What it sends is checked against thereceiveoperations: a match is answered with the operation’sreply, anything else with a problem details message. - HTTP. A
receiveoperation takes messages withPOST(or its HTTP binding’s method) and answers 202, or 200 with its reply; an invalid message gets 422. Asendoperation answersGETwith a message. - MQTT. A broker on its own port takes subscriptions to the channels’ topics (wildcards
included: a parameter of an address takes its default, first enum value or example) and
publishes the
sendoperations’ messages with their MQTT binding’s QoS. Messages clients publish go to the other subscribers, as with any broker, once they match areceiveoperation; its reply is published to the reply channel’s topic. MQTT 3.1.1 and 5 clients are served; MQTT 5 subscribers also get the MQTT 5 properties of a message: its headers as user properties (the first example’s, else generated from the header schemas), and the payload format indicator, content type, correlation data and response topic of its MQTT binding (generated from their schemas when they are schemas) and the operation binding’s message expiry. An MQTT 5 client’s properties and user properties are checked as the MQTT proxy checks them; its request is answered on the topic of its reply address user property, or on its Response Topic when the reply channel hasaddress: null, with its correlation id and Correlation Data. Retained messages expire with their Message Expiry Interval.
x-apigenie-mock on a send operation sets interval (milliseconds), count (messages per
client, 0 for no limit), jitter, messages (the names to send) and publish: false.
operations:
sendNotices:
action: send
channel: {$ref: '#/channels/room'}
x-apigenie-mock: {interval: 500, count: 10}
The log has ws-open, ws-in, ws-out and ws-close events for WebSocket clients and
mqtt-subscribe, mqtt-in, mqtt-out and mqtt-unsubscribe ones for MQTT, with the channel’s
address, the operation and the message. When the server stops, WebSocket clients get the close
code 1001 and MQTT 5 clients a DISCONNECT; after a reload they reconnect to the new version.
Contract testing of a WebSocket API
With --proxy-to ws://host:port an AsyncAPI document’s WebSocket channels aren’t mocked: each
client of a channel gets a connection to the real server, and every message of either side is
checked against the document. Point a frontend or a test suite at the mock instead of the server.
apigenie-server chat.yaml --proxy-to ws://localhost:9000 --report contract.json --fail-on errors
The mock connects to the server first, with the client’s path under the URL’s prefix, its query,
Origin, cookies, Authorization and Sec-WebSocket-Protocol, plus Via and X-Forwarded-*;
the client gets the subprotocol the server chose. A server that can’t be reached answers the
client’s handshake with 502 (504 when it doesn’t answer in time), and a server that refuses it
(401, 404, …) has its answer passed on. What clients send is checked against the channel’s
receive operations, what the server sends against its send operations. A client’s message of
an operation with a reply on the same channel waits for its reply: the first message the server
sends that is one of the reply’s messages is checked as that reply (logged as name (reply) with
the request’s operation), not against the send operations. Messages are relayed
whole (fragments are put together first); pings are answered on each side; a close frame goes on
with its code. No WebSocket extension is negotiated (permessage-deflate is left out of the
handshake). HTTP channels go to the same server (see below);
the MQTT channels to the broker of a second --proxy-to mqtt://..., or they keep being mocked.
| Check | Severity | When |
|---|---|---|
message.incompatible |
error | The message matches none of the messages of the channel’s operations in its direction. |
message.malformed |
error | The message isn’t JSON where JSON is expected. |
message.frame.unexpected |
warn | A binary frame for a text or JSON message, or a text frame for a binary one (image/png, application/octet-stream, Protobuf, Avro, …). |
message.qos.unexpected |
warn | MQTT: a client published at a QoS other than the one of the operation’s MQTT binding. |
channel.parameter.invalid |
error | A parameter of the channel’s address (a path segment, a topic level) has a value outside the parameter’s enum. |
channel.receive.undeclared |
warn | A client sent a message on a channel the application has no receive operation for. |
channel.send.undeclared |
warn | The server sent a message on a channel it has no send operation for. |
message.reply.uncorrelated |
error | The reply’s correlationId (in the payload) isn’t the request’s, or is missing. |
operation.query.invalid |
error | HTTP channels: the request’s query doesn’t match the query of the operation’s HTTP binding. |
message.headers.invalid |
error | HTTP channels: the request’s or the reply’s header fields don’t match the message’s headers or its HTTP binding’s headers. |
validation.request sets what happens to the clients’ messages and validation.response to the
server’s: log by default for a proxy (reported, and forwarded), enforce with --strict. In
enforce mode a message with an error isn’t forwarded, and the client gets a problem message
instead (asyncapi.validate_incoming stays the mock’s setting):
{"type": "https://apigenie.pl/mock/errors#invalid-upstream-message", "title": "The upstream's message doesn't match the specification",
"status": 422, "channel": "rooms/1", "direction": "upstream", "validation": [{"location": "message/text", "code": "type",
"message": "expected type string, found integer", "check": "message.incompatible", "severity": "error"}]}
A client’s message gets #invalid-message with "direction": "client". The log has the
ws-open, ws-in, ws-out and ws-close events with the operation, the message’s name, the
violations and source: proxy (mock for what the proxy answered itself). contract.severity
changes the checks’ severities as for the other proxies.
Coverage and reports. Each message of each operation is a branch: it is covered once a valid
one went by. An operation is covered, partial or not-tested; a message that matches none is
counted for the operation it was checked against first. GET /__apigenie/contract returns the
report ("specification": "asyncapi", connections, messages in and out, channels, operations,
violations), --report and --report-junit write it when the server stops (a test case per
operation), and --fail-on and --min-coverage (the share of messages) make the exit status 5.
Contract testing of MQTT channels
With --proxy-to mqtt://host:port the mock’s broker relays to a real broker: each client that
connects to the mock gets a connection of its own to the broker, and every message published
either way is checked against the document.
apigenie-server devices.yaml --proxy-to mqtt://localhost:1884 --mqtt-port 1883 --report contract.json
apigenie-server events.yaml --proxy-to ws://localhost:9000 --proxy-to mqtt://localhost:1884
The upstream connection has the client’s MQTT version, client identifier (the one the mock
assigns when the client gives none), clean start, keep-alive, will, user name and password. The
client’s CONNECT gets the broker’s CONNACK, a refusal included; a broker that can’t be reached is
answered with “server unavailable” (return code 3, MQTT 5 0x88). Subscriptions are the broker’s:
the SUBACK is its answer, and the broker decides who gets which message. What clients publish is
checked against the topic’s channel’s receive operations, with the QoS of the operation’s MQTT
binding when it gives one (message.qos.unexpected); what the broker delivers against the send
operations. A subscriber also gets other clients’ messages, so on a channel without send
operations a delivery is checked against the receive operations. Topics of no channel are
relayed unchecked (debug level in the log).
In enforce mode a client’s message with an error isn’t forwarded (MQTT 5 clients get reason code
0x99 in their acknowledgement), and a broker’s message with an error isn’t delivered. A client
that disconnects is disconnected from the broker; one whose connection is lost has its upstream
connection dropped too, so the broker publishes its will; a client the broker disconnects is
disconnected. A reload keeps the clients connected. The log has mqtt-connect,
mqtt-subscribe, mqtt-in, mqtt-out, mqtt-unsubscribe and mqtt-disconnect events with
source: proxy (mock for what the proxy decided itself), and coverage and reports work as for
WebSocket.
MQTT 5 properties are relayed both ways: user properties, payload format indicator, content type, response topic, correlation data and message expiry (not the topic alias and subscription identifiers, which belong to one connection). An MQTT 5 message’s are checked against the MQTT bindings (0.2.0):
| Binding | Checked (message.properties.invalid, error) |
|---|---|
message payloadFormatIndicator |
The Payload Format Indicator is the same (absent counts as 0) |
message contentType |
The Content Type is the same, ignoring case |
message correlationData |
Correlation Data is present and matches the schema |
message responseTopic |
A Response Topic is present, the string given or matching the schema |
operation messageExpiryInterval |
A Message Expiry Interval is present and no longer |
Its user properties are its headers, checked against the message’s headers
(message.headers.invalid). MQTT 3.1.1 messages have no properties: nothing is checked for them.
The report counts the messages whose bindings state properties, checked or not:
summary.properties: {"checked": 3, "unchecked": 1}, properties of each operation, and a
summary line MQTT 5 properties: checked on 3 messages, not on 1 (MQTT 3.1.1). A client’s QoS 1
and 2 messages are acknowledged by the mock before the broker
acknowledges them, and the mock delivers at most QoS 1. An mqtts:// broker (port 8883 by default) is reached over TLS
and verified as https:// upstreams are (see TLS).
Contract testing of Kafka channels
There is no Kafka broker in the mock. With --proxy-to kafka://host:port (port 9092 by
default; kafkas://, port 9093, over TLS) the server is a client of the real cluster, a validating
tap: it reads the topics of the document’s Kafka channels and checks every record on them,
whoever produced it.
apigenie-server shop.yaml --proxy-to kafka://localhost:9092 --report contract.json
apigenie-server events.yaml --proxy-to mqtt://localhost:1883 --proxy-to kafka://localhost:9092
A channel with a kafka or kafka-secure server is a Kafka channel; its topic is the channel’s
Kafka binding topic, else its address. Parameters of the address match the topic’s segments, so
stock.{region} reads stock.eu and stock.us; the cluster’s topics are looked at again every
10 seconds for new ones. The tap reads every partition from the offset it had when the server
started, outside any consumer group, so it never takes partitions from the application’s consumers
and commits no offsets. A topic the cluster doesn’t have yet is reported once and read once it
appears; a lost connection is reported and tried again.
A record doesn’t say who produced it, so the channel’s operations tell. On a channel with only
send operations it is the application’s (a kafka-out event, checked against them); with only
receive operations it is a client’s (kafka-in); with both it is checked as the application’s,
and as a client’s when that doesn’t match. What is checked:
| What | Checked against | Check |
|---|---|---|
| The value | The operations’ messages | message.incompatible, message.malformed |
| The headers | The message’s headers (values read as the type the schema says) |
message.headers.invalid |
| The key | The message’s Kafka binding key (an error too when the record has none) |
message.key.invalid (error) |
| The partition | The channel’s Kafka binding partitions |
message.partition.invalid (warn) |
| The topic | The address’s parameters | channel.parameter.invalid |
Records are on the cluster before the tap sees them, so enforce mode reports them like log mode.
Kafka operations count in the coverage, GET /__apigenie/contract, --report and
--report-junit like any other, and the report’s upstream lists the kafka:// URL. The log has
kafka-in and kafka-out events with the topic as channel, the record’s partition and offset
in the message, and its key (as kafka-key) and headers as the event’s headers; what the tap
found or failed at are server events with source: kafka.
The SASL PLAIN user and password come from APIGENIE_KAFKA_USERNAME and
APIGENIE_KAFKA_PASSWORD (apigenie test reads them too), never from the URL. kafkas:// brokers
are verified as https:// upstreams are (see TLS). The cluster needs Kafka 2.3 or later.
Compressed record batches aren’t read: they are reported (3 record(s) at offset 40 compressed with zstd) and skipped. A kafka:// upstream goes with one document, not with
several services. See ADR-170 for what the client does and doesn’t do.
Contract testing of AMQP channels
There is no AMQP broker in the mock either. With --proxy-to amqp://host:port/vhost (port 5672 by
default; amqps://, port 5671, over TLS; the path is the virtual host, /%2Fcarpet-express for
/carpet-express, none for /) the server is an AMQP 0-9-1 client of the real broker, a
validating tap.
APIGENIE_AMQP_USERNAME=conformance APIGENIE_AMQP_PASSWORD=conformance \
apigenie-server customs.yaml --proxy-to amqp://localhost:5672/%2Fcarpet-express --report contract.json
A channel with an amqp or amqps server is an AMQP channel. Its AMQP channel binding says what it
is:
is: routingKey(the default): the address is a routing key ofexchange.name(ofexchange.type, topic by default). The tap binds a queue of its own (exclusive, deleted when it disconnects) to the exchange: for a topic exchange with the address’s parameters as*words (customs.{country}.filingiscustoms.*.filing), for a fanout or headers exchange with any key. Direct exchanges bind the address itself, so their addresses can’t have parameters. It reads that queue, so it gets a copy of every message the exchange routes there, and the application’s queues are left alone. An exchange the broker doesn’t have is reported, and looked for again every 10 seconds.is: queue, and routing keys of the default exchange: a queue the application consumes. Reading it would take the application’s messages, so the tap doesn’t, and says so when it starts. Whatapigenie testand the admin API send there is checked.
Messages are checked as the Kafka tap checks records, in the
same direction: their body and headers against the operations’ messages, and their properties
against the AMQP bindings (0.3.0) (message.properties.invalid, error):
| Binding | Checked |
|---|---|
operation deliveryMode, priority, userId, replyTo |
The property is present and the same |
operation expiration |
An expiration is present and no longer |
operation timestamp: true |
A timestamp is present |
message contentEncoding |
The content encoding is the same |
message messageType |
The type property is the same |
The log has amqp-in and amqp-out events with the routing key as channel and the exchange in the
message; coverage and reports work as for Kafka, and enforce mode reports too. The PLAIN user and
password come from APIGENIE_AMQP_USERNAME and APIGENIE_AMQP_PASSWORD (guest without them),
never from the URL. An amqp:// upstream goes with one document. See ADR-171.
Contract testing of HTTP channels
With --proxy-to http://host:port (or the ws:// server of the WebSocket channels) an AsyncAPI
document’s HTTP channels go to that server through the same transport as an OpenAPI document’s
proxy (Via, X-Forwarded-*, 502 and 504 problems), checked against the document instead of
an OpenAPI one:
- A
receiveoperation takes requests with its HTTP binding’s method (POST by default). The request’s body is checked against the operation’s messages, its query against the binding’squeryschema (operation.query.invalid) and its header fields against the message’sheadersand HTTP bindingheaders, its traits’ included (message.headers.invalid). A 2XX response’s body and header fields are checked against itsreply. - A
sendoperation is asked with GET, as the mock serves it; a 2XX response with a body is checked against its messages (not its header fields: they are the webhook’s). - Other paths get 404 and other methods 405, without the server.
Query parameters and header fields are read as the type their schema says (integer, number,
boolean, an array from repeated parameters); header fields the schemas don’t name are left
out. apigenie test checks the requests of HTTP channels with the same code and the same checks
(see Requests of HTTP channels).
In enforce mode an invalid request gets 422 (#invalid-message) without the server, and an
invalid response is replaced by 502 (#invalid-upstream-message). Responses carry
X-Apigenie-Mock-Source, X-Apigenie-Mock-Operation and X-Apigenie-Mock-Violations, and each
exchange counts as a message of its operation in the coverage. Responses other than 2XX and the
reply’s statusCode binding aren’t checked; fallback mode, --record-dir and --record-har are
for OpenAPI documents.
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:
- Its
x-apigenie-mock-*metadata (below). - A script’s
onRequest. - The rules of the active scenarios.
- The stubs.
- The upstream of
--proxy-to. - Messages generated from the response type, seeded by
--seed: one, or one to three for a server stream; withgrpc.generation: false, UNIMPLEMENTED. Fields marked(google.api.field_behavior) = REQUIREDare 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 markedINPUT_ONLYare 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}}"}}
methodispackage.Service/Method(ormethod: Methodwithservice: package.Service). An unknown method stops the start with exit status 2 and the methods there are.requestmatches the request message, written as proto3 JSON:equalscompares canonical forms, so default values may be left out;containsmatches a subset, recursively (arrays as subsets,ignoreArrayOrderfor any order);matchesholds regular expressions for string fields.request.metadatamatches the metadata the same three ways.requestsmatches 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) andtimes(how many calls a stub answers before it’s skipped) order the candidates.responsehasheaders,messageormessages(a list of{message, delay}for a stream),each(bidirectional streams: one answer per message received),trailers,status(codeas a name or a number,message,detailsasgoogle.protobuf.AnyJSON),delay(milliseconds),drop(reset,close,timeout,truncate) andgenerate: true(fill the fields the message doesn’t give).- Templates. String values can use
{{request.name}},{{requests[1].name}},{{requests.length}},{{message.name}}(the messageeachanswers),{{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-timeoutsets 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 andgrpc-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 agoogle.rpc.BadRequestingrpc-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-messageis percent-encoded,-binmetadata base64-encoded. grpc.reflection.v1andv1alphalet grpcurl, buf curl, Postman, Kreya and Bruno list the services and fetch their descriptors;grpc.health.v1.HealthanswersCheckandWatchwith SERVING for every service and for"".- Scenario rules match
operationId(acme.greeter.v1.Greeter/SayHello),path, or the tagsgrpcand the service’s name, and setgrpc_statusandgrpc_messagebesides 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.
POSTwithapplication/json({query, variables, operationName, extensions}) orapplication/graphql,GETwith the same as query parameters (queries only: a mutation overGETgets 405), and batches (a JSON array of requests,graphql.max_batch). Clients that acceptapplication/graphql-response+jsonget 400 for requests that fail before execution;application/jsonclients 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
locationsandextensions.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,@includeand__typenamebehave 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’spath; a non-null field’s null goes up to the nearest nullable one. - Introspection.
__schemaand__typeare complete, so GraphiQL, Apollo Sandbox and code generators work against the mock (graphql.introspection: falseturns it off). The SDL is at/graphql/schema.graphqlandGET /__apigenie/spec. - GraphiQL. A browser opening the endpoint gets GraphiQL, loaded from esm.sh
(
graphql.playground: falseturns 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 thelocale.@examples(values:)picks one of the values;@listLength(min:, max:)sizes a list.@mocktakesfaker(any faker provider, asx-faker) withargs(a list by position or an object by name),valueorvalues,minandmaxfor numbers,lengthorminLengthandmaxLengthfor lists, andnullProbability. On a root field,delay(milliseconds) delays the response anderrorRatewitherrormakes it fail; on a subscription field,intervalandcountpace 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) {},
};
reqhasmethod,path,query(an object; repeated parameters are arrays),headers(lower-case names),body(parsed JSON, or the text),rawBody,operationIdandroute(inonResponse),remoteandconnectionId.reshasstatus,headers,body,source, anddelay(milliseconds) anddrop(reset,close,timeout,truncate) to set. A response returned byonRequesthas the same fields.msghasdirection(inorout),channel,operation,message,contentTypeandpayload.operationhasname,type(query,mutation,subscription),query,variablesandrootFields(["Query.user"]). The result’s data is cut to the selection set and completed with generated values, like an example’s.ctxhasstate(get,set,deleteandkeysof 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 ofx-faker, with arguments by position and a last object by name:fake('string.alpha', {length: 4})) andscenarios(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/petsmakes 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’sX-Apigenie-Testheader 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’spathis the endpoint. -
grpc:{session, stubs: [stub]}, the stub form of a.protofile’s--record-dir, with the whole streams (at most 100 messages per side); the note’sorder([in, out, in, out]) says which side each message came from,codethe status. Thex-apigenie-testmetadata is left out. -
wsandmqtt: 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(orreplay.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_MISSINGerror (status 502) with the same members inextensions.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: anerrormessage). 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 withFAILED_PRECONDITION, the problem in theapigenie-replay-problemtrailer.
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:
nameandspec(required),mount, and the settings of its document:proxy,stubs,scenarios,overrides,validation,security,operations,responses,generation,headers,asyncapi,graphqlandgrpc. These keys at the top of the file are every service’s defaults; a service’sstubs,scenarios,overridesandproxyreplace the defaults rather than adding to them.server,cors,simulation,tls,admin,log,contract,scripts,db,request_overridesandasyncapi.mqtt_portare 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 whoseHostheader names that host (any port), and may add aprefix.{port: N}is a listener of the service’s own (0picks a free one), which a.protofile’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 404no-service, which lists the mounts. Two services can’t share a name, nor can one’s mount hide another’s (/shopand/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-dirand--record-harname 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/+/openin both, orstatusand#): 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/). Anmqtt://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/serviceslists them with their mounts and URLs, the items of/routes(full paths),/scenarios,/overrides,/logand the status’sdocumentsname their service, and?service=NAMEnarrows 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 400admin-service-requiredotherwise; a service’s own endpoints (the gRPC ones,/contract/violations) answer 404 without it.PUT /simulation,POST /seedandPOST /reloadapply to every service unless one is named. - The contract report (
--report,GET /__apigenie/contract) has a section per service insections, each a whole report of its document with itsnameandmount, and asummarythat counts them together: requests, violations andcoverage.combined, the units of every service added up (OpenAPI branches, GraphQL fields, gRPC methods, AsyncAPI messages).--fail-onand--min-coverageapply 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.servicenames 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.
whereis 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.sortis'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--seedforformat: 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
readOnlyproperties it lacks (generated, likecreatedAt), 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) whenvalidation.responseisenforce(--strict). - Seeds.
db.seednames a file of documents (a JSON or YAML array) per collection, or a directory of them (seed/shipments.jsonseedsshipments). A resource’s collection without one is seeded from its list operation’s example, else withdb.generate(3) documents generated with the run’s seed.ctx.db.reset()andPOST /__apigenie/db/resetgo 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/dbendpoints take the same header (or?test=<id>). - Snapshots.
PUT /__apigenie/db/snapshots/{name}copies the documents of every collection,POST /__apigenie/db/snapshots/{name}/restoreputs them back. - Persistence. In memory by default, so a restart is a clean slate. With
--db-file(ordb.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 onPOST /__apigenie/db/save. The file holdsformatVersion1, 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
dbevent: 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).onMessageandonGraphQLuse 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.dband its resources use;db.seedseeds every service’s collections, and the admin API’s/dbendpoints take?service=when several could be meant.--db-file(anddb.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 calleddata,items,resultsor like the collection), else the item response’s or the request body’s. The id is the schema’sidproperty, else the path parameter when it is a property; integers count, strings withformat: uuidare UUIDs. Answers leavewriteOnlyproperties out. - Lists page by the list operation’s parameters:
offsetandlimit,page(from 1) andper_page(page_size,pageSize,size,limit), orcursor(after,page_token) and a size, the size’sdefaultwhen not given (20 without one). An envelope gets the page in its array property, andtotal(totalCount,total_count…),hasMore,nextCursor,page,offsetandlimitfilled 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), andsort(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’sPrefer(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 sayX-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 withdb.crud: auto) orresource: {name: parcels, id: code, schema: Parcel, list: entries}(the id property, the schema undercomponents/schemasor as a#/reference, the envelope’s array property). - Proxies. A document served with
--proxy-tohas 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.

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~ clientline of the stylish log, and in the admin API’s log. - The contract report lists the clients:
clientsin JSON ([{"subject": "CN=billing, O=Example", "exchanges": 12}], counting HTTP requests, gRPC calls, WebSocket connections and MQTT messages), aclientproperty per subject in JUnit, and aClients: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 toapigenie-serverin the archives,.deb,.rpmand Homebrew, a copy in the Windows archive, and a command of the npm package.APIGENIE_MOCK_*variables: read when theAPIGENIE_SERVER_*variable of the same name is unset.mykulyak/apigenie-mock: itslatesttag 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
.protodocument’s hash covers the file, not its imports; MQTT messages keep neither QoS nor retain nor MQTT 5 properties;session exportleaves 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, andreadOnlyproperties 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:typearen’t mapped, andnodeType: noneis 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),@deferand@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 (@specifiedByURLs,@oneOf, repeatable directives, deprecated arguments) and the directives applied in the schema.