Skip to content

OpenAPI Documentation

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.

Generated-from-proto OpenAPI docs (what this course does)

  • Pros: always in sync with the real REST surface by construction — the same buf generate step that regenerates catalog.pb.gw.go regenerates catalog.swagger.json in the same pass, so there’s no separate documentation-update step to forget; zero authoring effort beyond the .proto file 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/openapiv2 plugin 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 .proto file’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 .proto comments (which protoc-gen-openapiv2 does 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 .proto file 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 .proto change 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 .proto contracts 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.”

Terminal window
find gen -name '*.swagger.json'
gen/shopmicro/catalog/v1/catalog.swagger.json
gen/shopmicro/order/v1/order.swagger.json

If 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), not http.FileServer over the whole gen/ tree. Serving each generated document through its own explicit handler means only these two exact files are ever exposed at /openapi/... — mounting a FileServer over all of gen/ would also serve .pb.go/.swagger.json sibling 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. swaggerUIPage is a plain HTML string served with Content-Type: text/html — no template engine, no embedded static assets, no new entry in go.mod. SwaggerUIBundle’s urls option (an array of {url, name} pairs, not the singular url) 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.
  • /docs and /openapi/*.swagger.json are registered on root, the plain http.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 why root wraps mux instead of the other way around, a distinction grpc-gateway → already set up.

Run all three services, then the gateway:

Terminal window
go run ./services/catalog/cmd
go run ./services/order/cmd
go run ./gateway/cmd

Fetch the raw OpenAPI document and confirm it’s a real Swagger 2.0 document:

Terminal window
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 outExecute, 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:

Terminal window
go build ./...

No output means success.

gateway/cmd/main.go now serves catalog.swagger.json and order.swagger.jsonCode 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.