diff --git a/Makefile b/Makefile index bba4a99..de7dd55 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test: test-unit test-integration # Start PostgreSQL for integration tests docker-up: @echo "Starting PostgreSQL container..." - @docker-compose up -d postgres-test + @podman compose up -d postgres-test @echo "Waiting for PostgreSQL to be ready..." @sleep 5 @echo "PostgreSQL is ready!" @@ -24,12 +24,12 @@ docker-up: # Stop PostgreSQL container docker-down: @echo "Stopping PostgreSQL container..." - @docker-compose down + @podman compose down # Clean up Docker volumes and test data clean: @echo "Cleaning up..." - @docker-compose down -v + @podman compose down -v @echo "Cleanup complete!" # Run integration tests with Docker (full workflow) diff --git a/go.mod b/go.mod index 546474a..167e7d3 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,17 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/jackc/pgx/v5 v5.6.0 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.17.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/uptrace/bun v1.2.15 - github.com/uptrace/bun/dialect/sqlitedialect v1.2.15 - github.com/uptrace/bun/driver/sqliteshim v1.2.15 + github.com/uptrace/bun v1.2.16 + github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 + github.com/uptrace/bun/driver/sqliteshim v1.2.16 github.com/uptrace/bunrouter v1.0.23 go.opentelemetry.io/otel v1.38.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 @@ -33,37 +35,69 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + 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/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/gorilla/websocket v1.5.3 // 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 - github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.28 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect 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/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -71,28 +105,34 @@ require ( 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/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/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect + 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.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // 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 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 gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.66.3 // indirect + modernc.org/libc v1.67.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.38.0 // indirect + modernc.org/sqlite v1.40.1 // indirect ) + +replace github.com/uptrace/bun => github.com/warkanum/bun v1.2.17 diff --git a/go.sum b/go.sum index bac8865..72bb182 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= @@ -8,17 +16,43 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -36,10 +70,13 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -52,6 +89,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -73,14 +112,40 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= -github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -89,6 +154,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -107,6 +174,10 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -118,12 +189,16 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -133,28 +208,38 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/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= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= -github.com/uptrace/bun v1.2.15 h1:Ut68XRBLDgp9qG9QBMa9ELWaZOmzHNdczHQdrOZbEFE= -github.com/uptrace/bun v1.2.15/go.mod h1:Eghz7NonZMiTX/Z6oKYytJ0oaMEJ/eq3kEV4vSqG038= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.15 h1:7upGMVjFRB1oI78GQw6ruNLblYn5CR+kxqcbbeBBils= -github.com/uptrace/bun/dialect/sqlitedialect v1.2.15/go.mod h1:c7YIDaPNS2CU2uI1p7umFuFWkuKbDcPDDvp+DLHZnkI= -github.com/uptrace/bun/driver/sqliteshim v1.2.15 h1:M/rZJSjOPV4OmfTVnDPtL+wJmdMTqDUn8cuk5ycfABA= -github.com/uptrace/bun/driver/sqliteshim v1.2.15/go.mod h1:YqwxFyvM992XOCpGJtXyKPkgkb+aZpIIMzGbpaw1hIk= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E= +github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI= +github.com/uptrace/bun/driver/sqliteshim v1.2.16/go.mod h1:iKdJ06P3XS+pwKcONjSIK07bbhksH3lWsw3mpfr0+bY= github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU= github.com/uptrace/bunrouter v1.0.23/go.mod h1:O3jAcl+5qgnF+ejhgkmbceEk0E/mqaK+ADOocdNpY8M= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/warkanum/bun v1.2.17 h1:HP8eTuKSNcqMDhhIPFxEbgV/yct6RR0/c3qHH3PNZUA= +github.com/warkanum/bun v1.2.17/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= @@ -175,25 +260,34 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= -golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +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/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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/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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= @@ -214,18 +308,22 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +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= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.67.0 h1:QzL4IrKab2OFmxA3/vRYl0tLXrIamwrhD6CKD4WBVjQ= +modernc.org/libc v1.67.0/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= @@ -234,8 +332,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= -modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 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= diff --git a/pkg/cache/cache_manager.go b/pkg/cache/cache_manager.go index e1ca28d..2f88cda 100644 --- a/pkg/cache/cache_manager.go +++ b/pkg/cache/cache_manager.go @@ -57,11 +57,31 @@ func (c *Cache) SetBytes(ctx context.Context, key string, value []byte, ttl time return c.provider.Set(ctx, key, value, ttl) } +// SetWithTags serializes and stores a value in the cache with the specified TTL and tags. +func (c *Cache) SetWithTags(ctx context.Context, key string, value interface{}, ttl time.Duration, tags []string) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + + return c.provider.SetWithTags(ctx, key, data, ttl, tags) +} + +// SetBytesWithTags stores raw bytes in the cache with the specified TTL and tags. +func (c *Cache) SetBytesWithTags(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error { + return c.provider.SetWithTags(ctx, key, value, ttl, tags) +} + // Delete removes a key from the cache. func (c *Cache) Delete(ctx context.Context, key string) error { return c.provider.Delete(ctx, key) } +// DeleteByTag removes all keys associated with the given tag. +func (c *Cache) DeleteByTag(ctx context.Context, tag string) error { + return c.provider.DeleteByTag(ctx, tag) +} + // DeleteByPattern removes all keys matching the pattern. func (c *Cache) DeleteByPattern(ctx context.Context, pattern string) error { return c.provider.DeleteByPattern(ctx, pattern) diff --git a/pkg/cache/provider.go b/pkg/cache/provider.go index 8988d01..87ba1e7 100644 --- a/pkg/cache/provider.go +++ b/pkg/cache/provider.go @@ -15,9 +15,17 @@ type Provider interface { // If ttl is 0, the item never expires. Set(ctx context.Context, key string, value []byte, ttl time.Duration) error + // SetWithTags stores a value in the cache with the specified TTL and tags. + // Tags can be used to invalidate groups of related keys. + // If ttl is 0, the item never expires. + SetWithTags(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error + // Delete removes a key from the cache. Delete(ctx context.Context, key string) error + // DeleteByTag removes all keys associated with the given tag. + DeleteByTag(ctx context.Context, tag string) error + // DeleteByPattern removes all keys matching the pattern. // Pattern syntax depends on the provider implementation. DeleteByPattern(ctx context.Context, pattern string) error diff --git a/pkg/cache/provider_memcache.go b/pkg/cache/provider_memcache.go index f764d72..ab42837 100644 --- a/pkg/cache/provider_memcache.go +++ b/pkg/cache/provider_memcache.go @@ -2,6 +2,7 @@ package cache import ( "context" + "encoding/json" "fmt" "time" @@ -97,8 +98,115 @@ func (m *MemcacheProvider) Set(ctx context.Context, key string, value []byte, tt return m.client.Set(item) } +// SetWithTags stores a value in the cache with the specified TTL and tags. +// Note: Tag support in Memcache is limited and less efficient than Redis. +func (m *MemcacheProvider) SetWithTags(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error { + if ttl == 0 { + ttl = m.options.DefaultTTL + } + + expiration := int32(ttl.Seconds()) + + // Set the main value + item := &memcache.Item{ + Key: key, + Value: value, + Expiration: expiration, + } + if err := m.client.Set(item); err != nil { + return err + } + + // Store tags for this key + if len(tags) > 0 { + tagsData, err := json.Marshal(tags) + if err != nil { + return fmt.Errorf("failed to marshal tags: %w", err) + } + + tagsItem := &memcache.Item{ + Key: fmt.Sprintf("cache:tags:%s", key), + Value: tagsData, + Expiration: expiration, + } + if err := m.client.Set(tagsItem); err != nil { + return err + } + + // Add key to each tag's key list + for _, tag := range tags { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + + // Get existing keys for this tag + var keys []string + if item, err := m.client.Get(tagKey); err == nil { + _ = json.Unmarshal(item.Value, &keys) + } + + // Add current key if not already present + found := false + for _, k := range keys { + if k == key { + found = true + break + } + } + if !found { + keys = append(keys, key) + } + + // Store updated key list + keysData, err := json.Marshal(keys) + if err != nil { + continue + } + + tagItem := &memcache.Item{ + Key: tagKey, + Value: keysData, + Expiration: expiration + 3600, // Give tag lists longer TTL + } + _ = m.client.Set(tagItem) + } + } + + return nil +} + // Delete removes a key from the cache. func (m *MemcacheProvider) Delete(ctx context.Context, key string) error { + // Get tags for this key + tagsKey := fmt.Sprintf("cache:tags:%s", key) + if item, err := m.client.Get(tagsKey); err == nil { + var tags []string + if err := json.Unmarshal(item.Value, &tags); err == nil { + // Remove key from each tag's key list + for _, tag := range tags { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + if tagItem, err := m.client.Get(tagKey); err == nil { + var keys []string + if err := json.Unmarshal(tagItem.Value, &keys); err == nil { + // Remove current key from the list + newKeys := make([]string, 0, len(keys)) + for _, k := range keys { + if k != key { + newKeys = append(newKeys, k) + } + } + // Update the tag's key list + if keysData, err := json.Marshal(newKeys); err == nil { + tagItem.Value = keysData + _ = m.client.Set(tagItem) + } + } + } + } + } + // Delete the tags key + _ = m.client.Delete(tagsKey) + } + + // Delete the actual key err := m.client.Delete(key) if err == memcache.ErrCacheMiss { return nil @@ -106,6 +214,38 @@ func (m *MemcacheProvider) Delete(ctx context.Context, key string) error { return err } +// DeleteByTag removes all keys associated with the given tag. +func (m *MemcacheProvider) DeleteByTag(ctx context.Context, tag string) error { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + + // Get all keys associated with this tag + item, err := m.client.Get(tagKey) + if err == memcache.ErrCacheMiss { + return nil + } + if err != nil { + return err + } + + var keys []string + if err := json.Unmarshal(item.Value, &keys); err != nil { + return fmt.Errorf("failed to unmarshal tag keys: %w", err) + } + + // Delete all keys + for _, key := range keys { + _ = m.client.Delete(key) + // Also delete the tags key for this cache key + tagsKey := fmt.Sprintf("cache:tags:%s", key) + _ = m.client.Delete(tagsKey) + } + + // Delete the tag key itself + _ = m.client.Delete(tagKey) + + return nil +} + // DeleteByPattern removes all keys matching the pattern. // Note: Memcache does not support pattern-based deletion natively. // This is a no-op for memcache and returns an error. diff --git a/pkg/cache/provider_memory.go b/pkg/cache/provider_memory.go index dd24877..1f70758 100644 --- a/pkg/cache/provider_memory.go +++ b/pkg/cache/provider_memory.go @@ -15,6 +15,7 @@ type memoryItem struct { Expiration time.Time LastAccess time.Time HitCount int64 + Tags []string } // isExpired checks if the item has expired. @@ -27,11 +28,12 @@ func (m *memoryItem) isExpired() bool { // MemoryProvider is an in-memory implementation of the Provider interface. type MemoryProvider struct { - mu sync.RWMutex - items map[string]*memoryItem - options *Options - hits atomic.Int64 - misses atomic.Int64 + mu sync.RWMutex + items map[string]*memoryItem + tagToKeys map[string]map[string]struct{} // tag -> set of keys + options *Options + hits atomic.Int64 + misses atomic.Int64 } // NewMemoryProvider creates a new in-memory cache provider. @@ -44,8 +46,9 @@ func NewMemoryProvider(opts *Options) *MemoryProvider { } return &MemoryProvider{ - items: make(map[string]*memoryItem), - options: opts, + items: make(map[string]*memoryItem), + tagToKeys: make(map[string]map[string]struct{}), + options: opts, } } @@ -114,15 +117,116 @@ func (m *MemoryProvider) Set(ctx context.Context, key string, value []byte, ttl return nil } +// SetWithTags stores a value in the cache with the specified TTL and tags. +func (m *MemoryProvider) SetWithTags(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if ttl == 0 { + ttl = m.options.DefaultTTL + } + + var expiration time.Time + if ttl > 0 { + expiration = time.Now().Add(ttl) + } + + // Check max size and evict if necessary + if m.options.MaxSize > 0 && len(m.items) >= m.options.MaxSize { + if _, exists := m.items[key]; !exists { + m.evictOne() + } + } + + // Remove old tag associations if key exists + if oldItem, exists := m.items[key]; exists { + for _, tag := range oldItem.Tags { + if keySet, ok := m.tagToKeys[tag]; ok { + delete(keySet, key) + if len(keySet) == 0 { + delete(m.tagToKeys, tag) + } + } + } + } + + // Store the item + m.items[key] = &memoryItem{ + Value: value, + Expiration: expiration, + LastAccess: time.Now(), + Tags: tags, + } + + // Add new tag associations + for _, tag := range tags { + if m.tagToKeys[tag] == nil { + m.tagToKeys[tag] = make(map[string]struct{}) + } + m.tagToKeys[tag][key] = struct{}{} + } + + return nil +} + // Delete removes a key from the cache. func (m *MemoryProvider) Delete(ctx context.Context, key string) error { m.mu.Lock() defer m.mu.Unlock() + // Remove tag associations + if item, exists := m.items[key]; exists { + for _, tag := range item.Tags { + if keySet, ok := m.tagToKeys[tag]; ok { + delete(keySet, key) + if len(keySet) == 0 { + delete(m.tagToKeys, tag) + } + } + } + } + delete(m.items, key) return nil } +// DeleteByTag removes all keys associated with the given tag. +func (m *MemoryProvider) DeleteByTag(ctx context.Context, tag string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Get all keys associated with this tag + keySet, exists := m.tagToKeys[tag] + if !exists { + return nil // No keys with this tag + } + + // Delete all items with this tag + for key := range keySet { + if item, ok := m.items[key]; ok { + // Remove this tag from the item's tag list + newTags := make([]string, 0, len(item.Tags)) + for _, t := range item.Tags { + if t != tag { + newTags = append(newTags, t) + } + } + + // If item has no more tags, delete it + // Otherwise update its tags + if len(newTags) == 0 { + delete(m.items, key) + } else { + item.Tags = newTags + } + } + } + + // Remove the tag mapping + delete(m.tagToKeys, tag) + return nil +} + // DeleteByPattern removes all keys matching the pattern. func (m *MemoryProvider) DeleteByPattern(ctx context.Context, pattern string) error { m.mu.Lock() diff --git a/pkg/cache/provider_redis.go b/pkg/cache/provider_redis.go index 4e04711..f5c5ac4 100644 --- a/pkg/cache/provider_redis.go +++ b/pkg/cache/provider_redis.go @@ -103,9 +103,93 @@ func (r *RedisProvider) Set(ctx context.Context, key string, value []byte, ttl t return r.client.Set(ctx, key, value, ttl).Err() } +// SetWithTags stores a value in the cache with the specified TTL and tags. +func (r *RedisProvider) SetWithTags(ctx context.Context, key string, value []byte, ttl time.Duration, tags []string) error { + if ttl == 0 { + ttl = r.options.DefaultTTL + } + + pipe := r.client.Pipeline() + + // Set the value + pipe.Set(ctx, key, value, ttl) + + // Add key to each tag's set + for _, tag := range tags { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + pipe.SAdd(ctx, tagKey, key) + // Set expiration on tag set (longer than cache items to ensure cleanup) + if ttl > 0 { + pipe.Expire(ctx, tagKey, ttl+time.Hour) + } + } + + // Store tags for this key for later cleanup + if len(tags) > 0 { + tagsKey := fmt.Sprintf("cache:tags:%s", key) + pipe.SAdd(ctx, tagsKey, tags) + if ttl > 0 { + pipe.Expire(ctx, tagsKey, ttl) + } + } + + _, err := pipe.Exec(ctx) + return err +} + // Delete removes a key from the cache. func (r *RedisProvider) Delete(ctx context.Context, key string) error { - return r.client.Del(ctx, key).Err() + pipe := r.client.Pipeline() + + // Get tags for this key + tagsKey := fmt.Sprintf("cache:tags:%s", key) + tags, err := r.client.SMembers(ctx, tagsKey).Result() + if err == nil && len(tags) > 0 { + // Remove key from each tag set + for _, tag := range tags { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + pipe.SRem(ctx, tagKey, key) + } + // Delete the tags key + pipe.Del(ctx, tagsKey) + } + + // Delete the actual key + pipe.Del(ctx, key) + + _, err = pipe.Exec(ctx) + return err +} + +// DeleteByTag removes all keys associated with the given tag. +func (r *RedisProvider) DeleteByTag(ctx context.Context, tag string) error { + tagKey := fmt.Sprintf("cache:tag:%s", tag) + + // Get all keys associated with this tag + keys, err := r.client.SMembers(ctx, tagKey).Result() + if err != nil { + return err + } + + if len(keys) == 0 { + return nil + } + + pipe := r.client.Pipeline() + + // Delete all keys and their tag associations + for _, key := range keys { + pipe.Del(ctx, key) + // Also delete the tags key for this cache key + tagsKey := fmt.Sprintf("cache:tags:%s", key) + pipe.Del(ctx, tagsKey) + } + + // Delete the tag set itself + pipe.Del(ctx, tagKey) + + _, err = pipe.Exec(ctx) + return err } // DeleteByPattern removes all keys matching the pattern. diff --git a/pkg/cache/query_cache_test.go b/pkg/cache/query_cache_test.go deleted file mode 100644 index 0920983..0000000 --- a/pkg/cache/query_cache_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package cache - -import ( - "context" - "testing" - "time" - - "github.com/bitechdev/ResolveSpec/pkg/common" -) - -func TestBuildQueryCacheKey(t *testing.T) { - filters := []common.FilterOption{ - {Column: "name", Operator: "eq", Value: "test"}, - {Column: "age", Operator: "gt", Value: 25}, - } - sorts := []common.SortOption{ - {Column: "name", Direction: "asc"}, - } - - // Generate cache key - key1 := BuildQueryCacheKey("users", filters, sorts, "status = 'active'", "") - - // Same parameters should generate same key - key2 := BuildQueryCacheKey("users", filters, sorts, "status = 'active'", "") - - if key1 != key2 { - t.Errorf("Expected same cache keys for identical parameters, got %s and %s", key1, key2) - } - - // Different parameters should generate different key - key3 := BuildQueryCacheKey("users", filters, sorts, "status = 'inactive'", "") - - if key1 == key3 { - t.Errorf("Expected different cache keys for different parameters, got %s and %s", key1, key3) - } -} - -func TestBuildExtendedQueryCacheKey(t *testing.T) { - filters := []common.FilterOption{ - {Column: "name", Operator: "eq", Value: "test"}, - } - sorts := []common.SortOption{ - {Column: "name", Direction: "asc"}, - } - expandOpts := []interface{}{ - map[string]interface{}{ - "relation": "posts", - "where": "status = 'published'", - }, - } - - // Generate cache key - key1 := BuildExtendedQueryCacheKey("users", filters, sorts, "", "", expandOpts, false, "", "") - - // Same parameters should generate same key - key2 := BuildExtendedQueryCacheKey("users", filters, sorts, "", "", expandOpts, false, "", "") - - if key1 != key2 { - t.Errorf("Expected same cache keys for identical parameters") - } - - // Different distinct value should generate different key - key3 := BuildExtendedQueryCacheKey("users", filters, sorts, "", "", expandOpts, true, "", "") - - if key1 == key3 { - t.Errorf("Expected different cache keys for different distinct values") - } -} - -func TestGetQueryTotalCacheKey(t *testing.T) { - hash := "abc123" - key := GetQueryTotalCacheKey(hash) - - expected := "query_total:abc123" - if key != expected { - t.Errorf("Expected %s, got %s", expected, key) - } -} - -func TestCachedTotalIntegration(t *testing.T) { - // Initialize cache with memory provider for testing - UseMemory(&Options{ - DefaultTTL: 1 * time.Minute, - MaxSize: 100, - }) - - ctx := context.Background() - - // Create test data - filters := []common.FilterOption{ - {Column: "status", Operator: "eq", Value: "active"}, - } - sorts := []common.SortOption{ - {Column: "created_at", Direction: "desc"}, - } - - // Build cache key - cacheKeyHash := BuildQueryCacheKey("test_table", filters, sorts, "", "") - cacheKey := GetQueryTotalCacheKey(cacheKeyHash) - - // Store a total count in cache - totalToCache := CachedTotal{Total: 42} - err := GetDefaultCache().Set(ctx, cacheKey, totalToCache, time.Minute) - if err != nil { - t.Fatalf("Failed to set cache: %v", err) - } - - // Retrieve from cache - var cachedTotal CachedTotal - err = GetDefaultCache().Get(ctx, cacheKey, &cachedTotal) - if err != nil { - t.Fatalf("Failed to get from cache: %v", err) - } - - if cachedTotal.Total != 42 { - t.Errorf("Expected total 42, got %d", cachedTotal.Total) - } - - // Test cache miss - nonExistentKey := GetQueryTotalCacheKey("nonexistent") - var missedTotal CachedTotal - err = GetDefaultCache().Get(ctx, nonExistentKey, &missedTotal) - if err == nil { - t.Errorf("Expected error for cache miss, got nil") - } -} - -func TestHashString(t *testing.T) { - input1 := "test string" - input2 := "test string" - input3 := "different string" - - hash1 := hashString(input1) - hash2 := hashString(input2) - hash3 := hashString(input3) - - // Same input should produce same hash - if hash1 != hash2 { - t.Errorf("Expected same hash for identical inputs") - } - - // Different input should produce different hash - if hash1 == hash3 { - t.Errorf("Expected different hash for different inputs") - } - - // Hash should be hex encoded SHA256 (64 characters) - if len(hash1) != 64 { - t.Errorf("Expected hash length of 64, got %d", len(hash1)) - } -} diff --git a/pkg/common/adapters/database/bun.go b/pkg/common/adapters/database/bun.go index a83b945..52ba33d 100644 --- a/pkg/common/adapters/database/bun.go +++ b/pkg/common/adapters/database/bun.go @@ -691,6 +691,11 @@ func (b *BunSelectQuery) Order(order string) common.SelectQuery { return b } +func (b *BunSelectQuery) OrderExpr(order string, args ...interface{}) common.SelectQuery { + b.query = b.query.OrderExpr(order, args...) + return b +} + func (b *BunSelectQuery) Limit(n int) common.SelectQuery { b.query = b.query.Limit(n) return b diff --git a/pkg/common/adapters/database/gorm.go b/pkg/common/adapters/database/gorm.go index 4bf1cf6..9d3b3d9 100644 --- a/pkg/common/adapters/database/gorm.go +++ b/pkg/common/adapters/database/gorm.go @@ -386,6 +386,12 @@ func (g *GormSelectQuery) Order(order string) common.SelectQuery { return g } +func (g *GormSelectQuery) OrderExpr(order string, args ...interface{}) common.SelectQuery { + // GORM's Order can handle expressions directly + g.db = g.db.Order(gorm.Expr(order, args...)) + return g +} + func (g *GormSelectQuery) Limit(n int) common.SelectQuery { g.db = g.db.Limit(n) return g diff --git a/pkg/common/adapters/database/pgsql.go b/pkg/common/adapters/database/pgsql.go index 4b81204..f2486f3 100644 --- a/pkg/common/adapters/database/pgsql.go +++ b/pkg/common/adapters/database/pgsql.go @@ -281,6 +281,13 @@ func (p *PgSQLSelectQuery) Order(order string) common.SelectQuery { return p } +func (p *PgSQLSelectQuery) OrderExpr(order string, args ...interface{}) common.SelectQuery { + // For PgSQL, expressions are passed directly without quoting + // If there are args, we would need to format them, but for now just append the expression + p.orderBy = append(p.orderBy, order) + return p +} + func (p *PgSQLSelectQuery) Limit(n int) common.SelectQuery { p.limit = n return p diff --git a/pkg/common/interfaces.go b/pkg/common/interfaces.go index 57dd78f..03a72a0 100644 --- a/pkg/common/interfaces.go +++ b/pkg/common/interfaces.go @@ -46,6 +46,7 @@ type SelectQuery interface { PreloadRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery JoinRelation(relation string, apply ...func(SelectQuery) SelectQuery) SelectQuery Order(order string) SelectQuery + OrderExpr(order string, args ...interface{}) SelectQuery Limit(n int) SelectQuery Offset(n int) SelectQuery Group(group string) SelectQuery diff --git a/pkg/common/sql_helpers.go b/pkg/common/sql_helpers.go index 5b14ce5..2036dfb 100644 --- a/pkg/common/sql_helpers.go +++ b/pkg/common/sql_helpers.go @@ -208,21 +208,9 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti } } } - } else if tableName != "" && !hasTablePrefix(condToCheck) { - // If tableName is provided and the condition DOESN'T have a table prefix, - // qualify unambiguous column references to prevent "ambiguous column" errors - // when there are multiple joins on the same table (e.g., recursive preloads) - columnName := extractUnqualifiedColumnName(condToCheck) - if columnName != "" && (validColumns == nil || isValidColumn(columnName, validColumns)) { - // Qualify the column with the table name - // Be careful to only replace the column name, not other occurrences of the string - oldRef := columnName - newRef := tableName + "." + columnName - // Use word boundary matching to avoid replacing partial matches - cond = qualifyColumnInCondition(cond, oldRef, newRef) - logger.Debug("Qualified unqualified column in condition: '%s' added table prefix '%s'", oldRef, tableName) - } } + // Note: We no longer add prefixes to unqualified columns here. + // Use AddTablePrefixToColumns() separately if you need to add prefixes. validConditions = append(validConditions, cond) } @@ -633,3 +621,145 @@ func isValidColumn(columnName string, validColumns map[string]bool) bool { } return validColumns[strings.ToLower(columnName)] } + +// AddTablePrefixToColumns adds table prefix to unqualified column references in a WHERE clause. +// This function only prefixes simple column references and skips: +// - Columns already having a table prefix (containing a dot) +// - Columns inside function calls or expressions (inside parentheses) +// - Columns inside subqueries +// - Columns that don't exist in the table (validation via model registry) +// +// Examples: +// - "status = 'active'" -> "users.status = 'active'" (if status exists in users table) +// - "COALESCE(status, 'default') = 'active'" -> unchanged (status inside function) +// - "users.status = 'active'" -> unchanged (already has prefix) +// - "(status = 'active')" -> "(users.status = 'active')" (grouping parens are OK) +// - "invalid_col = 'value'" -> unchanged (if invalid_col doesn't exist in table) +// +// Parameters: +// - where: The WHERE clause to process +// - tableName: The table name to use as prefix +// +// Returns: +// - The WHERE clause with table prefixes added to appropriate and valid columns +func AddTablePrefixToColumns(where string, tableName string) string { + if where == "" || tableName == "" { + return where + } + + where = strings.TrimSpace(where) + + // Get valid columns from the model registry for validation + validColumns := getValidColumnsForTable(tableName) + + // Split by AND to handle multiple conditions (parenthesis-aware) + conditions := splitByAND(where) + prefixedConditions := make([]string, 0, len(conditions)) + + for _, cond := range conditions { + cond = strings.TrimSpace(cond) + if cond == "" { + continue + } + + // Process this condition to add table prefix if appropriate + processedCond := addPrefixToSingleCondition(cond, tableName, validColumns) + prefixedConditions = append(prefixedConditions, processedCond) + } + + if len(prefixedConditions) == 0 { + return "" + } + + return strings.Join(prefixedConditions, " AND ") +} + +// addPrefixToSingleCondition adds table prefix to a single condition if appropriate +// Returns the condition unchanged if: +// - The condition is a SQL literal/expression (true, false, null, 1=1, etc.) +// - The column reference is inside a function call +// - The column already has a table prefix +// - No valid column reference is found +// - The column doesn't exist in the table (when validColumns is provided) +func addPrefixToSingleCondition(cond string, tableName string, validColumns map[string]bool) string { + // Strip outer grouping parentheses to get to the actual condition + strippedCond := stripOuterParentheses(cond) + + // Skip SQL literals and trivial conditions (true, false, null, 1=1, etc.) + if IsSQLExpression(strippedCond) || IsTrivialCondition(strippedCond) { + logger.Debug("Skipping SQL literal/trivial condition: '%s'", strippedCond) + return cond + } + + // Extract the left side of the comparison (before the operator) + columnRef := extractLeftSideOfComparison(strippedCond) + if columnRef == "" { + return cond + } + + // Skip if it already has a prefix (contains a dot) + if strings.Contains(columnRef, ".") { + logger.Debug("Skipping column '%s' - already has table prefix", columnRef) + return cond + } + + // Skip if it's a function call or expression (contains parentheses) + if strings.Contains(columnRef, "(") { + logger.Debug("Skipping column reference '%s' - inside function or expression", columnRef) + return cond + } + + // Validate that the column exists in the table (if we have column info) + if !isValidColumn(columnRef, validColumns) { + logger.Debug("Skipping column '%s' - not found in table '%s'", columnRef, tableName) + return cond + } + + // It's a simple unqualified column reference that exists in the table - add the table prefix + newRef := tableName + "." + columnRef + result := qualifyColumnInCondition(cond, columnRef, newRef) + logger.Debug("Added table prefix to column: '%s' -> '%s'", columnRef, newRef) + + return result +} + +// extractLeftSideOfComparison extracts the left side of a comparison operator from a condition. +// This is used to identify the column reference that may need a table prefix. +// +// Examples: +// - "status = 'active'" returns "status" +// - "COALESCE(status, 'default') = 'active'" returns "COALESCE(status, 'default')" +// - "priority > 5" returns "priority" +// +// Returns empty string if no operator is found. +func extractLeftSideOfComparison(cond string) string { + operators := []string{" = ", " != ", " <> ", " > ", " >= ", " < ", " <= ", " LIKE ", " like ", " IN ", " in ", " IS ", " is ", " NOT ", " not "} + + // Find the first operator outside of parentheses and quotes + minIdx := -1 + for _, op := range operators { + idx := findOperatorOutsideParentheses(cond, op) + if idx > 0 && (minIdx == -1 || idx < minIdx) { + minIdx = idx + } + } + + if minIdx > 0 { + leftSide := strings.TrimSpace(cond[:minIdx]) + // Remove any surrounding quotes + leftSide = strings.Trim(leftSide, "`\"'") + return leftSide + } + + // No operator found - might be a boolean column + parts := strings.Fields(cond) + if len(parts) > 0 { + columnRef := strings.Trim(parts[0], "`\"'") + // Make sure it's not a SQL keyword + if !IsSQLKeyword(strings.ToLower(columnRef)) { + return columnRef + } + } + + return "" +} diff --git a/pkg/common/validation.go b/pkg/common/validation.go index c177471..a1ac064 100644 --- a/pkg/common/validation.go +++ b/pkg/common/validation.go @@ -237,6 +237,13 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp for _, sort := range options.Sort { if v.IsValidColumn(sort.Column) { validSorts = append(validSorts, sort) + } else if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") { + // Allow sort by expression/subquery, but validate for security + if IsSafeSortExpression(sort.Column) { + validSorts = append(validSorts, sort) + } else { + logger.Warn("Unsafe sort expression '%s' removed", sort.Column) + } } else { logger.Warn("Invalid column in sort '%s' removed", sort.Column) } @@ -262,6 +269,24 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp } filteredPreload.Filters = validPreloadFilters + // Filter preload sort columns + validPreloadSorts := make([]SortOption, 0, len(preload.Sort)) + for _, sort := range preload.Sort { + if v.IsValidColumn(sort.Column) { + validPreloadSorts = append(validPreloadSorts, sort) + } else if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") { + // Allow sort by expression/subquery, but validate for security + if IsSafeSortExpression(sort.Column) { + validPreloadSorts = append(validPreloadSorts, sort) + } else { + logger.Warn("Unsafe sort expression in preload '%s' removed: '%s'", preload.Relation, sort.Column) + } + } else { + logger.Warn("Invalid column in preload '%s' sort '%s' removed", preload.Relation, sort.Column) + } + } + filteredPreload.Sort = validPreloadSorts + validPreloads = append(validPreloads, filteredPreload) } filtered.Preload = validPreloads @@ -269,6 +294,56 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp return filtered } +// IsSafeSortExpression validates that a sort expression (enclosed in brackets) is safe +// and doesn't contain SQL injection attempts or dangerous commands +func IsSafeSortExpression(expr string) bool { + if expr == "" { + return false + } + + // Expression must be enclosed in brackets + expr = strings.TrimSpace(expr) + if !strings.HasPrefix(expr, "(") || !strings.HasSuffix(expr, ")") { + return false + } + + // Remove outer brackets for content validation + expr = expr[1 : len(expr)-1] + expr = strings.TrimSpace(expr) + + // Convert to lowercase for checking dangerous keywords + exprLower := strings.ToLower(expr) + + // Check for dangerous SQL commands that should never be in a sort expression + dangerousKeywords := []string{ + "drop ", "delete ", "insert ", "update ", "alter ", "create ", + "truncate ", "exec ", "execute ", "grant ", "revoke ", + "into ", "values ", "set ", "shutdown", "xp_", + } + + for _, keyword := range dangerousKeywords { + if strings.Contains(exprLower, keyword) { + logger.Warn("Dangerous SQL keyword '%s' detected in sort expression: %s", keyword, expr) + return false + } + } + + // Check for SQL comment attempts + if strings.Contains(expr, "--") || strings.Contains(expr, "/*") || strings.Contains(expr, "*/") { + logger.Warn("SQL comment detected in sort expression: %s", expr) + return false + } + + // Check for semicolon (command separator) + if strings.Contains(expr, ";") { + logger.Warn("Command separator (;) detected in sort expression: %s", expr) + return false + } + + // Expression appears safe + return true +} + // GetValidColumns returns a list of all valid column names for debugging purposes func (v *ColumnValidator) GetValidColumns() []string { columns := make([]string, 0, len(v.validColumns)) diff --git a/pkg/common/validation_test.go b/pkg/common/validation_test.go index a68be98..1e56070 100644 --- a/pkg/common/validation_test.go +++ b/pkg/common/validation_test.go @@ -361,3 +361,83 @@ func TestFilterRequestOptions(t *testing.T) { t.Errorf("Expected sort column 'id', got %s", filtered.Sort[0].Column) } } + +func TestIsSafeSortExpression(t *testing.T) { + tests := []struct { + name string + expression string + shouldPass bool + }{ + // Safe expressions + {"Valid subquery", "(SELECT MAX(price) FROM products)", true}, + {"Valid CASE expression", "(CASE WHEN status = 'active' THEN 1 ELSE 0 END)", true}, + {"Valid aggregate", "(COUNT(*) OVER (PARTITION BY category))", true}, + {"Valid function", "(COALESCE(discount, 0))", true}, + + // Dangerous expressions - SQL injection attempts + {"DROP TABLE attempt", "(id); DROP TABLE users; --", false}, + {"DELETE attempt", "(id WHERE 1=1); DELETE FROM users; --", false}, + {"INSERT attempt", "(id); INSERT INTO admin VALUES ('hacker'); --", false}, + {"UPDATE attempt", "(id); UPDATE users SET role='admin'; --", false}, + {"EXEC attempt", "(id); EXEC sp_executesql 'DROP TABLE users'; --", false}, + {"XP_ stored proc", "(id); xp_cmdshell 'dir'; --", false}, + + // Comment injection + {"SQL comment dash", "(id) -- malicious comment", false}, + {"SQL comment block start", "(id) /* comment", false}, + {"SQL comment block end", "(id) comment */", false}, + + // Semicolon attempts + {"Semicolon separator", "(id); SELECT * FROM passwords", false}, + + // Empty/invalid + {"Empty string", "", false}, + {"Just brackets", "()", true}, // Empty but technically valid structure + {"No brackets", "id", false}, // Must have brackets for expressions + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSafeSortExpression(tt.expression) + if result != tt.shouldPass { + t.Errorf("IsSafeSortExpression(%q) = %v, want %v", tt.expression, result, tt.shouldPass) + } + }) + } +} + +func TestFilterRequestOptions_WithSortExpressions(t *testing.T) { + model := TestModel{} + validator := NewColumnValidator(model) + + options := RequestOptions{ + Sort: []SortOption{ + {Column: "id", Direction: "ASC"}, // Valid column + {Column: "(SELECT MAX(age) FROM users)", Direction: "DESC"}, // Safe expression + {Column: "name", Direction: "ASC"}, // Valid column + {Column: "(id); DROP TABLE users; --", Direction: "DESC"}, // Dangerous expression + {Column: "invalid_col", Direction: "ASC"}, // Invalid column + {Column: "(CASE WHEN age > 18 THEN 1 ELSE 0 END)", Direction: "ASC"}, // Safe expression + }, + } + + filtered := validator.FilterRequestOptions(options) + + // Should keep: id, safe expression, name, another safe expression + // Should remove: dangerous expression, invalid column + expectedCount := 4 + if len(filtered.Sort) != expectedCount { + t.Errorf("Expected %d sort options, got %d", expectedCount, len(filtered.Sort)) + } + + // Verify the kept options + if filtered.Sort[0].Column != "id" { + t.Errorf("Expected first sort to be 'id', got '%s'", filtered.Sort[0].Column) + } + if filtered.Sort[1].Column != "(SELECT MAX(age) FROM users)" { + t.Errorf("Expected second sort to be safe expression, got '%s'", filtered.Sort[1].Column) + } + if filtered.Sort[2].Column != "name" { + t.Errorf("Expected third sort to be 'name', got '%s'", filtered.Sort[2].Column) + } +} diff --git a/pkg/openapi/README.md b/pkg/openapi/README.md index 2c95dcd..a9141bc 100644 --- a/pkg/openapi/README.md +++ b/pkg/openapi/README.md @@ -273,25 +273,151 @@ handler.SetOpenAPIGenerator(func() (string, error) { }) ``` -## Using with Swagger UI +## Using the Built-in UI Handler -You can serve the generated OpenAPI spec with Swagger UI: +The package includes a built-in UI handler that serves popular OpenAPI visualization tools. No need to download or manage static files - everything is served from CDN. + +### Quick Start + +```go +import ( + "github.com/bitechdev/ResolveSpec/pkg/openapi" + "github.com/gorilla/mux" +) + +func main() { + router := mux.NewRouter() + + // Setup your API routes and OpenAPI generator... + // (see examples above) + + // Add the UI handler - defaults to Swagger UI + openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + SpecURL: "/openapi", + Title: "My API Documentation", + }) + + // Now visit http://localhost:8080/docs + http.ListenAndServe(":8080", router) +} +``` + +### Supported UI Frameworks + +The handler supports four popular OpenAPI UI frameworks: + +#### 1. Swagger UI (Default) +The most widely used OpenAPI UI with excellent compatibility and features. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + Theme: "dark", // optional: "light" or "dark" +}) +``` + +#### 2. RapiDoc +Modern, customizable, and feature-rich OpenAPI UI. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.RapiDoc, + Theme: "dark", +}) +``` + +#### 3. Redoc +Clean, responsive documentation with great UX. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.Redoc, +}) +``` + +#### 4. Scalar +Modern and sleek OpenAPI documentation. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.Scalar, + Theme: "dark", +}) +``` + +### Configuration Options + +```go +type UIConfig struct { + UIType UIType // SwaggerUI, RapiDoc, Redoc, or Scalar + SpecURL string // URL to OpenAPI spec (default: "/openapi") + Title string // Page title (default: "API Documentation") + FaviconURL string // Custom favicon URL (optional) + CustomCSS string // Custom CSS to inject (optional) + Theme string // "light" or "dark" (support varies by UI) +} +``` + +### Custom Styling Example + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + Title: "Acme Corp API", + CustomCSS: ` + .swagger-ui .topbar { + background-color: #1976d2; + } + .swagger-ui .info .title { + color: #1976d2; + } + `, +}) +``` + +### Using Multiple UIs + +You can serve different UIs at different paths: + +```go +// Swagger UI at /docs +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, +}) + +// Redoc at /redoc +openapi.SetupUIRoute(router, "/redoc", openapi.UIConfig{ + UIType: openapi.Redoc, +}) + +// RapiDoc at /api-docs +openapi.SetupUIRoute(router, "/api-docs", openapi.UIConfig{ + UIType: openapi.RapiDoc, +}) +``` + +### Manual Handler Usage + +If you need more control, use the handler directly: + +```go +handler := openapi.UIHandler(openapi.UIConfig{ + UIType: openapi.SwaggerUI, + SpecURL: "/api/openapi.json", +}) + +router.Handle("/documentation", handler) +``` + +## Using with External Swagger UI + +Alternatively, you can use an external Swagger UI instance: 1. Get the spec from `/openapi` 2. Load it in Swagger UI at `https://petstore.swagger.io/` 3. Or self-host Swagger UI and point it to your `/openapi` endpoint -Example with self-hosted Swagger UI: - -```go -// Serve Swagger UI static files -router.PathPrefix("/swagger/").Handler( - http.StripPrefix("/swagger/", http.FileServer(http.Dir("./swagger-ui"))), -) - -// Configure Swagger UI to use /openapi -``` - ## Testing You can test the OpenAPI endpoint: diff --git a/pkg/openapi/example.go b/pkg/openapi/example.go index 15022f0..29624bf 100644 --- a/pkg/openapi/example.go +++ b/pkg/openapi/example.go @@ -183,6 +183,69 @@ func ExampleWithFuncSpec() { _ = generatorFunc } +// ExampleWithUIHandler shows how to serve OpenAPI documentation with a web UI +func ExampleWithUIHandler(db *gorm.DB) { + // Create handler and configure OpenAPI generator + handler := restheadspec.NewHandlerWithGORM(db) + registry := modelregistry.NewModelRegistry() + + handler.SetOpenAPIGenerator(func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My API", + Description: "API documentation with interactive UI", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: registry, + IncludeRestheadSpec: true, + }) + return generator.GenerateJSON() + }) + + // Setup routes + router := mux.NewRouter() + restheadspec.SetupMuxRoutes(router, handler, nil) + + // Add UI handlers for different frameworks + // Swagger UI at /docs (most popular) + SetupUIRoute(router, "/docs", UIConfig{ + UIType: SwaggerUI, + SpecURL: "/openapi", + Title: "My API - Swagger UI", + Theme: "light", + }) + + // RapiDoc at /rapidoc (modern alternative) + SetupUIRoute(router, "/rapidoc", UIConfig{ + UIType: RapiDoc, + SpecURL: "/openapi", + Title: "My API - RapiDoc", + }) + + // Redoc at /redoc (clean and responsive) + SetupUIRoute(router, "/redoc", UIConfig{ + UIType: Redoc, + SpecURL: "/openapi", + Title: "My API - Redoc", + }) + + // Scalar at /scalar (modern and sleek) + SetupUIRoute(router, "/scalar", UIConfig{ + UIType: Scalar, + SpecURL: "/openapi", + Title: "My API - Scalar", + Theme: "dark", + }) + + // Now you can access: + // http://localhost:8080/docs - Swagger UI + // http://localhost:8080/rapidoc - RapiDoc + // http://localhost:8080/redoc - Redoc + // http://localhost:8080/scalar - Scalar + // http://localhost:8080/openapi - Raw OpenAPI JSON + + _ = router +} + // ExampleCustomization shows advanced customization options func ExampleCustomization() { // Create registry and register models with descriptions using struct tags diff --git a/pkg/openapi/ui_handler.go b/pkg/openapi/ui_handler.go new file mode 100644 index 0000000..9d5cfe2 --- /dev/null +++ b/pkg/openapi/ui_handler.go @@ -0,0 +1,294 @@ +package openapi + +import ( + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// UIType represents the type of OpenAPI UI to serve +type UIType string + +const ( + // SwaggerUI is the most popular OpenAPI UI + SwaggerUI UIType = "swagger-ui" + // RapiDoc is a modern, customizable OpenAPI UI + RapiDoc UIType = "rapidoc" + // Redoc is a clean, responsive OpenAPI UI + Redoc UIType = "redoc" + // Scalar is a modern and sleek OpenAPI UI + Scalar UIType = "scalar" +) + +// UIConfig holds configuration for the OpenAPI UI handler +type UIConfig struct { + // UIType specifies which UI framework to use (default: SwaggerUI) + UIType UIType + // SpecURL is the URL to the OpenAPI spec JSON (default: "/openapi") + SpecURL string + // Title is the page title (default: "API Documentation") + Title string + // FaviconURL is the URL to the favicon (optional) + FaviconURL string + // CustomCSS allows injecting custom CSS (optional) + CustomCSS string + // Theme for the UI (light/dark, depends on UI type) + Theme string +} + +// UIHandler creates an HTTP handler that serves an OpenAPI UI +func UIHandler(config UIConfig) http.HandlerFunc { + // Set defaults + if config.UIType == "" { + config.UIType = SwaggerUI + } + if config.SpecURL == "" { + config.SpecURL = "/openapi" + } + if config.Title == "" { + config.Title = "API Documentation" + } + if config.Theme == "" { + config.Theme = "light" + } + + return func(w http.ResponseWriter, r *http.Request) { + var htmlContent string + var err error + + switch config.UIType { + case SwaggerUI: + htmlContent, err = generateSwaggerUI(config) + case RapiDoc: + htmlContent, err = generateRapiDoc(config) + case Redoc: + htmlContent, err = generateRedoc(config) + case Scalar: + htmlContent, err = generateScalar(config) + default: + http.Error(w, "Unsupported UI type", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate UI: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(htmlContent)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to write response: %v", err), http.StatusInternalServerError) + return + } + } +} + +// templateData wraps UIConfig to properly handle CSS in templates +type templateData struct { + UIConfig + SafeCustomCSS template.CSS +} + +// generateSwaggerUI generates the HTML for Swagger UI +func generateSwaggerUI(config UIConfig) (string, error) { + tmpl := ` + +
+ + +