From 1baa0af0ac1236a1394bca46882938fc69a7df00 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 9 Dec 2025 09:19:56 +0200 Subject: [PATCH] Config Package --- .env.example | 52 +++++++ cmd/testserver/main.go | 65 +++++++-- config.yaml | 41 ++++++ config.yaml.example | 57 ++++++++ go.mod | 11 ++ go.sum | 22 +++ pkg/config/README.md | 291 +++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 80 ++++++++++ pkg/config/manager.go | 168 +++++++++++++++++++++ pkg/config/manager_test.go | 166 +++++++++++++++++++++ pkg/resolvespec/handler.go | 6 +- todo.md | 148 +------------------ 12 files changed, 944 insertions(+), 163 deletions(-) create mode 100644 .env.example create mode 100644 config.yaml create mode 100644 config.yaml.example create mode 100644 pkg/config/README.md create mode 100644 pkg/config/config.go create mode 100644 pkg/config/manager.go create mode 100644 pkg/config/manager_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8dc814c --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ResolveSpec Environment Variables Example +# Environment variables override config file settings +# All variables are prefixed with RESOLVESPEC_ +# Nested config uses underscores (e.g., server.addr -> RESOLVESPEC_SERVER_ADDR) + +# Server Configuration +RESOLVESPEC_SERVER_ADDR=:8080 +RESOLVESPEC_SERVER_SHUTDOWN_TIMEOUT=30s +RESOLVESPEC_SERVER_DRAIN_TIMEOUT=25s +RESOLVESPEC_SERVER_READ_TIMEOUT=10s +RESOLVESPEC_SERVER_WRITE_TIMEOUT=10s +RESOLVESPEC_SERVER_IDLE_TIMEOUT=120s + +# Tracing Configuration +RESOLVESPEC_TRACING_ENABLED=false +RESOLVESPEC_TRACING_SERVICE_NAME=resolvespec +RESOLVESPEC_TRACING_SERVICE_VERSION=1.0.0 +RESOLVESPEC_TRACING_ENDPOINT=http://localhost:4318/v1/traces + +# Cache Configuration +RESOLVESPEC_CACHE_PROVIDER=memory + +# Redis Cache (when provider=redis) +RESOLVESPEC_CACHE_REDIS_HOST=localhost +RESOLVESPEC_CACHE_REDIS_PORT=6379 +RESOLVESPEC_CACHE_REDIS_PASSWORD= +RESOLVESPEC_CACHE_REDIS_DB=0 + +# Memcache (when provider=memcache) +# Note: For arrays, separate values with commas +RESOLVESPEC_CACHE_MEMCACHE_SERVERS=localhost:11211 +RESOLVESPEC_CACHE_MEMCACHE_MAX_IDLE_CONNS=10 +RESOLVESPEC_CACHE_MEMCACHE_TIMEOUT=100ms + +# Logger Configuration +RESOLVESPEC_LOGGER_DEV=false +RESOLVESPEC_LOGGER_PATH= + +# Middleware Configuration +RESOLVESPEC_MIDDLEWARE_RATE_LIMIT_RPS=100.0 +RESOLVESPEC_MIDDLEWARE_RATE_LIMIT_BURST=200 +RESOLVESPEC_MIDDLEWARE_MAX_REQUEST_SIZE=10485760 + +# CORS Configuration +# Note: For arrays in env vars, separate with commas +RESOLVESPEC_CORS_ALLOWED_ORIGINS=* +RESOLVESPEC_CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS +RESOLVESPEC_CORS_ALLOWED_HEADERS=* +RESOLVESPEC_CORS_MAX_AGE=3600 + +# Database Configuration +RESOLVESPEC_DATABASE_URL=host=localhost user=postgres password=postgres dbname=resolvespec_test port=5434 sslmode=disable diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go index c753c91..02574b3 100644 --- a/cmd/testserver/main.go +++ b/cmd/testserver/main.go @@ -6,8 +6,10 @@ import ( "os" "time" + "github.com/bitechdev/ResolveSpec/pkg/config" "github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/modelregistry" + "github.com/bitechdev/ResolveSpec/pkg/server" "github.com/bitechdev/ResolveSpec/pkg/testmodels" "github.com/bitechdev/ResolveSpec/pkg/resolvespec" @@ -19,12 +21,27 @@ import ( ) func main() { - // Initialize logger - logger.Init(true) + // Load configuration + cfgMgr := config.NewManager() + if err := cfgMgr.Load(); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + cfg, err := cfgMgr.GetConfig() + if err != nil { + log.Fatalf("Failed to get configuration: %v", err) + } + + // Initialize logger with configuration + logger.Init(cfg.Logger.Dev) + if cfg.Logger.Path != "" { + 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 - db, err := initDB() + db, err := initDB(cfg) if err != nil { logger.Error("Failed to initialize database: %+v", err) os.Exit(1) @@ -50,29 +67,51 @@ func main() { // Setup routes using new SetupMuxRoutes function (without authentication) resolvespec.SetupMuxRoutes(r, handler, nil) - // Start server - logger.Info("Starting server on :8080") - if err := http.ListenAndServe(":8080", r); err != nil { + // Create graceful server with configuration + srv := server.NewGracefulServer(server.Config{ + Addr: cfg.Server.Addr, + Handler: r, + ShutdownTimeout: cfg.Server.ShutdownTimeout, + DrainTimeout: cfg.Server.DrainTimeout, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + }) + + // Start server with graceful shutdown + logger.Info("Starting server on %s", cfg.Server.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Error("Server failed to start: %v", err) os.Exit(1) } } -func initDB() (*gorm.DB, error) { +func initDB(cfg *config.Config) (*gorm.DB, error) { + // Configure GORM logger based on config + logLevel := gormlog.Info + if !cfg.Logger.Dev { + logLevel = gormlog.Warn + } newLogger := gormlog.New( log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer gormlog.Config{ - SlowThreshold: time.Second, // Slow SQL threshold - LogLevel: gormlog.Info, // Log level - IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger - ParameterizedQueries: true, // Don't include params in the SQL log - Colorful: true, // Disable color + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: logLevel, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + ParameterizedQueries: true, // Don't include params in the SQL log + Colorful: cfg.Logger.Dev, }, ) + // Use database URL from config if available, otherwise use default SQLite + dbURL := cfg.Database.URL + if dbURL == "" { + dbURL = "test.db" + } + // Create SQLite database - db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{Logger: newLogger, FullSaveAssociations: false}) + db, err := gorm.Open(sqlite.Open(dbURL), &gorm.Config{Logger: newLogger, FullSaveAssociations: false}) if err != nil { return nil, err } diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..892d822 --- /dev/null +++ b/config.yaml @@ -0,0 +1,41 @@ +# ResolveSpec Test Server Configuration +# This is a minimal configuration for the test server + +server: + addr: ":8080" + shutdown_timeout: 30s + drain_timeout: 25s + read_timeout: 10s + write_timeout: 10s + idle_timeout: 120s + +logger: + dev: true # Enable development mode for readable logs + path: "" # Empty means log to stdout + +cache: + provider: "memory" + +middleware: + rate_limit_rps: 100.0 + rate_limit_burst: 200 + max_request_size: 10485760 # 10MB + +cors: + allowed_origins: + - "*" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "*" + max_age: 3600 + +tracing: + enabled: false + +database: + url: "" # Empty means use default SQLite (test.db) diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..b6abeaa --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,57 @@ +# ResolveSpec Configuration Example +# This file demonstrates all available configuration options +# Copy this file to config.yaml and customize as needed + +server: + addr: ":8080" + shutdown_timeout: 30s + drain_timeout: 25s + read_timeout: 10s + write_timeout: 10s + idle_timeout: 120s + +tracing: + enabled: false + service_name: "resolvespec" + service_version: "1.0.0" + endpoint: "http://localhost:4318/v1/traces" # OTLP endpoint + +cache: + provider: "memory" # Options: memory, redis, memcache + + redis: + host: "localhost" + port: 6379 + password: "" + db: 0 + + memcache: + servers: + - "localhost:11211" + max_idle_conns: 10 + timeout: 100ms + +logger: + dev: false + path: "" # Empty for stdout, or specify file path + +middleware: + rate_limit_rps: 100.0 + rate_limit_burst: 200 + max_request_size: 10485760 # 10MB in bytes + +cors: + allowed_origins: + - "*" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "*" + max_age: 3600 + +database: + url: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5434 sslmode=disable" diff --git a/go.mod b/go.mod index b0bfd99..f8ed31c 100644 --- a/go.mod +++ b/go.mod @@ -36,9 +36,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -50,12 +52,20 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect @@ -66,6 +76,7 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/net v0.43.0 // indirect diff --git a/go.sum b/go.sum index bff5082..85dddca 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -26,6 +28,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -66,6 +70,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -84,11 +90,25 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -138,6 +158,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= diff --git a/pkg/config/README.md b/pkg/config/README.md new file mode 100644 index 0000000..05f2fc1 --- /dev/null +++ b/pkg/config/README.md @@ -0,0 +1,291 @@ +# ResolveSpec Configuration System + +A centralized configuration system with support for multiple configuration sources: config files (YAML, TOML, JSON), environment variables, and programmatic configuration. + +## Features + +- **Multiple Config Sources**: Config files, environment variables, and code +- **Priority Order**: Environment variables > Config file > Defaults +- **Multiple Formats**: YAML, TOML, JSON supported +- **Type Safety**: Strongly-typed configuration structs +- **Sensible Defaults**: Works out of the box with reasonable defaults + +## Quick Start + +### Basic Usage + +```go +import "github.com/heinhel/ResolveSpec/pkg/config" + +// Create a new config manager +mgr := config.NewManager() + +// Load configuration from file and environment +if err := mgr.Load(); err != nil { + log.Fatal(err) +} + +// Get the complete configuration +cfg, err := mgr.GetConfig() +if err != nil { + log.Fatal(err) +} + +// Use the configuration +fmt.Println("Server address:", cfg.Server.Addr) +``` + +### Custom Configuration Paths + +```go +mgr := config.NewManagerWithOptions( + config.WithConfigFile("/path/to/config.yaml"), + config.WithEnvPrefix("MYAPP"), +) +``` + +## Configuration Sources + +### 1. Config Files + +Place a `config.yaml` file in one of these locations: +- Current directory (`.`) +- `./config/` +- `/etc/resolvespec/` +- `$HOME/.resolvespec/` + +Example `config.yaml`: + +```yaml +server: + addr: ":8080" + shutdown_timeout: 30s + +tracing: + enabled: true + service_name: "my-service" + +cache: + provider: "redis" + redis: + host: "localhost" + port: 6379 +``` + +### 2. Environment Variables + +All configuration can be set via environment variables with the `RESOLVESPEC_` prefix: + +```bash +export RESOLVESPEC_SERVER_ADDR=":9090" +export RESOLVESPEC_TRACING_ENABLED=true +export RESOLVESPEC_CACHE_PROVIDER=redis +export RESOLVESPEC_CACHE_REDIS_HOST=localhost +``` + +Nested configuration uses underscores: +- `server.addr` → `RESOLVESPEC_SERVER_ADDR` +- `cache.redis.host` → `RESOLVESPEC_CACHE_REDIS_HOST` + +### 3. Programmatic Configuration + +```go +mgr := config.NewManager() +mgr.Set("server.addr", ":9090") +mgr.Set("tracing.enabled", true) + +cfg, _ := mgr.GetConfig() +``` + +## Configuration Options + +### Server Configuration + +```yaml +server: + addr: ":8080" # Server address + shutdown_timeout: 30s # Graceful shutdown timeout + drain_timeout: 25s # Connection drain timeout + read_timeout: 10s # HTTP read timeout + write_timeout: 10s # HTTP write timeout + idle_timeout: 120s # HTTP idle timeout +``` + +### Tracing Configuration + +```yaml +tracing: + enabled: false # Enable/disable tracing + service_name: "resolvespec" # Service name + service_version: "1.0.0" # Service version + endpoint: "http://localhost:4318/v1/traces" # OTLP endpoint +``` + +### Cache Configuration + +```yaml +cache: + provider: "memory" # Options: memory, redis, memcache + + redis: + host: "localhost" + port: 6379 + password: "" + db: 0 + + memcache: + servers: + - "localhost:11211" + max_idle_conns: 10 + timeout: 100ms +``` + +### Logger Configuration + +```yaml +logger: + dev: false # Development mode (human-readable output) + path: "" # Log file path (empty = stdout) +``` + +### Middleware Configuration + +```yaml +middleware: + rate_limit_rps: 100.0 # Requests per second + rate_limit_burst: 200 # Burst size + max_request_size: 10485760 # Max request size in bytes (10MB) +``` + +### CORS Configuration + +```yaml +cors: + allowed_origins: + - "*" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "*" + max_age: 3600 +``` + +### Database Configuration + +```yaml +database: + url: "host=localhost user=postgres password=postgres dbname=mydb port=5432 sslmode=disable" +``` + +## Priority and Overrides + +Configuration sources are applied in this order (highest priority first): + +1. **Environment Variables** (highest priority) +2. **Config File** +3. **Defaults** (lowest priority) + +This allows you to: +- Set defaults in code +- Override with a config file +- Override specific values with environment variables + +## Examples + +### Production Setup + +```yaml +# config.yaml +server: + addr: ":8080" + +tracing: + enabled: true + service_name: "myapi" + endpoint: "http://jaeger:4318/v1/traces" + +cache: + provider: "redis" + redis: + host: "redis" + port: 6379 + password: "${REDIS_PASSWORD}" + +logger: + dev: false + path: "/var/log/myapi/app.log" +``` + +### Development Setup + +```bash +# Use environment variables for development +export RESOLVESPEC_LOGGER_DEV=true +export RESOLVESPEC_TRACING_ENABLED=false +export RESOLVESPEC_CACHE_PROVIDER=memory +``` + +### Testing Setup + +```go +// Override config for tests +mgr := config.NewManager() +mgr.Set("cache.provider", "memory") +mgr.Set("database.url", testDBURL) + +cfg, _ := mgr.GetConfig() +``` + +## Best Practices + +1. **Use config files for base configuration** - Define your standard settings +2. **Use environment variables for secrets** - Never commit passwords/tokens +3. **Use environment variables for deployment-specific values** - Different per environment +4. **Keep defaults sensible** - Application should work with minimal configuration +5. **Document your configuration** - Comment your config.yaml files + +## Integration with ResolveSpec Components + +The configuration system integrates seamlessly with ResolveSpec components: + +```go +cfg, _ := config.NewManager().Load().GetConfig() + +// Server +srv := server.NewGracefulServer(server.Config{ + Addr: cfg.Server.Addr, + ShutdownTimeout: cfg.Server.ShutdownTimeout, + // ... other fields +}) + +// Tracing +if cfg.Tracing.Enabled { + tracer := tracing.Init(tracing.Config{ + ServiceName: cfg.Tracing.ServiceName, + ServiceVersion: cfg.Tracing.ServiceVersion, + Endpoint: cfg.Tracing.Endpoint, + }) + defer tracer.Shutdown(context.Background()) +} + +// Cache +var cacheProvider cache.Provider +switch cfg.Cache.Provider { +case "redis": + cacheProvider = cache.NewRedisProvider(cfg.Cache.Redis.Host, cfg.Cache.Redis.Port, ...) +case "memcache": + cacheProvider = cache.NewMemcacheProvider(cfg.Cache.Memcache.Servers, ...) +default: + cacheProvider = cache.NewMemoryProvider() +} + +// Logger +logger.Init(cfg.Logger.Dev) +if cfg.Logger.Path != "" { + logger.UpdateLoggerPath(cfg.Logger.Path, cfg.Logger.Dev) +} +``` diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..684833d --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,80 @@ +package config + +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"` + Middleware MiddlewareConfig `mapstructure:"middleware"` + CORS CORSConfig `mapstructure:"cors"` + Database DatabaseConfig `mapstructure:"database"` +} + +// ServerConfig holds server-related configuration +type ServerConfig struct { + Addr string `mapstructure:"addr"` + 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"` +} + +// TracingConfig holds OpenTelemetry tracing configuration +type TracingConfig struct { + Enabled bool `mapstructure:"enabled"` + ServiceName string `mapstructure:"service_name"` + ServiceVersion string `mapstructure:"service_version"` + Endpoint string `mapstructure:"endpoint"` +} + +// CacheConfig holds cache provider configuration +type CacheConfig struct { + Provider string `mapstructure:"provider"` // memory, redis, memcache + Redis RedisConfig `mapstructure:"redis"` + Memcache MemcacheConfig `mapstructure:"memcache"` +} + +// RedisConfig holds Redis-specific configuration +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +// MemcacheConfig holds Memcache-specific configuration +type MemcacheConfig struct { + Servers []string `mapstructure:"servers"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + Timeout time.Duration `mapstructure:"timeout"` +} + +// LoggerConfig holds logger configuration +type LoggerConfig struct { + Dev bool `mapstructure:"dev"` + Path string `mapstructure:"path"` +} + +// MiddlewareConfig holds middleware configuration +type MiddlewareConfig struct { + RateLimitRPS float64 `mapstructure:"rate_limit_rps"` + RateLimitBurst int `mapstructure:"rate_limit_burst"` + MaxRequestSize int64 `mapstructure:"max_request_size"` +} + +// CORSConfig holds CORS configuration +type CORSConfig struct { + AllowedOrigins []string `mapstructure:"allowed_origins"` + AllowedMethods []string `mapstructure:"allowed_methods"` + AllowedHeaders []string `mapstructure:"allowed_headers"` + MaxAge int `mapstructure:"max_age"` +} + +// DatabaseConfig holds database configuration (primarily for testing) +type DatabaseConfig struct { + URL string `mapstructure:"url"` +} diff --git a/pkg/config/manager.go b/pkg/config/manager.go new file mode 100644 index 0000000..45b15d9 --- /dev/null +++ b/pkg/config/manager.go @@ -0,0 +1,168 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +// Manager handles configuration loading from multiple sources +type Manager struct { + v *viper.Viper +} + +// NewManager creates a new configuration manager with defaults +func NewManager() *Manager { + v := viper.New() + + // Set configuration file settings + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("./config") + v.AddConfigPath("/etc/resolvespec") + v.AddConfigPath("$HOME/.resolvespec") + + // Enable environment variable support + v.SetEnvPrefix("RESOLVESPEC") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Set default values + setDefaults(v) + + return &Manager{v: v} +} + +// NewManagerWithOptions creates a new configuration manager with custom options +func NewManagerWithOptions(opts ...Option) *Manager { + m := NewManager() + for _, opt := range opts { + opt(m) + } + return m +} + +// Option is a functional option for configuring the Manager +type Option func(*Manager) + +// WithConfigFile sets a specific config file path +func WithConfigFile(path string) Option { + return func(m *Manager) { + m.v.SetConfigFile(path) + } +} + +// WithConfigName sets the config file name (without extension) +func WithConfigName(name string) Option { + return func(m *Manager) { + m.v.SetConfigName(name) + } +} + +// WithConfigPath adds a path to search for config files +func WithConfigPath(path string) Option { + return func(m *Manager) { + m.v.AddConfigPath(path) + } +} + +// WithEnvPrefix sets the environment variable prefix +func WithEnvPrefix(prefix string) Option { + return func(m *Manager) { + m.v.SetEnvPrefix(prefix) + } +} + +// Load attempts to load configuration from file and environment +func (m *Manager) Load() error { + // Try to read config file (not an error if it doesn't exist) + if err := m.v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("error reading config file: %w", err) + } + // Config file not found; will rely on defaults and env vars + } + + return nil +} + +// GetConfig returns the complete configuration +func (m *Manager) GetConfig() (*Config, error) { + var cfg Config + if err := m.v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + return &cfg, nil +} + +// Get returns a configuration value by key +func (m *Manager) Get(key string) interface{} { + return m.v.Get(key) +} + +// GetString returns a string configuration value +func (m *Manager) GetString(key string) string { + return m.v.GetString(key) +} + +// GetInt returns an int configuration value +func (m *Manager) GetInt(key string) int { + return m.v.GetInt(key) +} + +// GetBool returns a bool configuration value +func (m *Manager) GetBool(key string) bool { + return m.v.GetBool(key) +} + +// Set sets a configuration value +func (m *Manager) Set(key string, value interface{}) { + m.v.Set(key, value) +} + +// 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") + + // Tracing defaults + v.SetDefault("tracing.enabled", false) + v.SetDefault("tracing.service_name", "resolvespec") + v.SetDefault("tracing.service_version", "1.0.0") + v.SetDefault("tracing.endpoint", "") + + // Cache defaults + v.SetDefault("cache.provider", "memory") + v.SetDefault("cache.redis.host", "localhost") + v.SetDefault("cache.redis.port", 6379) + v.SetDefault("cache.redis.password", "") + v.SetDefault("cache.redis.db", 0) + v.SetDefault("cache.memcache.servers", []string{"localhost:11211"}) + v.SetDefault("cache.memcache.max_idle_conns", 10) + v.SetDefault("cache.memcache.timeout", "100ms") + + // Logger defaults + v.SetDefault("logger.dev", false) + v.SetDefault("logger.path", "") + + // Middleware defaults + v.SetDefault("middleware.rate_limit_rps", 100.0) + v.SetDefault("middleware.rate_limit_burst", 200) + v.SetDefault("middleware.max_request_size", 10485760) // 10MB + + // CORS defaults + v.SetDefault("cors.allowed_origins", []string{"*"}) + v.SetDefault("cors.allowed_methods", []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}) + v.SetDefault("cors.allowed_headers", []string{"*"}) + v.SetDefault("cors.max_age", 3600) + + // Database defaults + v.SetDefault("database.url", "") +} diff --git a/pkg/config/manager_test.go b/pkg/config/manager_test.go new file mode 100644 index 0000000..ec83378 --- /dev/null +++ b/pkg/config/manager_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestNewManager(t *testing.T) { + mgr := NewManager() + if mgr == nil { + t.Fatal("Expected manager to be non-nil") + } + + if mgr.v == nil { + t.Fatal("Expected viper instance to be non-nil") + } +} + +func TestDefaultValues(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 values + tests := []struct { + name string + got interface{} + expected interface{} + }{ + {"server.addr", cfg.Server.Addr, ":8080"}, + {"server.shutdown_timeout", cfg.Server.ShutdownTimeout, 30 * time.Second}, + {"tracing.enabled", cfg.Tracing.Enabled, false}, + {"tracing.service_name", cfg.Tracing.ServiceName, "resolvespec"}, + {"cache.provider", cfg.Cache.Provider, "memory"}, + {"cache.redis.host", cfg.Cache.Redis.Host, "localhost"}, + {"cache.redis.port", cfg.Cache.Redis.Port, 6379}, + {"logger.dev", cfg.Logger.Dev, false}, + {"middleware.rate_limit_rps", cfg.Middleware.RateLimitRPS, 100.0}, + {"middleware.rate_limit_burst", cfg.Middleware.RateLimitBurst, 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.expected { + t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.expected) + } + }) + } +} + +func TestEnvironmentVariableOverrides(t *testing.T) { + // Set environment variables + os.Setenv("RESOLVESPEC_SERVER_ADDR", ":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_TRACING_ENABLED") + os.Unsetenv("RESOLVESPEC_CACHE_PROVIDER") + os.Unsetenv("RESOLVESPEC_LOGGER_DEV") + }() + + 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 overrides + tests := []struct { + name string + 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}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.expected { + t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.expected) + } + }) + } +} + +func TestProgrammaticConfiguration(t *testing.T) { + mgr := NewManager() + mgr.Set("server.addr", ":7070") + mgr.Set("tracing.service_name", "test-service") + + cfg, err := mgr.GetConfig() + if err != nil { + 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.Tracing.ServiceName != "test-service" { + t.Errorf("tracing.service_name: got %s, want test-service", cfg.Tracing.ServiceName) + } +} + +func TestGetterMethods(t *testing.T) { + mgr := NewManager() + mgr.Set("test.string", "value") + mgr.Set("test.int", 42) + mgr.Set("test.bool", true) + + if got := mgr.GetString("test.string"); got != "value" { + t.Errorf("GetString: got %s, want value", got) + } + + if got := mgr.GetInt("test.int"); got != 42 { + t.Errorf("GetInt: got %d, want 42", got) + } + + if got := mgr.GetBool("test.bool"); !got { + t.Errorf("GetBool: got %v, want true", got) + } +} + +func TestWithOptions(t *testing.T) { + mgr := NewManagerWithOptions( + WithEnvPrefix("MYAPP"), + WithConfigName("myconfig"), + ) + + if mgr == nil { + t.Fatal("Expected manager to be non-nil") + } + + // Set environment variable with custom prefix + os.Setenv("MYAPP_SERVER_ADDR", ":5000") + defer os.Unsetenv("MYAPP_SERVER_ADDR") + + 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) + } + + if cfg.Server.Addr != ":5000" { + t.Errorf("server.addr: got %s, want :5000", cfg.Server.Addr) + } +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index c1d3b2f..e38d362 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -321,9 +321,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st tableName, options.Filters, options.Sort, - "", // No custom SQL WHERE in resolvespec - "", // No custom SQL OR in resolvespec - nil, // No expand options in resolvespec + "", // No custom SQL WHERE in resolvespec + "", // No custom SQL OR in resolvespec + nil, // No expand options in resolvespec false, // distinct not used here options.CursorForward, options.CursorBackward, diff --git a/todo.md b/todo.md index 7426404..2a5964e 100644 --- a/todo.md +++ b/todo.md @@ -2,143 +2,11 @@ This document tracks incomplete features and improvements for the ResolveSpec project. -## Core Features to Implement - -### 1. Column Selection and Filtering for Preloads -**Location:** `pkg/resolvespec/handler.go:730` -**Status:** Not Implemented -**Description:** Currently, preloads are applied without any column selection or filtering. This feature would allow clients to: -- Select specific columns for preloaded relationships -- Apply filters to preloaded data -- Reduce payload size and improve performance - -**Current Limitation:** -```go -// For now, we'll preload without conditions -// TODO: Implement column selection and filtering for preloads -// This requires a more sophisticated approach with callbacks or query builders -query = query.Preload(relationFieldName) -``` - -**Required Implementation:** -- Add support for column selection in preloaded relationships -- Implement filtering conditions for preloaded data -- Design a callback or query builder approach that works across different ORMs - ---- - -### 2. Recursive JSON Cleaning -**Location:** `pkg/restheadspec/handler.go:796` -**Status:** Partially Implemented (Simplified) -**Description:** The current `cleanJSON` function returns data as-is without recursively removing null and empty fields from nested structures. - -**Current Limitation:** -```go -// This is a simplified implementation -// A full implementation would recursively clean nested structures -// For now, we'll return the data as-is -// TODO: Implement recursive cleaning -return data -``` - -**Required Implementation:** -- Recursively traverse nested structures (maps, slices, structs) -- Remove null values -- Remove empty objects and arrays -- Handle edge cases (circular references, pointers, etc.) - ---- - -### 3. Custom SQL Join Support -**Location:** `pkg/restheadspec/headers.go:159` -**Status:** Not Implemented -**Description:** Support for custom SQL joins via the `X-Custom-SQL-Join` header is currently logged but not executed. - -**Current Limitation:** -```go -case strings.HasPrefix(normalizedKey, "x-custom-sql-join"): - // TODO: Implement custom SQL join - logger.Debug("Custom SQL join not yet implemented: %s", decodedValue) -``` - -**Required Implementation:** -- Parse custom SQL join expressions from headers -- Apply joins to the query builder -- Ensure security (SQL injection prevention) -- Support for different join types (INNER, LEFT, RIGHT, FULL) -- Works across different database adapters (GORM, Bun) - ---- - -### 4. Proper Condition Handling for Bun Preloads -**Location:** `pkg/common/adapters/database/bun.go:202` -**Status:** Partially Implemented -**Description:** The Bun adapter's `Preload` method currently ignores conditions passed to it. - -**Current Limitation:** -```go -func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery { - // Bun uses Relation() method for preloading - // For now, we'll just pass the relation name without conditions - // TODO: Implement proper condition handling for Bun - b.query = b.query.Relation(relation) - return b -} -``` - -**Required Implementation:** -- Properly handle condition parameters in Bun's Relation() method -- Support filtering on preloaded relationships -- Ensure compatibility with GORM's condition syntax where possible -- Test with various condition types - ---- - -## Code Quality Improvements - -### 5. Modernize Go Type Declarations -**Location:** `pkg/common/types.go:5, 42, 64, 79` -**Status:** Pending -**Priority:** Low -**Description:** Replace legacy `interface{}` with modern `any` type alias (Go 1.18+). - -**Affected Lines:** -- Line 5: Function parameter or return type -- Line 42: Function parameter or return type -- Line 64: Function parameter or return type -- Line 79: Function parameter or return type - -**Benefits:** -- More modern and idiomatic Go code -- Better readability -- Aligns with current Go best practices - ---- - -### 6. Pre / Post select/update/delete query in transaction. -- This will allow us to set a user before doing a select -- When making changes, we can have the trigger fire with the correct user. -- Maybe wrap the handleRead,Update,Create,Delete handlers in a transaction with context that can abort when the request is cancelled or a configurable timeout is reached. - -### 7. - -## Additional Considerations - ### Documentation - Ensure all new features are documented in README.md - Update examples to showcase new functionality - Add migration notes if any breaking changes are introduced -### Testing -- Add unit tests for each new feature -- Add integration tests for database adapter compatibility -- Ensure backward compatibility is maintained - -### Performance -- Profile preload performance with column selection and filtering -- Optimize recursive JSON cleaning for large payloads -- Benchmark custom SQL join performance - ### 8. @@ -147,20 +15,6 @@ func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) com - Add unit tests for security providers - Add concurrency tests for model registry -2. **Security Enhancements**: - - Add request size limits - - Configure CORS properly - - Implement input sanitization beyond SQL - -3. **Configuration Management**: - - Centralized config system - - Environment-based configuration - -4. **Graceful Shutdown**: - - Implement shutdown coordination - - Drain in-flight requests - - --- ## Priority Ranking @@ -180,4 +34,4 @@ func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) com -**Last Updated:** 2025-11-07 +**Last Updated:** 2025-12-09