npm.io
1.2.0 • Published 3h agoCLI

@massiv-oss/grpc-fake-server

Licence
MIT
Version
1.2.0
Deps
6
Size
424 kB
Vulns
0
Weekly
0

grpc-fake-server

A fake gRPC server that returns responses from Protobuf schemas without a real backend.

  • Serves gRPC, gRPC-Web, and Connect on one port.
  • Builds default responses from example values declared as Protobuf options (Declarative Fake).
  • Lets tests register whole responses or RPC errors per RPC and read the call history (Dynamic Fake), isolated per test by a sequence ID.
  • A buf plugin generates a typed admin client for each service.
  • Reloads automatically when the Protobuf files change.

This project is under construction and has not been released yet. The options use the extension numbers 79133–79135 from the range that Protobuf leaves for use within an organization (50000–99999). If your own extensions of google.protobuf.FieldOptions or google.protobuf.OneofOptions use these numbers, buf build fails with a conflict.

The design and the reasoning behind it are in docs/design.md.

Quick start

  1. Install the package, and add its Protobuf directory to buf.yaml (Declare example values).
  2. Declare example values on the fields of your response messages.
  3. Start the server: npx grpc-fake-server . --service example.v1.BookService (Start the server).
  4. Generate the admin client with protoc-gen-grpc-fake (Generate the admin client).
  5. In each test, register the responses it needs with the admin client (Use the admin client).

Install

npm install --save-dev @massiv-oss/grpc-fake-server @bufbuild/protoc-gen-es
npm install @bufbuild/protobuf

Node.js 24 or later is required. The server runs buf build with its own dependency on @bufbuild/buf, so the server does not need the Buf CLI on PATH. To run buf generate yourself with a package manager that does not hoist dependencies, such as pnpm, also add @bufbuild/buf to your devDependencies.

Declare example values

The package ships the option definitions in proto/grpc_fake/v1/options.proto. Add the directory to your buf.yaml as a module so that your Protobuf files can import it.

version: v2
modules:
  - path: proto
  - path: node_modules/@massiv-oss/grpc-fake-server/proto

Import the options with import option (edition 2024). An option import is not a dependency of the generated code, so you do not need to generate code for the options. With an older edition or proto3, use a regular import "grpc_fake/v1/options.proto"; instead; the options then become a dependency of the generated code.

edition = "2024";

package example.v1;

import option "grpc_fake/v1/options.proto";

message Book {
  string title = 1 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
}

A field without a declaration gets a fixed default value of its type (see Default values), so you only declare the fields whose values matter for your screens and tests. The server checks every declaration against the field type at startup, and the plugin checks them at generation. A wrong declaration fails with the Protobuf file and the field name in the error.

The examples below cover every kind of value. examples/vitest has all of them in one file, with tests that read them through the server.

Scalars

Each scalar type has its own option, named after the type. An int32 option on an int64 field is a type mismatch.

message Book {
  string title = 1 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
  int32 pages = 2 [(grpc_fake.v1.example).int32 = 352];
  int64 sold = 3 [(grpc_fake.v1.example).int64 = 9007199254740993];
  uint32 edition = 4 [(grpc_fake.v1.example).uint32 = 2];
  sint64 balance = 5 [(grpc_fake.v1.example).sint64 = -100];
  fixed64 checksum = 6 [(grpc_fake.v1.example).fixed64 = 12345];
  float rating = 7 [(grpc_fake.v1.example).float = 4.5];
  double price = 8 [(grpc_fake.v1.example).double = 39.99];
  bool in_stock = 9 [(grpc_fake.v1.example).bool = true];
  bytes cover = 10 [(grpc_fake.v1.example).bytes = "\x89PNG"];
}
Enums

Give the name of a value defined in the enum. The name is checked against the enum.

enum Genre {
  GENRE_UNSPECIFIED = 0;
  GENRE_FICTION = 1;
  GENRE_TECHNOLOGY = 2;
}

message Book {
  Genre genre = 1 [(grpc_fake.v1.example).enum = "GENRE_TECHNOLOGY"];
}
Repeated fields: a fixed list

list declares the whole list. Each element is an example value of the element type. list = {} declares an empty list explicitly.

message Book {
  repeated string tags = 1 [(grpc_fake.v1.example).list = {
    values: [
      {string: "software"},
      {string: "career"}
    ]
  }];
  repeated int32 chapters = 2 [(grpc_fake.v1.example).list = {
    values: [{int32: 1}, {int32: 2}, {int32: 3}]
  }];
  repeated string errata = 3 [(grpc_fake.v1.example).list = {}];
}
Repeated messages: count and uuid

For a list of messages, declare example values on the fields of the element type, and set the number of elements with count. Use uuid for identifiers: each element gets a different UUID, built from the seed and the path of the field (such as books[1].id). The first 8 digits are 00000000, so values from the fake are easy to recognize, and the same declaration at the same position always gives the same value.

message ListBooksResponse {
  // Three books. Each one is built from the declarations of Book.
  repeated Book books = 1 [(grpc_fake.v1.count) = 3];
}

message Book {
  // books[0].id, books[1].id, and books[2].id get different UUIDs.
  string id = 1 [(grpc_fake.v1.example).uuid = "book"];
  string title = 2 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
  // Nested counts work too. The UUIDs include both indexes, such as books[1].reviews[0].id.
  repeated Review reviews = 3 [(grpc_fake.v1.count) = 2];
}

message Review {
  string id = 1 [(grpc_fake.v1.example).uuid = "review"];
}

count cannot be combined with list on the same field.

Maps

map declares all entries. Each key and value is an example value of the key and value types.

message Book {
  map<string, int32> ratings = 1 [(grpc_fake.v1.example).map = {
    entries: [
      {
        key: {string: "goodreads"}
        value: {int32: 4}
      },
      {
        key: {string: "amazon"}
        value: {int32: 5}
      }
    ]
  }];
}
Messages

A message field without a declaration is built recursively from the declarations of its type. message sets the whole value of one field instead: the listed fields are set and the other fields are left unset. Use it when one field needs a different value from the type's declarations.

message Book {
  // Built from the declarations of Author: {name: "Anonymous", bio: "string"}.
  Author author = 1;
  // Only name is set; bio is unset.
  Author editor = 2 [(grpc_fake.v1.example).message = {
    fields: [
      {
        name: "name"
        value: {string: "Andy Hunt"}
      }
    ]
  }];
}

message Author {
  string name = 1 [(grpc_fake.v1.example).string = "Anonymous"];
  string bio = 2;
}

A message field that refers back to its own type (such as User invited_by in User) is left unset, so recursive types do not loop.

Oneofs

A oneof is left unset unless it declares which member to select, like the Protobuf default. To select a member, put oneof_example on the oneof. The value is an example of the message that has the oneof, written with its type name, that sets exactly one member. The Protobuf compiler checks the field names and value types.

message Book {
  oneof format {
    option (grpc_fake.v1.oneof_example) = {[type.googleapis.com/example.v1.Book]: {ebook: {file_size_bytes: 2048000}}};

    Paperback paperback = 1;
    Ebook ebook = 2;
  }

  oneof price {
    option (grpc_fake.v1.oneof_example) = {[type.googleapis.com/example.v1.Book]: {free: true}};

    double amount = 3;
    bool free = 4;
  }
}

example on a oneof member is an error: declare the selected member on the oneof instead.

Well-known types

Timestamp, Duration, Empty, Struct, Value, ListValue, and FieldMask take the well-known message itself, written in the Protobuf text format. Wrapper types take the wrapped scalar with wrapper.

import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";

message Book {
  // 2019-09-16T00:00:00Z
  google.protobuf.Timestamp published_at = 1 [(grpc_fake.v1.example).timestamp = {seconds: 1568592000}];
  // 10 hours
  google.protobuf.Duration reading_time = 2 [(grpc_fake.v1.example).duration = {seconds: 36000}];
  google.protobuf.StringValue subtitle = 3 [(grpc_fake.v1.example).wrapper = {string: "Your Journey to Mastery"}];
  google.protobuf.Int32Value stock = 4 [(grpc_fake.v1.example).wrapper = {int32: 12}];
  google.protobuf.Empty marker = 5 [(grpc_fake.v1.example).empty = {}];
  google.protobuf.FieldMask mask = 6 [(grpc_fake.v1.example).field_mask = {paths: ["title", "price"]}];
  google.protobuf.Struct metadata = 7 [(grpc_fake.v1.example).struct = {
    fields: {
      key: "source"
      value: {string_value: "catalog"}
    }
  }];
  google.protobuf.Value extra = 8 [(grpc_fake.v1.example).value = {number_value: 1}];
  google.protobuf.ListValue history = 9 [(grpc_fake.v1.example).list_value = {
    values: [{string_value: "draft"}, {string_value: "published"}]
  }];
}
Any

any takes the type URL and the fields of the contained message. The type must be one that the server has loaded.

import "google/protobuf/any.proto";

message Book {
  google.protobuf.Any detail = 1 [(grpc_fake.v1.example).any = {
    type_url: "type.googleapis.com/example.v1.Ebook"
    value: {
      fields: [
        {
          name: "file_size_bytes"
          value: {int64: 2048000}
        }
      ]
    }
  }];
}
Default values

When a field has no declaration, the server uses these values. Random numbers and the current time are never used, so the same schema always gives the same response.

Type Default
string "string"
numbers (int32, int64, double, ...) 42
bool true
bytes empty
enum the first value in the definition
repeated, map empty
oneof unset
message built recursively; unset on a cycle back to the same type or at depth 16
Timestamp 2026-09-27T01:23:45Z
Duration 0 seconds
wrappers the default of the wrapped scalar
Empty, Struct, ListValue, FieldMask empty
Value null
Any unset

Declarations on a message type apply to every RPC that uses the type. For a different value in one RPC or one test, register a response with the admin client.

Start the server

Pass a Buf input and the full names (including the Protobuf package) of the services to serve. You do not need to know where the Protobuf files are.

npx grpc-fake-server . \
  --service example.v1.UserService \
  --allow-origin http://localhost:3000

The server runs buf build on the whole input with the bundled Buf CLI, then finds the services by name. The input can be any Buf input: a directory with buf.yaml, a descriptor set, a Git repository, a Buf Schema Registry module, and so on. Services in imported files and dependency modules can also be served by their full names.

Protobuf files without declarations work too: every field gets the default value of its type, and tests register responses as needed. The following serves a fake of the Google Cloud Run API. The googleapis repository cannot be built as a whole, so --path narrows the files to build.

npx grpc-fake-server https://github.com/googleapis/googleapis.git \
  --path google/cloud/run/v2 \
  --service google.cloud.run.v2.Services

npx grpc-fake-server --help prints the options below, the endpoints, and examples. npx grpc-fake-server --help <topic> prints a section of this README in the terminal: example-values, server, generate, admin-client, cel, or recipes.

Option Default Description
<input> (first argument) . (current directory) A Buf input: a directory with buf.yaml, a descriptor set, a Git repository, a BSR module, and so on
--service All services in the non-import files of the built input The full name of a service to serve. Can be repeated
--path All files of the input A file or directory for buf build to build. For a local directory input, relative to the current directory. Can be repeated
--listen 127.0.0.1:18090 The address to listen on
--allow-origin None An origin allowed to send requests from browsers. Can be repeated
--allow-host None A host name accepted in addition to IP addresses and localhost. Can be repeated
--max-message-bytes 64 MiB The limit for requests and responses of the target RPCs
--max-calls-per-sequence 10,000 The number of calls kept in the history per sequence ID
--max-total-bytes 512 MiB The total size of the registrations and the history
--page-target-bytes 16 MiB The target size of one page of the call history
--max-admin-bytes 192 MiB The limit for requests and responses of the admin service, after encoding. In the JSON format, bytes are counted after base64 encoding

GET /readyz returns 200 after the schema and the declarations are checked and the server starts listening. One port accepts HTTP/1.1 (Connect and gRPC-Web from browsers) and HTTP/2 without TLS (native gRPC).

The server answers 403 without processing the following requests, so that unrelated sites opened in the developer's browser cannot change the registrations or read the history (see Local Server Security Best Practices):

  • Requests whose Host is not an IP address, localhost, a name ending with .localhost, or a host name allowed with --allow-host (protection against DNS rebinding).
  • Requests from browser pages whose Origin is not allowed with --allow-origin. A request with Origin, or with Sec-Fetch-Site other than none, is treated as a request from a browser page.

When containers in CI connect by service name, such as http://grpc-fake:18090, pass --allow-host grpc-fake.

Reloading on Protobuf changes

When the input is a directory, the server watches the .proto files and buf.yaml, buf.lock, and buf.work.yaml under it, and switches to the new schema without restarting. node_modules and directories starting with . are not watched. Other inputs (descriptor sets, Git repositories, BSR modules) are not watched.

  • Switching clears the Dynamic registrations and the call history. The server does not switch when the files of the served services and their dependencies did not change.
  • When the new schema cannot be built (a syntax error, a --service that is not found, or an invalid declaration), the server logs the error and keeps serving the previous schema.
  • The typed admin clients are not regenerated automatically. Run buf generate after changing RPC definitions. Admin functions for unchanged RPCs keep working.
  • Responses registered before regenerating are encoded with the old types. A response with a field removed from the server's schema is rejected at registration, and new fields get default values.

Generate the admin client

Add the plugin next to Protobuf-ES in buf.gen.yaml. The plugin writes <Service>Fake.ts for each service into fake/ under the directory of the *_pb.ts files.

version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-es
    out: gen
    opt:
      - target=ts
      - import_extension=ts
  - local: protoc-gen-grpc-fake
    out: gen
    opt:
      # Optional. The extension of the imports in the generated code: ts (default), js, or none.
      - import_extension=ts

Run buf generate through npm (for example, an npm script) so that protoc-gen-es and protoc-gen-grpc-fake in node_modules/.bin are on PATH.

<Service>Fake.ts exports a definition for each unary RPC of the service and the admin client. The admin client is embedded in each file, so there is no shared runtime library. At run time, the generated code imports only the *_pb.ts of the same service and @bufbuild/protobuf. The plugin does not generate code for the admin service and the options (grpc_fake.v1) themselves.

Use the admin client

Tests change the responses for themselves through the admin client. Create it with create<Service>Fake from <Service>Fake.ts, and pass the RPC definitions from the same file to each operation. The functions are typed to accept only the unary RPCs of that service.

The admin client sends Connect JSON requests with fetch. Each operation returns its result on success and throws GrpcFakeAdminError on failure, so a failing test stops at the failed line and shows the reason.

The examples below use this service. The same code runs in examples/vitest.

service BookService {
  rpc GetBook(GetBookRequest) returns (GetBookResponse);
  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}
Isolate tests with a sequence ID

Registrations and the call history belong to a sequence ID. The application's client sends the sequence ID in the sequence-id header, and the server uses the registrations of that ID. Give each test its own ID, and tests can run in parallel against one server without seeing each other's registrations. Requests without the header get the declarative responses.

With Connect-ES, set the header with an interceptor. GRPC_FAKE_SEQUENCE_ID_HEADER is exported from the generated code.

import { createClient } from "@connectrpc/connect";
import type { Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";

import { BookService } from "./gen/example/v1/book_service_pb.ts";
import {
  createBookServiceFake,
  GRPC_FAKE_SEQUENCE_ID_HEADER,
} from "./gen/example/v1/fake/BookServiceFake.ts";

const baseUrl = "http://127.0.0.1:18090";
const fake = createBookServiceFake({ baseUrl });
const sequenceId = crypto.randomUUID();

const sequenceIdInterceptor: Interceptor = (next) => {
  return (request) => {
    request.header.set(GRPC_FAKE_SEQUENCE_ID_HEADER, sequenceId);
    return next(request);
  };
};
const client = createClient(
  BookService,
  createConnectTransport({
    baseUrl,
    httpVersion: "1.1",
    interceptors: [sequenceIdInterceptor],
  }),
);
Register a response

registerResponse registers the whole successful response. The type of response is the generated message type without $typeName and $unknown. Scalars, repeated fields, and maps are required properties, and there is no partial override, so write the whole response; message fields and oneofs can be left unset.

import { Genre } from "./gen/example/v1/book_service_pb.ts";
import { getBook } from "./gen/example/v1/fake/BookServiceFake.ts";

await fake.registerResponse(getBook, {
  sequenceId,
  response: {
    book: {
      id: "book-1",
      title: "Refactoring",
      genre: Genre.TECHNOLOGY,
      pages: 448,
      price: 47.99,
      inStock: true,
      tags: [],
      ratings: {},
      isbn: "978-0-13-475759-9",
      format: { case: undefined },
    },
  },
});

const { book } = await client.getBook({ bookId: "book-1" });
// book.title === "Refactoring"
Match requests with a condition

when is a CEL expression over the request, referred to as req with the Protobuf field names. A registration is used only for the calls that match. Calls that match no registration get the declarative response.

await fake.registerResponse(getBook, {
  sequenceId,
  ruleId: "out-of-print",
  when: 'req.book_id == "out-of-print"',
  response: { book: { ...outOfPrintBook, inStock: false } },
});

await client.getBook({ bookId: "out-of-print" }); // the registered response
await client.getBook({ bookId: "other" }); // the declarative response

ruleId names a registration. Registering again with the same ruleId replaces it, and different ruleIds add registrations side by side. Without ruleId, the registration is named default, so a second registration without ruleId replaces the first.

Return an RPC error

registerError makes the RPC fail with a gRPC status code. code is the number of the status code. The generated file exports GrpcFakeCode, whose names and values match Code of Connect-ES, so either works.

import {
  GrpcFakeCode,
  getBook,
} from "./gen/example/v1/fake/BookServiceFake.ts";

await fake.registerError(getBook, {
  sequenceId,
  code: GrpcFakeCode.NotFound,
  message: "book not found",
});

await client.getBook({ bookId: "missing" });
// throws ConnectError { code: Code.NotFound, rawMessage: "book not found" }
Return error details

details attaches error details to the RPC error, the Protobuf messages that a server puts in google.rpc.Status. Each detail is a pair of a message descriptor and its value, the same shape that ConnectError of Connect-ES accepts. The value is type-checked against its own descriptor. The message type must be in the schema that the server loaded.

await fake.registerError(getBook, {
  sequenceId,
  code: GrpcFakeCode.NotFound,
  message: "book not found",
  details: [{ desc: BookNotFoundSchema, value: { bookId: "missing" } }],
});

const error = await client.getBook({ bookId: "missing" }).catch((cause) => {
  return ConnectError.from(cause);
});
error.findDetails(BookNotFoundSchema); // [{ bookId: "missing", ... }]

The client receives the details the same way as from a real server: in the grpc-status-details-bin trailer for gRPC and gRPC-Web, and in the error JSON for Connect. In the call history, the details of an error result are pairs of a type name and a Protobuf binary. Decode them with fromBinary.

Combine registrations with priorities

Registrations with a larger priority are evaluated first (the default is 0). Use this for a specific case on top of a general one.

// The general case: every ListBooks call returns an empty list.
await fake.registerResponse(listBooks, {
  sequenceId,
  ruleId: "empty",
  response: { books: [] },
});

// The specific case: the fiction genre fails. It is evaluated before "empty".
await fake.registerError(listBooks, {
  sequenceId,
  ruleId: "fiction-unavailable",
  when: `req.genre == ${Genre.FICTION}`,
  priority: 10,
  code: GrpcFakeCode.Unavailable,
  message: "fiction is unavailable",
});
Check the calls

getCalls returns the requests that the server received for the sequence ID, together with the result returned to each one. Use it to check what the application sent.

const page = await fake.getCalls(getBook, { sequenceId });

page.totalCount; // 2
page.calls.map((call) => {
  return {
    bookId: call.request.bookId,
    // "dynamic" for a registration, "declarative" for the example values
    source: call.source,
    // "success" or "error"
    kind: call.result.kind,
  };
});

The history is paged. Pass nextCursor back as cursor to read the next page, and limit to change the page size (100 by default, at most 1,000).

let cursor: string | undefined;
do {
  const page = await fake.getCalls(getBook, {
    sequenceId,
    limit: 500,
    ...(cursor === undefined ? {} : { cursor }),
  });
  for (const call of page.calls) {
    // ...
  }
  cursor = page.nextCursor;
} while (cursor !== undefined);

pendingCount is the number of calls that the server received but has not answered yet. diagnostics reports misconfigurations, such as a condition that failed to evaluate on a real request.

Reset

reset deletes the registrations and the call history of a sequence ID. With a new sequence ID for each test, you do not need it; use it when a test reuses an ID.

await fake.reset({ sequenceId });
Handle admin errors

Use type of GrpcFakeAdminError to tell the failures apart.

import { GrpcFakeAdminError } from "./gen/example/v1/fake/BookServiceFake.ts";

try {
  await fake.registerResponse(getBook, {
    sequenceId,
    // Fails: GetBookRequest has no field named unknown_field.
    when: "req.unknown_field == 1",
    response: { book: undefined },
  });
} catch (error) {
  if (error instanceof GrpcFakeAdminError && error.type === "InvalidRequest") {
    console.error(error.message);
  }
}
type Meaning
InvalidRequest Invalid registration data or arguments
PreconditionFailed An RPC that the server does not serve, or a cursor invalidated by a reset
LimitExceeded A limit was exceeded: response size, expression size, storage, and so on
TransportFailed The request failed, or the server failed unexpectedly. When the server returned a code, code has the name of the gRPC status code
InvalidResponse The response or the history returned by the server cannot be read

Recipes

Start the server from Vitest

Start the server once in a global setup file and wait for /readyz. examples/vitest is a complete project.

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globalSetup: ["src/startFakeServer.ts"],
  },
});
// src/startFakeServer.ts
import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";

export default async (): Promise<() => void> => {
  const server = spawn(
    "grpc-fake-server",
    [".", "--service", "example.v1.BookService", "--listen", "127.0.0.1:18091"],
    { stdio: ["ignore", "ignore", "inherit"] },
  );
  for (let i = 0; i < 300; i++) {
    const ready = await fetch("http://127.0.0.1:18091/readyz").then(
      (response) => response.ok,
      () => false,
    );
    if (ready) {
      return () => {
        server.kill();
      };
    }
    await sleep(200);
  }
  throw new Error("grpc-fake-server did not become ready");
};
Use it with Playwright

Start the server with the webServer option, and point the web app to it. Browsers send Connect or gRPC-Web requests, so allow the origin of the web app.

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

export default defineConfig({
  webServer: [
    {
      command:
        "grpc-fake-server . --service example.v1.BookService --allow-origin http://localhost:3000",
      url: "http://127.0.0.1:18090/readyz",
      reuseExistingServer: !process.env.CI,
    },
    {
      // The web app reads the URL of the API server from an environment variable.
      command: "API_BASE_URL=http://127.0.0.1:18090 npm run dev",
      url: "http://localhost:3000",
      reuseExistingServer: !process.env.CI,
    },
  ],
});

When the web app does not forward a header of its own, setExtraHTTPHeaders adds the sequence ID to every request of the page, including the RPCs. Register the responses before the page loads.

import { expect, test } from "@playwright/test";

import {
  createBookServiceFake,
  getBook,
  GRPC_FAKE_SEQUENCE_ID_HEADER,
} from "../gen/example/v1/fake/BookServiceFake.ts";

const fake = createBookServiceFake({ baseUrl: "http://127.0.0.1:18090" });

test("shows that the book is out of stock", async ({ page }) => {
  const sequenceId = crypto.randomUUID();
  await page.setExtraHTTPHeaders({
    [GRPC_FAKE_SEQUENCE_ID_HEADER]: sequenceId,
  });
  await fake.registerResponse(getBook, {
    sequenceId,
    response: { book: { ...book, inStock: false } },
  });

  await page.goto("/books/book-1");

  await expect(page.getByText("Out of stock")).toBeVisible();
});
Run in a container

Inside a container, listen on all interfaces. When other containers connect by service name, allow that host name, because the server rejects unknown Host headers.

# compose.yaml
services:
  grpc-fake:
    image: node:24
    working_dir: /app
    volumes:
      - .:/app
    command: >-
      npx grpc-fake-server .
      --service example.v1.BookService
      --listen 0.0.0.0:18090
      --allow-host grpc-fake
  app:
    build: .
    environment:
      API_BASE_URL: http://grpc-fake:18090
Use the admin service from other languages

The admin service is defined in proto/grpc_fake/v1/admin.proto in this package, and the server serves it over Connect, gRPC-Web, and gRPC like the target services. Tests in other languages can generate a client from it. Successful responses and call inputs are exchanged as TypedMessage: the Protobuf binary of the target RPC's type with its type name. The client and the server do not negotiate versions, so generate the client from the same version of the package as the server.

Operations that do not carry a message, such as registering an error or resetting, also work with plain Connect JSON requests:

# Make GetBook fail with NOT_FOUND (5) for the sequence ID "test-1".
curl -X POST http://127.0.0.1:18090/grpc_fake.v1.FakeAdminService/Register \
  -H "content-type: application/json" \
  -d '{"sequenceId": "test-1", "method": "example.v1.BookService.GetBook", "error": {"code": 5, "message": "not found"}}'

# Delete the registrations and the history of "test-1".
curl -X POST http://127.0.0.1:18090/grpc_fake.v1.FakeAdminService/Reset \
  -H "content-type: application/json" \
  -d '{"sequenceId": "test-1"}'

Input conditions (CEL)

Input conditions are evaluated with @bufbuild/cel directly on Protobuf-ES messages. Each evaluation is stopped after 100 milliseconds with the timeout of the Node.js vm module. A timeout or an evaluation error is reported as a misconfiguration, not as a non-match.

  • Refer to the fields of req by their Protobuf field names (for example, req.user_id).
  • Unset fields read as their default values. has() follows Protobuf field presence.
  • Well-known types follow the CEL specification: Timestamp is timestamp, Duration is duration, wrapper types are the wrapped value or null, and Struct, Value, and ListValue are JSON values.
  • Comparing values of different types with ==, !=, or in is an evaluation error, not false. Integers, unsigned integers, and floating-point numbers can be compared with each other and with null.
  • Functions whose result changes on each evaluation, such as now(), are not available.

At registration, the server checks the syntax and evaluates the expression once with a request whose fields are all unset. Unknown fields, undefined names, functions with wrong argument types, comparisons of different types, and non-boolean results fail in this evaluation, and the registration is rejected. References that fail on an empty request are rejected too, so check the size or the key before you access an element: for example, req.fields.size() > 0 && req.fields[0] == "a" or "math" in req.scores && req.scores["math"] == 90. Mistakes in branches that this evaluation does not reach (such as the right side of &&) are reported as misconfigurations when a real request evaluates that branch.

Development

See the repository README.

License

MIT

Keywords