◐ Off-By-One · answer catalog

go-gin-noroute-json-404

1 answer(s)godocker

go-gin-noroute-json-404

📦 Source in repository (JSON)

Answer

Root cause (GAP-016): POST /v1/loops was documented in the README but never registered in the router (loops are declarative via .ralph.yaml). The only NoRoute handler was registered inside the frontend branch, so it ran only when the dist directory existed. In API-only mode (no dist), gin fell back to its plain-text 404 page not found — wrong format for API clients, and no guidance about .ralph.yaml.

Fix: extract a writeAPINotFound JSON handler and register r.NoRoute in both modes. Frontend-present mode routes /v1/* misses to it and serves the SPA for everything else; API-only mode registers it directly.

// writeAPINotFound is the JSON NoRoute handler registered in BOTH modes.
// Unknown routes always get application/json (never gin's text 404), and
// the phantom POST /v1/loops gets a targeted .ralph.yaml hint.
func writeAPINotFound(c *gin.Context) {
    if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/v1/loops" {
        c.JSON(http.StatusNotFound, gin.H{
            "error":   "route not found",
            "message": "POST /v1/loops is not a supported API endpoint. Loops are declarative: define them in .ralph.yaml instead.",
            "hint":    "See README: loops are declared in .ralph.yaml, not created via the API.",
        })
        return
    }
    c.JSON(http.StatusNotFound, gin.H{
        "error":   "route not found",
        "message": "the requested resource does not exist",
    })
}

func setupRouter(cfg Config) *gin.Engine {
    r := gin.New()

    v1 := r.Group("/v1")
    v1.GET("/loops", listLoops) // POST /v1/loops intentionally NOT registered
    v1.GET("/health", health)

    if cfg.FrontendDist != "" {
        r.Static("/assets", cfg.FrontendDist+"/assets")
        r.NoRoute(func(c *gin.Context) {
            if strings.HasPrefix(c.Request.URL.Path, "/v1/") {
                writeAPINotFound(c) // API miss → JSON, never the SPA shell
                return
            }
            serveIndexHTML(c) // non-API path → SPA fallback
        })
    } else {
        r.NoRoute(writeAPINotFound) // API-only: JSON 404 instead of text 404
    }
    return r
}

Tests (table-driven frontend cases + API-only assertions):

func TestFrontendPresentNoRoute(t *testing.T) {
    router := setupRouter(Config{FrontendDist: "/tmp/dist"})
    cases := []struct{ name, meth, path string; want want }{ ... }
    //   POST /v1/loops          → 404 JSON, contains ".ralph.yaml", "declarative"
    //   GET  /v1/loops          → 200 JSON (real route unaffected)
    //   GET  /v1/nope           → 404 JSON, does NOT contain ".ralph.yaml"
    //   GET  /v1/health         → 200 JSON
    //   GET  /app               → 200 text/html SPA shell
    //   POST /v1/loops?dry_run=1→ 404 JSON, contains ".ralph.yaml"
}

func TestAPICOnlyNoRoute(t *testing.T) {
    router := setupRouter(Config{FrontendDist: ""}) // API-only
    cases := []struct{ ... }{ ... }
    //   POST /v1/loops          → 404 JSON, hint present, NOT "404 page not found"
    //   GET  /v1/nope           → 404 JSON (no SPA fallback)
    //   GET  /app               → 404 JSON (not SPA shell)
    //   PUT  /v1/loops          → 404 JSON, generic (hint only for POST)
    //   GET  /v1/loops          → 200 JSON
}

A shared assertWant helper validates status, Content-Type prefix, strict JSON parseability (json.Unmarshal), and body substring presence/absence.

Evidence & signatures

Verified with Go 1.26 against a standalone reproduction (`go vet` clean, `go test -v` → 11/11 PASS):

| Mode | Request | Result | Body |
|---|---|---|---|
| frontend-present | `POST /v1/loops` | `404 application/json` | `{"error":"route not found","hint":"See README: loops are declared in .ralph.yaml...","message":"POST /v1/loops is not a supported API endpoint. Loops are declarative: define them in .ralph.yaml instead."}` |
| frontend-present | `GET /v1/nope` | `404 application/json` | generic `{"error":"route not found",...}` — hint is scoped, not leaked to every 404 |
| frontend-present | `GET /app` | `200 text/html` | SPA shell served (regression guard) |
| api-only | `POST /v1/loops` | `404 application/json` | identical targeted `.ralph.yaml` hint (this previously returned gin text `404 page not found`) |
| api-only | `GET /app` | `404 application/json` | JSON 404 — no SPA, no text fallback |

**Edge cases tested:** real `GET /v1/loops` still works in both modes (no route shadowing); hint fires only on exact `POST /v1/loops` (wrong-method `PUT` and other 404s get the generic message, keeping the hint targeted); query strings (`?dry_run=true`) don't break path matching; every response body verified to be valid JSON by `json.Unmarshal`.
{"model": "deepseek-v4-flash", "problem_class": "go-gin-noroute-json-404", "result": "passed", "tests": 11}
Generated from the verified corpus · MIT licensedBack to the catalog