600 lines
16 KiB
Markdown
600 lines
16 KiB
Markdown
# 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 |
|
|
| `gemini` | Google Gemini API client (text + image generation) |
|
|
| `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 |
|
|
| `laozhang` | LaoZhang API client (text + image generation, pay-per-use) |
|
|
| `logging` | slog-based structured logging with context integration |
|
|
| `mediagen` | Unified media generation with provider fallback routing |
|
|
| `middleware` | HTTP middleware: CORS, recovery, request ID, request logging |
|
|
| `routing` | Provider fallback, circuit breaker, cooldown management |
|
|
| `synap` | Synap cognitive memory database client |
|
|
| `textgen` | Unified text generation with provider fallback routing |
|
|
|
|
## Quick Start
|
|
|
|
### Creating a New Service
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/app"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/httperror"
|
|
"git.threesix.ai/jordan/persona-community-5/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,
|
|
}))
|
|
```
|
|
|
|
## AI Generation Packages
|
|
|
|
The following packages provide unified AI generation capabilities with automatic provider fallback.
|
|
|
|
### pkg/routing
|
|
|
|
Core fallback and cooldown logic used by mediagen and textgen.
|
|
|
|
```go
|
|
import "git.threesix.ai/jordan/persona-community-5/pkg/routing"
|
|
|
|
// Strategies
|
|
routing.StrategyPrimaryOnly // Only try first provider
|
|
routing.StrategyFallback // Try providers in order until success
|
|
routing.StrategyRoundRobin // Rotate between providers
|
|
|
|
// Execute with fallback
|
|
result, err := routing.Execute(ctx, providers, config, func(ctx context.Context, p routing.Provider) (*Response, error) {
|
|
return p.Generate(ctx, req)
|
|
})
|
|
|
|
// The LAST provider is the "terminus" and is ALWAYS tried regardless of cooldown
|
|
```
|
|
|
|
### pkg/gemini
|
|
|
|
Google Gemini API client for text and image generation.
|
|
|
|
```go
|
|
import "git.threesix.ai/jordan/persona-community-5/pkg/gemini"
|
|
|
|
// Create client
|
|
client, err := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
|
|
|
|
// Text generation
|
|
resp, err := client.Chat(ctx, []gemini.Message{
|
|
{Role: "user", Content: "Hello!"},
|
|
})
|
|
|
|
// Image generation (Imagen)
|
|
images, err := client.GenerateImages(ctx, gemini.ImageRequest{
|
|
Prompt: "a sunset over mountains",
|
|
Count: 1,
|
|
})
|
|
```
|
|
|
|
### pkg/laozhang
|
|
|
|
LaoZhang API client (pay-per-use, reliable terminus provider).
|
|
|
|
```go
|
|
import "git.threesix.ai/jordan/persona-community-5/pkg/laozhang"
|
|
|
|
// Create client
|
|
client := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
|
|
|
|
// Text generation
|
|
resp, err := client.ChatCompletion(ctx, laozhang.ChatCompletionRequest{
|
|
Model: "gemini-3-flash-preview",
|
|
Messages: []laozhang.ChatMessage{
|
|
{Role: "user", Content: "Hello!"},
|
|
},
|
|
})
|
|
|
|
// Image generation
|
|
images, err := client.GenerateImage(ctx, laozhang.ImageRequest{
|
|
Prompt: "a sunset over mountains",
|
|
})
|
|
```
|
|
|
|
### pkg/mediagen
|
|
|
|
Unified media generation manager with provider routing.
|
|
|
|
```go
|
|
import (
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/mediagen"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/mediagen/adapters"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/gemini"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/laozhang"
|
|
)
|
|
|
|
// Create providers
|
|
geminiClient, _ := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
|
|
laozhangClient := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
|
|
|
|
// Create manager with production config
|
|
// Ordering: LaoZhang (primary) -> Gemini (terminus)
|
|
cfg := mediagen.ProductionConfig(mediagen.ProviderSet{
|
|
LaoZhang: adapters.NewLaoZhangProvider(laozhangClient),
|
|
Gemini: adapters.NewGeminiProvider(geminiClient),
|
|
})
|
|
|
|
manager, err := mediagen.NewManager(cfg)
|
|
|
|
// Generate image (auto-fallback between providers)
|
|
resp, err := manager.GenerateImage(ctx, mediagen.ImageRequest{
|
|
Prompt: "a sunset over mountains",
|
|
})
|
|
```
|
|
|
|
### pkg/textgen
|
|
|
|
Unified text generation manager with provider routing.
|
|
|
|
```go
|
|
import (
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/textgen"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/textgen/adapters"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/gemini"
|
|
"git.threesix.ai/jordan/persona-community-5/pkg/laozhang"
|
|
)
|
|
|
|
// Create providers
|
|
geminiClient, _ := gemini.NewClient(ctx, os.Getenv("GEMINI_API_KEY"))
|
|
laozhangClient := laozhang.NewClient(os.Getenv("LAOZHANG_API_KEY"))
|
|
|
|
// Create manager with production config
|
|
// Ordering: LaoZhang (primary) -> Gemini (terminus)
|
|
cfg := textgen.ProductionConfig(textgen.ProviderSet{
|
|
LaoZhang: adapters.NewLaoZhangTextProvider(laozhangClient, ""),
|
|
Gemini: adapters.NewGeminiTextProvider(ctx, adapters.GeminiTextConfig{Client: geminiClient}),
|
|
})
|
|
|
|
manager, err := textgen.NewManager(cfg)
|
|
|
|
// Generate text (auto-fallback between providers)
|
|
resp, err := manager.GenerateText(ctx, textgen.TextRequest{
|
|
SystemPrompt: "You are a helpful assistant.",
|
|
Prompt: "What is the capital of France?",
|
|
})
|
|
```
|
|
|
|
### pkg/synap
|
|
|
|
Client for Synap cognitive memory database.
|
|
|
|
```go
|
|
import "git.threesix.ai/jordan/persona-community-5/pkg/synap"
|
|
|
|
// Create client
|
|
client := synap.NewClient(synap.Config{
|
|
BaseURL: os.Getenv("SYNAP_URL"),
|
|
APIKey: os.Getenv("SYNAP_API_KEY"),
|
|
Space: "conversation_" + userID,
|
|
})
|
|
|
|
// Store episode (memory)
|
|
err := client.StoreEpisode(ctx, synap.Episode{
|
|
What: "User asked about capital of France",
|
|
When: time.Now(),
|
|
Where: "chat",
|
|
Who: "user123",
|
|
Why: "geography question",
|
|
How: "direct question",
|
|
})
|
|
|
|
// Recall memories
|
|
memories, err := client.Recall(ctx, synap.RecallRequest{
|
|
Query: "France geography",
|
|
Limit: 10,
|
|
})
|
|
|
|
// Build chat context with memories
|
|
context, err := client.BuildChatContext(ctx, synap.ChatContextRequest{
|
|
RecentMessages: recentMessages,
|
|
Query: currentQuery,
|
|
MemoryLimit: 5,
|
|
})
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
The AI packages require these environment variables:
|
|
|
|
```bash
|
|
# Gemini
|
|
GEMINI_API_KEY=xxx
|
|
|
|
# LaoZhang
|
|
LAOZHANG_API_KEY=xxx
|
|
LAOZHANG_BASE_URL=https://api.laozhang.ai # Optional, has default
|
|
|
|
# Synap (memory)
|
|
SYNAP_URL=http://synap.synap.svc.cluster.local:7432
|
|
SYNAP_API_KEY=xxx
|
|
SYNAP_DEFAULT_SPACE=conversation_{uuid} # Optional
|
|
```
|
|
|
|
## Guidelines
|
|
|
|
- **Import Path**: Use `git.threesix.ai/jordan/persona-community-5/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
|