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>
222 lines
5.2 KiB
Go
222 lines
5.2 KiB
Go
// 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)
|
|
}
|