go-http-openapi-docs-viewer
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.
**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}