Serve API docs from your own server¶
By the end of this you'll have a Go server that answers a real API request, serves
its OpenAPI document at /openapi.yaml, and serves an interactive reference at
/docs/ where you can call that endpoint from the browser without any CORS setup.
Allow about fifteen minutes. Everything runs locally; nothing is published.
What you need first¶
- Go 1.26.5 or newer. The module's
go.moddeclaresgo 1.26.5, so an older toolchain refuses to build it. curl, and a browser for the last step.- No OpenAPI generator. You'll hand-write a short spec here so the moving parts stay visible. In a real project the spec is usually generated — from protobuf annotations, from route metadata, from whatever describes your API — and nothing in this tutorial changes when it is.
Create the project¶
mkdir greeting && cd greeting
go mod init greeting
go get gitlab.com/phpboyscout/go/transport-openapi
Write the OpenAPI document¶
Save this as openapi.yaml next to go.mod. It describes one endpoint — a GET
that takes an optional name query parameter and returns a JSON object.
openapi: 3.0.3
info:
title: Greeting API
version: 0.1.0
paths:
/v1/greeting:
get:
summary: Return a greeting
operationId: getGreeting
parameters:
- name: name
in: query
required: false
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
message:
type: string
Register does not parse or validate this document — it serves the bytes you hand
it. A typo here shows up as a broken page in the docs UI rather than as a startup
error, so it's worth a quick read before you move on.
Write the server¶
Save this as main.go. It does three things: serves the greeting endpoint, embeds
the spec into the binary with //go:embed, and hands both the mux and the spec to
openapi.Register.
package main
import (
"encoding/json"
_ "embed"
"log"
"net/http"
openapi "gitlab.com/phpboyscout/go/transport-openapi"
)
//go:embed openapi.yaml
var spec []byte
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/greeting", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"message": "hello, " + name})
})
if err := openapi.Register(mux, spec, openapi.WithTitle("Greeting API")); err != nil {
log.Fatalf("mounting API docs: %v", err)
}
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The //go:embed line is what stops the spec being a runtime dependency: the
generated document is compiled into the binary, so there's no file to ship alongside
it and no path to get wrong in production.
Everything the docs site needs beyond that — the Stoplight Elements JavaScript and CSS, about 2.4 MB of it — is already embedded in the module. You never vendor it yourself.
Run it and check the three routes¶
You should see:
In another terminal, call the API:
Then the spec, which comes back exactly as you wrote it:
And the docs page, which is HTML and carries security headers you did not have to ask for:
HTTP/1.1 200 OK
Content-Security-Policy: frame-ancestors 'none'
Content-Type: text/html; charset=utf-8
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Those four headers are on by default for the docs and spec routes, and the console works with them in place. You can change or remove them, but you don't need to here.
Call your API from the docs page¶
Open http://localhost:8080/docs/ in a browser. The sidebar lists Return a
greeting under ENDPOINTS, and the browser tab reads "Greeting API" — that came
from WithTitle.
Pick the endpoint, open the Try It panel, put phpboyscout in the name field
and send it. The response body is the same JSON curl returned a moment ago,
because the console called the same server that served the page.
That last part is the point of mounting the docs on your own mux. The console
issues its request to localhost:8080, which is where the page came from, so the
browser treats it as same-origin and no CORS headers are involved at all. Publish
the same docs on a separate static host and that request becomes cross-origin, and
you're into Access-Control-Allow-Origin and — as soon as auth is involved —
credentials policy. Same-origin docs by design
goes into why that trade is worth making.
Move the routes under a version prefix¶
One thing worth doing before you leave: the default paths are rarely the ones you
want in a versioned API. Change the Register call to
if err := openapi.Register(mux, spec,
openapi.WithSpecPath("/v1/openapi.yaml"),
openapi.WithDocsPath("/v1/docs/"),
openapi.WithTitle("Greeting API v1"),
); err != nil {
log.Fatalf("mounting API docs: %v", err)
}
Restart, and the spec is at /v1/openapi.yaml and the docs at /v1/docs/. You only
set the paths — the generated page is wired to whatever WithSpecPath resolves to,
so the two cannot drift apart.
WithDocsPath must end in a slash. "/v1/docs" without one does not fall back
to anything sensible: it panics inside net/http while Register is building the
route patterns, before your server ever listens. The options
reference lists the path
mistakes that behave this way.
Where to go next¶
- Customise the spec and docs paths — including serving several API versions from one server.
- Serve on a go/transport server — the same mux, wired into lifecycle, health endpoints and middleware.
- Options reference — every option, its default, and what happens when it is wrong.
- Limitations — what this deliberately does not do, worth reading before you design around it.