composed5/pkg/README.md
jordan e57cfe1f57
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-01 19:58:14 +00:00

7.3 KiB

Shared Packages

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

Package Overview

Package Description
app Service bootstrapper with chi router, middleware, and graceful shutdown
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
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"

    "github.com/jordan/composed5/pkg/app"
    "github.com/jordan/composed5/pkg/httpresponse"
)

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

    // Register routes
    svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) {
        httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"})
    })

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

Package Documentation

pkg/app

Service bootstrapper that provides:

  • Chi router with standard middleware
  • Graceful shutdown handling
  • Health check endpoints (/health, /ready)
app := app.New("my-service",
    app.WithDefaultPort(8080),
    app.WithLogger(customLogger),
)

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

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

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

app.Run()

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/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 github.com/jordan/composed5/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