feat(backend): establish gin sqlite service skeleton
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DatabasePinger interface {
|
||||
PingContext(context.Context) error
|
||||
}
|
||||
|
||||
type EventLogger func(string)
|
||||
|
||||
func NewRouter(
|
||||
database DatabasePinger,
|
||||
logEvent EventLogger,
|
||||
) (http.Handler, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database pinger is required")
|
||||
}
|
||||
if logEvent == nil {
|
||||
return nil, errors.New("event logger is required")
|
||||
}
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.New()
|
||||
router.Use(requestIDMiddleware())
|
||||
router.Use(safeRecovery(logEvent))
|
||||
router.HandleMethodNotAllowed = true
|
||||
if err := router.SetTrustedProxies(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router.GET("/healthz", healthHandler(database))
|
||||
router.NoRoute(func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusNotFound, errorResponse(
|
||||
ctx,
|
||||
"NOT_FOUND",
|
||||
"resource not found",
|
||||
))
|
||||
})
|
||||
router.NoMethod(func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusMethodNotAllowed, errorResponse(
|
||||
ctx,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
))
|
||||
})
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func safeRecovery(logEvent EventLogger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
logEvent("HTTP handler panic recovered")
|
||||
ctx.AbortWithStatusJSON(
|
||||
http.StatusInternalServerError,
|
||||
errorResponse(
|
||||
ctx,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
requestID := newRequestID()
|
||||
ctx.Set(requestIDContextKey, requestID)
|
||||
ctx.Header(requestIDHeader, requestID)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func healthHandler(database DatabasePinger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
pingContext, cancel := context.WithTimeout(ctx.Request.Context(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if err := database.PingContext(pingContext); err != nil {
|
||||
ctx.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func errorResponse(ctx *gin.Context, code, message string) gin.H {
|
||||
requestID, _ := ctx.Get(requestIDContextKey)
|
||||
return gin.H{
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"retryable": false,
|
||||
"details": gin.H{},
|
||||
},
|
||||
"request_id": requestID,
|
||||
}
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
now := uint64(time.Now().UnixNano())
|
||||
binary.BigEndian.PutUint64(value[:8], now)
|
||||
binary.BigEndian.PutUint64(value[8:], fallbackRequestID.Add(1))
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
value[0:4],
|
||||
value[4:6],
|
||||
value[6:8],
|
||||
value[8:10],
|
||||
value[10:16],
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
requestIDContextKey = "request_id"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
)
|
||||
|
||||
var fallbackRequestID atomic.Uint64
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakePinger struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (p fakePinger) PingContext(context.Context) error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestHealthzReturnsStableHealthyResponse(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
response := performRequest(t, router, http.MethodGet, "/healthz")
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
assertJSON(t, response, map[string]any{"status": "ok"})
|
||||
if response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control"))
|
||||
}
|
||||
if response.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("default CORS must remain disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{
|
||||
err: errors.New("private database path and driver details"),
|
||||
}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
response := performRequest(t, router, http.MethodGet, "/healthz")
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "private database") {
|
||||
t.Fatal("health response leaked the database error")
|
||||
}
|
||||
assertJSON(t, response, map[string]any{"status": "unavailable"})
|
||||
}
|
||||
|
||||
func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) {
|
||||
router, err := NewRouter(fakePinger{}, discardEvent)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
|
||||
notFound := performRequest(t, router, http.MethodGet, "/missing")
|
||||
if notFound.Code != http.StatusNotFound {
|
||||
t.Fatalf("not found status = %d", notFound.Code)
|
||||
}
|
||||
assertErrorCode(t, notFound, "NOT_FOUND")
|
||||
|
||||
notAllowed := performRequest(t, router, http.MethodPost, "/healthz")
|
||||
if notAllowed.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("not allowed status = %d", notAllowed.Code)
|
||||
}
|
||||
assertErrorCode(t, notAllowed, "METHOD_NOT_ALLOWED")
|
||||
}
|
||||
|
||||
func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T) {
|
||||
var events []string
|
||||
router, err := NewRouter(
|
||||
panicPinger{},
|
||||
func(event string) {
|
||||
events = append(events, event)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
request.Header.Set("Authorization", "Bearer private-token")
|
||||
request.Header.Set("Cookie", "session=private-cookie")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
assertErrorCode(t, response, "INTERNAL_ERROR")
|
||||
if len(events) != 1 || events[0] != "HTTP handler panic recovered" {
|
||||
t.Fatalf("events = %#v", events)
|
||||
}
|
||||
eventText := strings.Join(events, " ")
|
||||
if strings.Contains(eventText, "private") ||
|
||||
strings.Contains(eventText, "Bearer") ||
|
||||
strings.Contains(eventText, "session") {
|
||||
t.Fatalf("event log leaked request or panic content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterRequiresDependencies(t *testing.T) {
|
||||
if _, err := NewRouter(nil, discardEvent); err == nil {
|
||||
t.Fatal("NewRouter(nil database) error = nil")
|
||||
}
|
||||
if _, err := NewRouter(fakePinger{}, nil); err == nil {
|
||||
t.Fatal("NewRouter(nil logger) error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func performRequest(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
method string,
|
||||
path string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func assertJSON(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
want map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("JSON = %#v, want %#v", got, want)
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
if got[key] != wantValue {
|
||||
t.Fatalf("JSON[%q] = %#v, want %#v", key, got[key], wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertErrorCode(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
want string,
|
||||
) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Details map[string]any `json:"details"`
|
||||
} `json:"error"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error JSON: %v", err)
|
||||
}
|
||||
if body.Error.Code != want {
|
||||
t.Fatalf("error code = %q, want %q", body.Error.Code, want)
|
||||
}
|
||||
if body.Error.Retryable {
|
||||
t.Fatal("retryable = true")
|
||||
}
|
||||
if body.Error.Details == nil || len(body.Error.Details) != 0 {
|
||||
t.Fatalf("details = %#v", body.Error.Details)
|
||||
}
|
||||
if !requestIDPattern.MatchString(body.RequestID) {
|
||||
t.Fatalf("request_id = %q", body.RequestID)
|
||||
}
|
||||
if response.Header().Get(requestIDHeader) != body.RequestID {
|
||||
t.Fatalf("request ID header does not match body")
|
||||
}
|
||||
}
|
||||
|
||||
type panicPinger struct{}
|
||||
|
||||
func (panicPinger) PingContext(context.Context) error {
|
||||
panic("private failure detail")
|
||||
}
|
||||
|
||||
func discardEvent(string) {}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(
|
||||
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
)
|
||||
|
||||
func NewServer(cfg config.Config, handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/config"
|
||||
)
|
||||
|
||||
func TestNewServerAppliesAllSafetyLimits(t *testing.T) {
|
||||
cfg, err := config.Load(func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
server := NewServer(cfg, handler)
|
||||
|
||||
if server.Addr != cfg.HTTPAddress || server.Handler == nil {
|
||||
t.Fatalf("server = %+v", server)
|
||||
}
|
||||
if server.ReadHeaderTimeout != cfg.ReadHeaderTimeout ||
|
||||
server.ReadTimeout != cfg.ReadTimeout ||
|
||||
server.WriteTimeout != cfg.WriteTimeout ||
|
||||
server.IdleTimeout != cfg.IdleTimeout ||
|
||||
server.MaxHeaderBytes != cfg.MaxHeaderBytes {
|
||||
t.Fatal("server safety limits do not match config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSupportsBoundedGracefulShutdown(t *testing.T) {
|
||||
cfg, err := config.Load(func(string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
server := NewServer(
|
||||
cfg,
|
||||
http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen() error = %v", err)
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrors <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
client := http.Client{Timeout: time.Second}
|
||||
response, err := client.Get("http://" + listener.Addr().String())
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
t.Fatalf("GET server: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownContext); err != nil {
|
||||
t.Fatalf("Shutdown() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Fatalf("Serve() error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Serve() did not stop after Shutdown()")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user