# Part 16: OpenAPI: a contract that can't drift, and a docs page you can trust

*Docs rot. Here is how to publish an OpenAPI spec and an interactive UI from the same binary, then make it impossible for the spec and the code to disagree without a test failing.*

---

**Series:** Build a production-grade REST API in Go · **Part 16 of 17** **Where we are:** the idempotent API from Part 15.

Until now, the only description of our API has been prose, plus whatever sample requests someone keeps by hand. That has three problems.

1. **Humans can't explore it.** A new teammate wants to click around, not read 15 posts.
2. **Machines can't read it.** Client generators, gateways, linters and mock servers all want a structured description.
3. **Documentation drifts.** The day someone renames a field and forgets the docs, the docs become a lie, and a wrong doc is worse than none.

The industry answer to the first two is an **OpenAPI document**: a YAML/JSON file describing every endpoint, parameter, request body, response and error. This post adds one, serves it with an interactive UI, and, more importantly, solves problem 3 with tests.

## Decision: where does the spec come from?

There are three common strategies in Go. We should choose deliberately.

| Approach | How | Cost |
| --- | --- | --- |
| **Annotations** (e.g. `swaggo`) | comments above handlers generate the spec | silently wrong when a comment is forgotten; the spec is not checked against reality |
| **Code generation** (e.g. `oapi-codegen`) | the spec generates server interfaces and types | your handler style is dictated by the generator; big change to existing code |
| **Hand-written spec + contract tests** | you write the YAML; tests run the real server against it | you write YAML, but it stays readable, reviewable, and *verified* |

We keep our handlers as they are and take the third route. The tests (below) make "the spec and the code disagree" a build failure instead of a surprise.

## Step 1: write the spec

The whole file is `api/openapi.yaml` (about 500 lines). Its shape:

`api/openapi.yaml (lines 29-45)`

```yaml
servers:
  - url: /
    description: This server (same origin as this document)
  - url: https://localhost:7800
    description: Local development (docker compose)
tags:
  - name: Auth
    description: Accounts and tokens
  - name: Users
    description: Profiles. Admins can see everyone; users only themselves.
  - name: Orders
    description: Orders belong to the user who created them. Admins can see all.

security:
  - bearerAuth: []

paths:
```

- `servers`: `/` means "wherever this document was loaded from", which makes the "Try it out" button hit the right origin from any hostname. The second entry is for tools that need an absolute URL.
- `security`: a *default* requirement that every operation needs a bearer token. Public operations opt out with `security: []`.

One operation, to see the anatomy:

`api/openapi.yaml (lines 46-74)`

```yaml
  /auth/register:
    post:
      tags: [Auth]
      operationId: register
      summary: Create an account
      description: Creates a user (role `user`) and returns tokens. The role cannot be chosen by the caller.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterRequest' }
      responses:
        '201':
          description: Account created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: The email is already registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
```

Notice how much is **reused by reference**. Errors that appear on many operations are defined once under `components/responses` and referenced with `$ref`:

`api/openapi.yaml (lines 373-387)`

```yaml
  responses:
    BadRequest:
      description: Malformed JSON, unknown field, or an invalid id, limit, cursor or header
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, malformed, expired or forged access token
      headers:
        WWW-Authenticate:
          required: true
          description: How to authenticate
          schema: { type: string }
      content:
        application/json:
```

The `Unauthorized` response declares a **required** `WWW-Authenticate` **header**, matching what our `Authenticate` middleware sets. Likewise `TooManyRequests` declares a required `Retry-After`. Headers are part of the contract too.

Request schemas mirror our strict decoding from Part 3:

`api/openapi.yaml (lines 459-466)`

```yaml
    RegisterRequest:
      type: object
      additionalProperties: false
      required: [name, email, password]
      properties:
        name: { type: string, minLength: 1, maxLength: 100, example: Ann }
        email: { type: string, format: email, maxLength: 254, example: ann@example.com }
        password: { type: string, minLength: 10, maxLength: 72, description: 10 to 72 bytes, format: password }
```

`additionalProperties: false` says "unknown fields are invalid", exactly what `DisallowUnknownFields` enforces. The limits (`maxLength: 100`, `minLength: 10`...) repeat the validation rules, so a generated client can reject bad input before sending it.

### Two details that bit me while writing it

**YAML flow mappings and commas.** I wrote `access_token: { type: string, description: Short-lived JWT, send as a Bearer token }`. In `{ ... }` form a comma separates *keys*, so YAML read `send as a Bearer token` as a stray key. The very first test run failed with `extra sibling fields: [send as a Bearer token]`. Quote any description containing a comma. (This is why the first test below is "is the file valid OpenAPI?")

**Never use** `default:` **for errors.** It is tempting to end every operation with `default: $ref InternalError`. But `default` means "any status not listed", so *every* undeclared status code, a surprise `409` or `418`, looks documented. We list `'500'` explicitly and have no `default` anywhere. You'll see why in the mutation tests.

## Step 2: embed it and serve it

A spec file that has to be deployed next to the binary can be missing, stale or mismatched. Go's `embed` compiles it in:

**Create** `api/embed.go`

```go
// Package api embeds the OpenAPI document so the server can serve exactly the
// contract it was built (and tested) against: no file to deploy, no way for the
// spec and the binary to drift apart on a running system.
package api

import _ "embed"

// Spec is the OpenAPI 3.0 document (openapi.yaml).
//
//go:embed openapi.yaml
var Spec []byte
```

Now the server always serves *exactly* the contract it was built and tested with. The handler:

`internal/docs/docs.go (excerpt)`

```go
// Mount registers:
//
//	GET /openapi.yaml    the spec
//	GET /docs            the UI (/docs/ redirects to it)
//	GET /docs/assets/*   the UI's scripts, styles and icon
//
// None of these require authentication: the documentation of a public API is
// public, and "Try it out" still needs a token for protected operations. If the
// spec should not be public, don't mount it (DOCS_ENABLED=false) or put it
// behind your gateway.
func Mount(mux *http.ServeMux, spec []byte) {
	sum := sha256.Sum256(spec)
	etag := `"` + hex.EncodeToString(sum[:8]) + `"`

	mux.HandleFunc("GET /openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Content-Type", "application/yaml; charset=utf-8")
		h.Set("Cache-Control", "public, max-age=300")
		h.Set("ETag", etag)
		if r.Header.Get("If-None-Match") == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		_, _ = w.Write(spec)
	})

	sub, err := fs.Sub(assets, "swaggerui")
	if err != nil {
		panic(err) // the embedded directory is a compile-time constant
	}
	index, err := fs.ReadFile(sub, "index.html")
	if err != nil {
		panic(err)
	}

	mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
		h := w.Header()
		h.Set("Content-Type", "text/html; charset=utf-8")
		h.Set("Content-Security-Policy", csp)
		h.Set("Referrer-Policy", "no-referrer")
		_, _ = w.Write(index)
	})

	// "/docs/" is what people type and what many links produce; send them to the page.
	mux.HandleFunc("GET /docs/{$}", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/docs", http.StatusMovedPermanently)
	})

	files := http.StripPrefix("/docs/assets/", http.FileServerFS(sub))
	mux.HandleFunc("GET /docs/assets/", func(w http.ResponseWriter, r *http.Request) {
		// Versioned third-party files that never change between releases of the
		// binary: let browsers keep them (the global default is no-store).
		w.Header().Set("Cache-Control", "public, max-age=3600")
		files.ServeHTTP(w, r)
	})
}
```

- The spec carries an `ETag` (a hash) and answers `304 Not Modified` to conditional requests.
- `application/yaml` is the right content type for YAML.

## Step 3: the docs page (Swagger UI), served safely

Swagger UI is the best-known OpenAPI browser: it lists operations, lets you paste a token under **Authorize**, and **Try it out** sends real requests. We *vendor* its files (copied unmodified from the `swagger-ui-dist` package, license files included) and embed them like the spec. That means:

- no request to a CDN when someone opens the page (works offline, no third party sees your visitors, no supply-chain surprise if a CDN file changes);
- the UI version is pinned in your project.

These are third-party files (about 1.7 MB of minified JavaScript), so you download them instead of pasting them. From the project root:

```bash
npm pack swagger-ui-dist@5.34.0
tar xzf swagger-ui-dist-5.34.0.tgz
mkdir -p internal/docs/swaggerui
cp package/swagger-ui-bundle.js package/swagger-ui-bundle.js.LICENSE.txt package/swagger-ui.css \
   package/favicon-32x32.png package/LICENSE package/NOTICE internal/docs/swaggerui/
rm -rf package swagger-ui-dist-5.34.0.tgz
```

(Swagger UI is Apache-2.0 licensed; keep its `LICENSE` and `NOTICE` next to the files.)

The page is the only place in this service that serves HTML, so it gets a strict **Content-Security-Policy**:

**Create** `internal/docs/docs.go`

```go
// Package docs serves the OpenAPI document and an interactive UI (Swagger UI).
//
// Everything is embedded in the binary: no CDN, no third-party request when
// someone opens the page, and it works offline and in locked-down networks.
package docs

import (
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"io/fs"
	"net/http"
)

//go:embed swaggerui
var assets embed.FS

// csp is deliberately strict. The UI loads only scripts from our own origin (no
// inline script: that is why the page and the initializer are separate files),
// and may only call our own origin ("Try it out"). Swagger UI sets inline
// styles at runtime, which is the one reason for 'unsafe-inline' on styles.
const csp = "default-src 'none'; " +
	"script-src 'self'; " +
	"style-src 'self' 'unsafe-inline'; " +
	"img-src 'self' data:; " +
	"font-src 'self' data:; " +
	"connect-src 'self'; " +
	"base-uri 'none'; " +
	"form-action 'none'; " +
	"frame-ancestors 'none'"

// Mount registers:
//
//	GET /openapi.yaml    the spec
//	GET /docs            the UI (/docs/ redirects to it)
//	GET /docs/assets/*   the UI's scripts, styles and icon
//
// None of these require authentication: the documentation of a public API is
// public, and "Try it out" still needs a token for protected operations. If the
// spec should not be public, don't mount it (DOCS_ENABLED=false) or put it
// behind your gateway.
func Mount(mux *http.ServeMux, spec []byte) {
	sum := sha256.Sum256(spec)
	etag := `"` + hex.EncodeToString(sum[:8]) + `"`

	mux.HandleFunc("GET /openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Content-Type", "application/yaml; charset=utf-8")
		h.Set("Cache-Control", "public, max-age=300")
		h.Set("ETag", etag)
		if r.Header.Get("If-None-Match") == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		_, _ = w.Write(spec)
	})

	sub, err := fs.Sub(assets, "swaggerui")
	if err != nil {
		panic(err) // the embedded directory is a compile-time constant
	}
	index, err := fs.ReadFile(sub, "index.html")
	if err != nil {
		panic(err)
	}

	mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
		h := w.Header()
		h.Set("Content-Type", "text/html; charset=utf-8")
		h.Set("Content-Security-Policy", csp)
		h.Set("Referrer-Policy", "no-referrer")
		_, _ = w.Write(index)
	})

	// "/docs/" is what people type and what many links produce; send them to the page.
	mux.HandleFunc("GET /docs/{$}", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/docs", http.StatusMovedPermanently)
	})

	files := http.StripPrefix("/docs/assets/", http.FileServerFS(sub))
	mux.HandleFunc("GET /docs/assets/", func(w http.ResponseWriter, r *http.Request) {
		// Versioned third-party files that never change between releases of the
		// binary: let browsers keep them (the global default is no-store).
		w.Header().Set("Cache-Control", "public, max-age=3600")
		files.ServeHTTP(w, r)
	})
}
```

Read the policy as a whitelist (`default-src 'none'` forbids everything, then we allow only what the page needs):

| Directive | Meaning here |
| --- | --- |
| `script-src 'self'` | scripts only from our own origin: no inline scripts, no third-party scripts, no `eval` |
| `connect-src 'self'` | "Try it out" may only call *this* server |
| `frame-ancestors 'none'` | nobody can embed the page in a frame (clickjacking) |
| `style-src 'self' 'unsafe-inline'` | the one concession: Swagger UI sets inline styles at runtime |

Because inline scripts are forbidden, the page is plain HTML that loads two scripts, the bundle you just downloaded and a *separate* initializer file of ours:

**Create** `internal/docs/swaggerui/index.html`

```plaintext
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>rest-go API</title>
  <link rel="icon" type="image/png" href="/docs/assets/favicon-32x32.png">
  <link rel="stylesheet" href="/docs/assets/swagger-ui.css">
</head>
<body>
  <div id="swagger-ui"></div>
  <!-- No inline script: the page's Content-Security-Policy only allows scripts from our own origin. -->
  <script src="/docs/assets/swagger-ui-bundle.js"></script>
  <script src="/docs/assets/swagger-initializer.js"></script>
</body>
</html>
```

**Create** `internal/docs/swaggerui/swagger-initializer.js`

```plaintext
window.onload = function () {
  window.ui = SwaggerUIBundle({
    url: "/openapi.yaml",         // same origin: the spec is embedded in the server binary
    dom_id: "#swagger-ui",
    deepLinking: true,
    displayRequestDuration: true,
    filter: true,
    docExpansion: "list",
    tryItOutEnabled: true,
    persistAuthorization: false,  // never keep a bearer token in localStorage
  });
};
```

`persistAuthorization: false` is deliberate. Swagger UI has an option to remember the pasted token in `localStorage`, where any future XSS bug could read it. It is off by default, and we pin it off explicitly so nobody turns it on later without noticing.

Is it public? Yes by default, since the spec documents a public API. `DOCS_ENABLED=false` removes both `/docs` and `/openapi.yaml`; or protect them at your gateway.

## Step 4: the contract tests

This is the heart of the post. Three levels, each catching a different kind of drift. Everything lives in one self-contained test file, `internal/contract/contract_test.go`, which starts its own copy of the API against a throwaway PostgreSQL schema (the `testdb` helper from Part 6). The excerpts below walk through it; the complete file is at the end of this step.

### Level 1: the spec is valid OpenAPI

`internal/contract/contract_test.go (excerpt)`

```go
func loadSpec(t *testing.T) *openapi3.T {
	t.Helper()
	doc, err := openapi3.NewLoader().LoadFromData(api.Spec)
	if err != nil {
		t.Fatalf("spec does not parse: %v", err)
	}
	if err := doc.Validate(context.Background()); err != nil {
		t.Fatalf("spec is not valid OpenAPI: %v", err)
	}
	return doc
}
```

We use [kin-openapi](https://github.com/getkin/kin-openapi), a Go library that parses and validates OpenAPI and, crucially, can validate real HTTP requests and responses against it.

### Level 2: the spec and the router list the same operations

To compare, we need the list of routes the server registers. `http.ServeMux` can't enumerate its patterns, but `SetupRoutes` only ever calls one method on it. So we shrank its parameter to that one-method interface:

`internal/routes/routes.go (excerpt)`

```go
// Registrar is the part of *http.ServeMux that SetupRoutes needs. Depending on a
// one-method interface lets tests record which routes exist and compare them with
// the OpenAPI document.
type Registrar interface {
	Handle(pattern string, h http.Handler)
}
```

`*http.ServeMux` satisfies it automatically (no change at the call site in `main`), and a test can pass a recorder:

`internal/contract/contract_test.go (excerpt)`

```go
func TestSpecAndRouterListTheSameOperations(t *testing.T) {
	doc := loadSpec(t)

	rec := &recorder{}
	routes.SetupRoutes(rec, routes.Deps{
		Auth: &handlers.AuthHandler{}, Users: &handlers.UserHandler{}, Orders: &handlers.OrderHandler{},
		Authenticate: func(h http.Handler) http.Handler { return h },
	})
	sort.Strings(rec.patterns)

	got, want := strings.Join(rec.patterns, "\n"), strings.Join(specOperations(doc), "\n")
	if got != want {
		t.Fatalf("routes and OpenAPI operations differ.\n--- registered routes ---\n%s\n--- documented operations ---\n%s", got, want)
	}
}
```

An undocumented route fails. A documented route that doesn't exist fails. This is the "someone added an endpoint and forgot the docs" test.

### Level 3: the real server behaves as the spec says

A wrapper sends each request to the real server (real router, handlers, database) and checks both directions:

`internal/contract/contract_test.go (excerpt)`

```go
// call sends a request, validates the RESPONSE against the spec, and, unless
// invalid is set (for requests that are wrong on purpose), the REQUEST too.
func (c *contract) call(want int, method, path, token, body string, headers map[string]string, invalid bool) (string, http.Header) {
	c.t.Helper()
	build := func() *http.Request {
		req, _ := http.NewRequest(method, c.a.srv.URL+path, strings.NewReader(body))
		if body != "" {
			req.Header.Set("Content-Type", "application/json")
		}
		if token != "" {
			req.Header.Set("Authorization", "Bearer "+token)
		}
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		return req
	}

	vreq := build() // a separate copy for the validator, which consumes the body
	route, pathParams, err := c.router.FindRoute(vreq)
	if err != nil {
		c.t.Fatalf("%s %s is not in the OpenAPI document: %v", method, path, err)
	}
	c.visited[route.Method+" "+route.Path] = true
	rvi := &openapi3filter.RequestValidationInput{
		Request: vreq, PathParams: pathParams, Route: route,
		Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc},
	}
	if !invalid {
		if err := openapi3filter.ValidateRequest(context.Background(), rvi); err != nil {
			c.t.Fatalf("%s %s: request violates the spec: %v", method, path, err)
		}
	}

	resp, err := http.DefaultClient.Do(build())
	if err != nil {
		c.t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != want {
		c.t.Fatalf("%s %s: status %d, want %d (%s)", method, path, resp.StatusCode, want, b)
	}
	// IncludeResponseStatus makes a status code the operation does not declare a failure.
	err = openapi3filter.ValidateResponse(context.Background(), &openapi3filter.ResponseValidationInput{
		RequestValidationInput: rvi, Status: resp.StatusCode, Header: resp.Header,
		Body: io.NopCloser(bytes.NewReader(b)), Options: &openapi3filter.Options{IncludeResponseStatus: true},
	})
	if err != nil {
		c.t.Fatalf("%s %s -> %d: response violates the spec: %v\nbody: %s", method, path, resp.StatusCode, err, b)
	}
	return string(b), resp.Header
}
```

For every exchange it checks the **request** against the spec (unless the test is deliberately sending something invalid) and the **response**: the status code is one the operation declares (`IncludeResponseStatus`), the body matches the schema, and declared headers are present. Then a scripted scenario walks the whole API (register, login, refresh and reuse, orders with idempotency keys, pagination, users, admin) and finally asserts that **every operation in the spec was called at least once**:

`internal/contract/contract_test.go (lines 257-280)`

```go
func TestServerBehavesAsTheSpecDescribes(t *testing.T) {
	a := newApp(t, 0)
	c := newContract(t, a)
	good := `{"name":"Ann","email":"ann@example.com","password":"correct-horse-battery"}`

	// ---- auth
	reg := c.ok(201, "POST", "/auth/register", "", good)
	var s session
	mustJSON(t, reg, &s)
	c.ok(409, "POST", "/auth/register", "", good)
	c.bad(422, "POST", "/auth/register", "", `{"name":"x","email":"nope","password":"short"}`)
	c.bad(400, "POST", "/auth/register", "", `{`)
	c.bad(400, "POST", "/auth/register", "", `{"name":"x","email":"b@example.com","password":"correct-horse-battery","role":"admin"}`)
	c.bad(413, "POST", "/auth/register", "", `{"name":"`+strings.Repeat("a", 2<<10)+`","email":"b@example.com","password":"correct-horse-battery"}`)

	c.ok(200, "POST", "/auth/login", "", `{"email":"ann@example.com","password":"correct-horse-battery"}`)
	c.ok(401, "POST", "/auth/login", "", `{"email":"ann@example.com","password":"wrong-password-xx"}`)
	c.bad(400, "POST", "/auth/login", "", `{`)

	c.ok(200, "GET", "/auth/me", s.AccessToken, "")
	c.bad(401, "GET", "/auth/me", "", "")
	c.bad(401, "GET", "/auth/me", "not.a.token", "")

	var rotated session
```

(The scenario continues for another ~100 lines through orders, users and admin; it is in the complete file at the end of this step.)

A second test provokes a genuine `429` and validates it, which checks that `Retry-After` really is present as the spec promises.

### Do these tests actually have teeth?

A test you never watched fail proves little. So I broke the spec on purpose, three ways, and ran the suite after each:

| Deliberate mistake | Result |
| --- | --- |
| spec says `Order.quantity` is a **string** (the server returns a number) | ✗ `response body doesn't match schema ... /quantity: value must be a string` |
| spec forgets to declare `409` on `/auth/register` | first attempt: **tests passed** (a hole, explained below). After fixing the hole: ✗ `status is not supported` |
| spec documents `/auth/whoami` (the server has `/auth/me`) | ✗ `routes and OpenAPI operations differ` |

The second one is the instructive failure *of the tests*: it slipped through, because of that `default:` response from earlier, which made every status "declared". Replacing `default` with an explicit `'500'` closed the gap, and the same mutation then failed with `status is not supported`. **Mutation-test your tests**: deliberately introduce the bug a test claims to catch, and make sure it goes red.

### The complete test file

**Create** `internal/contract/contract_test.go`

```go
package contract_test

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/getkin/kin-openapi/openapi3filter"
	"github.com/getkin/kin-openapi/routers"
	"github.com/getkin/kin-openapi/routers/legacy"

	"go-rest/api"
	"go-rest/internal/auth"
	"go-rest/internal/handlers"
	"go-rest/internal/middleware"
	"go-rest/internal/ratelimit"
	"go-rest/internal/routes"
	"go-rest/internal/storage"
	"go-rest/internal/testdb"
)

// The OpenAPI document is a CONTRACT. These tests make it impossible for the code
// and the document to drift apart unnoticed:
//
//  1. the document is a valid OpenAPI 3 file;
//  2. the routes the server registers and the operations the document lists are
//     exactly the same set (an undocumented route or a phantom operation fails);
//  3. a scripted scenario runs against the REAL server and every request and
//     response is validated against the document, including "this status code is
//     not declared for this operation" and the declared response headers.

// ---- a minimal running copy of the API, wired exactly like main.go does it ----

type session struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	User         struct {
		ID int `json:"id"`
	} `json:"user"`
}

type app struct {
	t      *testing.T
	srv    *httptest.Server
	users  *storage.UserStore
	hasher *auth.Hasher
}

// newApp starts the real router and handlers on a fresh, migrated PostgreSQL schema.
// userBurst > 0 turns on the per-user rate limiter with that burst (and a refill so
// slow that no token comes back during a test).
func newApp(t *testing.T, userBurst int) *app {
	t.Helper()
	pool := testdb.New(t)
	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	limits := handlers.Limits{MaxBodyBytes: 1 << 10, MaxNameLen: 100, DefaultPageSize: 20, MaxPageSize: 3}
	tm, _ := auth.NewTokenManager("0123456789abcdef0123456789abcdef", "test", time.Minute)
	hasher, _ := auth.NewHasher(4) // bcrypt's minimum cost: fast tests
	users := storage.NewUserStore(pool)

	deps := routes.Deps{
		Auth:         handlers.NewAuthHandler(users, storage.NewTokenStore(pool), hasher, tm, time.Hour, log, limits),
		Users:        handlers.NewUserHandler(users, log, limits),
		Orders:       handlers.NewOrderHandler(storage.NewOrderStore(pool), log, limits),
		Authenticate: middleware.Authenticate(tm, log),
	}
	if userBurst > 0 {
		deps.UserLimit = middleware.RateLimit(middleware.RateLimitOptions{
			Limiter: ratelimit.New(1.0/3600, userBurst, time.Hour, 1000),
			Key: func(r *http.Request) string {
				if p, ok := auth.FromContext(r.Context()); ok {
					return fmt.Sprint("u:", p.UserID)
				}
				return ""
			},
		})
	}
	mux := http.NewServeMux()
	routes.SetupRoutes(mux, deps)
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return &app{t: t, srv: srv, users: users, hasher: hasher}
}

func (a *app) post(path, body string) session {
	a.t.Helper()
	resp, err := http.Post(a.srv.URL+path, "application/json", strings.NewReader(body))
	if err != nil {
		a.t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 300 {
		a.t.Fatalf("POST %s: %d %s", path, resp.StatusCode, b)
	}
	var s session
	_ = json.Unmarshal(b, &s)
	return s
}

func (a *app) register(email string) session {
	return a.post("/auth/register", fmt.Sprintf(`{"name":"n","email":%q,"password":"correct-horse-battery"}`, email))
}

// admin creates an admin directly in the database (there is deliberately no HTTP way to
// become one) and logs in through the API like a real admin would.
func (a *app) admin(email string) session {
	a.t.Helper()
	hash, _ := a.hasher.Hash("admin-password-123")
	if _, err := a.users.UpsertAdmin(context.Background(), "root", email, hash); err != nil {
		a.t.Fatal(err)
	}
	return a.post("/auth/login", fmt.Sprintf(`{"email":%q,"password":"admin-password-123"}`, email))
}

// ---- the contract tests ----

func loadSpec(t *testing.T) *openapi3.T {
	t.Helper()
	doc, err := openapi3.NewLoader().LoadFromData(api.Spec)
	if err != nil {
		t.Fatalf("spec does not parse: %v", err)
	}
	if err := doc.Validate(context.Background()); err != nil {
		t.Fatalf("spec is not valid OpenAPI: %v", err)
	}
	return doc
}

func specOperations(doc *openapi3.T) []string {
	var ops []string
	for path, item := range doc.Paths.Map() {
		for method := range item.Operations() {
			ops = append(ops, method+" "+path)
		}
	}
	sort.Strings(ops)
	return ops
}

type recorder struct{ patterns []string }

func (r *recorder) Handle(pattern string, _ http.Handler) { r.patterns = append(r.patterns, pattern) }

func TestSpecIsValidOpenAPI(t *testing.T) { loadSpec(t) }

func TestSpecAndRouterListTheSameOperations(t *testing.T) {
	doc := loadSpec(t)

	rec := &recorder{}
	routes.SetupRoutes(rec, routes.Deps{
		Auth: &handlers.AuthHandler{}, Users: &handlers.UserHandler{}, Orders: &handlers.OrderHandler{},
		Authenticate: func(h http.Handler) http.Handler { return h },
	})
	sort.Strings(rec.patterns)

	got, want := strings.Join(rec.patterns, "\n"), strings.Join(specOperations(doc), "\n")
	if got != want {
		t.Fatalf("routes and OpenAPI operations differ.\n--- registered routes ---\n%s\n--- documented operations ---\n%s", got, want)
	}
}

// contract wraps the test server so every exchange is checked against the spec.
type contract struct {
	t       *testing.T
	a       *app
	router  routers.Router
	visited map[string]bool
}

func newContract(t *testing.T, a *app) *contract {
	doc := loadSpec(t)
	doc.Servers = nil // match any host:port (the test server picks a random one)
	r, err := legacy.NewRouter(doc)
	if err != nil {
		t.Fatal(err)
	}
	return &contract{t: t, a: a, router: r, visited: map[string]bool{}}
}

// call sends a request, validates the RESPONSE against the spec, and, unless
// invalid is set (for requests that are wrong on purpose), the REQUEST too.
func (c *contract) call(want int, method, path, token, body string, headers map[string]string, invalid bool) (string, http.Header) {
	c.t.Helper()
	build := func() *http.Request {
		req, _ := http.NewRequest(method, c.a.srv.URL+path, strings.NewReader(body))
		if body != "" {
			req.Header.Set("Content-Type", "application/json")
		}
		if token != "" {
			req.Header.Set("Authorization", "Bearer "+token)
		}
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		return req
	}

	vreq := build() // a separate copy for the validator, which consumes the body
	route, pathParams, err := c.router.FindRoute(vreq)
	if err != nil {
		c.t.Fatalf("%s %s is not in the OpenAPI document: %v", method, path, err)
	}
	c.visited[route.Method+" "+route.Path] = true
	rvi := &openapi3filter.RequestValidationInput{
		Request: vreq, PathParams: pathParams, Route: route,
		Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc},
	}
	if !invalid {
		if err := openapi3filter.ValidateRequest(context.Background(), rvi); err != nil {
			c.t.Fatalf("%s %s: request violates the spec: %v", method, path, err)
		}
	}

	resp, err := http.DefaultClient.Do(build())
	if err != nil {
		c.t.Fatal(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != want {
		c.t.Fatalf("%s %s: status %d, want %d (%s)", method, path, resp.StatusCode, want, b)
	}
	// IncludeResponseStatus makes a status code the operation does not declare a failure.
	err = openapi3filter.ValidateResponse(context.Background(), &openapi3filter.ResponseValidationInput{
		RequestValidationInput: rvi, Status: resp.StatusCode, Header: resp.Header,
		Body: io.NopCloser(bytes.NewReader(b)), Options: &openapi3filter.Options{IncludeResponseStatus: true},
	})
	if err != nil {
		c.t.Fatalf("%s %s -> %d: response violates the spec: %v\nbody: %s", method, path, resp.StatusCode, err, b)
	}
	return string(b), resp.Header
}

func (c *contract) ok(want int, method, path, token, body string) string {
	c.t.Helper()
	b, _ := c.call(want, method, path, token, body, nil, false)
	return b
}

func (c *contract) bad(want int, method, path, token, body string) string {
	c.t.Helper()
	b, _ := c.call(want, method, path, token, body, nil, true)
	return b
}

func TestServerBehavesAsTheSpecDescribes(t *testing.T) {
	a := newApp(t, 0)
	c := newContract(t, a)
	good := `{"name":"Ann","email":"ann@example.com","password":"correct-horse-battery"}`

	// ---- auth
	reg := c.ok(201, "POST", "/auth/register", "", good)
	var s session
	mustJSON(t, reg, &s)
	c.ok(409, "POST", "/auth/register", "", good)
	c.bad(422, "POST", "/auth/register", "", `{"name":"x","email":"nope","password":"short"}`)
	c.bad(400, "POST", "/auth/register", "", `{`)
	c.bad(400, "POST", "/auth/register", "", `{"name":"x","email":"b@example.com","password":"correct-horse-battery","role":"admin"}`)
	c.bad(413, "POST", "/auth/register", "", `{"name":"`+strings.Repeat("a", 2<<10)+`","email":"b@example.com","password":"correct-horse-battery"}`)

	c.ok(200, "POST", "/auth/login", "", `{"email":"ann@example.com","password":"correct-horse-battery"}`)
	c.ok(401, "POST", "/auth/login", "", `{"email":"ann@example.com","password":"wrong-password-xx"}`)
	c.bad(400, "POST", "/auth/login", "", `{`)

	c.ok(200, "GET", "/auth/me", s.AccessToken, "")
	c.bad(401, "GET", "/auth/me", "", "")
	c.bad(401, "GET", "/auth/me", "not.a.token", "")

	var rotated session
	mustJSON(t, c.ok(200, "POST", "/auth/refresh", "", fmt.Sprintf(`{"refresh_token":%q}`, s.RefreshToken)), &rotated)
	c.ok(401, "POST", "/auth/refresh", "", fmt.Sprintf(`{"refresh_token":%q}`, s.RefreshToken)) // reuse
	c.bad(422, "POST", "/auth/refresh", "", `{"refresh_token":""}`)
	c.ok(204, "POST", "/auth/logout", "", fmt.Sprintf(`{"refresh_token":%q}`, rotated.RefreshToken))
	c.bad(400, "POST", "/auth/logout", "", `{`)

	token := rotated.AccessToken

	// ---- orders (with an idempotency key, replay, reuse)
	key := map[string]string{"Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7"}
	first, h1 := c.call(201, "POST", "/orders", token, `{"item":"book","quantity":2}`, key, false)
	if h1.Get("Idempotent-Replayed") != "" {
		t.Fatal("first use must not be flagged as a replay")
	}
	replay, h2 := c.call(201, "POST", "/orders", token, `{"item":"book","quantity":2}`, key, false)
	if h2.Get("Idempotent-Replayed") != "true" || replay != first {
		t.Fatalf("replay not flagged / not identical:\n%s\n%s", first, replay)
	}
	c.call(422, "POST", "/orders", token, `{"item":"pen","quantity":9}`, key, false)
	c.call(400, "POST", "/orders", token, `{"item":"book","quantity":2}`, map[string]string{"Idempotency-Key": "has spaces"}, true)
	c.ok(201, "POST", "/orders", token, `{"item":"mug","quantity":1}`)
	c.bad(422, "POST", "/orders", token, `{"item":"mug","quantity":0}`)
	c.bad(400, "POST", "/orders", token, `{"item":"mug","quantity":1,"user_id":5}`)
	c.bad(401, "POST", "/orders", "", `{"item":"mug","quantity":1}`)

	var o struct {
		ID int `json:"id"`
	}
	mustJSON(t, first, &o)
	c.ok(200, "GET", "/orders", token, "")
	page1 := c.ok(200, "GET", "/orders?limit=1", token, "")
	var pg struct {
		Next string `json:"next_cursor"`
	}
	mustJSON(t, page1, &pg)
	if pg.Next == "" {
		t.Fatal("expected a next_cursor with two orders and limit=1")
	}
	c.ok(200, "GET", "/orders?limit=1&cursor="+pg.Next, token, "")
	c.bad(400, "GET", "/orders?cursor=!!!", token, "")
	c.bad(403, "GET", "/orders?user_id=999999", token, "")
	c.ok(200, "GET", fmt.Sprintf("/orders/%d", o.ID), token, "")
	c.ok(404, "GET", "/orders/999999999", token, "")
	c.bad(400, "GET", "/orders/abc", token, "")
	c.ok(204, "DELETE", fmt.Sprintf("/orders/%d", o.ID), token, "")
	c.ok(404, "DELETE", fmt.Sprintf("/orders/%d", o.ID), token, "")
	c.bad(400, "DELETE", "/orders/0", token, "")

	// ---- users
	uid := fmt.Sprint(s.User.ID)
	c.ok(200, "GET", "/users/"+uid, token, "")
	c.ok(200, "PUT", "/users/"+uid, token, `{"name":"Anna"}`)
	c.bad(400, "PUT", "/users/"+uid, token, `{"name":"x","role":"admin"}`)
	c.bad(422, "PUT", "/users/"+uid, token, `{"name":"  "}`)
	bob := a.register("bob@example.com") // someone else's profile
	c.ok(403, "GET", "/users", token, "")
	c.ok(403, "GET", fmt.Sprintf("/users/%d", bob.User.ID), token, "")
	c.bad(400, "GET", "/users/abc", token, "")

	admin := a.admin("root@example.com")
	c.ok(200, "GET", "/users?limit=1", admin.AccessToken, "")
	c.bad(400, "GET", "/users?limit=0", admin.AccessToken, "")
	c.ok(404, "GET", "/users/999999999", admin.AccessToken, "")
	c.ok(404, "PUT", "/users/999999999", admin.AccessToken, `{"name":"x"}`)
	c.ok(404, "DELETE", "/users/999999999", admin.AccessToken, "")
	c.ok(204, "DELETE", "/users/"+uid, token, "")
	c.bad(401, "GET", "/users/"+uid, "", "")

	// ---- every operation in the document was exercised
	var missed []string
	for _, op := range specOperations(c.docForCoverage()) {
		if !c.visited[op] {
			missed = append(missed, op)
		}
	}
	if len(missed) > 0 {
		t.Fatalf("operations in the spec that this scenario never called: %v", missed)
	}
}

func (c *contract) docForCoverage() *openapi3.T { return loadSpec(c.t) }

// 429 is documented with a required Retry-After header: provoke a real one.
func TestRateLimitResponseMatchesTheSpec(t *testing.T) {
	a := newApp(t, 2)
	c := newContract(t, a)
	s := a.register("ann@example.com")
	c.ok(200, "GET", "/auth/me", s.AccessToken, "")
	c.ok(200, "GET", "/auth/me", s.AccessToken, "")
	_, h := c.call(429, "GET", "/auth/me", s.AccessToken, "", nil, false) // validates Retry-After is present
	if h.Get("Retry-After") == "" {
		t.Fatal("429 without Retry-After")
	}
}

func mustJSON(t *testing.T, s string, v any) {
	t.Helper()
	if err := json.Unmarshal([]byte(s), v); err != nil {
		t.Fatalf("%v: %s", err, s)
	}
}
```

Run it (it needs a PostgreSQL, like the other database tests):

```bash
export TEST_DATABASE_URL='postgres://app:change-me@localhost:5432/app?sslmode=disable'
go test ./internal/contract
```

## Step 5: tooling around the spec

**Lint it.** Redocly's CLI checks style and common mistakes (missing operation ids, unused components, unreachable responses). Our config turns off two rules on purpose and says why:

**Create** `redocly.yaml`

```yaml
# Lint rules for `make openapi-lint` (Redocly CLI): the recommended ruleset, minus two
# deliberate exceptions.
extends:
  - recommended
rules:
  # The spec lists the local development server on purpose.
  no-server-example.com: off
  # This repository has no LICENSE file yet. Once you choose one, add an `info.license`
  # block to api/openapi.yaml and delete this line.
  info-license: off
```

```bash
make openapi-lint
# Woohoo! Your API description is valid. 🎉
```

(We switched off the license rule because this project doesn't have a license file yet; claiming one in the spec would be inventing a legal fact.)

**Use it everywhere.** The same file imports into API clients: Yaak's own importer turned it into 13 requests in 3 folders in one command. It can generate client SDKs, drive a mock server, or feed an API gateway.

## Verify it in a real browser

Unit tests prove the server returns the right headers; they can't prove a browser accepts the page under that CSP. So I drove an actual Chrome through its DevTools protocol (Puppeteer):

The probe logs every response (the last column is the `Content-Encoding`) and then inspects the rendered page:

```text
RESP 200 /docs -
RESP 200 /docs/assets/swagger-ui.css zstd
RESP 200 /docs/assets/swagger-initializer.js -
RESP 200 /docs/assets/swagger-ui-bundle.js zstd
RESP 200 /openapi.yaml zstd
RESP 200 /docs/assets/favicon-32x32.png -
{ "ops": 13,
  "paths": ["/auth/register", "/auth/login", "/auth/refresh", "/auth/logout", "/auth/me",
            "/users", "/users/{id}", "/orders", "/orders/{id}"],
  "tags": ["Auth", "Users", "Orders"],
  "authorize": true,
  "title": "rest-go API 1.0.0 OAS 3.0" }
```

No failed requests, no console errors, no CSP violations. (`zstd` is the gzip alternative that modern browsers negotiate; the compression middleware from Part 9 handles it.)

Then the part that matters, **Authorize → Try it out → Execute** against the live API. The script records what the UI actually sends:

```text
requests the UI sent:
  GET /auth/me auth=none            <- before authorizing: no Authorization header (Chrome logged the 401)
  GET /auth/me auth=Bearer eyJhbGc  <- after pasting the token under Authorize
response shown in the UI after authorizing:
  { "id": 42, "name": "UI Tester", "email": "ui-1792678403445@example.com", "role": "user", ... }
```

The real login flow works through the UI, under the strict policy. One honest side note: headless Chrome rejected our dev CA even though the macOS keychain trusts it (curl and Yaak accept it), so this check ran with certificate errors ignored. The page itself is unaffected. In your everyday Chrome you may need to click through the warning for `localhost`, or use Safari.

A smaller story: my first script clicked "Try it out" and then waited for "Execute", and timed out. The cause was my own initializer: `tryItOutEnabled: true` makes every operation interactive as soon as it opens, so the extra click was switching it *off*. Config options have consequences.

## Complete code for this part

Everything else to type, file by file. The spec is long (it documents all 13 operations) but it is plain YAML. Then `go mod tidy` (it adds the `kin-openapi` test dependency) and `go build ./...`.

*Already given earlier in this post:* `api/embed.go`*,* `internal/contract/contract_test.go`*,* `internal/docs/docs.go`*,* `internal/docs/swaggerui/index.html`*,* `internal/docs/swaggerui/swagger-initializer.js`*,* `redocly.yaml`*.*

**Create** `api/openapi.yaml`

```yaml
openapi: 3.0.3
info:
  title: rest-go API
  version: 1.0.0
  description: |
    Users, orders, JWT authentication, idempotent creates and keyset pagination.

    ## Authentication
    Call `POST /auth/register` or `POST /auth/login`, then send the returned `access_token` as
    `Authorization: Bearer <token>`. Access tokens live 15 minutes; use `POST /auth/refresh` with the
    `refresh_token` to get a new pair. **Refresh tokens are single use**: presenting one twice is treated
    as theft and revokes the whole session.

    In the UI: click **Authorize** and paste the access token (without the word `Bearer`).

    ## Pagination
    List endpoints use keyset pagination: pass `limit`, and copy `next_cursor` from a response into
    `cursor` to get the next page. A missing `next_cursor` means the last page. Cursors are opaque.

    ## Idempotency
    `POST /orders` accepts an `Idempotency-Key` header. Retrying with the same key and the same request
    returns the original order (with `Idempotent-Replayed: true`); the same key with a different request is `422`.

    ## Errors
    Every error body is `{"error": "message"}`.

    ## Rate limits
    Requests are limited per IP, per user, and (for login) per account. `429` responses carry `Retry-After`.
servers:
  - url: /
    description: This server (same origin as this document)
  - url: https://localhost:7800
    description: Local development (docker compose)
tags:
  - name: Auth
    description: Accounts and tokens
  - name: Users
    description: Profiles. Admins can see everyone; users only themselves.
  - name: Orders
    description: Orders belong to the user who created them. Admins can see all.

security:
  - bearerAuth: []

paths:
  /auth/register:
    post:
      tags: [Auth]
      operationId: register
      summary: Create an account
      description: Creates a user (role `user`) and returns tokens. The role cannot be chosen by the caller.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterRequest' }
      responses:
        '201':
          description: Account created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409':
          description: The email is already registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /auth/login:
    post:
      tags: [Auth]
      operationId: login
      summary: Log in
      description: |
        Wrong password and unknown account return the **same** `401`, so callers cannot discover which
        emails are registered. After repeated failures for one account the endpoint answers `429`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
      responses:
        '200':
          description: Logged in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: invalid credentials }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /auth/refresh:
    post:
      tags: [Auth]
      operationId: refreshToken
      summary: Exchange a refresh token for a new token pair
      description: |
        Rotates the refresh token: the old one stops working. Reusing an already-used token is rejected and
        revokes every session of that user.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefreshRequest' }
      responses:
        '200':
          description: New tokens
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Unknown, expired, revoked or reused refresh token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /auth/logout:
    post:
      tags: [Auth]
      operationId: logout
      summary: Revoke a refresh token
      description: Idempotent. The access token keeps working until it expires (15 minutes).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefreshRequest' }
      responses:
        '204': { description: Revoked (or already invalid) }
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '500': { $ref: '#/components/responses/InternalError' }

  /auth/me:
    get:
      tags: [Auth]
      operationId: me
      summary: The authenticated user's profile
      responses:
        '200':
          description: The caller
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /users:
    get:
      tags: [Users]
      operationId: listUsers
      summary: List all users (admin only)
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of users
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /users/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Users]
      operationId: getUser
      summary: Get a user (yourself, or any user as an admin)
      responses:
        '200':
          description: The user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      tags: [Users]
      operationId: updateUser
      summary: Rename a user (yourself, or any user as an admin)
      description: Only `name` can be changed. Sending any other field (such as `role`) is rejected with `400`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateUserRequest' }
      responses:
        '200':
          description: The updated user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Users]
      operationId: deleteUser
      summary: Delete a user and all their orders
      responses:
        '204': { description: Deleted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: |
        Returns your orders. Admins see everyone's and may filter with `user_id`. A regular user passing
        someone else's `user_id` gets `403`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: user_id
          in: query
          description: Only this user's orders (admins; users may only pass their own id)
          schema: { type: integer, format: int64, minimum: 1 }
      responses:
        '200':
          description: A page of orders, oldest first
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create an order for the caller
      description: |
        The owner is always the authenticated caller; the body has no `user_id` (sending one is rejected).

        Send an `Idempotency-Key` to make retries safe: the same key with the same request returns the
        original order and sets `Idempotent-Replayed: true`; the same key with a different request is `422`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrderRequest' }
      responses:
        '201':
          description: The order (new, or the original one when `Idempotent-Replayed` is `true`)
          headers:
            Idempotent-Replayed:
              description: Present with value `true` when this response was replayed from an earlier request with the same key
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422':
          description: Validation failed, or the `Idempotency-Key` was already used with a different request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /orders/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get an order
      description: Someone else's order answers `404`, not `403`, so the API does not confirm it exists.
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      tags: [Orders]
      operationId: deleteOrder
      summary: Delete an order
      responses:
        '204': { description: Deleted }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Access token from `/auth/login` or `/auth/register`.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: integer, format: int64, minimum: 1 }
    Limit:
      name: limit
      in: query
      description: Page size. Values above the server maximum are clamped to it.
      schema: { type: integer, minimum: 1, default: 20, maximum: 100 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor copied from the previous response's `next_cursor`.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        A unique value per logical operation (a UUID is ideal), reused for every retry of that operation.
        1-255 visible ASCII characters. Keys are scoped to the caller and remembered for 24 hours.
      schema: { type: string, minLength: 1, maxLength: 255, pattern: '^[\x21-\x7E]+$' }
      example: 7c9e6679-7425-40de-944b-e07fc1f90ae7

  responses:
    BadRequest:
      description: Malformed JSON, unknown field, or an invalid id, limit, cursor or header
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, malformed, expired or forged access token
      headers:
        WWW-Authenticate:
          required: true
          description: How to authenticate
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Authenticated, but not allowed to do this
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such resource (or it belongs to someone else)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: Request body larger than the server limit
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: The JSON is valid but a field value is not acceptable
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: A rate limit was hit
      headers:
        Retry-After:
          required: true
          description: Seconds to wait before retrying
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Unexpected server error (details are in the server logs, never in the response)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
      example: { error: not found }
    User:
      type: object
      required: [id, name, role, created_at]
      properties:
        id: { type: integer, format: int64, example: 42 }
        name: { type: string, example: Ann }
        email: { type: string, format: email, description: Absent for accounts created before email was introduced, example: ann@example.com }
        role: { type: string, enum: [user, admin] }
        created_at: { type: string, format: date-time }
    Order:
      type: object
      required: [id, user_id, item, quantity, created_at]
      properties:
        id: { type: integer, format: int64, example: 7 }
        user_id: { type: integer, format: int64, example: 42 }
        item: { type: string, example: book }
        quantity: { type: integer, minimum: 1, maximum: 10000, example: 2 }
        created_at: { type: string, format: date-time }
    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, refresh_token, user]
      properties:
        access_token: { type: string, description: 'Short-lived JWT; send it as a Bearer token' }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, description: Access token lifetime in seconds, example: 900 }
        refresh_token: { type: string, description: Single-use token for POST /auth/refresh }
        user: { $ref: '#/components/schemas/User' }
    RegisterRequest:
      type: object
      additionalProperties: false
      required: [name, email, password]
      properties:
        name: { type: string, minLength: 1, maxLength: 100, example: Ann }
        email: { type: string, format: email, maxLength: 254, example: ann@example.com }
        password: { type: string, minLength: 10, maxLength: 72, description: 10 to 72 bytes, format: password }
    LoginRequest:
      type: object
      additionalProperties: false
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
    RefreshRequest:
      type: object
      additionalProperties: false
      required: [refresh_token]
      properties:
        refresh_token: { type: string }
    UpdateUserRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 100 }
    CreateOrderRequest:
      type: object
      additionalProperties: false
      required: [item, quantity]
      properties:
        item: { type: string, minLength: 1, maxLength: 100, example: book }
        quantity: { type: integer, minimum: 1, maximum: 10000, example: 2 }
    UserPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/User' }
        next_cursor: { type: string, description: Absent on the last page }
    OrderPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Order' }
        next_cursor: { type: string, description: Absent on the last page }
```

**Change** `internal/config/config.go`

```diff
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -40,6 +40,11 @@ type Config struct {
 	// realistic client retry window (retries with backoff, queued mobile requests).
 	IdempotencyTTL time.Duration
 
+	// DocsEnabled serves /openapi.yaml and the interactive UI at /docs. They are public
+	// (the spec documents a public API); turn them off, or gate them at your gateway,
+	// if the API surface should not be discoverable.
+	DocsEnabled bool
+
 	MaxBodyBytes    int64
 	MaxNameLen      int
 	DefaultPageSize int
@@ -100,6 +105,7 @@ func Load() (Config, error) {
 		ShutdownTimeout:     15 * time.Second,
 		RequestTimeout:      10 * time.Second,
 		IdempotencyTTL:      24 * time.Hour,
+		DocsEnabled:         true,
 		MaxBodyBytes:        1 << 20,
 		MaxNameLen:          100,
 		DefaultPageSize:     20,
@@ -201,6 +207,11 @@ func Load() (Config, error) {
 			}
 		}
 	}
+	if v := os.Getenv("DOCS_ENABLED"); v != "" {
+		if c.DocsEnabled, err = strconv.ParseBool(v); err != nil {
+			return c, fmt.Errorf("DOCS_ENABLED: %w", err)
+		}
+	}
 	if v := os.Getenv("RATE_LIMIT_BURST"); v != "" {
 		if c.RateLimitBurst, err = strconv.Atoi(v); err != nil {
 			return c, fmt.Errorf("RATE_LIMIT_BURST: %w", err)
```

**Change** `internal/routes/routes.go`

```diff
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -17,9 +17,16 @@ type Deps struct {
 	AuthLimit func(http.Handler) http.Handler // strict per-IP quota for login/register/refresh
 }
 
+// Registrar is the part of *http.ServeMux that SetupRoutes needs. Depending on a
+// one-method interface lets tests record which routes exist and compare them with
+// the OpenAPI document.
+type Registrar interface {
+	Handle(pattern string, h http.Handler)
+}
+
 // SetupRoutes registers every endpoint. Reading this file answers "which routes
 // are public?": only those registered with `public`.
-func SetupRoutes(mux *http.ServeMux, d Deps) {
+func SetupRoutes(mux Registrar, d Deps) {
 	userLimit, authLimit := orIdent(d.UserLimit), orIdent(d.AuthLimit)
 
 	public := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, h) }
```

**Change** `cmd/rest/main.go`

```diff
--- a/cmd/rest/main.go
+++ b/cmd/rest/main.go
@@ -17,8 +17,10 @@ import (
 
 	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 
+	"go-rest/api"
 	"go-rest/internal/auth"
 	"go-rest/internal/config"
+	"go-rest/internal/docs"
 	"go-rest/internal/handlers"
 	"go-rest/internal/middleware"
 	"go-rest/internal/observability"
@@ -297,6 +299,11 @@ func serve() error {
 		}
 	}
 
+	// API documentation: the OpenAPI spec embedded in the binary plus Swagger UI.
+	if cfg.DocsEnabled {
+		docs.Mount(mux, api.Spec)
+	}
+
 	// First listed = outermost. ORDER MATTERS:
 	//  1. otelhttp, RequestID, Timeout all COPY the request (r.WithContext), so
 	//     they go first.
```

**Change** `docker-compose.yml`

```diff
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,7 @@ services:
       RATE_LIMIT_RPS: ${RATE_LIMIT_RPS:-200}
       RATE_LIMIT_BURST: ${RATE_LIMIT_BURST:-400}
       # Pass-through (null value = take from your shell/.env if set, else use the app default):
+      DOCS_ENABLED:                 # false hides /docs and /openapi.yaml (default true)
       USER_RATE_LIMIT_RPS:
       USER_RATE_LIMIT_BURST:
       AUTH_RATE_LIMIT_PER_MIN:
```

**Change** `makefile`

```diff
--- a/makefile
+++ b/makefile
@@ -1,4 +1,4 @@
-.PHONY: admin certs rotate-certs db run build test test-integration up down logs reset migrate psql load clean
+.PHONY: openapi-lint yaak-test api-test admin certs rotate-certs db run build test test-integration up down logs reset migrate psql load clean
 
 BINARY := bin/server
 # Local (non-Docker) default; matches the compose Postgres published on localhost.
@@ -62,6 +62,16 @@ logs:
 psql:
 	docker compose exec postgres sh -c 'psql -U $$POSTGRES_USER -d $$POSTGRES_DB'
 
+openapi-lint:             ## lint api/openapi.yaml with Redocly (needs Node.js)
+	npx -y @redocly/cli@latest lint api/openapi.yaml
+
+api-test:                 ## run the Postman collection with newman (see postman/README.md: needs AUTH_RATE_LIMIT_PER_MIN=0 and `make admin`)
+	npx -y newman run postman/rest-go.postman_collection.json -e postman/local.postman_environment.json --ssl-extra-ca-certs certs/ca.pem
+
+yaak-test:                ## send every request via the Yaak CLI and check the documented statuses (new AND existing-workspace import)
+	python3 yaak/verify.py
+	python3 yaak/verify.py --existing
+
 load:                     ## generate some traffic so dashboards have data
 	./scripts/load.sh
 
```

## Try it

```bash
make up
open https://localhost:7800/docs        # or: curl --cacert certs/ca.pem https://localhost:7800/openapi.yaml
```

In the UI: register through **POST /auth/register → Try it out → Execute**, copy the `access_token`, click **Authorize**, paste it, and call **GET /auth/me**.

```bash
go test ./internal/contract                                # the contract tests (needs TEST_DATABASE_URL)
make openapi-lint                                          # style
```

## Changing the API from now on

1. Change the handler/route **and** `api/openapi.yaml` in the same commit.
2. Run the tests. A mismatch (field, status, header, undocumented route) fails loudly.
3. New endpoint? Add it to the scenario test; the test fails if any operation is never called.
4. `make openapi-lint`.

## What this does not give you

- **Breaking-change detection between releases.** Renaming a field passes these tests if spec and code agree, but it breaks clients. A tool like `oasdiff` in CI compares the new spec to the last released one and flags removals and tightened rules.
- **Full response coverage.** Only the statuses the scenario triggers are response-validated; the rest of the declared codes are documentation.
- **OpenAPI 3.1.** We chose 3.0.3 for the widest tool support; 3.1 aligns with newer JSON Schema, which this API doesn't need.
- **Generated clients and a mock server**, which the spec makes easy but we didn't build.

## Recap

- A spec makes the API explorable by people *and* readable by tools.
- Hand-written spec + contract tests beats annotations (unchecked) and codegen (invasive) for an existing codebase.
- Embed the spec and the UI in the binary; serve the page under a strict CSP; don't persist tokens in the browser.
- Test three things: the file is valid, the route set matches, and real traffic conforms (statuses, bodies, headers, and that every operation is covered).
- Mutation-test the tests. The `default:` response taught us that a green suite can be hiding a hole.

## Exercises

1. Add `PATCH /users/{id}` to the code but not to the spec. Which test fails first, and what does the message say?
2. Remove `additionalProperties: false` from `CreateOrderRequest`. Which test still passes, and what real-world behavior has silently stopped being documented?
3. Add `oasdiff` to your workflow: save the current spec as `base.yaml`, change a response field's type, and run `oasdiff breaking base.yaml api/openapi.yaml`.
4. Put `/docs` behind admin authentication. Where would the check go: in `docs.Mount`, or in a middleware you share with the rest of the API?
