◐ Off-By-One · answer catalog

go-api-spec-gap-wiring

1 answer(s)godocker

go-api-spec-gap-wiring

📦 Source in repository (JSON)

Answer

The gap: spec 01-core §6.2 promised GET /api/v1/pools/:appID/status served by sandbox.StatusHandler, but no handler and no route existed. The domain primitive already existed and is reused unchanged: SandboxManager.PoolStatus(appID) (PoolStatus, error) plus the PoolStatus JSON type (with ErrPoolNotFound sentinel). The fix is pure wiring, zero reimplementation.

1. New sandbox/status_handler.go — status mapping 200/404 POOL_NOT_FOUND/400/500, router-style {"error":{"code","message"}} envelope:

var appIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

// StatusHandler serves GET /api/v1/pools/{appID}/status (spec 01-core §6.2).
// 200 OK (PoolStatus JSON) | 400 BAD_REQUEST | 404 POOL_NOT_FOUND | 500 INTERNAL_ERROR
func StatusHandler(mgr SandboxManager) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        appID := r.PathValue("appID")
        if appID == "" || !appIDRe.MatchString(appID) {
            writeError(w, http.StatusBadRequest, "BAD_REQUEST",
                "path parameter appID must be a non-empty alphanumeric identifier")
            return
        }
        status, err := mgr.PoolStatus(appID) // existing primitive, untouched
        if err != nil {
            switch {
            case errors.Is(err, ErrPoolNotFound):
                writeError(w, http.StatusNotFound, "POOL_NOT_FOUND",
                    fmt.Sprintf("no pool exists for appID %q", appID))
            default:
                writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
                    "failed to read pool status") // backend detail never leaked
            }
            return
        }
        writeJSON(w, http.StatusOK, status)
    })
}

// Router-style error envelope shared with the rest of the API.
type errorEnvelope struct{ Error errorBody `json:"error"` }
type errorBody struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func writeError(w http.ResponseWriter, code int, errCode, message string) {
    writeJSON(w, code, errorEnvelope{Error: errorBody{Code: errCode, Message: message}})
}

func writeJSON(w http.ResponseWriter, code int, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(code)
    _ = json.NewEncoder(w).Encode(v)
}

2. The one wiring line in framework.go (Go 1.22+ method-pattern ServeMux; {appID} populates r.PathValue):

mux := http.NewServeMux()

// U-GAP-005 fix: spec 01-core §6.2 promised this route; it was never wired.
mux.Handle("GET /api/v1/pools/{appID}/status", sandbox.StatusHandler(mgr))

// Existing route: /execute runs GetOrCreatePool before Acquire.
mux.Handle("POST /api/v1/execute", sandbox.ExecuteHandler(mgr))

The /execute handler already guarantees GetOrCreatePool runs before Acquire — which is exactly what makes the live E2E (below) work: the pool a request creates via /execute is immediately visible to the status route.

3. httptest fake-manager tests (sandbox/status_handler_test.go) — a fakeManager implements SandboxManager with injectable statusErr/createErr/acquireErr so the handler is tested against a controllable boundary, plus one live E2E test against the real in-memory manager through the exact framework.go wiring.


Evidence & signatures

**Automated tests — 7/7 PASS** (`go test ./sandbox/ -count=1 -race -v`):

| # | Test | Verifies |
|---|------|----------|
| 1 | `TestStatusHandlerOK` | 200, `Content-Type: application/json`, body round-trips as `PoolStatus` with exact fields (Available=2, InUse=2, Queued=1) |
| 2 | `TestStatusHandlerPoolNotFound` | unknown pool → **404**, envelope code `POOL_NOT_FOUND` (not a silent zero-value 200) |
| 3 | `TestStatusHandlerBadRequestEmptyAppID` | empty path segment → **400** `BAD_REQUEST`, manager never called |
| 4 | `TestStatusHandlerBadRequestInvalidAppID` | appID `"a b"` through the real mux → **400** `BAD_REQUEST` |
| 5 | `TestStatusHandlerBackendError` | manager returns `"backend down"` → **500** `INTERNAL_ERROR`, detail not leaked to client |
| 6 | `TestStatusHandlerEnvelopeShape` | error body is exactly `{"error":{"code","message"}}` — one top-level key, router style |
| 7 | `TestExecuteCreatesPoolThenStatusE2E` | live E2E: `/execute` → 201 with sandbox id; status of `e2e-app` → 200 with `available:3, inUse:1` (proves GetOrCreatePool ran before Acquire); bad execute → 400 and never-created pool stays 404 |

`go vet ./...` clean; `go build ./...` OK.

**Live server smoke test** (real binary, curl; ran on :18081 because :8080 is occupied by environment infra):

```
GET  /api/v1/pools/ghost/status   → {"error":{"code":"POOL_NOT_FOUND","message":"no pool exists for appID \"ghost\""}}   HTTP 404
POST /api/v1/execute {"appID":"live-app"} → {"id":"sbx-001","appID":"live-app"}                                        HTTP 201
GET  /api/v1/pools/live-app/status → {"appID":"live-app","poolSize":4,"available":3,"inUse":1,"queued":0,"capacity":4,...} HTTP 200
GET  /api/v1/pools/a%20b/status    → {"error":{"code":"BAD_REQUEST","message":"path parameter appID must be a non-empty alphanumeric identifier"}} HTTP 400
```

**Edge cases covered:** unknown pool vs. zero-value PoolStatus (404, never a false 200); empty/malformed appID (400 before reaching the manager); non-`ErrPoolNotFound` backend failure (500 with no detail leak); envelope shape on every error; content type on success and error; pool creation only happens for valid `/execute` requests; concurrency-safe in-memory manager (race detector clean).

---
{"model": "deepseek-v4-flash", "problem_class": "go-api-spec-gap-wiring", "result": "passed", "tests": 7}
Generated from the verified corpus · MIT licensedBack to the catalog