Table of contents
- Why a dual-surface service is a documentation problem
- The REST/OpenAPI half: let FastAPI generate, then curate
- The gRPC/proto half: document the schema at the source
- Structuring one docs experience over two surfaces
- Runnable examples that match the contract
- Keeping both surfaces in sync: the part that actually fails
- Conclusion
- Additional resources
Shipping a service with both a FastAPI REST interface and a gRPC interface is a reasonable architecture decision. Documenting it without creating two disconnected, drift-prone reference portals is harder than most teams expect. The engineering problem and the documentation problem look similar at first glance: two well-defined contracts, two sets of tooling, job done. The trap is assuming that two well-linted contracts equals one agreed contract. They do not, and the rest of this guide is about the gap between those two things. For a broader map of modern API documentation approaches across protocols, that parent guide is the right starting point before you go deep here.
Why a dual-surface service is a documentation problem
A FastAPI service generates its contract automatically: the OpenAPI 3.x schema is produced at runtime from route definitions, Python type hints, and Pydantic models, and served interactively at /docs (Swagger UI) and /redoc (ReDoc) with no separate doc files required. A gRPC service uses the .proto file as its interface definition language: the schema is hand-authored, compiled, and then rendered into human-readable docs by a separate tool. Two contracts, two authoring modes, two rendering pipelines.
By default those two pipelines produce output that lives in separate places, uses different formats, and updates on different cycles. A consumer trying to understand your service has to cross-reference a Swagger UI page and a generated proto HTML page, with no shared navigation, no shared terminology, and no authoritative answer to the question “when these two descriptions disagree, which one is correct?”
The deeper problem is the one teams tend not to state plainly: linting each contract independently does not prove the two surfaces describe the same behavior. buf lint and buf breaking protect the proto side. OpenAPI validation protects the REST side. Neither tool looks across the fence. Two healthy contracts can still describe two different services.
The REST/OpenAPI half: let FastAPI generate, then curate
FastAPI’s automatic OpenAPI generation is genuinely useful, and the default output is already valid. The problem is that valid is not the same as consumer-grade. The default schema ships without meaningful operation tags, without field-level descriptions, and without the kind of contextual explanation that helps a new consumer choose the right endpoint. Before the schema is worth publishing, it needs curation.
Practical curation work for the REST surface:
- Add
tagsto route decorators to group operations logically in Swagger UI. - Use
summaryanddescriptionparameters on route functions, and populateField(description=...)on Pydantic models. - Override the default schema title and version in the
FastAPI()constructor. - Export
openapi.jsonoropenapi.yamlas a build artifact committed to version control, so the contract has a reviewable history independent of a running server.
That last point matters for tooling downstream: linters, SDK generators, and mock servers all consume the file, not the live /openapi.json endpoint. Treat the exported schema as a first-class artifact of your build, not a convenience endpoint.
Schema existence is still not schema truth. A spec that was accurate six months ago and has not been tested against live traffic can silently diverge from production behavior. Runtime conformance validation with something like openapi-core’s FastAPIOpenAPIMiddleware validates every request and response against the spec as traffic flows through, which is where you actually prove the REST surface matches its own documentation. Log-replay validation serves the same purpose when you cannot add middleware to a production path. Both checks are REST-only: they tell you nothing about whether the REST surface agrees with the gRPC surface. For deeper guidance on the REST side in isolation, the REST API documentation best practices guide covers curation and publishing in full.
The gRPC/proto half: document the schema at the source
The .proto file is both the contract and the documentation source. Comments written directly in the proto file travel with the schema through version control and are available to doc generators without any intermediate step. This makes annotation discipline especially important: a field with no comment produces a reference doc with a blank description.
Two tools are worth knowing for rendering proto docs:
protoc-gen-doc generates HTML, Markdown, and JSON from proto comments and works with most standard setups. One critical flag: --include_source_info must be passed to protoc, or comments are stripped from the descriptor and the output is bare schema with no prose. The limitation is that its coverage of full gRPC service contracts (methods, streaming modes, error behaviors) can be thin depending on how comments are structured.
sabledocs is a more recent alternative that covers both service contracts and data models in a single rendered output, making it a better fit for services where the method-level documentation matters as much as the message definitions.
Buf governs this side of the contract: buf lint enforces API-shape rules against the proto, and buf breaking checks backward compatibility against a stored baseline. Both are scoped to the proto contract. They do not cross over to the REST surface.
Proto directory hygiene matters here. Keep .proto source files in their own directory, separate from the generated Go/Python/Java stubs. Version-control history on the source directory stays readable when generated code is not mixed in; reviewers can see what changed in the schema without sifting through regenerated stubs.
Structuring one docs experience over two surfaces
The Diataxis framework gives you a clean way to separate what generators can produce from what they cannot. Applied to a dual-surface service:
Reference (auto-generated): the OpenAPI reference and the proto reference both live here. Neither requires hand-authoring once generation is set up. They are facts about the interface, not guidance about how to use it.
How-to guides (hand-written): authentication flows, pagination patterns, retry strategies, choosing REST versus gRPC for a given use case. No generator can produce these because they require editorial judgment about what a consumer actually needs to know.
Explanation (hand-written): why the service exposes two surfaces, when to prefer gRPC over REST, what the tradeoffs are in latency-sensitive versus throughput-sensitive contexts.
Tutorials (hand-written, optional): end-to-end walkthroughs that take a new consumer from zero to a working integration.
The navigation structure that holds these together should give consumers one landing page with a clear choice: “Using the REST API” and “Using the gRPC API” as top-level paths, each containing the generated reference for that surface plus links to the shared how-to and explanation content that applies to both. The “which surface should I use?” question belongs on the landing page, not buried in a subsection. Consumers who have to hunt for that answer before they can even start will leave.
Runnable examples that match the contract
Static reference docs are necessary but not sufficient. Consumers need to be able to execute a real call before they trust that the docs are accurate.
For the REST surface, FastAPI’s built-in Swagger UI at /docs provides in-browser “try it out” execution against the live server, with no additional tooling. For full guidance on using Swagger UI to explore FastAPI’s auto-generated OpenAPI spec, that guide covers the testing workflow in depth.
For the gRPC surface, grpcurl is the equivalent tool:
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"name": "world"}' localhost:50051 helloworld.Greeter/SayHello
grpcurl uses server reflection or a local proto file to discover the service contract, which means the call is always validated against the actual running interface.
grpc-gateway is the architecture where these two surfaces converge into a genuine single source of truth. It reads the protobuf service definition and generates a reverse-proxy server that translates RESTful HTTP/JSON calls into gRPC, driven by google.api.http annotations on your service methods:
import "google/api/annotations.proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
post: "/v1/hello"
body: "*"
};
}
}
The same annotated .proto file can also emit an OpenAPI schema as a generated artifact, not a hand-authored one. One file, two surfaces, no possibility of disagreement between them: the verifier is codegen itself. The important caveat is that grpc-gateway’s OpenAPI output historically defaulted to version 2, but recent versions (like v2) primarily generate OpenAPI 3.0. Most modern tooling expects OpenAPI 3.x, so a v2-to-v3 conversion step is normally required before linters, SDK generators, or portal renderers can consume the output cleanly.
For teams describing multi-step workflows that span both surfaces, Arazzo provides a spec format for that layer. And for teams thinking ahead to AI tooling consuming their docs, the llms.txt approach for dual-protocol docs and what agent-ready API documentation requires when you have two spec formats are both worth reading once the human-readable layer is solid.
Keeping both surfaces in sync: the part that actually fails
This is where well-intentioned documentation setups quietly break down. The two CI gates described above protect each side independently. They do not prove the two sides agree.
Architecture 1: proto-first with grpc-gateway. The verifier is codegen. One annotated .proto produces both the gRPC service and the REST proxy, so disagreement is structurally impossible. Wire buf lint and buf breaking as merge gates on the proto. The OpenAPI output is a build artifact, not a second source of truth.
Architecture 2: independent contracts (hand-written OpenAPI alongside a separate gRPC service). This is the most common setup for teams that grew a FastAPI service and a gRPC service in parallel. There is no automatic verifier of cross-surface agreement. The options are:
- Contract tests. Pact distinguishes synchronous non-HTTP request/response interactions and explicitly supports gRPC calls in that category, so both surfaces can be covered without hand-rolled mocks. Unlike a schema or spec, which is a static artifact describing all possible states of a resource, a Pact contract is enforced by executing a collection of concrete request/response pairs. On the provider side, verification fails when real responses differ from the responses specified in the contract, which is what makes it a behavioral check rather than a static one.
- A named human reviewer. If contract tests are not feasible, the team needs to explicitly designate someone responsible for cross-surface consistency review on every change that touches either contract. “The CI is green” is not a substitute for this.
Architecture 3: shared domain model in code. Both surfaces are generated from the same in-code types. The verifier is the type system plus the build. If the type changes, both renderers pick it up; they cannot disagree.
For each architecture, name the verifier out loud in your contributing guide. The mistake is leaving it implicit and assuming tooling handles something it does not cover.
On the prose side, the same discipline applies. Automated prose linting as a merge gate, as described in the Vale GitHub Actions guide, catches terminology drift across two doc surfaces that use different vocabulary for the same concepts. It does not replace contract alignment, but it does prevent the docs from drifting apart in ways that confuse consumers even when the contracts are correct.
Conclusion
FastAPI and gRPC documentation starts with two auto-generated contracts and gets harder from there. The generation is the easy part. The hard part is structure, runnable examples, and the discipline to keep both surfaces honest. Apply Diataxis to separate what generators handle from what requires human judgment. Pick your architecture deliberately and name its verifier explicitly: codegen for proto-first, contract tests or a human reviewer for independent contracts, the type system for shared-domain-model builds. Wire both contracts as versioned build artifacts. Give consumers one landing page with a clear split, not two disconnected portals. Two healthy, linted contracts can still describe two different services. The question is not whether each contract passes its own gate; it is whether the service a consumer builds against the REST docs is the same service they would build against the proto docs.
Ready to bring this kind of documentation structure to your service? Book a consult or reach out directly to talk through your setup.
Additional resources
- Modern API documentation approaches across protocols — a broader map of FastAPI, gRPC, GraphQL, and AsyncAPI documentation patterns, useful context before going deep on dual-surface setups.
- grpc-gateway on GitHub — the canonical source for
google.api.httpannotation syntax and OpenAPI emission options. - grpc-gateway and OpenAPI via Speakeasy — covers the v2-to-v3 conversion requirement and downstream tooling compatibility.
- Pact documentation — authoritative reference on the schema-versus-contract distinction and synchronous non-HTTP (gRPC) contract testing support.
- openapi-core FastAPI integration — runtime request/response conformance validation for the REST surface via middleware.
References
- https://www.speakeasy.com/openapi/frameworks/grpc-gateway
- grpc-gateway: gRPC to JSON proxy generator following the gRPC HTTP spec
- https://docs.pact.io/
- https://stevekinney.com/courses/enterprise-ui/api-contract-testing
- Consumer-Driven Contract Testing (CDC) - Engineering Fundamentals Playbook
- FastAPI - OpenAPI-core
- Validating your OpenAPI specification with reality - Jacob Bednarz