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.