feat(internal): add foundational packages — config, logger, errors
- config: env-based loading with startup validation, 13 tests - logger: structured slog wrapper with redaction, 10 tests - errors: unified AppError model with HTTP mapping, 18 tests - 41 tests total, build + vet clean
This commit is contained in:
1 parent
b69b74435a
commit
7943e290ec
11 files changed
+1590
No files matched your search
@@ -0,0 +1,5 @@
|
||||
module git.yeij.top/AskaEth/Cyrene_v2
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/joho/godotenv v1.5.1
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
Whitespace-only changes.
@@ -0,0 +1,311 @@
|
||||
// Package config provides configuration loading with environment variable binding and startup validation.
|
||||
// All configuration values are loaded from environment variables with sensible defaults.
|
||||
// Required fields that are missing will cause a fatal error at startup.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
// Fields are populated from environment variables during Load().
|
||||
type Config struct {
|
||||
// Environment
|
||||
Env string // "development" | "production" | "test"
|
||||
LogLevel string // "debug" | "info" | "warn" | "error"
|
||||
LogFmt string // "text" | "json"
|
||||
|
||||
// Server
|
||||
Port int
|
||||
Host string
|
||||
GinMode string // "debug" | "release" | "test"
|
||||
|
||||
// Database
|
||||
DatabaseURL string // Full connection string (takes priority)
|
||||
PostgresHost string
|
||||
PostgresPort int
|
||||
PostgresUser string
|
||||
PostgresPassword string
|
||||
PostgresDB string
|
||||
PostgresSSLMode string
|
||||
|
||||
// NATS
|
||||
NATSUrl string
|
||||
|
||||
// Redis
|
||||
RedisHost string
|
||||
RedisPort int
|
||||
|
||||
// LLM
|
||||
LLMBaseURL string
|
||||
LLMAPIKey string
|
||||
LLMModel string
|
||||
LLMFallbackModel string
|
||||
|
||||
// Embedding
|
||||
EmbeddingAPIURL string
|
||||
EmbeddingModel string
|
||||
EmbeddingAPIKey string
|
||||
|
||||
// Voice
|
||||
DashScopeAPIKey string
|
||||
TTSProvider string // "edge-tts" | "espeak"
|
||||
ASRProvider string // "dashscope" | "whisper-local"
|
||||
|
||||
// Auth
|
||||
JWTSecret string
|
||||
JWTExpiryHours int
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
RegistrationOpen bool
|
||||
|
||||
// Internal Service
|
||||
InternalServiceToken string
|
||||
|
||||
// CORS
|
||||
AllowedOrigins []string
|
||||
|
||||
// Background Thinking
|
||||
EnableBackgroundThinking bool
|
||||
ThinkIntervalSec int
|
||||
ThinkSilenceTimeoutSec int
|
||||
|
||||
// Platform Bridge
|
||||
PlatformBridgeEnabled bool
|
||||
PlatformChannels string
|
||||
|
||||
// IoT
|
||||
IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none"
|
||||
|
||||
// IoT Debug Service URL (legacy, kept for v1 compat)
|
||||
IoTServiceURL string
|
||||
|
||||
// RAG
|
||||
PersonaDir string
|
||||
KnowledgeDir string
|
||||
DataDir string
|
||||
|
||||
// Search
|
||||
SearXNGURL string
|
||||
|
||||
// Host Exec
|
||||
HostExecBackend string // "wsl" | "docker" | "direct" | ""
|
||||
|
||||
// Caddy (production)
|
||||
Domain string
|
||||
ACMEEmail string
|
||||
|
||||
// Ethend (deprecated, kept for compat)
|
||||
EthendPort int
|
||||
|
||||
// WebSocket
|
||||
WSMaxConnections int
|
||||
SessionIdleTimeoutMin int
|
||||
|
||||
// Timezone
|
||||
TZ string
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config populated with safe default values.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Env: "development",
|
||||
LogLevel: "info",
|
||||
LogFmt: "text",
|
||||
|
||||
Port: 8080,
|
||||
Host: "0.0.0.0",
|
||||
GinMode: "debug",
|
||||
|
||||
PostgresHost: "localhost",
|
||||
PostgresPort: 5432,
|
||||
PostgresUser: "cyrene",
|
||||
PostgresPassword: "cyrene_pass",
|
||||
PostgresDB: "cyrene_ai",
|
||||
PostgresSSLMode: "disable",
|
||||
|
||||
NATSUrl: "nats://localhost:4222",
|
||||
|
||||
RedisHost: "localhost",
|
||||
RedisPort: 6379,
|
||||
|
||||
LLMBaseURL: "https://api.openai.com/v1",
|
||||
LLMModel: "gpt-4o",
|
||||
LLMFallbackModel: "gpt-4o-mini",
|
||||
|
||||
EmbeddingModel: "text-embedding-3-small",
|
||||
|
||||
TTSProvider: "edge-tts",
|
||||
ASRProvider: "dashscope",
|
||||
|
||||
JWTExpiryHours: 720,
|
||||
AdminUsername: "admin",
|
||||
RegistrationOpen: true,
|
||||
|
||||
EnableBackgroundThinking: true,
|
||||
ThinkIntervalSec: 300,
|
||||
ThinkSilenceTimeoutSec: 120,
|
||||
|
||||
IoTProvider: "mock",
|
||||
|
||||
PersonaDir: "./internal/persona",
|
||||
KnowledgeDir: "./data/knowledge",
|
||||
DataDir: "./data",
|
||||
|
||||
HostExecBackend: "",
|
||||
|
||||
EthendPort: 9090,
|
||||
|
||||
WSMaxConnections: 1000,
|
||||
SessionIdleTimeoutMin: 30,
|
||||
|
||||
TZ: "Asia/Shanghai",
|
||||
}
|
||||
}
|
||||
|
||||
// DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set.
|
||||
func (c *Config) GetDatabaseURL() string {
|
||||
if c.DatabaseURL != "" {
|
||||
return c.DatabaseURL
|
||||
}
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode)
|
||||
}
|
||||
|
||||
// IsDevelopment returns true when running in development mode.
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
return c.Env == "development"
|
||||
}
|
||||
|
||||
// IsProduction returns true when running in production mode.
|
||||
func (c *Config) IsProduction() bool {
|
||||
return c.Env == "production"
|
||||
}
|
||||
|
||||
// validate runs all validation checks on the loaded config.
|
||||
// Returns a list of validation errors.
|
||||
func (c *Config) validate() []string {
|
||||
var errs []string
|
||||
|
||||
// Required: LLM API Key (only required in production or when not using mock)
|
||||
if c.LLMAPIKey == "" {
|
||||
errs = append(errs, "LLM_API_KEY is required")
|
||||
}
|
||||
|
||||
// Required: JWT Secret
|
||||
if c.JWTSecret == "" {
|
||||
errs = append(errs, "JWT_SECRET is required")
|
||||
}
|
||||
if len(c.JWTSecret) < 32 {
|
||||
errs = append(errs, "JWT_SECRET must be at least 32 characters")
|
||||
}
|
||||
|
||||
// Required: Admin Password
|
||||
if c.AdminPassword == "" {
|
||||
errs = append(errs, "ADMIN_PASSWORD is required")
|
||||
}
|
||||
if len(c.AdminPassword) < 8 {
|
||||
errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters")
|
||||
}
|
||||
|
||||
// Required: Internal Service Token
|
||||
if c.InternalServiceToken == "" && c.IsProduction() {
|
||||
errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production")
|
||||
}
|
||||
|
||||
// IoT Provider must be valid
|
||||
validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true}
|
||||
if !validIoTProviders[c.IoTProvider] {
|
||||
errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing.
|
||||
func (c *Config) ValidateOrFatal() {
|
||||
errs := c.validate()
|
||||
if len(errs) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n")
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", e)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "=======================================\n")
|
||||
fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions for loading from environment ---
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultVal bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvSlice(key string, defaultVal []string) []string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return defaultVal
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Port != 8080 {
|
||||
t.Errorf("expected Port=8080, got %d", cfg.Port)
|
||||
}
|
||||
if cfg.Env != "development" {
|
||||
t.Errorf("expected Env=development, got %s", cfg.Env)
|
||||
}
|
||||
if cfg.PostgresHost != "localhost" {
|
||||
t.Errorf("expected PostgresHost=localhost, got %s", cfg.PostgresHost)
|
||||
}
|
||||
if cfg.IoTProvider != "mock" {
|
||||
t.Errorf("expected IoTProvider=mock, got %s", cfg.IoTProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatabaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbURL string
|
||||
host string
|
||||
port int
|
||||
user string
|
||||
pass string
|
||||
db string
|
||||
sslMode string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "explicit DATABASE_URL takes priority",
|
||||
dbURL: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
|
||||
expected: "postgres://custom:pass@remote:9999/mydb?sslmode=require",
|
||||
},
|
||||
{
|
||||
name: "default connection string from individual fields",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
user: "cyrene",
|
||||
pass: "cyrene_pass",
|
||||
db: "cyrene_ai",
|
||||
sslMode: "disable",
|
||||
expected: "postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if tt.dbURL != "" {
|
||||
cfg.DatabaseURL = tt.dbURL
|
||||
}
|
||||
if tt.host != "" {
|
||||
cfg.PostgresHost = tt.host
|
||||
}
|
||||
if tt.port != 0 {
|
||||
cfg.PostgresPort = tt.port
|
||||
}
|
||||
if tt.user != "" {
|
||||
cfg.PostgresUser = tt.user
|
||||
}
|
||||
if tt.pass != "" {
|
||||
cfg.PostgresPassword = tt.pass
|
||||
}
|
||||
if tt.db != "" {
|
||||
cfg.PostgresDB = tt.db
|
||||
}
|
||||
if tt.sslMode != "" {
|
||||
cfg.PostgresSSLMode = tt.sslMode
|
||||
}
|
||||
|
||||
got := cfg.GetDatabaseURL()
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetDatabaseURL() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDevelopment(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Env = "development"
|
||||
if !cfg.IsDevelopment() {
|
||||
t.Error("expected IsDevelopment() to be true")
|
||||
}
|
||||
if cfg.IsProduction() {
|
||||
t.Error("expected IsProduction() to be false in dev mode")
|
||||
}
|
||||
|
||||
cfg.Env = "production"
|
||||
if cfg.IsDevelopment() {
|
||||
t.Error("expected IsDevelopment() to be false in prod mode")
|
||||
}
|
||||
if !cfg.IsProduction() {
|
||||
t.Error("expected IsProduction() to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingLLMAPIKey(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.LLMAPIKey = ""
|
||||
cfg.Env = "development"
|
||||
|
||||
// In dev mode, we validate but don't call ValidateOrFatal directly
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "LLM_API_KEY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected LLM_API_KEY validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingJWTSecret(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.JWTSecret = ""
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "JWT_SECRET") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected JWT_SECRET validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTSecretTooShort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.JWTSecret = "short"
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "JWT_SECRET must be at least 32 characters") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected JWT_SECRET length validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingAdminPassword(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AdminPassword = ""
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "ADMIN_PASSWORD") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected ADMIN_PASSWORD validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAdminPasswordTooShort(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.AdminPassword = "1234567" // 7 chars, min is 8
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "ADMIN_PASSWORD must be at least 8 characters") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected ADMIN_PASSWORD length validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInvalidIoTProvider(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.IoTProvider = "invalid-provider"
|
||||
errs := cfg.validate()
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if contains(e, "IOT_PROVIDER") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected IOT_PROVIDER validation error, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
// Set some test env vars
|
||||
os.Setenv("PORT", "9999")
|
||||
os.Setenv("ENV", "test")
|
||||
os.Setenv("LLM_API_KEY", "sk-test-key")
|
||||
os.Setenv("JWT_SECRET", "a-very-long-secret-that-is-at-least-32-chars!!")
|
||||
os.Setenv("ADMIN_PASSWORD", "test-admin-password")
|
||||
defer func() {
|
||||
os.Unsetenv("PORT")
|
||||
os.Unsetenv("ENV")
|
||||
os.Unsetenv("LLM_API_KEY")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
os.Unsetenv("ADMIN_PASSWORD")
|
||||
}()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != 9999 {
|
||||
t.Errorf("expected Port=9999 from env, got %d", cfg.Port)
|
||||
}
|
||||
if cfg.Env != "test" {
|
||||
t.Errorf("expected Env=test from env, got %s", cfg.Env)
|
||||
}
|
||||
if cfg.LLMAPIKey != "sk-test-key" {
|
||||
t.Errorf("expected LLMAPIKey from env, got %s", cfg.LLMAPIKey)
|
||||
}
|
||||
|
||||
// Validation should pass with these values
|
||||
errs := cfg.validate()
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("validation should pass but got errors: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBool(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true},
|
||||
{"0", false}, {"false", false}, {"FALSE", false}, {"no", false}, {"off", false},
|
||||
{"", true}, // empty should use default=true
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
os.Setenv("TEST_BOOL_VAR", tt.input)
|
||||
got := getEnvBool("TEST_BOOL_VAR", true)
|
||||
if got != tt.expected {
|
||||
t.Errorf("getEnvBool(%q, true) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
os.Unsetenv("TEST_BOOL_VAR")
|
||||
}
|
||||
|
||||
func TestGetEnvSlice(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"", []string{"default"}},
|
||||
{"a,b,c", []string{"a", "b", "c"}},
|
||||
{" a , b , c ", []string{"a", "b", "c"}}, // trim spaces
|
||||
{"single", []string{"single"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
os.Setenv("TEST_SLICE_VAR", tt.input)
|
||||
got := getEnvSlice("TEST_SLICE_VAR", []string{"default"})
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("getEnvSlice(%q) length = %d, want %d", tt.input, len(got), len(tt.expected))
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expected[i] {
|
||||
t.Errorf("getEnvSlice(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Unsetenv("TEST_SLICE_VAR")
|
||||
}
|
||||
|
||||
func TestAllValidIoTProviders(t *testing.T) {
|
||||
validProviders := []string{"mock", "homeassistant", "mqtt", "none"}
|
||||
for _, provider := range validProviders {
|
||||
cfg := DefaultConfig()
|
||||
cfg.IoTProvider = provider
|
||||
errs := cfg.validate()
|
||||
for _, e := range errs {
|
||||
if contains(e, "IOT_PROVIDER") {
|
||||
t.Errorf("IoTProvider=%q should be valid but got error: %s", provider, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||
}
|
||||
|
||||
func searchSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Load reads configuration from environment variables, with optional .env file support.
|
||||
// It returns a fully populated Config or an error if the .env file failed to load.
|
||||
// Use ValidateOrFatal() after loading to enforce required fields.
|
||||
func Load() (*Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Attempt to load .env file from multiple locations
|
||||
_ = loadEnvFile()
|
||||
|
||||
// Merge environment variables over defaults
|
||||
cfg.mergeEnv()
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// loadEnvFile tries to load .env from the project root.
|
||||
// It does not return an error; missing .env is acceptable.
|
||||
func loadEnvFile() error {
|
||||
// Try current directory first
|
||||
if err := godotenv.Load(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try relative to executable
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(exeDir, ".env"),
|
||||
filepath.Join(exeDir, "..", ".env"),
|
||||
filepath.Join(exeDir, "..", "..", ".env"),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
_ = godotenv.Load(path)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf(".env not found")
|
||||
}
|
||||
|
||||
// mergeEnv reads all environment variables and overrides config defaults.
|
||||
func (c *Config) mergeEnv() {
|
||||
// Environment
|
||||
c.Env = getEnv("ENV", c.Env)
|
||||
c.LogLevel = getEnv("LOG_LEVEL", c.LogLevel)
|
||||
c.LogFmt = getEnv("LOG_FORMAT", c.LogFmt)
|
||||
|
||||
// Server
|
||||
c.Port = getEnvInt("PORT", c.Port)
|
||||
c.Host = getEnv("HOST", c.Host)
|
||||
c.GinMode = getEnv("GIN_MODE", c.GinMode)
|
||||
|
||||
// Database
|
||||
c.DatabaseURL = os.Getenv("DATABASE_URL")
|
||||
c.PostgresHost = getEnv("POSTGRES_HOST", c.PostgresHost)
|
||||
c.PostgresPort = getEnvInt("POSTGRES_PORT", c.PostgresPort)
|
||||
c.PostgresUser = getEnv("POSTGRES_USER", c.PostgresUser)
|
||||
c.PostgresPassword = getEnv("POSTGRES_PASSWORD", c.PostgresPassword)
|
||||
c.PostgresDB = getEnv("POSTGRES_DB", c.PostgresDB)
|
||||
c.PostgresSSLMode = getEnv("POSTGRES_SSLMODE", c.PostgresSSLMode)
|
||||
|
||||
// NATS
|
||||
c.NATSUrl = getEnv("NATS_URL", c.NATSUrl)
|
||||
|
||||
// Redis
|
||||
c.RedisHost = getEnv("REDIS_HOST", c.RedisHost)
|
||||
c.RedisPort = getEnvInt("REDIS_PORT", c.RedisPort)
|
||||
|
||||
// LLM
|
||||
c.LLMBaseURL = getEnv("LLM_API_URL", c.LLMBaseURL)
|
||||
c.LLMAPIKey = getEnv("LLM_API_KEY", c.LLMAPIKey)
|
||||
c.LLMModel = getEnv("LLM_MODEL", c.LLMModel)
|
||||
c.LLMFallbackModel = getEnv("LLM_FALLBACK_MODEL", c.LLMFallbackModel)
|
||||
|
||||
// Embedding
|
||||
c.EmbeddingAPIURL = getEnv("EMBEDDING_API_URL", c.EmbeddingAPIURL)
|
||||
c.EmbeddingModel = getEnv("EMBEDDING_MODEL", c.EmbeddingModel)
|
||||
c.EmbeddingAPIKey = getEnv("EMBEDDING_API_KEY", c.EmbeddingAPIKey)
|
||||
|
||||
// Voice
|
||||
c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey)
|
||||
c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider)
|
||||
c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider)
|
||||
|
||||
// Auth
|
||||
c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret)
|
||||
c.JWTExpiryHours = getEnvInt("JWT_EXPIRY_HOURS", c.JWTExpiryHours)
|
||||
c.AdminUsername = getEnv("ADMIN_USERNAME", c.AdminUsername)
|
||||
c.AdminPassword = getEnv("ADMIN_PASSWORD", c.AdminPassword)
|
||||
c.RegistrationOpen = getEnvBool("REGISTRATION_ENABLED", c.RegistrationOpen)
|
||||
|
||||
// Internal Service
|
||||
c.InternalServiceToken = getEnv("INTERNAL_SERVICE_TOKEN", c.InternalServiceToken)
|
||||
|
||||
// CORS
|
||||
c.AllowedOrigins = getEnvSlice("ALLOWED_ORIGINS", c.AllowedOrigins)
|
||||
|
||||
// Background Thinking
|
||||
c.EnableBackgroundThinking = getEnvBool("ENABLE_BACKGROUND_THINKING", c.EnableBackgroundThinking)
|
||||
c.ThinkIntervalSec = getEnvInt("THINK_INTERVAL_SEC", c.ThinkIntervalSec)
|
||||
c.ThinkSilenceTimeoutSec = getEnvInt("THINK_SILENCE_TIMEOUT_SEC", c.ThinkSilenceTimeoutSec)
|
||||
|
||||
// Platform Bridge
|
||||
c.PlatformBridgeEnabled = getEnvBool("PLATFORM_BRIDGE_ENABLED", c.PlatformBridgeEnabled)
|
||||
c.PlatformChannels = getEnv("PLATFORM_CHANNELS", c.PlatformChannels)
|
||||
|
||||
// IoT
|
||||
c.IoTProvider = getEnv("IOT_PROVIDER", c.IoTProvider)
|
||||
c.IoTServiceURL = getEnv("IOT_SERVICE_URL", c.IoTServiceURL)
|
||||
|
||||
// RAG & Data
|
||||
c.PersonaDir = getEnv("PERSONA_DIR", c.PersonaDir)
|
||||
c.KnowledgeDir = getEnv("KNOWLEDGE_DIR", c.KnowledgeDir)
|
||||
c.DataDir = getEnv("DATA_DIR", c.DataDir)
|
||||
|
||||
// Search
|
||||
c.SearXNGURL = getEnv("SEARXNG_URL", c.SearXNGURL)
|
||||
|
||||
// Host Exec
|
||||
c.HostExecBackend = getEnv("HOST_EXEC_BACKEND", c.HostExecBackend)
|
||||
|
||||
// Caddy (production)
|
||||
c.Domain = getEnv("DOMAIN", c.Domain)
|
||||
c.ACMEEmail = getEnv("ACME_EMAIL", c.ACMEEmail)
|
||||
|
||||
// Ethend (deprecated)
|
||||
c.EthendPort = getEnvInt("ETHEND_PORT", c.EthendPort)
|
||||
|
||||
// WebSocket
|
||||
c.WSMaxConnections = getEnvInt("WS_MAX_CONNECTIONS", c.WSMaxConnections)
|
||||
c.SessionIdleTimeoutMin = getEnvInt("SESSION_IDLE_TIMEOUT_MIN", c.SessionIdleTimeoutMin)
|
||||
|
||||
// Timezone
|
||||
c.TZ = getEnv("TZ", c.TZ)
|
||||
}
|
||||
|
||||
// logSanitized prints the loaded config, with sensitive fields redacted.
|
||||
func (c *Config) LogSanitized() {
|
||||
log.Printf("[config] ENV=%s LOG_LEVEL=%s LOG_FORMAT=%s", c.Env, c.LogLevel, c.LogFmt)
|
||||
log.Printf("[config] PORT=%d GIN_MODE=%s", c.Port, c.GinMode)
|
||||
log.Printf("[config] DB_HOST=%s DB_PORT=%d DB_USER=%s DB_NAME=%s",
|
||||
c.PostgresHost, c.PostgresPort, c.PostgresUser, c.PostgresDB)
|
||||
log.Printf("[config] NATS_URL=%s REDIS=%s:%d", c.NATSUrl, c.RedisHost, c.RedisPort)
|
||||
log.Printf("[config] LLM_URL=%s LLM_MODEL=%s FALLBACK=%s",
|
||||
c.LLMBaseURL, c.LLMModel, c.LLMFallbackModel)
|
||||
log.Printf("[config] LLM_API_KEY=**** (len=%d)", len(c.LLMAPIKey))
|
||||
if c.EmbeddingAPIURL != "" {
|
||||
log.Printf("[config] EMBEDDING_URL=%s MODEL=%s", c.EmbeddingAPIURL, c.EmbeddingModel)
|
||||
} else {
|
||||
log.Printf("[config] EMBEDDING=disabled (using n-gram fallback)")
|
||||
}
|
||||
log.Printf("[config] IOT_PROVIDER=%s", c.IoTProvider)
|
||||
log.Printf("[config] TTS=%s ASR=%s", c.TTSProvider, c.ASRProvider)
|
||||
log.Printf("[config] BACKGROUND_THINKING=%v INTERVAL=%ds SILENCE=%ds",
|
||||
c.EnableBackgroundThinking, c.ThinkIntervalSec, c.ThinkSilenceTimeoutSec)
|
||||
log.Printf("[config] JWT_SECRET=**** (len=%d)", len(c.JWTSecret))
|
||||
log.Printf("[config] ADMIN=%s REGISTRATION=%v", c.AdminUsername, c.RegistrationOpen)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package errors provides a unified error model for the Cyrene platform.
|
||||
// All errors flowing through the system should use AppError or its subtypes.
|
||||
// This ensures consistent error codes, user-friendly messages, and HTTP status mapping.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AppError is the unified application error type.
|
||||
type AppError struct {
|
||||
Code string // Machine-readable error code (e.g., "USER_NOT_FOUND")
|
||||
Message string // Human-readable message (safe to show to users)
|
||||
Cause error // Original error (internal only, not exposed to clients)
|
||||
HTTPCode int // HTTP status code
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *AppError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause error.
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// --- Pre-defined common errors ---
|
||||
|
||||
// NewNotFound creates a NOT_FOUND error.
|
||||
func NewNotFound(message string) *AppError {
|
||||
return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound}
|
||||
}
|
||||
|
||||
// NewUnauthorized creates an UNAUTHORIZED error.
|
||||
func NewUnauthorized(message string) *AppError {
|
||||
return &AppError{Code: "UNAUTHORIZED", Message: message, HTTPCode: http.StatusUnauthorized}
|
||||
}
|
||||
|
||||
// NewForbidden creates a FORBIDDEN error.
|
||||
func NewForbidden(message string) *AppError {
|
||||
return &AppError{Code: "FORBIDDEN", Message: message, HTTPCode: http.StatusForbidden}
|
||||
}
|
||||
|
||||
// NewBadRequest creates a BAD_REQUEST error.
|
||||
func NewBadRequest(message string) *AppError {
|
||||
return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
// NewConflict creates a CONFLICT error.
|
||||
func NewConflict(message string) *AppError {
|
||||
return &AppError{Code: "CONFLICT", Message: message, HTTPCode: http.StatusConflict}
|
||||
}
|
||||
|
||||
// NewTooManyRequests creates a TOO_MANY_REQUESTS error.
|
||||
func NewTooManyRequests(message string) *AppError {
|
||||
return &AppError{Code: "TOO_MANY_REQUESTS", Message: message, HTTPCode: http.StatusTooManyRequests}
|
||||
}
|
||||
|
||||
// NewInternal creates an INTERNAL error with a generic message for clients.
|
||||
// The cause is logged internally but not exposed.
|
||||
func NewInternal(message string, cause error) *AppError {
|
||||
return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: cause}
|
||||
}
|
||||
|
||||
// NewValidation creates a VALIDATION error.
|
||||
func NewValidation(message string) *AppError {
|
||||
return &AppError{Code: "VALIDATION", Message: message, HTTPCode: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
// --- Constructors with cause ---
|
||||
|
||||
// Wrap wraps an existing error with an application error.
|
||||
func Wrap(err error, code, message string, httpCode int) *AppError {
|
||||
return &AppError{Code: code, Message: message, HTTPCode: httpCode, Cause: err}
|
||||
}
|
||||
|
||||
// WrapNotFound wraps an error as NOT_FOUND.
|
||||
func WrapNotFound(err error, message string) *AppError {
|
||||
return &AppError{Code: "NOT_FOUND", Message: message, HTTPCode: http.StatusNotFound, Cause: err}
|
||||
}
|
||||
|
||||
// WrapBadRequest wraps an error as BAD_REQUEST.
|
||||
func WrapBadRequest(err error, message string) *AppError {
|
||||
return &AppError{Code: "BAD_REQUEST", Message: message, HTTPCode: http.StatusBadRequest, Cause: err}
|
||||
}
|
||||
|
||||
// WrapInternal wraps an error as INTERNAL.
|
||||
func WrapInternal(err error, message string) *AppError {
|
||||
return &AppError{Code: "INTERNAL", Message: message, HTTPCode: http.StatusInternalServerError, Cause: err}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// IsAppError checks if the error is an AppError.
|
||||
func IsAppError(err error) (*AppError, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
ae, ok := err.(*AppError)
|
||||
return ae, ok
|
||||
}
|
||||
|
||||
// StatusCode extracts the HTTP status code from an error.
|
||||
// Returns 500 if the error is not an AppError.
|
||||
func StatusCode(err error) int {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.HTTPCode
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// Message extracts the user-safe message from an error.
|
||||
// Returns a generic message if the error is not an AppError.
|
||||
func Message(err error) string {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Message
|
||||
}
|
||||
return "Internal server error"
|
||||
}
|
||||
|
||||
// Code extracts the machine-readable error code.
|
||||
// Returns "INTERNAL" if the error is not an AppError.
|
||||
func Code(err error) string {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Code
|
||||
}
|
||||
return "INTERNAL"
|
||||
}
|
||||
|
||||
// --- Sentinel errors for quick checks ---
|
||||
|
||||
var (
|
||||
// ErrUnauthorized is a pre-built unauthorized error.
|
||||
ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "Authentication required", HTTPCode: http.StatusUnauthorized}
|
||||
|
||||
// ErrForbidden is a pre-built forbidden error.
|
||||
ErrForbidden = &AppError{Code: "FORBIDDEN", Message: "Insufficient permissions", HTTPCode: http.StatusForbidden}
|
||||
|
||||
// ErrInternal is a pre-built internal error.
|
||||
ErrInternal = &AppError{Code: "INTERNAL", Message: "Internal server error", HTTPCode: http.StatusInternalServerError}
|
||||
)
|
||||
|
||||
// IsCode checks if the error has the given error code.
|
||||
func IsCode(err error, code string) bool {
|
||||
if ae, ok := IsAppError(err); ok {
|
||||
return ae.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a NOT_FOUND error.
|
||||
func IsNotFound(err error) bool {
|
||||
return IsCode(err, "NOT_FOUND")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewNotFound(t *testing.T) {
|
||||
err := NewNotFound("user not found")
|
||||
|
||||
if err.Code != "NOT_FOUND" {
|
||||
t.Errorf("expected code NOT_FOUND, got %s", err.Code)
|
||||
}
|
||||
if err.Message != "user not found" {
|
||||
t.Errorf("expected message 'user not found', got %s", err.Message)
|
||||
}
|
||||
if err.HTTPCode != http.StatusNotFound {
|
||||
t.Errorf("expected HTTP 404, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorized(t *testing.T) {
|
||||
err := NewUnauthorized("invalid token")
|
||||
if err.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("expected code UNAUTHORIZED, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected HTTP 401, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForbidden(t *testing.T) {
|
||||
err := NewForbidden("admin only")
|
||||
if err.HTTPCode != http.StatusForbidden {
|
||||
t.Errorf("expected HTTP 403, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBadRequest(t *testing.T) {
|
||||
err := NewBadRequest("invalid input")
|
||||
if err.HTTPCode != http.StatusBadRequest {
|
||||
t.Errorf("expected HTTP 400, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConflict(t *testing.T) {
|
||||
err := NewConflict("duplicate entry")
|
||||
if err.HTTPCode != http.StatusConflict {
|
||||
t.Errorf("expected HTTP 409, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTooManyRequests(t *testing.T) {
|
||||
err := NewTooManyRequests("rate limit")
|
||||
if err.HTTPCode != http.StatusTooManyRequests {
|
||||
t.Errorf("expected HTTP 429, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInternal(t *testing.T) {
|
||||
cause := fmt.Errorf("db connection refused")
|
||||
err := NewInternal("database error", cause)
|
||||
|
||||
if err.Code != "INTERNAL" {
|
||||
t.Errorf("expected code INTERNAL, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusInternalServerError {
|
||||
t.Errorf("expected HTTP 500, got %d", err.HTTPCode)
|
||||
}
|
||||
if err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
// Check that Error() includes the cause
|
||||
errStr := err.Error()
|
||||
if errStr[len(errStr)-1] == '.' {
|
||||
// just check it doesn't panic
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
err := NewValidation("field required: email")
|
||||
if err.Code != "VALIDATION" {
|
||||
t.Errorf("expected code VALIDATION, got %s", err.Code)
|
||||
}
|
||||
if err.HTTPCode != http.StatusBadRequest {
|
||||
t.Errorf("expected HTTP 400, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
cause := fmt.Errorf("raw error")
|
||||
err := Wrap(cause, "CUSTOM", "custom message", http.StatusTeapot)
|
||||
|
||||
if err.Code != "CUSTOM" {
|
||||
t.Errorf("expected code CUSTOM, got %s", err.Code)
|
||||
}
|
||||
if err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
if err.HTTPCode != http.StatusTeapot {
|
||||
t.Errorf("expected HTTP 418, got %d", err.HTTPCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapConvenienceFuncs(t *testing.T) {
|
||||
cause := fmt.Errorf("raw error")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AppError
|
||||
code string
|
||||
httpCode int
|
||||
}{
|
||||
{"WrapNotFound", WrapNotFound(cause, "not found"), "NOT_FOUND", 404},
|
||||
{"WrapBadRequest", WrapBadRequest(cause, "bad input"), "BAD_REQUEST", 400},
|
||||
{"WrapInternal", WrapInternal(cause, "oops"), "INTERNAL", 500},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Code != tt.code {
|
||||
t.Errorf("expected code %s, got %s", tt.code, tt.err.Code)
|
||||
}
|
||||
if tt.err.HTTPCode != tt.httpCode {
|
||||
t.Errorf("expected HTTP %d, got %d", tt.httpCode, tt.err.HTTPCode)
|
||||
}
|
||||
if tt.err.Cause != cause {
|
||||
t.Errorf("expected cause to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrap(t *testing.T) {
|
||||
cause := fmt.Errorf("original")
|
||||
err := WrapInternal(cause, "wrapped")
|
||||
unwrapped := err.Unwrap()
|
||||
if unwrapped != cause {
|
||||
t.Errorf("Unwrap() = %v, want %v", unwrapped, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAppError(t *testing.T) {
|
||||
ae := NewNotFound("test")
|
||||
_, ok := IsAppError(ae)
|
||||
if !ok {
|
||||
t.Error("expected AppError to be identified")
|
||||
}
|
||||
|
||||
plain := fmt.Errorf("plain error")
|
||||
_, ok = IsAppError(plain)
|
||||
if ok {
|
||||
t.Error("plain error should not be AppError")
|
||||
}
|
||||
|
||||
_, ok = IsAppError(nil)
|
||||
if ok {
|
||||
t.Error("nil should not be AppError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCode(t *testing.T) {
|
||||
if sc := StatusCode(NewNotFound("x")); sc != 404 {
|
||||
t.Errorf("expected 404, got %d", sc)
|
||||
}
|
||||
if sc := StatusCode(fmt.Errorf("plain")); sc != 500 {
|
||||
t.Errorf("expected 500 for plain error, got %d", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage(t *testing.T) {
|
||||
if msg := Message(NewNotFound("user x not found")); msg != "user x not found" {
|
||||
t.Errorf("expected message preserved, got %s", msg)
|
||||
}
|
||||
if msg := Message(fmt.Errorf("raw")); msg != "Internal server error" {
|
||||
t.Errorf("expected generic message for unknown error, got %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCode(t *testing.T) {
|
||||
if c := Code(NewForbidden("nope")); c != "FORBIDDEN" {
|
||||
t.Errorf("expected FORBIDDEN, got %s", c)
|
||||
}
|
||||
if c := Code(fmt.Errorf("raw")); c != "INTERNAL" {
|
||||
t.Errorf("expected INTERNAL for unknown error, got %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCode(t *testing.T) {
|
||||
err := NewNotFound("test")
|
||||
if !IsCode(err, "NOT_FOUND") {
|
||||
t.Error("expected IsCode true for NOT_FOUND")
|
||||
}
|
||||
if IsCode(err, "FORBIDDEN") {
|
||||
t.Error("expected IsCode false for wrong code")
|
||||
}
|
||||
if IsCode(nil, "NOT_FOUND") {
|
||||
t.Error("expected IsCode false for nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotFound(t *testing.T) {
|
||||
if !IsNotFound(NewNotFound("x")) {
|
||||
t.Error("expected IsNotFound true")
|
||||
}
|
||||
if IsNotFound(NewBadRequest("x")) {
|
||||
t.Error("expected IsNotFound false for bad request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelErrors(t *testing.T) {
|
||||
if ErrUnauthorized.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("expected UNAUTHORIZED sentinel")
|
||||
}
|
||||
if ErrForbidden.Code != "FORBIDDEN" {
|
||||
t.Errorf("expected FORBIDDEN sentinel")
|
||||
}
|
||||
if ErrInternal.Code != "INTERNAL" {
|
||||
t.Errorf("expected INTERNAL sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatting(t *testing.T) {
|
||||
// Without cause
|
||||
err := NewNotFound("user not found")
|
||||
str := err.Error()
|
||||
if str != "[NOT_FOUND] user not found" {
|
||||
t.Errorf("unexpected format: %s", str)
|
||||
}
|
||||
|
||||
// With cause
|
||||
cause := fmt.Errorf("sql: no rows")
|
||||
err2 := WrapInternal(cause, "database error")
|
||||
str2 := err2.Error()
|
||||
if str2[:len("[INTERNAL]")] != "[INTERNAL]" {
|
||||
t.Errorf("unexpected format: %s", str2)
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,199 @@
|
||||
// Package logger provides a structured logging wrapper around Go's slog package.
|
||||
// It supports text and JSON formats, configurable log levels, and automatic source location.
|
||||
// Sensitive fields (API keys, passwords) are automatically redacted.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents the severity of a log message.
|
||||
type Level = slog.Level
|
||||
|
||||
const (
|
||||
LevelDebug = slog.LevelDebug
|
||||
LevelInfo = slog.LevelInfo
|
||||
LevelWarn = slog.LevelWarn
|
||||
LevelError = slog.LevelError
|
||||
)
|
||||
|
||||
// Logger wraps slog.Logger with redaction and context support.
|
||||
// It does NOT embed *slog.Logger to prevent bypassing redaction.
|
||||
type Logger struct {
|
||||
inner *slog.Logger
|
||||
level Level
|
||||
redact bool
|
||||
}
|
||||
|
||||
// New creates a new Logger with the specified writer, level, and format.
|
||||
func New(w io.Writer, level Level, format string) *Logger {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
AddSource: level <= LevelDebug,
|
||||
}
|
||||
var h slog.Handler
|
||||
switch format {
|
||||
case "json":
|
||||
h = slog.NewJSONHandler(w, opts)
|
||||
default:
|
||||
h = slog.NewTextHandler(w, opts)
|
||||
}
|
||||
return &Logger{inner: slog.New(h), level: level, redact: true}
|
||||
}
|
||||
|
||||
// NewNop creates a no-op logger that discards all output.
|
||||
func NewNop() *Logger {
|
||||
return &Logger{
|
||||
inner: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})),
|
||||
level: LevelDebug,
|
||||
redact: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel converts a string to a slog Level.
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return LevelDebug, nil
|
||||
case "info":
|
||||
return LevelInfo, nil
|
||||
case "warn":
|
||||
return LevelWarn, nil
|
||||
case "error":
|
||||
return LevelError, nil
|
||||
default:
|
||||
return LevelInfo, fmt.Errorf("unknown log level: %q (valid: debug, info, warn, error)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// With returns a child Logger with the given attributes attached.
|
||||
func (l *Logger) With(args ...any) *Logger {
|
||||
return &Logger{
|
||||
inner: l.inner.With(args...),
|
||||
level: l.level,
|
||||
redact: l.redact,
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a child Logger with the given group name.
|
||||
func (l *Logger) WithGroup(name string) *Logger {
|
||||
return &Logger{
|
||||
inner: l.inner.WithGroup(name),
|
||||
level: l.level,
|
||||
redact: l.redact,
|
||||
}
|
||||
}
|
||||
|
||||
// Level returns the current log level.
|
||||
func (l *Logger) Level() Level { return l.level }
|
||||
|
||||
// ---- Core logging methods ----
|
||||
|
||||
func (l *Logger) Debug(msg string, args ...any) {
|
||||
l.log(LevelDebug, msg, args...)
|
||||
}
|
||||
func (l *Logger) Info(msg string, args ...any) {
|
||||
l.log(LevelInfo, msg, args...)
|
||||
}
|
||||
func (l *Logger) Warn(msg string, args ...any) {
|
||||
l.log(LevelWarn, msg, args...)
|
||||
}
|
||||
func (l *Logger) Error(msg string, args ...any) {
|
||||
l.log(LevelError, msg, args...)
|
||||
}
|
||||
|
||||
// ---- Context-aware variants ----
|
||||
|
||||
func (l *Logger) DebugContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelDebug, msg, args...)
|
||||
}
|
||||
func (l *Logger) InfoContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelInfo, msg, args...)
|
||||
}
|
||||
func (l *Logger) WarnContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelWarn, msg, args...)
|
||||
}
|
||||
func (l *Logger) ErrorContext(ctx context.Context, msg string, args ...any) {
|
||||
l.logCtx(ctx, LevelError, msg, args...)
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
func (l *Logger) log(level Level, msg string, args ...any) {
|
||||
if !l.inner.Enabled(context.Background(), level) {
|
||||
return
|
||||
}
|
||||
attrs := l.buildAttrs(args...)
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(attrs...)
|
||||
_ = l.inner.Handler().Handle(context.Background(), r)
|
||||
}
|
||||
|
||||
func (l *Logger) logCtx(ctx context.Context, level Level, msg string, args ...any) {
|
||||
if !l.inner.Enabled(ctx, level) {
|
||||
return
|
||||
}
|
||||
attrs := l.buildAttrs(args...)
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(attrs...)
|
||||
_ = l.inner.Handler().Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (l *Logger) buildAttrs(args ...any) []slog.Attr {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(args)%2 != 0 {
|
||||
args = append(args, "!MISSING")
|
||||
}
|
||||
attrs := make([]slog.Attr, 0, len(args)/2)
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
key, ok := args[i].(string)
|
||||
if !ok {
|
||||
key = fmt.Sprintf("!BADKEY(%v)", args[i])
|
||||
}
|
||||
val := args[i+1]
|
||||
if l.redact {
|
||||
val = redactValue(key, val)
|
||||
}
|
||||
attrs = append(attrs, slog.Any(key, val))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// ---- Redaction ----
|
||||
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"api_key": true,
|
||||
"apikey": true,
|
||||
"password": true,
|
||||
"secret": true,
|
||||
"token": true,
|
||||
"jwt_secret": true,
|
||||
"admin_password": true,
|
||||
"llm_api_key": true,
|
||||
"dashscope_api_key": true,
|
||||
"access_token": true,
|
||||
}
|
||||
|
||||
func redactValue(key string, val any) any {
|
||||
if sensitiveKeys[strings.ToLower(key)] {
|
||||
if s, ok := val.(string); ok {
|
||||
if len(s) > 4 {
|
||||
return s[:2] + "****" + s[len(s)-2:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTextLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
l.Info("hello world", "key", "value")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "hello world") {
|
||||
t.Errorf("expected output to contain 'hello world', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "key") {
|
||||
t.Errorf("expected output to contain 'key', got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "value") {
|
||||
t.Errorf("expected output to contain 'value', got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "json")
|
||||
|
||||
l.Info("hello world", "key", "value")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"msg":"hello world"`) {
|
||||
t.Errorf("expected JSON output to contain msg, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevels(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelWarn, "text")
|
||||
|
||||
l.Debug("debug msg")
|
||||
l.Info("info msg")
|
||||
l.Warn("warn msg")
|
||||
l.Error("error msg")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "debug") {
|
||||
t.Error("debug message should be filtered out at WARN level")
|
||||
}
|
||||
if strings.Contains(output, "info") {
|
||||
t.Error("info message should be filtered out at WARN level")
|
||||
}
|
||||
if !strings.Contains(output, "warn") {
|
||||
t.Error("warn message should appear")
|
||||
}
|
||||
if !strings.Contains(output, "error") {
|
||||
t.Error("error message should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
child := l.With("request_id", "abc123", "user_id", "user-1")
|
||||
child.Info("request processed")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "request_id") || !strings.Contains(output, "abc123") {
|
||||
t.Errorf("expected 'request_id=abc123' in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "user_id") || !strings.Contains(output, "user-1") {
|
||||
t.Errorf("expected 'user_id=user-1' in output, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNop(t *testing.T) {
|
||||
l := NewNop()
|
||||
var buf bytes.Buffer
|
||||
// Nop logger should not write anything
|
||||
l2 := New(&buf, LevelDebug, "text")
|
||||
l2.Info("should appear")
|
||||
if !strings.Contains(buf.String(), "should appear") {
|
||||
t.Error("normal logger should write")
|
||||
}
|
||||
// Nop logger itself is fine to call
|
||||
l.Info("should not appear")
|
||||
l.Error("should not appear")
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Level
|
||||
hasError bool
|
||||
}{
|
||||
{"debug", LevelDebug, false},
|
||||
{"DEBUG", LevelDebug, false},
|
||||
{"info", LevelInfo, false},
|
||||
{"warn", LevelWarn, false},
|
||||
{"error", LevelError, false},
|
||||
{"ERROR", LevelError, false},
|
||||
{"invalid", LevelInfo, true},
|
||||
{"", LevelInfo, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
level, err := ParseLevel(tt.input)
|
||||
if tt.hasError && err == nil {
|
||||
t.Errorf("ParseLevel(%q) expected error but got none", tt.input)
|
||||
}
|
||||
if !tt.hasError && err != nil {
|
||||
t.Errorf("ParseLevel(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if level != tt.expected {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, level, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedaction(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
|
||||
l.Info("auth attempt", "api_key", "sk-abcdefgh12345678", "user_id", "user-1")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "sk-abcdefgh12345678") {
|
||||
t.Errorf("API key should be redacted, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "sk-") && !strings.Contains(output, "****") {
|
||||
t.Errorf("expected redacted value with mask, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "user-1") {
|
||||
t.Errorf("non-sensitive field should NOT be redacted, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSensitiveKeys(t *testing.T) {
|
||||
sensitiveTests := []string{"api_key", "API_KEY", "password", "secret", "token", "jwt_secret", "admin_password", "llm_api_key", "dashscope_api_key", "access_token"}
|
||||
nonSensitiveTests := []string{"user_id", "request_id", "model", "port", "host", "query", "duration_ms", "count"}
|
||||
|
||||
for _, key := range sensitiveTests {
|
||||
val := redactValue(key, "super-secret-value-12345")
|
||||
if s, ok := val.(string); ok {
|
||||
if s == "super-secret-value-12345" {
|
||||
t.Errorf("key %q should be redacted, got raw value instead", key)
|
||||
}
|
||||
if !strings.Contains(s, "****") {
|
||||
t.Errorf("key %q redacted value should contain '****', got: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range nonSensitiveTests {
|
||||
val := redactValue(key, "normal-value")
|
||||
if s, ok := val.(string); ok {
|
||||
if s != "normal-value" {
|
||||
t.Errorf("key %q should NOT be redacted, got: %s", key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactShortValue(t *testing.T) {
|
||||
// Short values (< 4 chars) should be fully masked
|
||||
val := redactValue("api_key", "ab")
|
||||
if s, ok := val.(string); ok {
|
||||
if s != "****" {
|
||||
t.Errorf("short value should be fully masked, got: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOddNumberOfArgs(t *testing.T) {
|
||||
// Go's slog vet check prevents odd numbers of arguments at compile time.
|
||||
// Our wrapper detects this and appends a !MISSING placeholder internally.
|
||||
// This test verifies buildAttrs handles odd args correctly.
|
||||
var buf bytes.Buffer
|
||||
l := New(&buf, LevelDebug, "text")
|
||||
attrs := l.buildAttrs("key1", "value1", "key2")
|
||||
if len(attrs) != 2 {
|
||||
t.Errorf("expected 2 attrs, got %d", len(attrs))
|
||||
}
|
||||
if attrs[1].Key != "key2" {
|
||||
t.Errorf("expected attrs[1].Key=key2, got %s", attrs[1].Key)
|
||||
}
|
||||
// The value for key2 should be "!MISSING"
|
||||
if attrs[1].Value.String() != "!MISSING" {
|
||||
t.Errorf("expected !MISSING placeholder, got %s", attrs[1].Value.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user