diff --git a/cmd/relayproxy/config/retriever.go b/cmd/relayproxy/config/retriever.go index c494be0474a..e6a54492a9c 100644 --- a/cmd/relayproxy/config/retriever.go +++ b/cmd/relayproxy/config/retriever.go @@ -30,6 +30,9 @@ type RetrieverConf struct { URI string `mapstructure:"uri" koanf:"uri"` Database string `mapstructure:"database" koanf:"database"` Collection string `mapstructure:"collection" koanf:"collection"` + Type string `mapstructure:"type" koanf:"type"` + Table string `mapstructure:"table" koanf:"table"` + Column string `mapstructure:"column" koanf:"column"` RedisOptions *redis.Options `mapstructure:"redisOptions" koanf:"redisOptions"` RedisPrefix string `mapstructure:"redisPrefix" koanf:"redisPrefix"` AccountName string `mapstructure:"accountName" koanf:"accountname"` @@ -67,6 +70,9 @@ func (c *RetrieverConf) IsValid() error { if c.Kind == MongoDBRetriever { return c.validateMongoDBRetriever() } + if c.Kind == PostgreSQLRetriever { + return c.validatePostgreSQLRetriever() + } if c.Kind == RedisRetriever { return c.validateRedisRetriever() } @@ -112,6 +118,23 @@ func (c *RetrieverConf) validateMongoDBRetriever() error { return nil } +func (c *RetrieverConf) validatePostgreSQLRetriever() error { + if c.Column == "" { + return fmt.Errorf("invalid retriever: no \"column\" property found for kind \"%s\"", c.Kind) + } + if c.Table == "" { + return fmt.Errorf("invalid retriever: no \"table\" property found for kind \"%s\"", c.Kind) + } + if c.URI == "" { + return fmt.Errorf("invalid retriever: no \"uri\" property found for kind \"%s\"", c.Kind) + } + if c.Type == "" || !(c.Type == "json") { + return fmt.Errorf("invalid retriever: no \"type\" property or not a valid type in kind \"%s\"", c.Kind) + } + + return nil +} + func (c *RetrieverConf) validateRedisRetriever() error { if c.RedisOptions == nil { return fmt.Errorf("invalid retriever: no \"redisOptions\" property found for kind \"%s\"", c.Kind) @@ -144,6 +167,7 @@ const ( GoogleStorageRetriever RetrieverKind = "googleStorage" KubernetesRetriever RetrieverKind = "configmap" MongoDBRetriever RetrieverKind = "mongodb" + PostgreSQLRetriever RetrieverKind = "postgresql" RedisRetriever RetrieverKind = "redis" BitbucketRetriever RetrieverKind = "bitbucket" AzBlobStorageRetriever RetrieverKind = "azureBlobStorage" @@ -154,7 +178,7 @@ func (r RetrieverKind) IsValid() error { switch r { case HTTPRetriever, GitHubRetriever, GitlabRetriever, S3Retriever, RedisRetriever, FileRetriever, GoogleStorageRetriever, KubernetesRetriever, MongoDBRetriever, - BitbucketRetriever, AzBlobStorageRetriever: + PostgreSQLRetriever, BitbucketRetriever, AzBlobStorageRetriever: return nil } return fmt.Errorf("invalid retriever: kind \"%s\" is not supported", r) diff --git a/cmd/relayproxy/config/retriever_test.go b/cmd/relayproxy/config/retriever_test.go index d831abedf8c..92772860116 100644 --- a/cmd/relayproxy/config/retriever_test.go +++ b/cmd/relayproxy/config/retriever_test.go @@ -262,6 +262,73 @@ func TestRetrieverConf_IsValid(t *testing.T) { wantErr: true, errValue: "invalid retriever: no \"redisOptions\" property found for kind \"redis\"", }, + { + name: "kind postgreSQL without Table", + fields: config.RetrieverConf{ + Kind: "postgresql", + URI: "xxx", + Column: "xxx", + Type: "json", + }, + wantErr: true, + errValue: "invalid retriever: no \"table\" property found for kind \"postgresql\"", + }, + { + name: "kind postgreSQL without Column", + fields: config.RetrieverConf{ + Kind: "postgresql", + URI: "xxx", + Table: "xxx", + Type: "json", + }, + wantErr: true, + errValue: "invalid retriever: no \"column\" property found for kind \"postgresql\"", + }, + { + name: "kind postgreSQL without URI", + fields: config.RetrieverConf{ + Kind: "postgresql", + Column: "xxx", + Table: "xxx", + Type: "json", + }, + wantErr: true, + errValue: "invalid retriever: no \"uri\" property found for kind \"postgresql\"", + }, + { + name: "kind postgreSQL without Type", + fields: config.RetrieverConf{ + Kind: "postgresql", + Column: "xxx", + Table: "xxx", + URI: "xxx", + }, + wantErr: true, + errValue: "invalid retriever: no \"type\" property or not a valid type in kind \"postgresql\"", + }, + { + name: "kind postgreSQL wrong Type", + fields: config.RetrieverConf{ + Kind: "postgresql", + Column: "xxx", + Table: "xxx", + URI: "xxx", + Type: "wrong", + }, + wantErr: true, + errValue: "invalid retriever: no \"type\" property or not a valid type in kind \"postgresql\"", + }, + + { + name: "kind postgreSQL valid", + fields: config.RetrieverConf{ + Kind: "postgresql", + URI: "xxx", + Table: "xxx", + Type: "json", + Column: "xxx", + }, + }, { name: "kind mongoDB without Collection", fields: config.RetrieverConf{ diff --git a/cmd/relayproxy/service/gofeatureflag.go b/cmd/relayproxy/service/gofeatureflag.go index 1f3702d98f3..9ffd68dd569 100644 --- a/cmd/relayproxy/service/gofeatureflag.go +++ b/cmd/relayproxy/service/gofeatureflag.go @@ -35,6 +35,7 @@ import ( "github.com/thomaspoignant/go-feature-flag/retriever/httpretriever" "github.com/thomaspoignant/go-feature-flag/retriever/k8sretriever" "github.com/thomaspoignant/go-feature-flag/retriever/mongodbretriever" + "github.com/thomaspoignant/go-feature-flag/retriever/postgresqlretriever" "github.com/thomaspoignant/go-feature-flag/retriever/redisretriever" "github.com/thomaspoignant/go-feature-flag/retriever/s3retrieverv2" "go.uber.org/zap" @@ -185,6 +186,8 @@ func initRetriever(c *config.RetrieverConf) (retriever.Retriever, error) { ClientConfig: *client}, nil case config.MongoDBRetriever: return &mongodbretriever.Retriever{Database: c.Database, URI: c.URI, Collection: c.Collection}, nil + case config.PostgreSQLRetriever: + return &postgresqlretriever.Retriever{Type: c.Type, URI: c.URI, Table: c.Table, Column: c.Column}, nil case config.RedisRetriever: return &redisretriever.Retriever{Options: c.RedisOptions, Prefix: c.RedisPrefix}, nil case config.AzBlobStorageRetriever: diff --git a/examples/retriever_postgresql/README.md b/examples/retriever_postgresql/README.md new file mode 100644 index 00000000000..3d86c25a851 --- /dev/null +++ b/examples/retriever_postgresql/README.md @@ -0,0 +1,30 @@ +# PostgreSQL example + +This example contains everything you need to use **`PostgreSQL`** as the source for your flags. + +As you can see the `main.go` file contains a basic HTTP server that expose an API that use your flags. + +## How to setup the example +_All commands should be run in the root level of the repository._ + +1. Load all dependencies + +```shell +make vendor +``` + +2. Run the PostgreSQL container provided in the `docker-compose.yml` file. + +```shell +docker compose -f ./example/retriever_postgresql/docker-compose.yml up +``` + +The container will run an initialization script that populates the database with example flags + +3. Run the example app to visualize the flags being evaluated + +```shell +go run ./examples/retriever_postgresql/main.go +``` + +4. Play with the values in the configured MongoDB documents to see different outputs diff --git a/examples/retriever_postgresql/docker-compose.yml b/examples/retriever_postgresql/docker-compose.yml new file mode 100644 index 00000000000..53003e3391b --- /dev/null +++ b/examples/retriever_postgresql/docker-compose.yml @@ -0,0 +1,17 @@ +# Use root/example as user/password credentials +version: '3.1' + +services: + + postgres: + container_name: goff_postgres + hostname: postgres + image: postgres:16-alpine + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=example + - POSTGRES_DB=flags_db + ports: + - "5432:5432" + volumes: + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro diff --git a/examples/retriever_postgresql/init-db.sh b/examples/retriever_postgresql/init-db.sh new file mode 100755 index 00000000000..e7cf5e69138 --- /dev/null +++ b/examples/retriever_postgresql/init-db.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE TABLE IF NOT EXISTS flags ( + id SERIAL PRIMARY KEY, + flag JSONB + ); + + INSERT INTO flags (flag) VALUES + ( + '{ + "flag": "new-admin-access", + "variations": { + "default_var": false, + "false_var": false, + "true_var": true + }, + "defaultRule": { + "percentage": { + "false_var": 70, + "true_var": 30 + } + } + }'::jsonb + ); + + INSERT INTO flags (flag) VALUES + ( + '{ + "flag": "flag-only-for-admin", + "variations": { + "default_var": false, + "false_var": false, + "true_var": true + }, + "targeting": [ + { + "query": "admin eq true", + "percentage": { + "false_var": 0, + "true_var": 100 + } + } + ], + "defaultRule": { + "variation": "default_var" + } + }'::jsonb + ); +EOSQL + diff --git a/examples/retriever_postgresql/main.go b/examples/retriever_postgresql/main.go new file mode 100644 index 00000000000..46d9cc545c3 --- /dev/null +++ b/examples/retriever_postgresql/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "time" + + "github.com/thomaspoignant/go-feature-flag/ffcontext" + + "github.com/thomaspoignant/go-feature-flag/exporter/fileexporter" + "github.com/thomaspoignant/go-feature-flag/retriever/postgresqlretriever" + + ffclient "github.com/thomaspoignant/go-feature-flag" +) + +func main() { + // Init ffclient with a file retriever. + err := ffclient.Init(ffclient.Config{ + PollingInterval: 10 * time.Second, + LeveledLogger: slog.Default(), + Context: context.Background(), + Retriever: &postgresqlretriever.Retriever{ + Table: "flags", + Type: "json", + Column: "flag", + URI: "postgres://root:example@localhost:5432/flags_db?sslmode=disable", + }, + DataExporter: ffclient.DataExporter{ + FlushInterval: 1 * time.Second, + MaxEventInMemory: 100, + Exporter: &fileexporter.Exporter{ + OutputDir: "./", + }, + }, + }) + // Check init errors. + if err != nil { + log.Fatal(err) + } + // defer closing ffclient + defer ffclient.Close() + + // create users + user1 := ffcontext. + NewEvaluationContextBuilder("aea2fdc1-b9a0-417a-b707-0c9083de68e3"). + AddCustom("anonymous", true). + AddCustom("environment", "dev"). + Build() + user2 := ffcontext.NewEvaluationContext("332460b9-a8aa-4f7a-bc5d-9cc33632df9a") + user3 := ffcontext.NewEvaluationContextBuilder("785a14bf-d2c5-4caa-9c70-2bbc4e3732a5"). + AddCustom("email", "user2@email.com"). + AddCustom("firstname", "John"). + AddCustom("lastname", "Doe"). + AddCustom("admin", true). + Build() + + // --- test flag with no rule + // user1 + user1HasAccessToNewAdmin, err := ffclient.BoolVariation("new-admin-access", user1, false) + if err != nil { + // we log the error, but we still have a meaningful value in user1HasAccessToNewAdmin (the default value). + log.Printf("something went wrong when getting the flag: %v", err) + } + if user1HasAccessToNewAdmin { + fmt.Println("user1 has access to the new admin") + } + + // user2 + user2HasAccessToNewAdmin, err := ffclient.BoolVariation("new-admin-access", user2, false) + if err != nil { + // we log the error, but we still have a meaningful value in hasAccessToNewAdmin (the default value). + log.Printf("something went wrong when getting the flag: %v", err) + } + if !user2HasAccessToNewAdmin { + fmt.Println("user2 has not access to the new admin") + } + + // --- test flag with rule only for admins + // user 1 is not admin so should not access to the flag + user1HasAccess, _ := ffclient.BoolVariation("flag-only-for-admin", user1, false) + if !user1HasAccess { + fmt.Println("user1 is not admin so no access to the flag") + } + + // user 3 is admin and the flag apply to this key. + if user3HasAccess, _ := ffclient.BoolVariation("flag-only-for-admin", user3, false); user3HasAccess { + fmt.Println("user 3 is admin and the flag apply to this key.") + } +} diff --git a/go.mod b/go.mod index 4a26ef9a650..c270918b140 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/invopop/jsonschema v0.12.0 + github.com/jackc/pgx/v5 v5.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/jsternberg/zap-logfmt v1.3.0 github.com/knadh/koanf/parsers/json v0.1.0 @@ -39,6 +40,7 @@ require ( github.com/knadh/koanf/v2 v2.1.2 github.com/labstack/echo-contrib v0.17.1 github.com/labstack/echo/v4 v4.13.0 + github.com/lib/pq v1.10.9 github.com/luci/go-render v0.0.0-20160219211803-9a04cc21af0f github.com/nikunjy/rules v1.5.0 github.com/pablor21/echo-etag/v4 v4.0.3 @@ -54,6 +56,7 @@ require ( github.com/testcontainers/testcontainers-go v0.34.0 github.com/testcontainers/testcontainers-go/modules/azurite v0.34.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 github.com/testcontainers/testcontainers-go/modules/redis v0.34.0 github.com/thejerf/slogassert v0.3.4 github.com/xitongsys/parquet-go v1.6.2 @@ -164,6 +167,9 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect diff --git a/go.sum b/go.sum index 2c788a186c7..4c686c030bf 100644 --- a/go.sum +++ b/go.sum @@ -596,6 +596,7 @@ github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bY github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +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/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= @@ -606,6 +607,8 @@ github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwX github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= @@ -616,10 +619,14 @@ github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -711,6 +718,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/luci/go-render v0.0.0-20160219211803-9a04cc21af0f h1:WVPqVsbUsrzAebTEgWRAZMdDOfkFx06iyhbIoyMgtkE= github.com/luci/go-render v0.0.0-20160219211803-9a04cc21af0f/go.mod h1:aS446i8akEg0DAtNKTVYpNpLPMc0SzsZ0RtGhjl0uFM= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -893,6 +902,8 @@ github.com/testcontainers/testcontainers-go/modules/azurite v0.34.0 h1:dCRLvB/ot github.com/testcontainers/testcontainers-go/modules/azurite v0.34.0/go.mod h1:uQ+kO93Q1u7HC2OXdjsNNq+aRCJFnwGaFPlemMKyJSs= github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 h1:o3bgcECyBFfMwqexCH/6vIJ8XzbCffCP/Euesu33rgY= github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0/go.mod h1:ljLR42dN7k40CX0dp30R8BRIB3OOdvr7rBANEpfmMs4= +github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 h1:c51aBXT3v2HEBVarmaBnsKzvgZjC5amn0qsj8Naqi50= +github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0/go.mod h1:EWP75ogLQU4M4L8U+20mFipjV4WIR9WtlMXSB6/wiuc= github.com/testcontainers/testcontainers-go/modules/redis v0.34.0 h1:HkkKZPi6W2I+ywqplvnKOYRBKXQgpdxErBbdgx8F8nw= github.com/testcontainers/testcontainers-go/modules/redis v0.34.0/go.mod h1:iUkbN75F4E8WC5C1MfHbGOHOuKU7gOJfHjtwMT8G9QE= github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= diff --git a/retriever/postgresqlretriever/retriever.go b/retriever/postgresqlretriever/retriever.go new file mode 100644 index 00000000000..95309e12984 --- /dev/null +++ b/retriever/postgresqlretriever/retriever.go @@ -0,0 +1,111 @@ +package postgresqlretriever + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/thomaspoignant/go-feature-flag/retriever" + "github.com/thomaspoignant/go-feature-flag/utils/fflog" +) + +// Retriever is a configuration struct for a PostgreSQL connection +type Retriever struct { + // PostgreSQL connection URI + URI string + Type string + // PostgreSQL table where flag column is stored + Table string + // PostgreSQL column where flag definitions are stored + Column string + conn *pgx.Conn + status string + logger *fflog.FFLogger +} + +func (r *Retriever) Init(ctx context.Context, logger *fflog.FFLogger) error { + r.logger = logger + if r.conn == nil { + r.status = retriever.RetrieverNotReady + + conn, err := pgx.Connect(ctx, r.URI) + if err != nil { + r.status = retriever.RetrieverError + return err + } + + // Test the database connection + if err := conn.Ping(ctx); err != nil { + r.status = retriever.RetrieverError + return err + } + + r.conn = conn + r.status = retriever.RetrieverReady + } + + return nil +} + +// Status returns the current status of the retriever +func (r *Retriever) Status() retriever.Status { + if r == nil || r.status == "" { + return retriever.RetrieverNotReady + } + return r.status +} + +// Shutdown disconnects the retriever from Mongodb instance +func (r *Retriever) Shutdown(ctx context.Context) error { + r.conn.Close(ctx) + return nil +} + +// Retrieve Reads flag configuration from postgreSQL and returns it +// If a document does not comply with the specification it will be ignored +func (r *Retriever) Retrieve(ctx context.Context) ([]byte, error) { + query := fmt.Sprintf("SELECT %s FROM %s WHERE %s IS NOT NULL", r.Column, r.Table, r.Column) + rows, err := r.conn.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + mappedFlagDocs := make(map[string]interface{}) + for rows.Next() { + var jsonData []byte + + err := rows.Scan(&jsonData) + if err != nil { + return nil, err + } + + var doc map[string]interface{} + err = json.Unmarshal(jsonData, &doc) + + if err != nil { + r.logger.Error("Failed to unmarshal row:", err) + continue + } + + if val, ok := doc["flag"]; ok { + delete(doc, "flag") + if str, ok := val.(string); ok { + mappedFlagDocs[str] = doc + } else { + r.logger.Error("Flag key does not have a string value") + } + } else { + r.logger.Warn("No 'flag' entry found") + } + } + + flags, err := json.Marshal(mappedFlagDocs) + + if err != nil { + return nil, err + } + + return flags, nil +} diff --git a/retriever/postgresqlretriever/retriever_test.go b/retriever/postgresqlretriever/retriever_test.go new file mode 100644 index 00000000000..5ef2aebaa7e --- /dev/null +++ b/retriever/postgresqlretriever/retriever_test.go @@ -0,0 +1,170 @@ +package postgresqlretriever_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5" + _ "github.com/jackc/pgx/v5/stdlib" // Needed for the SQL container driver + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/thomaspoignant/go-feature-flag/retriever" + "github.com/thomaspoignant/go-feature-flag/retriever/postgresqlretriever" + "github.com/thomaspoignant/go-feature-flag/testutils" + "github.com/thomaspoignant/go-feature-flag/utils/fflog" + "go.mongodb.org/mongo-driver/bson" +) + +func Test_PostgreSQLRetriever_Retrieve(t *testing.T) { + ctx := context.Background() + + dbName := "flags_db" + dbUser := "root" + dbPassword := "example" + + tests := []struct { + name string + want []byte + data string + wantErr bool + }{ + { + name: "Returns well formed flag definition document", + data: testutils.MongoFindResultString, + want: []byte(testutils.QueryResult), + wantErr: false, + }, + { + name: "One of the Flag definition document does not have 'flag' key/value (ignore this document)", + data: testutils.MongoMissingFlagKey, + want: []byte(testutils.MissingFlagKeyResult), + wantErr: false, + }, + { + name: "Flag definition document 'flag' key does not have 'string' value (ignore this document)", + data: testutils.MongoFindResultFlagNoStr, + want: []byte(testutils.FlagKeyNotStringResult), + wantErr: false, + }, + { + name: "No flags found on DB", + want: []byte("{}"), + wantErr: true, + }, + } + + // Start the postgres container + ctr, err := postgres.Run( + ctx, + "postgres:16-alpine", + postgres.WithDatabase(dbName), + postgres.WithUsername(dbUser), + postgres.WithPassword(dbPassword), + postgres.BasicWaitStrategies(), + postgres.WithSQLDriver("pgx"), + ) + testcontainers.CleanupContainer(t, ctr) + require.NoError(t, err) + + // Run initialization query to create the table and the column + _, _, err = ctr.Exec(ctx, []string{"psql", "-U", dbUser, "-d", dbName, "-c", "CREATE TABLE flags (id SERIAL PRIMARY KEY,flag JSONB)"}) + require.NoError(t, err) + + // Create snapshot of the database, which is then restored before each test + err = ctr.Snapshot(ctx) + require.NoError(t, err) + + dbURL, err := ctr.ConnectionString(ctx) + require.NoError(t, err) + + for _, item := range tests { + // Restore the database state to its snapshot + err = ctr.Restore(ctx) + require.NoError(t, err) + + conn, err := pgx.Connect(context.Background(), dbURL) + require.NoError(t, err) + defer conn.Close(context.Background()) + + if item.data != "" { + // Insert data + var documents []bson.M + err = json.Unmarshal([]byte(item.data), &documents) + require.NoError(t, err) + + for _, doc := range documents { + _, err = conn.Exec(ctx, "INSERT INTO flags(flag) VALUES ($1)", doc) + require.NoError(t, err) + } + } + + // Initialize Retriever + mdb := postgresqlretriever.Retriever{ + URI: dbURL, + Table: "flags", + Column: "flag", + } + + assert.Equal(t, retriever.RetrieverNotReady, mdb.Status()) + err = mdb.Init(context.TODO(), &fflog.FFLogger{}) + assert.NoError(t, err) + defer func() { _ = mdb.Shutdown(context.TODO()) }() + assert.Equal(t, retriever.RetrieverReady, mdb.Status()) + + got, err := mdb.Retrieve(context.Background()) + if item.want == nil { + assert.Nil(t, got) + } else { + modifiedGot, err := removeIDFromJSON(string(got)) + require.NoError(t, err) + assert.JSONEq(t, string(item.want), modifiedGot) + } + + require.NoError(t, err) + } +} + +func Test_PostgreSQLRetriever_InvalidURI(t *testing.T) { + mdb := postgresqlretriever.Retriever{ + URI: "invalidURI", + Table: "xxx", + Column: "xxx", + } + assert.Equal(t, retriever.RetrieverNotReady, mdb.Status()) + err := mdb.Init(context.TODO(), &fflog.FFLogger{}) + assert.Error(t, err) + assert.Equal(t, retriever.RetrieverError, mdb.Status()) +} + +func removeIDFromJSON(jsonStr string) (string, error) { + var data interface{} + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return "", err + } + + removeIDFields(data) + + modifiedJSON, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(modifiedJSON), nil +} + +func removeIDFields(data interface{}) { + switch v := data.(type) { + case map[string]interface{}: + delete(v, "_id") + for _, value := range v { + removeIDFields(value) + } + case []interface{}: + for _, item := range v { + removeIDFields(item) + } + } +}