# 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 ```go package main import ( "net/http" "git.threesix.ai/jordan/slate-v3-1770514618/pkg/app" "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httperror" "git.threesix.ai/jordan/slate-v3-1770514618/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 ```go 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:** ```go // 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:** ```go // 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:** ```go // 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. ```go // 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. ```go // 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. ```go // 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. ```go // 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. ```go // 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:** ```json { "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. ```go 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. ```go // 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. ```go 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/slate-v3-1770514618/pkg/` 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