diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..90c3c8b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,87 @@ +name: "Build" +on: + push: + branches: + - main + pull_request: + types: [ opened, synchronize, reopened ] + +jobs: + Build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make vendor + - run: make build + Lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make lint + + Test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make test + + Coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make coverage + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + +# swagger-change: +# name: Swagger Change +# runs-on: ubuntu-latest +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# with: +# fetch-depth: 1 +# - name: Setup go +# uses: actions/setup-go@v5 +# with: +# go-version-file: go.mod +# check-latest: true +# - run: make swagger +# - run: git diff --exit-code --quiet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e27a230 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +out/ + +.DS_Store +.vscode +.idea +*.iml +.run + +# reporting stuff +*-report.xml +*.cov +coverage.xml +bin/ + + +# CI deployment folder +.deployment + +# goreleaser +dist/ +release/ + +vendor/ + +# venv created for the documentation script +.venv + +# binaries +release/ +cmd/migrationcli/migrationcli +cmd/relayproxy/relayproxy +tmp/ + +# Local dev files +goff-proxy.yaml +flags.yaml + +go-feature-flag-relay-proxy/ + +# AWS SAM build folder +.aws-sam + +node_modules/ +oryxBuildBinary + +./.sonarlint +sonarlint.xml +vcs.xml +workspace.xml +codeStyles/ +inspectionProfiles/ +misc.xml +modules.xml +shelf/ \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..cf73e7f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,81 @@ +linters: + enable: + - asciicheck + - bodyclose + - dogsled + - dupl + - funlen + - gochecknoinits + - gocognit + - goconst + - gocritic + - gocyclo + - revive + - gosec + - lll + - misspell + - nakedret + - noctx + - prealloc + - rowserrcheck + - copyloopvar + - stylecheck + - unconvert + - unparam + - whitespace + - gofmt + - gci +# - gofumpt +linters-settings: + funlen: + lines: 90 + statements: 50 + gocritic: + disabled-checks: + - singleCaseSwitch + golint: + min-confidence: 0.6 + gosimple: + checks: ["all","-S1023"] + gofumpt: + module-path: github.com/thomaspoignant/go-feature-flag + gci: + skip-generated: true + no-lex-order: true +issues: + exclude-dirs: + - (^|/)bin($|/) + - (^|/)examples($|/) + exclude-rules: + - path: _test.go + linters: + - funlen + - maligned + - noctx + - scopelint + - bodyclose + - lll + - goconst + - gocognit + - gocyclo + - gochecknoinits + - dupl + - staticcheck + - revive + - gosec + - copyloopvar + - path: _mock.go + linters: + - funlen + - maligned + - noctx + - scopelint + - bodyclose + - lll + - goconst + - gocognit + - gocyclo + - gochecknoinits + - dupl + - staticcheck + - revive diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4a86ec7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/Bahjat/pre-commit-golang + rev: v1.0.3 + hooks: + - id: gofumpt + + - repo: https://github.com/golangci/golangci-lint + rev: v1.59.0 + hooks: + - id: golangci-lint + entry: golangci-lint run --enable-only=gci --fix + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.11.0 + hooks: + - id: black + language_version: python3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6214e66 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +GOCMD=go +GOTEST=$(GOCMD) test +GOVET=$(GOCMD) vet + +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +WHITE := $(shell tput -Txterm setaf 7) +CYAN := $(shell tput -Txterm setaf 6) +RESET := $(shell tput -Txterm sgr0) + +.PHONY: all test build vendor + + + +all: help +## Build: +build: build-api ## Build all the binaries and put the output in out/bin/ + +create-out-dir: + mkdir -p out/bin + +build-api: create-out-dir ## Build the migration cli in out/bin/ + CGO_ENABLED=0 GO111MODULE=on $(GOCMD) build -mod vendor -o out/bin/goff-api . + +clean: ## Remove build related file + -rm -fr ./bin ./out ./release + -rm -f ./junit-report.xml checkstyle-report.xml ./coverage.xml ./profile.cov yamllint-checkstyle.xml + +vendor: ## Copy of all packages needed to support builds and tests in the vendor directory + $(GOCMD) mod tidy + $(GOCMD) mod vendor + +## Dev: +swagger: ## Build swagger documentation + $(GOCMD) install github.com/swaggo/swag/cmd/swag@latest + cd cmd/relayproxy && swag init --parseDependency --parseDepth=1 --parseInternal --markdownFiles docs + +setup-env: + docker stop goff || true + docker rm goff || true + docker run --name goff -e POSTGRES_DB=gofeatureflag -e POSTGRES_PASSWORD=my-secret-pw -p 5432:5432 -e POSTGRES_USER=goff-user -d postgres + sleep 2 + migrate -source "file://database_migration" -database "postgres://goff-user:my-secret-pw@localhost:5432/gofeatureflag?sslmode=disable" up + +## Test: +test: ## Run the tests of the project + $(GOTEST) -v -race ./... + +coverage: ## Run the tests of the project and export the coverage + $(GOTEST) -cover -covermode=count -tags=docker -coverprofile=coverage.cov.tmp ./... \ + && cat coverage.cov.tmp | grep -v "/examples/" > coverage.cov + + +## Lint: +lint: ## Use golintci-lint on your project + mkdir -p ./bin + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s latest # Install linters + ./bin/golangci-lint run --timeout=5m --timeout=5m ./... --enable-only=gci --fix # Run linters + +## Help: +help: ## Show this help. + @echo '' + @echo 'Usage:' + @echo ' ${YELLOW}make${RESET} ${GREEN}${RESET}' + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} { \ + if (/^[a-zA-Z_-]+:.*?##.*$$/) {printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n", $$1, $$2} \ + else if (/^## .*$$/) {printf " ${CYAN}%s${RESET}\n", substr($$1,4)} \ + }' $(MAKEFILE_LIST) diff --git a/README.md b/README.md index e5c2e37..de0c1e3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ -# app-api -API for https://app.gofeatureflag.org to manage your feature flags. +# GO Feature Flag API - API to configure your feature flag +![WIP](https://img.shields.io/badge/status-%E2%9A%A0%EF%B8%8FWIP-red) + +This repository is a work in progress initiative to create an API to manage your feature flags. + +## Goals +- Create an API to manage your feature flags +- API should allow to add, modify and delete feature flags. +- Use a database to store the feature flags. +- This API is created to integrate a front end application to manage the feature flags. +- We should manage authentication and authorization to access the API. + - Authentication should be generic enough to be integrated with any authentication provider. +- We should be able to provide history of a flag to see when it was created, modified and deleted. + +## Tech stack +- GO API using echo +- Postgres database using `sqlx` and `pq` as driver. + + + +## Contributing +⚠️ Since this it is a work in progress initiative please come to the [Slack channel](https://gofeatureflag.org/slack) first before contributing. + +### How to start the project. +After cloning the project you can start the database _(using docker)_: +```shell +make setup-env +``` +It will start an instance of postgres with the following credentials: +- user: `goff-user` +- password: `my-secret-pw` + +And it will apply the database migrations to your environment. + +To start the API: +```shell +make build +./out/bin/goff-api +``` \ No newline at end of file diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..c0433e4 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +ignore: + - "examples" + - "testdata" + - "testutils" + - "**/_test.go" + - "**/_mock.go" + - "**/testdata/" + - "**/testutils/" \ No newline at end of file diff --git a/dao/dbmodel/feature_flag.go b/dao/dbmodel/feature_flag.go new file mode 100644 index 0000000..d9fcfe2 --- /dev/null +++ b/dao/dbmodel/feature_flag.go @@ -0,0 +1,119 @@ +package dbmodel + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/go-feature-flag/app-api/model" + "github.com/google/uuid" +) + +type JSONB json.RawMessage + +func (j JSONB) Value() (driver.Value, error) { + valueString, err := json.Marshal(j) + return string(valueString), err +} + +func (j *JSONB) Scan(value interface{}) error { + return json.Unmarshal(value.([]byte), &j) +} + +type FeatureFlag struct { + ID uuid.UUID `db:"id"` + Name string `db:"name"` + Description *string `db:"description"` + Variations JSONB `db:"variations"` + Type model.FlagType `db:"type"` + BucketingKey *string `db:"bucketing_key"` + Metadata JSONB `db:"metadata"` + TrackEvents *bool `db:"track_events"` + Disable *bool `db:"disable"` + Version *string `db:"version"` + CreatedDate time.Time `db:"created_date"` + LastUpdatedDate time.Time `db:"last_updated_date"` + LastModifiedBy string `db:"last_modified_by"` +} + +func FromModelFeatureFlag(mff model.FeatureFlag) (FeatureFlag, error) { + id, err := uuid.Parse(mff.ID) + if err != nil { + return FeatureFlag{}, err + } + + variations, err := json.Marshal(mff.Variations) + if err != nil { + return FeatureFlag{}, err + } + + var metadata JSONB + if mff.Metadata != nil { + metadataBytes, err := json.Marshal(mff.Metadata) + if err != nil { + return FeatureFlag{}, err + } + metadata = JSONB(metadataBytes) + } + + return FeatureFlag{ + ID: id, + Name: mff.Name, + Description: mff.Description, + Variations: JSONB(variations), + Type: mff.VariationType, + BucketingKey: mff.BucketingKey, + Metadata: metadata, + TrackEvents: mff.TrackEvents, + Disable: mff.Disable, + Version: mff.Version, + CreatedDate: mff.CreatedDate, + LastUpdatedDate: mff.LastUpdatedDate, + LastModifiedBy: mff.LastModifiedBy, + }, nil +} + +func (ff *FeatureFlag) ToAPI(rules []Rule) (model.FeatureFlag, error) { + var apiRules = make([]model.Rule, 0) + var defaultRule *model.Rule + for _, rule := range rules { + convertedRule, err := rule.ToAPI() + if err != nil { + return model.FeatureFlag{}, err + } + if rule.IsDefault { + defaultRule = &convertedRule + continue + } + apiRules = append(apiRules, convertedRule) + } + + var variations map[string]*interface{} + err := json.Unmarshal(ff.Variations, &variations) + if err != nil { + return model.FeatureFlag{}, err + } + + var metadata map[string]interface{} + err = json.Unmarshal(ff.Metadata, &metadata) + if err != nil { + return model.FeatureFlag{}, err + } + + return model.FeatureFlag{ + ID: ff.ID.String(), + Name: ff.Name, + Description: ff.Description, + Variations: &variations, + VariationType: ff.Type, + BucketingKey: ff.BucketingKey, + Metadata: &metadata, + TrackEvents: ff.TrackEvents, + Disable: ff.Disable, + Version: ff.Version, + CreatedDate: ff.CreatedDate, + LastUpdatedDate: ff.LastUpdatedDate, + Rules: &apiRules, + DefaultRule: defaultRule, + }, nil +} diff --git a/dao/dbmodel/rule.go b/dao/dbmodel/rule.go new file mode 100644 index 0000000..d9ee6f6 --- /dev/null +++ b/dao/dbmodel/rule.go @@ -0,0 +1,116 @@ +package dbmodel + +import ( + "encoding/json" + "time" + + "github.com/go-feature-flag/app-api/model" + "github.com/google/uuid" +) + +type Rule struct { + ID uuid.UUID `db:"id"` + FeatureFlagID uuid.UUID `db:"feature_flag_id"` + IsDefault bool `db:"is_default"` + Name string `db:"name"` + Query string `db:"query"` + VariationResult *string `db:"variation_result"` + Percentages *string `db:"percentages"` // JSONB is stored as string + Disable bool `db:"disable"` + ProgressiveRolloutInitialVariation *string `db:"progressive_rollout_initial_variation"` + ProgressiveRolloutEndVariation *string `db:"progressive_rollout_end_variation"` + ProgressiveRolloutInitialPercentage *float64 `db:"progressive_rollout_initial_percentage"` + ProgressiveRolloutEndPercentage *float64 `db:"progressive_rollout_end_percentage"` + ProgressiveRolloutStartDate *time.Time `db:"progressive_rollout_start_date"` + ProgressiveRolloutEndDate *time.Time `db:"progressive_rollout_end_date"` + OrderIndex int `db:"order_index"` +} + +func FromModelRule(mr model.Rule, featureFlagID uuid.UUID, isDefault bool, orderIndex int) (Rule, error) { + var id uuid.UUID + if mr.ID != "" { + var err error + id, err = uuid.Parse(mr.ID) + if err != nil { + return Rule{}, err + } + } else { + id = uuid.New() + } + + var percentages *string + if mr.Percentages != nil { + percentagesJSON, err := json.Marshal(mr.Percentages) + if err != nil { + return Rule{}, err + } + percentagesStr := string(percentagesJSON) + percentages = &percentagesStr + } + + dbr := Rule{ + ID: id, + FeatureFlagID: featureFlagID, + IsDefault: isDefault, + Name: mr.Name, + Query: mr.Query, + Disable: mr.Disable, + OrderIndex: orderIndex, + } + + if mr.VariationResult != nil { + dbr.VariationResult = mr.VariationResult + } + + if percentages != nil { + dbr.Percentages = percentages + } + + if mr.ProgressiveRollout != nil { + dbr.ProgressiveRolloutInitialVariation = mr.ProgressiveRollout.Initial.Variation + dbr.ProgressiveRolloutEndVariation = mr.ProgressiveRollout.End.Variation + dbr.ProgressiveRolloutInitialPercentage = mr.ProgressiveRollout.Initial.Percentage + dbr.ProgressiveRolloutEndPercentage = mr.ProgressiveRollout.End.Percentage + dbr.ProgressiveRolloutStartDate = mr.ProgressiveRollout.Initial.Date + dbr.ProgressiveRolloutEndDate = mr.ProgressiveRollout.End.Date + } + return dbr, nil +} + +func (rule *Rule) ToAPI() (model.Rule, error) { + apiRule := model.Rule{ + ID: rule.ID.String(), + Name: rule.Name, + Query: rule.Query, + Disable: rule.Disable, + } + + if rule.VariationResult != nil { + apiRule.VariationResult = rule.VariationResult + } + + if rule.Percentages != nil { + var percentages map[string]float64 + err := json.Unmarshal([]byte(*rule.Percentages), &percentages) + if err != nil { + return model.Rule{}, err + } + apiRule.Percentages = &percentages + } + + if rule.ProgressiveRolloutInitialVariation != nil || rule.ProgressiveRolloutEndVariation != nil { + apiRule.ProgressiveRollout = &model.ProgressiveRollout{ + Initial: &model.ProgressiveRolloutStep{ + Variation: rule.ProgressiveRolloutInitialVariation, + Percentage: rule.ProgressiveRolloutInitialPercentage, + Date: rule.ProgressiveRolloutStartDate, + }, + End: &model.ProgressiveRolloutStep{ + Variation: rule.ProgressiveRolloutEndVariation, + Percentage: rule.ProgressiveRolloutEndPercentage, + Date: rule.ProgressiveRolloutEndDate, + }, + } + } + return apiRule, nil +} diff --git a/dao/flags.go b/dao/flags.go new file mode 100644 index 0000000..5530637 --- /dev/null +++ b/dao/flags.go @@ -0,0 +1,27 @@ +package dao + +import ( + "context" + + "github.com/go-feature-flag/app-api/model" +) + +type Flags interface { + // GetFlags return all the flags + GetFlags(ctx context.Context) ([]model.FeatureFlag, error) + + // GetFlagById return a flag by its ID + GetFlagByID(ctx context.Context, id string) (model.FeatureFlag, error) + + // GetFlagByName return a flag by its name + GetFlagByName(ctx context.Context, name string) (model.FeatureFlag, error) + + // CreateFlag create a new flag, return the id of the flag + CreateFlag(ctx context.Context, flag model.FeatureFlag) (string, error) + + // UpdateFlag update a flag + UpdateFlag(ctx context.Context, flag model.FeatureFlag) error + + // DeleteFlagByID delete a flag + DeleteFlagByID(ctx context.Context, id string) error +} diff --git a/dao/postgres_impl.go b/dao/postgres_impl.go new file mode 100644 index 0000000..381a0c2 --- /dev/null +++ b/dao/postgres_impl.go @@ -0,0 +1,365 @@ +package dao + +import ( + "context" + "errors" + "fmt" + + "github.com/go-feature-flag/app-api/dao/dbmodel" + "github.com/go-feature-flag/app-api/model" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" // we import the driver used by sqlx +) + +func NewPostgresDao(serverHost string, port int, database string, username string, password string) (Flags, error) { + // TODO: add checks for the input parameters + // TODO: close the connection when the dao is closed + + connectionString := fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=disable", username, password, serverHost, port, database) + + conn, err := sqlx.Connect("postgres", connectionString) + if err != nil { + return nil, fmt.Errorf("testConnection: database connection is nil") + } + instance := &pgFlagImpl{ + conn: conn, + } + + return instance, nil +} + +type pgFlagImpl struct { + conn *sqlx.DB +} + +// GetFlags return all the flags +func (m *pgFlagImpl) GetFlags(ctx context.Context) ([]model.FeatureFlag, error) { + var f []dbmodel.FeatureFlag + err := m.conn.SelectContext(ctx, &f, "SELECT * FROM feature_flags") + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []model.FeatureFlag{}, nil + } + return []model.FeatureFlag{}, err + } + res := make([]model.FeatureFlag, 0, len(f)) + for _, flag := range f { + var rules []dbmodel.Rule + err := m.conn.SelectContext(ctx, &rules, `SELECT * FROM rules WHERE feature_flag_id = $1`, flag.ID) + if err != nil { + return []model.FeatureFlag{}, err + } + + convertedFlag, err := flag.ToAPI(rules) + if err != nil { + return []model.FeatureFlag{}, err + } + res = append(res, convertedFlag) + } + return res, nil +} + +// GetFlagByID return a flag by its ID +func (m *pgFlagImpl) GetFlagByID(ctx context.Context, id string) (model.FeatureFlag, error) { + var f dbmodel.FeatureFlag + err := m.conn.GetContext(ctx, &f, `SELECT * FROM feature_flags WHERE id = $1`, id) + if err != nil { + return model.FeatureFlag{}, err + } + + var rules []dbmodel.Rule + errRule := m.conn.SelectContext( + ctx, + &rules, + `SELECT * FROM rules WHERE feature_flag_id = $1 ORDER BY order_index`, f.ID) + + if errRule != nil { + return model.FeatureFlag{}, errRule + } + + convertedFlag, err := f.ToAPI(rules) + if err != nil { + return model.FeatureFlag{}, err + } + return convertedFlag, nil +} + +// GetFlagByName return a flag by its name +func (m *pgFlagImpl) GetFlagByName(ctx context.Context, name string) (model.FeatureFlag, error) { + var f dbmodel.FeatureFlag + err := m.conn.GetContext(ctx, &f, `SELECT * FROM feature_flags WHERE name = $1`, name) + if err != nil { + return model.FeatureFlag{}, err + } + + var rules []dbmodel.Rule + errRule := m.conn.SelectContext(ctx, &rules, + `SELECT * FROM rules WHERE feature_flag_id = $1 ORDER BY order_index DESC`, f.ID) + if errRule != nil { + return model.FeatureFlag{}, errRule + } + + convertedFlag, err := f.ToAPI(rules) + if err != nil { + return model.FeatureFlag{}, err + } + return convertedFlag, nil +} + +// CreateFlag create a new flag, return the id of the flag +func (m *pgFlagImpl) CreateFlag(ctx context.Context, flag model.FeatureFlag) (string, error) { + dbFeatureFlag, err := dbmodel.FromModelFeatureFlag(flag) + if err != nil { + return "", err + } + + tx, err := m.conn.Beginx() + if err != nil { + return "", err + } + defer func() { _ = tx.Commit() }() + _, err = tx.NamedExecContext( + ctx, + `INSERT INTO feature_flags ( + id, + name, + description, + variations, + type, + bucketing_key, + metadata, + track_events, + disable, + version, + created_date, + last_updated_date, + last_modified_by) + VALUES ( + :id, + :name, + :description, + :variations, + :type, + :bucketing_key, + :metadata, + :track_events, + :disable, + :version, + :created_date, + :last_updated_date, + :last_modified_by)`, + dbFeatureFlag) + if err != nil { + _ = tx.Rollback() + return "", err + } + + if flag.DefaultRule == nil { + return "", fmt.Errorf("default rule is required") + } + err = m.insertRule(ctx, *flag.DefaultRule, true, dbFeatureFlag.ID, tx, -1) + if err != nil { + _ = tx.Rollback() + return "", err + } + + if flag.Rules != nil { + for index, rule := range *flag.Rules { + err = m.insertRule(ctx, rule, false, dbFeatureFlag.ID, tx, index) + if err != nil { + _ = tx.Rollback() + return "", err + } + } + } + + err = tx.Commit() + if err != nil { + _ = tx.Rollback() + return "", err + } + return dbFeatureFlag.ID.String(), nil +} + +func (m *pgFlagImpl) UpdateFlag(ctx context.Context, flag model.FeatureFlag) error { + dbQuery, err := dbmodel.FromModelFeatureFlag(flag) + if err != nil { + return err + } + tx, err := m.conn.Beginx() + if err != nil { + return err + } + + flagOrder := map[string]int{} + for i, rule := range flag.GetRules() { + flagOrder[rule.ID] = i + } + + dbFF, err := m.GetFlagByID(ctx, flag.ID) + if err != nil { + return err + } + + // update default rule + if flag.DefaultRule == nil { + return fmt.Errorf("default rule is required") + } + + if err := m.updateRule(ctx, flag.GetDefaultRule(), true, dbQuery.ID, tx, -1); err != nil { + _ = tx.Rollback + return err + } + + listExistingRuleIDs := make(map[string]model.Rule) + for _, rule := range dbFF.GetRules() { + listExistingRuleIDs[rule.ID] = rule + } + listNewRuleIDs := make(map[string]model.Rule) + for _, rule := range flag.GetRules() { + listNewRuleIDs[rule.ID] = rule + } + + var toDelete, toCreate, toUpdate []string + for id := range listExistingRuleIDs { + if _, found := listNewRuleIDs[id]; found { + toUpdate = append(toUpdate, id) + } else { + toDelete = append(toDelete, id) + } + } + + for id := range listNewRuleIDs { + if _, found := listExistingRuleIDs[id]; !found { + toCreate = append(toCreate, id) + } + } + + // Delete rules + for _, id := range toDelete { + if _, err := tx.ExecContext(ctx, `DELETE FROM rules WHERE id = $1`, id); err != nil { + _ = tx.Rollback + return err + } + } + + for _, id := range toCreate { + rule := listNewRuleIDs[id] + if err := m.insertRule(ctx, rule, false, dbQuery.ID, tx, flagOrder[dbQuery.ID.String()]); err != nil { + _ = tx.Rollback + return err + } + } + + for _, id := range toUpdate { + rule := listNewRuleIDs[id] + if err = m.updateRule(ctx, rule, false, dbQuery.ID, tx, flagOrder[dbQuery.ID.String()]); err != nil { + _ = tx.Rollback + return err + } + } + + return tx.Commit() +} + +func (m *pgFlagImpl) DeleteFlagByID(ctx context.Context, id string) error { + tx, err := m.conn.Beginx() + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, `DELETE FROM rules WHERE feature_flag_id = $1`, id) + if err != nil { + _ = tx.Rollback() + return err + } + + _, err = tx.ExecContext(ctx, `DELETE FROM feature_flags WHERE id = $1`, id) + if err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (m *pgFlagImpl) insertRule( + ctx context.Context, + rule model.Rule, + isDefault bool, + featureFlagID uuid.UUID, + tx *sqlx.Tx, + orderIndex int) error { + r, err := dbmodel.FromModelRule(rule, featureFlagID, isDefault, orderIndex) + if err != nil { + return err + } + + _, errTx := tx.NamedExecContext( + ctx, + `INSERT INTO rules ( + id, + feature_flag_id, + is_default, + name, + query, + variation_result, + percentages, + disable, + progressive_rollout_initial_variation, + progressive_rollout_end_variation, + progressive_rollout_initial_percentage, + progressive_rollout_end_percentage, + progressive_rollout_start_date, + progressive_rollout_end_date, + order_index) + VALUES ( + :id, + :feature_flag_id, + :is_default, + :name, + :query, + :variation_result, + :percentages, + :disable, + :progressive_rollout_initial_variation, + :progressive_rollout_end_variation, + :progressive_rollout_initial_percentage, + :progressive_rollout_end_percentage, + :progressive_rollout_start_date, + :progressive_rollout_end_date, + :order_index)`, + r) + return errTx +} + +func (m *pgFlagImpl) updateRule( + ctx context.Context, + rule model.Rule, + isDefault bool, + featureFlagID uuid.UUID, + tx *sqlx.Tx, orderIndex int) error { + r, err := dbmodel.FromModelRule(rule, featureFlagID, isDefault, orderIndex) + if err != nil { + return err + } + + _, errTx := tx.NamedExecContext(ctx, + `UPDATE rules SET + name=:name, + query=:query, + variation_result=:variation_result, + percentages=:percentages, + disable=:disable, + progressive_rollout_initial_variation=:progressive_rollout_initial_variation, + progressive_rollout_end_variation=:progressive_rollout_end_variation, + progressive_rollout_initial_percentage=:progressive_rollout_initial_percentage, + progressive_rollout_end_percentage=:progressive_rollout_end_percentage, + progressive_rollout_start_date=:progressive_rollout_start_date, + progressive_rollout_end_date=:progressive_rollout_end_date + WHERE id=:id`, r) + + return errTx +} diff --git a/database_migration/0001_create_feature_flags_tables.down.sql b/database_migration/0001_create_feature_flags_tables.down.sql new file mode 100644 index 0000000..3778e8c --- /dev/null +++ b/database_migration/0001_create_feature_flags_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS rules; +DROP TABLE IF EXISTS feature_flags; +DROP TYPE IF EXISTS variationType; diff --git a/database_migration/0001_create_feature_flags_tables.up.sql b/database_migration/0001_create_feature_flags_tables.up.sql new file mode 100644 index 0000000..4238498 --- /dev/null +++ b/database_migration/0001_create_feature_flags_tables.up.sql @@ -0,0 +1,47 @@ +CREATE TYPE variationType AS ENUM ('string','boolean','integer','double','json'); +CREATE TABLE IF NOT EXISTS feature_flags +( + id UUID NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE CHECK (name <> ''), + description TEXT, + variations JSONB NOT NULL, + type variationType NOT NULL, + bucketing_key TEXT, + metadata JSONB, + track_events BOOLEAN DEFAULT TRUE, + disable BOOLEAN DEFAULT FALSE, + version TEXT, + created_date TIMESTAMP NOT NULL, + last_updated_date TIMESTAMP NOT NULL, + last_modified_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS rules +( + id UUID NOT NULL PRIMARY KEY, + feature_flag_id UUID REFERENCES feature_flags (id) NOT NULL, + name TEXT, + query TEXT, + variation_result TEXT, + percentages JSONB, + disable BOOLEAN DEFAULT FALSE NOT NULL, + progressive_rollout_initial_variation TEXT, + progressive_rollout_end_variation TEXT, + progressive_rollout_initial_percentage FLOAT, + progressive_rollout_end_percentage FLOAT, + progressive_rollout_start_date TIMESTAMP, + progressive_rollout_end_date TIMESTAMP, + is_default BOOLEAN DEFAULT FALSE NOT NULL, + order_index INTEGER NOT NULL, + CONSTRAINT rule_return_something CHECK (percentages IS NOT NULL + OR variation_result IS NOT NULL + OR (progressive_rollout_initial_variation IS NOT NULL + AND progressive_rollout_end_variation IS NOT NULL + AND progressive_rollout_start_date IS NOT NULL + AND progressive_rollout_end_date IS NOT NULL + ) + ) +); + +CREATE INDEX idx_feature_flags_name ON feature_flags (name); +CREATE INDEX idx_rules_feature_flag_id ON rules (feature_flag_id); diff --git a/database_migration/REAME.md b/database_migration/REAME.md new file mode 100644 index 0000000..24d540d --- /dev/null +++ b/database_migration/REAME.md @@ -0,0 +1,14 @@ +# Database Migration process +This document describes the process of migrating the database from one version to another. +We are using the [`go-migrate` cli](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate) to manage the database migrations. + + +``` +docker run --name goff --rm -e POSTGRES_PASSWORD=my-secret-pw -p 5432:5432 -e POSTGRES_USER=goff-user -d postgres +``` + + +```shell +migrate -source "file:///Users/thomas.poignant/dev/thomaspoignant/app-api/database_migration" \ + -database "postgres://goff-user:my-secret-pw@localhost:5432/gofeatureflag?sslmode=disable" down +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33189c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/go-feature-flag/app-api + +go 1.22.5 + +toolchain go1.22.7 + +require ( + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.1 + github.com/jmoiron/sqlx v1.4.0 + github.com/labstack/echo/v4 v4.12.0 + github.com/lib/pq v1.10.9 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/time v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a60e4c8 --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/flags.go b/handler/flags.go new file mode 100644 index 0000000..54401c8 --- /dev/null +++ b/handler/flags.go @@ -0,0 +1,135 @@ +package handler + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-feature-flag/app-api/dao" + "github.com/go-feature-flag/app-api/model" + "github.com/labstack/echo/v4" +) + +type Flags struct { + dao dao.Flags +} + +func NewFlags(dao dao.Flags) Flags { + return Flags{dao: dao} +} + +func (f Flags) GetAllFeatureFlags(c echo.Context) error { + flags, err := f.dao.GetFlags(c.Request().Context()) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, err) + } + return c.JSON(http.StatusOK, flags) +} + +func (f Flags) GetFeatureFlagsByID(c echo.Context) error { + flag, err := f.dao.GetFlagByID(c.Request().Context(), c.Param("id")) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, flag) +} + +func (f Flags) CreateNewFlag(c echo.Context) error { + var flag model.FeatureFlag + if err := c.Bind(&flag); err != nil { + fmt.Println(err) + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + // Check if flag with this name exists + res, err := f.dao.GetFlagByName(c.Request().Context(), flag.Name) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + if res.ID != "" { + return c.JSON(http.StatusConflict, map[string]string{"error": "flag with this name already exists"}) + } + + // Create the flag + flag.CreatedDate = time.Now() + // TODO: remove this line + flag.LastModifiedBy = "toto" + + id, err := f.dao.CreateFlag(c.Request().Context(), flag) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + flag.ID = id + return c.JSON(http.StatusOK, flag) +} + +func (f Flags) UpdateFlagByID(c echo.Context) error { + // check if the flag exists + _, err := f.dao.GetFlagByID(c.Request().Context(), c.Param("id")) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + // update the flag + var flag model.FeatureFlag + if err := c.Bind(&flag); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + if flag.ID == "" { + flag.ID = c.Param("id") + } + + flag.LastUpdatedDate = time.Now() + + err = f.dao.UpdateFlag(c.Request().Context(), flag) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, flag) +} + +func (f Flags) DeleteFlagByID(c echo.Context) error { + err := f.dao.DeleteFlagByID(c.Request().Context(), c.Param("id")) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, nil) +} + +func (f Flags) UpdateFeatureFlagStatus(c echo.Context) error { + flag, err := f.dao.GetFlagByID(c.Request().Context(), c.Param("id")) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + // FeatureFlagStatusUpdate represents the input for updating the status of a feature flag. + type FeatureFlagStatusUpdate struct { + Disable bool `json:"disable"` + } + + var statusUpdate FeatureFlagStatusUpdate + if err := c.Bind(&statusUpdate); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + flag.Disable = &statusUpdate.Disable + flag.LastUpdatedDate = time.Now() + err = f.dao.UpdateFlag(c.Request().Context(), flag) + if err != nil { + fmt.Println(err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, flag) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bdedfb7 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "github.com/go-feature-flag/app-api/dao" + "github.com/go-feature-flag/app-api/handler" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func main() { + data, err := dao.NewPostgresDao("localhost", 5432, "gofeatureflag", "goff-user", "my-secret-pw") + if err != nil { + panic(err) + } + + handlers := handler.NewFlags(data) + + e := echo.New() + e.Use(middleware.CORSWithConfig(middleware.DefaultCORSConfig)) + groupV1 := e.Group("/v1") + groupV1.GET("/flags", handlers.GetAllFeatureFlags) + groupV1.GET("/flags/:id", handlers.GetFeatureFlagsByID) + groupV1.POST("/flags", handlers.CreateNewFlag) + groupV1.PUT("/flags/:id", handlers.UpdateFlagByID) + groupV1.DELETE("/flags/:id", handlers.DeleteFlagByID) + groupV1.PATCH("/flags/:id/status", handlers.UpdateFeatureFlagStatus) + e.Logger.Fatal(e.Start(":3001")) +} diff --git a/model/flags.go b/model/flags.go new file mode 100644 index 0000000..edb1a09 --- /dev/null +++ b/model/flags.go @@ -0,0 +1,120 @@ +package model + +import ( + "time" +) + +type FlagType string + +const ( + FlagTypeBoolean FlagType = "boolean" + FlagTypeString FlagType = "string" + FlagTypeInteger FlagType = "integer" + FlagTypeDouble FlagType = "double" + FlagTypeJSON FlagType = "json" +) + +type FeatureFlag struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + CreatedDate time.Time `json:"createdDate" db:"created_date"` + LastUpdatedDate time.Time `json:"lastUpdatedDate" db:"last_updated_date"` + LastModifiedBy string `json:"LastModifiedBy" db:"last_modified_by"` + Description *string `json:"description" db:"description"` + VariationType FlagType `json:"type" db:"type"` + // Variations are all the variations available for this flag. The minimum is 2 variations and, we don't have any max + // limit except if the variationValue is a bool, the max is 2. + Variations *map[string]*interface{} `json:"variations,omitempty"` + + // Rules is the list of Rule for this flag. + // This an optional field. + Rules *[]Rule `json:"targeting,omitempty"` // nolint: lll + + // BucketingKey defines a source for a dynamic targeting key + BucketingKey *string `json:"bucketingKey,omitempty"` + + // DefaultRule is the rule applied after checking that any other rules + // matched the user. + DefaultRule *Rule `json:"defaultRule,omitempty"` // nolint: lll + + // Experimentation is your struct to configure an experimentation. + // It will allow you to configure a start date and an end date for your flag. + // When the experimentation is not running, the flag will serve the default value. + // Experimentation *ExperimentationDto `json:"experimentation,omitempty""` + + // Metadata is a field containing information about your flag such as an issue tracker link, a description, etc ... + Metadata *map[string]interface{} `json:"metadata,omitempty"` // nolint: lll + + // Disable is true if the flag is disabled. + Disable *bool `json:"disable,omitempty" yaml:"disable,omitempty" toml:"disable,omitempty"` + + // Version (optional) This field contains the version of the flag. + // The version is manually managed when you configure your flags and, it is used to display the information + // in the notifications and data collection. + Version *string `json:"version,omitempty" yaml:"version,omitempty" toml:"version,omitempty"` + + // TrackEvents is false if you don't want to export the data in your data exporter. + // Default value is true + TrackEvents *bool `json:"trackEvents,omitempty" yaml:"trackEvents,omitempty" toml:"trackEvents,omitempty"` +} + +type Rule struct { + // Id of the rule + ID string `json:"id" db:"id"` + // Name is the name of the rule, this field is mandatory if you want + // to update the rule during scheduled rollout + Name string `json:"name,omitempty"` + // Query represents an antlr query in the nikunjy/rules format + Query string `json:"query,omitempty"` + + // VariationResult represents the variation name to use if the rule apply for the user. + // In case we have a percentage field in the config VariationResult is ignored + VariationResult *string `json:"variation,omitempty"` // nolint: lll + + // Percentages represents the percentage we should give to each variation. + // example: variationA = 10%, variationB = 80%, variationC = 10% + Percentages *map[string]float64 `json:"percentage,omitempty" ` // nolint: lll + + // ProgressiveRollout is your struct to configure a progressive rollout deployment of your flag. + // It will allow you to ramp up the percentage of your flag over time. + // You can decide at which percentage you starts with and at what percentage you ends with in your release ramp. + // Before the start date we will serve the initial percentage and, after we will serve the end percentage. + ProgressiveRollout *ProgressiveRollout `json:"progressiveRollout,omitempty" yaml:"progressiveRollout,omitempty" toml:"progressiveRollout,omitempty" jsonschema:"title=progressiveRollout,description=Configure a progressive rollout deployment of your flag."` // nolint: lll + + // Disable indicates that this rule is disabled. + Disable bool `json:"disable,omitempty" ` // nolint: lll +} + +type ProgressiveRollout struct { + // Initial contains a description of the initial state of the rollout. + Initial *ProgressiveRolloutStep `json:"initial,omitempty" yaml:"initial,omitempty" toml:"initial,omitempty" jsonschema:"title=initial,description=A description of the initial state of the rollout."` // nolint: lll + + // End contains what describes the end status of the rollout. + End *ProgressiveRolloutStep `json:"end,omitempty" yaml:"end,omitempty" toml:"end,omitempty" jsonschema:"title=initial,description=A description of the end state of the rollout."` // nolint: lll +} + +// ProgressiveRolloutStep define a progressive rollout step (initial and end) +type ProgressiveRolloutStep struct { + // Variation - name of the variation for this step + Variation *string `json:"variation,omitempty" yaml:"variation,omitempty" toml:"variation,omitempty" jsonschema:"required,title=variation,description=Name of the variation to apply."` // nolint: lll + + // Percentage is the percentage (initial or end) for the progressive rollout + Percentage *float64 `json:"percentage,omitempty" yaml:"percentage,omitempty" toml:"percentage,omitempty" jsonschema:"required,title=percentage,description=The percentage (initial or end) for the progressive rollout."` // nolint: lll + + // Date is the time it starts or ends. + Date *time.Time `json:"date,omitempty" yaml:"date,omitempty" toml:"date,omitempty" jsonschema:"required,title=date,description=Date is the time it starts or ends."` // nolint: lll +} + +func (ff *FeatureFlag) GetRules() []Rule { + if ff.Rules == nil { + return []Rule{} + } + return *ff.Rules +} + +func (ff *FeatureFlag) GetDefaultRule() Rule { + if ff.DefaultRule == nil { + return Rule{} + } + return *ff.DefaultRule +} diff --git a/testdata/flag.yaml b/testdata/flag.yaml new file mode 100644 index 0000000..f6b36bf --- /dev/null +++ b/testdata/flag.yaml @@ -0,0 +1,92 @@ +app-enable-query-builder: + variations: + enabledddd: giefohdho + vfds: feslhds + targeting: [] + defaultRule: + name: defaultRule + variation: enabledddd + disable: false + metadata: + gofeatureflag_createdDate: 0001-01-01T00:00:00Z + gofeatureflag_description: "" + gofeatureflag_id: 28d977e9-c32b-45c9-8ef9-e6076d53efa6 + gofeatureflag_lastUpdatedDate: 2024-09-18T10:50:56.1765+02:00 + titi: "4" + toto: tata + trackEvents: false + disable: false + version: "" +app-enable-rollout-tab: + variations: + clohjsl: 6 + varA: 1 + yoyo: 2 + targeting: + - name: I need a rule name here + query: targetingKey eq "28d977e9-c32b-45c9-8ef9-e6076d53efa6" + percentage: + clohjsl: 10 + varA: 20 + yoyo: 70 + disable: false + defaultRule: + name: defaultRule + variation: varA + disable: false + metadata: + gofeatureflag_createdDate: 0001-01-01T00:00:00Z + gofeatureflag_description: vrgksugheakh cflwihqdexlh erfvwkugcsd + gofeatureflag_id: 28d977e9-c32b-45c9-8ef9-e6076d53efa7 + gofeatureflag_lastUpdatedDate: 2024-09-16T17:21:11.154283+02:00 + titi: "4" + toto: tata + trackEvents: false + disable: false + version: "" +cvfeadsx: + variations: + cazs: true + disabled: false + targeting: [] + defaultRule: + name: defaultRule + progressiveRollout: + initial: + variation: cazs + percentage: 0 + date: 2024-09-18T08:57:44.725Z + end: + variation: disabled + percentage: 100 + date: 2024-09-19T08:57:44.725Z + disable: false + metadata: + gofeatureflag_createdDate: 2024-09-18T08:57:38.806Z + gofeatureflag_description: "" + gofeatureflag_id: b8ea76ff-3e64-4ccd-8451-cdb8fda1f26c + gofeatureflag_lastUpdatedDate: 2024-09-18T11:21:18.490328+02:00 + trackEvents: false + disable: false + version: 0.0.1 +my-new-flag-a-moi: + variations: + ghfds: true + vfdvdfscs: false + targeting: [] + defaultRule: + name: defaultRule + percentage: + ghfds: 80 + vfdvdfscs: 10 + disable: false + metadata: + betrgdfs: bgrwvsfda + gofeatureflag_createdDate: 2024-09-13T16:12:01.171Z + gofeatureflag_description: "" + gofeatureflag_id: cca6f71e-7a49-4c3b-9fcb-432008094899 + gofeatureflag_lastUpdatedDate: 2024-09-18T15:11:56.557436+02:00 + jreuythndbr: gerfsvd + trackEvents: false + disable: false + version: 0.1.0