feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
# Gin Server Composition Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the standard-library ServeMux API layer with Gin while keeping explicit `http.Server` composition and configurable network settings.
|
||||
|
||||
**Architecture:** `internal/api.NewRouter` will build and return a `*gin.Engine`. `cmd/server/main.go` will pass that engine into `http.Server{Handler: router}` and continue using `ListenAndServe`, never `router.Run`. `pkg/config` will parse server timeout/header settings into strong types used by `http.Server`.
|
||||
|
||||
**Tech Stack:** Go 1.22+, Gin (`github.com/gin-gonic/gin`), standard library `net/http`, YAML config via `gopkg.in/yaml.v3`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Use Gin for API routing.
|
||||
- Do not use `router.Run()` or `engine.Run()`.
|
||||
- Set `*gin.Engine` as `http.Server.Handler` explicitly.
|
||||
- Keep all existing API paths, HTTP methods, and JSON response shapes compatible.
|
||||
- Add configurable `readTimeout`, `writeTimeout`, `idleTimeout`, and `maxHeaderBytes` server settings.
|
||||
- Do not add auth, CORS, rate limiting, request logger middleware, WebSocket, Git, LSP, watcher streaming, or AI Agent orchestration.
|
||||
- Use only one new dependency for this migration: `github.com/gin-gonic/gin`.
|
||||
- Code generation/refactoring must be delegated to Codex MCP using exactly `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`.
|
||||
- Do not commit unless the user explicitly asks.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Modify
|
||||
|
||||
- `go.mod` — add Gin dependency.
|
||||
- `go.sum` — update transitive dependency checksums from `go mod tidy`.
|
||||
- `configs/config.yaml` — add server network settings.
|
||||
- `pkg/config/config.go` — parse duration/header config and environment overrides.
|
||||
- `internal/api/router.go` — replace `http.ServeMux` with `gin.Engine` route registration.
|
||||
- `internal/api/errors_helper.go` — convert error writer to Gin context.
|
||||
- `internal/api/workspace_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/file_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/process_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/router_test.go` — keep smoke tests and add Gin/server compatibility assertions if useful.
|
||||
- `cmd/server/main.go` — use configured server network parameters and keep explicit `http.Server`.
|
||||
- `README.md` — update dependency/framework and config examples if needed.
|
||||
|
||||
### Create
|
||||
|
||||
- `pkg/config/config_test.go` — tests for default config, YAML duration parsing, environment overrides, and invalid config failures.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add configurable server network settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `configs/config.yaml`
|
||||
- Modify: `pkg/config/config.go`
|
||||
- Create: `pkg/config/config_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `config.Load(path string) (Config, error)`.
|
||||
- Produces: `config.ServerConfig` with fields `Addr string`, `ReadTimeout time.Duration`, `WriteTimeout time.Duration`, `IdleTimeout time.Duration`, `MaxHeaderBytes int`.
|
||||
|
||||
- [ ] **Step 1: Write config tests first**
|
||||
|
||||
Create `pkg/config/config_test.go` with tests for these behaviors:
|
||||
|
||||
```go
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIncludesServerNetworkSettings(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Server.Addr != ":8080" {
|
||||
t.Fatalf("Addr = %q, want :8080", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 15*time.Second {
|
||||
t.Fatalf("ReadTimeout = %v, want 15s", cfg.Server.ReadTimeout)
|
||||
}
|
||||
if cfg.Server.WriteTimeout != 15*time.Second {
|
||||
t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout)
|
||||
}
|
||||
if cfg.Server.IdleTimeout != 60*time.Second {
|
||||
t.Fatalf("IdleTimeout = %v, want 60s", cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 1048576 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 1048576", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesServerNetworkSettingsFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
addr: ":9090"
|
||||
readTimeout: "2s"
|
||||
writeTimeout: "3s"
|
||||
idleTimeout: "4s"
|
||||
maxHeaderBytes: 2048
|
||||
workspace:
|
||||
root: "./tmp-workspaces"
|
||||
process:
|
||||
opencodeCommand: "fake-opencode"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":9090" {
|
||||
t.Fatalf("Addr = %q, want :9090", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 2*time.Second || cfg.Server.WriteTimeout != 3*time.Second || cfg.Server.IdleTimeout != 4*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 2s/3s/4s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 2048 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 2048", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesServerEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("CODESPACE_ADDR", ":7070")
|
||||
t.Setenv("CODESPACE_READ_TIMEOUT", "5s")
|
||||
t.Setenv("CODESPACE_WRITE_TIMEOUT", "6s")
|
||||
t.Setenv("CODESPACE_IDLE_TIMEOUT", "7s")
|
||||
t.Setenv("CODESPACE_MAX_HEADER_BYTES", "4096")
|
||||
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":7070" {
|
||||
t.Fatalf("Addr = %q, want :7070", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.WriteTimeout != 6*time.Second || cfg.Server.IdleTimeout != 7*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 5s/6s/7s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 4096 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 4096", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
readTimeout: "garbage"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected invalid duration error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./pkg/config
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Fails because `ServerConfig` lacks timeout/header fields.
|
||||
|
||||
- [ ] **Step 3: Implement config parsing**
|
||||
|
||||
Modify `pkg/config/config.go`:
|
||||
|
||||
- Import `fmt`, `strconv`, and `time`.
|
||||
- Change public `ServerConfig` to:
|
||||
|
||||
```go
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
```
|
||||
|
||||
- Introduce raw YAML structs:
|
||||
|
||||
```go
|
||||
type rawConfig struct {
|
||||
Server rawServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
}
|
||||
|
||||
type rawServerConfig struct {
|
||||
Addr string `yaml:"addr"`
|
||||
ReadTimeout string `yaml:"readTimeout"`
|
||||
WriteTimeout string `yaml:"writeTimeout"`
|
||||
IdleTimeout string `yaml:"idleTimeout"`
|
||||
MaxHeaderBytes int `yaml:"maxHeaderBytes"`
|
||||
}
|
||||
```
|
||||
|
||||
- `Default()` returns `15*time.Second`, `15*time.Second`, `60*time.Second`, and `1048576`.
|
||||
- `Load(path)` starts from `Default()`, unmarshals YAML into `rawConfig`, applies only non-zero/non-empty raw fields, parses durations using `time.ParseDuration`, then applies env.
|
||||
- `applyEnv` parses:
|
||||
- `CODESPACE_READ_TIMEOUT`
|
||||
- `CODESPACE_WRITE_TIMEOUT`
|
||||
- `CODESPACE_IDLE_TIMEOUT`
|
||||
- `CODESPACE_MAX_HEADER_BYTES`
|
||||
- Invalid duration or integer env values return an error from `Load`, not silent fallback.
|
||||
|
||||
A clean implementation shape:
|
||||
|
||||
```go
|
||||
func applyRaw(cfg *Config, raw rawConfig) error
|
||||
func applyDuration(target *time.Duration, value string, name string) error
|
||||
func applyEnv(cfg *Config) error
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update YAML config**
|
||||
|
||||
Modify `configs/config.yaml` to:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify config package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w pkg/config
|
||||
go test ./pkg/config
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate API router and handlers to Gin
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`
|
||||
- Modify: `go.sum`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `internal/api/errors_helper.go`
|
||||
- Modify: `internal/api/workspace_handler.go`
|
||||
- Modify: `internal/api/file_handler.go`
|
||||
- Modify: `internal/api/process_handler.go`
|
||||
- Modify: `internal/api/router_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: service constructors and methods unchanged.
|
||||
- Produces: `api.NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine`.
|
||||
|
||||
- [ ] **Step 1: Add Gin dependency**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go get github.com/gin-gonic/gin
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `go.mod` contains `github.com/gin-gonic/gin`.
|
||||
- `go.sum` is updated.
|
||||
|
||||
- [ ] **Step 2: Keep HTTP tests as compatibility tests**
|
||||
|
||||
`internal/api/router_test.go` can keep using `http.Handler` because `*gin.Engine` implements `ServeHTTP`. Update setup only if compile requires it:
|
||||
|
||||
```go
|
||||
func setupTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
wsMgr := workspace.NewLocalManager(root)
|
||||
procMgr := process.NewManager("")
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr)
|
||||
fileSvc := service.NewFileService(wsMgr)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc)
|
||||
}
|
||||
```
|
||||
|
||||
Existing tests must continue to assert the same HTTP status codes and JSON response bodies.
|
||||
|
||||
- [ ] **Step 3: Implement Gin router**
|
||||
|
||||
Modify `internal/api/router.go` to:
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
wsHandler := &workspaceHandler{svc: workspaces}
|
||||
fileHandler := &fileHandler{svc: files}
|
||||
procHandler := &processHandler{svc: processes}
|
||||
|
||||
r.GET("/healthz", healthHandler)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.POST("/workspaces", wsHandler.create)
|
||||
api.GET("/workspaces/:id", wsHandler.get)
|
||||
api.DELETE("/workspaces/:id", wsHandler.delete)
|
||||
|
||||
api.GET("/workspaces/:id/files", fileHandler.list)
|
||||
api.GET("/workspaces/:id/files/read", fileHandler.read)
|
||||
api.PUT("/workspaces/:id/files/write", fileHandler.write)
|
||||
api.POST("/workspaces/:id/files/mkdir", fileHandler.mkdir)
|
||||
api.DELETE("/workspaces/:id/files", fileHandler.remove)
|
||||
api.POST("/workspaces/:id/files/rename", fileHandler.rename)
|
||||
|
||||
api.POST("/workspaces/:id/process/start", procHandler.start)
|
||||
api.POST("/workspaces/:id/process/stop", procHandler.stop)
|
||||
api.POST("/workspaces/:id/process/restart", procHandler.restart)
|
||||
api.GET("/workspaces/:id/process/status", procHandler.status)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func healthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update error helper for Gin**
|
||||
|
||||
Modify `internal/api/errors_helper.go` to use:
|
||||
|
||||
```go
|
||||
func writeError(c *gin.Context, err error)
|
||||
```
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Use `util.CodeOf(err)`.
|
||||
- Map bad request/not found/conflict/internal to 400/404/409/500.
|
||||
- Respond with `response.ErrorBody{Error: response.ErrorDetail{Code: string(code), Message: message}}`.
|
||||
- If `err == nil`, message should be `http.StatusText(status)`.
|
||||
|
||||
- [ ] **Step 5: Update workspace handler**
|
||||
|
||||
Modify `internal/api/workspace_handler.go`:
|
||||
|
||||
- Remove `encoding/json` import.
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- `create` uses `c.ShouldBindJSON(&req)`.
|
||||
- `get/delete` use `c.Param("id")`.
|
||||
- Success responses use `c.JSON(...)` or `c.Status(http.StatusNoContent)`.
|
||||
|
||||
- [ ] **Step 6: Update file handler**
|
||||
|
||||
Modify `internal/api/file_handler.go`:
|
||||
|
||||
- Remove `encoding/json` import.
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- Workspace ID uses `c.Param("id")`.
|
||||
- Query path uses `c.Query("path")`.
|
||||
- JSON body uses `c.ShouldBindJSON(&req)`.
|
||||
- No-content responses use `c.Status(http.StatusNoContent)`.
|
||||
|
||||
- [ ] **Step 7: Update process handler**
|
||||
|
||||
Modify `internal/api/process_handler.go`:
|
||||
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- Workspace ID uses `c.Param("id")`.
|
||||
- Success responses use `c.Status(http.StatusNoContent)` or `c.JSON(...)`.
|
||||
|
||||
- [ ] **Step 8: Verify API package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w internal/api
|
||||
go test ./internal/api
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- API tests pass with the same status/body assertions.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire Gin engine into explicit http.Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/server/main.go`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.NewRouter(...) *gin.Engine`.
|
||||
- Consumes: `cfg.Server.ReadTimeout`, `cfg.Server.WriteTimeout`, `cfg.Server.IdleTimeout`, `cfg.Server.MaxHeaderBytes`.
|
||||
- Produces: explicit `http.Server{Handler: router}` startup without Gin `Run()`.
|
||||
|
||||
- [ ] **Step 1: Update server wiring**
|
||||
|
||||
Modify `cmd/server/main.go` server construction to:
|
||||
|
||||
```go
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
```
|
||||
|
||||
Remove direct `time` usage if it becomes unused except shutdown timeout. Keep `10*time.Second` for graceful shutdown context unless you add a separate config field; do not add that field in this task.
|
||||
|
||||
- [ ] **Step 2: Update README**
|
||||
|
||||
Update `README.md` so it says:
|
||||
|
||||
- The API router uses Gin.
|
||||
- The server is still started through explicit `http.Server`, not `gin.Engine.Run()`.
|
||||
- Config supports `server.readTimeout`, `server.writeTimeout`, `server.idleTimeout`, and `server.maxHeaderBytes`.
|
||||
|
||||
Do not add promises for auth/CORS/Git/LSP/frontend.
|
||||
|
||||
- [ ] **Step 3: Verify server package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w cmd/server
|
||||
go test ./cmd/server ./pkg/config ./internal/api
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- All listed packages pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Final validation and safety checks
|
||||
|
||||
**Files:**
|
||||
- Modify only files from Tasks 1-3 if validation exposes issues.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces validated Gin migration.
|
||||
|
||||
- [ ] **Step 1: Format, tidy, and test everything**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w cmd internal pkg
|
||||
go mod tidy
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- All tests pass.
|
||||
|
||||
- [ ] **Step 2: Check Gin Run is not used**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -R "\.Run(" cmd internal || true
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- No output.
|
||||
|
||||
- [ ] **Step 3: Check explicit http.Server composition exists**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -R "http.Server" -n cmd/server/main.go
|
||||
grep -R "Handler:" -n cmd/server/main.go
|
||||
grep -R "MaxHeaderBytes" -n cmd/server/main.go pkg/config configs/config.yaml
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `cmd/server/main.go` contains `http.Server`, `Handler: router`, and `MaxHeaderBytes: cfg.Server.MaxHeaderBytes`.
|
||||
- `pkg/config/config.go` and `configs/config.yaml` contain `MaxHeaderBytes`/`maxHeaderBytes`.
|
||||
|
||||
- [ ] **Step 4: Run server smoke test**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
(go run ./cmd/server > /tmp/codespace-server.log 2>&1 & pid=$!; \
|
||||
for i in $(seq 1 30); do \
|
||||
if curl -fsS http://localhost:8080/healthz; then \
|
||||
kill $pid; wait $pid 2>/dev/null || true; exit 0; \
|
||||
fi; \
|
||||
sleep 0.2; \
|
||||
done; \
|
||||
kill $pid 2>/dev/null || true; \
|
||||
wait $pid 2>/dev/null || true; \
|
||||
cat /tmp/codespace-server.log; exit 1)
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Check git diff for scope creep**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Changes are limited to Gin migration, config, docs/spec/plan, `.gitignore`, and previously generated skeleton files.
|
||||
- No auth/CORS/Git/LSP/frontend implementation appears.
|
||||
|
||||
---
|
||||
|
||||
## Codex Execution Prompt
|
||||
|
||||
Use this exact structure when dispatching Codex:
|
||||
|
||||
```text
|
||||
## Context
|
||||
- Project root: /Users/taochen/llm/codespace
|
||||
- Current branch: feat/codespace-backend-skeleton
|
||||
- Existing backend is a Go 1.22 codespace service.
|
||||
- Current router uses standard library http.ServeMux in internal/api/router.go.
|
||||
- Server already uses explicit http.Server in cmd/server/main.go.
|
||||
- Design spec: docs/superpowers/specs/2026-07-02-gin-server-composition-design.md
|
||||
- Implementation plan: docs/superpowers/plans/2026-07-02-gin-server-composition.md
|
||||
|
||||
## Task
|
||||
Migrate routing to Gin and configure explicit http.Server network settings.
|
||||
Steps:
|
||||
1. Add Gin dependency.
|
||||
2. Add configurable server read/write/idle timeout and maxHeaderBytes settings.
|
||||
3. Replace internal/api ServeMux router and handlers with Gin equivalents.
|
||||
4. Keep cmd/server/main.go using explicit http.Server{Handler: router}; do not use router.Run().
|
||||
5. Update tests and README.
|
||||
6. Run final validation.
|
||||
|
||||
## Constraints
|
||||
- Do not change API paths, methods, or response JSON shapes.
|
||||
- Do not change service interfaces.
|
||||
- Do not add auth, CORS, logging middleware, rate limiting, WebSocket, Git, LSP, frontend, watcher streaming, or AI Agent features.
|
||||
- Use only one new dependency: github.com/gin-gonic/gin.
|
||||
- Do not commit.
|
||||
|
||||
## Acceptance
|
||||
- gofmt -w cmd internal pkg succeeds.
|
||||
- go mod tidy succeeds.
|
||||
- go test ./... passes.
|
||||
- grep -R "\.Run(" cmd internal || true returns no output.
|
||||
- cmd/server/main.go contains explicit http.Server with Handler: router and MaxHeaderBytes from config.
|
||||
- go run ./cmd/server starts and /healthz returns {"status":"ok"}.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: Plan covers Gin routing, handler migration, explicit server composition, configurable network settings, docs, tests, and validation against `Run()` misuse.
|
||||
- Placeholder scan: No TBD/TODO/ambiguous implementation steps remain.
|
||||
- Type consistency: `api.NewRouter` consistently returns `*gin.Engine`; `cmd/server/main.go` consumes it as `http.Server.Handler`; `ServerConfig` fields are consistent across plan tasks.
|
||||
- Scope check: Plan is limited to Gin migration and server config. It does not introduce auth, CORS, WebSocket, Git, LSP, frontend, watcher streaming, or Agent features.
|
||||
@@ -0,0 +1,390 @@
|
||||
# codespace 可运行后端骨架设计
|
||||
|
||||
日期:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
在当前目录 `/Users/taochen/llm/codespace` 生成一个 Go 后端项目骨架。第一版目标是可运行、可测试、边界清晰的 Workspace Service,而不是完整 Web IDE。
|
||||
|
||||
核心能力:
|
||||
|
||||
- 每个 workspace 对应本地文件系统中的一个目录。
|
||||
- 文件读写统一通过 `FileSystem` 接口,第一版实现 `LocalFS`。
|
||||
- OpenCode 以本机进程方式启动,工作目录 `cwd` 指向 workspace root。
|
||||
- HTTP handler 不直接操作文件系统或进程,必须经过 service 层。
|
||||
- 预留 watcher、Git、LSP、AI Agent 的后续扩展点。
|
||||
- 不依赖 Docker。
|
||||
|
||||
## 非目标
|
||||
|
||||
第一版不实现:
|
||||
|
||||
- 用户认证和权限系统。
|
||||
- Docker / 容器隔离。
|
||||
- Git 集成。
|
||||
- LSP。
|
||||
- AI Agent 编排。
|
||||
- Web 前端。
|
||||
- 完整 watcher / WebSocket 文件事件推送。
|
||||
|
||||
这些能力以后接入,但不能污染第一版的核心边界。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go
|
||||
├── configs/
|
||||
│ └── config.yaml
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── router.go
|
||||
│ │ ├── workspace_handler.go
|
||||
│ │ ├── file_handler.go
|
||||
│ │ └── process_handler.go
|
||||
│ ├── workspace/
|
||||
│ │ ├── manager.go
|
||||
│ │ ├── workspace.go
|
||||
│ │ └── path.go
|
||||
│ ├── fs/
|
||||
│ │ ├── filesystem.go
|
||||
│ │ ├── localfs.go
|
||||
│ │ └── fileinfo.go
|
||||
│ ├── process/
|
||||
│ │ ├── manager.go
|
||||
│ │ ├── session.go
|
||||
│ │ └── opencode.go
|
||||
│ ├── watcher/
|
||||
│ │ └── watcher.go
|
||||
│ ├── service/
|
||||
│ │ ├── workspace_service.go
|
||||
│ │ ├── file_service.go
|
||||
│ │ └── process_service.go
|
||||
│ ├── model/
|
||||
│ │ ├── workspace.go
|
||||
│ │ ├── file.go
|
||||
│ │ └── process.go
|
||||
│ └── util/
|
||||
│ ├── path.go
|
||||
│ └── errors.go
|
||||
├── pkg/
|
||||
│ ├── config/
|
||||
│ ├── logger/
|
||||
│ └── response/
|
||||
├── web/
|
||||
├── docs/
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
HTTP request
|
||||
↓
|
||||
internal/api handler
|
||||
↓
|
||||
internal/service
|
||||
↓
|
||||
workspace.Manager / fs.FileSystem / process.Manager
|
||||
↓
|
||||
local workspace directory + opencode process
|
||||
```
|
||||
|
||||
边界规则:
|
||||
|
||||
1. `api` 只负责 HTTP 编解码、路由参数、状态码。
|
||||
2. `service` 负责业务编排和校验。
|
||||
3. `workspace` 负责 workspace 元数据和 root 目录管理。
|
||||
4. `fs` 负责 workspace 内文件操作。
|
||||
5. `process` 负责进程生命周期。
|
||||
6. `watcher` 第一版只保留接口/占位,不接入运行链路。
|
||||
|
||||
## 核心数据结构
|
||||
|
||||
### Workspace
|
||||
|
||||
```go
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- `ID` 是外部 API 使用的 workspace 标识。
|
||||
- `Root` 是服务端本地绝对路径。
|
||||
- 第一版不引入数据库,workspace 是否存在由目录和 manager 判断。
|
||||
|
||||
### FileInfo
|
||||
|
||||
```go
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
```
|
||||
|
||||
`Path` 始终是 workspace 内相对路径,不向 API 暴露宿主机绝对路径。
|
||||
|
||||
### ProcessStatus
|
||||
|
||||
```go
|
||||
type ProcessStatus struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Workspace Manager
|
||||
|
||||
职责:
|
||||
|
||||
- 创建 workspace 目录。
|
||||
- 查询 workspace。
|
||||
- 删除 workspace 目录。
|
||||
- 生成 workspace root 路径。
|
||||
|
||||
接口:
|
||||
|
||||
```go
|
||||
type Manager interface {
|
||||
Create(id string) (*Workspace, error)
|
||||
Get(id string) (*Workspace, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
```
|
||||
|
||||
路径策略:
|
||||
|
||||
- 配置项 `workspaceRoot` 默认使用 `./workspaces`。
|
||||
- workspace root 为 `<workspaceRoot>/<workspaceID>`。
|
||||
- workspace ID 第一版限制为安全路径片段,只允许字母、数字、`-`、`_`、`.`。
|
||||
|
||||
## FileSystem
|
||||
|
||||
所有文件操作必须走接口:
|
||||
|
||||
```go
|
||||
type FileSystem interface {
|
||||
List(path string) ([]FileInfo, error)
|
||||
Read(path string) ([]byte, error)
|
||||
Write(path string, data []byte) error
|
||||
Mkdir(path string) error
|
||||
Remove(path string) error
|
||||
Rename(oldPath, newPath string) error
|
||||
Stat(path string) (*FileInfo, error)
|
||||
}
|
||||
```
|
||||
|
||||
第一版实现 `LocalFS`:
|
||||
|
||||
- `LocalFS` 绑定一个 workspace root。
|
||||
- 所有传入路径都是 workspace 内相对路径。
|
||||
- 解析路径时必须阻止 `../` 逃逸。
|
||||
- API 不返回宿主机绝对路径。
|
||||
|
||||
## Process Manager
|
||||
|
||||
职责:按 workspace 管理 OpenCode 进程。
|
||||
|
||||
接口:
|
||||
|
||||
```go
|
||||
type Manager interface {
|
||||
Start(workspaceID string, workspaceRoot string) error
|
||||
Stop(workspaceID string) error
|
||||
Restart(workspaceID string, workspaceRoot string) error
|
||||
Status(workspaceID string) Status
|
||||
}
|
||||
```
|
||||
|
||||
第一版行为:
|
||||
|
||||
- 命令默认是 `opencode`,通过配置项 `opencodeCommand` 可改。
|
||||
- `cmd.Dir = workspace.Root`。
|
||||
- `HOME` 默认设置为 workspace root,避免不同 workspace 共享 CLI 状态。
|
||||
- 同一个 workspace 已有运行进程时,再次 start 返回已运行错误,不重复启动。
|
||||
- stop 使用进程 kill;优雅退出可以后续补。
|
||||
|
||||
## Service 层
|
||||
|
||||
### WorkspaceService
|
||||
|
||||
- `CreateWorkspace(id string)`
|
||||
- `GetWorkspace(id string)`
|
||||
- `DeleteWorkspace(id string)`
|
||||
|
||||
删除 workspace 前,先停止对应进程,避免目录被运行进程占用。
|
||||
|
||||
### FileService
|
||||
|
||||
- `List(workspaceID, path string)`
|
||||
- `Read(workspaceID, path string)`
|
||||
- `Write(workspaceID, path string, data []byte)`
|
||||
- `Mkdir(workspaceID, path string)`
|
||||
- `Remove(workspaceID, path string)`
|
||||
- `Rename(workspaceID, oldPath, newPath string)`
|
||||
- `Stat(workspaceID, path string)`
|
||||
|
||||
每次通过 workspace manager 取 workspace,再构造绑定该 root 的 `LocalFS`。
|
||||
|
||||
### ProcessService
|
||||
|
||||
- `Start(workspaceID string)`
|
||||
- `Stop(workspaceID string)`
|
||||
- `Restart(workspaceID string)`
|
||||
- `Status(workspaceID string)`
|
||||
|
||||
ProcessService 不自己拼路径,只通过 workspace manager 取 root。
|
||||
|
||||
## HTTP API
|
||||
|
||||
基础路径:`/api`
|
||||
|
||||
### 健康检查
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
### Workspace
|
||||
|
||||
```text
|
||||
POST /api/workspaces
|
||||
GET /api/workspaces/{id}
|
||||
DELETE /api/workspaces/{id}
|
||||
```
|
||||
|
||||
创建请求:
|
||||
|
||||
```json
|
||||
{"id":"user1"}
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```text
|
||||
GET /api/workspaces/{id}/files?path=.
|
||||
GET /api/workspaces/{id}/files/read?path=main.go
|
||||
PUT /api/workspaces/{id}/files/write
|
||||
POST /api/workspaces/{id}/files/mkdir
|
||||
DELETE /api/workspaces/{id}/files?path=main.go
|
||||
POST /api/workspaces/{id}/files/rename
|
||||
```
|
||||
|
||||
写文件请求:
|
||||
|
||||
```json
|
||||
{"path":"main.go","content":"package main\n"}
|
||||
```
|
||||
|
||||
重命名请求:
|
||||
|
||||
```json
|
||||
{"oldPath":"old.go","newPath":"new.go"}
|
||||
```
|
||||
|
||||
### Process
|
||||
|
||||
```text
|
||||
POST /api/workspaces/{id}/process/start
|
||||
POST /api/workspaces/{id}/process/stop
|
||||
POST /api/workspaces/{id}/process/restart
|
||||
GET /api/workspaces/{id}/process/status
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`configs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
配置加载规则:
|
||||
|
||||
1. 先加载默认值。
|
||||
2. 如果配置文件存在,覆盖默认值。
|
||||
3. 环境变量可覆盖:
|
||||
- `CODESPACE_ADDR`
|
||||
- `CODESPACE_WORKSPACE_ROOT`
|
||||
- `CODESPACE_OPENCODE_COMMAND`
|
||||
|
||||
## 错误处理
|
||||
|
||||
统一 JSON 响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "bad_request",
|
||||
"message": "invalid workspace id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见映射:
|
||||
|
||||
- 参数错误:400
|
||||
- workspace 不存在:404
|
||||
- 文件不存在:404
|
||||
- 进程已运行:409
|
||||
- 内部错误:500
|
||||
|
||||
第一版只做简单错误类型,不引入复杂错误框架。
|
||||
|
||||
## 测试策略
|
||||
|
||||
最小测试:
|
||||
|
||||
- workspace ID 校验和路径生成。
|
||||
- LocalFS 路径逃逸防护。
|
||||
- LocalFS 基础读写/列表。
|
||||
- Process manager 状态逻辑使用可配置命令测试,不依赖真实 OpenCode。
|
||||
- HTTP handler 可做轻量测试,至少覆盖 health 和 workspace 创建。
|
||||
|
||||
验收命令:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
## 兼容性与风险
|
||||
|
||||
- 当前目录是项目根目录,不再额外创建 `codespace/` 子目录。
|
||||
- 初始化会创建标准 Go 项目文件;如果目标文件已存在,实施阶段必须先检查,避免覆盖用户已有内容。
|
||||
- 不改变公共 API,因为这是初始版本。
|
||||
- 最大安全风险是路径逃逸,必须在 `LocalFS` 和 workspace ID 校验中处理。
|
||||
- 最大复杂度风险是过早引入 watcher/Git/LSP/Agent;第一版明确不实现。
|
||||
|
||||
## 骨架完成标准
|
||||
|
||||
完成后应满足:
|
||||
|
||||
- 当前目录包含完整 Go module。
|
||||
- `go test ./...` 通过。
|
||||
- `go run ./cmd/server` 能启动 HTTP 服务。
|
||||
- 可以创建 workspace、读写 workspace 内文件、查询/启动/停止进程。
|
||||
- Handler 不直接调用 `os.*` 或 `exec.*`。
|
||||
- 文件路径不能逃逸 workspace root。
|
||||
@@ -0,0 +1,287 @@
|
||||
# Gin Router and Explicit HTTP Server Composition Design
|
||||
|
||||
日期:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
将 codespace 后端的原生 Go 1.22 `http.ServeMux` 路由替换为 Gin,同时继续显式构造 `http.Server`。Gin 只负责路由和 handler 上下文,网络层仍由 `http.Server` 控制。
|
||||
|
||||
本次改动的核心要求:
|
||||
|
||||
- 使用 Gin 注册 API 路由。
|
||||
- 不使用 `router.Run()` 或 `engine.Run()`。
|
||||
- 将 `*gin.Engine` 显式设置为 `http.Server.Handler`。
|
||||
- 将服务器网络参数配置化,包括 timeout 和最大请求头大小。
|
||||
- 保持现有 API 路径、HTTP method、响应 JSON 结构不变。
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前代码:
|
||||
|
||||
- `cmd/server/main.go` 已显式构造 `http.Server`。
|
||||
- `internal/api/router.go` 使用 `http.NewServeMux()`。
|
||||
- handler 使用 `http.ResponseWriter`、`*http.Request`、`r.PathValue("id")` 和 `json.NewDecoder(r.Body)`。
|
||||
- `pkg/config` 只配置 `server.addr`,timeout 仍硬编码在 `main.go`。
|
||||
|
||||
## 非目标
|
||||
|
||||
本次不做:
|
||||
|
||||
- 不引入认证、CORS、rate limit、request logger middleware。
|
||||
- 不改变 workspace/file/process service 接口。
|
||||
- 不改变 API 路径、HTTP method、响应结构。
|
||||
- 不实现 WebSocket、watcher 推送、Git、LSP、AI Agent。
|
||||
- 不把网络层控制权交给 Gin 的 `Run()`。
|
||||
|
||||
## 目标架构
|
||||
|
||||
```text
|
||||
cmd/server/main.go
|
||||
↓
|
||||
config.Load()
|
||||
↓
|
||||
api.NewRouter(...)
|
||||
↓
|
||||
*gin.Engine
|
||||
↓
|
||||
http.Server{
|
||||
Addr,
|
||||
Handler: ginEngine,
|
||||
ReadTimeout,
|
||||
WriteTimeout,
|
||||
IdleTimeout,
|
||||
MaxHeaderBytes,
|
||||
}
|
||||
↓
|
||||
ListenAndServe()
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
1. `internal/api.NewRouter` 返回 `*gin.Engine`。
|
||||
2. `cmd/server/main.go` 显式创建 `http.Server`。
|
||||
3. `http.Server.Handler` 设置为 Gin engine。
|
||||
4. 代码中不得出现 `.Run(` 用于启动 Gin server。
|
||||
5. `ReadTimeout`、`WriteTimeout`、`IdleTimeout`、`MaxHeaderBytes` 来自配置。
|
||||
|
||||
## 配置设计
|
||||
|
||||
`configs/config.yaml` 扩展为:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
`pkg/config.ServerConfig` 对外使用强类型:
|
||||
|
||||
```go
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
```
|
||||
|
||||
YAML 加载不要依赖 `time.Duration` 的隐式解析。使用 raw config 字符串字段承接 YAML,然后通过 `time.ParseDuration` 显式解析。这样 `"15s"`、`"1m"`、`"500ms"` 都清晰可控。
|
||||
|
||||
环境变量增加:
|
||||
|
||||
- `CODESPACE_READ_TIMEOUT`
|
||||
- `CODESPACE_WRITE_TIMEOUT`
|
||||
- `CODESPACE_IDLE_TIMEOUT`
|
||||
- `CODESPACE_MAX_HEADER_BYTES`
|
||||
|
||||
已有环境变量保持不变:
|
||||
|
||||
- `CODESPACE_ADDR`
|
||||
- `CODESPACE_WORKSPACE_ROOT`
|
||||
- `CODESPACE_OPENCODE_COMMAND`
|
||||
|
||||
默认值:
|
||||
|
||||
- `addr`: `:8080`
|
||||
- `readTimeout`: `15s`
|
||||
- `writeTimeout`: `15s`
|
||||
- `idleTimeout`: `60s`
|
||||
- `maxHeaderBytes`: `1048576`
|
||||
|
||||
## 路由设计
|
||||
|
||||
原路由保持不变:
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
POST /api/workspaces
|
||||
GET /api/workspaces/:id
|
||||
DELETE /api/workspaces/:id
|
||||
GET /api/workspaces/:id/files
|
||||
GET /api/workspaces/:id/files/read
|
||||
PUT /api/workspaces/:id/files/write
|
||||
POST /api/workspaces/:id/files/mkdir
|
||||
DELETE /api/workspaces/:id/files
|
||||
POST /api/workspaces/:id/files/rename
|
||||
POST /api/workspaces/:id/process/start
|
||||
POST /api/workspaces/:id/process/stop
|
||||
POST /api/workspaces/:id/process/restart
|
||||
GET /api/workspaces/:id/process/status
|
||||
```
|
||||
|
||||
Gin 注册方式:
|
||||
|
||||
```go
|
||||
r := gin.New()
|
||||
r.GET("/healthz", healthHandler)
|
||||
api := r.Group("/api")
|
||||
api.POST("/workspaces", workspaceHandler.create)
|
||||
api.GET("/workspaces/:id", workspaceHandler.get)
|
||||
```
|
||||
|
||||
第一版使用 `gin.New()` 而不是 `gin.Default()`,避免隐式加入 logger middleware。只添加 `gin.Recovery()`,防止 panic 打爆进程。请求日志以后有真实需求再加。
|
||||
|
||||
## Handler 迁移
|
||||
|
||||
签名从:
|
||||
|
||||
```go
|
||||
func (h *workspaceHandler) get(w http.ResponseWriter, r *http.Request)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```go
|
||||
func (h *workspaceHandler) get(c *gin.Context)
|
||||
```
|
||||
|
||||
路径参数:
|
||||
|
||||
```go
|
||||
id := c.Param("id")
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
```go
|
||||
path := c.Query("path")
|
||||
```
|
||||
|
||||
JSON body:
|
||||
|
||||
```go
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, util.New(util.CodeBadRequest, "invalid json"))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```go
|
||||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||||
c.Status(http.StatusNoContent)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
保持现有错误 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "bad_request",
|
||||
"message": "invalid json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`internal/api/errors_helper.go` 改为接收 `*gin.Context`:
|
||||
|
||||
```go
|
||||
func writeError(c *gin.Context, err error)
|
||||
```
|
||||
|
||||
继续使用 `util.CodeOf(err)` 映射:
|
||||
|
||||
- `bad_request` -> 400
|
||||
- `not_found` -> 404
|
||||
- `conflict` -> 409
|
||||
- default -> 500
|
||||
|
||||
可以复用 `pkg/response.ErrorBody` 和 `pkg/response.ErrorDetail`,避免重复定义响应结构。
|
||||
|
||||
## Server 组合
|
||||
|
||||
`cmd/server/main.go` 最终保持显式组合:
|
||||
|
||||
```go
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
```
|
||||
|
||||
启动仍然使用:
|
||||
|
||||
```go
|
||||
server.ListenAndServe()
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
现有 `httptest` 测试继续有效,因为 `*gin.Engine` 实现 `http.Handler`。
|
||||
|
||||
需要验证:
|
||||
|
||||
- `GET /healthz` 行为不变。
|
||||
- workspace 创建、文件写读、process status 行为不变。
|
||||
- Gin path param `:id` 正常传入 service。
|
||||
- 配置默认值正确。
|
||||
- YAML timeout 字符串能解析。
|
||||
- 环境变量能覆盖 timeout 和 `maxHeaderBytes`。
|
||||
- `go test ./...` 通过。
|
||||
- `grep -R "\.Run(" cmd internal || true` 无输出。
|
||||
- `go run ./cmd/server` 后 `/healthz` 返回 `{"status":"ok"}`。
|
||||
|
||||
## 依赖
|
||||
|
||||
新增唯一依赖:
|
||||
|
||||
```text
|
||||
github.com/gin-gonic/gin
|
||||
```
|
||||
|
||||
不新增其他框架或中间件依赖。
|
||||
|
||||
## 风险与处理
|
||||
|
||||
- 风险:Gin 默认模式可能输出 debug 信息。处理:启动时可设置 `gin.SetMode(gin.ReleaseMode)`,或保留 Gin 默认但不影响功能。第一版建议在 `NewRouter` 中设置 `gin.ReleaseMode`,减少噪音。
|
||||
- 风险:`time.Duration` YAML 解析行为不符合预期。处理:使用 raw string + `time.ParseDuration`。
|
||||
- 风险:错误响应格式变化。处理:复用现有响应结构并保留 HTTP 测试。
|
||||
- 风险:误用 `router.Run()`。处理:增加 grep 验证。
|
||||
|
||||
## 完成标准
|
||||
|
||||
完成后必须满足:
|
||||
|
||||
- `internal/api` 使用 Gin 注册路由。
|
||||
- `cmd/server/main.go` 使用 `http.Server{Handler: router}`。
|
||||
- 不出现 Gin `Run()` 启动服务器。
|
||||
- server 网络参数来自配置。
|
||||
- API 行为和响应结构保持兼容。
|
||||
- `go test ./...` 通过。
|
||||
- server smoke test 通过。
|
||||
Reference in New Issue
Block a user