├── .chglog ├── CHANGELOG.tpl.md └── config.yml ├── .dependabot └── config.yml ├── .dockerignore ├── .github └── workflows │ ├── build.yml │ ├── dependabot.yml │ ├── go.yml │ ├── javascript.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── flaggio │ ├── admin.go │ ├── api.go │ ├── config.go │ ├── infra.go │ ├── instrumentation.go │ └── main.go ├── go.mod ├── go.sum ├── internal ├── errors │ └── errors.go ├── flaggio │ ├── admin.models.generated.go │ ├── cache.go │ ├── cache_test.go │ ├── constraint.go │ ├── constraint_test.go │ ├── distribution.go │ ├── distribution_test.go │ ├── evaluation.go │ ├── evaluator.go │ ├── evaluator_test.go │ ├── flag.go │ ├── flag_test.go │ ├── mocks │ │ └── evaluator_mock.go │ ├── rule.go │ ├── rule_test.go │ ├── segment.go │ ├── segment_test.go │ ├── usercontext.go │ ├── usercontext_test.go │ └── variant.go ├── operator │ ├── contains.go │ ├── contains_test.go │ ├── endswith.go │ ├── endswith_test.go │ ├── exists.go │ ├── exists_test.go │ ├── greaterlower.go │ ├── greaterlower_test.go │ ├── innetwork.go │ ├── innetwork_test.go │ ├── oneof.go │ ├── oneof_test.go │ ├── regex.go │ ├── regex_test.go │ ├── startswith.go │ ├── startswith_test.go │ ├── validates.go │ └── validates_test.go ├── repository │ ├── evaluation.go │ ├── flag.go │ ├── mocks │ │ ├── evaluation_mock.go │ │ ├── flag_mock.go │ │ ├── rule_mock.go │ │ ├── segment_mock.go │ │ ├── user_mock.go │ │ └── variant_mock.go │ ├── mongodb │ │ ├── evaluation.repository.go │ │ ├── evaluation.repository_test.go │ │ ├── flag.repository.go │ │ ├── flag.repository_test.go │ │ ├── model.go │ │ ├── mongo_test.go │ │ ├── rule.repository.go │ │ ├── rule.repository_test.go │ │ ├── segment.repository.go │ │ ├── segment.repository_test.go │ │ ├── user.repository.go │ │ ├── user.repository_test.go │ │ ├── variant.repository.go │ │ └── variant.repository_test.go │ ├── redis │ │ ├── cache.go │ │ ├── evaluation.repository.go │ │ ├── flag.repository.go │ │ ├── flag.repository_test.go │ │ ├── redis_test.go │ │ ├── rule.repository.go │ │ ├── rule.repository_test.go │ │ ├── segment.repository.go │ │ ├── segment.repository_test.go │ │ ├── variant.repository.go │ │ └── variant.repository_test.go │ ├── rule.go │ ├── segment.go │ ├── user.go │ └── variant.go ├── server │ ├── admin │ │ ├── admin.generated.go │ │ ├── mutation.resolver.go │ │ ├── query.resolver.go │ │ ├── resolver.go │ │ └── user.resolver.go │ └── api │ │ ├── routes.go │ │ └── server.go └── service │ ├── flag.go │ ├── flag.service.go │ ├── flag.service_test.go │ ├── mocks │ └── flag_mock.go │ ├── model.go │ └── model_test.go ├── schema ├── admin.graphql ├── flaggio.graphql ├── generate.go └── gqlgen.admin.yml └── web ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── _redirects ├── images │ ├── auth.jpg │ ├── logos │ │ └── flaggio.svg │ ├── not_found.png │ ├── undraw_page_not_found_su7k.svg │ └── undraw_resume_folder_2_arse.svg ├── index.html └── manifest.json └── src ├── App.js ├── Routes.js ├── assets └── scss │ └── index.scss ├── common └── validators.js ├── components ├── ConstraintFields │ ├── ConstraintFields.js │ └── index.js ├── DistributionFields │ ├── DistributionFields.js │ └── index.js ├── RouteWithLayout │ ├── RouteWithLayout.js │ └── index.js ├── SearchInput │ ├── SearchInput.js │ └── index.js ├── StatusBullet │ ├── StatusBullet.js │ └── index.js └── index.js ├── helpers ├── cast.js ├── index.js └── models.js ├── icons ├── Facebook │ └── index.js ├── Google │ └── index.js └── index.js ├── index.js ├── layouts ├── Main │ ├── Main.js │ ├── components │ │ ├── Sidebar │ │ │ ├── Sidebar.js │ │ │ ├── components │ │ │ │ ├── Profile │ │ │ │ │ ├── Profile.js │ │ │ │ │ └── index.js │ │ │ │ ├── SidebarNav │ │ │ │ │ ├── SidebarNav.js │ │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── Topbar │ │ │ ├── Topbar.js │ │ │ └── index.js │ │ └── index.js │ └── index.js ├── Minimal │ ├── Minimal.js │ ├── components │ │ ├── Topbar │ │ │ ├── Topbar.js │ │ │ └── index.js │ │ └── index.js │ └── index.js └── index.js ├── theme ├── index.js ├── overrides │ ├── MuiButton.js │ ├── MuiIconButton.js │ ├── MuiPaper.js │ ├── MuiTableCell.js │ ├── MuiTableHead.js │ ├── MuiTableRow.js │ ├── MuiTypography.js │ └── index.js ├── palette.js └── typography.js └── views ├── FlagForm ├── FlagForm.js ├── components │ ├── DeleteFlagDialog │ │ ├── DeleteFlagDialog.js │ │ └── index.js │ ├── FlagDetails │ │ ├── FlagDetails.js │ │ └── index.js │ ├── RuleFields │ │ ├── RuleFields.js │ │ └── index.js │ ├── VariantFields │ │ ├── VariantFields.js │ │ └── index.js │ └── index.js ├── copy.js ├── index.js └── queries.js ├── FlagList ├── FlagList.js ├── components │ ├── FlagsTable │ │ ├── FlagsTable.js │ │ └── index.js │ ├── FlagsToolbar │ │ ├── AddFlagButton.js │ │ ├── FlagsToolbar.js │ │ └── index.js │ └── index.js ├── index.js └── queries.js ├── NotFound ├── NotFound.js └── index.js ├── SegmentForm ├── SegmentForm.js ├── components │ ├── DeleteSegmentDialog │ │ ├── DeleteSegmentDialog.js │ │ └── index.js │ ├── RuleFields │ │ ├── RuleFields.js │ │ └── index.js │ ├── SegmentDetails │ │ ├── SegmentDetails.js │ │ └── index.js │ └── index.js ├── copy.js ├── index.js └── queries.js ├── SegmentList ├── SegmentList.js ├── components │ ├── SegmentsTable │ │ ├── SegmentsTable.js │ │ └── index.js │ ├── SegmentsToolbar │ │ ├── SegmentsToolbar.js │ │ └── index.js │ └── index.js ├── index.js └── queries.js ├── UserForm ├── UserForm.js ├── components │ ├── DeleteEvaluationDialog │ │ ├── DeleteEvaluationDialog.js │ │ └── index.js │ ├── DeleteUserDialog │ │ ├── DeleteUserDialog.js │ │ └── index.js │ ├── EvaluationsTable │ │ ├── EvaluationsTable.js │ │ └── index.js │ ├── EvaluationsToolbar │ │ ├── EvaluationsToolbar.js │ │ └── index.js │ ├── UserDetails │ │ ├── UserDetails.js │ │ └── index.js │ └── index.js ├── index.js └── queries.js ├── UserList ├── UserList.js ├── components │ ├── UsersTable │ │ ├── UsersTable.js │ │ └── index.js │ ├── UsersToolbar │ │ ├── UsersToolbar.js │ │ └── index.js │ └── index.js ├── index.js └── queries.js └── index.js /.chglog/CHANGELOG.tpl.md: -------------------------------------------------------------------------------- 1 | {{ if .Versions -}} 2 | 3 | ## [Unreleased] 4 | 5 | {{ if .Unreleased.CommitGroups -}} 6 | {{ range .Unreleased.CommitGroups -}} 7 | ### {{ .Title }} 8 | {{ range .Commits -}} 9 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 10 | {{ end }} 11 | {{ end -}} 12 | {{ end -}} 13 | {{ end -}} 14 | 15 | {{ range .Versions }} 16 | 17 | ## {{ if .Tag.Previous }}[{{ .Tag.Name }}]{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }} 18 | {{ range .CommitGroups -}} 19 | ### {{ .Title }} 20 | {{ range .Commits -}} 21 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 22 | {{ end }} 23 | {{ end -}} 24 | 25 | {{- if .RevertCommits -}} 26 | ### Reverts 27 | {{ range .RevertCommits -}} 28 | - {{ .Revert.Header }} 29 | {{ end }} 30 | {{ end -}} 31 | 32 | {{- if .MergeCommits -}} 33 | ### Pull Requests 34 | {{ range .MergeCommits -}} 35 | - {{ .Header }} 36 | {{ end }} 37 | {{ end -}} 38 | 39 | {{- if .NoteGroups -}} 40 | {{ range .NoteGroups -}} 41 | ### {{ .Title }} 42 | {{ range .Notes }} 43 | {{ .Body }} 44 | {{ end }} 45 | {{ end -}} 46 | {{ end -}} 47 | {{ end -}} 48 | 49 | {{- if .Versions }} 50 | [Unreleased]: {{ .Info.RepositoryURL }}/compare/{{ $latest := index .Versions 0 }}{{ $latest.Tag.Name }}...HEAD 51 | {{ range .Versions -}} 52 | {{ if .Tag.Previous -}} 53 | [{{ .Tag.Name }}]: {{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }} 54 | {{ end -}} 55 | {{ end -}} 56 | {{ end -}} -------------------------------------------------------------------------------- /.chglog/config.yml: -------------------------------------------------------------------------------- 1 | style: github 2 | template: CHANGELOG.tpl.md 3 | info: 4 | title: CHANGELOG 5 | repository_url: https://github.com/uw-labs/flaggio 6 | options: 7 | commits: 8 | # filters: 9 | # Type: 10 | # - feat 11 | # - fix 12 | # - perf 13 | # - refactor 14 | commit_groups: 15 | title_maps: 16 | feat: Features 17 | fix: Bug Fixes 18 | perf: Performance Improvements 19 | refactor: Code Refactoring 20 | docs: Documentation 21 | ci: Continuous Integration 22 | deps: Dependencies 23 | wip: Work in Progress 24 | header: 25 | pattern: "^(\\w*)\\:\\s(.*)$" 26 | pattern_maps: 27 | - Type 28 | - Subject 29 | notes: 30 | keywords: 31 | - BREAKING CHANGE -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "go:modules" 4 | directory: "/" 5 | update_schedule: "monthly" 6 | default_labels: 7 | - "dependencies" 8 | - "go" 9 | commit_message: 10 | prefix: "deps" 11 | - package_manager: "javascript" 12 | directory: "/web" 13 | update_schedule: "weekly" 14 | version_requirement_updates: "increase_versions" 15 | default_labels: 16 | - "dependencies" 17 | - "javascript" 18 | commit_message: 19 | prefix: "deps" 20 | prefix_development: "chore" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | web/node_modules -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code into the Go module directory 14 | uses: actions/checkout@v2 15 | - name: Build 16 | run: make docker-build 17 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot mod fix 2 | on: 3 | push: 4 | branches: 5 | # Only run on dependabot Go branches 6 | - 'dependabot/go_modules/**' 7 | jobs: 8 | fix: 9 | runs-on: ubuntu-latest 10 | container: 'golang:1.14' 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v1 14 | - name: Fix detached HEAD 15 | run: git checkout ${GITHUB_REF#refs/heads/} # https://github.com/actions/checkout/issues/6 16 | - name: Tidy 17 | run: | 18 | go mod tidy 19 | - name: Set up Git 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.DEPENDABOT_FIX_GITHUB_TOKEN }} 22 | run: | 23 | git config user.name "${GITHUB_ACTOR}" 24 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 25 | git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 26 | - name: Commit and push changes 27 | run: | 28 | git add . 29 | if output=$(git status --porcelain) && [ ! -z "$output" ]; then 30 | git commit --amend --no-edit 31 | git push --force-with-lease 32 | fi 33 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.go' 7 | - 'go.mod' 8 | - 'go.sum' 9 | branches: [ master ] 10 | pull_request: 11 | paths: 12 | - '**.go' 13 | - 'go.mod' 14 | - 'go.sum' 15 | branches: [ master ] 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | container: "golang:1.17" 21 | services: 22 | redis: 23 | image: "redis:5-alpine" 24 | ports: 25 | - 6379:6379 26 | options: --entrypoint redis-server 27 | mongo: 28 | image: "mongo" 29 | ports: 30 | - 27017:27017 31 | steps: 32 | - name: Check out code into the Go module directory 33 | uses: actions/checkout@v2 34 | - name: Get dependencies 35 | run: go get -v -t -d ./... 36 | - name: Test 37 | run: make test 38 | env: 39 | REDIS_HOST: redis 40 | REDIS_PORT: ${{ job.services.redis.ports[6379] }} 41 | MONGO_URI: mongodb://mongo:${{ job.services.mongo.ports[27017] }} 42 | 43 | lint: 44 | runs-on: ubuntu-latest 45 | container: "golangci/golangci-lint:latest" 46 | steps: 47 | - name: Check out code into the Go module directory 48 | uses: actions/checkout@v2 49 | - name: Lint 50 | run: golangci-lint run --timeout 5m0s 51 | 52 | mod: 53 | runs-on: ubuntu-latest 54 | container: "golang:1.17" 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v2 58 | - name: Tidy 59 | run: go mod tidy 60 | - name: Check for changes 61 | run: git diff --exit-code 62 | 63 | generate: 64 | runs-on: ubuntu-latest 65 | container: "golang:1.17" 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v2 69 | - name: Generate 70 | run: | 71 | go install github.com/golang/mock/mockgen 72 | make gen 73 | - name: Check for changes 74 | run: git diff --exit-code 75 | -------------------------------------------------------------------------------- /.github/workflows/javascript.yml: -------------------------------------------------------------------------------- 1 | name: Javascript 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'web/**' 7 | branches: [ master ] 8 | pull_request: 9 | paths: 10 | - 'web/**' 11 | branches: [ master ] 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | container: "node:12" 17 | steps: 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@v2 20 | - name: Install dependencies 21 | run: | 22 | cd web 23 | npm install 24 | npx eslint --no-color src/**/*.js 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ 'v*' ] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code into the Go module directory 12 | uses: actions/checkout@v2 13 | - name: Docker login 14 | run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 15 | - name: Release 16 | run: make release 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # dependencies 15 | /web*/node_modules 16 | /web*/build 17 | /web/.pnp 18 | .pnp.js 19 | 20 | # testing 21 | /coverage 22 | 23 | # production 24 | /build 25 | 26 | # misc 27 | .DS_Store 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | .vscode/ 38 | .idea/ 39 | bin/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | govet: 3 | check-shadowing: false 4 | settings: 5 | printf: 6 | funcs: 7 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 8 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 9 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 10 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 11 | golint: 12 | min-confidence: 0 13 | gocyclo: 14 | min-complexity: 15 15 | goconst: 16 | min-len: 2 17 | min-occurrences: 2 18 | gocritic: 19 | enabled-tags: 20 | - diagnostic 21 | - experimental 22 | - opinionated 23 | - performance 24 | - style 25 | disabled-checks: 26 | - ifElseChain 27 | - octalLiteral 28 | - wrapperFunc 29 | 30 | linters: 31 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 32 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 33 | disable-all: true 34 | enable: 35 | - deadcode 36 | - errcheck 37 | - gochecknoinits 38 | - goconst 39 | - gocritic 40 | - gocyclo 41 | - gofmt 42 | - goimports 43 | - golint 44 | - gosec 45 | - gosimple 46 | - govet 47 | - ineffassign 48 | - interfacer 49 | - misspell 50 | - nakedret 51 | - scopelint 52 | - staticcheck 53 | - structcheck 54 | - stylecheck 55 | - unconvert 56 | - unparam 57 | - varcheck 58 | 59 | run: 60 | skip-dirs: 61 | - web 62 | - schema 63 | 64 | # golangci.com configuration 65 | # https://github.com/golangci/golangci/wiki/Configuration 66 | service: 67 | golangci-lint-version: 1.22.x # use the fixed version to not introduce new linters unexpectedly 68 | prepare: 69 | - echo "here I can run custom commands, but no preparation needed for this repo" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ###################################### 2 | # STEP 1 build go executable binary 3 | ###################################### 4 | FROM golang:1.14-alpine AS go_builder 5 | 6 | RUN apk update && apk add --no-cache git make ca-certificates tzdata && update-ca-certificates 7 | WORKDIR /flaggio 8 | 9 | # Create appuser. 10 | RUN adduser -D -g '' flaggio 11 | 12 | COPY . . 13 | 14 | # Fetch dependencies and build the binary 15 | RUN make install && \ 16 | GOOS=linux GOARCH=amd64 make build 17 | 18 | ###################################### 19 | # STEP 2 build frontend app 20 | ###################################### 21 | FROM node:12-alpine AS node_builder 22 | 23 | ENV NODE_ENV production 24 | 25 | WORKDIR /flaggio 26 | 27 | COPY --from=go_builder /flaggio/web /flaggio 28 | 29 | RUN npm install && npm run build 30 | 31 | ###################################### 32 | # STEP 3 build image 33 | ###################################### 34 | FROM scratch 35 | 36 | COPY --from=go_builder /usr/share/zoneinfo /usr/share/zoneinfo 37 | COPY --from=go_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 38 | COPY --from=go_builder /etc/passwd /etc/passwd 39 | 40 | COPY --from=go_builder /flaggio/bin/flaggio /flaggio 41 | COPY --from=node_builder /flaggio/build / 42 | 43 | USER flaggio 44 | 45 | EXPOSE 8080 46 | EXPOSE 8081 47 | 48 | ENTRYPOINT ["/flaggio"] 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------------------- 2 | # Variables 3 | # ----------------------------------------------------------------------------------------- 4 | GIT_HASH := $(shell git rev-parse HEAD) 5 | GIT_SUMMARY := $(shell git describe --tags --dirty --always) 6 | GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD) 7 | GIT_TAG := $(shell git describe --abbrev=0 --tags || echo "v0.0.0") 8 | BUILD_STAMP := $(shell date -u '+%Y-%m-%dT%H:%M:%S%z') 9 | VERSION := $(shell echo $(GIT_TAG) | sed -e "s/^v//") 10 | LDFLAGS = -ldflags '-w -s \ 11 | -X "main.ApplicationName=$(1)" \ 12 | -X "main.ApplicationDescription=$(2)" \ 13 | -X "main.ApplicationVersion=$(VERSION)" \ 14 | -X "main.GitSummary=$(GIT_SUMMARY)" \ 15 | -X "main.GitBranch=$(GIT_BRANCH)" \ 16 | -X "main.BuildStamp=$(BUILD_STAMP)"' 17 | .info: 18 | @echo GIT_SUMMARY: $(GIT_SUMMARY) 19 | @echo GIT_BRANCH: $(GIT_BRANCH) 20 | @echo BUILD_STAMP: $(BUILD_STAMP) 21 | @echo LDFLAGS: $(LDFLAGS) 22 | @echo VERSION: $(VERSION) 23 | 24 | NAMESPACE=flaggio 25 | DOCKER_CONTAINER_NAME=flaggio 26 | DOCKER_REPOSITORY=$(NAMESPACE)/$(DOCKER_CONTAINER_NAME) 27 | 28 | # ----------------------------------------------------------------------------------------- 29 | # Application Tasks 30 | # ----------------------------------------------------------------------------------------- 31 | 32 | install: ## Install dependencies 33 | go get -t -d -v ./... 34 | 35 | lint: ## Run linting 36 | go vet ./... 37 | [[ -z "$(shell gofmt -e -l .)" ]] || exit 1 # gofmt 38 | 39 | test: ## Run tests 40 | go test -race -v ./... 41 | 42 | build: 43 | rm -rf bin && \ 44 | mkdir bin && \ 45 | CGO_ENABLED=0 go build -a $(call LDFLAGS,flaggio,Self hosted feature flag solution) -o bin/flaggio ./cmd/flaggio/ 46 | 47 | gen: 48 | go generate ./... 49 | 50 | docker-build: 51 | docker build -t $(DOCKER_REPOSITORY):latest . 52 | 53 | release: docker-build 54 | docker tag $(DOCKER_REPOSITORY):latest $(DOCKER_REPOSITORY):$(VERSION) 55 | docker push $(DOCKER_REPOSITORY):latest 56 | docker push $(DOCKER_REPOSITORY):$(VERSION) 57 | -------------------------------------------------------------------------------- /cmd/flaggio/api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/go-chi/chi/middleware" 10 | "github.com/go-redis/redis/v7" 11 | "github.com/rs/cors" 12 | "github.com/sirupsen/logrus" 13 | mongo_repo "github.com/uw-labs/flaggio/internal/repository/mongodb" 14 | redis_repo "github.com/uw-labs/flaggio/internal/repository/redis" 15 | "github.com/uw-labs/flaggio/internal/server/api" 16 | "github.com/uw-labs/flaggio/internal/service" 17 | "github.com/victorkt/clientip" 18 | ) 19 | 20 | func startAPI(ctx context.Context, wg *sync.WaitGroup, logger *logrus.Entry) error { 21 | logger.Debug("starting api server ...") 22 | 23 | // connect to mongo 24 | db, err := newMongoDatabase(ctx, cfg.databaseURI, logger, wg) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | var redisClient *redis.Client 30 | if cfg.isCachingEnabled() { 31 | // connect to redis 32 | redisClient, err = newRedisClient(ctx, cfg.redisURI, logger, wg) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | 38 | // setup repositories 39 | flagRepo, err := mongo_repo.NewFlagRepository(ctx, db) 40 | if err != nil { 41 | return err 42 | } 43 | segmentRepo, err := mongo_repo.NewSegmentRepository(ctx, db) 44 | if err != nil { 45 | return err 46 | } 47 | evalRepo, err := mongo_repo.NewEvaluationRepository(ctx, db) 48 | if err != nil { 49 | return err 50 | } 51 | userRepo, err := mongo_repo.NewUserRepository(ctx, db) 52 | if err != nil { 53 | return err 54 | } 55 | if redisClient != nil { 56 | flagRepo = redis_repo.NewFlagRepository(redisClient, flagRepo) 57 | segmentRepo = redis_repo.NewSegmentRepository(redisClient, segmentRepo) 58 | evalRepo = redis_repo.NewEvaluationRepository(redisClient, evalRepo) 59 | } 60 | 61 | // setup services 62 | flagService := service.NewFlagService(flagRepo, segmentRepo, evalRepo, userRepo) 63 | 64 | // setup router 65 | router := chi.NewRouter() 66 | router.Use( 67 | middleware.Recoverer, 68 | middleware.RequestID, 69 | middleware.Heartbeat("/ready"), 70 | middleware.RequestLogger(&middleware.DefaultLogFormatter{ 71 | Logger: logger, 72 | NoColor: cfg.logFormatter != logFormatterText, 73 | }), 74 | middleware.Logger, 75 | tracingMiddleware("flaggio-api", logger), 76 | cors.New(cors.Options{ 77 | AllowedOrigins: cfg.corsAllowedOrigins.Value(), 78 | AllowedHeaders: cfg.corsAllowedHeaders.Value(), 79 | AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, 80 | AllowCredentials: true, 81 | Debug: cfg.corsDebug, 82 | }).Handler, 83 | clientip.Middleware, 84 | ) 85 | 86 | // setup API server 87 | apiSrv := api.NewServer( 88 | router, 89 | flagService, 90 | logger, 91 | ) 92 | 93 | logger.WithFields(logrus.Fields{ 94 | "caching": cfg.isCachingEnabled(), 95 | "tracing": cfg.isTracingEnabled(), 96 | "listening": cfg.apiAddr, 97 | }).Info("api server started") 98 | 99 | // setup http server 100 | srv := newHTTPServer(ctx, cfg.apiAddr, apiSrv, logger, wg) 101 | 102 | return srv.ListenAndServe() 103 | } 104 | -------------------------------------------------------------------------------- /cmd/flaggio/infra.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "sync" 8 | "time" 9 | 10 | "github.com/go-redis/redis/v7" 11 | "github.com/sirupsen/logrus" 12 | "go.mongodb.org/mongo-driver/mongo" 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | func newHTTPServer(ctx context.Context, addr string, handler http.Handler, logger *logrus.Entry, wg *sync.WaitGroup) *http.Server { 17 | srv := &http.Server{ 18 | Addr: addr, 19 | Handler: handler, 20 | WriteTimeout: 10 * time.Second, 21 | ReadTimeout: 10 * time.Second, 22 | IdleTimeout: 15 * time.Second, 23 | ReadHeaderTimeout: 10 * time.Second, 24 | } 25 | 26 | wg.Add(1) 27 | go gracefulServerShutdown(ctx, srv, logger, wg) 28 | 29 | return srv 30 | } 31 | 32 | func newRedisClient(ctx context.Context, uri string, logger *logrus.Entry, wg *sync.WaitGroup) (*redis.Client, error) { 33 | // parse provided uri 34 | redisURL, err := url.Parse(uri) 35 | if err != nil { 36 | return nil, err 37 | } 38 | // check if password was provided 39 | redisPass, hasPass := redisURL.User.Password() 40 | 41 | // redis connection options 42 | redisOpts := &redis.Options{ 43 | Addr: redisURL.Host, 44 | } 45 | if hasPass { 46 | redisOpts.Password = redisPass 47 | } 48 | 49 | // create redis client & test connection 50 | redisClient := redis.NewClient(redisOpts) 51 | if err := redisClient.Ping().Err(); err != nil { 52 | return nil, err 53 | } 54 | 55 | wg.Add(1) 56 | go gracefulRedisClose(ctx, redisClient, logger, wg) 57 | 58 | return redisClient, nil 59 | } 60 | 61 | func newMongoDatabase(ctx context.Context, uri string, logger *logrus.Entry, wg *sync.WaitGroup) (*mongo.Database, error) { 62 | mongoClient, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) 63 | if err != nil { 64 | return nil, err 65 | } 66 | wg.Add(1) 67 | go gracefulMongoDisconnect(ctx, mongoClient, logger, wg) 68 | return mongoClient.Database("flaggio"), nil // TODO: make configurable 69 | } 70 | 71 | func gracefulMongoDisconnect(ctx context.Context, client *mongo.Client, logger *logrus.Entry, wg *sync.WaitGroup) { // nolint:interfacer // want mongo.Client for consistency 72 | <-ctx.Done() 73 | logger.Debug("disconnecting from mongo") 74 | if err := client.Disconnect(ctx); err != nil { 75 | logger.WithError(err).Error("failed to disconnect from mongo") 76 | } 77 | wg.Done() 78 | } 79 | 80 | func gracefulRedisClose(ctx context.Context, client *redis.Client, logger *logrus.Entry, wg *sync.WaitGroup) { 81 | <-ctx.Done() 82 | logger.Debug("disconnecting from redis") 83 | if err := client.Close(); err != nil { 84 | logger.WithError(err).Error("failed to disconnect from redis") 85 | } 86 | wg.Done() 87 | } 88 | 89 | func gracefulServerShutdown(ctx context.Context, srv *http.Server, logger *logrus.Entry, wg *sync.WaitGroup) { 90 | <-ctx.Done() 91 | logger.Debug("shutting down http server") 92 | 93 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 94 | defer cancel() 95 | 96 | srv.SetKeepAlivesEnabled(false) 97 | if err := srv.Shutdown(ctx); err != nil { 98 | logger.WithError(err).Error("could not gracefully shutdown the server") 99 | } 100 | wg.Done() 101 | } 102 | -------------------------------------------------------------------------------- /cmd/flaggio/instrumentation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-chi/chi/middleware" 10 | "github.com/opentracing/opentracing-go" 11 | "github.com/opentracing/opentracing-go/ext" 12 | "github.com/sirupsen/logrus" 13 | "github.com/uber/jaeger-client-go" 14 | jaegercfg "github.com/uber/jaeger-client-go/config" 15 | "github.com/uber/jaeger-client-go/rpcmetrics" 16 | "github.com/uber/jaeger-lib/metrics" 17 | "github.com/uber/jaeger-lib/metrics/prometheus" 18 | ) 19 | 20 | func newTracer(jaegerHost string, logger *logrus.Entry) (opentracing.Tracer, io.Closer, error) { 21 | sender, err := jaeger.NewUDPTransport(jaegerHost, 0) 22 | if err != nil { 23 | return nil, nil, err 24 | } 25 | 26 | metricsFactory := prometheus.New().Namespace(metrics.NSOptions{Name: ApplicationName, Tags: nil}) 27 | 28 | jlogger := &jaegerLogger{logger: logger} 29 | c := jaegercfg.Configuration{ 30 | ServiceName: "flaggio", 31 | } 32 | return c.NewTracer( 33 | jaegercfg.Reporter(jaeger.NewRemoteReporter( 34 | sender, 35 | jaeger.ReporterOptions.BufferFlushInterval(5*time.Second), 36 | jaeger.ReporterOptions.Logger(jlogger), 37 | )), 38 | jaegercfg.Logger(jlogger), 39 | jaegercfg.Metrics(metricsFactory), 40 | jaegercfg.Observer(rpcmetrics.NewObserver(metricsFactory, rpcmetrics.DefaultNameNormalizer)), 41 | jaegercfg.Sampler(jaeger.NewRateLimitingSampler(1)), 42 | ) 43 | } 44 | 45 | func tracingMiddleware(operationName string, logger *logrus.Entry) func(next http.Handler) http.Handler { 46 | tracer := opentracing.GlobalTracer() 47 | return func(next http.Handler) http.Handler { 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | spanCtx, err := tracer.Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(r.Header)) 50 | if err != nil && !errors.Is(err, opentracing.ErrSpanContextNotFound) { 51 | logger.WithError(err).Debug("failed to extract opentracing headers") 52 | } 53 | serverSpan := tracer.StartSpan(operationName, ext.RPCServerOption(spanCtx), opentracing.Tags{ 54 | string(ext.Component): "http", 55 | string(ext.HTTPMethod): r.Method, 56 | string(ext.HTTPUrl): r.URL.String(), 57 | "request.id": middleware.GetReqID(r.Context()), 58 | }) 59 | defer serverSpan.Finish() 60 | newReq := r.WithContext(opentracing.ContextWithSpan(r.Context(), serverSpan)) 61 | 62 | next.ServeHTTP(w, newReq) 63 | }) 64 | } 65 | } 66 | 67 | type jaegerLogger struct { 68 | logger *logrus.Entry 69 | } 70 | 71 | func (l *jaegerLogger) Error(msg string) { 72 | l.logger.Error(msg) 73 | } 74 | 75 | func (l *jaegerLogger) Infof(msg string, args ...interface{}) { 76 | l.logger.Infof(msg, args...) 77 | } 78 | -------------------------------------------------------------------------------- /cmd/flaggio/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "sync" 10 | "syscall" 11 | 12 | "github.com/opentracing/opentracing-go" 13 | "github.com/sirupsen/logrus" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | var ( 18 | ApplicationName = "flaggio" 19 | ApplicationDescription = "Self hosted feature flag solution" 20 | ApplicationVersion = "0.1.0" 21 | GitSummary = "" 22 | GitBranch = "" 23 | BuildStamp = "" 24 | ) 25 | 26 | const ( 27 | logFormatterText = "text" 28 | logFormatterJSON = "json" 29 | ) 30 | 31 | func build() string { 32 | return fmt.Sprintf("%s[%s] (%s)", GitBranch, GitSummary, BuildStamp) 33 | } 34 | 35 | func main() { // nolint:gocyclo // dependencies 36 | app := cli.App{ 37 | Name: ApplicationName, 38 | Description: ApplicationDescription, 39 | Version: ApplicationVersion, 40 | Flags: flags, 41 | Action: func(_ *cli.Context) error { 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | defer cancel() 44 | 45 | logger := logrus.New() 46 | logLevel, err := logrus.ParseLevel(cfg.logLevel) 47 | if err != nil { 48 | return err 49 | } 50 | logger.SetLevel(logLevel) 51 | switch cfg.logFormatter { 52 | case logFormatterText: 53 | logger.SetFormatter(new(logrus.TextFormatter)) 54 | case logFormatterJSON: 55 | logger.SetFormatter(new(logrus.JSONFormatter)) 56 | default: 57 | return fmt.Errorf("invalid formatter: %s", cfg.logFormatter) 58 | } 59 | 60 | logger. 61 | WithFields(logrus.Fields{"version": ApplicationVersion, "build": build()}). 62 | Infof("starting %s application", ApplicationName) 63 | 64 | done := make(chan os.Signal, 1) 65 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 66 | 67 | // setup tracer 68 | if cfg.isTracingEnabled() { 69 | tracer, closer, err := newTracer(cfg.jaegerAgentHost, logger.WithField("app", "tracer")) 70 | if err != nil { 71 | return err 72 | } 73 | defer closer.Close() 74 | opentracing.SetGlobalTracer(tracer) 75 | } 76 | 77 | errs := make(chan error, 1) 78 | var wg sync.WaitGroup 79 | if !cfg.noAPI { 80 | // start API server 81 | go func() { 82 | err := startAPI(ctx, &wg, logger.WithField("app", "api")) 83 | if err != nil { 84 | errs <- err 85 | } 86 | }() 87 | } 88 | if !cfg.noAdmin { 89 | // start Admin server 90 | go func() { 91 | err := startAdmin(ctx, &wg, logger.WithField("app", "admin")) 92 | if err != nil { 93 | errs <- err 94 | } 95 | }() 96 | } 97 | 98 | for { 99 | select { 100 | case err := <-errs: 101 | cancel() 102 | wg.Wait() 103 | return err 104 | case <-done: 105 | logger.Debug("got os.Interrupt, cancelling main context") 106 | cancel() 107 | case <-ctx.Done(): 108 | logger.Trace("context done") 109 | wg.Wait() 110 | logger.Info("shutdown completed") 111 | return ctx.Err() 112 | } 113 | } 114 | }, 115 | } 116 | 117 | if err := app.Run(os.Args); err != nil && !errors.Is(err, context.Canceled) { 118 | logrus.Fatal(err) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/uw-labs/flaggio 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.13.0 7 | github.com/HdrHistogram/hdrhistogram-go v1.0.0 // indirect 8 | github.com/go-chi/chi v4.1.2+incompatible 9 | github.com/go-chi/render v1.0.1 10 | github.com/go-redis/redis/v7 v7.4.0 11 | github.com/golang/mock v1.4.4 12 | github.com/opentracing/opentracing-go v1.2.0 13 | github.com/prometheus/client_golang v1.5.1 // indirect 14 | github.com/rs/cors v1.7.0 15 | github.com/sirupsen/logrus v1.7.0 16 | github.com/stretchr/testify v1.6.1 17 | github.com/uber/jaeger-client-go v2.25.0+incompatible 18 | github.com/uber/jaeger-lib v2.4.0+incompatible 19 | github.com/urfave/cli/v2 v2.2.0 20 | github.com/vektah/gqlparser/v2 v2.1.0 21 | github.com/victorkt/clientip v0.2.0 22 | github.com/vmihailenco/msgpack/v4 v4.3.12 23 | go.mongodb.org/mongo-driver v1.7.3 24 | go.uber.org/atomic v1.6.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | var _ error = (*Err)(nil) 9 | 10 | // Err represents an application error. 11 | type Err struct { 12 | msg string 13 | statusCode int 14 | appCode string 15 | } 16 | 17 | // Error returns the error message as string. 18 | func (e Err) Error() string { 19 | return e.msg 20 | } 21 | 22 | // StatusCode returns the HTTP status code associated with 23 | // the error. 24 | func (e Err) StatusCode() int { 25 | return e.statusCode 26 | } 27 | 28 | // AppCode returns an internal code associated with the error, 29 | // so that it can be more easily recognised by clients of the API. 30 | func (e Err) AppCode() string { 31 | return e.appCode 32 | } 33 | 34 | // List of application errors 35 | var ( 36 | ErrBadRequest = Err{ 37 | msg: "bad request", 38 | statusCode: http.StatusBadRequest, appCode: "BadRequest"} 39 | ErrCannotRenderResponse = Err{ 40 | msg: "error rendering response", 41 | statusCode: http.StatusUnprocessableEntity, appCode: "CannotRenderResponse"} 42 | ErrNoDefaultVariant = Err{ 43 | msg: "no default variant defined for flag", 44 | statusCode: http.StatusUnprocessableEntity, appCode: "NoDefaultVariant"} 45 | ErrInvalidFlag = Err{ 46 | msg: "invalid flag", 47 | statusCode: http.StatusUnprocessableEntity, appCode: "InvalidFlag"} 48 | ErrNoVariantToDistribute = Err{ 49 | msg: "no variants to distribute, please check the rule configuration", 50 | statusCode: http.StatusUnprocessableEntity, appCode: "NoVariantToDistribute"} 51 | ErrNotFound = Err{ 52 | msg: "not found", 53 | statusCode: http.StatusNotFound, appCode: "NotFound"} 54 | ErrNotImplemented = Err{ 55 | msg: "not implemented", 56 | statusCode: http.StatusNotImplemented, appCode: "NotImplemented"} 57 | ) 58 | 59 | // NotFound returns an ErrNotFound error, for the given entity. 60 | func NotFound(entity string) error { 61 | return fmt.Errorf("%s: %w", entity, ErrNotFound) 62 | } 63 | 64 | // BadRequest returns an ErrBadRequest error, with an additional message. 65 | func BadRequest(message string) error { 66 | return fmt.Errorf("%w: %s", ErrBadRequest, message) 67 | } 68 | 69 | // InvalidFlag returns an ErrInvalidFlag error, with an additional message. 70 | func InvalidFlag(message string) error { 71 | return fmt.Errorf("%w: %s", ErrInvalidFlag, message) 72 | } 73 | -------------------------------------------------------------------------------- /internal/flaggio/cache.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | namespace = "flaggio" 10 | flagNamespace = "flag" 11 | segmentNamespace = "segment" 12 | evaluateNamespace = "eval" 13 | ) 14 | 15 | func cacheKey(model string, parts ...string) string { 16 | var sb strings.Builder 17 | sb.WriteString(fmt.Sprintf("%s:%s", namespace, model)) 18 | for _, part := range parts { 19 | sb.WriteString(fmt.Sprintf(":%s", part)) 20 | } 21 | return sb.String() 22 | } 23 | 24 | func FlagCacheKey(parts ...string) string { 25 | return cacheKey(flagNamespace, parts...) 26 | } 27 | 28 | func SegmentCacheKey(parts ...string) string { 29 | return cacheKey(segmentNamespace, parts...) 30 | } 31 | 32 | func EvalCacheKey(parts ...string) string { 33 | return cacheKey(evaluateNamespace, parts...) 34 | } 35 | -------------------------------------------------------------------------------- /internal/flaggio/cache_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/uw-labs/flaggio/internal/flaggio" 8 | ) 9 | 10 | func TestFlagCacheKey(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | parts []string 14 | expectedKey string 15 | }{ 16 | { 17 | name: "uses no parts", 18 | parts: []string{}, 19 | expectedKey: "flaggio:flag", 20 | }, 21 | { 22 | name: "uses all parts", 23 | parts: []string{"key", "my.flag.key"}, 24 | expectedKey: "flaggio:flag:key:my.flag.key", 25 | }, 26 | } 27 | 28 | for _, tt := range tests { 29 | tt := tt 30 | t.Run(tt.name, func(t *testing.T) { 31 | key := flaggio.FlagCacheKey(tt.parts...) 32 | assert.Equal(t, tt.expectedKey, key) 33 | }) 34 | } 35 | } 36 | 37 | func TestSegmentCacheKey(t *testing.T) { 38 | tests := []struct { 39 | name string 40 | parts []string 41 | expectedKey string 42 | }{ 43 | { 44 | name: "uses no parts", 45 | parts: []string{}, 46 | expectedKey: "flaggio:segment", 47 | }, 48 | { 49 | name: "uses all parts", 50 | parts: []string{"key", "123"}, 51 | expectedKey: "flaggio:segment:key:123", 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | tt := tt 57 | t.Run(tt.name, func(t *testing.T) { 58 | key := flaggio.SegmentCacheKey(tt.parts...) 59 | assert.Equal(t, tt.expectedKey, key) 60 | }) 61 | } 62 | } 63 | 64 | func TestEvalCacheKey(t *testing.T) { 65 | tests := []struct { 66 | name string 67 | parts []string 68 | expectedKey string 69 | }{ 70 | { 71 | name: "uses no parts", 72 | parts: []string{}, 73 | expectedKey: "flaggio:eval", 74 | }, 75 | { 76 | name: "uses all parts", 77 | parts: []string{"123", "456"}, 78 | expectedKey: "flaggio:eval:123:456", 79 | }, 80 | } 81 | 82 | for _, tt := range tests { 83 | tt := tt 84 | t.Run(tt.name, func(t *testing.T) { 85 | key := flaggio.EvalCacheKey(tt.parts...) 86 | assert.Equal(t, tt.expectedKey, key) 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/flaggio/distribution.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/uw-labs/flaggio/internal/errors" 8 | ) 9 | 10 | var _ Evaluator = (*DistributionList)(nil) 11 | 12 | // Distribution represents a percentage chance for a variant to 13 | // be selected as the result value for the flag evaluation. 14 | type Distribution struct { 15 | ID string 16 | Variant *Variant 17 | Percentage int 18 | } 19 | 20 | // DistributionList is a slice of *Distribution. 21 | type DistributionList []*Distribution 22 | 23 | // Evaluate will select one of the distributions based on their percentage 24 | // chance and return it's value as answer. 25 | func (dl DistributionList) Evaluate(usrContext map[string]interface{}) (EvalResult, error) { 26 | ref := dl.Distribute() 27 | if ref == nil || ref.ID == "" { 28 | // configuration problem, return error 29 | return EvalResult{}, errors.ErrNoVariantToDistribute 30 | } 31 | return EvalResult{ 32 | Answer: ref.Value, 33 | }, nil 34 | } 35 | 36 | // Distribute selects a distribution randomly, respecting the configured probability. 37 | func (dl DistributionList) Distribute() *Variant { 38 | r1 := rand.New(rand.NewSource(time.Now().UnixNano())) // nolint:gosec // not security critical 39 | num := 1 + r1.Intn(100) // random int between 1 and 100 40 | 41 | var total int 42 | for _, dstrbtn := range dl { 43 | total += dstrbtn.Percentage 44 | if num <= total { 45 | return dstrbtn.Variant 46 | } 47 | } 48 | 49 | // fallback, should never happen 50 | return dl[0].Variant 51 | } 52 | -------------------------------------------------------------------------------- /internal/flaggio/distribution_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/uw-labs/flaggio/internal/flaggio" 8 | ) 9 | 10 | func TestDistributionList_Distribute(t *testing.T) { 11 | if testing.Short() { 12 | t.Skip("skipping testing in short mode") 13 | } 14 | vrnt1 := &flaggio.Variant{ID: "1"} 15 | vrnt2 := &flaggio.Variant{ID: "2"} 16 | vrnt3 := &flaggio.Variant{ID: "3"} 17 | dstrbtn := flaggio.DistributionList{ 18 | {Variant: vrnt1, Percentage: 20}, 19 | {Variant: vrnt2, Percentage: 70}, 20 | {Variant: vrnt3, Percentage: 10}, 21 | } 22 | 23 | totalDistributions := 500000 24 | var vrnt1Count, vrnt2Count, vrnt3Count int 25 | for i := 0; i < totalDistributions; i++ { 26 | vrnt := dstrbtn.Distribute() 27 | switch vrnt { 28 | case vrnt1: 29 | vrnt1Count++ 30 | case vrnt2: 31 | vrnt2Count++ 32 | case vrnt3: 33 | vrnt3Count++ 34 | } 35 | } 36 | 37 | assert.InDelta(t, dstrbtn[0].Percentage, float32(vrnt1Count)/float32(totalDistributions)*100, 0.2) 38 | assert.InDelta(t, dstrbtn[1].Percentage, float32(vrnt2Count)/float32(totalDistributions)*100, 0.2) 39 | assert.InDelta(t, dstrbtn[2].Percentage, float32(vrnt3Count)/float32(totalDistributions)*100, 0.2) 40 | } 41 | 42 | func BenchmarkDistributionList_Distribute(b *testing.B) { 43 | vrnt1 := &flaggio.Variant{ID: "1"} 44 | vrnt2 := &flaggio.Variant{ID: "2"} 45 | vrnt3 := &flaggio.Variant{ID: "3"} 46 | dstrbtn := flaggio.DistributionList{ 47 | {Variant: vrnt1, Percentage: 20}, 48 | {Variant: vrnt2, Percentage: 70}, 49 | {Variant: vrnt3, Percentage: 10}, 50 | } 51 | 52 | for n := 0; n < b.N; n++ { 53 | _ = dstrbtn.Distribute() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/flaggio/evaluation.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // Evaluation is the final result of a flag evaluation. It holds the 9 | // returned value associated with the key for the given user. 10 | // If an error occurred, value will be nil and the error property will 11 | // contain the error message. 12 | // Optionally, a stack trace of the evaluation process can be attached 13 | // to the object. 14 | type Evaluation struct { 15 | ID string `json:"-"` 16 | FlagID string `json:"-"` 17 | FlagVersion int `json:"-"` 18 | RequestHash string `json:"-"` 19 | CreatedAt time.Time `json:"-"` 20 | FlagKey string `json:"flagKey"` 21 | Value interface{} `json:"value,omitempty"` 22 | Error string `json:"error,omitempty"` 23 | StackTrace []*StackTrace `json:"stackTrace,omitempty"` 24 | } 25 | 26 | // StackTrace contains detailed information about the evaluation process. 27 | // Type is the type of the model object that evaluated the user context 28 | // ID holds the ID of the same object, if any. Answer is the evaluation 29 | // answer, if any. 30 | type StackTrace struct { 31 | Type string `json:"type"` 32 | ID *string `json:"id"` 33 | Answer interface{} `json:"answer"` 34 | } 35 | 36 | // EvaluationList is a slice of *Evaluation. 37 | type EvaluationList []*Evaluation 38 | 39 | // Render can enrich the EvaluationList before being returned to the 40 | // user. Currently it does nothing, but is needed to satisfy the 41 | // chi.Renderer interface. 42 | func (l EvaluationList) Render(w http.ResponseWriter, r *http.Request) error { 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/flaggio/evaluator.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | //go:generate mockgen -destination=./mocks/evaluator_mock.go -package=flaggio_mock github.com/uw-labs/flaggio/internal/flaggio Evaluator,Identifier 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // Evaluator represents an object that can evaluate a given user context 11 | // and return an answer and/or point to other evaluators that can possibly 12 | // return an answer. 13 | type Evaluator interface { 14 | Evaluate(usrContext map[string]interface{}) (EvalResult, error) 15 | } 16 | 17 | // Identifier represents an object that can return an ID. 18 | type Identifier interface { 19 | GetID() string 20 | } 21 | 22 | // EvalResult is the result generated by an Evaluator. It possibly contains 23 | // an answer and/or a list of the next Evaluators that should be called. 24 | type EvalResult struct { 25 | Answer interface{} 26 | Next []Evaluator 27 | evaluator Evaluator 28 | previous *EvalResult 29 | } 30 | 31 | // Stack will generate a stack trace of the evaluation process. 32 | func (r EvalResult) Stack() (stack []*StackTrace) { 33 | prev := &r 34 | for prev != nil { 35 | var id *string 36 | ider, ok := prev.evaluator.(Identifier) 37 | if ok { 38 | v := ider.GetID() 39 | id = &v 40 | } 41 | stack = append(stack, &StackTrace{ 42 | Type: strings.Replace( 43 | fmt.Sprintf("%T", prev.evaluator), "flaggio.", "", 1, 44 | ), 45 | ID: id, 46 | Answer: prev.Answer, 47 | }) 48 | prev = prev.previous 49 | } 50 | return 51 | } 52 | 53 | // Evaluate is the starting point for a chain of evaluations. 54 | func Evaluate(usrContext map[string]interface{}, root Evaluator) (EvalResult, error) { 55 | return evaluate(usrContext, []Evaluator{root}) 56 | } 57 | 58 | // evaluate will call all the evaluators, passing the user context as parameter 59 | // and expect an EvalResult. The evaluation process will continue until one of the 60 | // evaluators return an answer and no new evaluators to continue, or no more 61 | // evaluators are left in the chain, whichever happens first. 62 | func evaluate(usrContext map[string]interface{}, evaluators []Evaluator) (EvalResult, error) { 63 | // the last evaluation result that had an answer 64 | var lastWithResult EvalResult 65 | // used to keep track of the evaluator chain and generate a stack trace 66 | var lastInChain *EvalResult 67 | 68 | // go through the evaluators in the list 69 | for len(evaluators) > 0 { 70 | // get the first evaluator in the list 71 | evltr := evaluators[0] 72 | 73 | // call the evaluator and get the answer 74 | res, err := evltr.Evaluate(usrContext) 75 | if err != nil { 76 | return EvalResult{}, err 77 | } 78 | 79 | // attach additional data so that we can generate a stack trace 80 | res.evaluator = evltr 81 | res.previous = lastInChain 82 | lastInChain = &res 83 | 84 | if res.Answer != nil { 85 | if len(res.Next) == 0 { 86 | // we have an answer and no new evaluators were returned 87 | // so this is the final answer 88 | return res, nil 89 | } 90 | // save the result. in case no new answers are returned this will 91 | // be used as final answer 92 | lastWithResult = res 93 | } 94 | 95 | if len(res.Next) > 0 { 96 | // we have more evaluators, add them to the start of the list 97 | evaluators = append(res.Next, evaluators[1:]...) 98 | } else { 99 | // no new evaluators, remove the first evaluator from the list 100 | // which has just been calleed 101 | evaluators = evaluators[1:] 102 | } 103 | } 104 | 105 | // return the final answer, if any 106 | return lastWithResult, nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/flaggio/flag.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/uw-labs/flaggio/internal/errors" 7 | ) 8 | 9 | var _ Identifier = (*Flag)(nil) 10 | var _ Evaluator = (*Flag)(nil) 11 | 12 | // Flag holds all the information needed to evaluate a key to a value. 13 | type Flag struct { 14 | ID string 15 | Key string 16 | Name string 17 | Description *string 18 | Enabled bool 19 | Version int 20 | Variants []*Variant 21 | Rules []*FlagRule 22 | DefaultVariantWhenOn *Variant 23 | DefaultVariantWhenOff *Variant 24 | CreatedAt time.Time 25 | UpdatedAt *time.Time 26 | } 27 | 28 | // GetID returns the flag ID. 29 | func (f *Flag) GetID() string { 30 | return f.ID 31 | } 32 | 33 | // Evaluate will return the default variant as answer based on the flag status (on or off). 34 | // If the flag is on, it will also return the list of rules to be evaluated. 35 | // If there is no default variant configured for the given flag enabled state, an error 36 | // is returned. 37 | func (f *Flag) Evaluate(usrContext map[string]interface{}) (EvalResult, error) { 38 | var answer interface{} 39 | var next []Evaluator 40 | if f.Enabled { 41 | vrnt := f.DefaultVariantWhenOn 42 | if vrnt == nil { 43 | return EvalResult{}, errors.ErrNoDefaultVariant 44 | } 45 | answer = vrnt.Value 46 | for _, rl := range f.Rules { 47 | next = append(next, rl) 48 | } 49 | } else { 50 | vrnt := f.DefaultVariantWhenOff 51 | if vrnt == nil { 52 | return EvalResult{}, errors.ErrNoDefaultVariant 53 | } 54 | answer = vrnt.Value 55 | } 56 | return EvalResult{ 57 | Answer: answer, 58 | Next: next, 59 | }, nil 60 | } 61 | 62 | // Populate will try to populate all references in the list of rules. 63 | func (f *Flag) Populate(identifiers []Identifier) { 64 | for _, r := range f.Rules { 65 | r.Populate(identifiers) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/flaggio/flag_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/uw-labs/flaggio/internal/errors" 9 | "github.com/uw-labs/flaggio/internal/flaggio" 10 | ) 11 | 12 | func TestFlag_GetID(t *testing.T) { 13 | t.Parallel() 14 | flg := flaggio.Flag{ID: "123456"} 15 | assert.Equal(t, "123456", flg.GetID()) 16 | } 17 | 18 | func TestFlag_Evaluate(t *testing.T) { 19 | t.Parallel() 20 | vrnt1 := &flaggio.Variant{ID: "1", Value: 1} 21 | vrnt2 := &flaggio.Variant{ID: "2", Value: 2} 22 | rl1 := &flaggio.FlagRule{} 23 | 24 | tests := []struct { 25 | name string 26 | flag flaggio.Flag 27 | expectedResult flaggio.EvalResult 28 | expectedError error 29 | }{ 30 | { 31 | name: "returns error when there is no variant when off", 32 | flag: flaggio.Flag{ 33 | Enabled: false, 34 | Variants: []*flaggio.Variant{vrnt1, vrnt2}, 35 | }, 36 | expectedError: errors.ErrNoDefaultVariant, 37 | }, 38 | { 39 | name: "returns error when there is no variant when on", 40 | flag: flaggio.Flag{ 41 | Enabled: true, 42 | Variants: []*flaggio.Variant{vrnt1, vrnt2}, 43 | }, 44 | expectedError: errors.ErrNoDefaultVariant, 45 | }, 46 | { 47 | name: "returns default variant when off", 48 | flag: flaggio.Flag{ 49 | Enabled: false, 50 | Variants: []*flaggio.Variant{vrnt1, vrnt2}, 51 | DefaultVariantWhenOn: vrnt1, 52 | DefaultVariantWhenOff: vrnt2, 53 | }, 54 | expectedResult: flaggio.EvalResult{Answer: 2}, 55 | }, 56 | { 57 | name: "returns default variant when on", 58 | flag: flaggio.Flag{ 59 | Enabled: true, 60 | Variants: []*flaggio.Variant{vrnt1, vrnt2}, 61 | Rules: []*flaggio.FlagRule{rl1}, 62 | DefaultVariantWhenOn: vrnt1, 63 | DefaultVariantWhenOff: vrnt2, 64 | }, 65 | expectedResult: flaggio.EvalResult{Answer: 1, Next: []flaggio.Evaluator{rl1}}, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | tt := tt 71 | t.Run(tt.name, func(t *testing.T) { 72 | t.Parallel() 73 | usrContext := map[string]interface{}{"name": "John"} 74 | eval, err := tt.flag.Evaluate(usrContext) 75 | assert.Equal(t, tt.expectedError, err) 76 | assert.Equal(t, tt.expectedResult, eval) 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/flaggio/mocks/evaluator_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/uw-labs/flaggio/internal/flaggio (interfaces: Evaluator,Identifier) 3 | 4 | // Package flaggio_mock is a generated GoMock package. 5 | package flaggio_mock 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | flaggio "github.com/uw-labs/flaggio/internal/flaggio" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockEvaluator is a mock of Evaluator interface 14 | type MockEvaluator struct { 15 | ctrl *gomock.Controller 16 | recorder *MockEvaluatorMockRecorder 17 | } 18 | 19 | // MockEvaluatorMockRecorder is the mock recorder for MockEvaluator 20 | type MockEvaluatorMockRecorder struct { 21 | mock *MockEvaluator 22 | } 23 | 24 | // NewMockEvaluator creates a new mock instance 25 | func NewMockEvaluator(ctrl *gomock.Controller) *MockEvaluator { 26 | mock := &MockEvaluator{ctrl: ctrl} 27 | mock.recorder = &MockEvaluatorMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockEvaluator) EXPECT() *MockEvaluatorMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Evaluate mocks base method 37 | func (m *MockEvaluator) Evaluate(arg0 map[string]interface{}) (flaggio.EvalResult, error) { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Evaluate", arg0) 40 | ret0, _ := ret[0].(flaggio.EvalResult) 41 | ret1, _ := ret[1].(error) 42 | return ret0, ret1 43 | } 44 | 45 | // Evaluate indicates an expected call of Evaluate 46 | func (mr *MockEvaluatorMockRecorder) Evaluate(arg0 interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockEvaluator)(nil).Evaluate), arg0) 49 | } 50 | 51 | // MockIdentifier is a mock of Identifier interface 52 | type MockIdentifier struct { 53 | ctrl *gomock.Controller 54 | recorder *MockIdentifierMockRecorder 55 | } 56 | 57 | // MockIdentifierMockRecorder is the mock recorder for MockIdentifier 58 | type MockIdentifierMockRecorder struct { 59 | mock *MockIdentifier 60 | } 61 | 62 | // NewMockIdentifier creates a new mock instance 63 | func NewMockIdentifier(ctrl *gomock.Controller) *MockIdentifier { 64 | mock := &MockIdentifier{ctrl: ctrl} 65 | mock.recorder = &MockIdentifierMockRecorder{mock} 66 | return mock 67 | } 68 | 69 | // EXPECT returns an object that allows the caller to indicate expected use 70 | func (m *MockIdentifier) EXPECT() *MockIdentifierMockRecorder { 71 | return m.recorder 72 | } 73 | 74 | // GetID mocks base method 75 | func (m *MockIdentifier) GetID() string { 76 | m.ctrl.T.Helper() 77 | ret := m.ctrl.Call(m, "GetID") 78 | ret0, _ := ret[0].(string) 79 | return ret0 80 | } 81 | 82 | // GetID indicates an expected call of GetID 83 | func (mr *MockIdentifierMockRecorder) GetID() *gomock.Call { 84 | mr.mock.ctrl.T.Helper() 85 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetID", reflect.TypeOf((*MockIdentifier)(nil).GetID)) 86 | } 87 | -------------------------------------------------------------------------------- /internal/flaggio/rule.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | var _ Identifier = (*Rule)(nil) 4 | var _ Evaluator = (*FlagRule)(nil) 5 | 6 | // Rule has a list of constraints that all need to be satisfied so that 7 | // it can pass. 8 | type Rule struct { 9 | ID string 10 | Constraints []*Constraint 11 | } 12 | 13 | // IsRuler is defined so that Rule can implement the Ruler interface. 14 | func (r Rule) IsRuler() {} 15 | 16 | // GetID returns the rule ID. 17 | func (r Rule) GetID() string { 18 | return r.ID 19 | } 20 | 21 | // Populate will try to populate all references in the list of constraints. 22 | func (r *Rule) Populate(identifiers []Identifier) { 23 | ConstraintList(r.Constraints).Populate(identifiers) 24 | } 25 | 26 | // FlagRule is a rule that also holds a list of distributions. 27 | type FlagRule struct { 28 | Rule 29 | Distributions []*Distribution 30 | } 31 | 32 | // Evaluate will check that all constraints in this rule validates to true. If that 33 | // is the case, it returns the list of distributions as next to be evaluated. 34 | // If any of the constraints fail to pass, the rule returns an empty list of 35 | // next evaluators. In any case, no answer is returned from the evaluation. 36 | func (r FlagRule) Evaluate(usrContext map[string]interface{}) (EvalResult, error) { 37 | var next []Evaluator 38 | ok, err := ConstraintList(r.Constraints).Validate(usrContext) 39 | if ok { 40 | next = []Evaluator{DistributionList(r.Distributions)} 41 | } 42 | return EvalResult{ 43 | Next: next, 44 | }, err 45 | } 46 | 47 | // SegmentRule is a rule to be used by segments. 48 | type SegmentRule struct { 49 | Rule 50 | } 51 | -------------------------------------------------------------------------------- /internal/flaggio/rule_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/uw-labs/flaggio/internal/flaggio" 8 | ) 9 | 10 | func TestRule_GetID(t *testing.T) { 11 | t.Parallel() 12 | rl := flaggio.Rule{ID: "123456"} 13 | assert.Equal(t, "123456", rl.GetID()) 14 | } 15 | 16 | func TestFlagRule_Evaluate(t *testing.T) { 17 | t.Parallel() 18 | vrnt1 := &flaggio.Variant{ID: "1", Value: 1} 19 | dstrbtn := &flaggio.Distribution{ 20 | ID: "1", 21 | Variant: vrnt1, 22 | Percentage: 100, 23 | } 24 | rl := flaggio.Rule{ 25 | ID: "123-abc", 26 | Constraints: []*flaggio.Constraint{{ 27 | ID: "1", 28 | Property: "name", 29 | Operation: flaggio.OperationOneOf, 30 | Values: []interface{}{"John"}, 31 | }, { 32 | ID: "2", 33 | Property: "age", 34 | Operation: flaggio.OperationGreaterOrEqual, 35 | Values: []interface{}{30}, 36 | }}, 37 | } 38 | 39 | tests := []struct { 40 | name string 41 | usrContext map[string]interface{} 42 | rule flaggio.FlagRule 43 | expectedResult flaggio.EvalResult 44 | expectedError error 45 | }{ 46 | { 47 | name: "doesn't return the distribution list when ANY constraint validate to false", 48 | usrContext: map[string]interface{}{"name": "Mary", "age": 40}, 49 | rule: flaggio.FlagRule{ 50 | Rule: rl, 51 | Distributions: []*flaggio.Distribution{dstrbtn}, 52 | }, 53 | expectedResult: flaggio.EvalResult{Answer: nil, Next: nil}, 54 | }, 55 | { 56 | name: "returns the distribution list when ALL constraints validate to true", 57 | usrContext: map[string]interface{}{"name": "John", "age": 30}, 58 | rule: flaggio.FlagRule{ 59 | Rule: rl, 60 | Distributions: []*flaggio.Distribution{dstrbtn}, 61 | }, 62 | expectedResult: flaggio.EvalResult{ 63 | Answer: nil, 64 | Next: []flaggio.Evaluator{flaggio.DistributionList([]*flaggio.Distribution{dstrbtn})}, 65 | }, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | tt := tt 71 | t.Run(tt.name, func(t *testing.T) { 72 | t.Parallel() 73 | eval, err := tt.rule.Evaluate(tt.usrContext) 74 | assert.Equal(t, tt.expectedError, err) 75 | assert.Equal(t, tt.expectedResult, eval) 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/flaggio/segment.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/uw-labs/flaggio/internal/operator" 7 | ) 8 | 9 | var _ Identifier = (*Segment)(nil) 10 | var _ operator.Validator = (*Segment)(nil) 11 | 12 | // Segment represents a category of users that can be grouped based on a 13 | // set of rules. 14 | type Segment struct { 15 | ID string 16 | Name string 17 | Description *string 18 | Rules []*SegmentRule 19 | CreatedAt time.Time 20 | UpdatedAt *time.Time 21 | } 22 | 23 | // GetID returns the segment ID. 24 | func (s *Segment) GetID() string { 25 | return s.ID 26 | } 27 | 28 | // Validate will check if any of the segment rules passes validation. If so, 29 | // the validation is successful, otherwise it returns false. 30 | func (s *Segment) Validate(usrContext map[string]interface{}) (bool, error) { 31 | for _, rl := range s.Rules { 32 | ok, err := ConstraintList(rl.Constraints).Validate(usrContext) 33 | if err != nil { 34 | return false, err 35 | } 36 | if ok { 37 | return true, nil 38 | } 39 | } 40 | return false, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/flaggio/segment_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/uw-labs/flaggio/internal/flaggio" 8 | ) 9 | 10 | func TestSegment_GetID(t *testing.T) { 11 | t.Parallel() 12 | sgmnt := flaggio.Segment{ID: "123456"} 13 | assert.Equal(t, "123456", sgmnt.GetID()) 14 | } 15 | 16 | func TestSegment_Validate(t *testing.T) { 17 | t.Parallel() 18 | rl1 := flaggio.Rule{ 19 | Constraints: []*flaggio.Constraint{{ 20 | Property: "name", 21 | Operation: flaggio.OperationOneOf, 22 | Values: []interface{}{"John"}, 23 | }}, 24 | } 25 | rl2 := flaggio.Rule{ 26 | Constraints: []*flaggio.Constraint{{ 27 | Property: "age", 28 | Operation: flaggio.OperationGreaterOrEqual, 29 | Values: []interface{}{30}, 30 | }}, 31 | } 32 | 33 | tests := []struct { 34 | name string 35 | usrContext map[string]interface{} 36 | segment flaggio.Segment 37 | expectedResult bool 38 | expectedError error 39 | }{ 40 | { 41 | name: "returns true when ANY of the rules validate to true", 42 | usrContext: map[string]interface{}{"name": "Mary", "age": 40}, 43 | segment: flaggio.Segment{Rules: []*flaggio.SegmentRule{{rl1}, {rl2}}}, 44 | expectedResult: true, 45 | }, 46 | { 47 | name: "returns false when ALL of the rules validate to false", 48 | usrContext: map[string]interface{}{"name": "Jane", "age": 25}, 49 | segment: flaggio.Segment{Rules: []*flaggio.SegmentRule{{rl1}, {rl2}}}, 50 | expectedResult: false, 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | tt := tt 56 | t.Run(tt.name, func(t *testing.T) { 57 | t.Parallel() 58 | result, err := tt.segment.Validate(tt.usrContext) 59 | assert.Equal(t, tt.expectedError, err) 60 | assert.Equal(t, tt.expectedResult, result) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/flaggio/usercontext.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | var _ json.Unmarshaler = (*UserContext)(nil) 9 | 10 | // UserContext is a map of strings and one of: 11 | // int64, float64, bool, string 12 | type UserContext map[string]interface{} 13 | 14 | // UnmarshalJSON unmarshals the bytes into UserContext 15 | func (a UserContext) UnmarshalJSON(b []byte) error { 16 | data := make(map[string]json.RawMessage) 17 | if err := json.Unmarshal(b, &data); err != nil { 18 | return err 19 | } 20 | for k, v := range data { 21 | strV := string(v) 22 | if n, err := strconv.ParseInt(strV, 10, 64); err == nil { 23 | a[k] = n 24 | } else if n, err := strconv.ParseFloat(strV, 64); err == nil { 25 | a[k] = n 26 | } else if b, err := strconv.ParseBool(strV); err == nil { 27 | a[k] = b 28 | } else { 29 | // everything else is treated as a string, even null. 30 | // strings will still be quoted on the json.RawMessage so we 31 | // try to unmarshal them. it will fail for objects and arrays 32 | // in that case, ignore the error and return the raw string 33 | _ = json.Unmarshal(v, &strV) 34 | a[k] = strV 35 | } 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/flaggio/usercontext_test.go: -------------------------------------------------------------------------------- 1 | package flaggio_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | func TestUserContext_UnmarshalJSON(t *testing.T) { 12 | t.Parallel() 13 | ucJSON := []byte(` 14 | { 15 | "string": "value", 16 | "int": 1, 17 | "float": 2.5, 18 | "bool": true, 19 | "null": null, 20 | "object": {}, 21 | "array": [] 22 | }`) 23 | uc := make(flaggio.UserContext) 24 | err := json.Unmarshal(ucJSON, &uc) 25 | assert.NoError(t, err) 26 | assert.Equal(t, "value", uc["string"]) 27 | assert.Equal(t, int64(1), uc["int"]) 28 | assert.Equal(t, float64(2.5), uc["float"]) 29 | assert.Equal(t, true, uc["bool"]) 30 | assert.Equal(t, "null", uc["null"]) 31 | assert.Equal(t, "{}", uc["object"]) 32 | assert.Equal(t, "[]", uc["array"]) 33 | } 34 | -------------------------------------------------------------------------------- /internal/flaggio/variant.go: -------------------------------------------------------------------------------- 1 | package flaggio 2 | 3 | // Variant represents a value that can be returned by the evaluation 4 | // process of a Flag. 5 | type Variant struct { 6 | ID string 7 | Description *string 8 | Value interface{} 9 | } 10 | -------------------------------------------------------------------------------- /internal/operator/contains.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // Contains operator will check if the value from the user context contains 9 | // any of the configured values on the flag. 10 | func Contains(usrValue interface{}, validValues []interface{}) (bool, error) { 11 | for _, v := range validValues { 12 | if contains(v, usrValue) { 13 | return true, nil 14 | } 15 | } 16 | return false, nil 17 | } 18 | 19 | // DoesntContain operator will check if the value from the user context doesn't contain 20 | // any of the configured values on the flag. 21 | func DoesntContain(usrValue interface{}, validValues []interface{}) (bool, error) { 22 | for _, v := range validValues { 23 | if contains(v, usrValue) { 24 | return false, nil 25 | } 26 | } 27 | return true, nil 28 | } 29 | 30 | func contains(cnstrnValue, userValue interface{}) bool { 31 | str, err := toString(userValue) 32 | if err != nil { 33 | return false 34 | } 35 | switch v := cnstrnValue.(type) { 36 | case string: 37 | return strings.Contains(str, v) 38 | case []byte: 39 | return strings.Contains(str, string(v)) 40 | default: 41 | return false 42 | } 43 | } 44 | 45 | func toString(str interface{}) (string, error) { 46 | switch v := str.(type) { 47 | case string: 48 | return v, nil 49 | case []byte: 50 | return string(v), nil 51 | default: 52 | return "", errors.New("invalid string type") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/operator/endswith.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // EndsWith operator will check if the value from the user context ends with 8 | // any of the configured values on the flag. 9 | func EndsWith(usrValue interface{}, validValues []interface{}) (bool, error) { 10 | for _, v := range validValues { 11 | if endsWith(v, usrValue) { 12 | return true, nil 13 | } 14 | } 15 | return false, nil 16 | } 17 | 18 | // DoesntEndWith operator will check if the value from the user context doesn't end with 19 | // any of the configured values on the flag. 20 | func DoesntEndWith(usrValue interface{}, validValues []interface{}) (bool, error) { 21 | for _, v := range validValues { 22 | if endsWith(v, usrValue) { 23 | return false, nil 24 | } 25 | } 26 | return true, nil 27 | } 28 | 29 | func endsWith(cnstrnValue, userValue interface{}) bool { 30 | str, err := toString(userValue) 31 | if err != nil { 32 | return false 33 | } 34 | switch v := cnstrnValue.(type) { 35 | case string: 36 | return strings.HasSuffix(str, v) 37 | case []byte: 38 | return strings.HasSuffix(str, string(v)) 39 | default: 40 | return false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/operator/exists.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | // Exists operator will check if the property from the user context exists (is not null) 4 | func Exists(usrValue interface{}, _ []interface{}) (bool, error) { 5 | return usrValue != nil, nil 6 | } 7 | 8 | // DoesntExist operator will check if the property from the user context doesn't exist (is null) 9 | func DoesntExist(usrValue interface{}, _ []interface{}) (bool, error) { 10 | return usrValue == nil, nil 11 | } 12 | -------------------------------------------------------------------------------- /internal/operator/exists_test.go: -------------------------------------------------------------------------------- 1 | package operator_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/uw-labs/flaggio/internal/operator" 8 | ) 9 | 10 | func TestExists(t *testing.T) { 11 | t.Parallel() 12 | tests := []struct { 13 | name string 14 | usrContext map[string]interface{} 15 | property string 16 | values []interface{} 17 | expectedResult bool 18 | }{ 19 | { 20 | name: "property exists", 21 | usrContext: map[string]interface{}{"prop": "abc"}, 22 | property: "prop", 23 | expectedResult: true, 24 | }, 25 | { 26 | name: "property doesnt exist", 27 | usrContext: map[string]interface{}{"prop": "abc"}, 28 | property: "prop2", 29 | expectedResult: false, 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | tt := tt 35 | t.Run(tt.name, func(t *testing.T) { 36 | t.Parallel() 37 | res, err := operator.Exists(tt.usrContext[tt.property], tt.values) 38 | assert.NoError(t, err) 39 | assert.Equal(t, tt.expectedResult, res) 40 | }) 41 | } 42 | } 43 | 44 | func TestDoesntExist(t *testing.T) { 45 | t.Parallel() 46 | tests := []struct { 47 | name string 48 | usrContext map[string]interface{} 49 | property string 50 | values []interface{} 51 | expectedResult bool 52 | }{ 53 | { 54 | name: "property exists", 55 | usrContext: map[string]interface{}{"prop": "abc"}, 56 | property: "prop", 57 | expectedResult: false, 58 | }, 59 | { 60 | name: "property doesnt exist", 61 | usrContext: map[string]interface{}{"prop": "abc"}, 62 | property: "prop2", 63 | expectedResult: true, 64 | }, 65 | } 66 | 67 | for _, tt := range tests { 68 | tt := tt 69 | t.Run(tt.name, func(t *testing.T) { 70 | t.Parallel() 71 | res, err := operator.DoesntExist(tt.usrContext[tt.property], tt.values) 72 | assert.NoError(t, err) 73 | assert.Equal(t, tt.expectedResult, res) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/operator/greaterlower.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | // Greater operator will check if the value from the user context is greater 4 | // than any of the configured values on the flag. 5 | func Greater(usrValue interface{}, validValues []interface{}) (bool, error) { 6 | for _, v := range validValues { 7 | ok, err := greater(v, usrValue, false) 8 | if err != nil { 9 | return false, err 10 | } 11 | if !ok { 12 | return false, nil 13 | } 14 | } 15 | return true, nil 16 | } 17 | 18 | // GreaterOrEqual operator will check if the value from the user context is greater 19 | // or equal than any of the configured values on the flag. 20 | func GreaterOrEqual(usrValue interface{}, validValues []interface{}) (bool, error) { 21 | for _, v := range validValues { 22 | ok, err := greater(v, usrValue, true) 23 | if err != nil { 24 | return false, err 25 | } 26 | if !ok { 27 | return false, nil 28 | } 29 | } 30 | return true, nil 31 | } 32 | 33 | // Lower operator will check if the value from the user context is lower 34 | // than any of the configured values on the flag. 35 | func Lower(usrValue interface{}, validValues []interface{}) (bool, error) { 36 | for _, v := range validValues { 37 | ok, err := lower(v, usrValue, false) 38 | if err != nil { 39 | return false, err 40 | } 41 | if !ok { 42 | return false, nil 43 | } 44 | } 45 | return true, nil 46 | } 47 | 48 | // LowerOrEqual operator will check if the value from the user context is lower 49 | // or equal than any of the configured values on the flag. 50 | func LowerOrEqual(usrValue interface{}, validValues []interface{}) (bool, error) { 51 | for _, v := range validValues { 52 | ok, err := lower(v, usrValue, true) 53 | if err != nil { 54 | return false, err 55 | } 56 | if !ok { 57 | return false, nil 58 | } 59 | } 60 | return true, nil 61 | } 62 | 63 | func greater(cnstrnValue, userValue interface{}, orEqual bool) (bool, error) { 64 | if userValue == nil { 65 | return false, nil 66 | } 67 | switch cv := cnstrnValue.(type) { 68 | case int, int32, int64: 69 | n1, _ := toInt64(cv) 70 | n2, err := toInt64(userValue) 71 | if err != nil { 72 | return false, err 73 | } 74 | if orEqual { 75 | return n2 >= n1, nil 76 | } 77 | return n2 > n1, nil 78 | case uint, uint32, uint64: 79 | n1, _ := toUInt64(cv) 80 | n2, err := toUInt64(userValue) 81 | if err != nil { 82 | return false, err 83 | } 84 | if orEqual { 85 | return n2 >= n1, nil 86 | } 87 | return n2 > n1, nil 88 | case float64: 89 | flt, ok := userValue.(float64) 90 | if !ok { 91 | return false, nil 92 | } 93 | if orEqual { 94 | return flt >= cv, nil 95 | } 96 | return flt > cv, nil 97 | default: 98 | return false, nil 99 | } 100 | } 101 | 102 | func lower(cnstrnValue, userValue interface{}, orEqual bool) (bool, error) { 103 | if userValue == nil { 104 | return false, nil 105 | } 106 | switch cv := cnstrnValue.(type) { 107 | case int, int32, int64: 108 | n1, _ := toInt64(cv) 109 | n2, err := toInt64(userValue) 110 | if err != nil { 111 | return false, err 112 | } 113 | if orEqual { 114 | return n2 <= n1, nil 115 | } 116 | return n2 < n1, nil 117 | case uint, uint32, uint64: 118 | n1, _ := toUInt64(cv) 119 | n2, err := toUInt64(userValue) 120 | if err != nil { 121 | return false, err 122 | } 123 | if orEqual { 124 | return n2 <= n1, nil 125 | } 126 | return n2 < n1, nil 127 | case float64: 128 | flt, ok := userValue.(float64) 129 | if !ok { 130 | return false, nil 131 | } 132 | if orEqual { 133 | return flt <= cv, nil 134 | } 135 | return flt < cv, nil 136 | default: 137 | return false, nil 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /internal/operator/innetwork.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | // InNetwork operator will check if the value from the user context is an ip 8 | // that is included in any of the networks configured on the flag. 9 | func InNetwork(usrValue interface{}, validValues []interface{}) (bool, error) { 10 | for _, v := range validValues { 11 | ok, err := inNetwork(v, usrValue) 12 | if err != nil { 13 | return false, err 14 | } 15 | if ok { 16 | return true, nil 17 | } 18 | } 19 | return false, nil 20 | } 21 | 22 | func inNetwork(cnstrnValue, userValue interface{}) (bool, error) { 23 | u, err := toString(userValue) 24 | if err != nil { 25 | return false, nil 26 | } 27 | v, ok := cnstrnValue.(string) 28 | if !ok { 29 | return false, nil 30 | } 31 | userIP := net.ParseIP(u) 32 | _, ipnet, err := net.ParseCIDR(v) 33 | if err != nil { 34 | return false, err 35 | } 36 | 37 | return ipnet.Contains(userIP), nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/operator/innetwork_test.go: -------------------------------------------------------------------------------- 1 | package operator_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/uw-labs/flaggio/internal/operator" 9 | ) 10 | 11 | func TestInNetwork(t *testing.T) { 12 | t.Parallel() 13 | tests := []struct { 14 | name string 15 | usrContext map[string]interface{} 16 | property string 17 | values []interface{} 18 | expectedResult bool 19 | }{ 20 | { 21 | name: "contains ipv4", 22 | usrContext: map[string]interface{}{"$ip": "166.9.193.112"}, 23 | property: "$ip", 24 | values: []interface{}{"166.9.193.0/8"}, 25 | expectedResult: true, 26 | }, 27 | { 28 | name: "doesnt contain ipv4", 29 | usrContext: map[string]interface{}{"$ip": "166.9.193.112"}, 30 | property: "$ip", 31 | values: []interface{}{"166.9.194.0/24"}, 32 | expectedResult: false, 33 | }, 34 | { 35 | name: "contains ipv6", 36 | usrContext: map[string]interface{}{"$ip": "2001:0db8:0123:4567:89ab:cdef:1234:5678"}, 37 | property: "$ip", 38 | values: []interface{}{"::/0"}, 39 | expectedResult: true, 40 | }, 41 | { 42 | name: "doesnt contain ipv6", 43 | usrContext: map[string]interface{}{"$ip": "2001:0db8:0123:4567:89ab:cdef:1234:5678"}, 44 | property: "$ip", 45 | values: []interface{}{"2001:0db9:0:0:0:0:0:0/32"}, 46 | expectedResult: false, 47 | }, 48 | // ======================================================================== 49 | { 50 | name: "unknown config type", 51 | usrContext: map[string]interface{}{"$ip": "abcdef"}, 52 | property: "$ip", 53 | values: []interface{}{struct{}{}}, 54 | expectedResult: false, 55 | }, 56 | { 57 | name: "non-string user type", 58 | usrContext: map[string]interface{}{"$ip": nil}, 59 | property: "$ip", 60 | values: []interface{}{"2001:0db9:0:0:0:0:0:0/32"}, 61 | expectedResult: false, 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | tt := tt 67 | t.Run(tt.name, func(t *testing.T) { 68 | t.Parallel() 69 | res, err := operator.InNetwork(tt.usrContext[tt.property], tt.values) 70 | assert.NoError(t, err) 71 | assert.Equal(t, tt.expectedResult, res) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/operator/oneof.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | ) 8 | 9 | // OneOf operator will check if the value from the user context equals to 10 | // any of the configured values on the flag. 11 | func OneOf(usrValue interface{}, validValues []interface{}) (bool, error) { 12 | for _, v := range validValues { 13 | ok, err := equals(v, usrValue) 14 | if err != nil { 15 | return false, err 16 | } 17 | if ok { 18 | return true, nil 19 | } 20 | } 21 | return false, nil 22 | } 23 | 24 | // NotOneOf operator will check if the value from the user context doesn't equal to 25 | // any of the configured values on the flag. 26 | func NotOneOf(usrValue interface{}, validValues []interface{}) (bool, error) { 27 | for _, v := range validValues { 28 | ok, err := equals(v, usrValue) 29 | if err != nil { 30 | return false, err 31 | } 32 | if ok { 33 | return false, nil 34 | } 35 | } 36 | return true, nil 37 | } 38 | 39 | func equals(cnstrnValue, userValue interface{}) (bool, error) { 40 | if userValue == nil { 41 | return false, nil 42 | } 43 | switch v := cnstrnValue.(type) { 44 | case string, bool, float64: 45 | return v == userValue, nil 46 | case int, int32, int64: 47 | v1, _ := toInt64(v) 48 | v2, err := toInt64(userValue) 49 | if err != nil { 50 | return false, err 51 | } 52 | return v1 == v2, nil 53 | case uint, uint32, uint64: 54 | v1, _ := toUInt64(v) 55 | v2, err := toUInt64(userValue) 56 | if err != nil { 57 | return false, err 58 | } 59 | return v1 == v2, nil 60 | case []byte: 61 | uv, ok := userValue.([]byte) 62 | if !ok { 63 | return false, nil 64 | } 65 | return bytes.Equal(v, uv), nil 66 | default: 67 | return false, nil 68 | } 69 | } 70 | 71 | func toInt64(n interface{}) (int64, error) { 72 | switch v := n.(type) { 73 | case int: 74 | return int64(v), nil 75 | case int32: 76 | return int64(v), nil 77 | case int64: 78 | return v, nil 79 | case json.Number: 80 | return v.Int64() 81 | default: 82 | return 0, errors.New("not an integer") 83 | } 84 | } 85 | 86 | func toUInt64(n interface{}) (uint64, error) { 87 | switch v := n.(type) { 88 | case uint: 89 | return uint64(v), nil 90 | case uint32: 91 | return uint64(v), nil 92 | case uint64: 93 | return v, nil 94 | default: 95 | return 0, errors.New("not an unsigned integer") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/operator/regex.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | // MatchesRegex operator will check if the value from the user context matches 8 | // any regexes configured on the flag. 9 | func MatchesRegex(usrValue interface{}, validValues []interface{}) (bool, error) { 10 | for _, v := range validValues { 11 | ok, err := matches(v, usrValue) 12 | if err != nil { 13 | return false, err 14 | } 15 | if ok { 16 | return true, nil 17 | } 18 | } 19 | return false, nil 20 | } 21 | 22 | // DoesntMatchRegex operator will check if the value from the user context doesn't match 23 | // any regexes configured on the flag. 24 | func DoesntMatchRegex(usrValue interface{}, validValues []interface{}) (bool, error) { 25 | for _, v := range validValues { 26 | ok, err := matches(v, usrValue) 27 | if err != nil { 28 | return false, err 29 | } 30 | if ok { 31 | return false, nil 32 | } 33 | } 34 | return true, nil 35 | } 36 | 37 | func matches(cnstrnValue, userValue interface{}) (bool, error) { 38 | str, err := toString(userValue) 39 | if err != nil { 40 | return false, nil 41 | } 42 | switch v := cnstrnValue.(type) { 43 | case string: 44 | return regexp.Match(v, []byte(str)) 45 | case []byte: 46 | return regexp.Match(string(v), []byte(str)) 47 | default: 48 | return false, nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/operator/regex_test.go: -------------------------------------------------------------------------------- 1 | package operator_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/uw-labs/flaggio/internal/operator" 9 | ) 10 | 11 | func TestMatchesRegex(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | usrContext map[string]interface{} 15 | property string 16 | values []interface{} 17 | expectedResult bool 18 | }{ 19 | { 20 | name: "matches string", 21 | usrContext: map[string]interface{}{"prop": "abcdef"}, 22 | property: "prop", 23 | values: []interface{}{"[a-z]+"}, 24 | expectedResult: true, 25 | }, 26 | { 27 | name: "matches any strings from the list", 28 | usrContext: map[string]interface{}{"prop": "abcdef"}, 29 | property: "prop", 30 | values: []interface{}{"[a-z]+", "[0-9]+"}, 31 | expectedResult: true, 32 | }, 33 | { 34 | name: "doesnt match string", 35 | usrContext: map[string]interface{}{"prop": "abcdef"}, 36 | property: "prop", 37 | values: []interface{}{"[0-9]+"}, 38 | expectedResult: false, 39 | }, 40 | // ======================================================================== 41 | { 42 | name: "unknown config type", 43 | usrContext: map[string]interface{}{"prop": "abcdef"}, 44 | property: "prop", 45 | values: []interface{}{struct{}{}}, 46 | expectedResult: false, 47 | }, 48 | { 49 | name: "non-string user type", 50 | usrContext: map[string]interface{}{"prop": nil}, 51 | property: "prop", 52 | values: []interface{}{"cde"}, 53 | expectedResult: false, 54 | }, 55 | } 56 | 57 | for _, tt := range tests { 58 | tt := tt 59 | t.Run(tt.name, func(t *testing.T) { 60 | res, err := operator.MatchesRegex(tt.usrContext[tt.property], tt.values) 61 | assert.NoError(t, err) 62 | assert.Equal(t, tt.expectedResult, res) 63 | }) 64 | } 65 | } 66 | 67 | func TestDoesntMatchRegex(t *testing.T) { 68 | tests := []struct { 69 | name string 70 | usrContext map[string]interface{} 71 | property string 72 | values []interface{} 73 | expectedResult bool 74 | }{ 75 | { 76 | name: "doesnt match string", 77 | usrContext: map[string]interface{}{"prop": "abcdef"}, 78 | property: "prop", 79 | values: []interface{}{"[0-9]+"}, 80 | expectedResult: true, 81 | }, 82 | { 83 | name: "doesnt match any strings from list", 84 | usrContext: map[string]interface{}{"prop": "abcdef"}, 85 | property: "prop", 86 | values: []interface{}{"[0-9]+", "[x-z]+"}, 87 | expectedResult: true, 88 | }, 89 | { 90 | name: "matches string", 91 | usrContext: map[string]interface{}{"prop": "abcdef"}, 92 | property: "prop", 93 | values: []interface{}{"[a-z]+"}, 94 | expectedResult: false, 95 | }, 96 | // ======================================================================== 97 | { 98 | name: "unknown config type", 99 | usrContext: map[string]interface{}{"prop": "abcdef"}, 100 | property: "prop", 101 | values: []interface{}{struct{}{}}, 102 | expectedResult: true, 103 | }, 104 | { 105 | name: "non-string user type", 106 | usrContext: map[string]interface{}{"prop": nil}, 107 | property: "prop", 108 | values: []interface{}{"cde"}, 109 | expectedResult: true, 110 | }, 111 | } 112 | 113 | for _, tt := range tests { 114 | tt := tt 115 | t.Run(tt.name, func(t *testing.T) { 116 | res, err := operator.DoesntMatchRegex(tt.usrContext[tt.property], tt.values) 117 | assert.NoError(t, err) 118 | assert.Equal(t, tt.expectedResult, res) 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /internal/operator/startswith.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // StartsWith operator will check if the value from the user context starts with 8 | // any of the configured values on the flag. 9 | func StartsWith(usrValue interface{}, validValues []interface{}) (bool, error) { 10 | for _, v := range validValues { 11 | if startsWith(v, usrValue) { 12 | return true, nil 13 | } 14 | } 15 | return false, nil 16 | } 17 | 18 | // DoesntStartWith operator will check if the value from the user context doesn't start with 19 | // any of the configured values on the flag. 20 | func DoesntStartWith(usrValue interface{}, validValues []interface{}) (bool, error) { 21 | for _, v := range validValues { 22 | if startsWith(v, usrValue) { 23 | return false, nil 24 | } 25 | } 26 | return true, nil 27 | } 28 | 29 | func startsWith(cnstrnValue, userValue interface{}) bool { 30 | str, err := toString(userValue) 31 | if err != nil { 32 | return false 33 | } 34 | switch v := cnstrnValue.(type) { 35 | case string: 36 | return strings.HasPrefix(str, v) 37 | case []byte: 38 | return strings.HasPrefix(str, string(v)) 39 | default: 40 | return false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/operator/validates.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/uw-labs/flaggio/internal/errors" 8 | ) 9 | 10 | // Validator validates the user context somehow and returns true when 11 | // the validations are satisfied. 12 | type Validator interface { 13 | Validate(usrContext map[string]interface{}) (bool, error) 14 | } 15 | 16 | // Validates operator will check if the value from the user context validates 17 | // against the configured validator on the flag. 18 | func Validates(usrValue interface{}, validValues []interface{}) (bool, error) { 19 | uv, ok := usrValue.(map[string]interface{}) 20 | if !ok { 21 | return false, errors.InvalidFlag( 22 | fmt.Sprintf("expected user value to be a map[string]interface{}, got: %s", uv), 23 | ) 24 | } 25 | for _, v := range validValues { 26 | vldtr, ok := v.(Validator) 27 | if !ok || vldtr == nil { 28 | // this happens when the reference is invalid on the flag 29 | // one possible scenario is the segment was deleted, leaving the 30 | // the flag constraint with an invalid reference 31 | logrus.WithField("value", v).Error("expected value to be an Evaluator") 32 | return false, nil 33 | } 34 | valid, err := vldtr.Validate(uv) 35 | if err != nil { 36 | return false, err 37 | } 38 | if !valid { 39 | return false, nil 40 | } 41 | } 42 | return true, nil 43 | } 44 | 45 | // DoesntValidate operator will check if the value from the user context doesn't validate 46 | // against the configured validator on the flag. 47 | func DoesntValidate(usrValue interface{}, validValues []interface{}) (bool, error) { 48 | uv, ok := usrValue.(map[string]interface{}) 49 | if !ok { 50 | return false, errors.InvalidFlag( 51 | fmt.Sprintf("expected user value to be a map[string]interface{}, got: %s", uv), 52 | ) 53 | } 54 | for _, v := range validValues { 55 | evltr, ok := v.(Validator) 56 | if !ok || evltr == nil { 57 | logrus.WithField("value", v).Error("expected value to be an Evaluator") 58 | return false, nil 59 | } 60 | valid, err := evltr.Validate(uv) 61 | if err != nil { 62 | return false, err 63 | } 64 | if valid { 65 | return false, nil 66 | } 67 | } 68 | return true, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/repository/evaluation.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/evaluation_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository Evaluation 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // Flag represents a set of operations available to list and manage evaluations. 12 | type Evaluation interface { 13 | // FindAllByUserID returns all previous flag evaluations for a given user ID. 14 | FindAllByUserID(ctx context.Context, userID string, search *string, offset, limit *int64) (*flaggio.EvaluationResults, error) 15 | // FindAllByReqHash returns all previous flag evaluations for a given request hash. 16 | FindAllByReqHash(ctx context.Context, reqHash string) (flaggio.EvaluationList, error) 17 | // FindByReqHashAndFlagKey returns a previous flag evaluation for a given request hash and flag key. 18 | FindByReqHashAndFlagKey(ctx context.Context, reqHash, flagKey string) (*flaggio.Evaluation, error) 19 | // FindByID returns a previous flag evaluation by its ID. 20 | FindByID(ctx context.Context, id string) (*flaggio.Evaluation, error) 21 | // ReplaceOne creates or replaces one evaluation for a user ID. 22 | ReplaceOne(ctx context.Context, userID string, eval *flaggio.Evaluation) error 23 | // ReplaceAll creates or replaces evaluations for a combination of user and request hash. 24 | ReplaceAll(ctx context.Context, userID, reqHash string, evals flaggio.EvaluationList) error 25 | // DeleteAllByUserID deletes evaluations for a user. 26 | DeleteAllByUserID(ctx context.Context, userID string) error 27 | // DeleteByID deletes an evaluation by its ID. 28 | DeleteByID(ctx context.Context, id string) error 29 | } 30 | -------------------------------------------------------------------------------- /internal/repository/flag.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/flag_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository Flag 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // Flag represents a set of operations available to list and manage flags. 12 | type Flag interface { 13 | // FindAll returns a list of flags, based on an optional offset and limit. 14 | FindAll(ctx context.Context, search *string, offset, limit *int64) (*flaggio.FlagResults, error) 15 | // FindByID returns a flag that has a given ID. 16 | FindByID(ctx context.Context, id string) (*flaggio.Flag, error) 17 | // FindByKey returns a flag that has a given key. 18 | FindByKey(ctx context.Context, key string) (*flaggio.Flag, error) 19 | // Create creates a new flag. 20 | Create(ctx context.Context, input flaggio.NewFlag) (string, error) 21 | // Update updates a flag. 22 | Update(ctx context.Context, id string, input flaggio.UpdateFlag) error 23 | // Delete deletes a flag. 24 | Delete(ctx context.Context, id string) error 25 | } 26 | -------------------------------------------------------------------------------- /internal/repository/mocks/user_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/uw-labs/flaggio/internal/repository (interfaces: User) 3 | 4 | // Package repository_mock is a generated GoMock package. 5 | package repository_mock 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | flaggio "github.com/uw-labs/flaggio/internal/flaggio" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockUser is a mock of User interface 15 | type MockUser struct { 16 | ctrl *gomock.Controller 17 | recorder *MockUserMockRecorder 18 | } 19 | 20 | // MockUserMockRecorder is the mock recorder for MockUser 21 | type MockUserMockRecorder struct { 22 | mock *MockUser 23 | } 24 | 25 | // NewMockUser creates a new mock instance 26 | func NewMockUser(ctrl *gomock.Controller) *MockUser { 27 | mock := &MockUser{ctrl: ctrl} 28 | mock.recorder = &MockUserMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockUser) EXPECT() *MockUserMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Delete mocks base method 38 | func (m *MockUser) Delete(arg0 context.Context, arg1 string) error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Delete", arg0, arg1) 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // Delete indicates an expected call of Delete 46 | func (mr *MockUserMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockUser)(nil).Delete), arg0, arg1) 49 | } 50 | 51 | // FindAll mocks base method 52 | func (m *MockUser) FindAll(arg0 context.Context, arg1 *string, arg2, arg3 *int64) (*flaggio.UserResults, error) { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "FindAll", arg0, arg1, arg2, arg3) 55 | ret0, _ := ret[0].(*flaggio.UserResults) 56 | ret1, _ := ret[1].(error) 57 | return ret0, ret1 58 | } 59 | 60 | // FindAll indicates an expected call of FindAll 61 | func (mr *MockUserMockRecorder) FindAll(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockUser)(nil).FindAll), arg0, arg1, arg2, arg3) 64 | } 65 | 66 | // FindByID mocks base method 67 | func (m *MockUser) FindByID(arg0 context.Context, arg1 string) (*flaggio.User, error) { 68 | m.ctrl.T.Helper() 69 | ret := m.ctrl.Call(m, "FindByID", arg0, arg1) 70 | ret0, _ := ret[0].(*flaggio.User) 71 | ret1, _ := ret[1].(error) 72 | return ret0, ret1 73 | } 74 | 75 | // FindByID indicates an expected call of FindByID 76 | func (mr *MockUserMockRecorder) FindByID(arg0, arg1 interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockUser)(nil).FindByID), arg0, arg1) 79 | } 80 | 81 | // Replace mocks base method 82 | func (m *MockUser) Replace(arg0 context.Context, arg1 string, arg2 flaggio.UserContext) error { 83 | m.ctrl.T.Helper() 84 | ret := m.ctrl.Call(m, "Replace", arg0, arg1, arg2) 85 | ret0, _ := ret[0].(error) 86 | return ret0 87 | } 88 | 89 | // Replace indicates an expected call of Replace 90 | func (mr *MockUserMockRecorder) Replace(arg0, arg1, arg2 interface{}) *gomock.Call { 91 | mr.mock.ctrl.T.Helper() 92 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Replace", reflect.TypeOf((*MockUser)(nil).Replace), arg0, arg1, arg2) 93 | } 94 | -------------------------------------------------------------------------------- /internal/repository/mocks/variant_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/uw-labs/flaggio/internal/repository (interfaces: Variant) 3 | 4 | // Package repository_mock is a generated GoMock package. 5 | package repository_mock 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | flaggio "github.com/uw-labs/flaggio/internal/flaggio" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockVariant is a mock of Variant interface 15 | type MockVariant struct { 16 | ctrl *gomock.Controller 17 | recorder *MockVariantMockRecorder 18 | } 19 | 20 | // MockVariantMockRecorder is the mock recorder for MockVariant 21 | type MockVariantMockRecorder struct { 22 | mock *MockVariant 23 | } 24 | 25 | // NewMockVariant creates a new mock instance 26 | func NewMockVariant(ctrl *gomock.Controller) *MockVariant { 27 | mock := &MockVariant{ctrl: ctrl} 28 | mock.recorder = &MockVariantMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockVariant) EXPECT() *MockVariantMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Create mocks base method 38 | func (m *MockVariant) Create(arg0 context.Context, arg1 string, arg2 flaggio.NewVariant) (string, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) 41 | ret0, _ := ret[0].(string) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Create indicates an expected call of Create 47 | func (mr *MockVariantMockRecorder) Create(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockVariant)(nil).Create), arg0, arg1, arg2) 50 | } 51 | 52 | // Delete mocks base method 53 | func (m *MockVariant) Delete(arg0 context.Context, arg1, arg2 string) error { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2) 56 | ret0, _ := ret[0].(error) 57 | return ret0 58 | } 59 | 60 | // Delete indicates an expected call of Delete 61 | func (mr *MockVariantMockRecorder) Delete(arg0, arg1, arg2 interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockVariant)(nil).Delete), arg0, arg1, arg2) 64 | } 65 | 66 | // FindByID mocks base method 67 | func (m *MockVariant) FindByID(arg0 context.Context, arg1, arg2 string) (*flaggio.Variant, error) { 68 | m.ctrl.T.Helper() 69 | ret := m.ctrl.Call(m, "FindByID", arg0, arg1, arg2) 70 | ret0, _ := ret[0].(*flaggio.Variant) 71 | ret1, _ := ret[1].(error) 72 | return ret0, ret1 73 | } 74 | 75 | // FindByID indicates an expected call of FindByID 76 | func (mr *MockVariantMockRecorder) FindByID(arg0, arg1, arg2 interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockVariant)(nil).FindByID), arg0, arg1, arg2) 79 | } 80 | 81 | // Update mocks base method 82 | func (m *MockVariant) Update(arg0 context.Context, arg1, arg2 string, arg3 flaggio.UpdateVariant) error { 83 | m.ctrl.T.Helper() 84 | ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3) 85 | ret0, _ := ret[0].(error) 86 | return ret0 87 | } 88 | 89 | // Update indicates an expected call of Update 90 | func (mr *MockVariantMockRecorder) Update(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 91 | mr.mock.ctrl.T.Helper() 92 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockVariant)(nil).Update), arg0, arg1, arg2, arg3) 93 | } 94 | -------------------------------------------------------------------------------- /internal/repository/mongodb/mongo_test.go: -------------------------------------------------------------------------------- 1 | package mongodb_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "go.mongodb.org/mongo-driver/mongo" 9 | "go.mongodb.org/mongo-driver/mongo/options" 10 | ) 11 | 12 | var ( 13 | mongoClient *mongo.Client 14 | mongoDB *mongo.Database 15 | ) 16 | 17 | func TestMain(t *testing.M) { 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | mongoURI := os.Getenv("MONGO_URI") 20 | if mongoURI == "" { 21 | mongoURI = "mongodb://localhost:27017" 22 | } 23 | c, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI)) 24 | if err != nil { 25 | panic(err) 26 | } 27 | if err := c.Ping(ctx, nil); err != nil { 28 | panic(err) 29 | } 30 | mongoClient = c 31 | mongoDB = mongoClient.Database("flaggio_test") 32 | code := t.Run() 33 | if err := mongoClient.Disconnect(ctx); err != nil { 34 | panic(err) 35 | } 36 | cancel() 37 | os.Exit(code) 38 | } 39 | -------------------------------------------------------------------------------- /internal/repository/mongodb/variant.repository_test.go: -------------------------------------------------------------------------------- 1 | package mongodb_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/uw-labs/flaggio/internal/flaggio" 10 | mongo_repo "github.com/uw-labs/flaggio/internal/repository/mongodb" 11 | ) 12 | 13 | func TestVariantRepository(t *testing.T) { 14 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 15 | defer cancel() 16 | // drop database first 17 | if err := mongoDB.Drop(ctx); err != nil { 18 | t.Fatalf("failed drop database: %s", err) 19 | } 20 | 21 | // create new repo 22 | flgRepo, err := mongo_repo.NewFlagRepository(ctx, mongoDB) 23 | assert.NoError(t, err, "failed to create flag repository") 24 | repo := mongo_repo.NewVariantRepository(flgRepo.(*mongo_repo.FlagRepository)) 25 | 26 | // create a flag 27 | flgID, err := flgRepo.Create(ctx, flaggio.NewFlag{Key: "test"}) 28 | assert.NoError(t, err, "failed to create flag") 29 | 30 | var vrnt1ID, vrnt2ID string 31 | 32 | tests := []struct { 33 | name string 34 | run func(t *testing.T) 35 | }{ 36 | { 37 | name: "create the first variant", 38 | run: func(t *testing.T) { 39 | vrnt1ID, err = repo.Create(ctx, flgID, flaggio.NewVariant{Value: 2.1}) 40 | assert.NoError(t, err, "failed to create first variant") 41 | }, 42 | }, 43 | { 44 | name: "checks the variant was created", 45 | run: func(t *testing.T) { 46 | vrnt, err := repo.FindByID(ctx, flgID, vrnt1ID) 47 | assert.NoError(t, err, "failed to find first variant") 48 | assert.Equal(t, &flaggio.Variant{ID: vrnt1ID, Value: 2.1}, vrnt) 49 | }, 50 | }, 51 | { 52 | name: "create the second variant", 53 | run: func(t *testing.T) { 54 | vrnt2ID, err = repo.Create(ctx, flgID, flaggio.NewVariant{Value: "a"}) 55 | assert.NoError(t, err, "failed to create second variant") 56 | }, 57 | }, 58 | { 59 | name: "find the created variant", 60 | run: func(t *testing.T) { 61 | vrnt, err := repo.FindByID(ctx, flgID, vrnt2ID) 62 | assert.NoError(t, err, "failed to find second variant") 63 | assert.Equal(t, &flaggio.Variant{ID: vrnt2ID, Value: "a"}, vrnt) 64 | }, 65 | }, 66 | { 67 | name: "update the second variant", 68 | run: func(t *testing.T) { 69 | err := repo.Update(ctx, flgID, vrnt2ID, flaggio.UpdateVariant{Value: false}) 70 | assert.NoError(t, err, "failed to update second variant") 71 | }, 72 | }, 73 | { 74 | name: "find second variant", 75 | run: func(t *testing.T) { 76 | vrnt, err := repo.FindByID(ctx, flgID, vrnt2ID) 77 | assert.NoError(t, err, "failed to find second variant again") 78 | assert.Equal(t, &flaggio.Variant{ID: vrnt2ID, Value: false}, vrnt) 79 | }, 80 | }, 81 | { 82 | name: "delete the first variant", 83 | run: func(t *testing.T) { 84 | err := repo.Delete(ctx, flgID, vrnt1ID) 85 | assert.NoError(t, err, "failed to delete first variant") 86 | }, 87 | }, 88 | { 89 | name: "find deleted variant", 90 | run: func(t *testing.T) { 91 | vrnt, err := repo.FindByID(ctx, flgID, vrnt1ID) 92 | assert.EqualError(t, err, "variant: not found") 93 | assert.Nil(t, vrnt) 94 | }, 95 | }, 96 | } 97 | 98 | for _, tt := range tests { 99 | tt := tt 100 | t.Run(tt.name, tt.run) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /internal/repository/redis/cache.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | func shouldCacheFindAll(search *string, offset, limit *int64) bool { 4 | if search == nil && offset == nil && limit == nil { 5 | return true 6 | } 7 | return false 8 | } 9 | -------------------------------------------------------------------------------- /internal/repository/redis/redis_test.go: -------------------------------------------------------------------------------- 1 | package redis_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/go-redis/redis/v7" 9 | ) 10 | 11 | var ( 12 | redisClient *redis.Client 13 | ) 14 | 15 | func TestMain(t *testing.M) { 16 | redisClient = redis.NewClient(&redis.Options{ 17 | Addr: func() string { 18 | host := os.Getenv("REDIS_HOST") 19 | port := os.Getenv("REDIS_PORT") 20 | if host == "" { 21 | host = "localhost" 22 | } 23 | if port == "" { 24 | port = "6379" 25 | } 26 | return fmt.Sprintf("%s:%s", host, port) 27 | }(), 28 | }) 29 | if err := redisClient.Ping().Err(); err != nil { 30 | panic(err) 31 | } 32 | code := t.Run() 33 | if err := redisClient.Close(); err != nil { 34 | panic(err) 35 | } 36 | os.Exit(code) 37 | } 38 | -------------------------------------------------------------------------------- /internal/repository/redis/variant.repository.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-redis/redis/v7" 8 | "github.com/opentracing/opentracing-go" 9 | "github.com/uw-labs/flaggio/internal/flaggio" 10 | "github.com/uw-labs/flaggio/internal/repository" 11 | ) 12 | 13 | var _ repository.Variant = (*VariantRepository)(nil) 14 | 15 | // VariantRepository implements repository.Variant interface using redis. 16 | type VariantRepository struct { 17 | redis *redis.Client 18 | store repository.Variant 19 | flagStore repository.Flag 20 | ttl time.Duration 21 | } 22 | 23 | // FindByID returns a variant that has a given ID. 24 | func (r *VariantRepository) FindByID(ctx context.Context, flagIDHex, idHex string) (*flaggio.Variant, error) { 25 | span, ctx := opentracing.StartSpanFromContext(ctx, "RedisVariantRepository.FindByID") 26 | defer span.Finish() 27 | 28 | // no caching for variants 29 | return r.store.FindByID(ctx, flagIDHex, idHex) 30 | } 31 | 32 | // Create creates a new variant. 33 | func (r *VariantRepository) Create(ctx context.Context, flagID string, input flaggio.NewVariant) (string, error) { 34 | span, ctx := opentracing.StartSpanFromContext(ctx, "RedisVariantRepository.Create") 35 | defer span.Finish() 36 | 37 | id, err := r.store.Create(ctx, flagID, input) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | // invalidate all relevant keys 43 | return id, r.invalidateRelevantCacheKeys(ctx, flagID) 44 | } 45 | 46 | // Update updates a variant. 47 | func (r *VariantRepository) Update(ctx context.Context, flagID, id string, input flaggio.UpdateVariant) error { 48 | span, ctx := opentracing.StartSpanFromContext(ctx, "RedisVariantRepository.Update") 49 | defer span.Finish() 50 | 51 | if err := r.store.Update(ctx, flagID, id, input); err != nil { 52 | return err 53 | } 54 | 55 | // invalidate all relevant keys 56 | return r.invalidateRelevantCacheKeys(ctx, flagID) 57 | } 58 | 59 | // Delete deletes a variant. 60 | func (r *VariantRepository) Delete(ctx context.Context, flagID, id string) error { 61 | span, ctx := opentracing.StartSpanFromContext(ctx, "RedisVariantRepository.Delete") 62 | defer span.Finish() 63 | 64 | // delete the flag 65 | if err := r.store.Delete(ctx, flagID, id); err != nil { 66 | return err 67 | } 68 | 69 | // invalidate all relevant keys 70 | return r.invalidateRelevantCacheKeys(ctx, flagID) 71 | } 72 | 73 | func (r *VariantRepository) invalidateRelevantCacheKeys(ctx context.Context, flagID string) error { 74 | // find the flag so we can get the flag key 75 | f, err := r.flagStore.FindByID(ctx, flagID) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // invalidate all relevant keys 81 | return r.redis.WithContext(ctx).Del( 82 | flaggio.FlagCacheKey("*"), 83 | flaggio.FlagCacheKey(flagID), 84 | flaggio.FlagCacheKey("key", f.Key), 85 | ).Err() 86 | } 87 | 88 | // NewVariantRepository returns a new variant repository that uses redis 89 | // as underlying storage. 90 | func NewVariantRepository(redisClient *redis.Client, store repository.Variant, flagStore repository.Flag) repository.Variant { 91 | return &VariantRepository{ 92 | redis: redisClient, 93 | store: store, 94 | flagStore: flagStore, 95 | ttl: 1 * time.Hour, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/repository/rule.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/rule_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository Rule 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // Rule represents a set of operations available to list and manage rules. 12 | type Rule interface { 13 | // FindFlagRuleByID returns a flag rule that has a given ID. 14 | FindFlagRuleByID(ctx context.Context, flagIDHex, idHex string) (*flaggio.FlagRule, error) 15 | // CreateFlagRule creates a new rule under a flag. 16 | CreateFlagRule(ctx context.Context, flagID string, input flaggio.NewFlagRule) (string, error) 17 | // UpdateFlagRule updates a rule under a flag. 18 | UpdateFlagRule(ctx context.Context, flagID, id string, input flaggio.UpdateFlagRule) error 19 | // DeleteFlagRule deletes a rule under a flag. 20 | DeleteFlagRule(ctx context.Context, flagID, id string) error 21 | // FindSegmentRuleByID returns a segment rule that has a given ID. 22 | FindSegmentRuleByID(ctx context.Context, segmentIDHex, idHex string) (*flaggio.SegmentRule, error) 23 | // CreateSegmentRule creates a new rule under a segment. 24 | CreateSegmentRule(ctx context.Context, segmentID string, input flaggio.NewSegmentRule) (string, error) 25 | // UpdateSegmentRule updates a rule under a segment. 26 | UpdateSegmentRule(ctx context.Context, segmentID, id string, input flaggio.UpdateSegmentRule) error 27 | // DeleteSegmentRule deletes a rule under a segment. 28 | DeleteSegmentRule(ctx context.Context, segmentID, id string) error 29 | } 30 | -------------------------------------------------------------------------------- /internal/repository/segment.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/segment_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository Segment 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // Segment represents a set of operations available to list and manage segments. 12 | type Segment interface { 13 | // FindAll returns a list of segments, based on an optional offset and limit. 14 | FindAll(ctx context.Context, offset, limit *int64) ([]*flaggio.Segment, error) 15 | // FindByID returns a segment that has a given ID. 16 | FindByID(ctx context.Context, id string) (*flaggio.Segment, error) 17 | // Create creates a new segment. 18 | Create(ctx context.Context, input flaggio.NewSegment) (string, error) 19 | // Update updates a segment. 20 | Update(ctx context.Context, id string, input flaggio.UpdateSegment) error 21 | // Delete deletes a segment. 22 | Delete(ctx context.Context, id string) error 23 | } 24 | -------------------------------------------------------------------------------- /internal/repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/user_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository User 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // User represents a set of operations available to list and manage users. 12 | type User interface { 13 | // FindAll returns a list of users, based on an optional offset and limit. 14 | FindAll(ctx context.Context, search *string, offset, limit *int64) (*flaggio.UserResults, error) 15 | // FindByID returns a user by its id. 16 | FindByID(ctx context.Context, id string) (*flaggio.User, error) 17 | // Replace creates or updates a user. 18 | Replace(ctx context.Context, userID string, userCtx flaggio.UserContext) error 19 | // Delete deletes a user. 20 | Delete(ctx context.Context, userID string) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/repository/variant.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | //go:generate mockgen -destination=./mocks/variant_mock.go -package=repository_mock github.com/uw-labs/flaggio/internal/repository Variant 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | ) 10 | 11 | // Variant represents a set of operations available to list and manage variants. 12 | type Variant interface { 13 | // FindByID returns a variant that has a given ID. 14 | FindByID(ctx context.Context, flagIDHex, idHex string) (*flaggio.Variant, error) 15 | // Create creates a new variant under a flag. 16 | Create(ctx context.Context, flagID string, input flaggio.NewVariant) (string, error) 17 | // Update updates a variant under a flag. 18 | Update(ctx context.Context, flagID, id string, input flaggio.UpdateVariant) error 19 | // Delete deletes a variant under a flag. 20 | Delete(ctx context.Context, flagID, id string) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/server/admin/query.resolver.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/uw-labs/flaggio/internal/flaggio" 7 | ) 8 | 9 | var _ QueryResolver = &queryResolver{} 10 | 11 | type queryResolver struct{ *Resolver } 12 | 13 | func (r *queryResolver) Ping(_ context.Context) (bool, error) { 14 | return true, nil 15 | } 16 | 17 | func (r *queryResolver) Flags(ctx context.Context, search *string, offset, limit *int) (*flaggio.FlagResults, error) { 18 | var ofst, lmt *int64 19 | if offset != nil { 20 | v := int64(*offset) 21 | ofst = &v 22 | } 23 | if limit != nil { 24 | v := int64(*limit) 25 | lmt = &v 26 | } 27 | return r.FlagRepo.FindAll(ctx, search, ofst, lmt) 28 | } 29 | 30 | func (r *queryResolver) Flag(ctx context.Context, id string) (*flaggio.Flag, error) { 31 | return r.FlagRepo.FindByID(ctx, id) 32 | } 33 | 34 | func (r *queryResolver) Segments(ctx context.Context, offset, limit *int) ([]*flaggio.Segment, error) { 35 | var ofst, lmt *int64 36 | if offset != nil { 37 | v := int64(*offset) 38 | ofst = &v 39 | } 40 | if limit != nil { 41 | v := int64(*limit) 42 | lmt = &v 43 | } 44 | return r.SegmentRepo.FindAll(ctx, ofst, lmt) 45 | } 46 | 47 | func (r *queryResolver) Segment(ctx context.Context, id string) (*flaggio.Segment, error) { 48 | return r.SegmentRepo.FindByID(ctx, id) 49 | } 50 | 51 | func (r *queryResolver) Users(ctx context.Context, search *string, offset, limit *int) (*flaggio.UserResults, error) { 52 | var ofst, lmt *int64 53 | if offset != nil { 54 | v := int64(*offset) 55 | ofst = &v 56 | } 57 | if limit != nil { 58 | v := int64(*limit) 59 | lmt = &v 60 | } 61 | return r.UserRepo.FindAll(ctx, search, ofst, lmt) 62 | } 63 | 64 | func (r *queryResolver) User(ctx context.Context, id string) (*flaggio.User, error) { 65 | return r.UserRepo.FindByID(ctx, id) 66 | } 67 | -------------------------------------------------------------------------------- /internal/server/admin/resolver.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "github.com/uw-labs/flaggio/internal/repository" 5 | ) 6 | 7 | var _ ResolverRoot = (*Resolver)(nil) 8 | 9 | // Resolver is the root resolver for the GraphQL server. 10 | type Resolver struct { 11 | FlagRepo repository.Flag 12 | VariantRepo repository.Variant 13 | RuleRepo repository.Rule 14 | SegmentRepo repository.Segment 15 | UserRepo repository.User 16 | EvaluationRepo repository.Evaluation 17 | } 18 | 19 | // Mutation returns the mutation resolver. 20 | func (r *Resolver) Mutation() MutationResolver { 21 | return &mutationResolver{r} 22 | } 23 | 24 | // Query returns the query resolver. 25 | func (r *Resolver) Query() QueryResolver { 26 | return &queryResolver{r} 27 | } 28 | 29 | // User returns the user resolver. 30 | func (r *Resolver) User() UserResolver { 31 | return &userResolver{r} 32 | } 33 | -------------------------------------------------------------------------------- /internal/server/admin/user.resolver.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/uw-labs/flaggio/internal/flaggio" 7 | ) 8 | 9 | var _ UserResolver = &userResolver{} 10 | 11 | type userResolver struct{ *Resolver } 12 | 13 | // Evaluations returns the list of evaluations for a given user. 14 | func (r *userResolver) Evaluations(ctx context.Context, usr *flaggio.User, search *string, offset, limit *int) (*flaggio.EvaluationResults, error) { 15 | var ofst, lmt *int64 16 | if offset != nil { 17 | v := int64(*offset) 18 | ofst = &v 19 | } 20 | if limit != nil { 21 | v := int64(*limit) 22 | lmt = &v 23 | } 24 | return r.EvaluationRepo.FindAllByUserID(ctx, usr.ID, search, ofst, lmt) 25 | } 26 | -------------------------------------------------------------------------------- /internal/server/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/go-chi/chi/middleware" 10 | "github.com/go-chi/render" 11 | "github.com/opentracing/opentracing-go" 12 | internalerrors "github.com/uw-labs/flaggio/internal/errors" 13 | "github.com/uw-labs/flaggio/internal/flaggio" 14 | "github.com/uw-labs/flaggio/internal/service" 15 | ) 16 | 17 | // POST /evaluate/{id} 18 | // Evaluates a given flag for the user 19 | func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { 20 | span, ctx := opentracing.StartSpanFromContext(r.Context(), "POST /evaluate/{id}") 21 | defer span.Finish() 22 | 23 | flagKey := chi.URLParam(r, "key") 24 | er := &service.EvaluationRequest{ 25 | UserContext: make(flaggio.UserContext), 26 | } 27 | defer r.Body.Close() 28 | 29 | // unmarshal JSON request 30 | if err := render.Bind(r, er); err != nil { 31 | badRequest := internalerrors.BadRequest(err.Error()) 32 | _ = render.Render(w, r, formatErr(badRequest)) 33 | return 34 | } 35 | 36 | // evaluate flag 37 | eval, err := s.flagsService.Evaluate(ctx, flagKey, er) 38 | if err != nil { 39 | s.logger.WithError(err).WithField("req_id", middleware.GetReqID(ctx)). 40 | Error("failed to evaluate flag") 41 | _ = render.Render(w, r, formatErr(err)) 42 | return 43 | } 44 | 45 | // render response 46 | if err = render.Render(w, r, eval); err != nil { 47 | cannotRender := fmt.Errorf("%w: %s", internalerrors.ErrCannotRenderResponse, err) 48 | _ = render.Render(w, r, formatErr(cannotRender)) 49 | return 50 | } 51 | } 52 | 53 | // POST /evaluate 54 | // Evaluates all flags for the user 55 | func (s *Server) handleEvaluateAll(w http.ResponseWriter, r *http.Request) { 56 | span, ctx := opentracing.StartSpanFromContext(r.Context(), "POST /evaluate") 57 | defer span.Finish() 58 | 59 | er := &service.EvaluationRequest{ 60 | UserContext: make(flaggio.UserContext), 61 | } 62 | defer r.Body.Close() 63 | 64 | // unmarshal JSON request 65 | if err := render.Bind(r, er); err != nil { 66 | badRequest := internalerrors.BadRequest(err.Error()) 67 | _ = render.Render(w, r, formatErr(badRequest)) 68 | return 69 | } 70 | 71 | // evaluate flags 72 | eval, err := s.flagsService.EvaluateAll(ctx, er) 73 | if err != nil { 74 | s.logger.WithError(err).WithField("req_id", middleware.GetReqID(ctx)). 75 | Error("failed to evaluate all") 76 | _ = render.Render(w, r, formatErr(err)) 77 | return 78 | } 79 | 80 | // render response 81 | if err = render.Render(w, r, eval); err != nil { 82 | cannotRender := fmt.Errorf("%w: %s", internalerrors.ErrCannotRenderResponse, err) 83 | _ = render.Render(w, r, formatErr(cannotRender)) 84 | return 85 | } 86 | } 87 | 88 | type errResponse struct { 89 | Err error `json:"-"` // low-level runtime error 90 | StatusCode int `json:"-"` // http response status code 91 | StatusText string `json:"status"` // user-level status message 92 | AppCode string `json:"code,omitempty"` // application-specific error code 93 | ErrorText string `json:"error,omitempty"` // application-level error message, for debugging 94 | } 95 | 96 | func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error { 97 | render.Status(r, e.StatusCode) 98 | return nil 99 | } 100 | 101 | func formatErr(err error) render.Renderer { 102 | res := &errResponse{ 103 | Err: err, 104 | StatusCode: http.StatusInternalServerError, 105 | StatusText: "error processing request", 106 | ErrorText: err.Error(), 107 | AppCode: "InternalServerError", 108 | } 109 | var e internalerrors.Err 110 | if errors.As(err, &e) { 111 | res.StatusCode = e.StatusCode() 112 | res.AppCode = e.AppCode() 113 | } 114 | return res 115 | } 116 | -------------------------------------------------------------------------------- /internal/server/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | "github.com/sirupsen/logrus" 8 | "github.com/uw-labs/flaggio/internal/service" 9 | ) 10 | 11 | // NewServer returns a new server object 12 | func NewServer( 13 | router chi.Router, 14 | flagsService service.Flag, 15 | logger *logrus.Entry, 16 | ) *Server { 17 | srv := &Server{ 18 | router: router, 19 | flagsService: flagsService, 20 | logger: logger, 21 | } 22 | srv.routes() 23 | return srv 24 | } 25 | 26 | // Server handles evaluation requests 27 | type Server struct { 28 | router chi.Router 29 | flagsService service.Flag 30 | logger *logrus.Entry 31 | } 32 | 33 | // ServeHTTP responds to an HTTP request 34 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35 | s.router.ServeHTTP(w, r) 36 | } 37 | 38 | // Setup all routes 39 | func (s *Server) routes() { 40 | // API version 1 41 | s.router.Route("/v1", func(r chi.Router) { 42 | r.Post("/evaluate", s.handleEvaluateAll) 43 | r.Post("/evaluate/{key}", s.handleEvaluate) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/service/flag.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | //go:generate mockgen -destination=./mocks/flag_mock.go -package=service_mock github.com/uw-labs/flaggio/internal/service Flag 4 | 5 | import ( 6 | "context" 7 | ) 8 | 9 | // Flag holds the logic for evaluating flags 10 | type Flag interface { 11 | // Evaluate returns the result of an evaluation of a single flag. 12 | Evaluate(ctx context.Context, flagKey string, req *EvaluationRequest) (*EvaluationResponse, error) 13 | // EvaluateAll returns the results of the evaluation of all flags. 14 | EvaluateAll(ctx context.Context, req *EvaluationRequest) (*EvaluationsResponse, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/service/mocks/flag_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/uw-labs/flaggio/internal/service (interfaces: Flag) 3 | 4 | // Package service_mock is a generated GoMock package. 5 | package service_mock 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | service "github.com/uw-labs/flaggio/internal/service" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockFlag is a mock of Flag interface 15 | type MockFlag struct { 16 | ctrl *gomock.Controller 17 | recorder *MockFlagMockRecorder 18 | } 19 | 20 | // MockFlagMockRecorder is the mock recorder for MockFlag 21 | type MockFlagMockRecorder struct { 22 | mock *MockFlag 23 | } 24 | 25 | // NewMockFlag creates a new mock instance 26 | func NewMockFlag(ctrl *gomock.Controller) *MockFlag { 27 | mock := &MockFlag{ctrl: ctrl} 28 | mock.recorder = &MockFlagMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockFlag) EXPECT() *MockFlagMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Evaluate mocks base method 38 | func (m *MockFlag) Evaluate(arg0 context.Context, arg1 string, arg2 *service.EvaluationRequest) (*service.EvaluationResponse, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Evaluate", arg0, arg1, arg2) 41 | ret0, _ := ret[0].(*service.EvaluationResponse) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Evaluate indicates an expected call of Evaluate 47 | func (mr *MockFlagMockRecorder) Evaluate(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockFlag)(nil).Evaluate), arg0, arg1, arg2) 50 | } 51 | 52 | // EvaluateAll mocks base method 53 | func (m *MockFlag) EvaluateAll(arg0 context.Context, arg1 *service.EvaluationRequest) (*service.EvaluationsResponse, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "EvaluateAll", arg0, arg1) 56 | ret0, _ := ret[0].(*service.EvaluationsResponse) 57 | ret1, _ := ret[1].(error) 58 | return ret0, ret1 59 | } 60 | 61 | // EvaluateAll indicates an expected call of EvaluateAll 62 | func (mr *MockFlagMockRecorder) EvaluateAll(arg0, arg1 interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateAll", reflect.TypeOf((*MockFlag)(nil).EvaluateAll), arg0, arg1) 65 | } 66 | -------------------------------------------------------------------------------- /internal/service/model.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "crypto/sha1" // nolint // only used for hashing requests 5 | "encoding/hex" 6 | "net/http" 7 | "sort" 8 | 9 | "github.com/victorkt/clientip" 10 | "github.com/vmihailenco/msgpack/v4" 11 | 12 | "github.com/uw-labs/flaggio/internal/flaggio" 13 | ) 14 | 15 | // EvaluationRequest is the evaluation request object 16 | type EvaluationRequest struct { 17 | UserID string `json:"userId"` 18 | UserContext flaggio.UserContext `json:"context"` 19 | Debug *bool `json:"debug,omitempty"` 20 | } 21 | 22 | // Bind adds additional data to the EvaluationRequest. 23 | // Some special fields are added to the user context: 24 | // * $userId is the user ID provided in the request 25 | // * $ip is the network address that originated the request 26 | func (er EvaluationRequest) Bind(r *http.Request) error { 27 | // enrich user context 28 | er.UserContext["$userId"] = er.UserID 29 | er.UserContext["$ip"] = clientip.FromContext(r.Context()).String() 30 | return nil 31 | } 32 | 33 | // IsDebug returns whether this is a debug request or not 34 | func (er EvaluationRequest) IsDebug() bool { 35 | return er.Debug != nil && *er.Debug 36 | } 37 | 38 | var hashContextBlacklist = map[string]struct{}{ 39 | "$ip": {}, 40 | } 41 | 42 | // Hash returns a hash string representation of EvaluationRequest 43 | // This function will return the same hash regardless of the order 44 | // the user context comes in. 45 | func (er EvaluationRequest) Hash() (string, error) { 46 | // sort user context keys 47 | var contextKeys []string 48 | for key := range er.UserContext { 49 | if _, blacklisted := hashContextBlacklist[key]; blacklisted { 50 | continue 51 | } 52 | contextKeys = append(contextKeys, key) 53 | } 54 | sort.Strings(contextKeys) 55 | 56 | // create 2d slice with sorted keys from user context 57 | ordered := make([]interface{}, len(contextKeys)) 58 | for idx, key := range contextKeys { 59 | ordered[idx] = []interface{}{key, er.UserContext[key]} 60 | } 61 | 62 | // marshal ordered slice and hash it 63 | bytes, err := msgpack.Marshal(ordered) 64 | if err != nil { 65 | return "", err 66 | } 67 | h := sha1.New() // nolint // we don't care about security for this 68 | if _, err := h.Write(bytes); err != nil { 69 | return "", err 70 | } 71 | return hex.EncodeToString(h.Sum(nil)), nil 72 | } 73 | 74 | // EvaluationResponse is the evaluation response object 75 | type EvaluationResponse struct { 76 | Evaluation *flaggio.Evaluation `json:"evaluation"` 77 | UserContext *flaggio.UserContext `json:"context,omitempty"` 78 | } 79 | 80 | // Render can enrich the EvaluationResponse object before being returned to the 81 | // user. Currently it does nothing, but is needed to satisfy the 82 | // chi.Renderer interface. 83 | func (e *EvaluationResponse) Render(w http.ResponseWriter, r *http.Request) error { 84 | return nil 85 | } 86 | 87 | // EvaluationsResponse is the evaluation response object 88 | type EvaluationsResponse struct { 89 | Evaluations flaggio.EvaluationList `json:"evaluations"` 90 | UserContext *flaggio.UserContext `json:"context,omitempty"` 91 | } 92 | 93 | // Render can enrich the EvaluationsResponse object before being returned to the 94 | // user. Currently it does nothing, but is needed to satisfy the 95 | // chi.Renderer interface. 96 | func (e *EvaluationsResponse) Render(w http.ResponseWriter, r *http.Request) error { 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/service/model_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/uw-labs/flaggio/internal/flaggio" 9 | "github.com/uw-labs/flaggio/internal/service" 10 | ) 11 | 12 | func TestEvaluationRequest_Hash(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | req service.EvaluationRequest 16 | expectedHash string 17 | }{ 18 | { 19 | name: "returns hash", 20 | req: service.EvaluationRequest{ 21 | UserID: "123", 22 | UserContext: flaggio.UserContext{ 23 | "abc": 123, 24 | "cde": "456", 25 | "efg": true, 26 | "ghi": nil, 27 | }, 28 | Debug: nil, 29 | }, 30 | expectedHash: "78c77cefc3d6a062e7c29140c4aef97be2d8e0c4", 31 | }, 32 | { 33 | name: "returns same hash regardless of order", 34 | req: service.EvaluationRequest{ 35 | UserID: "123", 36 | UserContext: flaggio.UserContext{ 37 | "cde": "456", 38 | "abc": 123, 39 | "ghi": nil, 40 | "efg": true, 41 | }, 42 | Debug: nil, 43 | }, 44 | expectedHash: "78c77cefc3d6a062e7c29140c4aef97be2d8e0c4", 45 | }, 46 | { 47 | name: "ignores blacklisted context attributes", 48 | req: service.EvaluationRequest{ 49 | UserID: "123", 50 | UserContext: flaggio.UserContext{ 51 | "$ip": "127.0.0.1", 52 | "cde": "456", 53 | "abc": 123, 54 | "ghi": nil, 55 | "efg": true, 56 | }, 57 | Debug: nil, 58 | }, 59 | expectedHash: "78c77cefc3d6a062e7c29140c4aef97be2d8e0c4", 60 | }, 61 | { 62 | name: "doesnt care about debug property", 63 | req: service.EvaluationRequest{ 64 | UserID: "123", 65 | UserContext: flaggio.UserContext{ 66 | "cde": "456", 67 | "abc": 123, 68 | "ghi": nil, 69 | "efg": true, 70 | }, 71 | Debug: boolPtr(true), 72 | }, 73 | expectedHash: "78c77cefc3d6a062e7c29140c4aef97be2d8e0c4", 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | tt := tt 79 | t.Run(tt.name, func(t *testing.T) { 80 | hash, err := tt.req.Hash() 81 | assert.NoError(t, err) 82 | assert.Equal(t, tt.expectedHash, hash) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /schema/admin.graphql: -------------------------------------------------------------------------------- 1 | input NewFlag { 2 | key: String! 3 | name: String! 4 | description: String 5 | } 6 | 7 | input UpdateFlag { 8 | key: String 9 | name: String 10 | description: String 11 | enabled: Boolean 12 | defaultVariantWhenOn: ID 13 | defaultVariantWhenOff: ID 14 | } 15 | 16 | input NewVariant { 17 | description: String 18 | value: Any! 19 | } 20 | 21 | input UpdateVariant { 22 | description: String 23 | value: Any 24 | } 25 | 26 | input NewConstraint { 27 | property: String! 28 | operation: Operation! 29 | values: [Any!]! 30 | } 31 | 32 | input NewDistribution { 33 | variantId: ID! 34 | percentage: Int! 35 | } 36 | 37 | input NewFlagRule { 38 | constraints: [NewConstraint!]! 39 | distributions: [NewDistribution!]! 40 | } 41 | 42 | input UpdateFlagRule { 43 | constraints: [NewConstraint!]! 44 | distributions: [NewDistribution!]! 45 | } 46 | 47 | input NewSegmentRule { 48 | constraints: [NewConstraint!]! 49 | } 50 | 51 | input UpdateSegmentRule { 52 | constraints: [NewConstraint!]! 53 | } 54 | 55 | input NewSegment { 56 | name: String! 57 | description: String 58 | } 59 | 60 | input UpdateSegment { 61 | name: String 62 | description: String 63 | } 64 | 65 | type FlagResults { 66 | flags: [Flag!]! 67 | total: Int! 68 | } 69 | 70 | type UserResults { 71 | users: [User!]! 72 | total: Int! 73 | } 74 | 75 | type EvaluationResults { 76 | evaluations: [Evaluation!]! 77 | total: Int! 78 | } 79 | 80 | extend type Query { 81 | flags(search: String, offset: Int, limit: Int): FlagResults! 82 | flag(id: ID!): Flag 83 | segments(offset: Int, limit: Int): [Segment!]! 84 | segment(id: ID!): Segment 85 | users(search: String, offset: Int, limit: Int): UserResults! 86 | user(id: ID!): User 87 | } 88 | 89 | extend type Mutation { 90 | createFlag(input: NewFlag!): Flag! 91 | updateFlag(id: ID!, input: UpdateFlag!): Flag! 92 | deleteFlag(id: ID!): ID! 93 | 94 | createVariant(flagId: ID!, input: NewVariant!): Variant! 95 | updateVariant(flagId: ID!, id: ID!, input: UpdateVariant!): Variant! 96 | deleteVariant(flagId: ID!, id: ID!): ID! 97 | 98 | createFlagRule(flagId: ID!, input: NewFlagRule!): FlagRule! 99 | updateFlagRule(flagId: ID!, id: ID!, input: UpdateFlagRule!): FlagRule! 100 | deleteFlagRule(flagId: ID!, id: ID!): ID! 101 | createSegmentRule(segmentId: ID!, input: NewSegmentRule!): SegmentRule! 102 | updateSegmentRule(segmentId: ID!, id: ID!, input: UpdateSegmentRule!): SegmentRule! 103 | deleteSegmentRule(segmentId: ID!, id: ID!): ID! 104 | 105 | createSegment(input: NewSegment!): Segment! 106 | updateSegment(id: ID!, input: UpdateSegment!): Segment! 107 | deleteSegment(id: ID!): ID! 108 | 109 | deleteUser(id: ID!): ID! 110 | 111 | deleteEvaluation(id: ID!): ID! 112 | } -------------------------------------------------------------------------------- /schema/flaggio.graphql: -------------------------------------------------------------------------------- 1 | scalar Time 2 | scalar Any 3 | scalar Map 4 | directive @goField(forceResolver: Boolean, name: String) on INPUT_FIELD_DEFINITION 5 | | FIELD_DEFINITION 6 | 7 | type Flag { 8 | id: ID! 9 | key: String! 10 | name: String! 11 | description: String 12 | enabled: Boolean! 13 | variants: [Variant!]! 14 | rules: [FlagRule!]! 15 | defaultVariantWhenOn: Variant 16 | defaultVariantWhenOff: Variant 17 | createdAt: Time! 18 | updatedAt: Time 19 | } 20 | 21 | type Variant { 22 | id: ID! 23 | description: String 24 | value: Any! 25 | } 26 | 27 | type Constraint { 28 | id: ID! 29 | property: String! 30 | operation: Operation! 31 | values: [Any]! 32 | } 33 | 34 | type Distribution { 35 | id: ID! 36 | variant: Variant! 37 | percentage: Int! 38 | } 39 | 40 | interface Ruler { 41 | id: ID! 42 | constraints: [Constraint!] 43 | } 44 | 45 | type FlagRule implements Ruler { 46 | id: ID! 47 | constraints: [Constraint!] 48 | distributions: [Distribution!] 49 | } 50 | 51 | type SegmentRule implements Ruler { 52 | id: ID! 53 | constraints: [Constraint!] 54 | } 55 | 56 | type Segment { 57 | id: ID! 58 | name: String! 59 | description: String 60 | rules: [SegmentRule!]! 61 | createdAt: Time! 62 | updatedAt: Time 63 | } 64 | 65 | type User { 66 | id: ID! 67 | context: Map! 68 | updatedAt: Time! 69 | evaluations(search: String, offset: Int, limit: Int): EvaluationResults! @goField(forceResolver: true) 70 | } 71 | 72 | type Evaluation { 73 | id: ID! 74 | flagId: ID! 75 | flagKey: String! 76 | flagVersion: Int! 77 | value: Any 78 | createdAt: Time! 79 | } 80 | 81 | enum Operation { 82 | ONE_OF 83 | NOT_ONE_OF 84 | GREATER 85 | GREATER_OR_EQUAL 86 | LOWER 87 | LOWER_OR_EQUAL 88 | EXISTS 89 | DOESNT_EXIST 90 | CONTAINS 91 | DOESNT_CONTAIN 92 | STARTS_WITH 93 | DOESNT_START_WITH 94 | ENDS_WITH 95 | DOESNT_END_WITH 96 | MATCHES_REGEX 97 | DOESNT_MATCH_REGEX 98 | IS_IN_SEGMENT 99 | ISNT_IN_SEGMENT 100 | IS_IN_NETWORK 101 | } 102 | 103 | type Query { 104 | ping: Boolean! 105 | } 106 | 107 | type Mutation { 108 | ping: Boolean! 109 | } -------------------------------------------------------------------------------- /schema/generate.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | //go:generate go run github.com/99designs/gqlgen --verbose --config gqlgen.admin.yml 4 | -------------------------------------------------------------------------------- /schema/gqlgen.admin.yml: -------------------------------------------------------------------------------- 1 | # gqlgen.admin.yml 2 | # Refer to https://gqlgen.com/config/ for detailed documentation. 3 | 4 | schema: 5 | - flaggio.graphql 6 | - admin.graphql 7 | exec: 8 | filename: ../internal/server/admin/admin.generated.go 9 | package: admin 10 | model: 11 | filename: ../internal/flaggio/admin.models.generated.go 12 | package: flaggio 13 | resolver: 14 | filename: ../internal/server/admin/resolver.go 15 | type: Resolver 16 | autobind: 17 | - github.com/uw-labs/flaggio/internal/flaggio -------------------------------------------------------------------------------- /web/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flaggio", 3 | "author": "Victor Kohl Tavares", 4 | "email": "flaggio@vkt.sh", 5 | "licence": "Apache License 2.0", 6 | "version": "0.4.0", 7 | "private": false, 8 | "scripts": { 9 | "start": "react-scripts start", 10 | "build": "react-scripts build", 11 | "test": "react-scripts test", 12 | "eject": "react-scripts eject" 13 | }, 14 | "eslintConfig": { 15 | "extends": "react-app" 16 | }, 17 | "browserslist": { 18 | "production": [ 19 | ">0.2%", 20 | "not dead", 21 | "not op_mini all" 22 | ], 23 | "development": [ 24 | "last 1 chrome version", 25 | "last 1 firefox version", 26 | "last 1 safari version" 27 | ] 28 | }, 29 | "dependencies": { 30 | "@apollo/react-hooks": "3.1.5", 31 | "@material-ui/core": "4.9.12", 32 | "@material-ui/icons": "4.9.1", 33 | "@material-ui/styles": "4.9.10", 34 | "@sindresorhus/slugify": "1.1.0", 35 | "apollo-boost": "0.4.9", 36 | "clsx": "1.1.1", 37 | "graphql": "14.7.0", 38 | "history": "4.10.1", 39 | "lodash": "4.17.21", 40 | "material-ui-chip-input": "2.0.0-beta.2", 41 | "moment": "2.29.4", 42 | "node-sass": "4.14.1", 43 | "prop-types": "15.7.2", 44 | "react": "16.13.1", 45 | "react-dom": "16.13.1", 46 | "react-perfect-scrollbar": "1.5.8", 47 | "react-router-dom": "5.2.0", 48 | "react-scripts": "3.4.3", 49 | "recompose": "0.30.0", 50 | "uuid": "8.3.1", 51 | "validate.js": "0.13.1" 52 | }, 53 | "devDependencies": { 54 | "eslint": "6.6.0", 55 | "eslint-plugin-prettier": "3.1.4", 56 | "eslint-plugin-react": "7.20.6", 57 | "prettier": "2.1.2", 58 | "prettier-eslint": "11.0.0", 59 | "prettier-eslint-cli": "5.0.0", 60 | "typescript": "4.1.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /web/public/images/auth.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uw-labs/flaggio/1151cb37af63897ba7d6f7bbe179e50cd9c055c4/web/public/images/auth.jpg -------------------------------------------------------------------------------- /web/public/images/logos/flaggio.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /web/public/images/not_found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uw-labs/flaggio/1151cb37af63897ba7d6f7bbe179e50cd9c055c4/web/public/images/not_found.png -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 10 | 11 | 12 | 16 |