From 4a042a8b71183b8dd5d8915dc69cea9700d48262 Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 24 Jan 2026 20:56:27 -0700 Subject: [PATCH] feat: Add rdev-api Go server with OpenAPI docs Implements a fully documented API server following the aeries chassis pattern: - pkg/api: Simplified chassis with App, Response helpers, and OpenAPI builder - cmd/rdev-api: Entry point with full OpenAPI spec for all v0.4 endpoints - internal/handlers: Stubbed project handlers (list, get, claude, shell, git, events) Endpoints: - GET /health, /ready - Health checks - GET /docs, /openapi.json - Scalar API docs - GET /projects - List projects - GET /projects/{id} - Get project - POST /projects/{id}/claude, shell, git - Run commands - GET /projects/{id}/events - SSE streaming Uses Scalar for dark-mode API documentation at /docs. Co-Authored-By: Claude Opus 4.5 --- cmd/rdev-api/main.go | 313 ++++++++++++++++++++++++++++++++++ go.mod | 10 ++ go.sum | 14 ++ internal/handlers/projects.go | 192 +++++++++++++++++++++ pkg/api/app.go | 221 ++++++++++++++++++++++++ pkg/api/openapi.go | 171 +++++++++++++++++++ pkg/api/response.go | 99 +++++++++++ 7 files changed, 1020 insertions(+) create mode 100644 cmd/rdev-api/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handlers/projects.go create mode 100644 pkg/api/app.go create mode 100644 pkg/api/openapi.go create mode 100644 pkg/api/response.go diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go new file mode 100644 index 0000000..4a5fcb0 --- /dev/null +++ b/cmd/rdev-api/main.go @@ -0,0 +1,313 @@ +// Package main provides the entry point for the rdev API server. +// +// rdev (Remote Developer) provides a REST API for controlling Claude Code +// instances running in Kubernetes pods. External clients (Discord bots, +// CLI tools, etc.) connect via this API. +// +// Endpoints: +// - GET /health - Health check +// - GET /ready - Readiness check +// - GET /docs - Scalar API documentation +// - GET /openapi.json - OpenAPI 3.0 specification +// - GET /projects - List available projects +// - GET /projects/{id} - Get project details +// - POST /projects/{id}/claude - Run Claude command +// - POST /projects/{id}/shell - Run shell command +// - POST /projects/{id}/git - Run git command +// - GET /projects/{id}/events - SSE stream for output +package main + +import ( + "github.com/orchard9/rdev/internal/handlers" + "github.com/orchard9/rdev/pkg/api" +) + +func main() { + app := api.New("rdev-api", api.WithPort(8080)) + + // Initialize handlers + projectsHandler := handlers.NewProjectsHandler() + + // Register routes + projectsHandler.Mount(app.Router()) + + // Enable API documentation + app.EnableDocs(buildOpenAPISpec()) + + app.Run() +} + +// buildOpenAPISpec creates the OpenAPI specification for the rdev API. +func buildOpenAPISpec() *api.OpenAPISpec { + spec := api.NewOpenAPISpec("rdev API", "0.4.0"). + WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes. + +rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming. +External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects. + +## Architecture + +- **rdev-api**: This Go service (what you're looking at) +- **claudebox pods**: Isolated pods running Claude Code CLI +- **Projects**: Each project has its own claudebox pod with mounted workspace + +## Authentication + +Authentication is planned for v0.6. Currently the API is internal-only. + +## Streaming + +Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/events endpoint. +`). + WithServer("http://localhost:8080", "Local development"). + WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal") + + // Tags + spec.WithTag("Projects", "Project management and discovery") + spec.WithTag("Commands", "Command execution (claude, shell, git)") + spec.WithTag("Events", "Server-Sent Events for real-time output") + spec.WithTag("System", "Health and readiness endpoints") + + // System endpoints (auto-added by chassis, but document them) + spec.AddPath("/health", "get", api.Op( + "Health check", + "Returns the health status of the rdev-api service", + "System", + )) + spec.AddPath("/ready", "get", api.Op( + "Readiness check", + "Returns whether the service is ready to accept traffic", + "System", + )) + + // Projects - List and Get + spec.AddPath("/projects", "get", withResponse( + "List projects", + "Returns all available projects (claudebox pods) in the cluster", + "Projects", + `[ + { + "id": "pantheon", + "name": "Pantheon", + "description": "Go API backend", + "pod": "claudebox-pantheon-0", + "status": "running" + } +]`, + )) + + spec.AddPath("/projects/{id}", "get", withParams( + "Get project", + "Returns details for a specific project including its configuration", + "Projects", + []param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}}, + )) + + // Commands + spec.AddPath("/projects/{id}/claude", "post", withRequestBody( + "Run Claude command", + "Executes a Claude Code prompt in the project's claudebox pod. Returns a command ID for tracking via SSE.", + "Commands", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "prompt": "fix the bug in auth handler", + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-001", + "project": "pantheon", + "type": "claude", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001" +}`, + )) + + spec.AddPath("/projects/{id}/shell", "post", withRequestBody( + "Run shell command", + "Executes a shell command in the project's claudebox pod. Use for running tests, builds, etc.", + "Commands", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "command": "go test ./...", + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-002", + "project": "pantheon", + "type": "shell", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-002" +}`, + )) + + spec.AddPath("/projects/{id}/git", "post", withRequestBody( + "Run git command", + "Executes a git command in the project's claudebox pod. Use for commits, pushes, pulls, etc.", + "Commands", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "args": ["status"], + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-003", + "project": "pantheon", + "type": "git", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-003" +}`, + )) + + // SSE Events + spec.AddPath("/projects/{id}/events", "get", map[string]any{ + "summary": "Stream events", + "description": `Server-Sent Events stream for real-time command output. + +Connect to this endpoint to receive output as commands execute. + +## Event Types + +- **connected**: Initial connection confirmation +- **output**: Command output (stdout or stderr) +- **error**: Error occurred during execution +- **complete**: Command finished (includes exit_code and duration_ms) +- **heartbeat**: Keep-alive (sent every 30s) + +## Example + +` + "```javascript" + ` +const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001'); + +events.addEventListener('output', (e) => { + const data = JSON.parse(e.data); + console.log(data.line); +}); + +events.addEventListener('complete', (e) => { + const data = JSON.parse(e.data); + console.log('Done:', data.exit_code); + events.close(); +}); +` + "```", + "tags": []string{"Events"}, + "parameters": []map[string]any{ + { + "name": "id", + "in": "path", + "description": "Project ID", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + { + "name": "stream_id", + "in": "query", + "description": "Command ID to filter events (optional)", + "required": false, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "SSE stream", + "content": map[string]any{ + "text/event-stream": map[string]any{ + "schema": map[string]any{ + "type": "string", + "example": "event: output\ndata: {\"line\": \"Building...\", \"stream\": \"stdout\"}\n\n", + }, + }, + }, + }, + }, + }) + + return spec +} + +// param represents an OpenAPI parameter. +type param struct { + Name string + In string + Description string + Required bool +} + +// withResponse creates an operation with example response. +func withResponse(summary, description, tag, example string) map[string]any { + return map[string]any{ + "summary": summary, + "description": description, + "tags": []string{tag}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Success", + "content": map[string]any{ + "application/json": map[string]any{ + "example": example, + }, + }, + }, + }, + } +} + +// withParams creates an operation with path parameters. +func withParams(summary, description, tag string, params []param) map[string]any { + parameters := make([]map[string]any, len(params)) + for i, p := range params { + parameters[i] = map[string]any{ + "name": p.Name, + "in": p.In, + "description": p.Description, + "required": p.Required, + "schema": map[string]any{"type": "string"}, + } + } + return map[string]any{ + "summary": summary, + "description": description, + "tags": []string{tag}, + "parameters": parameters, + "responses": map[string]any{ + "200": map[string]any{"description": "Success"}, + }, + } +} + +// withRequestBody creates an operation with request body and example response. +func withRequestBody(summary, description, tag string, params []param, requestExample, responseExample string) map[string]any { + parameters := make([]map[string]any, len(params)) + for i, p := range params { + parameters[i] = map[string]any{ + "name": p.Name, + "in": p.In, + "description": p.Description, + "required": p.Required, + "schema": map[string]any{"type": "string"}, + } + } + return map[string]any{ + "summary": summary, + "description": description, + "tags": []string{tag}, + "parameters": parameters, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "example": requestExample, + }, + }, + }, + "responses": map[string]any{ + "201": map[string]any{ + "description": "Command queued", + "content": map[string]any{ + "application/json": map[string]any{ + "example": responseExample, + }, + }, + }, + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7be34d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/orchard9/rdev + +go 1.23 + +require ( + github.com/bdpiprava/scalar-go v0.13.0 + github.com/go-chi/chi/v5 v5.1.0 +) + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1d624a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k= +github.com/bdpiprava/scalar-go v0.13.0/go.mod h1:e5Nn4yIhcYjlucu4ACMqcs410nIAe5whqj78H3Qv7vw= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go new file mode 100644 index 0000000..d4660bc --- /dev/null +++ b/internal/handlers/projects.go @@ -0,0 +1,192 @@ +// Package handlers provides HTTP handlers for the rdev API. +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/pkg/api" +) + +// ProjectsHandler handles project-related endpoints. +type ProjectsHandler struct{} + +// NewProjectsHandler creates a new projects handler. +func NewProjectsHandler() *ProjectsHandler { + return &ProjectsHandler{} +} + +// Mount registers the projects routes. +func (h *ProjectsHandler) Mount(r api.Router) { + r.Route("/projects", func(r chi.Router) { + r.Get("/", h.List) + r.Get("/{id}", h.Get) + r.Post("/{id}/claude", h.RunClaude) + r.Post("/{id}/shell", h.RunShell) + r.Post("/{id}/git", h.RunGit) + r.Get("/{id}/events", h.Events) + }) +} + +// List returns all available projects. +// GET /projects +func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { + // TODO: Implement project discovery from K8s + projects := []map[string]any{ + { + "id": "pantheon", + "name": "Pantheon", + "description": "Go API backend", + "pod": "claudebox-pantheon-0", + "status": "running", + }, + { + "id": "aeries", + "name": "Aeries", + "description": "Note community platform", + "pod": "claudebox-aeries-0", + "status": "running", + }, + } + + api.WriteSuccess(w, r, projects) +} + +// Get returns a specific project by ID. +// GET /projects/{id} +func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // TODO: Look up project from registry + project := map[string]any{ + "id": id, + "name": id, + "description": "Project description", + "pod": "claudebox-" + id + "-0", + "status": "running", + "workspace": "/workspace", + "config": map[string]any{ + "claude_auth": true, + "git_enabled": true, + "last_command": nil, + }, + } + + api.WriteSuccess(w, r, project) +} + +// RunClaude executes a Claude command in the project's claudebox. +// POST /projects/{id}/claude +func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // TODO: Parse request body for prompt + // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- claude "{prompt}" + + result := map[string]any{ + "id": "cmd-" + id + "-001", + "project": id, + "type": "claude", + "status": "queued", + "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-001", + } + + api.WriteCreated(w, r, result) +} + +// RunShell executes a shell command in the project's claudebox. +// POST /projects/{id}/shell +func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // TODO: Parse request body for command + // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- bash -c "{command}" + + result := map[string]any{ + "id": "cmd-" + id + "-002", + "project": id, + "type": "shell", + "status": "queued", + "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-002", + } + + api.WriteCreated(w, r, result) +} + +// RunGit executes a git command in the project's claudebox. +// POST /projects/{id}/git +func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // TODO: Parse request body for git command + // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- git {args} + + result := map[string]any{ + "id": "cmd-" + id + "-003", + "project": id, + "type": "git", + "status": "queued", + "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-003", + } + + api.WriteCreated(w, r, result) +} + +// Events streams command output via Server-Sent Events. +// GET /projects/{id}/events +func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + streamID := r.URL.Query().Get("stream_id") + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + // TODO: Stream actual command output + // For now, send mock events + + // Send initial connected event + writeSSE(w, flusher, "connected", map[string]any{ + "project": id, + "stream_id": streamID, + }) + + // Send mock output + writeSSE(w, flusher, "output", map[string]any{ + "line": "Starting command execution...", + "stream": "stdout", + }) + + writeSSE(w, flusher, "output", map[string]any{ + "line": "Command completed successfully.", + "stream": "stdout", + }) + + // Send complete event + writeSSE(w, flusher, "complete", map[string]any{ + "exit_code": 0, + "duration_ms": 1234, + }) +} + +// writeSSE writes a Server-Sent Event. +func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) { + dataBytes, _ := jsonMarshal(data) + w.Write([]byte("event: " + event + "\n")) + w.Write([]byte("data: " + string(dataBytes) + "\n\n")) + flusher.Flush() +} + +// jsonMarshal is a simple JSON marshal helper. +func jsonMarshal(v any) ([]byte, error) { + return json.Marshal(v) +} diff --git a/pkg/api/app.go b/pkg/api/app.go new file mode 100644 index 0000000..cec6b9f --- /dev/null +++ b/pkg/api/app.go @@ -0,0 +1,221 @@ +// Package api provides HTTP service infrastructure for rdev. +package api + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// Router is an alias for chi.Router, exposing it for handler mounting. +type Router = chi.Router + +// App is the main application struct that provides HTTP infrastructure. +// It manages routing, logging, and graceful shutdown. +type App struct { + name string + port int + logger *slog.Logger + router chi.Router + server *http.Server + + onShutdown []func(context.Context) error +} + +// Option configures the App. +type Option func(*App) + +// WithPort sets the server port. +func WithPort(port int) Option { + return func(a *App) { + a.port = port + } +} + +// WithLogger sets a custom logger. +func WithLogger(logger *slog.Logger) Option { + return func(a *App) { + a.logger = logger + } +} + +// New creates a new App instance. +func New(name string, opts ...Option) *App { + app := &App{ + name: name, + port: 8080, + onShutdown: make([]func(context.Context) error, 0), + } + + for _, opt := range opts { + opt(app) + } + + if app.logger == nil { + app.logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + } + + app.router = chi.NewRouter() + app.setupMiddleware() + app.setupHealthEndpoints() + + return app +} + +// setupMiddleware configures the standard middleware stack. +func (a *App) setupMiddleware() { + a.router.Use(middleware.RequestID) + a.router.Use(middleware.RealIP) + a.router.Use(middleware.Logger) + a.router.Use(middleware.Recoverer) + a.router.Use(middleware.Timeout(60 * time.Second)) +} + +// setupHealthEndpoints registers /health and /ready endpoints. +func (a *App) setupHealthEndpoints() { + a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) { + WriteSuccess(w, r, map[string]string{ + "status": "ok", + "service": a.name, + }) + }) + + a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) { + WriteSuccess(w, r, map[string]string{ + "status": "ready", + "service": a.name, + }) + }) +} + +// Logger returns the application logger. +func (a *App) Logger() *slog.Logger { + return a.logger +} + +// Router returns the underlying chi router. +func (a *App) Router() chi.Router { + return a.router +} + +// Use appends middleware to the router middleware stack. +func (a *App) Use(middlewares ...func(http.Handler) http.Handler) { + a.router.Use(middlewares...) +} + +// GET registers a handler for GET requests. +func (a *App) GET(pattern string, handler http.HandlerFunc) { + a.router.Get(pattern, handler) +} + +// POST registers a handler for POST requests. +func (a *App) POST(pattern string, handler http.HandlerFunc) { + a.router.Post(pattern, handler) +} + +// PUT registers a handler for PUT requests. +func (a *App) PUT(pattern string, handler http.HandlerFunc) { + a.router.Put(pattern, handler) +} + +// PATCH registers a handler for PATCH requests. +func (a *App) PATCH(pattern string, handler http.HandlerFunc) { + a.router.Patch(pattern, handler) +} + +// DELETE registers a handler for DELETE requests. +func (a *App) DELETE(pattern string, handler http.HandlerFunc) { + a.router.Delete(pattern, handler) +} + +// Route creates a new sub-router with the given pattern prefix. +func (a *App) Route(pattern string, fn func(r chi.Router)) { + a.router.Route(pattern, fn) +} + +// Mount attaches a sub-router or http.Handler at the given pattern. +func (a *App) Mount(pattern string, handler http.Handler) { + a.router.Mount(pattern, handler) +} + +// OnShutdown registers a function to be called during graceful shutdown. +func (a *App) OnShutdown(fn func(context.Context) error) { + a.onShutdown = append(a.onShutdown, fn) +} + +// Run starts the HTTP server and blocks until shutdown. +func (a *App) Run() { + addr := fmt.Sprintf(":%d", a.port) + + a.server = &http.Server{ + Addr: addr, + Handler: a.router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + errChan := make(chan error, 1) + go func() { + a.logger.Info("starting server", + "service", a.name, + "address", addr, + ) + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errChan: + a.logger.Error("server error", "error", err) + os.Exit(1) + case sig := <-quit: + a.logger.Info("received shutdown signal", "signal", sig.String()) + } + + a.shutdown() +} + +// shutdown performs graceful shutdown of the application. +func (a *App) shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + a.logger.Info("shutting down server") + + if err := a.server.Shutdown(ctx); err != nil { + a.logger.Error("server shutdown error", "error", err) + } + + for _, fn := range a.onShutdown { + if err := fn(ctx); err != nil { + a.logger.Error("shutdown hook error", "error", err) + } + } + + a.logger.Info("server stopped", "service", a.name) +} + +// ServeHTTP implements http.Handler, allowing App to be used in tests. +func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.router.ServeHTTP(w, r) +} + +// ListenAddr returns the address the server is configured to listen on. +func (a *App) ListenAddr() string { + return fmt.Sprintf(":%d", a.port) +} diff --git a/pkg/api/openapi.go b/pkg/api/openapi.go new file mode 100644 index 0000000..0b366d1 --- /dev/null +++ b/pkg/api/openapi.go @@ -0,0 +1,171 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + + scalargo "github.com/bdpiprava/scalar-go" +) + +// OpenAPIInfo contains metadata about the API. +type OpenAPIInfo struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Version string `json:"version"` +} + +// OpenAPIServer describes a server endpoint. +type OpenAPIServer struct { + URL string `json:"url"` + Description string `json:"description,omitempty"` +} + +// OpenAPISpec represents a minimal OpenAPI 3.0 specification. +type OpenAPISpec struct { + OpenAPI string `json:"openapi"` + Info OpenAPIInfo `json:"info"` + Servers []OpenAPIServer `json:"servers,omitempty"` + Paths map[string]map[string]interface{} `json:"paths"` + Tags []OpenAPITag `json:"tags,omitempty"` + + mu sync.RWMutex +} + +// OpenAPITag groups operations together. +type OpenAPITag struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// NewOpenAPISpec creates a new OpenAPI specification builder. +func NewOpenAPISpec(title, version string) *OpenAPISpec { + return &OpenAPISpec{ + OpenAPI: "3.0.3", + Info: OpenAPIInfo{ + Title: title, + Version: version, + }, + Paths: make(map[string]map[string]interface{}), + } +} + +// WithDescription sets the API description. +func (s *OpenAPISpec) WithDescription(desc string) *OpenAPISpec { + s.Info.Description = desc + return s +} + +// WithServer adds a server to the spec. +func (s *OpenAPISpec) WithServer(url, description string) *OpenAPISpec { + s.Servers = append(s.Servers, OpenAPIServer{ + URL: url, + Description: description, + }) + return s +} + +// WithTag adds a tag for grouping operations. +func (s *OpenAPISpec) WithTag(name, description string) *OpenAPISpec { + s.Tags = append(s.Tags, OpenAPITag{ + Name: name, + Description: description, + }) + return s +} + +// AddPath adds an operation to the spec. +// method should be lowercase (get, post, put, patch, delete). +func (s *OpenAPISpec) AddPath(path, method string, operation map[string]interface{}) *OpenAPISpec { + s.mu.Lock() + defer s.mu.Unlock() + + if s.Paths[path] == nil { + s.Paths[path] = make(map[string]interface{}) + } + s.Paths[path][method] = operation + return s +} + +// JSON returns the spec as JSON bytes. +func (s *OpenAPISpec) JSON() ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return json.MarshalIndent(s, "", " ") +} + +// EnableDocs adds /docs and /openapi.json endpoints to the app. +func (a *App) EnableDocs(spec *OpenAPISpec) { + // Serve OpenAPI JSON + a.router.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + specBytes, err := spec.JSON() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(specBytes) + }) + + // Serve Scalar docs UI + a.router.Get("/docs", func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host) + + html, err := scalargo.NewV2( + scalargo.WithSpecURL(specURL), + scalargo.WithDarkMode(), + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, html) + }) + + a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json") +} + +// Op creates an OpenAPI operation helper. +func Op(summary, description string, tags ...string) map[string]any { + return map[string]any{ + "summary": summary, + "description": description, + "tags": tags, + "responses": map[string]any{ + "200": map[string]any{"description": "Success"}, + }, + } +} + +// OpWithBody creates an OpenAPI operation with a request body. +func OpWithBody(summary, description string, tags ...string) map[string]any { + return map[string]any{ + "summary": summary, + "description": description, + "tags": tags, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "Success"}, + "201": map[string]any{"description": "Created"}, + }, + } +} diff --git a/pkg/api/response.go b/pkg/api/response.go new file mode 100644 index 0000000..df272cc --- /dev/null +++ b/pkg/api/response.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// Response is the standard envelope for all API responses. +type Response struct { + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta Meta `json:"meta"` +} + +// Error represents an API error. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details []any `json:"details,omitempty"` +} + +// Meta contains response metadata. +type Meta struct { + RequestID string `json:"request_id,omitempty"` + Timestamp string `json:"timestamp"` +} + +// newMeta creates a Meta with current timestamp and request ID from context. +func newMeta(r *http.Request) Meta { + return Meta{ + RequestID: middleware.GetReqID(r.Context()), + Timestamp: time.Now().UTC().Format(time.RFC3339), + } +} + +// WriteJSON writes a JSON response with the given status code. +func WriteJSON(w http.ResponseWriter, r *http.Request, status int, data any) { + resp := Response{ + Data: data, + Meta: newMeta(r), + } + writeResponse(w, status, resp) +} + +// WriteSuccess writes a successful JSON response with status 200 OK. +func WriteSuccess(w http.ResponseWriter, r *http.Request, data any) { + WriteJSON(w, r, http.StatusOK, data) +} + +// WriteCreated writes a successful JSON response with status 201 Created. +func WriteCreated(w http.ResponseWriter, r *http.Request, data any) { + WriteJSON(w, r, http.StatusCreated, data) +} + +// WriteNoContent writes a successful response with status 204 No Content. +func WriteNoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +// WriteError writes an error response with the given status code. +func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details ...any) { + resp := Response{ + Error: &Error{ + Code: code, + Message: message, + Details: details, + }, + Meta: newMeta(r), + } + writeResponse(w, status, resp) +} + +// WriteBadRequest writes a 400 Bad Request error response. +func WriteBadRequest(w http.ResponseWriter, r *http.Request, message string, details ...any) { + WriteError(w, r, http.StatusBadRequest, "BAD_REQUEST", message, details...) +} + +// WriteNotFound writes a 404 Not Found error response. +func WriteNotFound(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusNotFound, "NOT_FOUND", message) +} + +// WriteInternalError writes a 500 Internal Server Error response. +func WriteInternalError(w http.ResponseWriter, r *http.Request, message string) { + WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", message) +} + +// writeResponse marshals and writes the response. +func writeResponse(w http.ResponseWriter, status int, resp Response) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } +}