11 KiB
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/slack5-1770529463/pkg/app"
"git.threesix.ai/jordan/slack5-1770529463/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770529463/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, productionAPP_DEBUG- Enable debug modeSERVER_HOST- Server bind host (default: 0.0.0.0)SERVER_PORT- Server port (default: 8080)DATABASE_URL- Database connection stringLOG_LEVEL- debug, info, warn, errorLOG_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 RequestErrUnauthorized- 401 UnauthorizedErrForbidden- 403 ForbiddenErrNotFound- 404 Not FoundErrConflict- 409 ConflictErrInternal- 500 Internal Server ErrorErrValidation- 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/slack5-1770529463/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