Protobuf / gRPC

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

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

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

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

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

Stubs

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

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

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

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

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

Metadata directives

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

What a gRPC server does

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

Proxy and record

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

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

Contract testing of a gRPC API

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

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

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

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

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

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

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

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

Scripts and the log

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

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

Was this helpful?