added Feature Flag

This commit is contained in:
XTiNCT 2026-02-08 12:53:29 +05:30
parent a659a7b99f
commit 871bccd2dc
15 changed files with 1140 additions and 9 deletions

View file

@ -1,6 +1,6 @@
services:
mysql:
image: mysql:9.5
image: mysql:9.6
ports:
- "3306:3306"
environment:

18
pom.xml
View file

@ -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>

View file

@ -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 "";
}

View file

@ -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
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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());
}
}
}
}