feat: add Engine with Register, Handler, Serve, and graceful shutdown

Engine manages route groups and builds a Gin-based HTTP handler.
New() accepts functional options (WithAddr). Handler() builds a fresh
Gin engine with Recovery middleware and /health endpoint. Serve()
starts the server and performs graceful shutdown on context cancellation.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 15:46:11 +00:00
parent 6f5fb69944
commit db75c88d58
3 changed files with 327 additions and 0 deletions

110
api.go Normal file
View file

@ -0,0 +1,110 @@
// SPDX-License-Identifier: EUPL-1.2
// Package api provides a Gin-based REST framework with OpenAPI generation.
// Subsystems implement RouteGroup to register their own endpoints.
package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
const defaultAddr = ":8080"
// shutdownTimeout is the maximum duration to wait for in-flight requests
// to complete during graceful shutdown.
const shutdownTimeout = 10 * time.Second
// Engine is the central API server managing route groups and middleware.
type Engine struct {
addr string
groups []RouteGroup
}
// New creates an Engine with the given options.
// The default listen address is ":8080".
func New(opts ...Option) (*Engine, error) {
e := &Engine{
addr: defaultAddr,
}
for _, opt := range opts {
opt(e)
}
return e, nil
}
// Addr returns the configured listen address.
func (e *Engine) Addr() string {
return e.addr
}
// Groups returns all registered route groups.
func (e *Engine) Groups() []RouteGroup {
return e.groups
}
// Register adds a route group to the engine.
func (e *Engine) Register(group RouteGroup) {
e.groups = append(e.groups, group)
}
// Handler builds the Gin engine and returns it as an http.Handler.
// Each call produces a fresh handler reflecting the current set of groups.
func (e *Engine) Handler() http.Handler {
return e.build()
}
// Serve starts the HTTP server and blocks until the context is cancelled,
// then performs a graceful shutdown allowing in-flight requests to complete.
func (e *Engine) Serve(ctx context.Context) error {
srv := &http.Server{
Addr: e.addr,
Handler: e.build(),
}
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
// Block until context is cancelled.
<-ctx.Done()
// Graceful shutdown with timeout.
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
// Return any listen error that occurred before shutdown.
return <-errCh
}
// build creates a configured Gin engine with recovery middleware,
// the health endpoint, and all registered route groups.
func (e *Engine) build() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// Built-in health check.
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, OK("healthy"))
})
// Mount each registered group at its base path.
for _, g := range e.groups {
rg := r.Group(g.BasePath())
g.RegisterRoutes(rg)
}
return r
}

204
api_test.go Normal file
View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// ── Test helpers ────────────────────────────────────────────────────────
// healthGroup is a minimal RouteGroup for testing Engine integration.
type healthGroup struct{}
func (h *healthGroup) Name() string { return "health-extra" }
func (h *healthGroup) BasePath() string { return "/v1" }
func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/echo", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("echo"))
})
}
// ── New ─────────────────────────────────────────────────────────────────
func TestNew_Good(t *testing.T) {
e, err := api.New()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if e == nil {
t.Fatal("expected non-nil Engine")
}
}
func TestNew_Good_WithAddr(t *testing.T) {
e, err := api.New(api.WithAddr(":9090"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if e.Addr() != ":9090" {
t.Fatalf("expected addr=%q, got %q", ":9090", e.Addr())
}
}
// ── Default address ─────────────────────────────────────────────────────
func TestAddr_Good_Default(t *testing.T) {
e, _ := api.New()
if e.Addr() != ":8080" {
t.Fatalf("expected default addr=%q, got %q", ":8080", e.Addr())
}
}
// ── Register + Groups ───────────────────────────────────────────────────
func TestRegister_Good(t *testing.T) {
e, _ := api.New()
e.Register(&healthGroup{})
groups := e.Groups()
if len(groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(groups))
}
if groups[0].Name() != "health-extra" {
t.Fatalf("expected group name=%q, got %q", "health-extra", groups[0].Name())
}
}
func TestRegister_Good_MultipleGroups(t *testing.T) {
e, _ := api.New()
e.Register(&healthGroup{})
e.Register(&stubGroup{})
if len(e.Groups()) != 2 {
t.Fatalf("expected 2 groups, got %d", len(e.Groups()))
}
}
// ── Handler ─────────────────────────────────────────────────────────────
func TestHandler_Good_HealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !resp.Success {
t.Fatal("expected Success=true")
}
if resp.Data != "healthy" {
t.Fatalf("expected Data=%q, got %q", "healthy", resp.Data)
}
}
func TestHandler_Good_RegisteredRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
e.Register(&healthGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/echo", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data != "echo" {
t.Fatalf("expected Data=%q, got %q", "echo", resp.Data)
}
}
func TestHandler_Bad_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New()
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ── Serve + graceful shutdown ───────────────────────────────────────────
func TestServe_Good_GracefulShutdown(t *testing.T) {
// Pick a random free port to avoid conflicts.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to find free port: %v", err)
}
addr := ln.Addr().String()
ln.Close()
e, _ := api.New(api.WithAddr(addr))
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- e.Serve(ctx)
}()
// Wait for server to be ready.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
conn.Close()
break
}
time.Sleep(50 * time.Millisecond)
}
// Verify the server responds.
resp, err := http.Get("http://" + addr + "/health")
if err != nil {
t.Fatalf("health request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
// Cancel context to trigger graceful shutdown.
cancel()
select {
case serveErr := <-errCh:
if serveErr != nil {
t.Fatalf("Serve returned unexpected error: %v", serveErr)
}
case <-time.After(5 * time.Second):
t.Fatal("Serve did not return within 5 seconds after context cancellation")
}
}

13
options.go Normal file
View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: EUPL-1.2
package api
// Option configures an Engine during construction.
type Option func(*Engine)
// WithAddr sets the listen address for the server.
func WithAddr(addr string) Option {
return func(e *Engine) {
e.addr = addr
}
}