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:
parent
8ce28fc40c
commit
4a042a8b71
313
cmd/rdev-api/main.go
Normal file
313
cmd/rdev-api/main.go
Normal 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
10
go.mod
Normal 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
14
go.sum
Normal 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=
|
||||
192
internal/handlers/projects.go
Normal file
192
internal/handlers/projects.go
Normal 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
221
pkg/api/app.go
Normal 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
171
pkg/api/openapi.go
Normal 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
99
pkg/api/response.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user