Skip to content

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.mod declares go 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

go run .

You should see:

2026/08/02 18:57:43 listening on http://localhost:8080

In another terminal, call the API:

curl -s 'http://localhost:8080/v1/greeting?name=phpboyscout'
{"message":"hello, phpboyscout"}

Then the spec, which comes back exactly as you wrote it:

curl -s http://localhost:8080/openapi.yaml | head -4
openapi: 3.0.3
info:
  title: Greeting API
  version: 0.1.0

And the docs page, which is HTML and carries security headers you did not have to ask for:

curl -s -D- -o /dev/null http://localhost:8080/docs/
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