OpenAPI Documentation
What we’re building
Section titled “What we’re building”An extension to gateway/cmd/main.go from grpc-gateway →: two new routes that serve the catalog.swagger.json and order.swagger.json files Code Generation → already produced, and a /docs route serving a Swagger UI page — pulled from a CDN, not a new Go dependency — that renders both documents as interactive, “try it out” API documentation.
An OpenAPI document for this REST surface already exists — buf generate’s grpc-ecosystem/openapiv2 plugin wrote catalog.swagger.json and order.swagger.json the moment Module 2 ran it, describing the exact same /v1/products, /v1/orders, path parameters, and request/response shapes REST Mapping → just walked through — because it’s compiled from the identical google.api.http annotations that produced the gateway itself. Serving that file and rendering it is strictly cheaper than writing API documentation by hand: there is no separate “docs” artifact to remember to update whenever a .proto changes, because the docs are a deterministic function of the same source file every other artifact in this module already is.
Pros & cons
Section titled “Pros & cons”Generated-from-proto OpenAPI docs (what this course does)
- Pros: always in sync with the real REST surface by construction — the same
buf generatestep that regeneratescatalog.pb.gw.goregeneratescatalog.swagger.jsonin the same pass, so there’s no separate documentation-update step to forget; zero authoring effort beyond the.protofile that already had to be written for the gateway to exist at all; one source of truth drives code, wiring, and docs simultaneously, the same story Code Generation → already told for the other three generated files. - Cons: the
grpc-ecosystem/openapiv2plugin targets OpenAPI v2 (Swagger 2.0), not the newer v3 — some OpenAPI tooling built specifically for v3 features won’t accept this document directly without a conversion step; documentation quality is entirely a function of the.protofile’s own comments, and The Contracts → never wrote any field- or RPC-level comments — so every operation and field in the generated document below genuinely has an empty description, a real, honest limitation worth naming rather than glossing over; there’s no way to add a curated example payload or a hand-written usage note without either going back and adding.protocomments (whichprotoc-gen-openapiv2does read) or post-processing the generated JSON, which would defeat the entire “auto-generated, always in sync” premise.
A hand-maintained OpenAPI document (or a separate Postman/Insomnia collection)
- Pros: free to write rich descriptions, curated examples, and prose explanations no code generator can infer from a
.protofile alone; can document behavior that isn’t visible in the schema at all (rate limits, auth flows, deprecation notices). - Cons: a second artifact that a
.protochange can silently leave stale — nothing enforces that a renamed field or a new endpoint gets reflected in hand-written docs, the exact drift problem schema-first.protocontracts exist to prevent in the first place; doubles the maintenance burden for the same information, once in the contract and once in prose.
This course keeps the generated document as-is — its emptiness in a few places is presented honestly below, rather than quietly worked around, because that trade-off is the actual cost of “docs for free.”
Set it up
Section titled “Set it up”1. Confirm the generated documents exist
Section titled “1. Confirm the generated documents exist”find gen -name '*.swagger.json'gen/shopmicro/catalog/v1/catalog.swagger.jsongen/shopmicro/order/v1/order.swagger.jsonIf buf generate hasn’t been run recently (or gen/ was cleaned), re-run it — Code Generation → covers exactly what it produces, and nothing in this lesson touches .proto files or plugin configuration at all.
2. Extend gateway/cmd/main.go — serve the two documents and a Swagger UI
Section titled “2. Extend gateway/cmd/main.go — serve the two documents and a Swagger UI”// Command gateway runs the HTTP/JSON reverse proxy in front of the Catalog// and Order gRPC services, generated from their google.api.http// annotations, and serves the generated OpenAPI documents plus a Swagger UI// at /docs.package main
import ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" orderv1 "github.com/avetavos/shopmicro/gen/shopmicro/order/v1" "github.com/avetavos/shopmicro/pkg/config" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure")
const swaggerUIPage = `<!DOCTYPE html><html> <head> <title>ShopMicro API Docs</title> <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" /> </head> <body> <div id="swagger-ui"></div> <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script> window.onload = () => { SwaggerUIBundle({ urls: [ { url: "/openapi/catalog.swagger.json", name: "Catalog" }, { url: "/openapi/order.swagger.json", name: "Order" }, ], dom_id: "#swagger-ui", }); }; </script> </body></html>`
func main() { ctx := context.Background()
httpAddr := config.Get("GATEWAY_HTTP_ADDR", ":8080") catalogAddr := config.Get("CATALOG_GRPC_ADDR", ":50051") orderAddr := config.Get("ORDER_GRPC_ADDR", ":50052")
mux := runtime.NewServeMux() opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := catalogv1.RegisterCatalogServiceHandlerFromEndpoint(ctx, mux, catalogAddr, opts); err != nil { log.Fatalf("gateway: register catalog handler: %v", err) } if err := orderv1.RegisterOrderServiceHandlerFromEndpoint(ctx, mux, orderAddr, opts); err != nil { log.Fatalf("gateway: register order handler: %v", err) }
root := http.NewServeMux() root.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) root.HandleFunc("/openapi/catalog.swagger.json", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "gen/shopmicro/catalog/v1/catalog.swagger.json") }) root.HandleFunc("/openapi/order.swagger.json", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "gen/shopmicro/order/v1/order.swagger.json") }) root.HandleFunc("/docs", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(swaggerUIPage)) }) root.Handle("/", mux)
srv := &http.Server{ Addr: httpAddr, Handler: root, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, }
go func() { log.Printf("gateway: HTTP listening on %s", httpAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("gateway: serve: %v", err) } }()
stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop
log.Println("gateway: shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Fatalf("gateway: shutdown: %v", err) }}Save this over gateway/cmd/main.go. The only change from grpc-gateway →‘s version: three new routes registered on the same root mux, before the catch-all root.Handle("/", mux). Everything else — the gRPC dials, the http.Server timeouts, the goroutine, the signal handling — is unchanged. A few details worth calling out:
http.ServeFile(w, r, path), nothttp.FileServerover the wholegen/tree. Serving each generated document through its own explicit handler means only these two exact files are ever exposed at/openapi/...— mounting aFileServerover all ofgen/would also serve.pb.go/.swagger.jsonsibling files and the entire generated tree’s directory structure, none of which should be reachable over HTTP.- The Swagger UI is a CDN-hosted bundle, not a new Go module dependency.
swaggerUIPageis a plain HTML string served withContent-Type: text/html— no template engine, no embedded static assets, no new entry ingo.mod.SwaggerUIBundle’surlsoption (an array of{url, name}pairs, not the singularurl) is what renders both documents behind a single dropdown in one page, since this course fronts two independently generated OpenAPI documents rather than one merged one. /docsand/openapi/*.swagger.jsonare registered onroot, the plainhttp.ServeMux, exactly like/healthz. None of these three routes are gRPC-gateway concerns at all — they’re ordinary static-ish HTTP responses that happen to live in the same binary as the reverse proxy, which is exactly whyrootwrapsmuxinstead of the other way around, a distinction grpc-gateway → already set up.
Verify
Section titled “Verify”Run all three services, then the gateway:
go run ./services/catalog/cmdgo run ./services/order/cmdgo run ./gateway/cmdFetch the raw OpenAPI document and confirm it’s a real Swagger 2.0 document:
curl -s localhost:8080/openapi/catalog.swagger.json | head -c 200{"swagger":"2.0","info":{"title":"catalog.proto","version":"version not set"},"tags":[{"name":"CatalogService"}],"consumes":["application/json"],"produces":["application/json"],"paths":{"/v1/products":{"get":{"operationId""swagger": "2.0" confirms this is OpenAPI v2, exactly as the plugin choice in Code Generation → produces — "info":{"title":"catalog.proto", ...} and the empty per-operation descriptions further down the document are the direct, visible consequence of catalog.proto never having any .proto comments to draw from.
Open http://localhost:8080/docs in a browser. The page loads Swagger UI from the CDN and shows a dropdown with Catalog and Order; switching to Catalog and expanding GET /v1/products, then clicking Try it out → Execute, sends the exact same request curl -s localhost:8080/v1/products did in grpc-gateway → — because it’s hitting the same gateway, over the same REST surface, described by the same generated document.
Finally, confirm the whole module still builds:
go build ./...No output means success.
gateway/cmd/main.go now serves catalog.swagger.json and order.swagger.json — Code Generation →‘s grpc-ecosystem/openapiv2 output, unchanged — at /openapi/catalog.swagger.json and /openapi/order.swagger.json via plain http.ServeFile handlers, and a CDN-embedded Swagger UI at /docs that renders both documents behind one dropdown using SwaggerUIBundle’s urls array. None of it required a new Go dependency or a new documentation-authoring effort: the OpenAPI documents are exactly as current as the .proto files that drove every other artifact in this module, at the honest cost of Swagger 2.0 (not v3) and empty per-field descriptions, since The Contracts → never wrote proto comments for protoc-gen-openapiv2 to draw from. That closes Module 5: a single gateway process now fronts Catalog and Order with a REST/JSON API generated entirely from their .proto contracts, self-documenting at /docs, with the exact REST mapping and status-code behavior REST Mapping → already verified end-to-end. Next, Kafka (Event Stream) → — Module 6 — is where the outbox rows The Transactional Outbox → has been writing all along finally get published somewhere.