mirror of
https://github.com/spring-projects/spring-petclinic.git
synced 2026-02-11 09:01:10 +00:00
added Feature Flag
This commit is contained in:
parent
a659a7b99f
commit
871bccd2dc
15 changed files with 1140 additions and 9 deletions
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
mysql:
|
||||
image: mysql:9.5
|
||||
image: mysql:9.6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
|
|
|
|||
18
pom.xml
18
pom.xml
|
|
@ -1,10 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<version>4.0.2</version>
|
||||
</parent>
|
||||
<groupId>org.springframework.samples</groupId>
|
||||
<artifactId>spring-petclinic</artifactId>
|
||||
|
|
@ -13,7 +15,7 @@
|
|||
|
||||
<properties>
|
||||
<!-- Generic properties -->
|
||||
<java.version>17</java.version>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<!-- Important for reproducible builds. Update using e.g. ./mvnw versions:set -DnewVersion=... -->
|
||||
|
|
@ -99,8 +101,6 @@
|
|||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>webjars-locator-lite</artifactId>
|
||||
<version>${webjars-locator.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.npm</groupId>
|
||||
|
|
@ -172,7 +172,8 @@
|
|||
<configuration>
|
||||
<rules>
|
||||
<requireJavaVersion>
|
||||
<message>This build requires at least Java ${java.version}, update your JVM, and run the build again</message>
|
||||
<message>This build requires at least Java ${java.version}, update your JVM, and
|
||||
run the build again</message>
|
||||
<version>${java.version}</version>
|
||||
</requireJavaVersion>
|
||||
</rules>
|
||||
|
|
@ -326,7 +327,8 @@
|
|||
<configuration>
|
||||
<inputPath>${basedir}/src/main/scss/</inputPath>
|
||||
<outputPath>${basedir}/src/main/resources/static/resources/css/</outputPath>
|
||||
<includePath>${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/</includePath>
|
||||
<includePath>
|
||||
${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/</includePath>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
|
|
@ -408,4 +410,4 @@
|
|||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
</project>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package org.springframework.samples.petclinic.featureflag.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Custom annotation to mark methods protected by feature flags
|
||||
*
|
||||
* Usage:
|
||||
* @FeatureToggle(key = "add-new-pet")
|
||||
* public String processNewPetForm(...) {
|
||||
* // method implementation
|
||||
* }
|
||||
*
|
||||
* With context extraction:
|
||||
* @FeatureToggle(key = "owner-search", contextParam = "name")
|
||||
* public String processFindForm(@RequestParam("name") String name, ...) {
|
||||
* // method implementation
|
||||
* }
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface FeatureToggle {
|
||||
|
||||
/**
|
||||
* The feature flag key to check
|
||||
*/
|
||||
String key();
|
||||
|
||||
/**
|
||||
* Optional: name of the parameter to use as context
|
||||
* If specified, the value of this parameter will be used for whitelist/blacklist/percentage evaluation
|
||||
*/
|
||||
String contextParam() default "";
|
||||
|
||||
/**
|
||||
* Optional: custom message to show when feature is disabled
|
||||
*/
|
||||
String disabledMessage() default "This feature is currently disabled";
|
||||
|
||||
/**
|
||||
* Optional: redirect path when feature is disabled
|
||||
* If empty, will show error message
|
||||
*/
|
||||
String disabledRedirect() default "";
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package org.springframework.samples.petclinic.featureflag.aspect;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle;
|
||||
import org.springframework.samples.petclinic.featureflag.service.FeatureFlagService;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* AOP Aspect that intercepts methods annotated with @FeatureToggle
|
||||
* and checks if the feature is enabled before allowing execution
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
public class FeatureFlagAspect {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FeatureFlagAspect.class);
|
||||
|
||||
private final FeatureFlagService featureFlagService;
|
||||
|
||||
public FeatureFlagAspect(FeatureFlagService featureFlagService) {
|
||||
this.featureFlagService = featureFlagService;
|
||||
}
|
||||
|
||||
@Around("@annotation(org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle)")
|
||||
public Object checkFeatureFlag(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
FeatureToggle featureToggle = method.getAnnotation(FeatureToggle.class);
|
||||
|
||||
String flagKey = featureToggle.key();
|
||||
String contextParam = featureToggle.contextParam();
|
||||
|
||||
// Extract context if specified
|
||||
String context = null;
|
||||
if (!contextParam.isEmpty()) {
|
||||
context = extractContextFromParams(method, joinPoint.getArgs(), contextParam);
|
||||
}
|
||||
|
||||
// Check if feature is enabled
|
||||
boolean isEnabled = featureFlagService.isFeatureEnabled(flagKey, context);
|
||||
|
||||
logger.debug("Feature flag '{}' check: enabled={}, context={}", flagKey, isEnabled, context);
|
||||
|
||||
if (isEnabled) {
|
||||
// Feature is enabled, proceed with method execution
|
||||
return joinPoint.proceed();
|
||||
} else {
|
||||
// Feature is disabled, handle based on configuration
|
||||
logger.info("Feature '{}' is disabled, blocking execution", flagKey);
|
||||
return handleDisabledFeature(joinPoint, featureToggle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract context value from method parameters
|
||||
*/
|
||||
private String extractContextFromParams(Method method, Object[] args, String paramName) {
|
||||
Parameter[] parameters = method.getParameters();
|
||||
|
||||
for (int i = 0; i < parameters.length; i++) {
|
||||
// Check if parameter name matches (requires -parameters compiler flag)
|
||||
if (parameters[i].getName().equals(paramName)) {
|
||||
if (args[i] != null) {
|
||||
return args[i].toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for @RequestParam, @PathVariable annotations
|
||||
for (var annotation : parameters[i].getAnnotations()) {
|
||||
String annotationStr = annotation.toString();
|
||||
if (annotationStr.contains(paramName)) {
|
||||
if (args[i] != null) {
|
||||
return args[i].toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disabled feature - redirect or return error view
|
||||
*/
|
||||
private Object handleDisabledFeature(ProceedingJoinPoint joinPoint, FeatureToggle featureToggle) {
|
||||
String redirect = featureToggle.disabledRedirect();
|
||||
String message = featureToggle.disabledMessage();
|
||||
|
||||
// Look for RedirectAttributes in method parameters
|
||||
Object[] args = joinPoint.getArgs();
|
||||
for (Object arg : args) {
|
||||
if (arg instanceof RedirectAttributes) {
|
||||
RedirectAttributes redirectAttrs = (RedirectAttributes) arg;
|
||||
redirectAttrs.addFlashAttribute("message", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If redirect is specified, return redirect
|
||||
if (!redirect.isEmpty()) {
|
||||
return "redirect:" + redirect;
|
||||
}
|
||||
|
||||
// Otherwise return to a generic disabled feature page or home
|
||||
return "redirect:/oups"; // PetClinic error page
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package org.springframework.samples.petclinic.featureflag.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.featureflag.service.FeatureFlagService;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.RestController;
|
||||
import org.springframework.samples.petclinic.featureflag.dto.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/feature-flags")
|
||||
public class FeatureFlagController {
|
||||
private final FeatureFlagService featureFlagService;
|
||||
|
||||
public FeatureFlagController(FeatureFlagService featureFlagService) {
|
||||
this.featureFlagService = featureFlagService;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/feature-flags
|
||||
* Get all feature flags
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<FeatureFlagResponse>> getAllFlags() {
|
||||
List<FeatureFlagResponse> flags = featureFlagService.getAllFlags()
|
||||
.stream()
|
||||
.map(FeatureFlagResponse::fromEntity)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/feature-flags/{id}
|
||||
* Get a specific feature flag by ID
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<FeatureFlagResponse> getFlagById(@PathVariable Long id) {
|
||||
return featureFlagService.getFlagById(id)
|
||||
.map(FeatureFlagResponse::fromEntity)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/feature-flags/key/{flagKey}
|
||||
* Get a specific feature flag by key
|
||||
*/
|
||||
@GetMapping("/key/{flagKey}")
|
||||
public ResponseEntity<FeatureFlagResponse> getFlagByKey(@PathVariable String flagKey) {
|
||||
return featureFlagService.getFlagByKey(flagKey)
|
||||
.map(FeatureFlagResponse::fromEntity)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/feature-flags
|
||||
* Create a new feature flag
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createFlag(@RequestBody FeatureFlagRequest request) {
|
||||
try {
|
||||
FeatureFlag flag = request.toEntity();
|
||||
FeatureFlag created = featureFlagService.createFlag(flag);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(FeatureFlagResponse.fromEntity(created));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/feature-flags/{id}
|
||||
* Update an existing feature flag
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateFlag(@PathVariable Long id, @RequestBody FeatureFlagRequest request) {
|
||||
try {
|
||||
FeatureFlag flag = request.toEntity();
|
||||
FeatureFlag updated = featureFlagService.updateFlag(id, flag);
|
||||
return ResponseEntity.ok(FeatureFlagResponse.fromEntity(updated));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/feature-flags/{id}
|
||||
* Delete a feature flag
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteFlag(@PathVariable Long id) {
|
||||
try {
|
||||
featureFlagService.deleteFlag(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/feature-flags/{flagKey}/toggle
|
||||
* Toggle a feature flag on/off
|
||||
*/
|
||||
@PostMapping("/{flagKey}/toggle")
|
||||
public ResponseEntity<?> toggleFlag(@PathVariable String flagKey) {
|
||||
try {
|
||||
FeatureFlag toggled = featureFlagService.toggleFlag(flagKey);
|
||||
return ResponseEntity.ok(FeatureFlagResponse.fromEntity(toggled));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/feature-flags/check
|
||||
* Check if a feature is enabled for a given context
|
||||
*/
|
||||
@PostMapping("/check")
|
||||
public ResponseEntity<FeatureCheckResponse> checkFeature(@RequestBody FeatureCheckRequest request) {
|
||||
boolean enabled = featureFlagService.isFeatureEnabled(request.getFlagKey(), request.getContext());
|
||||
FeatureCheckResponse response = new FeatureCheckResponse(
|
||||
request.getFlagKey(),
|
||||
enabled,
|
||||
request.getContext());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/feature-flags/check/{flagKey}
|
||||
* Check if a feature is enabled (simple check without context)
|
||||
*/
|
||||
@GetMapping("/check/{flagKey}")
|
||||
public ResponseEntity<FeatureCheckResponse> checkFeatureSimple(@PathVariable String flagKey) {
|
||||
boolean enabled = featureFlagService.isFeatureEnabled(flagKey);
|
||||
FeatureCheckResponse response = new FeatureCheckResponse(flagKey, enabled, null);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response class
|
||||
*/
|
||||
public static class ErrorResponse {
|
||||
private String error;
|
||||
|
||||
public ErrorResponse(String error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public void setError(String error) {
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.springframework.samples.petclinic.featureflag.dto;
|
||||
|
||||
public class FeatureCheckRequest {
|
||||
|
||||
private String flagKey;
|
||||
private String context;
|
||||
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public String getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public void setContext(String context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package org.springframework.samples.petclinic.featureflag.dto;
|
||||
|
||||
public class FeatureCheckResponse {
|
||||
private String flagKey;
|
||||
private Boolean enabled;
|
||||
private String context;
|
||||
|
||||
public FeatureCheckResponse(String flagKey, Boolean enabled, String context) {
|
||||
this.flagKey = flagKey;
|
||||
this.enabled = enabled;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public void setContext(String context) {
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.springframework.samples.petclinic.featureflag.dto;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class FeatureFlagDTO {
|
||||
|
||||
@NotBlank
|
||||
private String flagKey;
|
||||
|
||||
@NotBlank
|
||||
private String description;
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
private String flagType; // SIMPLE, WHITELIST, BLACKLIST, PERCENTAGE
|
||||
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
private Integer percentage;
|
||||
|
||||
private Set<String> whitelist = new HashSet<>();
|
||||
|
||||
private Set<String> blacklist = new HashSet<>();
|
||||
|
||||
// Getters & Setters
|
||||
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getFlagType() {
|
||||
return flagType;
|
||||
}
|
||||
|
||||
public void setFlagType(String flagType) {
|
||||
this.flagType = flagType;
|
||||
}
|
||||
|
||||
public Integer getPercentage() {
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public void setPercentage(Integer percentage) {
|
||||
this.percentage = percentage;
|
||||
}
|
||||
|
||||
public Set<String> getWhitelist() {
|
||||
return whitelist;
|
||||
}
|
||||
|
||||
public void setWhitelist(Set<String> whitelist) {
|
||||
this.whitelist = whitelist;
|
||||
}
|
||||
|
||||
public Set<String> getBlacklist() {
|
||||
return blacklist;
|
||||
}
|
||||
|
||||
public void setBlacklist(Set<String> blacklist) {
|
||||
this.blacklist = blacklist;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.springframework.samples.petclinic.featureflag.dto;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FlagType;
|
||||
|
||||
public class FeatureFlagRequest {
|
||||
|
||||
private String flagKey;
|
||||
private String description;
|
||||
private FlagType flagType;
|
||||
private Boolean enabled;
|
||||
private Integer percentage;
|
||||
private Set<String> listItems;
|
||||
|
||||
// Getters and Setters
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public FlagType getFlagType() {
|
||||
return flagType;
|
||||
}
|
||||
|
||||
public void setFlagType(FlagType flagType) {
|
||||
this.flagType = flagType;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Integer getPercentage() {
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public void setPercentage(Integer percentage) {
|
||||
this.percentage = percentage;
|
||||
}
|
||||
|
||||
public Set<String> getListItems() {
|
||||
return listItems;
|
||||
}
|
||||
|
||||
public void setListItems(Set<String> listItems) {
|
||||
this.listItems = listItems;
|
||||
}
|
||||
|
||||
public FeatureFlag toEntity() {
|
||||
FeatureFlag flag = new FeatureFlag();
|
||||
flag.setFlagKey(this.flagKey);
|
||||
flag.setDescription(this.description);
|
||||
flag.setFlagType(this.flagType != null ? this.flagType : FlagType.SIMPLE);
|
||||
flag.setEnabled(this.enabled != null ? this.enabled : false);
|
||||
flag.setPercentage(this.percentage);
|
||||
flag.setBlacklist(this.listItems != null ? this.listItems : Set.of());
|
||||
flag.setWhitelist(this.listItems != null ? this.listItems : Set.of());
|
||||
return flag;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package org.springframework.samples.petclinic.featureflag.dto;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FlagType;
|
||||
|
||||
public class FeatureFlagResponse {
|
||||
|
||||
private Long id;
|
||||
private String flagKey;
|
||||
private String description;
|
||||
private FlagType flagType;
|
||||
private Boolean enabled;
|
||||
private Integer percentage;
|
||||
private Set<String> whitelist;
|
||||
private Set<String> blacklist;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
|
||||
public static FeatureFlagResponse fromEntity(FeatureFlag flag) {
|
||||
FeatureFlagResponse response = new FeatureFlagResponse();
|
||||
response.setId(flag.getId());
|
||||
response.setFlagKey(flag.getFlagKey());
|
||||
response.setDescription(flag.getDescription());
|
||||
response.setFlagType(flag.getFlagType());
|
||||
response.setEnabled(flag.isEnabled());
|
||||
response.setPercentage(flag.getPercentage());
|
||||
response.setWhiteList(flag.getWhitelist());
|
||||
response.setBlackList(flag.getBlacklist());
|
||||
response.setCreatedAt(flag.getCreatedAt().toString());
|
||||
response.setUpdatedAt(flag.getUpdatedAt().toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public FlagType getFlagType() {
|
||||
return flagType;
|
||||
}
|
||||
|
||||
public void setFlagType(FlagType flagType) {
|
||||
this.flagType = flagType;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Integer getPercentage() {
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public void setPercentage(Integer percentage) {
|
||||
this.percentage = percentage;
|
||||
}
|
||||
|
||||
public Set<String> getWhiteList() {
|
||||
return whitelist;
|
||||
}
|
||||
|
||||
public void setWhiteList(Set<String> listItems) {
|
||||
this.whitelist = listItems;
|
||||
}
|
||||
|
||||
public Set<String> getBlackList() {
|
||||
return blacklist;
|
||||
}
|
||||
|
||||
public void setBlackList(Set<String> listItems) {
|
||||
this.blacklist = listItems;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(String updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package org.springframework.samples.petclinic.featureflag.entity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Entity
|
||||
@Table(name = "feature_flags", uniqueConstraints = { @UniqueConstraint(columnNames = "flag_key") })
|
||||
public class FeatureFlag {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "flag_key", nullable = false, unique = true, updatable = false)
|
||||
private String flagKey;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "description", nullable = false)
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "flag_type", nullable = false)
|
||||
private FlagType flagType = FlagType.SIMPLE;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled = false;
|
||||
|
||||
/*
|
||||
* Used only for percentage rollouts, represents the percentage of users that should
|
||||
* have access to the feature. Should be a value between 0 and 100. Ignored for other
|
||||
* flag types.
|
||||
*
|
||||
*/
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@Column(name = "percentage")
|
||||
private Integer percentage;
|
||||
|
||||
/*
|
||||
* Explicit allow-list
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "feature_flag_whitelist", joinColumns = @JoinColumn(name = "feature_flag_id"))
|
||||
private Set<String> whitelist = new HashSet<>();
|
||||
|
||||
/*
|
||||
* Explicit deny-list (highest priority, overrides both percentage and allow-list)
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "feature_flag_blacklist", joinColumns = @JoinColumn(name = "feature_flag_id"))
|
||||
private Set<String> blacklist = new HashSet<>();
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = this.createdAt;
|
||||
this.flagKey = this.flagKey.toUpperCase();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getFlagKey() {
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
public void setFlagKey(String flagKey) {
|
||||
this.flagKey = flagKey;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public FlagType getFlagType() {
|
||||
return flagType;
|
||||
}
|
||||
|
||||
public void setFlagType(FlagType flagType) {
|
||||
this.flagType = flagType;
|
||||
}
|
||||
|
||||
public Integer getPercentage() {
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public void setPercentage(Integer percentage) {
|
||||
this.percentage = percentage;
|
||||
}
|
||||
|
||||
public Set<String> getWhitelist() {
|
||||
return whitelist;
|
||||
}
|
||||
|
||||
public void setWhitelist(Set<String> whitelist) {
|
||||
this.whitelist = whitelist;
|
||||
}
|
||||
|
||||
public Set<String> getBlacklist() {
|
||||
return blacklist;
|
||||
}
|
||||
|
||||
public void setBlacklist(Set<String> blacklist) {
|
||||
this.blacklist = blacklist;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.springframework.samples.petclinic.featureflag.entity;
|
||||
|
||||
public enum FlagType {
|
||||
|
||||
SIMPLE, // on/off flag
|
||||
WHITELIST, // only whitelisted users have access
|
||||
BLACKLIST, // all users have access except blacklisted ones
|
||||
PERCENTAGE, // Gradual rollout based on percentage of users
|
||||
GLOBAL_DISABLE // Override to disable globally regardless of other settings
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.springframework.samples.petclinic.featureflag.mapper;
|
||||
|
||||
import org.springframework.samples.petclinic.featureflag.dto.FeatureFlagDTO;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FlagType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class FeatureFlagMapper {
|
||||
|
||||
public FeatureFlag toEntity(FeatureFlagDTO dto) {
|
||||
FeatureFlag flag = new FeatureFlag();
|
||||
flag.setFlagKey(dto.getFlagKey());
|
||||
flag.setDescription(dto.getDescription());
|
||||
flag.setEnabled(dto.isEnabled());
|
||||
flag.setPercentage(dto.getPercentage());
|
||||
flag.setWhitelist(dto.getWhitelist());
|
||||
flag.setBlacklist(dto.getBlacklist());
|
||||
flag.setFlagType(FlagType.valueOf(dto.getFlagType()));
|
||||
return flag;
|
||||
}
|
||||
|
||||
public FeatureFlagDTO toDto(FeatureFlag flag) {
|
||||
FeatureFlagDTO dto = new FeatureFlagDTO();
|
||||
dto.setFlagKey(flag.getFlagKey());
|
||||
dto.setDescription(flag.getDescription());
|
||||
dto.setEnabled(flag.isEnabled());
|
||||
dto.setPercentage(flag.getPercentage());
|
||||
dto.setWhitelist(flag.getWhitelist());
|
||||
dto.setBlacklist(flag.getBlacklist());
|
||||
dto.setFlagType(flag.getFlagType().name());
|
||||
return dto;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.springframework.samples.petclinic.featureflag.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface FeatureFlagRepository extends JpaRepository<FeatureFlag, Long> {
|
||||
|
||||
Optional<FeatureFlag> findByFlagKey(String flagKey);
|
||||
|
||||
boolean existsByFlagKey(String flagKey);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package org.springframework.samples.petclinic.featureflag.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.featureflag.entity.FlagType;
|
||||
import org.springframework.samples.petclinic.featureflag.repository.FeatureFlagRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
/**
|
||||
* Feature Flag Service - Core service for managing and evaluating feature flags
|
||||
*
|
||||
* This service provides:
|
||||
* - CRUD operations for feature flags
|
||||
* - Advanced flag evaluation with multiple strategies
|
||||
* - Helper methods for easy integration anywhere in the application
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class FeatureFlagService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FeatureFlagService.class);
|
||||
|
||||
private final FeatureFlagRepository repository;
|
||||
|
||||
public FeatureFlagService(FeatureFlagRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main helper function to check if a feature is enabled
|
||||
* Can be called from anywhere in the application
|
||||
*
|
||||
* @param flagKey The unique identifier for the feature flag
|
||||
* @return true if feature is enabled, false otherwise
|
||||
*/
|
||||
public boolean isFeatureEnabled(String flagKey) {
|
||||
return isFeatureEnabled(flagKey, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if feature is enabled for a specific context/user
|
||||
*
|
||||
* @param flagKey The unique identifier for the feature flag
|
||||
* @param context Context identifier (e.g., userId, sessionId, email)
|
||||
* @return true if feature is enabled for this context, false otherwise
|
||||
*/
|
||||
public boolean isFeatureEnabled(String flagKey, String context) {
|
||||
try {
|
||||
Optional<FeatureFlag> flagOpt = repository.findByFlagKey(flagKey);
|
||||
|
||||
if (flagOpt.isEmpty()) {
|
||||
logger.warn("Feature flag '{}' not found, defaulting to disabled", flagKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
FeatureFlag flag = flagOpt.get();
|
||||
return evaluateFlag(flag, context);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error evaluating feature flag '{}': {}", flagKey, e.getMessage());
|
||||
// Fail safe: return false on errors
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a feature flag based on its type and configuration
|
||||
*/
|
||||
private boolean evaluateFlag(FeatureFlag flag, String context) {
|
||||
// GLOBAL_DISABLE always returns false
|
||||
if (flag.getFlagType() == FlagType.GLOBAL_DISABLE) {
|
||||
logger.debug("Flag '{}' is globally disabled", flag.getFlagKey());
|
||||
return false;
|
||||
}
|
||||
|
||||
// If not enabled, return false (except for specific override cases)
|
||||
if (!flag.isEnabled()) {
|
||||
logger.debug("Flag '{}' is disabled", flag.getFlagKey());
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (flag.getFlagType()) {
|
||||
case SIMPLE:
|
||||
return true; // Simple on/off
|
||||
|
||||
case WHITELIST:
|
||||
return evaluateWhitelist(flag, context);
|
||||
|
||||
case BLACKLIST:
|
||||
return evaluateBlacklist(flag, context);
|
||||
|
||||
case PERCENTAGE:
|
||||
return evaluatePercentage(flag, context);
|
||||
|
||||
default:
|
||||
logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist: Only allow if context is in the list
|
||||
*/
|
||||
private boolean evaluateWhitelist(FeatureFlag flag, String context) {
|
||||
if (context == null || context.trim().isEmpty()) {
|
||||
logger.debug("Whitelist flag '{}' requires context, got null/empty", flag.getFlagKey());
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean inWhitelist = flag.getWhitelist().contains(context.trim());
|
||||
logger.debug("Whitelist flag '{}' for context '{}': {}", flag.getFlagKey(), context, inWhitelist);
|
||||
return inWhitelist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blacklist: Allow unless context is in the list
|
||||
*/
|
||||
private boolean evaluateBlacklist(FeatureFlag flag, String context) {
|
||||
if (context == null || context.trim().isEmpty()) {
|
||||
// No context means not blacklisted
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean inBlacklist = flag.getBlacklist().contains(context.trim());
|
||||
logger.debug("Blacklist flag '{}' for context '{}': {}", flag.getFlagKey(), context, !inBlacklist);
|
||||
return !inBlacklist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentage: Enable for X% of requests using consistent hashing
|
||||
*/
|
||||
private boolean evaluatePercentage(FeatureFlag flag, String context) {
|
||||
if (flag.getPercentage() == null || flag.getPercentage() < 0 || flag.getPercentage() > 100) {
|
||||
logger.warn("Invalid percentage for flag '{}': {}", flag.getFlagKey(), flag.getPercentage());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flag.getPercentage() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flag.getPercentage() == 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use consistent hashing to ensure same context always gets same result
|
||||
String hashInput = flag.getFlagKey() + (context != null ? context : "");
|
||||
int hash = Math.abs(hashInput.hashCode());
|
||||
int bucket = hash % 100;
|
||||
|
||||
boolean enabled = bucket < flag.getPercentage();
|
||||
logger.debug("Percentage flag '{}' for context '{}': bucket={}, percentage={}, enabled={}",
|
||||
flag.getFlagKey(), context, bucket, flag.getPercentage(), enabled);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
public List<FeatureFlag> getAllFlags() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
public Optional<FeatureFlag> getFlagById(Long id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public Optional<FeatureFlag> getFlagByKey(String flagKey) {
|
||||
return repository.findByFlagKey(flagKey);
|
||||
}
|
||||
|
||||
public FeatureFlag createFlag(FeatureFlag flag) {
|
||||
if (repository.existsByFlagKey(flag.getFlagKey())) {
|
||||
throw new IllegalArgumentException("Feature flag with key '" + flag.getFlagKey() + "' already exists");
|
||||
}
|
||||
|
||||
validateFlag(flag);
|
||||
return repository.save(flag);
|
||||
}
|
||||
|
||||
public FeatureFlag updateFlag(Long id, FeatureFlag updatedFlag) {
|
||||
FeatureFlag existingFlag = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Feature flag not found with id: " + id));
|
||||
|
||||
// Don't allow changing the key
|
||||
updatedFlag.setFlagKey(existingFlag.getFlagKey());
|
||||
|
||||
validateFlag(updatedFlag);
|
||||
return repository.save(updatedFlag);
|
||||
}
|
||||
|
||||
public void deleteFlag(Long id) {
|
||||
if (!repository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Feature flag not found with id: " + id);
|
||||
}
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
public FeatureFlag toggleFlag(String flagKey) {
|
||||
FeatureFlag flag = repository.findByFlagKey(flagKey)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Feature flag not found: " + flagKey));
|
||||
|
||||
flag.setEnabled(!flag.isEnabled());
|
||||
return repository.save(flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate flag configuration
|
||||
*/
|
||||
private void validateFlag(FeatureFlag flag) {
|
||||
if (flag.getFlagKey() == null || flag.getFlagKey().trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Flag key cannot be empty");
|
||||
}
|
||||
|
||||
if (flag.getFlagType() == FlagType.PERCENTAGE) {
|
||||
if (flag.getPercentage() == null || flag.getPercentage() < 0 || flag.getPercentage() > 100) {
|
||||
throw new IllegalArgumentException("Percentage must be between 0 and 100");
|
||||
}
|
||||
}
|
||||
|
||||
if (flag.getFlagType() == FlagType.WHITELIST ||
|
||||
flag.getFlagType() == FlagType.BLACKLIST) {
|
||||
if (flag.getWhitelist() == null || flag.getBlacklist().isEmpty()) {
|
||||
logger.warn("Flag '{}' is of type {} but has no list items",
|
||||
flag.getFlagKey(), flag.getFlagType());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue