◐ Off-By-One · answer catalog

go-http-openapi-docs-viewer

1 answer(s)godocker

go-http-openapi-docs-viewer

📦 Source in repository (JSON)

Answer

Root cause: the chi router mounted the SPA fallback r.Get("/*") with no /docs routes, so every /docs* request fell through to the catch-all and returned index.html (or 404) — the spec was never reachable.

Fix: a DocsHandler registered before the SPA fallback, plus a spec-resolution ladder mirroring resolveDistDir. Key code from openapi.go:

// Register mounts the docs routes. MUST be called before the SPA
// catch-all, otherwise r.Get("/*") swallows /docs.
func (h *DocsHandler) Register(r chi.Router) {
    r.Get("/docs", h.ScalarPage)
    r.Get("/docs/openapi.yaml", h.RawSpec)
    r.Get("/docs/*", h.Subpath)
}

// ScalarPage renders the Scalar viewer (CDN) pointed at the local spec.
func (h *DocsHandler) ScalarPage(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    io.WriteString(w, docsHTML) // <script id="api-reference" data-url="/docs/openapi.yaml">
                                // <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
}

// RawSpec streams the spec verbatim; path resolved per request.
func (h *DocsHandler) RawSpec(w http.ResponseWriter, r *http.Request) {
    specPath, ok := resolveSpecPath()
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "openapi spec not found"})
        return
    }
    data, err := os.ReadFile(specPath)
    if err != nil { writeJSON(w, http.StatusInternalServerError, ...); return }
    w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
    w.Write(data)
}

// Subpath: /docs/ → 301 /docs; anything else → JSON 404.
func (h *DocsHandler) Subpath(w http.ResponseWriter, r *http.Request) {
    switch rest := chi.URLParam(r, "*"); rest {
    case "/", "":
        http.Redirect(w, r, "/docs", http.StatusMovedPermanently)
    default:
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found", "path": r.URL.Path})
    }
}

// Spec resolution: ASCE_API_SPEC env override, then a CWD-candidate
// ladder — the exact pattern resolveDistDir uses for the SPA dist dir.
func resolveSpecPath() (string, bool) {
    if p := os.Getenv("ASCE_API_SPEC"); p != "" {
        if info, err := os.Stat(p); err == nil && !info.IsDir() {
            abs, _ := filepath.Abs(p)
            return abs, true
        }
        return p, false
    }
    for _, c := range []string{"api/openapi.yaml", "./api/openapi.yaml",
        "../api/openapi.yaml", "../../api/openapi.yaml"} {
        if info, err := os.Stat(c); err == nil && !info.IsDir() {
            abs, _ := filepath.Abs(c)
            return abs, true
        }
    }
    return "api/openapi.yaml", false
}

Router wiring in main.go — ordering is the whole fix:

func NewRouter() chi.Router {
    r := chi.NewRouter()
    NewDocsHandler().Register(r)          // /docs, /docs/openapi.yaml, /docs/*
    if distDir, ok := resolveDistDir(); ok {
        r.Get("/*", spaHandler(distDir).ServeHTTP)   // SPA fallback, AFTER docs
    } else {
        r.Get("/*", http.NotFoundHandler().ServeHTTP)
    }
    return r
}

api/openapi.yaml is generated at 3455 lines (valid OpenAPI 3.0.3, 33 paths, 23 schemas) via scripts/gen-spec.py.

Evidence & signatures

**httptest suite** (`openapi_test.go`, 11 tests, `-race` clean) — the required `t.TempDir() + ASCE_API_SPEC` pattern:

```go
func newTestRouter(t *testing.T) http.Handler {
    specPath := filepath.Join(t.TempDir(), "openapi.yaml")
    writeFile(t, specPath, testSpec)
    t.Setenv("ASCE_API_SPEC", specPath)
    return NewRouter()
}
```

Verified behaviors:

| Case | Result |
|---|---|
| `GET /docs` | 200 `text/html`; contains `api-reference`, `cdn.jsdelivr.net`, `data-url="/docs/openapi.yaml"` |
| `GET /docs/openapi.yaml` | 200 `application/yaml`; body byte-identical to the spec file |
| `GET /docs/nope`, `/docs/openapi.json`, `/docs/anything/else` | 404 `application/json` `{"error":"not found"}` |
| `GET /docs/` | 301 → `/docs` |
| SPA dist present (`ASCE_DIST_DIR` + `index.html`) | `/docs` still wins over fallback; `/some/spa/route` → index.html; `/asset.js` → real file; `/docs/unknown` still JSON 404 |
| `ASCE_API_SPEC` override | wins over CWD candidates (unit + end-to-end) |
| No env, chdir to dir with `api/openapi.yaml` | CWD ladder resolves (unit + end-to-end) |
| `ASCE_API_SPEC` → missing file or a directory | JSON 404, not 500 |
| Repo default | `go run .` serves the shipped 3455-line spec via CWD ladder |

**Live smoke test** (`go build` + real server on a free port): `/docs` → 200 HTML w/ Scalar CDN; `/docs/openapi.yaml` → 200, 88146 bytes, 3455 lines; `/docs/unknown` → 404 JSON; `/docs/` → 301; `POST /docs` → 405; `/` → 404 (no dist). `go vet` and `gofmt` clean.
{"model": "deepseek-v4-flash", "problem_class": "go-http-openapi-docs-viewer", "result": "passed", "tests": 11}
Generated from the verified corpus · MIT licensedBack to the catalog