mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-01-07 20:34:25 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb20a354fc | ||
|
|
37c85361ba | ||
|
|
a7e640a6a1 | ||
|
|
bf7125efc3 | ||
|
|
e220ab3d34 | ||
|
|
6a0297713a | ||
|
|
6ea200bb2b | ||
|
|
987244019c | ||
|
|
62a8e56f1b | ||
|
|
d8df1bdac2 | ||
|
|
c0c669bd3d | ||
| 0cc3635466 | |||
| c2d86c9880 | |||
| 70bf0a4be1 | |||
| 4964d89158 | |||
| 96b098f912 | |||
| 5bba99efe3 | |||
| 8504b6d13d | |||
| ada4db6465 | |||
| 2017465cb8 | |||
| d33747c2d3 | |||
| c864aa4d90 | |||
| 250fcf686c | |||
| 47cfc4b3da | |||
| 0e8ae75daf |
@@ -39,7 +39,6 @@ func main() {
|
||||
logger.UpdateLoggerPath(cfg.Logger.Path, cfg.Logger.Dev)
|
||||
}
|
||||
logger.Info("ResolveSpec test server starting")
|
||||
logger.Info("Configuration loaded - Server will listen on: %s", cfg.Server.Addr)
|
||||
|
||||
// Initialize database manager
|
||||
ctx := context.Background()
|
||||
@@ -73,47 +72,30 @@ func main() {
|
||||
// Create server manager
|
||||
mgr := server.NewManager()
|
||||
|
||||
// Parse host and port from addr
|
||||
host := ""
|
||||
port := 8080
|
||||
if cfg.Server.Addr != "" {
|
||||
// Parse addr (format: ":8080" or "localhost:8080")
|
||||
if cfg.Server.Addr[0] == ':' {
|
||||
// Just port
|
||||
_, err := fmt.Sscanf(cfg.Server.Addr, ":%d", &port)
|
||||
if err != nil {
|
||||
logger.Error("Invalid server address: %s", cfg.Server.Addr)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
// Host and port
|
||||
_, err := fmt.Sscanf(cfg.Server.Addr, "%[^:]:%d", &host, &port)
|
||||
if err != nil {
|
||||
logger.Error("Invalid server address: %s", cfg.Server.Addr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
// Get default server configuration
|
||||
defaultServerCfg, err := cfg.Servers.GetDefault()
|
||||
if err != nil {
|
||||
logger.Error("Failed to get default server config: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Add server instance
|
||||
_, err = mgr.Add(server.Config{
|
||||
Name: "api",
|
||||
Host: host,
|
||||
Port: port,
|
||||
Handler: r,
|
||||
ShutdownTimeout: cfg.Server.ShutdownTimeout,
|
||||
DrainTimeout: cfg.Server.DrainTimeout,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
})
|
||||
// Apply global defaults
|
||||
defaultServerCfg.ApplyGlobalDefaults(cfg.Servers)
|
||||
|
||||
// Convert to server.Config and add instance
|
||||
serverCfg := server.FromConfigInstanceToServerConfig(defaultServerCfg, r)
|
||||
|
||||
logger.Info("Configuration loaded - Server '%s' will listen on %s:%d",
|
||||
serverCfg.Name, serverCfg.Host, serverCfg.Port)
|
||||
|
||||
_, err = mgr.Add(serverCfg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to add server: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start server with graceful shutdown
|
||||
logger.Info("Starting server on %s", cfg.Server.Addr)
|
||||
logger.Info("Starting server '%s' on %s:%d", serverCfg.Name, serverCfg.Host, serverCfg.Port)
|
||||
if err := mgr.ServeWithGracefulShutdown(); err != nil {
|
||||
logger.Error("Server failed: %v", err)
|
||||
os.Exit(1)
|
||||
|
||||
52
go.mod
52
go.mod
@@ -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
55
go.sum
@@ -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=
|
||||
|
||||
@@ -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, ", "))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
108
pkg/common/handler_utils_test.go
Normal file
108
pkg/common/handler_utils_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -4,20 +4,28 @@ import "time"
|
||||
|
||||
// Config represents the complete application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Tracing TracingConfig `mapstructure:"tracing"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
Logger LoggerConfig `mapstructure:"logger"`
|
||||
ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"`
|
||||
Middleware MiddlewareConfig `mapstructure:"middleware"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
EventBroker EventBrokerConfig `mapstructure:"event_broker"`
|
||||
DBManager DBManagerConfig `mapstructure:"dbmanager"`
|
||||
Servers ServersConfig `mapstructure:"servers"`
|
||||
Tracing TracingConfig `mapstructure:"tracing"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
Logger LoggerConfig `mapstructure:"logger"`
|
||||
ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"`
|
||||
Middleware MiddlewareConfig `mapstructure:"middleware"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
EventBroker EventBrokerConfig `mapstructure:"event_broker"`
|
||||
DBManager DBManagerConfig `mapstructure:"dbmanager"`
|
||||
Paths PathsConfig `mapstructure:"paths"`
|
||||
Extensions map[string]interface{} `mapstructure:"extensions"`
|
||||
}
|
||||
|
||||
// ServerConfig holds server-related configuration
|
||||
type ServerConfig struct {
|
||||
Addr string `mapstructure:"addr"`
|
||||
// ServersConfig contains configuration for the server manager
|
||||
type ServersConfig struct {
|
||||
// DefaultServer is the name of the default server to use
|
||||
DefaultServer string `mapstructure:"default_server"`
|
||||
|
||||
// Instances is a map of server name to server configuration
|
||||
Instances map[string]ServerInstanceConfig `mapstructure:"instances"`
|
||||
|
||||
// Global timeout defaults (can be overridden per instance)
|
||||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
|
||||
DrainTimeout time.Duration `mapstructure:"drain_timeout"`
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"`
|
||||
@@ -25,6 +33,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
|
||||
|
||||
@@ -2,6 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -91,6 +94,160 @@ func (c *DBManagerConfig) ToManagerConfig() interface{} {
|
||||
return c
|
||||
}
|
||||
|
||||
// PopulateFromDSN parses a DSN and populates the connection fields
|
||||
func (cc *DBConnectionConfig) PopulateFromDSN() error {
|
||||
if cc.DSN == "" {
|
||||
return nil // Nothing to populate
|
||||
}
|
||||
|
||||
switch cc.Type {
|
||||
case "postgres":
|
||||
return cc.populatePostgresDSN()
|
||||
case "mongodb":
|
||||
return cc.populateMongoDSN()
|
||||
case "mssql":
|
||||
return cc.populateMSSQLDSN()
|
||||
case "sqlite":
|
||||
return cc.populateSQLiteDSN()
|
||||
default:
|
||||
return fmt.Errorf("cannot parse DSN for unsupported database type: %s", cc.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// populatePostgresDSN parses PostgreSQL DSN format
|
||||
// Example: host=localhost port=5432 user=postgres password=secret dbname=mydb sslmode=disable
|
||||
func (cc *DBConnectionConfig) populatePostgresDSN() error {
|
||||
parts := strings.Fields(cc.DSN)
|
||||
for _, part := range parts {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key, value := kv[0], kv[1]
|
||||
|
||||
switch key {
|
||||
case "host":
|
||||
cc.Host = value
|
||||
case "port":
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port in DSN: %w", err)
|
||||
}
|
||||
cc.Port = port
|
||||
case "user":
|
||||
cc.User = value
|
||||
case "password":
|
||||
cc.Password = value
|
||||
case "dbname":
|
||||
cc.Database = value
|
||||
case "sslmode":
|
||||
cc.SSLMode = value
|
||||
case "search_path":
|
||||
cc.Schema = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// populateMongoDSN parses MongoDB DSN format
|
||||
// Example: mongodb://user:password@host:port/database?authSource=admin&replicaSet=rs0
|
||||
func (cc *DBConnectionConfig) populateMongoDSN() error {
|
||||
u, err := url.Parse(cc.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid MongoDB DSN: %w", err)
|
||||
}
|
||||
|
||||
// Extract user and password
|
||||
if u.User != nil {
|
||||
cc.User = u.User.Username()
|
||||
if password, ok := u.User.Password(); ok {
|
||||
cc.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host and port
|
||||
if u.Host != "" {
|
||||
host := u.Host
|
||||
if strings.Contains(host, ":") {
|
||||
hostPort := strings.SplitN(host, ":", 2)
|
||||
cc.Host = hostPort[0]
|
||||
if port, err := strconv.Atoi(hostPort[1]); err == nil {
|
||||
cc.Port = port
|
||||
}
|
||||
} else {
|
||||
cc.Host = host
|
||||
}
|
||||
}
|
||||
|
||||
// Extract database
|
||||
if u.Path != "" {
|
||||
cc.Database = strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
params := u.Query()
|
||||
if authSource := params.Get("authSource"); authSource != "" {
|
||||
cc.AuthSource = authSource
|
||||
}
|
||||
if replicaSet := params.Get("replicaSet"); replicaSet != "" {
|
||||
cc.ReplicaSet = replicaSet
|
||||
}
|
||||
if readPref := params.Get("readPreference"); readPref != "" {
|
||||
cc.ReadPreference = readPref
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// populateMSSQLDSN parses MSSQL DSN format
|
||||
// Example: sqlserver://username:password@host:port?database=dbname&schema=dbo
|
||||
func (cc *DBConnectionConfig) populateMSSQLDSN() error {
|
||||
u, err := url.Parse(cc.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid MSSQL DSN: %w", err)
|
||||
}
|
||||
|
||||
// Extract user and password
|
||||
if u.User != nil {
|
||||
cc.User = u.User.Username()
|
||||
if password, ok := u.User.Password(); ok {
|
||||
cc.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host and port
|
||||
if u.Host != "" {
|
||||
host := u.Host
|
||||
if strings.Contains(host, ":") {
|
||||
hostPort := strings.SplitN(host, ":", 2)
|
||||
cc.Host = hostPort[0]
|
||||
if port, err := strconv.Atoi(hostPort[1]); err == nil {
|
||||
cc.Port = port
|
||||
}
|
||||
} else {
|
||||
cc.Host = host
|
||||
}
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
params := u.Query()
|
||||
if database := params.Get("database"); database != "" {
|
||||
cc.Database = database
|
||||
}
|
||||
if schema := params.Get("schema"); schema != "" {
|
||||
cc.Schema = schema
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// populateSQLiteDSN parses SQLite DSN format
|
||||
// Example: /path/to/database.db or :memory:
|
||||
func (cc *DBConnectionConfig) populateSQLiteDSN() error {
|
||||
cc.FilePath = cc.DSN
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the DBManager configuration
|
||||
func (c *DBManagerConfig) Validate() error {
|
||||
if len(c.Connections) == 0 {
|
||||
|
||||
@@ -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
|
||||
@@ -97,6 +108,31 @@ func (m *Manager) GetConfig() (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SetConfig sets the complete configuration
|
||||
func (m *Manager) SetConfig(cfg *Config) error {
|
||||
configMap := make(map[string]interface{})
|
||||
|
||||
// Marshal the config to a map structure that viper can use
|
||||
if err := m.v.Unmarshal(&configMap); err != nil {
|
||||
return fmt.Errorf("failed to prepare config map: %w", err)
|
||||
}
|
||||
|
||||
// Use viper's merge to apply the config
|
||||
m.v.Set("servers", cfg.Servers)
|
||||
m.v.Set("tracing", cfg.Tracing)
|
||||
m.v.Set("cache", cfg.Cache)
|
||||
m.v.Set("logger", cfg.Logger)
|
||||
m.v.Set("error_tracking", cfg.ErrorTracking)
|
||||
m.v.Set("middleware", cfg.Middleware)
|
||||
m.v.Set("cors", cfg.CORS)
|
||||
m.v.Set("event_broker", cfg.EventBroker)
|
||||
m.v.Set("dbmanager", cfg.DBManager)
|
||||
m.v.Set("paths", cfg.Paths)
|
||||
m.v.Set("extensions", cfg.Extensions)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a configuration value by key
|
||||
func (m *Manager) Get(key string) interface{} {
|
||||
return m.v.Get(key)
|
||||
@@ -132,13 +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)
|
||||
@@ -236,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{}{})
|
||||
}
|
||||
|
||||
@@ -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
117
pkg/config/paths.go
Normal 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
149
pkg/config/server.go
Normal 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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dbmanager
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -159,6 +160,9 @@ func (c *sqlConnection) Close() error {
|
||||
|
||||
// HealthCheck verifies the connection is alive
|
||||
func (c *sqlConnection) HealthCheck(ctx context.Context) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -188,6 +192,9 @@ func (c *sqlConnection) Reconnect(ctx context.Context) error {
|
||||
|
||||
// Native returns the native *sql.DB connection
|
||||
func (c *sqlConnection) Native() (*sql.DB, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.nativeDB != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -219,6 +226,9 @@ func (c *sqlConnection) Native() (*sql.DB, error) {
|
||||
|
||||
// Bun returns a Bun ORM instance wrapping the native connection
|
||||
func (c *sqlConnection) Bun() (*bun.DB, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.bunDB != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -249,6 +259,9 @@ func (c *sqlConnection) Bun() (*bun.DB, error) {
|
||||
|
||||
// GORM returns a GORM instance wrapping the native connection
|
||||
func (c *sqlConnection) GORM() (*gorm.DB, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.gormDB != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -283,6 +296,9 @@ func (c *sqlConnection) GORM() (*gorm.DB, error) {
|
||||
|
||||
// Database returns the common.Database interface using the configured default ORM
|
||||
func (c *sqlConnection) Database() (common.Database, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
defaultORM := c.config.DefaultORM
|
||||
c.mu.RUnlock()
|
||||
@@ -307,6 +323,9 @@ func (c *sqlConnection) MongoDB() (*mongo.Client, error) {
|
||||
|
||||
// Stats returns connection statistics
|
||||
func (c *sqlConnection) Stats() *ConnectionStats {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -336,6 +355,9 @@ func (c *sqlConnection) Stats() *ConnectionStats {
|
||||
|
||||
// getBunAdapter returns or creates the Bun adapter
|
||||
func (c *sqlConnection) getBunAdapter() (common.Database, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.bunAdapter != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -350,17 +372,28 @@ func (c *sqlConnection) getBunAdapter() (common.Database, error) {
|
||||
return c.bunAdapter, nil
|
||||
}
|
||||
|
||||
bunDB, err := c.Bun()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Double-check bunDB exists (while already holding write lock)
|
||||
if c.bunDB == nil {
|
||||
// Get native connection first
|
||||
native, err := c.provider.GetNative()
|
||||
if err != nil {
|
||||
return nil, NewConnectionError(c.name, "get bun", err)
|
||||
}
|
||||
|
||||
// Create Bun DB wrapping the same sql.DB
|
||||
dialect := c.getBunDialect()
|
||||
c.bunDB = bun.NewDB(native, dialect)
|
||||
}
|
||||
|
||||
c.bunAdapter = database.NewBunAdapter(bunDB)
|
||||
c.bunAdapter = database.NewBunAdapter(c.bunDB)
|
||||
return c.bunAdapter, nil
|
||||
}
|
||||
|
||||
// getGORMAdapter returns or creates the GORM adapter
|
||||
func (c *sqlConnection) getGORMAdapter() (common.Database, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.gormAdapter != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -375,17 +408,33 @@ func (c *sqlConnection) getGORMAdapter() (common.Database, error) {
|
||||
return c.gormAdapter, nil
|
||||
}
|
||||
|
||||
gormDB, err := c.GORM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Double-check gormDB exists (while already holding write lock)
|
||||
if c.gormDB == nil {
|
||||
// Get native connection first
|
||||
native, err := c.provider.GetNative()
|
||||
if err != nil {
|
||||
return nil, NewConnectionError(c.name, "get gorm", err)
|
||||
}
|
||||
|
||||
// Create GORM DB wrapping the same sql.DB
|
||||
dialector := c.getGORMDialector(native)
|
||||
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, NewConnectionError(c.name, "initialize gorm", err)
|
||||
}
|
||||
|
||||
c.gormDB = db
|
||||
}
|
||||
|
||||
c.gormAdapter = database.NewGormAdapter(gormDB)
|
||||
c.gormAdapter = database.NewGormAdapter(c.gormDB)
|
||||
return c.gormAdapter, nil
|
||||
}
|
||||
|
||||
// getNativeAdapter returns or creates the native adapter
|
||||
func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("connection is nil")
|
||||
}
|
||||
c.mu.RLock()
|
||||
if c.nativeAdapter != nil {
|
||||
defer c.mu.RUnlock()
|
||||
@@ -400,21 +449,31 @@ func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
|
||||
return c.nativeAdapter, nil
|
||||
}
|
||||
|
||||
native, err := c.Native()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Double-check nativeDB exists (while already holding write lock)
|
||||
if c.nativeDB == nil {
|
||||
if !c.connected {
|
||||
return nil, ErrConnectionClosed
|
||||
}
|
||||
|
||||
// Get native connection from provider
|
||||
db, err := c.provider.GetNative()
|
||||
if err != nil {
|
||||
return nil, NewConnectionError(c.name, "get native", err)
|
||||
}
|
||||
|
||||
c.nativeDB = db
|
||||
}
|
||||
|
||||
// Create a native adapter based on database type
|
||||
switch c.dbType {
|
||||
case DatabaseTypePostgreSQL:
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(native)
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
|
||||
case DatabaseTypeSQLite:
|
||||
// For SQLite, we'll use the PgSQL adapter as it works with standard sql.DB
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(native)
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
|
||||
case DatabaseTypeMSSQL:
|
||||
// For MSSQL, we'll use the PgSQL adapter as it works with standard sql.DB
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(native)
|
||||
c.nativeAdapter = database.NewPgSQLAdapter(c.nativeDB)
|
||||
default:
|
||||
return nil, ErrUnsupportedDatabase
|
||||
}
|
||||
@@ -424,6 +483,7 @@ func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
|
||||
|
||||
// getBunDialect returns the appropriate Bun dialect for the database type
|
||||
func (c *sqlConnection) getBunDialect() schema.Dialect {
|
||||
|
||||
switch c.dbType {
|
||||
case DatabaseTypePostgreSQL:
|
||||
return database.GetPostgresDialect()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -584,11 +584,23 @@ 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' {
|
||||
result.WriteRune('_')
|
||||
// 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
logger.Debug("Updating by multiple IDs: %v", id)
|
||||
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(reflection.GetPrimaryKeyName(model))), id)
|
||||
if len(id) > 0 {
|
||||
logger.Debug("Updating by multiple IDs: %v", 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
47
pkg/server/config_helper.go
Normal file
47
pkg/server/config_helper.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
|
||||
@@ -15,16 +17,18 @@ import (
|
||||
// EmbedFSProvider serves files from an embedded filesystem.
|
||||
// It supports both direct embedded directories and embedded zip files.
|
||||
type EmbedFSProvider struct {
|
||||
embedFS *embed.FS
|
||||
zipFile string // Optional: path within embedded FS to zip file
|
||||
zipReader *zip.Reader
|
||||
fs fs.FS
|
||||
mu sync.RWMutex
|
||||
embedFS *embed.FS
|
||||
zipFile string // Optional: path within embedded FS to zip file
|
||||
stripPrefix string // Optional: prefix to strip from requested paths (e.g., "/dist")
|
||||
zipReader *zip.Reader
|
||||
fs fs.FS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewEmbedFSProvider creates a new EmbedFSProvider.
|
||||
// If zipFile is empty, the embedded FS is used directly.
|
||||
// If zipFile is specified, it's treated as a path to a zip file within the embedded FS.
|
||||
// Use WithStripPrefix to configure path prefix stripping.
|
||||
func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error) {
|
||||
if embedFS == nil {
|
||||
return nil, fmt.Errorf("embedded filesystem cannot be nil")
|
||||
@@ -81,6 +85,9 @@ func NewEmbedFSProvider(embedFS fs.FS, zipFile string) (*EmbedFSProvider, error)
|
||||
}
|
||||
|
||||
// Open opens the named file from the embedded filesystem.
|
||||
// If a strip prefix is configured, it prepends the prefix to the requested path.
|
||||
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
|
||||
// open "/dist/assets/style.css" from the embedded filesystem.
|
||||
func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
@@ -89,7 +96,21 @@ func (p *EmbedFSProvider) Open(name string) (fs.File, error) {
|
||||
return nil, fmt.Errorf("embedded filesystem is closed")
|
||||
}
|
||||
|
||||
return p.fs.Open(name)
|
||||
// Apply prefix stripping by prepending the prefix to the requested path
|
||||
actualPath := name
|
||||
if p.stripPrefix != "" {
|
||||
// Clean the paths to handle leading/trailing slashes
|
||||
prefix := strings.Trim(p.stripPrefix, "/")
|
||||
cleanName := strings.TrimPrefix(name, "/")
|
||||
|
||||
if prefix != "" {
|
||||
actualPath = path.Join(prefix, cleanName)
|
||||
} else {
|
||||
actualPath = cleanName
|
||||
}
|
||||
}
|
||||
|
||||
return p.fs.Open(actualPath)
|
||||
}
|
||||
|
||||
// Close releases any resources held by the provider.
|
||||
@@ -117,3 +138,19 @@ func (p *EmbedFSProvider) Type() string {
|
||||
func (p *EmbedFSProvider) ZipFile() string {
|
||||
return p.zipFile
|
||||
}
|
||||
|
||||
// WithStripPrefix sets the prefix to strip from requested paths.
|
||||
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
|
||||
// accessible via "/assets".
|
||||
func (p *EmbedFSProvider) WithStripPrefix(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stripPrefix = prefix
|
||||
}
|
||||
|
||||
// StripPrefix returns the configured strip prefix.
|
||||
func (p *EmbedFSProvider) StripPrefix() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stripPrefix
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LocalFSProvider serves files from a local directory.
|
||||
type LocalFSProvider struct {
|
||||
path string
|
||||
fs fs.FS
|
||||
path string
|
||||
stripPrefix string
|
||||
fs fs.FS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewLocalFSProvider creates a new LocalFSProvider for the given directory path.
|
||||
@@ -39,8 +44,28 @@ func NewLocalFSProvider(path string) (*LocalFSProvider, error) {
|
||||
}
|
||||
|
||||
// Open opens the named file from the local directory.
|
||||
// If a strip prefix is configured, it prepends the prefix to the requested path.
|
||||
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
|
||||
// open "/dist/assets/style.css" from the local filesystem.
|
||||
func (p *LocalFSProvider) Open(name string) (fs.File, error) {
|
||||
return p.fs.Open(name)
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
// Apply prefix stripping by prepending the prefix to the requested path
|
||||
actualPath := name
|
||||
if p.stripPrefix != "" {
|
||||
// Clean the paths to handle leading/trailing slashes
|
||||
prefix := strings.Trim(p.stripPrefix, "/")
|
||||
cleanName := strings.TrimPrefix(name, "/")
|
||||
|
||||
if prefix != "" {
|
||||
actualPath = path.Join(prefix, cleanName)
|
||||
} else {
|
||||
actualPath = cleanName
|
||||
}
|
||||
}
|
||||
|
||||
return p.fs.Open(actualPath)
|
||||
}
|
||||
|
||||
// Close releases any resources held by the provider.
|
||||
@@ -63,6 +88,9 @@ func (p *LocalFSProvider) Path() string {
|
||||
// For local directories, os.DirFS automatically picks up changes,
|
||||
// so this recreates the DirFS to ensure a fresh view.
|
||||
func (p *LocalFSProvider) Reload() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Verify the directory still exists
|
||||
info, err := os.Stat(p.path)
|
||||
if err != nil {
|
||||
@@ -78,3 +106,19 @@ func (p *LocalFSProvider) Reload() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithStripPrefix sets the prefix to strip from requested paths.
|
||||
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
|
||||
// accessible via "/assets".
|
||||
func (p *LocalFSProvider) WithStripPrefix(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stripPrefix = prefix
|
||||
}
|
||||
|
||||
// StripPrefix returns the configured strip prefix.
|
||||
func (p *LocalFSProvider) StripPrefix() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stripPrefix
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/server/zipfs"
|
||||
@@ -12,10 +14,11 @@ import (
|
||||
|
||||
// ZipFSProvider serves files from a zip file.
|
||||
type ZipFSProvider struct {
|
||||
zipPath string
|
||||
zipReader *zip.ReadCloser
|
||||
zipFS *zipfs.ZipFS
|
||||
mu sync.RWMutex
|
||||
zipPath string
|
||||
stripPrefix string
|
||||
zipReader *zip.ReadCloser
|
||||
zipFS *zipfs.ZipFS
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewZipFSProvider creates a new ZipFSProvider for the given zip file path.
|
||||
@@ -40,6 +43,9 @@ func NewZipFSProvider(zipPath string) (*ZipFSProvider, error) {
|
||||
}
|
||||
|
||||
// Open opens the named file from the zip archive.
|
||||
// If a strip prefix is configured, it prepends the prefix to the requested path.
|
||||
// For example, with stripPrefix="/dist", requesting "/assets/style.css" will
|
||||
// open "/dist/assets/style.css" from the zip filesystem.
|
||||
func (p *ZipFSProvider) Open(name string) (fs.File, error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
@@ -48,7 +54,21 @@ func (p *ZipFSProvider) Open(name string) (fs.File, error) {
|
||||
return nil, fmt.Errorf("zip filesystem is closed")
|
||||
}
|
||||
|
||||
return p.zipFS.Open(name)
|
||||
// Apply prefix stripping by prepending the prefix to the requested path
|
||||
actualPath := name
|
||||
if p.stripPrefix != "" {
|
||||
// Clean the paths to handle leading/trailing slashes
|
||||
prefix := strings.Trim(p.stripPrefix, "/")
|
||||
cleanName := strings.TrimPrefix(name, "/")
|
||||
|
||||
if prefix != "" {
|
||||
actualPath = path.Join(prefix, cleanName)
|
||||
} else {
|
||||
actualPath = cleanName
|
||||
}
|
||||
}
|
||||
|
||||
return p.zipFS.Open(actualPath)
|
||||
}
|
||||
|
||||
// Close releases resources held by the zip reader.
|
||||
@@ -100,3 +120,19 @@ func (p *ZipFSProvider) Reload() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithStripPrefix sets the prefix to strip from requested paths.
|
||||
// For example, WithStripPrefix("/dist") will make files at "/dist/assets"
|
||||
// accessible via "/assets".
|
||||
func (p *ZipFSProvider) WithStripPrefix(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stripPrefix = prefix
|
||||
}
|
||||
|
||||
// StripPrefix returns the configured strip prefix.
|
||||
func (p *ZipFSProvider) StripPrefix() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stripPrefix
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user