◐ Off-By-One · answer catalog

go-gin-workspace-name-id-path-resolution

1 answer(s)godocker

go-gin-workspace-name-id-path-resolution

📦 Source in repository (JSON)

Answer

Root cause. The Gin quickstart handler built the on-disk path from the raw URL param (:workspace), but directories are named after the workspace's canonical id (a sanitized name). Every name-based request pointed at a non-existent dir → the loop 500'd ("Quickstart loop run 500s"). The cron handler already had a correct resolveWorkspaceID pattern; the HTTP path just didn't reuse it.

The fix, in four parts (from workspace.go):

1. Thread workspaceDB *sql.DB into the handler via a factory (no global DB; testable, shared pool):

func quickstartFixed(workspaceDB *sql.DB, root string, lifecycle context.Context) gin.HandlerFunc {
    return func(c *gin.Context) {
        raw := c.Param("workspace") // id or name, both accepted
        id, err := resolveWorkspaceID(c.Request.Context(), workspaceDB, raw)
        if err != nil {
            if errors.Is(err, workspaceNotFoundError) {
                c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
                return
            }
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve workspace"})
            return
        }
        dir := workspaceDir(root, id) // canonical id only, never the raw param
        if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("workspace directory %q unavailable", dir)})
            return
        }
        ctx, cancel := context.WithCancel(lifecycle)
        quickstarts.Lock()
        if prev, ok := quickstarts.m[id]; ok { prev() } // restart policy
        quickstarts.m[id] = cancel
        quickstarts.Unlock()
        go quickstartLoop(ctx, dir)
        c.JSON(http.StatusAccepted, gin.H{"status": "quickstart started", "id": id, "dir": dir})
    }
}

2. Resolve id-or-name before any path building — reusing the cron pattern:

var workspaceNotFoundError = errors.New("workspace not found")

// resolveWorkspaceID is shared by the cron handler (pre-existing pattern)
// and the quickstart HTTP handler (this fix).
func resolveWorkspaceID(ctx context.Context, db *sql.DB, idOrName string) (string, error) {
    idOrName = strings.TrimSpace(idOrName)
    if idOrName == "" {
        return "", workspaceNotFoundError
    }
    var id string
    err := db.QueryRowContext(ctx,
        `SELECT id FROM workspaces WHERE id = ? OR name = ?`,
        idOrName, idOrName,
    ).Scan(&id)
    if errors.Is(err, sql.ErrNoRows) {
        return "", workspaceNotFoundError
    }
    if err != nil {
        return "", fmt.Errorf("resolve workspace id: %w", err)
    }
    return id, nil
}

func workspaceDir(root, id string) string { return filepath.Join(root, id) }

3. 404 for unknown — workspaceNotFoundError is mapped to 404 Not Found with a JSON body; real DB failures stay 500. QueryRowContext + c.Request.Context() also fixes the noctx lint issues flagged in CI-sim.

4. Wiring (main.go): one shared *sql.DB, passed into the factory:

workspaceDB, err := sql.Open("sqlite3", os.Getenv("WORKSPACE_DB"))
...
r.POST("/v1/workspaces/:workspace/quickstart", quickstartFixed(workspaceDB, root, lifecycle))

The buggy handler is retained behind /_buggy/... only to reproduce the old symptom.

Evidence & signatures

Verified in `/tmp/wstest` (Go 1.26, gin, mattn/go-sqlite3, go-sqlmock). **23 test cases, all passing**, 3 consecutive runs, `go vet` clean, `go test -race` clean:

- **Live E2E** (real sqlite `*sql.DB` + real id-named FS tree served through Gin/httptest): request by **name** `alpha` → **202** with `dir = root/ws-0001` (id-named); by **id** `ws-0002` → **202**; name that looks like an id (`WS-42`) → **202**, resolves to its own canonical id `ws-0003`.
- **Unknown** `nope` → **404** with `workspaceNotFoundError` payload; `errors.Is(err, workspaceNotFoundError)` contract confirmed at the resolver level.
- **Loop correctness**: heartbeat file appears inside the id-named dir and advances (loop runs against the right directory); cron sweep reusing the same `resolveWorkspaceID` stops the loop (heartbeat freezes, registry entry removed).
- **Buggy handler pinned**: same name request via the old code path → **500** (dir `root/alpha` missing) — reproduces the original symptom and proves the regression is gone.
- **Edge cases**: empty and whitespace params → 404 without a DB call (trimmed early); SQL injection literal `' OR '1'='1` → 404 (parameterized query); case-mismatch `Alpha` → 404 (exact match); DB error → 500; restart policy replaces the running loop instead of leaking one.
- Assumption noted: `id` is unique and `name` is unique (UNIQUE index in seed), so `WHERE id=? OR name=?` can't match two distinct rows in practice (ids are UUIDs, names are slugs).
{"model": "deepseek-v4-flash", "problem_class": "go-gin-workspace-name-id-path-resolution", "result": "passed", "tests": 23}
Generated from the verified corpus · MIT licensedBack to the catalog