Compare commits

..

13 Commits

Author SHA1 Message Date
c2d86c9880 fix(dbmanager): resolve deadlock in adapter getter methods
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 30s
Build , Vet Test, and Lint / Build (push) Successful in 1m56s
Tests / Unit Tests (push) Failing after 1m5s
Tests / Integration Tests (push) Failing after 1m1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -23m15s
Build , Vet Test, and Lint / Lint Code (push) Successful in -23m9s
Fixes deadlock caused by calling public methods (Bun, GORM, Native)
while holding a write lock. The public methods attempt to acquire
their own locks, causing the goroutine to freeze.

Solution: Call provider methods directly within the adapter getters
since they already hold the necessary write lock.
2026-01-03 17:26:43 +02:00
70bf0a4be1 fix(dbmanager): add nil checks to connection methods 2026-01-03 17:19:43 +02:00
4964d89158 feat(restheadspec): accept bunrouter.Router and bunrouter.Group in SetupBunRouterRoutes
Replaced *router.StandardBunRouterAdapter parameter with BunRouterHandler
interface to support both bunrouter.Router and bunrouter.Group types,
enabling route registration on router groups with path prefixes.

- Added BunRouterHandler interface
- Updated SetupBunRouterRoutes signature
- Updated ExampleBunRouterWithBunDB to use bunrouter.New() directly
- Added ExampleBunRouterWithGroup demonstrating group usage
2026-01-03 16:10:36 +02:00
96b098f912 feat(resolvespec): accept bunrouter.Router and bunrouter.Group in SetupBunRouterRoutes
Replaced *router.StandardBunRouterAdapter parameter with BunRouterHandler
interface to support both bunrouter.Router and bunrouter.Group types,
enabling route registration on router groups with path prefixes.

- Added BunRouterHandler interface
- Updated SetupBunRouterRoutes signature
- Updated example functions to use bunrouter.New() directly
- Added ExampleBunRouterWithGroup demonstrating group usage
2026-01-03 16:06:53 +02:00
5bba99efe3 Merge branch 'main' of github.com:bitechdev/ResolveSpec 2026-01-03 14:48:17 +02:00
8504b6d13d fix(staticweb): add nil check to WithStripPrefix helper
Prevent panic when WithStripPrefix is called with a nil provider by
using reflection to check for typed nil pointers stored in interfaces.
2026-01-03 14:47:21 +02:00
ada4db6465 fix(staticweb): add nil check to WithStripPrefix helper
Prevent panic when WithStripPrefix is called with a nil provider.
2026-01-03 14:43:31 +02:00
2017465cb8 feat(staticweb): add path prefix stripping to all filesystem providers
Add PrefixStrippingProvider interface and implement it in all providers
(EmbedFSProvider, LocalFSProvider, ZipFSProvider) to support serving
files from subdirectories at the root level.
2026-01-03 14:39:51 +02:00
d33747c2d3 feat(staticweb): add path prefix stripping to EmbedFSProvider
Adds WithStripPrefix method to allow serving files from subdirectories
at the root path. For example, files at /dist/assets can be made
accessible via /assets by calling WithStripPrefix("/dist").
2026-01-03 14:25:04 +02:00
c864aa4d90 perf(config): avoid copying structs in server validation loop
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -24m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in -24m1s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 3m8s
Tests / Unit Tests (push) Successful in -25m22s
Build , Vet Test, and Lint / Build (push) Successful in -25m1s
Tests / Integration Tests (push) Failing after 1m38s
- Use indexing instead of range value to prevent 208 byte copies per iteration
2026-01-03 01:50:56 +02:00
250fcf686c feat(config): add multiple server instances support
- Add ServersConfig and ServerInstanceConfig structs
- Support configuring multiple named server instances
- Add global timeout defaults with per-instance overrides
- Add TLS configuration options (SSL cert/key, self-signed, AutoTLS)
- Add validation for server configurations
- Add helper methods for applying defaults and getting default server
- Add conversion helper to avoid import cycles
2026-01-03 01:48:42 +02:00
47cfc4b3da feat(config): Add PopulateFromDSN method to parse connection DSN 2026-01-02 23:23:20 +02:00
0e8ae75daf feat(config): Add SetConfig method to Manager 2026-01-02 23:21:27 +02:00
16 changed files with 1309 additions and 110 deletions

View File

@@ -39,7 +39,6 @@ func main() {
logger.UpdateLoggerPath(cfg.Logger.Path, cfg.Logger.Dev)
}
logger.Info("ResolveSpec test server starting")
logger.Info("Configuration loaded - Server will listen on: %s", cfg.Server.Addr)
// Initialize database manager
ctx := context.Background()
@@ -73,47 +72,30 @@ func main() {
// Create server manager
mgr := server.NewManager()
// Parse host and port from addr
host := ""
port := 8080
if cfg.Server.Addr != "" {
// Parse addr (format: ":8080" or "localhost:8080")
if cfg.Server.Addr[0] == ':' {
// Just port
_, err := fmt.Sscanf(cfg.Server.Addr, ":%d", &port)
if err != nil {
logger.Error("Invalid server address: %s", cfg.Server.Addr)
os.Exit(1)
}
} else {
// Host and port
_, err := fmt.Sscanf(cfg.Server.Addr, "%[^:]:%d", &host, &port)
if err != nil {
logger.Error("Invalid server address: %s", cfg.Server.Addr)
os.Exit(1)
}
}
// Get default server configuration
defaultServerCfg, err := cfg.Servers.GetDefault()
if err != nil {
logger.Error("Failed to get default server config: %v", err)
os.Exit(1)
}
// Add server instance
_, err = mgr.Add(server.Config{
Name: "api",
Host: host,
Port: port,
Handler: r,
ShutdownTimeout: cfg.Server.ShutdownTimeout,
DrainTimeout: cfg.Server.DrainTimeout,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
})
// Apply global defaults
defaultServerCfg.ApplyGlobalDefaults(cfg.Servers)
// Convert to server.Config and add instance
serverCfg := server.FromConfigInstanceToServerConfig(defaultServerCfg, r)
logger.Info("Configuration loaded - Server '%s' will listen on %s:%d",
serverCfg.Name, serverCfg.Host, serverCfg.Port)
_, err = mgr.Add(serverCfg)
if err != nil {
logger.Error("Failed to add server: %v", err)
os.Exit(1)
}
// Start server with graceful shutdown
logger.Info("Starting server on %s", cfg.Server.Addr)
logger.Info("Starting server '%s' on %s:%d", serverCfg.Name, serverCfg.Host, serverCfg.Port)
if err := mgr.ServeWithGracefulShutdown(); err != nil {
logger.Error("Server failed: %v", err)
os.Exit(1)

View File

@@ -4,20 +4,28 @@ import "time"
// Config represents the complete application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Tracing TracingConfig `mapstructure:"tracing"`
Cache CacheConfig `mapstructure:"cache"`
Logger LoggerConfig `mapstructure:"logger"`
ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"`
Middleware MiddlewareConfig `mapstructure:"middleware"`
CORS CORSConfig `mapstructure:"cors"`
EventBroker EventBrokerConfig `mapstructure:"event_broker"`
DBManager DBManagerConfig `mapstructure:"dbmanager"`
Servers ServersConfig `mapstructure:"servers"`
Tracing TracingConfig `mapstructure:"tracing"`
Cache CacheConfig `mapstructure:"cache"`
Logger LoggerConfig `mapstructure:"logger"`
ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"`
Middleware MiddlewareConfig `mapstructure:"middleware"`
CORS CORSConfig `mapstructure:"cors"`
EventBroker EventBrokerConfig `mapstructure:"event_broker"`
DBManager DBManagerConfig `mapstructure:"dbmanager"`
Paths PathsConfig `mapstructure:"paths"`
Extensions map[string]interface{} `mapstructure:"extensions"`
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
Addr string `mapstructure:"addr"`
// ServersConfig contains configuration for the server manager
type ServersConfig struct {
// DefaultServer is the name of the default server to use
DefaultServer string `mapstructure:"default_server"`
// Instances is a map of server name to server configuration
Instances map[string]ServerInstanceConfig `mapstructure:"instances"`
// Global timeout defaults (can be overridden per instance)
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
DrainTimeout time.Duration `mapstructure:"drain_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
@@ -25,6 +33,48 @@ type ServerConfig struct {
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
}
// ServerInstanceConfig defines configuration for a single server instance
type ServerInstanceConfig struct {
// Name is the unique name of this server instance
Name string `mapstructure:"name"`
// Host is the host to bind to (e.g., "localhost", "0.0.0.0", "")
Host string `mapstructure:"host"`
// Port is the port number to listen on
Port int `mapstructure:"port"`
// Description is a human-readable description of this server
Description string `mapstructure:"description"`
// GZIP enables GZIP compression middleware
GZIP bool `mapstructure:"gzip"`
// TLS/HTTPS configuration options (mutually exclusive)
// Option 1: Provide certificate and key files directly
SSLCert string `mapstructure:"ssl_cert"`
SSLKey string `mapstructure:"ssl_key"`
// Option 2: Use self-signed certificate (for development/testing)
SelfSignedSSL bool `mapstructure:"self_signed_ssl"`
// Option 3: Use Let's Encrypt / AutoTLS
AutoTLS bool `mapstructure:"auto_tls"`
AutoTLSDomains []string `mapstructure:"auto_tls_domains"`
AutoTLSCacheDir string `mapstructure:"auto_tls_cache_dir"`
AutoTLSEmail string `mapstructure:"auto_tls_email"`
// Timeout configurations (overrides global defaults)
ShutdownTimeout *time.Duration `mapstructure:"shutdown_timeout"`
DrainTimeout *time.Duration `mapstructure:"drain_timeout"`
ReadTimeout *time.Duration `mapstructure:"read_timeout"`
WriteTimeout *time.Duration `mapstructure:"write_timeout"`
IdleTimeout *time.Duration `mapstructure:"idle_timeout"`
// Tags for organization and filtering
Tags map[string]string `mapstructure:"tags"`
}
// TracingConfig holds OpenTelemetry tracing configuration
type TracingConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -136,3 +186,8 @@ type EventBrokerRetryPolicyConfig struct {
MaxDelay time.Duration `mapstructure:"max_delay"`
BackoffFactor float64 `mapstructure:"backoff_factor"`
}
// PathsConfig contains configuration for named file system paths
// This is a map of path name to file system path
// Example: "data_dir": "/var/lib/myapp/data"
type PathsConfig map[string]string

View File

@@ -2,6 +2,9 @@ package config
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
@@ -91,6 +94,160 @@ func (c *DBManagerConfig) ToManagerConfig() interface{} {
return c
}
// PopulateFromDSN parses a DSN and populates the connection fields
func (cc *DBConnectionConfig) PopulateFromDSN() error {
if cc.DSN == "" {
return nil // Nothing to populate
}
switch cc.Type {
case "postgres":
return cc.populatePostgresDSN()
case "mongodb":
return cc.populateMongoDSN()
case "mssql":
return cc.populateMSSQLDSN()
case "sqlite":
return cc.populateSQLiteDSN()
default:
return fmt.Errorf("cannot parse DSN for unsupported database type: %s", cc.Type)
}
}
// populatePostgresDSN parses PostgreSQL DSN format
// Example: host=localhost port=5432 user=postgres password=secret dbname=mydb sslmode=disable
func (cc *DBConnectionConfig) populatePostgresDSN() error {
parts := strings.Fields(cc.DSN)
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key, value := kv[0], kv[1]
switch key {
case "host":
cc.Host = value
case "port":
port, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid port in DSN: %w", err)
}
cc.Port = port
case "user":
cc.User = value
case "password":
cc.Password = value
case "dbname":
cc.Database = value
case "sslmode":
cc.SSLMode = value
case "search_path":
cc.Schema = value
}
}
return nil
}
// populateMongoDSN parses MongoDB DSN format
// Example: mongodb://user:password@host:port/database?authSource=admin&replicaSet=rs0
func (cc *DBConnectionConfig) populateMongoDSN() error {
u, err := url.Parse(cc.DSN)
if err != nil {
return fmt.Errorf("invalid MongoDB DSN: %w", err)
}
// Extract user and password
if u.User != nil {
cc.User = u.User.Username()
if password, ok := u.User.Password(); ok {
cc.Password = password
}
}
// Extract host and port
if u.Host != "" {
host := u.Host
if strings.Contains(host, ":") {
hostPort := strings.SplitN(host, ":", 2)
cc.Host = hostPort[0]
if port, err := strconv.Atoi(hostPort[1]); err == nil {
cc.Port = port
}
} else {
cc.Host = host
}
}
// Extract database
if u.Path != "" {
cc.Database = strings.TrimPrefix(u.Path, "/")
}
// Extract query parameters
params := u.Query()
if authSource := params.Get("authSource"); authSource != "" {
cc.AuthSource = authSource
}
if replicaSet := params.Get("replicaSet"); replicaSet != "" {
cc.ReplicaSet = replicaSet
}
if readPref := params.Get("readPreference"); readPref != "" {
cc.ReadPreference = readPref
}
return nil
}
// populateMSSQLDSN parses MSSQL DSN format
// Example: sqlserver://username:password@host:port?database=dbname&schema=dbo
func (cc *DBConnectionConfig) populateMSSQLDSN() error {
u, err := url.Parse(cc.DSN)
if err != nil {
return fmt.Errorf("invalid MSSQL DSN: %w", err)
}
// Extract user and password
if u.User != nil {
cc.User = u.User.Username()
if password, ok := u.User.Password(); ok {
cc.Password = password
}
}
// Extract host and port
if u.Host != "" {
host := u.Host
if strings.Contains(host, ":") {
hostPort := strings.SplitN(host, ":", 2)
cc.Host = hostPort[0]
if port, err := strconv.Atoi(hostPort[1]); err == nil {
cc.Port = port
}
} else {
cc.Host = host
}
}
// Extract query parameters
params := u.Query()
if database := params.Get("database"); database != "" {
cc.Database = database
}
if schema := params.Get("schema"); schema != "" {
cc.Schema = schema
}
return nil
}
// populateSQLiteDSN parses SQLite DSN format
// Example: /path/to/database.db or :memory:
func (cc *DBConnectionConfig) populateSQLiteDSN() error {
cc.FilePath = cc.DSN
return nil
}
// Validate validates the DBManager configuration
func (c *DBManagerConfig) Validate() error {
if len(c.Connections) == 0 {

View File

@@ -97,6 +97,31 @@ func (m *Manager) GetConfig() (*Config, error) {
return &cfg, nil
}
// SetConfig sets the complete configuration
func (m *Manager) SetConfig(cfg *Config) error {
configMap := make(map[string]interface{})
// Marshal the config to a map structure that viper can use
if err := m.v.Unmarshal(&configMap); err != nil {
return fmt.Errorf("failed to prepare config map: %w", err)
}
// Use viper's merge to apply the config
m.v.Set("servers", cfg.Servers)
m.v.Set("tracing", cfg.Tracing)
m.v.Set("cache", cfg.Cache)
m.v.Set("logger", cfg.Logger)
m.v.Set("error_tracking", cfg.ErrorTracking)
m.v.Set("middleware", cfg.Middleware)
m.v.Set("cors", cfg.CORS)
m.v.Set("event_broker", cfg.EventBroker)
m.v.Set("dbmanager", cfg.DBManager)
m.v.Set("paths", cfg.Paths)
m.v.Set("extensions", cfg.Extensions)
return nil
}
// Get returns a configuration value by key
func (m *Manager) Get(key string) interface{} {
return m.v.Get(key)
@@ -132,13 +157,22 @@ func (m *Manager) SaveConfig(path string) error {
// setDefaults sets default configuration values
func setDefaults(v *viper.Viper) {
// Server defaults
v.SetDefault("server.addr", ":8080")
v.SetDefault("server.shutdown_timeout", "30s")
v.SetDefault("server.drain_timeout", "25s")
v.SetDefault("server.read_timeout", "10s")
v.SetDefault("server.write_timeout", "10s")
v.SetDefault("server.idle_timeout", "120s")
// Server defaults - new structure
v.SetDefault("servers.default_server", "default")
// Global server timeout defaults
v.SetDefault("servers.shutdown_timeout", "30s")
v.SetDefault("servers.drain_timeout", "25s")
v.SetDefault("servers.read_timeout", "10s")
v.SetDefault("servers.write_timeout", "10s")
v.SetDefault("servers.idle_timeout", "120s")
// Default server instance
v.SetDefault("servers.instances.default.name", "default")
v.SetDefault("servers.instances.default.host", "")
v.SetDefault("servers.instances.default.port", 8080)
v.SetDefault("servers.instances.default.description", "Default HTTP server")
v.SetDefault("servers.instances.default.gzip", false)
// Tracing defaults
v.SetDefault("tracing.enabled", false)
@@ -236,4 +270,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("event_broker.retry_policy.initial_delay", "1s")
v.SetDefault("event_broker.retry_policy.max_delay", "30s")
v.SetDefault("event_broker.retry_policy.backoff_factor", 2.0)
// Paths defaults (common directory paths)
v.SetDefault("paths.data_dir", "./data")
v.SetDefault("paths.config_dir", "./config")
v.SetDefault("paths.logs_dir", "./logs")
v.SetDefault("paths.temp_dir", "./tmp")
// Extensions defaults (empty map)
v.SetDefault("extensions", map[string]interface{}{})
}

View File

@@ -34,8 +34,8 @@ func TestDefaultValues(t *testing.T) {
got interface{}
expected interface{}
}{
{"server.addr", cfg.Server.Addr, ":8080"},
{"server.shutdown_timeout", cfg.Server.ShutdownTimeout, 30 * time.Second},
{"servers.default_server", cfg.Servers.DefaultServer, "default"},
{"servers.shutdown_timeout", cfg.Servers.ShutdownTimeout, 30 * time.Second},
{"tracing.enabled", cfg.Tracing.Enabled, false},
{"tracing.service_name", cfg.Tracing.ServiceName, "resolvespec"},
{"cache.provider", cfg.Cache.Provider, "memory"},
@@ -46,6 +46,18 @@ func TestDefaultValues(t *testing.T) {
{"middleware.rate_limit_burst", cfg.Middleware.RateLimitBurst, 200},
}
// Test default server instance
defaultServer, ok := cfg.Servers.Instances["default"]
if !ok {
t.Fatal("Default server instance not found")
}
if defaultServer.Port != 8080 {
t.Errorf("default server port: got %d, want 8080", defaultServer.Port)
}
if defaultServer.Name != "default" {
t.Errorf("default server name: got %s, want default", defaultServer.Name)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.expected {
@@ -57,12 +69,12 @@ func TestDefaultValues(t *testing.T) {
func TestEnvironmentVariableOverrides(t *testing.T) {
// Set environment variables
os.Setenv("RESOLVESPEC_SERVER_ADDR", ":9090")
os.Setenv("RESOLVESPEC_SERVERS_INSTANCES_DEFAULT_PORT", "9090")
os.Setenv("RESOLVESPEC_TRACING_ENABLED", "true")
os.Setenv("RESOLVESPEC_CACHE_PROVIDER", "redis")
os.Setenv("RESOLVESPEC_LOGGER_DEV", "true")
defer func() {
os.Unsetenv("RESOLVESPEC_SERVER_ADDR")
os.Unsetenv("RESOLVESPEC_SERVERS_INSTANCES_DEFAULT_PORT")
os.Unsetenv("RESOLVESPEC_TRACING_ENABLED")
os.Unsetenv("RESOLVESPEC_CACHE_PROVIDER")
os.Unsetenv("RESOLVESPEC_LOGGER_DEV")
@@ -84,7 +96,6 @@ func TestEnvironmentVariableOverrides(t *testing.T) {
got interface{}
expected interface{}
}{
{"server.addr", cfg.Server.Addr, ":9090"},
{"tracing.enabled", cfg.Tracing.Enabled, true},
{"cache.provider", cfg.Cache.Provider, "redis"},
{"logger.dev", cfg.Logger.Dev, true},
@@ -97,11 +108,17 @@ func TestEnvironmentVariableOverrides(t *testing.T) {
}
})
}
// Test server port override
defaultServer := cfg.Servers.Instances["default"]
if defaultServer.Port != 9090 {
t.Errorf("server port: got %d, want 9090", defaultServer.Port)
}
}
func TestProgrammaticConfiguration(t *testing.T) {
mgr := NewManager()
mgr.Set("server.addr", ":7070")
mgr.Set("servers.instances.default.port", 7070)
mgr.Set("tracing.service_name", "test-service")
cfg, err := mgr.GetConfig()
@@ -109,8 +126,8 @@ func TestProgrammaticConfiguration(t *testing.T) {
t.Fatalf("Failed to get config: %v", err)
}
if cfg.Server.Addr != ":7070" {
t.Errorf("server.addr: got %s, want :7070", cfg.Server.Addr)
if cfg.Servers.Instances["default"].Port != 7070 {
t.Errorf("server port: got %d, want 7070", cfg.Servers.Instances["default"].Port)
}
if cfg.Tracing.ServiceName != "test-service" {
@@ -148,8 +165,8 @@ func TestWithOptions(t *testing.T) {
}
// Set environment variable with custom prefix
os.Setenv("MYAPP_SERVER_ADDR", ":5000")
defer os.Unsetenv("MYAPP_SERVER_ADDR")
os.Setenv("MYAPP_SERVERS_INSTANCES_DEFAULT_PORT", "5000")
defer os.Unsetenv("MYAPP_SERVERS_INSTANCES_DEFAULT_PORT")
if err := mgr.Load(); err != nil {
t.Fatalf("Failed to load config: %v", err)
@@ -160,7 +177,432 @@ func TestWithOptions(t *testing.T) {
t.Fatalf("Failed to get config: %v", err)
}
if cfg.Server.Addr != ":5000" {
t.Errorf("server.addr: got %s, want :5000", cfg.Server.Addr)
if cfg.Servers.Instances["default"].Port != 5000 {
t.Errorf("server port: got %d, want 5000", cfg.Servers.Instances["default"].Port)
}
}
func TestServersConfig(t *testing.T) {
mgr := NewManager()
if err := mgr.Load(); err != nil {
t.Fatalf("Failed to load config: %v", err)
}
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
// Test default server exists
if cfg.Servers.DefaultServer != "default" {
t.Errorf("Expected default_server to be 'default', got %s", cfg.Servers.DefaultServer)
}
// Test default instance
defaultServer, ok := cfg.Servers.Instances["default"]
if !ok {
t.Fatal("Default server instance not found")
}
if defaultServer.Port != 8080 {
t.Errorf("Expected default port 8080, got %d", defaultServer.Port)
}
if defaultServer.Name != "default" {
t.Errorf("Expected default name 'default', got %s", defaultServer.Name)
}
if defaultServer.Description != "Default HTTP server" {
t.Errorf("Expected description 'Default HTTP server', got %s", defaultServer.Description)
}
}
func TestMultipleServerInstances(t *testing.T) {
mgr := NewManager()
// Add additional server instances (default instance exists from defaults)
mgr.Set("servers.default_server", "api")
mgr.Set("servers.instances.api.name", "api")
mgr.Set("servers.instances.api.host", "0.0.0.0")
mgr.Set("servers.instances.api.port", 8080)
mgr.Set("servers.instances.admin.name", "admin")
mgr.Set("servers.instances.admin.host", "localhost")
mgr.Set("servers.instances.admin.port", 8081)
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
// Should have default + api + admin = 3 instances
if len(cfg.Servers.Instances) < 2 {
t.Errorf("Expected at least 2 server instances, got %d", len(cfg.Servers.Instances))
}
// Verify api instance
apiServer, ok := cfg.Servers.Instances["api"]
if !ok {
t.Fatal("API server instance not found")
}
if apiServer.Port != 8080 {
t.Errorf("Expected API port 8080, got %d", apiServer.Port)
}
// Verify admin instance
adminServer, ok := cfg.Servers.Instances["admin"]
if !ok {
t.Fatal("Admin server instance not found")
}
if adminServer.Port != 8081 {
t.Errorf("Expected admin port 8081, got %d", adminServer.Port)
}
// Validate default server
if err := cfg.Servers.Validate(); err != nil {
t.Errorf("Server config validation failed: %v", err)
}
// Get default
defaultSrv, err := cfg.Servers.GetDefault()
if err != nil {
t.Fatalf("Failed to get default server: %v", err)
}
if defaultSrv.Name != "api" {
t.Errorf("Expected default server 'api', got '%s'", defaultSrv.Name)
}
}
func TestExtensionsField(t *testing.T) {
mgr := NewManager()
// Set custom extensions
mgr.Set("extensions.custom_feature.enabled", true)
mgr.Set("extensions.custom_feature.api_key", "test-key")
mgr.Set("extensions.another_extension.timeout", "5s")
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
if cfg.Extensions == nil {
t.Fatal("Extensions should not be nil")
}
// Verify extensions are accessible
customFeature := mgr.Get("extensions.custom_feature")
if customFeature == nil {
t.Error("custom_feature extension not found")
}
// Verify via config manager methods
if !mgr.GetBool("extensions.custom_feature.enabled") {
t.Error("Expected custom_feature.enabled to be true")
}
if mgr.GetString("extensions.custom_feature.api_key") != "test-key" {
t.Error("Expected api_key to be 'test-key'")
}
}
func TestServerInstanceValidation(t *testing.T) {
tests := []struct {
name string
instance ServerInstanceConfig
expectErr bool
}{
{
name: "valid basic config",
instance: ServerInstanceConfig{
Name: "test",
Port: 8080,
},
expectErr: false,
},
{
name: "invalid port - too high",
instance: ServerInstanceConfig{
Name: "test",
Port: 99999,
},
expectErr: true,
},
{
name: "invalid port - zero",
instance: ServerInstanceConfig{
Name: "test",
Port: 0,
},
expectErr: true,
},
{
name: "empty name",
instance: ServerInstanceConfig{
Name: "",
Port: 8080,
},
expectErr: true,
},
{
name: "conflicting TLS options",
instance: ServerInstanceConfig{
Name: "test",
Port: 8080,
SelfSignedSSL: true,
AutoTLS: true,
},
expectErr: true,
},
{
name: "incomplete SSL cert config",
instance: ServerInstanceConfig{
Name: "test",
Port: 8080,
SSLCert: "/path/to/cert.pem",
// Missing SSLKey
},
expectErr: true,
},
{
name: "AutoTLS without domains",
instance: ServerInstanceConfig{
Name: "test",
Port: 8080,
AutoTLS: true,
// Missing AutoTLSDomains
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.instance.Validate()
if tt.expectErr && err == nil {
t.Error("Expected validation error, got nil")
}
if !tt.expectErr && err != nil {
t.Errorf("Expected no error, got: %v", err)
}
})
}
}
func TestApplyGlobalDefaults(t *testing.T) {
globals := ServersConfig{
ShutdownTimeout: 30 * time.Second,
DrainTimeout: 25 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
instance := ServerInstanceConfig{
Name: "test",
Port: 8080,
}
// Apply global defaults
instance.ApplyGlobalDefaults(globals)
// Check that defaults were applied
if instance.ShutdownTimeout == nil || *instance.ShutdownTimeout != 30*time.Second {
t.Error("ShutdownTimeout not applied correctly")
}
if instance.DrainTimeout == nil || *instance.DrainTimeout != 25*time.Second {
t.Error("DrainTimeout not applied correctly")
}
if instance.ReadTimeout == nil || *instance.ReadTimeout != 10*time.Second {
t.Error("ReadTimeout not applied correctly")
}
if instance.WriteTimeout == nil || *instance.WriteTimeout != 10*time.Second {
t.Error("WriteTimeout not applied correctly")
}
if instance.IdleTimeout == nil || *instance.IdleTimeout != 120*time.Second {
t.Error("IdleTimeout not applied correctly")
}
// Test that explicit overrides are not replaced
customTimeout := 60 * time.Second
instance2 := ServerInstanceConfig{
Name: "test2",
Port: 8081,
ShutdownTimeout: &customTimeout,
}
instance2.ApplyGlobalDefaults(globals)
if instance2.ShutdownTimeout == nil || *instance2.ShutdownTimeout != 60*time.Second {
t.Error("Custom ShutdownTimeout was overridden")
}
}
func TestPathsConfig(t *testing.T) {
mgr := NewManager()
if err := mgr.Load(); err != nil {
t.Fatalf("Failed to load config: %v", err)
}
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
// Test default paths exist
if !cfg.Paths.Has("data_dir") {
t.Error("Expected data_dir path to exist")
}
if !cfg.Paths.Has("config_dir") {
t.Error("Expected config_dir path to exist")
}
if !cfg.Paths.Has("logs_dir") {
t.Error("Expected logs_dir path to exist")
}
if !cfg.Paths.Has("temp_dir") {
t.Error("Expected temp_dir path to exist")
}
// Test Get method
dataDir, err := cfg.Paths.Get("data_dir")
if err != nil {
t.Errorf("Failed to get data_dir: %v", err)
}
if dataDir != "./data" {
t.Errorf("Expected data_dir to be './data', got '%s'", dataDir)
}
// Test GetOrDefault
existing := cfg.Paths.GetOrDefault("data_dir", "/default/path")
if existing != "./data" {
t.Errorf("Expected existing path, got '%s'", existing)
}
nonExisting := cfg.Paths.GetOrDefault("nonexistent", "/default/path")
if nonExisting != "/default/path" {
t.Errorf("Expected default path, got '%s'", nonExisting)
}
}
func TestPathsConfigMethods(t *testing.T) {
pc := PathsConfig{
"base": "/var/myapp",
"data": "/var/myapp/data",
}
// Test Get
path, err := pc.Get("base")
if err != nil {
t.Errorf("Failed to get path: %v", err)
}
if path != "/var/myapp" {
t.Errorf("Expected '/var/myapp', got '%s'", path)
}
// Test Get non-existent
_, err = pc.Get("nonexistent")
if err == nil {
t.Error("Expected error for non-existent path")
}
// Test Set
pc.Set("new_path", "/new/location")
newPath, err := pc.Get("new_path")
if err != nil {
t.Errorf("Failed to get newly set path: %v", err)
}
if newPath != "/new/location" {
t.Errorf("Expected '/new/location', got '%s'", newPath)
}
// Test Has
if !pc.Has("base") {
t.Error("Expected 'base' path to exist")
}
if pc.Has("nonexistent") {
t.Error("Expected 'nonexistent' path to not exist")
}
// Test List
names := pc.List()
if len(names) != 3 {
t.Errorf("Expected 3 paths, got %d", len(names))
}
// Test Join
joined, err := pc.Join("base", "subdir", "file.txt")
if err != nil {
t.Errorf("Failed to join paths: %v", err)
}
expected := "/var/myapp/subdir/file.txt"
if joined != expected {
t.Errorf("Expected '%s', got '%s'", expected, joined)
}
}
func TestPathsConfigEnvironmentVariables(t *testing.T) {
// Set environment variables for paths
os.Setenv("RESOLVESPEC_PATHS_DATA_DIR", "/custom/data")
os.Setenv("RESOLVESPEC_PATHS_LOGS_DIR", "/custom/logs")
defer func() {
os.Unsetenv("RESOLVESPEC_PATHS_DATA_DIR")
os.Unsetenv("RESOLVESPEC_PATHS_LOGS_DIR")
}()
mgr := NewManager()
if err := mgr.Load(); err != nil {
t.Fatalf("Failed to load config: %v", err)
}
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
// Test environment variable override of existing default path
dataDir, err := cfg.Paths.Get("data_dir")
if err != nil {
t.Errorf("Failed to get data_dir: %v", err)
}
if dataDir != "/custom/data" {
t.Errorf("Expected '/custom/data', got '%s'", dataDir)
}
// Test another environment variable override
logsDir, err := cfg.Paths.Get("logs_dir")
if err != nil {
t.Errorf("Failed to get logs_dir: %v", err)
}
if logsDir != "/custom/logs" {
t.Errorf("Expected '/custom/logs', got '%s'", logsDir)
}
}
func TestPathsConfigProgrammatic(t *testing.T) {
mgr := NewManager()
// Set custom paths programmatically
mgr.Set("paths.custom_dir", "/my/custom/dir")
mgr.Set("paths.cache_dir", "/var/cache/myapp")
cfg, err := mgr.GetConfig()
if err != nil {
t.Fatalf("Failed to get config: %v", err)
}
// Verify custom paths
customDir, err := cfg.Paths.Get("custom_dir")
if err != nil {
t.Errorf("Failed to get custom_dir: %v", err)
}
if customDir != "/my/custom/dir" {
t.Errorf("Expected '/my/custom/dir', got '%s'", customDir)
}
cacheDir, err := cfg.Paths.Get("cache_dir")
if err != nil {
t.Errorf("Failed to get cache_dir: %v", err)
}
if cacheDir != "/var/cache/myapp" {
t.Errorf("Expected '/var/cache/myapp', got '%s'", cacheDir)
}
}

117
pkg/config/paths.go Normal file
View File

@@ -0,0 +1,117 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// Get retrieves a path by name
func (pc PathsConfig) Get(name string) (string, error) {
if pc == nil {
return "", fmt.Errorf("paths not initialized")
}
path, ok := pc[name]
if !ok {
return "", fmt.Errorf("path '%s' not found", name)
}
return path, nil
}
// GetOrDefault retrieves a path by name, returning defaultPath if not found
func (pc PathsConfig) GetOrDefault(name, defaultPath string) string {
if pc == nil {
return defaultPath
}
path, ok := pc[name]
if !ok {
return defaultPath
}
return path
}
// Set sets a path by name
func (pc PathsConfig) Set(name, path string) {
pc[name] = path
}
// Has checks if a path exists by name
func (pc PathsConfig) Has(name string) bool {
if pc == nil {
return false
}
_, ok := pc[name]
return ok
}
// EnsureDir ensures a directory exists at the specified path name
// Creates the directory if it doesn't exist with the given permissions
func (pc PathsConfig) EnsureDir(name string, perm os.FileMode) error {
path, err := pc.Get(name)
if err != nil {
return err
}
// Check if directory exists
info, err := os.Stat(path)
if err == nil {
// Path exists, check if it's a directory
if !info.IsDir() {
return fmt.Errorf("path '%s' exists but is not a directory: %s", name, path)
}
return nil
}
// Directory doesn't exist, create it
if os.IsNotExist(err) {
if err := os.MkdirAll(path, perm); err != nil {
return fmt.Errorf("failed to create directory for '%s' at %s: %w", name, path, err)
}
return nil
}
return fmt.Errorf("failed to stat path '%s' at %s: %w", name, path, err)
}
// AbsPath returns the absolute path for a named path
func (pc PathsConfig) AbsPath(name string) (string, error) {
path, err := pc.Get(name)
if err != nil {
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for '%s': %w", name, err)
}
return absPath, nil
}
// Join joins path segments with a named base path
func (pc PathsConfig) Join(name string, elem ...string) (string, error) {
base, err := pc.Get(name)
if err != nil {
return "", err
}
parts := append([]string{base}, elem...)
return filepath.Join(parts...), nil
}
// List returns all configured path names
func (pc PathsConfig) List() []string {
if pc == nil {
return []string{}
}
names := make([]string, 0, len(pc))
for name := range pc {
names = append(names, name)
}
return names
}

107
pkg/config/server.go Normal file
View File

@@ -0,0 +1,107 @@
package config
import (
"fmt"
)
// ApplyGlobalDefaults applies global server defaults to this instance
// Called for instances that don't specify their own timeout values
func (sic *ServerInstanceConfig) ApplyGlobalDefaults(globals ServersConfig) {
if sic.ShutdownTimeout == nil && globals.ShutdownTimeout > 0 {
t := globals.ShutdownTimeout
sic.ShutdownTimeout = &t
}
if sic.DrainTimeout == nil && globals.DrainTimeout > 0 {
t := globals.DrainTimeout
sic.DrainTimeout = &t
}
if sic.ReadTimeout == nil && globals.ReadTimeout > 0 {
t := globals.ReadTimeout
sic.ReadTimeout = &t
}
if sic.WriteTimeout == nil && globals.WriteTimeout > 0 {
t := globals.WriteTimeout
sic.WriteTimeout = &t
}
if sic.IdleTimeout == nil && globals.IdleTimeout > 0 {
t := globals.IdleTimeout
sic.IdleTimeout = &t
}
}
// Validate validates the ServerInstanceConfig
func (sic *ServerInstanceConfig) Validate() error {
if sic.Name == "" {
return fmt.Errorf("server instance name cannot be empty")
}
if sic.Port <= 0 || sic.Port > 65535 {
return fmt.Errorf("invalid port: %d (must be 1-65535)", sic.Port)
}
// Validate TLS options are mutually exclusive
tlsCount := 0
if sic.SSLCert != "" || sic.SSLKey != "" {
tlsCount++
}
if sic.SelfSignedSSL {
tlsCount++
}
if sic.AutoTLS {
tlsCount++
}
if tlsCount > 1 {
return fmt.Errorf("server '%s': only one TLS option can be enabled", sic.Name)
}
// If using certificate files, both must be provided
if (sic.SSLCert != "" && sic.SSLKey == "") || (sic.SSLCert == "" && sic.SSLKey != "") {
return fmt.Errorf("server '%s': both ssl_cert and ssl_key must be provided", sic.Name)
}
// If using AutoTLS, domains must be specified
if sic.AutoTLS && len(sic.AutoTLSDomains) == 0 {
return fmt.Errorf("server '%s': auto_tls_domains must be specified when auto_tls is enabled", sic.Name)
}
return nil
}
// Validate validates the ServersConfig
func (sc *ServersConfig) Validate() error {
if len(sc.Instances) == 0 {
return fmt.Errorf("at least one server instance must be configured")
}
if sc.DefaultServer != "" {
if _, ok := sc.Instances[sc.DefaultServer]; !ok {
return fmt.Errorf("default server '%s' not found in instances", sc.DefaultServer)
}
}
// Validate each instance
for name := range sc.Instances {
instance := sc.Instances[name]
if instance.Name != name {
return fmt.Errorf("server instance name mismatch: key='%s', name='%s'", name, instance.Name)
}
if err := instance.Validate(); err != nil {
return err
}
}
return nil
}
// GetDefault returns the default server instance configuration
func (sc *ServersConfig) GetDefault() (*ServerInstanceConfig, error) {
if sc.DefaultServer == "" {
return nil, fmt.Errorf("no default server configured")
}
instance, ok := sc.Instances[sc.DefaultServer]
if !ok {
return nil, fmt.Errorf("default server '%s' not found", sc.DefaultServer)
}
return &instance, nil
}

View File

@@ -3,6 +3,7 @@ package dbmanager
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
@@ -159,6 +160,9 @@ func (c *sqlConnection) Close() error {
// HealthCheck verifies the connection is alive
func (c *sqlConnection) HealthCheck(ctx context.Context) error {
if c == nil {
return fmt.Errorf("connection is nil")
}
c.mu.Lock()
defer c.mu.Unlock()
@@ -188,6 +192,9 @@ func (c *sqlConnection) Reconnect(ctx context.Context) error {
// Native returns the native *sql.DB connection
func (c *sqlConnection) Native() (*sql.DB, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.nativeDB != nil {
defer c.mu.RUnlock()
@@ -219,6 +226,9 @@ func (c *sqlConnection) Native() (*sql.DB, error) {
// Bun returns a Bun ORM instance wrapping the native connection
func (c *sqlConnection) Bun() (*bun.DB, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.bunDB != nil {
defer c.mu.RUnlock()
@@ -249,6 +259,9 @@ func (c *sqlConnection) Bun() (*bun.DB, error) {
// GORM returns a GORM instance wrapping the native connection
func (c *sqlConnection) GORM() (*gorm.DB, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.gormDB != nil {
defer c.mu.RUnlock()
@@ -283,6 +296,9 @@ func (c *sqlConnection) GORM() (*gorm.DB, error) {
// Database returns the common.Database interface using the configured default ORM
func (c *sqlConnection) Database() (common.Database, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
defaultORM := c.config.DefaultORM
c.mu.RUnlock()
@@ -307,6 +323,9 @@ func (c *sqlConnection) MongoDB() (*mongo.Client, error) {
// Stats returns connection statistics
func (c *sqlConnection) Stats() *ConnectionStats {
if c == nil {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
@@ -336,6 +355,9 @@ func (c *sqlConnection) Stats() *ConnectionStats {
// getBunAdapter returns or creates the Bun adapter
func (c *sqlConnection) getBunAdapter() (common.Database, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.bunAdapter != nil {
defer c.mu.RUnlock()
@@ -350,17 +372,28 @@ func (c *sqlConnection) getBunAdapter() (common.Database, error) {
return c.bunAdapter, nil
}
bunDB, err := c.Bun()
if err != nil {
return nil, err
// Double-check bunDB exists (while already holding write lock)
if c.bunDB == nil {
// Get native connection first
native, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get bun", err)
}
// Create Bun DB wrapping the same sql.DB
dialect := c.getBunDialect()
c.bunDB = bun.NewDB(native, dialect)
}
c.bunAdapter = database.NewBunAdapter(bunDB)
c.bunAdapter = database.NewBunAdapter(c.bunDB)
return c.bunAdapter, nil
}
// getGORMAdapter returns or creates the GORM adapter
func (c *sqlConnection) getGORMAdapter() (common.Database, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.gormAdapter != nil {
defer c.mu.RUnlock()
@@ -375,17 +408,33 @@ func (c *sqlConnection) getGORMAdapter() (common.Database, error) {
return c.gormAdapter, nil
}
gormDB, err := c.GORM()
if err != nil {
return nil, err
// Double-check gormDB exists (while already holding write lock)
if c.gormDB == nil {
// Get native connection first
native, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get gorm", err)
}
// Create GORM DB wrapping the same sql.DB
dialector := c.getGORMDialector(native)
db, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
return nil, NewConnectionError(c.name, "initialize gorm", err)
}
c.gormDB = db
}
c.gormAdapter = database.NewGormAdapter(gormDB)
c.gormAdapter = database.NewGormAdapter(c.gormDB)
return c.gormAdapter, nil
}
// getNativeAdapter returns or creates the native adapter
func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
if c == nil {
return nil, fmt.Errorf("connection is nil")
}
c.mu.RLock()
if c.nativeAdapter != nil {
defer c.mu.RUnlock()
@@ -400,21 +449,31 @@ func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
return c.nativeAdapter, nil
}
native, err := c.Native()
if err != nil {
return nil, err
// Double-check nativeDB exists (while already holding write lock)
if c.nativeDB == nil {
if !c.connected {
return nil, ErrConnectionClosed
}
// Get native connection from provider
db, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get native", err)
}
c.nativeDB = db
}
// Create a native adapter based on database type
switch c.dbType {
case DatabaseTypePostgreSQL:
c.nativeAdapter = database.NewPgSQLAdapter(native)
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
case DatabaseTypeSQLite:
// For SQLite, we'll use the PgSQL adapter as it works with standard sql.DB
c.nativeAdapter = database.NewPgSQLAdapter(native)
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
case DatabaseTypeMSSQL:
// For MSSQL, we'll use the PgSQL adapter as it works with standard sql.DB
c.nativeAdapter = database.NewPgSQLAdapter(native)
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
default:
return nil, ErrUnsupportedDatabase
}
@@ -424,6 +483,7 @@ func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
// getBunDialect returns the appropriate Bun dialect for the database type
func (c *sqlConnection) getBunDialect() schema.Dialect {
switch c.dbType {
case DatabaseTypePostgreSQL:
return database.GetPostgresDialect()

View File

@@ -207,9 +207,14 @@ func ExampleWithBun(bunDB *bun.DB) {
SetupMuxRoutes(muxRouter, handler, nil)
}
// BunRouterHandler is an interface that both bunrouter.Router and bunrouter.Group implement
type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc)
}
// SetupBunRouterRoutes sets up bunrouter routes for the ResolveSpec API
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
r := bunRouter.GetBunRouter()
// Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// CORS config
corsConfig := common.DefaultCORSConfig()
@@ -337,13 +342,13 @@ func ExampleWithBunRouter(bunDB *bun.DB) {
handler := NewHandlerWithBun(bunDB)
// Create bunrouter
bunRouter := router.NewStandardBunRouterAdapter()
bunRouter := bunrouter.New()
// Setup ResolveSpec routes with bunrouter
SetupBunRouterRoutes(bunRouter, handler)
// Start server
// http.ListenAndServe(":8080", bunRouter.GetBunRouter())
// http.ListenAndServe(":8080", bunRouter)
}
// ExampleBunRouterWithBunDB shows the full uptrace stack (bunrouter + Bun ORM)
@@ -359,11 +364,29 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
handler := NewHandler(dbAdapter, registry)
// Create bunrouter
bunRouter := router.NewStandardBunRouterAdapter()
bunRouter := bunrouter.New()
// Setup ResolveSpec routes
SetupBunRouterRoutes(bunRouter, handler)
// This gives you the full uptrace stack: bunrouter + Bun ORM
// http.ListenAndServe(":8080", bunRouter.GetBunRouter())
// http.ListenAndServe(":8080", bunRouter)
}
// ExampleBunRouterWithGroup shows how to use SetupBunRouterRoutes with a bunrouter.Group
func ExampleBunRouterWithGroup(bunDB *bun.DB) {
// Create handler with Bun adapter
handler := NewHandlerWithBun(bunDB)
// Create bunrouter
bunRouter := bunrouter.New()
// Create a route group with a prefix
apiGroup := bunRouter.NewGroup("/api")
// Setup ResolveSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler)
// Start server
// http.ListenAndServe(":8080", bunRouter)
}

View File

@@ -270,9 +270,14 @@ func ExampleWithBun(bunDB *bun.DB) {
SetupMuxRoutes(muxRouter, handler, nil)
}
// BunRouterHandler is an interface that both bunrouter.Router and bunrouter.Group implement
type BunRouterHandler interface {
Handle(method, path string, handler bunrouter.HandlerFunc)
}
// SetupBunRouterRoutes sets up bunrouter routes for the RestHeadSpec API
func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *Handler) {
r := bunRouter.GetBunRouter()
// Accepts bunrouter.Router or bunrouter.Group
func SetupBunRouterRoutes(r BunRouterHandler, handler *Handler) {
// CORS config
corsConfig := common.DefaultCORSConfig()
@@ -450,17 +455,34 @@ func ExampleBunRouterWithBunDB(bunDB *bun.DB) {
// Create handler
handler := NewHandlerWithBun(bunDB)
// Create BunRouter adapter
routerAdapter := NewStandardBunRouter()
// Create bunrouter
bunRouter := bunrouter.New()
// Setup routes
SetupBunRouterRoutes(routerAdapter, handler)
// Get the underlying router for server setup
r := routerAdapter.GetBunRouter()
SetupBunRouterRoutes(bunRouter, handler)
// Start server
if err := http.ListenAndServe(":8080", r); err != nil {
if err := http.ListenAndServe(":8080", bunRouter); err != nil {
logger.Error("Server failed to start: %v", err)
}
}
// ExampleBunRouterWithGroup shows how to use SetupBunRouterRoutes with a bunrouter.Group
func ExampleBunRouterWithGroup(bunDB *bun.DB) {
// Create handler with Bun adapter
handler := NewHandlerWithBun(bunDB)
// Create bunrouter
bunRouter := bunrouter.New()
// Create a route group with a prefix
apiGroup := bunRouter.NewGroup("/api")
// Setup RestHeadSpec routes on the group - routes will be under /api
SetupBunRouterRoutes(apiGroup, handler)
// Start server
if err := http.ListenAndServe(":8080", bunRouter); err != nil {
logger.Error("Server failed to start: %v", err)
}
}

View File

@@ -0,0 +1,47 @@
package server
import (
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
// FromConfigInstanceToServerConfig converts a config.ServerInstanceConfig to server.Config
// The handler must be provided separately as it cannot be serialized
func FromConfigInstanceToServerConfig(sic *config.ServerInstanceConfig, handler http.Handler) Config {
cfg := Config{
Name: sic.Name,
Host: sic.Host,
Port: sic.Port,
Description: sic.Description,
Handler: handler,
GZIP: sic.GZIP,
SSLCert: sic.SSLCert,
SSLKey: sic.SSLKey,
SelfSignedSSL: sic.SelfSignedSSL,
AutoTLS: sic.AutoTLS,
AutoTLSDomains: sic.AutoTLSDomains,
AutoTLSCacheDir: sic.AutoTLSCacheDir,
AutoTLSEmail: sic.AutoTLSEmail,
}
// Apply timeouts (use pointers to override, or use zero values for defaults)
if sic.ShutdownTimeout != nil {
cfg.ShutdownTimeout = *sic.ShutdownTimeout
}
if sic.DrainTimeout != nil {
cfg.DrainTimeout = *sic.DrainTimeout
}
if sic.ReadTimeout != nil {
cfg.ReadTimeout = *sic.ReadTimeout
}
if sic.WriteTimeout != nil {
cfg.WriteTimeout = *sic.WriteTimeout
}
if sic.IdleTimeout != nil {
cfg.IdleTimeout = *sic.IdleTimeout
}
return cfg
}

View File

@@ -501,7 +501,7 @@ func (s *serverInstance) Start() error {
if useTLS {
protocol = "HTTPS"
logger.Info("Starting %s server '%s' on %s", protocol, s.cfg.Name, s.Addr())
logger.Info("Starting %s server - Name: '%s', Address: %s, Port: %d", protocol, s.cfg.Name, s.cfg.Host, s.cfg.Port)
// For AutoTLS, we need to use a TLS listener
if s.cfg.AutoTLS {
@@ -519,7 +519,7 @@ func (s *serverInstance) Start() error {
err = s.gracefulServer.server.ListenAndServeTLS(s.certFile, s.keyFile)
}
} else {
logger.Info("Starting %s server '%s' on %s", protocol, s.cfg.Name, s.Addr())
logger.Info("Starting %s server - Name: '%s', Address: %s, Port: %d", protocol, s.cfg.Name, s.cfg.Host, s.cfg.Port)
err = s.gracefulServer.server.ListenAndServe()
}

View File

@@ -3,6 +3,7 @@ package staticweb
import (
"io/fs"
"net/http"
"reflect"
)
// FileSystemProvider abstracts the source of files (local, zip, embedded, future: http, s3)
@@ -34,6 +35,32 @@ type ReloadableProvider interface {
Reload() error
}
// PrefixStrippingProvider is an optional interface that providers can implement
// to support stripping path prefixes from requested paths.
// This is useful when files are stored in a subdirectory but should be accessible
// at the root level (e.g., files at "/dist/assets" accessible via "/assets").
type PrefixStrippingProvider interface {
// WithStripPrefix sets the prefix to strip from requested paths.
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
// accessible via "/assets".
WithStripPrefix(prefix string)
// StripPrefix returns the configured strip prefix.
StripPrefix() string
}
// WithStripPrefix is a helper function that sets the strip prefix on a provider
// if it implements PrefixStrippingProvider. Returns the provider for method chaining.
func WithStripPrefix(provider FileSystemProvider, prefix string) FileSystemProvider {
if provider == nil || reflect.ValueOf(provider).IsNil() {
return provider
}
if p, ok := provider.(PrefixStrippingProvider); ok && p != nil {
p.WithStripPrefix(prefix)
}
return provider
}
// CachePolicy defines how files should be cached by browsers and proxies.
// Implementations must be safe for concurrent use.
type CachePolicy interface {

View File

@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"io/fs"
"path"
"strings"
"sync"
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
@@ -15,16 +17,18 @@ import (
// EmbedFSProvider serves files from an embedded filesystem.
// It supports both direct embedded directories and embedded zip files.
type EmbedFSProvider struct {
embedFS *embed.FS
zipFile string // Optional: path within embedded FS to zip file
zipReader *zip.Reader
fs fs.FS
mu sync.RWMutex
embedFS *embed.FS
zipFile string // Optional: path within embedded FS to zip file
stripPrefix string // Optional: prefix to strip from requested paths (e.g., "/dist")
zipReader *zip.Reader
fs fs.FS
mu sync.RWMutex
}
// NewEmbedFSProvider creates a new EmbedFSProvider.
// If zipFile is empty, the embedded FS is used directly.
// If zipFile is specified, it's treated as a path to a zip file within the embedded FS.
// Use WithStripPrefix to configure path prefix stripping.
func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error) {
if embedFS == nil {
return nil, fmt.Errorf("embedded filesystem cannot be nil")
@@ -81,6 +85,9 @@ func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error)
}
// Open opens the named file from the embedded filesystem.
// If a strip prefix is configured, it prepends the prefix to the requested path.
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
// open "/dist/assets/style.css" from the embedded filesystem.
func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
p.mu.RLock()
defer p.mu.RUnlock()
@@ -89,7 +96,21 @@ func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
return nil, fmt.Errorf("embedded filesystem is closed")
}
return p.fs.Open(name)
// Apply prefix stripping by prepending the prefix to the requested path
actualPath := name
if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/")
cleanName := strings.TrimPrefix(name, "/")
if prefix != "" {
actualPath = path.Join(prefix, cleanName)
} else {
actualPath = cleanName
}
}
return p.fs.Open(actualPath)
}
// Close releases any resources held by the provider.
@@ -117,3 +138,19 @@ func (p *EmbedFSProvider) Type() string {
func (p *EmbedFSProvider) ZipFile() string {
return p.zipFile
}
// WithStripPrefix sets the prefix to strip from requested paths.
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
// accessible via "/assets".
func (p *EmbedFSProvider) WithStripPrefix(prefix string) {
p.mu.Lock()
defer p.mu.Unlock()
p.stripPrefix = prefix
}
// StripPrefix returns the configured strip prefix.
func (p *EmbedFSProvider) StripPrefix() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stripPrefix
}

View File

@@ -4,13 +4,18 @@ import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync"
)
// LocalFSProvider serves files from a local directory.
type LocalFSProvider struct {
path string
fs fs.FS
path string
stripPrefix string
fs fs.FS
mu sync.RWMutex
}
// NewLocalFSProvider creates a new LocalFSProvider for the given directory path.
@@ -39,8 +44,28 @@ func NewLocalFSProvider(path string) (*LocalFSProvider, error) {
}
// Open opens the named file from the local directory.
// If a strip prefix is configured, it prepends the prefix to the requested path.
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
// open "/dist/assets/style.css" from the local filesystem.
func (p *LocalFSProvider) Open(name string) (fs.File, error) {
return p.fs.Open(name)
p.mu.RLock()
defer p.mu.RUnlock()
// Apply prefix stripping by prepending the prefix to the requested path
actualPath := name
if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/")
cleanName := strings.TrimPrefix(name, "/")
if prefix != "" {
actualPath = path.Join(prefix, cleanName)
} else {
actualPath = cleanName
}
}
return p.fs.Open(actualPath)
}
// Close releases any resources held by the provider.
@@ -63,6 +88,9 @@ func (p *LocalFSProvider) Path() string {
// For local directories, os.DirFS automatically picks up changes,
// so this recreates the DirFS to ensure a fresh view.
func (p *LocalFSProvider) Reload() error {
p.mu.Lock()
defer p.mu.Unlock()
// Verify the directory still exists
info, err := os.Stat(p.path)
if err != nil {
@@ -78,3 +106,19 @@ func (p *LocalFSProvider) Reload() error {
return nil
}
// WithStripPrefix sets the prefix to strip from requested paths.
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
// accessible via "/assets".
func (p *LocalFSProvider) WithStripPrefix(prefix string) {
p.mu.Lock()
defer p.mu.Unlock()
p.stripPrefix = prefix
}
// StripPrefix returns the configured strip prefix.
func (p *LocalFSProvider) StripPrefix() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stripPrefix
}

View File

@@ -4,7 +4,9 @@ import (
"archive/zip"
"fmt"
"io/fs"
"path"
"path/filepath"
"strings"
"sync"
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
@@ -12,10 +14,11 @@ import (
// ZipFSProvider serves files from a zip file.
type ZipFSProvider struct {
zipPath string
zipReader *zip.ReadCloser
zipFS *zipfs.ZipFS
mu sync.RWMutex
zipPath string
stripPrefix string
zipReader *zip.ReadCloser
zipFS *zipfs.ZipFS
mu sync.RWMutex
}
// NewZipFSProvider creates a new ZipFSProvider for the given zip file path.
@@ -40,6 +43,9 @@ func NewZipFSProvider(zipPath string) (*ZipFSProvider, error) {
}
// Open opens the named file from the zip archive.
// If a strip prefix is configured, it prepends the prefix to the requested path.
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
// open "/dist/assets/style.css" from the zip filesystem.
func (p *ZipFSProvider) Open(name string) (fs.File, error) {
p.mu.RLock()
defer p.mu.RUnlock()
@@ -48,7 +54,21 @@ func (p *ZipFSProvider) Open(name string) (fs.File, error) {
return nil, fmt.Errorf("zip filesystem is closed")
}
return p.zipFS.Open(name)
// Apply prefix stripping by prepending the prefix to the requested path
actualPath := name
if p.stripPrefix != "" {
// Clean the paths to handle leading/trailing slashes
prefix := strings.Trim(p.stripPrefix, "/")
cleanName := strings.TrimPrefix(name, "/")
if prefix != "" {
actualPath = path.Join(prefix, cleanName)
} else {
actualPath = cleanName
}
}
return p.zipFS.Open(actualPath)
}
// Close releases resources held by the zip reader.
@@ -100,3 +120,19 @@ func (p *ZipFSProvider) Reload() error {
return nil
}
// WithStripPrefix sets the prefix to strip from requested paths.
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
// accessible via "/assets".
func (p *ZipFSProvider) WithStripPrefix(prefix string) {
p.mu.Lock()
defer p.mu.Unlock()
p.stripPrefix = prefix
}
// StripPrefix returns the configured strip prefix.
func (p *ZipFSProvider) StripPrefix() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stripPrefix
}