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:
parent
6f5fb69944
commit
db75c88d58
3 changed files with 327 additions and 0 deletions
110
api.go
Normal file
110
api.go
Normal 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
204
api_test.go
Normal 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
13
options.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue