Compare commits

..

23 Commits

Author SHA1 Message Date
Hein
cb20a354fc feat(cors): update SetCORSHeaders to accept Request
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m41s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 53s
Tests / Unit Tests (push) Successful in 29s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m8s
Build , Vet Test, and Lint / Build (push) Successful in -27m25s
Tests / Integration Tests (push) Failing after 37s
* Modify SetCORSHeaders function to include Request parameter.
* Set Access-Control-Allow-Origin and Access-Control-Allow-Headers to "*".
* Update all relevant calls to SetCORSHeaders across the codebase.
2026-01-07 15:24:44 +02:00
Hein
37c85361ba feat(cors): add check for server port in CORS config
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m37s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 55s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m0s
Build , Vet Test, and Lint / Build (push) Successful in -27m20s
Tests / Integration Tests (push) Failing after -27m54s
Tests / Unit Tests (push) Successful in 2m1s
2026-01-07 12:06:08 +02:00
Hein
a7e640a6a1 fix(recursive_crud): 🐛 use dynamic primary key name in insert
* Update processInsert to use the primary key name dynamically.
* Ensure correct ID retrieval from data based on primary key.
2026-01-07 11:58:44 +02:00
Hein
bf7125efc3 feat(reflection): add ExtractTagValue and GetRelationshipInfo functions
* Implement ExtractTagValue to handle struct tag parsing.
* Introduce GetRelationshipInfo for extracting relationship metadata.
* Update tests to validate new functionality.
* Refactor related code for improved clarity and maintainability.
2026-01-07 11:54:12 +02:00
Hein
e220ab3d34 refactor(reflection): 🛠️ comment out ToSnakeCase usage in MapToStruct 2026-01-07 10:23:37 +02:00
Hein
6a0297713a feat(reflection): enhance ToSnakeCase and add convertSlice function
* Improve ToSnakeCase to handle consecutive uppercase letters.
* Introduce convertSlice for element-wise conversions between slices.
* Update setFieldValue to support new slice conversion logic.
2026-01-07 10:23:23 +02:00
Hein
6ea200bb2b refactor(cors): 🛠️ improve host handling in CORS config
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m44s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m5s
Build , Vet Test, and Lint / Build (push) Successful in -27m29s
Tests / Unit Tests (push) Successful in -27m48s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m22s
Tests / Integration Tests (push) Failing after -28m1s
* Change loop to use index for server instances
* Simplify appending external URLs
* Clean up commented code for clarity
2026-01-06 14:07:56 +02:00
Hein
987244019c feat(cors): enhance CORS configuration with dynamic origins
* Update CORSConfig to allow dynamic origins based on server instances.
* Add ExternalURLs field to ServerInstanceConfig for additional CORS support.
* Implement GetIPs function to retrieve non-local IP addresses for CORS.
2026-01-06 14:05:36 +02:00
Hein
62a8e56f1b feat(reflection): add GetPointerElement function for type handling
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m40s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -27m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m5s
Build , Vet Test, and Lint / Build (push) Successful in -27m21s
Tests / Unit Tests (push) Successful in -27m42s
Tests / Integration Tests (push) Failing after -27m55s
* Introduced GetPointerElement to simplify pointer type extraction.
* Updated handleUpdate methods to utilize GetPointerElement for better clarity and maintainability.
2026-01-06 10:45:23 +02:00
Hein
d8df1bdac2 feat(funcspec): add JSON and UUID handling in normalization
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -27m50s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -27m25s
Build , Vet Test, and Lint / Lint Code (push) Successful in -27m22s
Build , Vet Test, and Lint / Build (push) Successful in -27m31s
Tests / Unit Tests (push) Successful in -27m54s
Tests / Integration Tests (push) Failing after -28m3s
* Enhance normalization to support JSON strings as json.RawMessage
* Add support for UUID formatting
* Maintain existing behavior for other types
2026-01-05 17:56:54 +02:00
Hein
c0c669bd3d feat(handler): enhance update logic to merge existing records with incoming data
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -26m28s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -24m52s
Build , Vet Test, and Lint / Lint Code (push) Successful in -26m57s
Build , Vet Test, and Lint / Build (push) Successful in -27m29s
Tests / Integration Tests (push) Failing after -27m58s
Tests / Unit Tests (push) Successful in -26m53s
2026-01-05 12:31:01 +02:00
0cc3635466 chore(deps): update Go dependencies
Some checks failed
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m9s
Build , Vet Test, and Lint / Build (push) Successful in 1m21s
Tests / Unit Tests (push) Failing after 1m5s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -23m55s
Build , Vet Test, and Lint / Lint Code (push) Successful in -23m56s
Tests / Integration Tests (push) Failing after 59s
- Update github.com/jackc/pgx/v5 from v5.6.0 to v5.8.0
- Update github.com/klauspost/compress from v1.18.0 to v1.18.2
- Update github.com/mattn/go-sqlite3 from v1.14.32 to v1.14.33
- Update github.com/redis/go-redis/v9 from v9.17.1 to v9.17.2
- Update go.uber.org/zap from v1.27.0 to v1.27.1
- Update golang.org/x/crypto from v0.43.0 to v0.46.0
- Update gorm.io/gorm from v1.30.0 to v1.31.1
- Update various indirect dependencies
2026-01-03 22:06:31 +02:00
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
30 changed files with 2216 additions and 571 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)
// Get default server configuration
defaultServerCfg, err := cfg.Servers.GetDefault()
if err != nil {
logger.Error("Invalid server address: %s", cfg.Server.Addr)
logger.Error("Failed to get default server config: %v", err)
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)
}
}
}
// 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)

52
go.mod
View File

@@ -13,14 +13,14 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.6.0
github.com/klauspost/compress v1.18.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/jackc/pgx/v5 v5.8.0
github.com/klauspost/compress v1.18.2
github.com/mattn/go-sqlite3 v1.14.33
github.com/microsoft/go-mssqldb v1.9.5
github.com/mochi-mqtt/server/v2 v2.7.9
github.com/nats-io/nats.go v1.48.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.1
github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
@@ -38,13 +38,13 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0
golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/driver/sqlserver v1.6.3
gorm.io/gorm v1.30.0
gorm.io/gorm v1.31.1
)
require (
@@ -70,14 +70,14 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/golang/snappy v1.0.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
@@ -107,12 +107,12 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@@ -122,15 +122,15 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // 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/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
@@ -138,24 +138,24 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
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.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.0 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
replace github.com/uptrace/bun => github.com/warkanum/bun v1.2.17

55
go.sum
View File

@@ -90,6 +90,8 @@ github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGoc
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
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/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -107,6 +109,7 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -115,6 +118,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -142,6 +147,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
@@ -159,6 +166,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -176,6 +185,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
@@ -237,12 +248,18 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@@ -253,6 +270,8 @@ github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
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/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -293,8 +312,12 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -323,6 +346,8 @@ github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -358,10 +383,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/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/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -378,8 +409,12 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -388,6 +423,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -407,6 +444,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -416,6 +455,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -441,6 +482,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -458,6 +501,7 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -474,6 +518,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -484,6 +530,7 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
@@ -496,6 +543,8 @@ google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -514,6 +563,8 @@ gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
@@ -530,6 +581,8 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ=
modernc.org/libc v1.67.0/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -540,6 +593,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -3,6 +3,8 @@ package common
import (
"fmt"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
// CORSConfig holds CORS configuration
@@ -15,8 +17,30 @@ type CORSConfig struct {
// DefaultCORSConfig returns a default CORS configuration suitable for HeadSpec
func DefaultCORSConfig() CORSConfig {
configManager := config.GetConfigManager()
cfg, _ := configManager.GetConfig()
hosts := make([]string, 0)
// hosts = append(hosts, "*")
_, _, ipsList := config.GetIPs()
for i := range cfg.Servers.Instances {
server := cfg.Servers.Instances[i]
if server.Port == 0 {
continue
}
hosts = append(hosts, server.ExternalURLs...)
hosts = append(hosts, fmt.Sprintf("http://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("http://%s:%d", "localhost", server.Port))
for _, ip := range ipsList {
hosts = append(hosts, fmt.Sprintf("http://%s:%d", ip.String(), server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", ip.String(), server.Port))
}
}
return CORSConfig{
AllowedOrigins: []string{"*"},
AllowedOrigins: hosts,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: GetHeadSpecHeaders(),
MaxAge: 86400, // 24 hours
@@ -90,11 +114,14 @@ func GetHeadSpecHeaders() []string {
}
// SetCORSHeaders sets CORS headers on a response writer
func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Set allowed origins
if len(config.AllowedOrigins) > 0 {
w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
}
// if len(config.AllowedOrigins) > 0 {
// w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
// }
// Todo origin list parsing
w.SetHeader("Access-Control-Allow-Origin", "*")
// Set allowed methods
if len(config.AllowedMethods) > 0 {
@@ -102,9 +129,10 @@ func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
}
// Set allowed headers
if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// if len(config.AllowedHeaders) > 0 {
// w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
// }
w.SetHeader("Access-Control-Allow-Headers", "*")
// Set max age
if config.MaxAge > 0 {
@@ -115,5 +143,7 @@ func SetCORSHeaders(w ResponseWriter, config CORSConfig) {
w.SetHeader("Access-Control-Allow-Credentials", "true")
// Expose headers that clients can read
w.SetHeader("Access-Control-Expose-Headers", "Content-Range, X-Api-Range-Total, X-Api-Range-Size")
exposeHeaders := config.AllowedHeaders
exposeHeaders = append(exposeHeaders, "Content-Range", "X-Api-Range-Total", "X-Api-Range-Size")
w.SetHeader("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
}

View File

@@ -3,6 +3,9 @@ package common
import (
"fmt"
"reflect"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// ValidateAndUnwrapModelResult contains the result of model validation
@@ -45,3 +48,216 @@ func ValidateAndUnwrapModel(model interface{}) (*ValidateAndUnwrapModelResult, e
OriginalType: originalType,
}, nil
}
// ExtractTagValue extracts the value for a given key from a struct tag string.
// It handles both semicolon and comma-separated tag formats (e.g., GORM and BUN tags).
// For tags like "json:name;validate:required" it will extract "name" for key "json".
// For tags like "rel:has-many,join:table" it will extract "table" for key "join".
func ExtractTagValue(tag, key string) string {
// Split by both semicolons and commas to handle different tag formats
// We need to be smart about this - commas can be part of values
// So we'll try semicolon first, then comma if needed
separators := []string{";", ","}
for _, sep := range separators {
parts := strings.Split(tag, sep)
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
}
return ""
}
// GetRelationshipInfo analyzes a model type and extracts relationship metadata
// for a specific relation field identified by its JSON name.
// Returns nil if the field is not found or is not a valid relationship.
func GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
bunTag := field.Tag.Get("bun")
info := &RelationshipInfo{
FieldName: field.Name,
JSONName: jsonName,
}
if strings.Contains(bunTag, "rel:") || strings.Contains(bunTag, "join:") {
//bun:"rel:has-many,join:rid_hub=rid_hub_division"
if strings.Contains(bunTag, "has-many") {
info.RelationType = "hasMany"
} else if strings.Contains(bunTag, "has-one") {
info.RelationType = "hasOne"
} else if strings.Contains(bunTag, "belongs-to") {
info.RelationType = "belongsTo"
} else if strings.Contains(bunTag, "many-to-many") {
info.RelationType = "many2many"
} else {
info.RelationType = "hasOne"
}
// Extract join info
joinPart := ExtractTagValue(bunTag, "join")
if joinPart != "" && info.RelationType == "many2many" {
// For many2many, the join part is the join table name
info.JoinTable = joinPart
} else if joinPart != "" {
// For other relations, parse foreignKey and references
joinParts := strings.Split(joinPart, "=")
if len(joinParts) == 2 {
info.ForeignKey = joinParts[0]
info.References = joinParts[1]
}
}
// Get related model type
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
return info
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.ForeignKey = ExtractTagValue(gormTag, "foreignKey")
info.References = ExtractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.RelationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.RelationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else if strings.Contains(gormTag, "many2many") {
info.RelationType = "many2many"
info.JoinTable = ExtractTagValue(gormTag, "many2many")
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else {
// Field has no GORM relationship tags, so it's not a relation
return nil
}
return info
}
}
return nil
}
// RelationPathToBunAlias converts a relation path (e.g., "Order.Customer") to a Bun alias format.
// It converts to lowercase and replaces dots with double underscores.
// For example: "Order.Customer" -> "order__customer"
func RelationPathToBunAlias(relationPath string) string {
if relationPath == "" {
return ""
}
// Convert to lowercase and replace dots with double underscores
alias := strings.ToLower(relationPath)
alias = strings.ReplaceAll(alias, ".", "__")
return alias
}
// ReplaceTableReferencesInSQL replaces references to a base table name in a SQL expression
// with the appropriate alias for the current preload level.
// For example, if baseTableName is "mastertaskitem" and targetAlias is "mal__mal",
// it will replace "mastertaskitem.rid_mastertaskitem" with "mal__mal.rid_mastertaskitem"
func ReplaceTableReferencesInSQL(sqlExpr, baseTableName, targetAlias string) string {
if sqlExpr == "" || baseTableName == "" || targetAlias == "" {
return sqlExpr
}
// Replace both quoted and unquoted table references
// Handle patterns like: tablename.column, "tablename".column, tablename."column", "tablename"."column"
// Pattern 1: tablename.column (unquoted)
result := strings.ReplaceAll(sqlExpr, baseTableName+".", targetAlias+".")
// Pattern 2: "tablename".column or "tablename"."column" (quoted table name)
result = strings.ReplaceAll(result, "\""+baseTableName+"\".", "\""+targetAlias+"\".")
return result
}
// GetTableNameFromModel extracts the table name from a model.
// It checks the bun tag first, then falls back to converting the struct name to snake_case.
func GetTableNameFromModel(model interface{}) string {
if model == nil {
return ""
}
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return ""
}
// Look for bun tag on embedded BaseModel
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Anonymous {
bunTag := field.Tag.Get("bun")
if strings.HasPrefix(bunTag, "table:") {
return strings.TrimPrefix(bunTag, "table:")
}
}
}
// Fallback: convert struct name to lowercase (simple heuristic)
// This handles cases like "MasterTaskItem" -> "mastertaskitem"
return strings.ToLower(modelType.Name())
}

View File

@@ -0,0 +1,108 @@
package common
import (
"testing"
)
func TestExtractTagValue(t *testing.T) {
tests := []struct {
name string
tag string
key string
expected string
}{
{
name: "Extract existing key",
tag: "json:name;validate:required",
key: "json",
expected: "name",
},
{
name: "Extract key with spaces",
tag: "json:name ; validate:required",
key: "validate",
expected: "required",
},
{
name: "Extract key at end",
tag: "json:name;validate:required;db:column_name",
key: "db",
expected: "column_name",
},
{
name: "Extract key at beginning",
tag: "primary:true;json:id;db:user_id",
key: "primary",
expected: "true",
},
{
name: "Key not found",
tag: "json:name;validate:required",
key: "db",
expected: "",
},
{
name: "Empty tag",
tag: "",
key: "json",
expected: "",
},
{
name: "Single key-value pair",
tag: "json:name",
key: "json",
expected: "name",
},
{
name: "Key with empty value",
tag: "json:;validate:required",
key: "json",
expected: "",
},
{
name: "Key with complex value",
tag: "json:user_name,omitempty;validate:required,min=3",
key: "json",
expected: "user_name,omitempty",
},
{
name: "Multiple semicolons",
tag: "json:name;;validate:required",
key: "validate",
expected: "required",
},
{
name: "BUN Tag with comma separator",
tag: "rel:has-many,join:rid_hub=rid_hub_child",
key: "join",
expected: "rid_hub=rid_hub_child",
},
{
name: "Extract foreignKey",
tag: "foreignKey:UserID;references:ID",
key: "foreignKey",
expected: "UserID",
},
{
name: "Extract references",
tag: "foreignKey:UserID;references:ID",
key: "references",
expected: "ID",
},
{
name: "Extract many2many",
tag: "many2many:user_roles",
key: "many2many",
expected: "user_roles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("ExtractTagValue(%q, %q) = %q; want %q", tt.tag, tt.key, result, tt.expected)
}
})
}
}

View File

@@ -20,17 +20,6 @@ type RelationshipInfoProvider interface {
GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo
}
// RelationshipInfo contains information about a model relationship
type RelationshipInfo struct {
FieldName string
JSONName string
RelationType string // "belongsTo", "hasMany", "hasOne", "many2many"
ForeignKey string
References string
JoinTable string
RelatedModel interface{}
}
// NestedCUDProcessor handles recursive processing of nested object graphs
type NestedCUDProcessor struct {
db Database
@@ -218,9 +207,9 @@ func (p *NestedCUDProcessor) processInsert(
for key, value := range data {
query = query.Value(key, value)
}
pkName := reflection.GetPrimaryKeyName(tableName)
// Add RETURNING clause to get the inserted ID
query = query.Returning("id")
query = query.Returning(pkName)
result, err := query.Exec(ctx)
if err != nil {
@@ -231,8 +220,8 @@ func (p *NestedCUDProcessor) processInsert(
var id interface{}
if lastID, err := result.LastInsertId(); err == nil && lastID > 0 {
id = lastID
} else if data["id"] != nil {
id = data["id"]
} else if data[pkName] != nil {
id = data[pkName]
}
logger.Debug("Insert successful, ID: %v, rows affected: %d", id, result.RowsAffected())

View File

@@ -111,3 +111,14 @@ type TableMetadata struct {
Columns []Column `json:"columns"`
Relations []string `json:"relations"`
}
// RelationshipInfo contains information about a model relationship
type RelationshipInfo struct {
FieldName string `json:"field_name"`
JSONName string `json:"json_name"`
RelationType string `json:"relation_type"` // "belongsTo", "hasMany", "hasOne", "many2many"
ForeignKey string `json:"foreign_key"`
References string `json:"references"`
JoinTable string `json:"join_table"`
RelatedModel interface{} `json:"related_model"`
}

View File

@@ -4,7 +4,7 @@ import "time"
// Config represents the complete application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Servers ServersConfig `mapstructure:"servers"`
Tracing TracingConfig `mapstructure:"tracing"`
Cache CacheConfig `mapstructure:"cache"`
Logger LoggerConfig `mapstructure:"logger"`
@@ -13,11 +13,19 @@ type Config struct {
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,51 @@ 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"`
// ExternalURLs are additional URLs that this server instance is accessible from (for CORS) for proxy setups
ExternalURLs []string `mapstructure:"external_urls"`
}
// TracingConfig holds OpenTelemetry tracing configuration
type TracingConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -136,3 +189,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

@@ -12,6 +12,16 @@ type Manager struct {
v *viper.Viper
}
var configInstance *Manager
// GetConfigManager returns a singleton configuration manager instance
func GetConfigManager() *Manager {
if configInstance == nil {
configInstance = NewManager()
}
return configInstance
}
// NewManager creates a new configuration manager with defaults
func NewManager() *Manager {
v := viper.New()
@@ -32,7 +42,8 @@ func NewManager() *Manager {
// Set default values
setDefaults(v)
return &Manager{v: v}
configInstance = &Manager{v: v}
return configInstance
}
// NewManagerWithOptions creates a new configuration manager with custom options
@@ -107,7 +118,7 @@ func (m *Manager) SetConfig(cfg *Config) error {
}
// Use viper's merge to apply the config
m.v.Set("server", cfg.Server)
m.v.Set("servers", cfg.Servers)
m.v.Set("tracing", cfg.Tracing)
m.v.Set("cache", cfg.Cache)
m.v.Set("logger", cfg.Logger)
@@ -116,6 +127,8 @@ func (m *Manager) SetConfig(cfg *Config) error {
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
}
@@ -155,13 +168,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)
@@ -259,4 +281,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
}

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

@@ -0,0 +1,149 @@
package config
import (
"fmt"
"net"
"os"
"strings"
)
// 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
}
// GetIPs - GetIP for pc
func GetIPs() (hostname string, ipList string, ipNetList []net.IP) {
defer func() {
if err := recover(); err != nil {
fmt.Println("Recovered in GetIPs", err)
}
}()
hostname, _ = os.Hostname()
ipaddrlist := make([]net.IP, 0)
iplist := ""
addrs, err := net.LookupIP(hostname)
if err != nil {
return hostname, iplist, ipaddrlist
}
for _, a := range addrs {
// cfg.LogInfo("\nFound IP Host Address: %s", a)
if strings.Contains(a.String(), "127.0.0.1") {
continue
}
iplist = fmt.Sprintf("%s,%s", iplist, a)
ipaddrlist = append(ipaddrlist, a)
}
if iplist == "" {
iff, _ := net.InterfaceAddrs()
for _, a := range iff {
// cfg.LogInfo("\nFound IP Address: %s", a)
if strings.Contains(a.String(), "127.0.0.1") {
continue
}
iplist = fmt.Sprintf("%s,%s", iplist, a)
}
}
iplist = strings.TrimLeft(iplist, ",")
return hostname, iplist, ipaddrlist
}

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()
// 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, err
return nil, NewConnectionError(c.name, "get bun", err)
}
c.bunAdapter = database.NewBunAdapter(bunDB)
// Create Bun DB wrapping the same sql.DB
dialect := c.getBunDialect()
c.bunDB = bun.NewDB(native, dialect)
}
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()
// 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, err
return nil, NewConnectionError(c.name, "get gorm", err)
}
c.gormAdapter = database.NewGormAdapter(gormDB)
// 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(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()
// 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, err
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

@@ -12,6 +12,8 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
@@ -123,27 +125,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
ComplexAPI: complexAPI,
}
// Execute BeforeQueryList hook
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return
}
// Use potentially modified SQL query and variables from hooks
sqlquery = hookCtx.SQLQuery
variables = hookCtx.Variables
// complexAPI = hookCtx.ComplexAPI
// Extract input variables from SQL query (placeholders like [variable])
sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -203,6 +184,27 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Set transaction in hook context for hooks to use
hookCtx.Tx = tx
// Execute BeforeQueryList hook (inside transaction)
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return fmt.Errorf("operation aborted: %s", hookCtx.AbortMessage)
}
// Use potentially modified SQL query from hook
sqlquery = hookCtx.SQLQuery
sqlqueryCnt := sqlquery
// Parse sorting and pagination parameters
@@ -286,6 +288,21 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
}
total = hookCtx.Total
// Execute AfterQueryList hook (inside transaction)
hookCtx.Result = dbobjlist
hookCtx.Total = total
hookCtx.Error = nil
if err := h.hooks.Execute(AfterQueryList, hookCtx); err != nil {
logger.Error("AfterQueryList hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return err
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.([]map[string]interface{}); ok {
dbobjlist = modifiedResult
}
total = hookCtx.Total
return nil
})
@@ -294,21 +311,6 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
return
}
// Execute AfterQueryList hook
hookCtx.Result = dbobjlist
hookCtx.Total = total
hookCtx.Error = err
if err := h.hooks.Execute(AfterQueryList, hookCtx); err != nil {
logger.Error("AfterQueryList hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.([]map[string]interface{}); ok {
dbobjlist = modifiedResult
}
total = hookCtx.Total
// Set response headers
respOffset := 0
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
@@ -459,26 +461,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
ComplexAPI: complexAPI,
}
// Execute BeforeQuery hook
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return
}
// Use potentially modified SQL query and variables from hooks
sqlquery = hookCtx.SQLQuery
variables = hookCtx.Variables
// Extract input variables from SQL query
sqlquery = h.extractInputVariables(sqlquery, &inputvars)
@@ -554,6 +536,28 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
// Execute query within transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Set transaction in hook context for hooks to use
hookCtx.Tx = tx
// Execute BeforeQuery hook (inside transaction)
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
}
// Check if hook aborted the operation
if hookCtx.Abort {
if hookCtx.AbortCode == 0 {
hookCtx.AbortCode = http.StatusBadRequest
}
sendError(w, hookCtx.AbortCode, "operation_aborted", hookCtx.AbortMessage, nil)
return fmt.Errorf("operation aborted: %s", hookCtx.AbortMessage)
}
// Use potentially modified SQL query from hook
sqlquery = hookCtx.SQLQuery
// Execute BeforeSQLExec hook
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err)
@@ -586,6 +590,19 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
dbobj = modifiedResult
}
// Execute AfterQuery hook (inside transaction)
hookCtx.Result = dbobj
hookCtx.Error = nil
if err := h.hooks.Execute(AfterQuery, hookCtx); err != nil {
logger.Error("AfterQuery hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return err
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.(map[string]interface{}); ok {
dbobj = modifiedResult
}
return nil
})
@@ -594,19 +611,6 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
return
}
// Execute AfterQuery hook
hookCtx.Result = dbobj
hookCtx.Error = err
if err := h.hooks.Execute(AfterQuery, hookCtx); err != nil {
logger.Error("AfterQuery hook failed: %v", err)
sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified result from hook
if modifiedResult, ok := hookCtx.Result.(map[string]interface{}); ok {
dbobj = modifiedResult
}
// Execute BeforeResponse hook
hookCtx.Result = dbobj
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -1097,9 +1101,25 @@ func normalizePostgresValue(value interface{}) interface{} {
case map[string]interface{}:
// Recursively normalize nested maps
return normalizePostgresTypes(v)
case string:
var jsonObj interface{}
if err := json.Unmarshal([]byte(v), &jsonObj); err == nil {
// It's valid JSON, return as json.RawMessage so it's not double-encoded
return json.RawMessage(v)
}
return v
case uuid.UUID:
return v.String()
case time.Time:
return v.Format(time.RFC3339)
case bool, int, int8, int16, int32, int64, float32, float64, uint, uint8, uint16, uint32, uint64:
return v
default:
// For other types (int, float, string, bool, etc.), return as-is
// For other types (int, float, bool, etc.), return as-is
// Check stringers
if str, ok := v.(fmt.Stringer); ok {
return str.String()
}
return v
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
@@ -46,6 +47,10 @@ type HookContext struct {
// User context
UserContext *security.UserContext
// Tx provides access to the database/transaction for executing additional SQL
// This allows hooks to run custom queries in addition to the main Query chain
Tx common.Database
// Pagination and filtering (for list queries)
SortColumns string
Limit int

View File

@@ -47,3 +47,20 @@ func ExtractTableNameOnly(fullName string) string {
return fullName[startIndex:]
}
// GetPointerElement returns the element type if the provided reflect.Type is a pointer.
// If the type is a slice of pointers, it returns the element type of the pointer within the slice.
// If neither condition is met, it returns the original type.
func GetPointerElement(v reflect.Type) reflect.Type {
if v.Kind() == reflect.Ptr {
return v.Elem()
}
if v.Kind() == reflect.Slice && v.Elem().Kind() == reflect.Ptr {
subElem := v.Elem()
if subElem.Elem().Kind() == reflect.Ptr {
return subElem.Elem().Elem()
}
return v.Elem()
}
return v
}

View File

@@ -584,12 +584,24 @@ func ExtractSourceColumn(colName string) string {
}
// ToSnakeCase converts a string from CamelCase to snake_case
// Handles consecutive uppercase letters (acronyms) correctly:
// "HTTPServer" -> "http_server", "UserID" -> "user_id", "MyHTTPServer" -> "my_http_server"
func ToSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
runes := []rune(s)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' {
// Add underscore if:
// 1. Previous character is lowercase, OR
// 2. Next character is lowercase (transition from acronym to word)
prevIsLower := runes[i-1] >= 'a' && runes[i-1] <= 'z'
nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
if prevIsLower || nextIsLower {
result.WriteRune('_')
}
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
@@ -961,7 +973,7 @@ func MapToStruct(dataMap map[string]interface{}, target interface{}) error {
// 4. Field name variations
columnNames = append(columnNames, field.Name)
columnNames = append(columnNames, strings.ToLower(field.Name))
columnNames = append(columnNames, ToSnakeCase(field.Name))
// columnNames = append(columnNames, ToSnakeCase(field.Name))
// Map all column name variations to this field index
for _, colName := range columnNames {
@@ -1067,7 +1079,7 @@ func setFieldValue(field reflect.Value, value interface{}) error {
case string:
field.SetBytes([]byte(v))
return nil
case map[string]interface{}, []interface{}:
case map[string]interface{}, []interface{}, []*any, map[string]*any:
// Marshal complex types to JSON for SqlJSONB fields
jsonBytes, err := json.Marshal(v)
if err != nil {
@@ -1077,6 +1089,11 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return nil
}
}
// Handle slice-to-slice conversions (e.g., []interface{} to []*SomeModel)
if valueReflect.Kind() == reflect.Slice {
return convertSlice(field, valueReflect)
}
}
// Handle struct types (like SqlTimeStamp, SqlDate, SqlTime which wrap SqlNull[time.Time])
@@ -1156,6 +1173,92 @@ func setFieldValue(field reflect.Value, value interface{}) error {
return fmt.Errorf("cannot convert %v to %v", valueReflect.Type(), field.Type())
}
// convertSlice converts a source slice to a target slice type, handling element-wise conversions
// Supports converting []interface{} to slices of structs or pointers to structs
func convertSlice(targetSlice reflect.Value, sourceSlice reflect.Value) error {
if sourceSlice.Kind() != reflect.Slice || targetSlice.Kind() != reflect.Slice {
return fmt.Errorf("both source and target must be slices")
}
// Get the element type of the target slice
targetElemType := targetSlice.Type().Elem()
sourceLen := sourceSlice.Len()
// Create a new slice with the same length as the source
newSlice := reflect.MakeSlice(targetSlice.Type(), sourceLen, sourceLen)
// Convert each element
for i := 0; i < sourceLen; i++ {
sourceElem := sourceSlice.Index(i)
targetElem := newSlice.Index(i)
// Get the actual value from the source element
var sourceValue interface{}
if sourceElem.CanInterface() {
sourceValue = sourceElem.Interface()
} else {
continue
}
// Handle nil elements
if sourceValue == nil {
// For pointer types, nil is valid
if targetElemType.Kind() == reflect.Ptr {
targetElem.Set(reflect.Zero(targetElemType))
}
continue
}
// If target element type is a pointer to struct, we need to create new instances
if targetElemType.Kind() == reflect.Ptr {
// Create a new instance of the pointed-to type
newElemPtr := reflect.New(targetElemType.Elem())
// Convert the source value to the struct
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Source is a map, use MapToStruct to populate the new instance
if err := MapToStruct(sv, newElemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
default:
// Try direct conversion or setFieldValue
if err := setFieldValue(newElemPtr.Elem(), sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
targetElem.Set(newElemPtr)
} else if targetElemType.Kind() == reflect.Struct {
// Target element is a struct (not a pointer)
switch sv := sourceValue.(type) {
case map[string]interface{}:
// Use MapToStruct to populate the element
elemPtr := targetElem.Addr()
if elemPtr.CanInterface() {
if err := MapToStruct(sv, elemPtr.Interface()); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
default:
// Try direct conversion
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
} else {
// For other types, use setFieldValue
if err := setFieldValue(targetElem, sourceValue); err != nil {
return fmt.Errorf("failed to convert element %d: %w", i, err)
}
}
}
// Set the converted slice to the target field
targetSlice.Set(newSlice)
return nil
}
// convertToInt64 attempts to convert various types to int64
func convertToInt64(value interface{}) (int64, bool) {
switch v := value.(type) {

View File

@@ -698,20 +698,83 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard processing without nested relations
query := h.db.NewUpdate().Table(tableName).SetMap(updates)
// Get the primary key name
pkName := reflection.GetPrimaryKeyName(model)
// Apply conditions
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := h.db.NewSelect().Model(existingRecord)
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
}
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
logger.Warn("No records found to update")
h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil)
return
}
logger.Error("Error fetching existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error fetching existing record", err)
return
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
logger.Error("Error marshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
logger.Error("Error unmarshaling existing record: %v", err)
h.sendError(w, http.StatusInternalServerError, "update_error", "Error processing existing record", err)
return
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates {
// Skip if the value is nil
if newValue == nil {
continue
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Build update query with merged data
query := h.db.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions
if urlID != "" {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
@@ -782,11 +845,42 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemID, ok := item["id"]; ok {
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(item).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Merge only non-null and non-empty values
for key, newValue := range item {
if newValue == nil {
continue
}
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
existingMap[key] = newValue
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
@@ -857,13 +951,44 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
}
// Standard batch update without nested relations
pkName := reflection.GetPrimaryKeyName(model)
list := make([]interface{}, 0)
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range updates {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemID, ok := itemMap["id"]; ok {
// First, read the existing record
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
continue // Skip if record not found
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(itemMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), itemID)
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Merge only non-null and non-empty values
for key, newValue := range itemMap {
if newValue == nil {
continue
}
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
existingMap[key] = newValue
}
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
@@ -1328,30 +1453,7 @@ func isNullable(field reflect.StructField) bool {
// GetRelationshipInfo implements common.RelationshipInfoProvider interface
func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo {
info := h.getRelationshipInfo(modelType, relationName)
if info == nil {
return nil
}
// Convert internal type to common type
return &common.RelationshipInfo{
FieldName: info.fieldName,
JSONName: info.jsonName,
RelationType: info.relationType,
ForeignKey: info.foreignKey,
References: info.references,
JoinTable: info.joinTable,
RelatedModel: info.relatedModel,
}
}
type relationshipInfo struct {
fieldName string
jsonName string
relationType string // "belongsTo", "hasMany", "hasOne", "many2many"
foreignKey string
references string
joinTable string
relatedModel interface{}
return common.GetRelationshipInfo(modelType, relationName)
}
func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, preloads []common.PreloadOption) (common.SelectQuery, error) {
@@ -1371,7 +1473,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
for idx := range preloads {
preload := preloads[idx]
logger.Debug("Processing preload for relation: %s", preload.Relation)
relInfo := h.getRelationshipInfo(modelType, preload.Relation)
relInfo := common.GetRelationshipInfo(modelType, preload.Relation)
if relInfo == nil {
logger.Warn("Relation %s not found in model", preload.Relation)
continue
@@ -1379,7 +1481,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
// Use the field name (capitalized) for ORM preloading
// ORMs like GORM and Bun expect the struct field name, not the JSON name
relationFieldName := relInfo.fieldName
relationFieldName := relInfo.FieldName
// Validate and fix WHERE clause to ensure it contains the relation prefix
if len(preload.Where) > 0 {
@@ -1422,13 +1524,13 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
copy(columns, preload.Columns)
// Add foreign key if not already present
if relInfo.foreignKey != "" {
if relInfo.ForeignKey != "" {
// Convert struct field name (e.g., DepartmentID) to snake_case (e.g., department_id)
foreignKeyColumn := toSnakeCase(relInfo.foreignKey)
foreignKeyColumn := toSnakeCase(relInfo.ForeignKey)
hasForeignKey := false
for _, col := range columns {
if col == foreignKeyColumn || col == relInfo.foreignKey {
if col == foreignKeyColumn || col == relInfo.ForeignKey {
hasForeignKey = true
break
}
@@ -1474,58 +1576,6 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
return query, nil
}
func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
info := &relationshipInfo{
fieldName: field.Name,
jsonName: jsonName,
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.foreignKey = h.extractTagValue(gormTag, "foreignKey")
info.references = h.extractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.relationType = "hasMany"
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.relationType = "belongsTo"
}
} else if strings.Contains(gormTag, "many2many") {
info.relationType = "many2many"
info.joinTable = h.extractTagValue(gormTag, "many2many")
}
return info
}
}
return nil
}
func (h *Handler) extractTagValue(tag, key string) string {
parts := strings.Split(tag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
return ""
}
// toSnakeCase converts a PascalCase or camelCase string to snake_case
func toSnakeCase(s string) string {
var result strings.Builder

View File

@@ -269,8 +269,6 @@ func TestToSnakeCase(t *testing.T) {
}
func TestExtractTagValue(t *testing.T) {
handler := NewHandler(nil, nil)
tests := []struct {
name string
tag string
@@ -311,9 +309,9 @@ func TestExtractTagValue(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.extractTagValue(tt.tag, tt.key)
result := common.ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("extractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected)
t.Errorf("ExtractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected)
}
})
}

View File

@@ -50,8 +50,9 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
})
muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS")
@@ -98,7 +99,8 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -106,7 +108,7 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.Handle(respAdapter, reqAdapter, vars)
}
}
@@ -117,7 +119,8 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -125,7 +128,7 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -137,13 +140,14 @@ func createMuxOptionsHandler(handler *Handler, schema, entity string, allowedMet
corsConfig := common.DefaultCORSConfig()
corsConfig.AllowedMethods = allowedMethods
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
// Return metadata in the OPTIONS response body
vars := make(map[string]string)
vars["schema"] = schema
vars["entity"] = entity
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -207,9 +211,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()
@@ -217,15 +226,16 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// Add global /openapi route
r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
return nil
})
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
return nil
})
@@ -248,12 +258,13 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// POST route without ID
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -261,13 +272,14 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// POST route with ID
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -275,12 +287,13 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// GET route without ID
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -288,13 +301,14 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// GET route with ID
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(req.Request)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -302,14 +316,15 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// OPTIONS route without ID (returns metadata)
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -317,14 +332,15 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// OPTIONS route with ID (returns metadata)
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewHTTPRequest(req.Request)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewHTTPRequest(req.Request)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -337,13 +353,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 +375,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

@@ -766,7 +766,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
// Apply ComputedQL fields if any
if len(preload.ComputedQL) > 0 {
// Get the base table name from the related model
baseTableName := getTableNameFromModel(relatedModel)
baseTableName := common.GetTableNameFromModel(relatedModel)
// Convert the preload relation path to the appropriate alias format
// This is ORM-specific. Currently we only support Bun's format.
@@ -777,7 +777,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
underlyingType := fmt.Sprintf("%T", h.db.GetUnderlyingDB())
if strings.Contains(underlyingType, "bun.DB") {
// Use Bun's alias format: lowercase with double underscores
preloadAlias = relationPathToBunAlias(preload.Relation)
preloadAlias = common.RelationPathToBunAlias(preload.Relation)
}
// For GORM: GORM doesn't use the same alias format, and this fix
// may not be needed since GORM handles preloads differently
@@ -792,7 +792,7 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
// levels of recursive/nested preloads
adjustedExpr := colExpr
if baseTableName != "" && preloadAlias != "" {
adjustedExpr = replaceTableReferencesInSQL(colExpr, baseTableName, preloadAlias)
adjustedExpr = common.ReplaceTableReferencesInSQL(colExpr, baseTableName, preloadAlias)
if adjustedExpr != colExpr {
logger.Debug("Adjusted computed column expression for %s: '%s' -> '%s'",
colName, colExpr, adjustedExpr)
@@ -903,73 +903,6 @@ func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload co
return query
}
// relationPathToBunAlias converts a relation path like "MAL.MAL.DEF" to the Bun alias format "mal__mal__def"
// Bun generates aliases for nested relations by lowercasing and replacing dots with double underscores
func relationPathToBunAlias(relationPath string) string {
if relationPath == "" {
return ""
}
// Convert to lowercase and replace dots with double underscores
alias := strings.ToLower(relationPath)
alias = strings.ReplaceAll(alias, ".", "__")
return alias
}
// replaceTableReferencesInSQL replaces references to a base table name in a SQL expression
// with the appropriate alias for the current preload level
// For example, if baseTableName is "mastertaskitem" and targetAlias is "mal__mal",
// it will replace "mastertaskitem.rid_mastertaskitem" with "mal__mal.rid_mastertaskitem"
func replaceTableReferencesInSQL(sqlExpr, baseTableName, targetAlias string) string {
if sqlExpr == "" || baseTableName == "" || targetAlias == "" {
return sqlExpr
}
// Replace both quoted and unquoted table references
// Handle patterns like: tablename.column, "tablename".column, tablename."column", "tablename"."column"
// Pattern 1: tablename.column (unquoted)
result := strings.ReplaceAll(sqlExpr, baseTableName+".", targetAlias+".")
// Pattern 2: "tablename".column or "tablename"."column" (quoted table name)
result = strings.ReplaceAll(result, "\""+baseTableName+"\".", "\""+targetAlias+"\".")
return result
}
// getTableNameFromModel extracts the table name from a model
// It checks the bun tag first, then falls back to converting the struct name to snake_case
func getTableNameFromModel(model interface{}) string {
if model == nil {
return ""
}
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
return ""
}
// Look for bun tag on embedded BaseModel
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if field.Anonymous {
bunTag := field.Tag.Get("bun")
if strings.HasPrefix(bunTag, "table:") {
return strings.TrimPrefix(bunTag, "table:")
}
}
}
// Fallback: convert struct name to lowercase (simple heuristic)
// This handles cases like "MasterTaskItem" -> "mastertaskitem"
return strings.ToLower(modelType.Name())
}
func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, data interface{}, options ExtendedRequestOptions) {
// Capture panics and return error response
defer func() {
@@ -1239,6 +1172,26 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
// First, read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("record not found with ID: %v", targetID)
}
return fmt.Errorf("failed to fetch existing record: %w", err)
}
// Convert existing record to map
existingMap := make(map[string]interface{})
jsonData, err := json.Marshal(existingRecord)
if err != nil {
return fmt.Errorf("failed to marshal existing record: %w", err)
}
if err := json.Unmarshal(jsonData, &existingMap); err != nil {
return fmt.Errorf("failed to unmarshal existing record: %w", err)
}
// Extract nested relations if present (but don't process them yet)
var nestedRelations map[string]interface{}
if h.shouldUseNestedProcessor(dataMap, model) {
@@ -1251,15 +1204,30 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range dataMap {
// Skip if the value is nil
if newValue == nil {
continue
}
// Skip if the value is an empty string
if strVal, ok := newValue.(string); ok && strVal == "" {
continue
}
// Update the existing map with the new value
existingMap[key] = newValue
}
// Ensure ID is in the data map for the update
dataMap[pkName] = targetID
existingMap[pkName] = targetID
dataMap = existingMap
// Populate model instance from dataMap to preserve custom types (like SqlJSONB)
// Get the type of the model, handling both pointer and non-pointer types
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
modelType = reflection.GetPointerElement(modelType)
modelInstance := reflect.New(modelType).Interface()
if err := reflection.MapToStruct(dataMap, modelInstance); err != nil {
return fmt.Errorf("failed to populate model from data: %w", err)
@@ -1297,7 +1265,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Fetch the updated record to return the new values
modelValue := reflect.New(reflect.TypeOf(model)).Interface()
selectQuery := tx.NewSelect().Model(modelValue).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
selectQuery = tx.NewSelect().Model(modelValue).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
return fmt.Errorf("failed to fetch updated record: %w", err)
}
@@ -1563,9 +1531,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// First, fetch the record that will be deleted
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
modelType = modelType.Elem()
}
modelType = reflection.GetPointerElement(modelType)
recordToDelete := reflect.New(modelType).Interface()
selectQuery := h.db.NewSelect().Model(recordToDelete).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
@@ -2537,10 +2503,10 @@ func (h *Handler) filterExtendedOptions(validator *common.ColumnValidator, optio
filteredExpand := expand
// Get the relationship info for this expand relation
relInfo := h.getRelationshipInfo(modelType, expand.Relation)
if relInfo != nil && relInfo.relatedModel != nil {
relInfo := common.GetRelationshipInfo(modelType, expand.Relation)
if relInfo != nil && relInfo.RelatedModel != nil {
// Create a validator for the related model
expandValidator := common.NewColumnValidator(relInfo.relatedModel)
expandValidator := common.NewColumnValidator(relInfo.RelatedModel)
// Filter columns using the related model's validator
filteredExpand.Columns = expandValidator.FilterValidColumns(expand.Columns)
@@ -2617,110 +2583,7 @@ func (h *Handler) shouldUseNestedProcessor(data map[string]interface{}, model in
// GetRelationshipInfo implements common.RelationshipInfoProvider interface
func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo {
info := h.getRelationshipInfo(modelType, relationName)
if info == nil {
return nil
}
// Convert internal type to common type
return &common.RelationshipInfo{
FieldName: info.fieldName,
JSONName: info.jsonName,
RelationType: info.relationType,
ForeignKey: info.foreignKey,
References: info.references,
JoinTable: info.joinTable,
RelatedModel: info.relatedModel,
}
}
type relationshipInfo struct {
fieldName string
jsonName string
relationType string // "belongsTo", "hasMany", "hasOne", "many2many"
foreignKey string
references string
joinTable string
relatedModel interface{}
}
func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo {
// Ensure we have a struct type
if modelType == nil || modelType.Kind() != reflect.Struct {
logger.Warn("Cannot get relationship info from non-struct type: %v", modelType)
return nil
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == relationName {
gormTag := field.Tag.Get("gorm")
info := &relationshipInfo{
fieldName: field.Name,
jsonName: jsonName,
}
// Parse GORM tag to determine relationship type and keys
if strings.Contains(gormTag, "foreignKey") {
info.foreignKey = h.extractTagValue(gormTag, "foreignKey")
info.references = h.extractTagValue(gormTag, "references")
// Determine if it's belongsTo or hasMany/hasOne
if field.Type.Kind() == reflect.Slice {
info.relationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
info.relationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else if strings.Contains(gormTag, "many2many") {
info.relationType = "many2many"
info.joinTable = h.extractTagValue(gormTag, "many2many")
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.relatedModel = reflect.New(elemType).Elem().Interface()
}
}
} else {
// Field has no GORM relationship tags, so it's not a relation
return nil
}
return info
}
}
return nil
}
func (h *Handler) extractTagValue(tag, key string) string {
parts := strings.Split(tag, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, key+":") {
return strings.TrimPrefix(part, key+":")
}
}
return ""
return common.GetRelationshipInfo(modelType, relationName)
}
// HandleOpenAPI generates and returns the OpenAPI specification

View File

@@ -103,8 +103,9 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd
openAPIHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
handler.HandleOpenAPI(respAdapter, reqAdapter)
})
muxRouter.Handle("/openapi", openAPIHandler).Methods("GET", "OPTIONS")
@@ -161,7 +162,8 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -169,7 +171,7 @@ func createMuxHandler(handler *Handler, schema, entity, idParam string) http.Han
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.Handle(respAdapter, reqAdapter, vars)
}
}
@@ -180,7 +182,8 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
// Set CORS headers
corsConfig := common.DefaultCORSConfig()
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
vars := make(map[string]string)
vars["schema"] = schema
@@ -188,7 +191,7 @@ func createMuxGetHandler(handler *Handler, schema, entity, idParam string) http.
if idParam != "" {
vars["id"] = mux.Vars(r)[idParam]
}
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -200,13 +203,14 @@ func createMuxOptionsHandler(handler *Handler, schema, entity string, allowedMet
corsConfig := common.DefaultCORSConfig()
corsConfig.AllowedMethods = allowedMethods
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewHTTPRequest(r)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
// Return metadata in the OPTIONS response body
vars := make(map[string]string)
vars["schema"] = schema
vars["entity"] = entity
reqAdapter := router.NewHTTPRequest(r)
handler.HandleGet(respAdapter, reqAdapter, vars)
}
}
@@ -270,9 +274,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()
@@ -280,15 +289,8 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// Add global /openapi route
r.Handle("GET", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleOpenAPI(respAdapter, reqAdapter)
return nil
})
r.Handle("OPTIONS", "/openapi", func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
return nil
})
@@ -312,24 +314,26 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// GET and POST for /{schema}/{entity}
r.Handle("GET", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -337,65 +341,70 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// GET, POST, PUT, PATCH, DELETE for /{schema}/{entity}/:id
r.Handle("GET", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("POST", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PUT", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("PATCH", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
r.Handle("DELETE", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
"id": req.Param("id"),
}
reqAdapter := router.NewBunRouterRequest(req)
handler.Handle(respAdapter, reqAdapter, params)
return nil
})
@@ -403,12 +412,13 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// Metadata endpoint
r.Handle("GET", metadataPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
common.SetCORSHeaders(respAdapter, corsConfig)
reqAdapter := router.NewBunRouterRequest(req)
common.SetCORSHeaders(respAdapter, reqAdapter, corsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -416,14 +426,15 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// OPTIONS route without ID (returns metadata)
r.Handle("OPTIONS", entityPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -431,14 +442,15 @@ func SetupBunRouterRoutes(bunRouter *router.StandardBunRouterAdapter, handler *H
// OPTIONS route with ID (returns metadata)
r.Handle("OPTIONS", entityWithIDPath, func(w http.ResponseWriter, req bunrouter.Request) error {
respAdapter := router.NewHTTPResponseWriter(w)
reqAdapter := router.NewBunRouterRequest(req)
optionsCorsConfig := corsConfig
optionsCorsConfig.AllowedMethods = []string{"GET", "PUT", "PATCH", "DELETE", "POST", "OPTIONS"}
common.SetCORSHeaders(respAdapter, optionsCorsConfig)
common.SetCORSHeaders(respAdapter, reqAdapter, optionsCorsConfig)
params := map[string]string{
"schema": currentSchema,
"entity": currentEntity,
}
reqAdapter := router.NewBunRouterRequest(req)
handler.HandleGet(respAdapter, reqAdapter, params)
return nil
})
@@ -450,17 +462,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

@@ -2,6 +2,8 @@ package restheadspec
import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestParseModelName(t *testing.T) {
@@ -112,3 +114,88 @@ func TestNewStandardBunRouter(t *testing.T) {
t.Error("Expected router to be created, got nil")
}
}
func TestExtractTagValue(t *testing.T) {
tests := []struct {
name string
tag string
key string
expected string
}{
{
name: "Extract existing key",
tag: "json:name;validate:required",
key: "json",
expected: "name",
},
{
name: "Extract key with spaces",
tag: "json:name ; validate:required",
key: "validate",
expected: "required",
},
{
name: "Extract key at end",
tag: "json:name;validate:required;db:column_name",
key: "db",
expected: "column_name",
},
{
name: "Extract key at beginning",
tag: "primary:true;json:id;db:user_id",
key: "primary",
expected: "true",
},
{
name: "Key not found",
tag: "json:name;validate:required",
key: "db",
expected: "",
},
{
name: "Empty tag",
tag: "",
key: "json",
expected: "",
},
{
name: "Single key-value pair",
tag: "json:name",
key: "json",
expected: "name",
},
{
name: "Key with empty value",
tag: "json:;validate:required",
key: "json",
expected: "",
},
{
name: "Key with complex value",
tag: "json:user_name,omitempty;validate:required,min=3",
key: "json",
expected: "user_name,omitempty",
},
{
name: "Multiple semicolons",
tag: "json:name;;validate:required",
key: "validate",
expected: "required",
},
{
name: "BUN Tag",
tag: "rel:has-many,join:rid_hub=rid_hub_child",
key: "join",
expected: "rid_hub=rid_hub_child",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := common.ExtractTagValue(tt.tag, tt.key)
if result != tt.expected {
t.Errorf("ExtractTagValue(%q, %q) = %q; want %q", tt.tag, tt.key, result, tt.expected)
}
})
}
}

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"
@@ -17,6 +19,7 @@ import (
type EmbedFSProvider struct {
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
@@ -25,6 +28,7 @@ type EmbedFSProvider struct {
// 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
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"
@@ -13,6 +15,7 @@ import (
// ZipFSProvider serves files from a zip file.
type ZipFSProvider struct {
zipPath string
stripPrefix string
zipReader *zip.ReadCloser
zipFS *zipfs.ZipFS
mu sync.RWMutex
@@ -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
}