diff --git a/feature-flags/.gitignore b/feature-flags/.gitignore new file mode 100644 index 0000000..a60a9a5 --- /dev/null +++ b/feature-flags/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +.idea/ +*.iml +*.ipr +*.iws +out/ +.settings/ +.classpath +.project +bin/ diff --git a/feature-flags/.sdkmanrc b/feature-flags/.sdkmanrc new file mode 100644 index 0000000..c247ebc --- /dev/null +++ b/feature-flags/.sdkmanrc @@ -0,0 +1 @@ +java=21-tem diff --git a/feature-flags/Dockerfile b/feature-flags/Dockerfile new file mode 100644 index 0000000..e57b889 --- /dev/null +++ b/feature-flags/Dockerfile @@ -0,0 +1,9 @@ +FROM gradle:8-jdk21 AS build +WORKDIR /app +COPY . . +RUN gradle bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/feature-flags/README.md b/feature-flags/README.md new file mode 100644 index 0000000..c7d82ba --- /dev/null +++ b/feature-flags/README.md @@ -0,0 +1,119 @@ +# Feature Flags with Flamingock + Spring Boot + PostgreSQL + +A working example that builds a feature-flag service from scratch using [Flamingock](https://www.flamingock.io) to manage database schema evolution, Spring Boot for the REST API, and PostgreSQL as the backing store. + +## Prerequisites + +- Java 21 (use `sdk env` if you have [SDKMAN](https://sdkman.io/) installed) +- Docker & Docker Compose + +## Quick Start + +```bash +docker compose up --build +``` + +That's it. Postgres starts first (health-checked), then the app boots, Flamingock runs the migrations, and the API is live at `http://localhost:8080`. + +### Running locally (without Docker for the app) + +```bash +# Start only Postgres +docker compose up db -d + +# Run the app +./gradlew bootRun +``` + +## API + +### Create a flag + +```bash +curl -s -X POST localhost:8080/flags \ + -H "Content-Type: application/json" \ + -d '{"name":"dark-mode","description":"Dark mode UI"}' +``` + +### List all flags + +```bash +curl -s localhost:8080/flags +``` + +### Update a flag (enable + set rollout %) + +```bash +curl -s -X PUT localhost:8080/flags/dark-mode \ + -H "Content-Type: application/json" \ + -d '{"enabled":true,"rolloutPercentage":30}' +``` + +### Evaluate a flag for a user + +```bash +curl -s "localhost:8080/flags/evaluate/dark-mode?userId=user-42" +``` + +Evaluation is deterministic — the same `userId` always lands in the same rollout bucket (SHA-256 hash). + +### Add a targeting rule + +```bash +curl -s -X POST localhost:8080/flags/dark-mode/rules \ + -H "Content-Type: application/json" \ + -d '{"attribute":"plan","operator":"equals","value":"pro"}' +``` + +### Evaluate with attributes + +```bash +curl -s "localhost:8080/flags/evaluate/dark-mode?userId=user-999&plan=pro" +``` + +When a targeting rule matches, the flag is enabled regardless of rollout percentage. + +### List rules for a flag + +```bash +curl -s localhost:8080/flags/dark-mode/rules +``` + +## How Flamingock manages the schema + +Instead of `ddl-auto` or hand-written SQL scripts, Flamingock applies versioned, auditable changes at startup: + +| Change | What it does | +|--------|-------------| +| `_0001__CreateFlagsTable` | Creates the `feature_flags` table | +| `_0002__AddRolloutPercentage` | Adds the `rollout_percentage` column | +| `_0003__CreateTargetingRules` | Creates the `targeting_rules` table + index | + +Each change targets the `postgres-flags` SQL target system and receives a `java.sql.Connection` automatically. Flamingock tracks execution in its audit store so changes run exactly once, even across restarts. + +## Targeting rule operators + +| Operator | Behaviour | +|----------|-----------| +| `equals` | Exact string match | +| `contains` | Substring match | +| `in` | Comma-separated list membership | +| `starts_with` | Prefix match | + +## Project structure + +``` +feature-flags/ +├── docker-compose.yml +├── Dockerfile +├── build.gradle +├── settings.gradle +└── src/main/java/com/example/flags/ + ├── FeatureFlagApplication.java # @EnableFlamingock entry point + ├── config/FlamingockConfig.java # SqlTargetSystem + audit store beans + ├── changes/ # Flamingock migrations + ├── model/ # JPA entities + ├── repository/ # Spring Data repositories + ├── service/EvaluationService.java # Flag evaluation logic + └── controller/FlagController.java # REST API +``` diff --git a/feature-flags/build.gradle b/feature-flags/build.gradle new file mode 100644 index 0000000..88dbe93 --- /dev/null +++ b/feature-flags/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' + id 'io.flamingock' version '1.0.0' +} + +flamingock { + community() + springboot() +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenLocal() + mavenCentral() +} + +group = 'com.example' +version = '1.0-SNAPSHOT' + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + runtimeOnly 'org.postgresql:postgresql' +} + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs.add('-parameters') +} diff --git a/feature-flags/docker-compose.yml b/feature-flags/docker-compose.yml new file mode 100644 index 0000000..ae93c0f --- /dev/null +++ b/feature-flags/docker-compose.yml @@ -0,0 +1,31 @@ +volumes: + pgdata: + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: flags + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/flags + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + depends_on: + db: + condition: service_healthy diff --git a/feature-flags/gradle/wrapper/gradle-wrapper.jar b/feature-flags/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/feature-flags/gradle/wrapper/gradle-wrapper.jar differ diff --git a/feature-flags/gradle/wrapper/gradle-wrapper.properties b/feature-flags/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3499ded --- /dev/null +++ b/feature-flags/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/feature-flags/gradlew b/feature-flags/gradlew new file mode 100755 index 0000000..a640a45 --- /dev/null +++ b/feature-flags/gradlew @@ -0,0 +1,2 @@ +#!/bin/sh +exec java -classpath "gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/feature-flags/gradlew.bat b/feature-flags/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/feature-flags/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/feature-flags/settings.gradle b/feature-flags/settings.gradle new file mode 100644 index 0000000..e6f4495 --- /dev/null +++ b/feature-flags/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} + +rootProject.name = 'feature-flags' diff --git a/feature-flags/src/main/java/io/flamingock/flags/FeatureFlagApplication.java b/feature-flags/src/main/java/io/flamingock/flags/FeatureFlagApplication.java new file mode 100644 index 0000000..d34700e --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/FeatureFlagApplication.java @@ -0,0 +1,12 @@ +package io.flamingock.flags; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class FeatureFlagApplication { + + public static void main(String[] args) { + SpringApplication.run(FeatureFlagApplication.class, args); + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/changes/_0001__CreateFlagsTable.java b/feature-flags/src/main/java/io/flamingock/flags/changes/_0001__CreateFlagsTable.java new file mode 100644 index 0000000..28a7a46 --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/changes/_0001__CreateFlagsTable.java @@ -0,0 +1,36 @@ +package io.flamingock.flags.changes; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.sql.Connection; +import java.sql.Statement; + +@TargetSystem(id = "postgres-flags") +@Change(id = "create-flags-table", author = "dev") +public class _0001__CreateFlagsTable { + + @Apply + public void apply(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute(""" + CREATE TABLE IF NOT EXISTS feature_flags ( + name VARCHAR(255) PRIMARY KEY, + description TEXT, + enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + """); + } + } + + @Rollback + public void rollback(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS feature_flags"); + } + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/changes/_0002__AddRolloutPercentage.java b/feature-flags/src/main/java/io/flamingock/flags/changes/_0002__AddRolloutPercentage.java new file mode 100644 index 0000000..ac2ddbb --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/changes/_0002__AddRolloutPercentage.java @@ -0,0 +1,28 @@ +package io.flamingock.flags.changes; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.sql.Connection; +import java.sql.Statement; + +@TargetSystem(id = "postgres-flags") +@Change(id = "add-rollout-percentage", author = "dev") +public class _0002__AddRolloutPercentage { + + @Apply + public void apply(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("ALTER TABLE feature_flags ADD COLUMN IF NOT EXISTS rollout_percentage INT DEFAULT 100"); + } + } + + @Rollback + public void rollback(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("ALTER TABLE feature_flags DROP COLUMN IF EXISTS rollout_percentage"); + } + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/changes/_0003__CreateTargetingRules.java b/feature-flags/src/main/java/io/flamingock/flags/changes/_0003__CreateTargetingRules.java new file mode 100644 index 0000000..0bb104e --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/changes/_0003__CreateTargetingRules.java @@ -0,0 +1,39 @@ +package io.flamingock.flags.changes; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.sql.Connection; +import java.sql.Statement; + +@TargetSystem(id = "postgres-flags") +@Change(id = "create-targeting-rules", author = "dev") +public class _0003__CreateTargetingRules { + + @Apply + public void apply(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS targeting_rules ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + flag_name VARCHAR(255) REFERENCES feature_flags(name), + attribute VARCHAR(255) NOT NULL, + operator VARCHAR(50) NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """); + stmt.execute("CREATE INDEX IF NOT EXISTS idx_targeting_rules_flag_name ON targeting_rules(flag_name)"); + } + } + + @Rollback + public void rollback(Connection connection) throws Exception { + try (Statement stmt = connection.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS targeting_rules"); + } + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/config/FlamingockConfig.java b/feature-flags/src/main/java/io/flamingock/flags/config/FlamingockConfig.java new file mode 100644 index 0000000..45a5a0c --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/config/FlamingockConfig.java @@ -0,0 +1,28 @@ +package io.flamingock.flags.config; + +import io.flamingock.api.annotations.EnableFlamingock; +import io.flamingock.api.annotations.Stage; +import io.flamingock.internal.core.external.store.CommunityAuditStore; +import io.flamingock.store.sql.SqlAuditStore; +import io.flamingock.targetsystem.sql.SqlTargetSystem; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +@EnableFlamingock( + stages = @Stage(location = "io.flamingock.flags.changes") +) +public class FlamingockConfig { + + @Bean + public SqlTargetSystem sqlTargetSystem(DataSource dataSource) { + return new SqlTargetSystem("postgres-flags", dataSource); + } + + @Bean + public CommunityAuditStore auditStore(SqlTargetSystem sqlTargetSystem) { + return SqlAuditStore.from(sqlTargetSystem); + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/controller/FlagController.java b/feature-flags/src/main/java/io/flamingock/flags/controller/FlagController.java new file mode 100644 index 0000000..e28eca5 --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/controller/FlagController.java @@ -0,0 +1,85 @@ +package io.flamingock.flags.controller; + +import io.flamingock.flags.model.FeatureFlag; +import io.flamingock.flags.model.TargetingRule; +import io.flamingock.flags.repository.FlagRepository; +import io.flamingock.flags.repository.TargetingRuleRepository; +import io.flamingock.flags.service.EvaluationService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/flags") +public class FlagController { + + record CreateFlagRequest(String name, String description) { + } + + record UpdateFlagRequest(Boolean enabled, Integer rolloutPercentage) { + } + + record AddRuleRequest(String attribute, String operator, String value) { + } + + private final FlagRepository flagRepository; + private final TargetingRuleRepository ruleRepository; + private final EvaluationService evaluationService; + + public FlagController(FlagRepository flagRepository, TargetingRuleRepository ruleRepository, EvaluationService evaluationService) { + this.flagRepository = flagRepository; + this.ruleRepository = ruleRepository; + this.evaluationService = evaluationService; + } + + @PostMapping + public FeatureFlag createFlag(@RequestBody CreateFlagRequest request) { + return flagRepository.save(new FeatureFlag(request.name(), request.description())); + } + + @GetMapping + public List listFlags() { + return flagRepository.findAll(); + } + + @PutMapping("/{name}") + public FeatureFlag updateFlag(@PathVariable String name, @RequestBody UpdateFlagRequest request) { + FeatureFlag flag = flagRepository.findById(name).orElseThrow(); + if (request.enabled() != null) { + flag.setEnabled(request.enabled()); + } + if (request.rolloutPercentage() != null) { + flag.setRolloutPercentage(request.rolloutPercentage()); + } + return flagRepository.save(flag); + } + + @GetMapping("/evaluate/{name}") + public EvaluationService.EvalResult evaluate( + @PathVariable String name, + @RequestParam String userId, + @RequestParam Map allParams) { + Map attrs = new HashMap<>(allParams); + attrs.remove("userId"); + return evaluationService.evaluate(name, userId, attrs); + } + + @PostMapping("/{name}/rules") + public TargetingRule addRule(@PathVariable String name, @RequestBody AddRuleRequest request) { + return ruleRepository.save(new TargetingRule(name, request.attribute(), request.operator(), request.value())); + } + + @GetMapping("/{name}/rules") + public List listRules(@PathVariable String name) { + return ruleRepository.findByFlagName(name); + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/model/FeatureFlag.java b/feature-flags/src/main/java/io/flamingock/flags/model/FeatureFlag.java new file mode 100644 index 0000000..4bcb6dc --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/model/FeatureFlag.java @@ -0,0 +1,74 @@ +package io.flamingock.flags.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "feature_flags") +public class FeatureFlag { + + @Id + private String name; + + private String description; + + private boolean enabled; + + @Column(name = "rollout_percentage") + private int rolloutPercentage = 100; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + protected FeatureFlag() { + } + + public FeatureFlag(String name, String description) { + this.name = name; + this.description = description; + this.enabled = false; + this.rolloutPercentage = 100; + this.createdAt = Instant.now(); + this.updatedAt = this.createdAt; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + this.updatedAt = Instant.now(); + } + + public int getRolloutPercentage() { + return rolloutPercentage; + } + + public void setRolloutPercentage(int rolloutPercentage) { + this.rolloutPercentage = rolloutPercentage; + this.updatedAt = Instant.now(); + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/model/TargetingRule.java b/feature-flags/src/main/java/io/flamingock/flags/model/TargetingRule.java new file mode 100644 index 0000000..a477fca --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/model/TargetingRule.java @@ -0,0 +1,66 @@ +package io.flamingock.flags.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "targeting_rules") +public class TargetingRule { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "flag_name") + private String flagName; + + private String attribute; + + private String operator; + + private String value; + + @Column(name = "created_at") + private Instant createdAt; + + protected TargetingRule() { + } + + public TargetingRule(String flagName, String attribute, String operator, String value) { + this.flagName = flagName; + this.attribute = attribute; + this.operator = operator; + this.value = value; + this.createdAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public String getFlagName() { + return flagName; + } + + public String getAttribute() { + return attribute; + } + + public String getOperator() { + return operator; + } + + public String getValue() { + return value; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/repository/FlagRepository.java b/feature-flags/src/main/java/io/flamingock/flags/repository/FlagRepository.java new file mode 100644 index 0000000..c770285 --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/repository/FlagRepository.java @@ -0,0 +1,7 @@ +package io.flamingock.flags.repository; + +import io.flamingock.flags.model.FeatureFlag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FlagRepository extends JpaRepository { +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/repository/TargetingRuleRepository.java b/feature-flags/src/main/java/io/flamingock/flags/repository/TargetingRuleRepository.java new file mode 100644 index 0000000..d574fab --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/repository/TargetingRuleRepository.java @@ -0,0 +1,11 @@ +package io.flamingock.flags.repository; + +import io.flamingock.flags.model.TargetingRule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface TargetingRuleRepository extends JpaRepository { + List findByFlagName(String flagName); +} diff --git a/feature-flags/src/main/java/io/flamingock/flags/service/EvaluationService.java b/feature-flags/src/main/java/io/flamingock/flags/service/EvaluationService.java new file mode 100644 index 0000000..47b7276 --- /dev/null +++ b/feature-flags/src/main/java/io/flamingock/flags/service/EvaluationService.java @@ -0,0 +1,82 @@ +package io.flamingock.flags.service; + +import io.flamingock.flags.model.FeatureFlag; +import io.flamingock.flags.model.TargetingRule; +import io.flamingock.flags.repository.FlagRepository; +import io.flamingock.flags.repository.TargetingRuleRepository; +import org.springframework.stereotype.Service; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; + +@Service +public class EvaluationService { + + public record EvalResult(boolean enabled, String reason) { + } + + private final FlagRepository flagRepository; + private final TargetingRuleRepository ruleRepository; + + public EvaluationService(FlagRepository flagRepository, TargetingRuleRepository ruleRepository) { + this.flagRepository = flagRepository; + this.ruleRepository = ruleRepository; + } + + public EvalResult evaluate(String flagName, String userId, Map attrs) { + FeatureFlag flag = flagRepository.findById(flagName).orElse(null); + if (flag == null) { + return new EvalResult(false, "flag not found"); + } + + if (!flag.isEnabled()) { + return new EvalResult(false, "flag disabled"); + } + + List rules = ruleRepository.findByFlagName(flagName); + if (!rules.isEmpty()) { + for (TargetingRule rule : rules) { + if (matches(rule, attrs)) { + return new EvalResult(true, "targeting rule matched: " + rule.getAttribute() + " " + rule.getOperator() + " " + rule.getValue()); + } + } + } + + if (flag.getRolloutPercentage() >= 100) { + return new EvalResult(true, "rollout 100%"); + } + + int bucket = bucket(flagName, userId); + boolean inRollout = bucket < flag.getRolloutPercentage(); + return new EvalResult(inRollout, inRollout + ? "in rollout bucket " + bucket + " < " + flag.getRolloutPercentage() + "%" + : "outside rollout bucket " + bucket + " >= " + flag.getRolloutPercentage() + "%"); + } + + private boolean matches(TargetingRule rule, Map attrs) { + String attrValue = attrs.get(rule.getAttribute()); + if (attrValue == null) { + return false; + } + return switch (rule.getOperator()) { + case "equals" -> attrValue.equals(rule.getValue()); + case "contains" -> attrValue.contains(rule.getValue()); + case "in" -> List.of(rule.getValue().split(",")).contains(attrValue); + case "starts_with" -> attrValue.startsWith(rule.getValue()); + default -> false; + }; + } + + private int bucket(String flagName, String userId) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((flagName + ":" + userId).getBytes()); + return Math.floorMod(ByteBuffer.wrap(hash).getInt(), 100); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/feature-flags/src/main/resources/application.yml b/feature-flags/src/main/resources/application.yml new file mode 100644 index 0000000..cd66a7c --- /dev/null +++ b/feature-flags/src/main/resources/application.yml @@ -0,0 +1,8 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/flags + username: postgres + password: postgres + jpa: + hibernate: + ddl-auto: none