diff --git a/docker-compose.yml b/docker-compose.yml
index b2313a1e6..08d9ed7d3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
mysql:
- image: mysql:9.5
+ image: mysql:9.6
ports:
- "3306:3306"
environment:
diff --git a/pom.xml b/pom.xml
index fb38cc3db..885b08553 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,10 +1,12 @@
-
+
4.0.0
org.springframework.boot
spring-boot-starter-parent
- 4.0.1
+ 4.0.2
org.springframework.samples
spring-petclinic
@@ -13,7 +15,7 @@
- 17
+ 25
UTF-8
UTF-8
@@ -99,8 +101,6 @@
org.webjars
webjars-locator-lite
- ${webjars-locator.version}
- runtime
org.webjars.npm
@@ -172,7 +172,8 @@
- This build requires at least Java ${java.version}, update your JVM, and run the build again
+ This build requires at least Java ${java.version}, update your JVM, and
+ run the build again
${java.version}
@@ -326,7 +327,8 @@
${basedir}/src/main/scss/
${basedir}/src/main/resources/static/resources/css/
- ${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/
+
+ ${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/
@@ -408,4 +410,4 @@
-
+
\ No newline at end of file
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java b/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java
new file mode 100644
index 000000000..e9719ddb1
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java
@@ -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 "";
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java
new file mode 100644
index 000000000..21d2e4006
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java
@@ -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
+ }
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java
new file mode 100644
index 000000000..14d07b65a
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java
@@ -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> getAllFlags() {
+ List 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 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 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 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 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;
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java
new file mode 100644
index 000000000..06635d82e
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java
@@ -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;
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java
new file mode 100644
index 000000000..427450c16
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java
@@ -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;
+ }
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagDTO.java b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagDTO.java
new file mode 100644
index 000000000..35c13e801
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagDTO.java
@@ -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 whitelist = new HashSet<>();
+
+ private Set 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 getWhitelist() {
+ return whitelist;
+ }
+
+ public void setWhitelist(Set whitelist) {
+ this.whitelist = whitelist;
+ }
+
+ public Set getBlacklist() {
+ return blacklist;
+ }
+
+ public void setBlacklist(Set blacklist) {
+ this.blacklist = blacklist;
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java
new file mode 100644
index 000000000..e1ecb7caa
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java
@@ -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 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 getListItems() {
+ return listItems;
+ }
+
+ public void setListItems(Set 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;
+ }
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java
new file mode 100644
index 000000000..22b307d12
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java
@@ -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 whitelist;
+ private Set 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 getWhiteList() {
+ return whitelist;
+ }
+
+ public void setWhiteList(Set listItems) {
+ this.whitelist = listItems;
+ }
+
+ public Set getBlackList() {
+ return blacklist;
+ }
+
+ public void setBlackList(Set 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;
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FeatureFlag.java b/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FeatureFlag.java
new file mode 100644
index 000000000..fd81f7f1a
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FeatureFlag.java
@@ -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 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 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 getWhitelist() {
+ return whitelist;
+ }
+
+ public void setWhitelist(Set whitelist) {
+ this.whitelist = whitelist;
+ }
+
+ public Set getBlacklist() {
+ return blacklist;
+ }
+
+ public void setBlacklist(Set blacklist) {
+ this.blacklist = blacklist;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FlagType.java b/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FlagType.java
new file mode 100644
index 000000000..2c06e777f
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/entity/FlagType.java
@@ -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
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/mapper/FeatureFlagMapper.java b/src/main/java/org/springframework/samples/petclinic/featureflag/mapper/FeatureFlagMapper.java
new file mode 100644
index 000000000..9d14ebe43
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/mapper/FeatureFlagMapper.java
@@ -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;
+ }
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/repository/FeatureFlagRepository.java b/src/main/java/org/springframework/samples/petclinic/featureflag/repository/FeatureFlagRepository.java
new file mode 100644
index 000000000..d21c9cb03
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/repository/FeatureFlagRepository.java
@@ -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 {
+
+ Optional findByFlagKey(String flagKey);
+
+ boolean existsByFlagKey(String flagKey);
+
+}
diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java
new file mode 100644
index 000000000..222dea161
--- /dev/null
+++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java
@@ -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 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 getAllFlags() {
+ return repository.findAll();
+ }
+
+ public Optional getFlagById(Long id) {
+ return repository.findById(id);
+ }
+
+ public Optional 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());
+ }
+ }
+ }
+}