The google.api.http annotations we added in part
4
have done one job so far: they told the gateway which REST calls map to which
RPCs. But they describe the API precisely, the paths, the verbs, the request and
response shapes, and a precise description of an API is most of an OpenAPI
document. Feed that document to a viewer and you have an interactive docs site:
every endpoint listed, every field typed, and a “try it” button that calls the
real service.
So from one .proto we’ve had gRPC, then REST, and now documentation, none of
it a separate thing to write or keep in sync.
The annotations, a third time
The pattern of this whole series is one source of truth and several outputs generated from it. The proto defined the gRPC service; the annotations on it generated the REST gateway; and those same annotations generate an OpenAPI document. Add a field to a message, and it shows up in the gRPC API, the REST API, and the docs, all at once, because all three are read from the proto.
Generate the OpenAPI document
This is one more buf plugin. A small wrinkle to know up front: grpc-gateway ships
its own OpenAPI generator, but it emits OpenAPI v2 (the old Swagger format). For
a v3 document we use kollalabs/protoc-gen-openapi,
which reads the very same google.api.http annotations:
go install github.com/kollalabs/protoc-gen-openapi@latest
# buf.gen.yaml
plugins:
# ... the go, go-grpc and grpc-gateway plugins from before ...
- local: protoc-gen-openapi
out: internal/docs/assets
opt:
- title=Macguffin API
- default_response=false
go generate ./... now also writes an openapi.yaml. It’s the REST API described
in full, drawn straight from the annotations:
# internal/docs/assets/openapi.yaml (generated, trimmed)
openapi: 3.0.3
info:
title: Macguffin API
version: 0.0.1
paths:
/v1/macguffins:
get:
summary: ListMacguffins
operationId: MacguffinService_ListMacguffins
parameters:
- name: pageSize
in: query
schema:
type: integer
format: int32
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ListMacguffinsResponse'
That GET /v1/macguffins operation is the get: "/v1/macguffins" rule from the
proto, turned into OpenAPI. You wrote the annotation once; it now feeds three
generators.
Serve it, viewer and all
A spec on disk is a means, not an end; people want to read it. The usual move is
to bolt on a docs UI like Stoplight
Elements or Swagger UI, which means
vendoring a couple of megabytes of JavaScript into every project. go-tool-base’s
openapi package does that part for you: the Stoplight Elements UI is embedded in
the framework, so your project ships only its generated spec.
openapi.Register mounts both the document and the docs site onto a mux:
// internal/docs/docs.go
package docs
import (
_ "embed"
"net/http"
"gitlab.com/phpboyscout/go-tool-base/pkg/openapi"
)
//go:embed assets/openapi.yaml
var spec []byte
// Register mounts /openapi.yaml and the Stoplight docs site (/docs/) onto mux.
func Register(mux *http.ServeMux) error {
return openapi.Register(mux, spec, openapi.WithTitle("Macguffin API"))
}
The //go:embed bakes the generated spec into the binary (the same trick as part
3.5), and openapi.Register serves it at /openapi.yaml and the Stoplight site
at /docs/. Wiring it into serve is one line, on the same mux the gateway is
mounted on:
// pkg/cmd/serve/main.go (the existing HTTP wiring)
mux := stdhttp.NewServeMux()
mux.Handle("/v1/", gw) // REST, from part 4
if err := docs.Register(mux); err != nil {
return err
}
Why it goes on the same server
That last point is doing more work than it looks. The docs, the spec, and the
live REST API are all on the one HTTP server, so they share an origin. That’s what
makes the “try it” console actually work: when you fill in a request in the docs
and hit send, the browser calls /v1/macguffins on the same host it loaded the
page from, with no cross-origin dance to configure. And because the certificate is
your mkcert one, the page and its requests are all clean HTTPS, no warnings to
click past, which is exactly why we set the local CA up back in part 2.
$ curl https://localhost:8443/openapi.yaml | head -4
# Generated with protoc-gen-openapi
openapi: 3.0.3
info:
title: Macguffin API
Open https://localhost:8443/docs/ in a browser and there’s the service:
every endpoint, every field, and a working console that calls the real thing.

One proto, three faces
Step back and look at what a single annotated .proto is now producing: a gRPC
service for the things that speak it, a REST API for the things that don’t, and an
interactive docs site for the people who have to consume either. One source, three
faces, and nothing hand-maintained between them. That’s the whole argument for
building it this way, and it’s why the annotations were worth the small ceremony.
The service is, by any reasonable measure, done: typed, fast, documented, and served over TLS. The last part is about what happens once it’s out there and taking traffic. In part 6 we add telemetry and logging, so you can see how it’s being used and why it’s slow, without bolting on a separate observability stack.
