sp4-debug-1770477266/pkg/README.md
jordan f5a6466c65
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-07 15:14:26 +00:00

11 KiB

Shared Packages

This directory contains shared Go packages used across all components in the monorepo.

Package Overview

Package Description
app Service bootstrapper with Wrap pattern, Bind helpers, health probes
config Viper-based configuration loading from environment variables
httpcontext Type-safe context key helpers for request-scoped data
httpclient Resilient HTTP client with automatic retries and exponential backoff
httperror Typed HTTP errors with sentinel error matching
httpresponse Standard response envelope pattern for API responses
httpvalidation Struct validation wrapper around go-playground/validator
logging slog-based structured logging with context integration
middleware HTTP middleware: CORS, recovery, request ID, request logging

Quick Start

Creating a New Service

package main

import (
    "net/http"

    "git.threesix.ai/jordan/sp4-debug-1770477266/pkg/app"
    "git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httperror"
    "git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpresponse"
)

func main() {
    // Create application with default middleware and health endpoints
    svc := app.New("my-service", app.WithDefaultPort(8080))

    // Register routes using Wrap pattern for error-returning handlers
    svc.GET("/hello", app.Wrap(getHello))
    svc.POST("/users", app.Wrap(createUser))

    // Start server (blocks until shutdown signal)
    svc.Run()
}

// HandlerFunc returns error - Wrap converts it to http.HandlerFunc
func getHello(w http.ResponseWriter, r *http.Request) error {
    httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"})
    return nil
}

func createUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    // BindAndValidate decodes JSON and validates in one call
    if err := app.BindAndValidate(r, &req); err != nil {
        return err // HTTPError is returned to client
    }

    user, err := createUserInDB(req)
    if err != nil {
        // Domain errors map to HTTP errors
        return httperror.Conflict("user already exists")
    }

    httpresponse.Created(w, r, user)
    return nil
}

Package Documentation

pkg/app

Service bootstrapper that provides:

  • Chi router with standard middleware
  • Wrap pattern for error-returning handlers
  • Bind helpers for request parsing and validation
  • Health probes with concurrent dependency checks
  • Graceful shutdown handling
app := app.New("my-service",
    app.WithDefaultPort(8080),
    app.WithLogger(customLogger),
)

// Register routes using Wrap pattern
app.GET("/users/{id}", app.Wrap(getUser))
app.POST("/users", app.Wrap(createUser))

// Group routes
app.Route("/api/v1", func(r chi.Router) {
    r.Get("/users", app.Wrap(listUsers))
})

// Register shutdown hooks
app.OnShutdown(func(ctx context.Context) error {
    return db.Close()
})

app.Run()

Wrap Pattern:

// HandlerFunc returns error - Wrap converts to http.HandlerFunc
func getUser(w http.ResponseWriter, r *http.Request) error {
    user, err := userSvc.Get(ctx, id)
    if err != nil {
        return httperror.NotFoundf("user %s not found", id)
    }
    httpresponse.OK(w, r, user)
    return nil
}

Bind Helpers:

// Bind - decode JSON only
if err := app.Bind(r, &req); err != nil {
    return err
}

// BindAndValidate - decode + validate with struct tags
if err := app.BindAndValidate(r, &req); err != nil {
    return err // Returns validation error with field details
}

Health Probes:

// Custom health handler with dependency checks
healthHandler := app.NewHealthHandler(app.HealthConfig{
    Service: "my-service",
    Timeout: 5 * time.Second,
    Checks: map[string]app.HealthChecker{
        "database": app.PingChecker(db.PingContext),
        "redis":    app.PingChecker(redis.Ping),
    },
})
r.Get("/health", healthHandler)

pkg/config

Configuration loading from environment variables with Viper.

// Initialize configuration (once at startup)
config.MustInit(config.Options{
    AppName:     "my-service",
    DefaultPort: 8080,
})

// Read typed configuration
appCfg := config.ReadAppConfig()       // APP_NAME, APP_ENVIRONMENT, APP_DEBUG
serverCfg := config.ReadServerConfig() // SERVER_HOST, SERVER_PORT, timeouts
dbCfg := config.ReadDatabaseConfig()   // DATABASE_URL, pool settings

// Direct access
dbURL := config.GetString("DATABASE_URL")
debug := config.GetBool("APP_DEBUG")

Environment Variables:

  • APP_NAME - Application name (default: service name)
  • APP_ENVIRONMENT - development, staging, production
  • APP_DEBUG - Enable debug mode
  • SERVER_HOST - Server bind host (default: 0.0.0.0)
  • SERVER_PORT - Server port (default: 8080)
  • DATABASE_URL - Database connection string
  • LOG_LEVEL - debug, info, warn, error
  • LOG_FORMAT - json, text, auto

pkg/httpcontext

Type-safe context key helpers.

// Set values in middleware
ctx := httpcontext.SetRequestID(r.Context(), requestID)
ctx = httpcontext.SetUser(ctx, user)
ctx = httpcontext.SetOrgID(ctx, orgID)

// Get values in handlers
requestID, ok := httpcontext.GetRequestID(ctx)
user, ok := httpcontext.GetUser(ctx)
orgID, ok := httpcontext.GetOrgID(ctx)

// Panic if not found (use when middleware guarantees presence)
user := httpcontext.MustGetUser(ctx)

pkg/httpclient

HTTP client with automatic retries.

// Create client
client := httpclient.New(httpclient.Config{
    Timeout:    10 * time.Second,
    MaxRetries: 3,
})

// Make requests
resp, err := client.Do(req)

// Convenience methods
resp, err := httpclient.Get(ctx, "https://api.example.com/users")
resp, err := httpclient.JSONPost(ctx, url, bytes.NewReader(jsonData))

Retries on:

  • HTTP 5xx server errors
  • HTTP 429 Too Many Requests
  • Connection errors (timeout, refused)

Does NOT retry on:

  • HTTP 4xx client errors (except 429)
  • Context cancellation

pkg/httperror

Typed HTTP errors with sentinel error matching for idiomatic Go error handling.

// Factory functions create typed errors
err := httperror.NotFound("user not found")
err := httperror.NotFoundf("user %s not found", id)
err := httperror.BadRequest("invalid input")
err := httperror.Unauthorized("authentication required")
err := httperror.Forbidden("access denied")
err := httperror.Conflict("resource already exists")
err := httperror.Internal("something went wrong")
err := httperror.Validation("validation failed")

// Check error types with errors.Is()
if errors.Is(err, httperror.ErrNotFound) {
    // handle not found
}
if errors.Is(err, httperror.ErrUnauthorized) {
    // handle unauthorized
}

// Add details to errors (field-level validation info)
err := httperror.WithDetails(httperror.Validation("validation failed"), []ValidationDetail{
    {Field: "email", Message: "is required"},
    {Field: "name", Message: "must be at least 2 characters"},
})

// Custom error codes for domain-specific errors
err := httperror.WithCode(httperror.Forbidden("access denied"), "KEY_REVOKED")

// Wrap underlying errors
err := httperror.WrapError(httperror.ErrInternal, dbError)
if underlyingErr := errors.Unwrap(err); underlyingErr != nil {
    // access original error
}

// Extract HTTP info from errors
status := httperror.StatusCode(err)  // e.g., 404
httpErr := httperror.AsHTTPError(err) // type assertion

Sentinel Errors:

  • ErrBadRequest - 400 Bad Request
  • ErrUnauthorized - 401 Unauthorized
  • ErrForbidden - 403 Forbidden
  • ErrNotFound - 404 Not Found
  • ErrConflict - 409 Conflict
  • ErrInternal - 500 Internal Server Error
  • ErrValidation - 400 Validation Error

pkg/httpresponse

Standard response envelope for API responses.

// Success responses
httpresponse.OK(w, r, data)       // 200 OK
httpresponse.Created(w, r, data)  // 201 Created
httpresponse.NoContent(w)         // 204 No Content

// Error responses
httpresponse.BadRequest(w, r, "invalid input")
httpresponse.Unauthorized(w, r, "authentication required")
httpresponse.Forbidden(w, r, "insufficient permissions")
httpresponse.NotFound(w, r, "user not found")
httpresponse.InternalError(w, r, "something went wrong")

// Validation errors with details
httpresponse.ValidationError(w, r, "validation failed", details)

// Decode request body
var req CreateUserRequest
if err := httpresponse.DecodeJSON(r, &req); err != nil {
    httpresponse.BadRequest(w, r, "invalid JSON")
    return
}

Response Format:

{
    "data": { ... },
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "validation failed",
        "details": [ ... ]
    },
    "meta": {
        "request_id": "abc-123",
        "timestamp": "2024-01-15T10:30:00Z"
    }
}

pkg/httpvalidation

Struct validation using go-playground/validator.

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Name  string `json:"name" validate:"required,min=2,max=100"`
    Phone string `json:"phone" validate:"omitempty,phone"`
}

// Validate struct
if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
    httpresponse.ValidationError(w, r, "validation failed", details)
    return
}

// Custom validators available:
// - uuid: Valid UUID
// - uuid_or_empty: Valid UUID or empty string
// - phone: E.164 phone number format
// - slug: URL-safe slug (lowercase, numbers, hyphens)
// - hex_color: Hex color code (#RGB, #RRGGBB, #RRGGBBAA)

pkg/logging

Structured logging with slog.

// Create logger
logger := logging.New(logging.Config{
    Level:       logging.LevelInfo,
    Format:      logging.FormatJSON,
    Environment: "production",
})

// Or use convenience constructors
logger := logging.NewDevelopment() // text format, debug level
logger := logging.NewProduction()  // JSON format, info level

// Log messages
logger.Info("user created", "user_id", userID)
logger.Error("failed to connect", "error", err)

// Create derived loggers
reqLogger := logger.With("request_id", requestID)
svcLogger := logger.WithService("user-service")

// Get logger from context (set by middleware)
logger := logging.FromContext(r.Context())

pkg/middleware

HTTP middleware for chi router.

r := chi.NewRouter()

// Request ID generation/propagation
r.Use(middleware.RequestID())

// Request logging
r.Use(middleware.RequestLogger(logger))

// Panic recovery
r.Use(middleware.Recoverer(logger))

// CORS
r.Use(middleware.CORS(middleware.DefaultCORSConfig()))

// Production CORS
r.Use(middleware.CORS(middleware.CORSConfig{
    AllowedOrigins:   []string{"https://app.example.com"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
    AllowCredentials: true,
}))

Guidelines

  • Import Path: Use git.threesix.ai/jordan/sp4-debug-1770477266/pkg/<package> for imports
  • Keep packages focused: Each package should do one thing well
  • No circular dependencies: pkg packages should not import from services/workers
  • Document public APIs: All exported functions should have doc comments
  • Write tests: Cover exported functions with unit tests