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