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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-24 20:56:27 -07:00
parent 8ce28fc40c
commit 4a042a8b71
7 changed files with 1020 additions and 0 deletions

313
cmd/rdev-api/main.go Normal file
View File

@ -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,
},
},
},
},
}
}

10
go.mod Normal file
View File

@ -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

14
go.sum Normal file
View File

@ -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=

View File

@ -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)
}

221
pkg/api/app.go Normal file
View File

@ -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)
}

171
pkg/api/openapi.go Normal file
View File

@ -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"},
},
}
}

99
pkg/api/response.go Normal file
View File

@ -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
}
}