From a659a7b99f0ce353f40add6d7cc6bdb9cacde982 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sat, 7 Feb 2026 20:24:31 +0530 Subject: [PATCH 1/9] added mysql profile to main class --- .../samples/petclinic/PetClinicApplication.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/springframework/samples/petclinic/PetClinicApplication.java b/src/main/java/org/springframework/samples/petclinic/PetClinicApplication.java index fa0630995..ad6fd67e7 100644 --- a/src/main/java/org/springframework/samples/petclinic/PetClinicApplication.java +++ b/src/main/java/org/springframework/samples/petclinic/PetClinicApplication.java @@ -30,7 +30,8 @@ import org.springframework.context.annotation.ImportRuntimeHints; public class PetClinicApplication { public static void main(String[] args) { - SpringApplication.run(PetClinicApplication.class, args); + SpringApplication.run(PetClinicApplication.class, "--spring.profiles.active=mysql", + "--spring.docker.compose.enabled=false"); } } From 871bccd2dc9a0bd051f13cfe1657994297f3e204 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 12:53:29 +0530 Subject: [PATCH 2/9] added Feature Flag --- docker-compose.yml | 2 +- pom.xml | 18 +- .../featureflag/annotation/FeatureToggle.java | 48 ++++ .../featureflag/aspect/FeatureFlagAspect.java | 116 +++++++++ .../controller/FeatureFlagController.java | 168 +++++++++++++ .../featureflag/dto/FeatureCheckRequest.java | 24 ++ .../featureflag/dto/FeatureCheckResponse.java | 37 +++ .../featureflag/dto/FeatureFlagDTO.java | 88 +++++++ .../featureflag/dto/FeatureFlagRequest.java | 77 ++++++ .../featureflag/dto/FeatureFlagResponse.java | 117 +++++++++ .../featureflag/entity/FeatureFlag.java | 160 ++++++++++++ .../featureflag/entity/FlagType.java | 11 + .../featureflag/mapper/FeatureFlagMapper.java | 35 +++ .../repository/FeatureFlagRepository.java | 16 ++ .../service/FeatureFlagService.java | 232 ++++++++++++++++++ 15 files changed, 1140 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagDTO.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/entity/FeatureFlag.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/entity/FlagType.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/mapper/FeatureFlagMapper.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/repository/FeatureFlagRepository.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java 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()); + } + } + } +} From c518db81a26417aaf57f3c9024cf7d4c4deed162 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 13:17:13 +0530 Subject: [PATCH 3/9] updated data.sql and schema.sql with feature flag table --- .../featureflag/annotation/FeatureToggle.java | 2 +- .../featureflag/aspect/FeatureFlagAspect.java | 116 ------ .../aspect/FeatureToggleAspect.java | 117 ++++++ .../featureflag/dto/FeatureCheckRequest.java | 1 + .../featureflag/dto/FeatureCheckResponse.java | 2 + .../featureflag/dto/FeatureFlagRequest.java | 126 +++--- .../featureflag/dto/FeatureFlagResponse.java | 180 ++++----- .../service/FeatureFlagService.java | 360 +++++++++--------- src/main/resources/db/mysql/data.sql | 41 ++ src/main/resources/db/mysql/schema.sql | 25 ++ 10 files changed, 537 insertions(+), 433 deletions(-) delete mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java create mode 100644 src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java 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 index e9719ddb1..405bd8ac7 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Custom annotation to mark methods protected by feature flags + * Custom annotation to mark methods protected by feature toggle * * Usage: * @FeatureToggle(key = "add-new-pet") 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 deleted file mode 100644 index 21d2e4006..000000000 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureFlagAspect.java +++ /dev/null @@ -1,116 +0,0 @@ -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/aspect/FeatureToggleAspect.java b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java new file mode 100644 index 000000000..e69471b92 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java @@ -0,0 +1,117 @@ +package org.springframework.samples.petclinic.featureflag.aspect; + +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; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * AOP Aspect that intercepts methods annotated with @FeatureToggle and checks if the + * feature is enabled before allowing execution + */ +@Aspect +@Component +public class FeatureToggleAspect { + + private static final Logger logger = LoggerFactory.getLogger(FeatureToggleAspect.class); + + private final FeatureFlagService featureFlagService; + + public FeatureToggleAspect(FeatureFlagService featureFlagService) { + this.featureFlagService = featureFlagService; + } + + @Around("@annotation(org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle)") + public Object checkFeatureToggle(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 toggle '{}' 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 redirectAttrs) { + 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 + } + +} \ No newline at end of file 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 index 06635d82e..a3a1f20e2 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java @@ -3,6 +3,7 @@ package org.springframework.samples.petclinic.featureflag.dto; public class FeatureCheckRequest { private String flagKey; + private String context; public String getFlagKey() { 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 index 427450c16..96c00d2e0 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java @@ -2,7 +2,9 @@ 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) { 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 index e1ecb7caa..1db284bd9 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java @@ -7,71 +7,89 @@ 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; + private String flagKey; - // Getters and Setters - public String getFlagKey() { - return flagKey; - } + private String description; - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + private FlagType flagType; - public String getDescription() { - return description; - } + private Boolean enabled; - public void setDescription(String description) { - this.description = description; - } + private Integer percentage; - public FlagType getFlagType() { - return flagType; - } + private Set whitelist; - public void setFlagType(FlagType flagType) { - this.flagType = flagType; - } + private Set blacklist; - public Boolean getEnabled() { - return enabled; - } + // Getters and Setters + public String getFlagKey() { + return flagKey; + } - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public Integer getPercentage() { - return percentage; - } + public String getDescription() { + return description; + } - public void setPercentage(Integer percentage) { - this.percentage = percentage; - } + public void setDescription(String description) { + this.description = description; + } - public Set getListItems() { - return listItems; - } + public FlagType getFlagType() { + return flagType; + } - public void setListItems(Set listItems) { - this.listItems = listItems; - } + public void setFlagType(FlagType flagType) { + this.flagType = flagType; + } - 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; - } + 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 whitelist) { + this.whitelist = whitelist; + } + + public Set getBlacklist() { + return blacklist; + } + + public void setBlacklist(Set blacklist) { + this.blacklist = blacklist; + } + + /** + * Convert DTO to Entity + */ + 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.setWhitelist(this.whitelist != null ? this.whitelist : Set.of()); + flag.setBlacklist(this.blacklist != null ? this.blacklist : 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 index 22b307d12..aa0950e8f 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java @@ -7,111 +7,121 @@ 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; + private Long id; - 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; - } + private String flagKey; - // Getters and Setters - public Long getId() { - return id; - } + private String description; - public void setId(Long id) { - this.id = id; - } + private FlagType flagType; - public String getFlagKey() { - return flagKey; - } + private Boolean enabled; - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + private Integer percentage; - public String getDescription() { - return description; - } + private Set whitelist; - public void setDescription(String description) { - this.description = description; - } + private Set blacklist; - public FlagType getFlagType() { - return flagType; - } + private String createdAt; - public void setFlagType(FlagType flagType) { - this.flagType = flagType; - } + private String updatedAt; - public Boolean getEnabled() { - return enabled; - } + 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; + } - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } + // Getters and Setters + public Long getId() { + return id; + } - public Integer getPercentage() { - return percentage; - } + public void setId(Long id) { + this.id = id; + } - public void setPercentage(Integer percentage) { - this.percentage = percentage; - } + public String getFlagKey() { + return flagKey; + } - public Set getWhiteList() { - return whitelist; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public void setWhiteList(Set listItems) { - this.whitelist = listItems; - } + public String getDescription() { + return description; + } - public Set getBlackList() { - return blacklist; - } + public void setDescription(String description) { + this.description = description; + } - public void setBlackList(Set listItems) { - this.blacklist = listItems; - } + public FlagType getFlagType() { + return flagType; + } - public String getCreatedAt() { - return createdAt; - } + public void setFlagType(FlagType flagType) { + this.flagType = flagType; + } - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } + public Boolean getEnabled() { + return enabled; + } - public String getUpdatedAt() { - return updatedAt; - } + 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 whitelist) { + this.whitelist = whitelist; + } + + public Set getBlacklist() { + return blacklist; + } + + public void setBlacklist(Set blacklist) { + this.blacklist = blacklist; + } + + 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; + } - public void setUpdatedAt(String updatedAt) { - this.updatedAt = updatedAt; - } } 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 index 222dea161..fa98d30b1 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java @@ -22,211 +22,217 @@ import org.springframework.transaction.annotation.Transactional; @Transactional public class FeatureFlagService { - private static final Logger logger = LoggerFactory.getLogger(FeatureFlagService.class); - - private final FeatureFlagRepository repository; + private static final Logger logger = LoggerFactory.getLogger(FeatureFlagService.class); - public FeatureFlagService(FeatureFlagRepository repository) { - this.repository = repository; - } + private final FeatureFlagRepository 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); - } + public FeatureFlagService(FeatureFlagRepository repository) { + this.repository = repository; + } - /** - * 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; - } + /** + * 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); + } - 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; - } - } + /** + * 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.toUpperCase()); - /** - * 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 (flagOpt.isEmpty()) { + logger.warn("Feature flag '{}' not found, defaulting to disabled", flagKey); + return false; + } - // If not enabled, return false (except for specific override cases) - if (!flag.isEnabled()) { - logger.debug("Flag '{}' is disabled", flag.getFlagKey()); - return false; - } + FeatureFlag flag = flagOpt.get(); + return evaluateFlag(flag, context); - switch (flag.getFlagType()) { - case SIMPLE: - return true; // Simple on/off + } + catch (Exception e) { + logger.error("Error evaluating feature flag '{}': {}", flagKey, e.getMessage()); + // Fail safe: return false on errors + return false; + } + } - case WHITELIST: - return evaluateWhitelist(flag, context); + /** + * Evaluate a feature flag based on its type and configuration + * Evaluation order (highest to lowest priority): + * 1. GLOBAL_DISABLE - always returns false + * 2. Blacklist - if context is blacklisted, return false + * 3. Enabled check - if not enabled, return false + * 4. Type-specific evaluation (WHITELIST, PERCENTAGE, SIMPLE) + */ + 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; + } - case BLACKLIST: - return evaluateBlacklist(flag, context); + // Check blacklist first (highest priority after GLOBAL_DISABLE) + if (context != null && !context.trim().isEmpty()) { + if (flag.getBlacklist().contains(context.trim())) { + logger.debug("Flag '{}' - context '{}' is blacklisted", flag.getFlagKey(), context); + return false; + } + } - case PERCENTAGE: - return evaluatePercentage(flag, context); + // If not enabled, return false (except for specific override cases) + if (!flag.isEnabled()) { + logger.debug("Flag '{}' is disabled", flag.getFlagKey()); + return false; + } - default: - logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType()); - return false; - } - } + switch (flag.getFlagType()) { + case SIMPLE: + return true; // Simple on/off - /** - * 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; - } + case WHITELIST: + return evaluateWhitelist(flag, context); - boolean inWhitelist = flag.getWhitelist().contains(context.trim()); - logger.debug("Whitelist flag '{}' for context '{}': {}", flag.getFlagKey(), context, inWhitelist); - return inWhitelist; - } + case BLACKLIST: + // If we got here, context is not in blacklist and flag is enabled + return true; - /** - * 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; - } + case PERCENTAGE: + return evaluatePercentage(flag, context); - boolean inBlacklist = flag.getBlacklist().contains(context.trim()); - logger.debug("Blacklist flag '{}' for context '{}': {}", flag.getFlagKey(), context, !inBlacklist); - return !inBlacklist; - } + default: + logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType()); + return false; + } + } - /** - * 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; - } + /** + * Whitelist: Only allow if context is in the whitelist + */ + 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; + } - if (flag.getPercentage() == 0) { - return false; - } + boolean inWhitelist = flag.getWhitelist().contains(context.trim()); + logger.debug("Whitelist flag '{}' for context '{}': {}", flag.getFlagKey(), context, inWhitelist); + return inWhitelist; + } - if (flag.getPercentage() == 100) { - return true; - } + /** + * 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; + } - // 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; - } + if (flag.getPercentage() == 0) { + return false; + } - // CRUD Operations + if (flag.getPercentage() == 100) { + return true; + } - public List getAllFlags() { - return repository.findAll(); - } + // 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; - public Optional getFlagById(Long id) { - return repository.findById(id); - } + boolean enabled = bucket < flag.getPercentage(); + logger.debug("Percentage flag '{}' for context '{}': bucket={}, percentage={}, enabled={}", + flag.getFlagKey(), context, bucket, flag.getPercentage(), enabled); + return enabled; + } - public Optional getFlagByKey(String flagKey) { - return repository.findByFlagKey(flagKey); - } + // CRUD Operations - 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 List getAllFlags() { + return repository.findAll(); + } - public FeatureFlag updateFlag(Long id, FeatureFlag updatedFlag) { - FeatureFlag existingFlag = repository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Feature flag not found with id: " + id)); + public Optional getFlagById(Long id) { + return repository.findById(id); + } - // Don't allow changing the key - updatedFlag.setFlagKey(existingFlag.getFlagKey()); - - validateFlag(updatedFlag); - return repository.save(updatedFlag); - } + public Optional getFlagByKey(String flagKey) { + return repository.findByFlagKey(flagKey.toUpperCase()); + } - public void deleteFlag(Long id) { - if (!repository.existsById(id)) { - throw new IllegalArgumentException("Feature flag not found with id: " + id); - } - repository.deleteById(id); - } + public FeatureFlag createFlag(FeatureFlag flag) { + if (repository.existsByFlagKey(flag.getFlagKey().toUpperCase())) { + throw new IllegalArgumentException("Feature flag with key '" + flag.getFlagKey() + "' already exists"); + } - 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); - } + validateFlag(flag); + 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"); - } + public FeatureFlag updateFlag(Long id, FeatureFlag updatedFlag) { + FeatureFlag existingFlag = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Feature flag not found with id: " + id)); - 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"); - } - } + // Don't allow changing the key + updatedFlag.setFlagKey(existingFlag.getFlagKey()); - 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()); - } - } - } -} + 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.toUpperCase()) + .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) { + if (flag.getWhitelist() == null || flag.getWhitelist().isEmpty()) { + logger.warn("Flag '{}' is of type WHITELIST but has no whitelist items", flag.getFlagKey()); + } + } + + if (flag.getFlagType() == FlagType.BLACKLIST) { + if (flag.getBlacklist() == null || flag.getBlacklist().isEmpty()) { + logger.warn("Flag '{}' is of type BLACKLIST but has no blacklist items", flag.getFlagKey()); + } + } + } + +} \ No newline at end of file diff --git a/src/main/resources/db/mysql/data.sql b/src/main/resources/db/mysql/data.sql index 3f1dcf8ea..ac50e4f77 100644 --- a/src/main/resources/db/mysql/data.sql +++ b/src/main/resources/db/mysql/data.sql @@ -51,3 +51,44 @@ INSERT IGNORE INTO visits VALUES (1, 7, '2010-03-04', 'rabies shot'); INSERT IGNORE INTO visits VALUES (2, 8, '2011-03-04', 'rabies shot'); INSERT IGNORE INTO visits VALUES (3, 8, '2009-06-04', 'neutered'); INSERT IGNORE INTO visits VALUES (4, 7, '2008-09-04', 'spayed'); + + +-- Sample data for feature flags + +-- 1. SIMPLE flag: Add New Pet (enabled by default) +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('ADD_NEW_PET', 'Controls whether users can add new pets to an owner', 'SIMPLE', TRUE, NULL, NOW(), NOW()); + +-- 2. SIMPLE flag: Add Visit (enabled by default) +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('ADD_VISIT', 'Controls whether users can add new visits for pets', 'SIMPLE', TRUE, NULL, NOW(), NOW()); + +-- 3. WHITELIST flag: Owner Search (only specific users can search) +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('OWNER_SEARCH', 'Controls who can search for owners', 'WHITELIST', TRUE, NULL, NOW(), NOW()); + +-- Add whitelist items for owner-search (example user contexts) +INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) +SELECT id, 'admin' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; + +INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) +SELECT id, 'Ramprakash' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; + +-- 4. PERCENTAGE flag: New UI Theme (gradually roll out to 50% of users) +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('NEW_UI_THEME', 'Gradually roll out new UI theme', 'PERCENTAGE', TRUE, 50, NOW(), NOW()); + +-- 5. BLACKLIST flag: Delete Owner (block specific users from deleting) +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('DELETE_OWNER', 'Controls who can delete owners', 'BLACKLIST', TRUE, NULL, NOW(), NOW()); + +-- Add blacklist items +INSERT IGNORE INTO feature_flag_blacklist (feature_flag_id, blacklist) +SELECT id, 'guest' FROM feature_flags WHERE flag_key = 'DELETE_OWNER'; + +INSERT IGNORE INTO feature_flag_blacklist (feature_flag_id, blacklist) +SELECT id, 'readonly_user' FROM feature_flags WHERE flag_key = 'DELETE_OWNER'; + +-- 6. GLOBAL_DISABLE flag: Emergency shutdown example +INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) +VALUES ('EMERGENCY_SHUTDOWN', 'Emergency feature kill switch', 'GLOBAL_DISABLE', FALSE, NULL, NOW(), NOW()); \ No newline at end of file diff --git a/src/main/resources/db/mysql/schema.sql b/src/main/resources/db/mysql/schema.sql index 2591a516d..98d10c68f 100644 --- a/src/main/resources/db/mysql/schema.sql +++ b/src/main/resources/db/mysql/schema.sql @@ -53,3 +53,28 @@ CREATE TABLE IF NOT EXISTS visits ( description VARCHAR(255), FOREIGN KEY (pet_id) REFERENCES pets(id) ) engine=InnoDB; + + +CREATE TABLE IF NOT EXISTS feature_flags ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + flag_key VARCHAR(255) NOT NULL UNIQUE, + description VARCHAR(500) NOT NULL, + flag_type VARCHAR(50) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + percentage INT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + INDEX(flag_key) +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS feature_flag_whitelist ( + feature_flag_id BIGINT NOT NULL, + whitelist VARCHAR(255), + FOREIGN KEY (feature_flag_id) REFERENCES feature_flags(id) ON DELETE CASCADE +) engine=InnoDB; + +CREATE TABLE IF NOT EXISTS feature_flag_blacklist ( + feature_flag_id BIGINT NOT NULL, + blacklist VARCHAR(255), + FOREIGN KEY (feature_flag_id) REFERENCES feature_flags(id) ON DELETE CASCADE +) engine=InnoDB; \ No newline at end of file From b17b470db9d42d25f3c41b02d0b16c53c732bfd0 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 14:00:35 +0530 Subject: [PATCH 4/9] added flag for add new pet --- .../featureflag/annotation/FeatureToggle.java | 61 ++--- .../controller/FeatureFlagController.java | 258 +++++++++--------- .../featureflag/dto/FeatureCheckRequest.java | 28 +- .../featureflag/dto/FeatureCheckResponse.java | 54 ++-- .../featureflag/dto/FeatureFlagRequest.java | 129 ++++----- .../featureflag/dto/FeatureFlagResponse.java | 171 ++++++------ .../service/FeatureFlagService.java | 30 +- .../petclinic/owner/OwnerController.java | 9 +- .../petclinic/owner/PetController.java | 9 +- .../templates/owners/ownerDetails.html | 2 +- 10 files changed, 376 insertions(+), 375 deletions(-) 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 index 405bd8ac7..015066b53 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/annotation/FeatureToggle.java @@ -7,42 +7,39 @@ import java.lang.annotation.Target; /** * Custom annotation to mark methods protected by feature toggle - * + * * Usage: - * @FeatureToggle(key = "add-new-pet") - * public String processNewPetForm(...) { - * // method implementation - * } - * + * + * @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 - * } + * @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 ""; + + /** + * 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/controller/FeatureFlagController.java b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java index 14d07b65a..aa1d0a2b1 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java @@ -20,149 +20,145 @@ 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; - } + private final 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); - } + public FeatureFlagController(FeatureFlagService featureFlagService) { + this.featureFlagService = featureFlagService; + } - /** - * 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 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/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()); - } + /** + * 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()); + } - /** - * 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())); - } - } + /** + * 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()); + } - /** - * 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())); - } - } + /** + * 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())); + } + } - /** - * 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())); - } - } + /** + * 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())); + } + } - /** - * 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())); - } - } + /** + * 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/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); - } + /** + * 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())); + } + } - /** - * 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); - } + /** + * 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); + } - /** - * Error response class - */ - public static class ErrorResponse { - private String error; + /** + * 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); + } - public ErrorResponse(String error) { - this.error = error; - } + /** + * Error response class + */ + public static class ErrorResponse { - public String getError() { - return error; - } + private String error; + + public ErrorResponse(String error) { + this.error = error; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = 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 index a3a1f20e2..8bf7bd7d7 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckRequest.java @@ -2,24 +2,24 @@ package org.springframework.samples.petclinic.featureflag.dto; public class FeatureCheckRequest { - private String flagKey; + private String flagKey; - private String context; + private String context; - public String getFlagKey() { - return flagKey; - } + public String getFlagKey() { + return flagKey; + } - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public String getContext() { - return context; - } + public String getContext() { + return context; + } - public void setContext(String context) { - this.context = 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 index 96c00d2e0..de6e86399 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureCheckResponse.java @@ -1,39 +1,41 @@ package org.springframework.samples.petclinic.featureflag.dto; public class FeatureCheckResponse { - private String flagKey; - private Boolean enabled; + private String flagKey; - private String context; + private Boolean enabled; - public FeatureCheckResponse(String flagKey, Boolean enabled, String context) { - this.flagKey = flagKey; - this.enabled = enabled; - this.context = context; - } + private String context; - public String getFlagKey() { - return flagKey; - } + public FeatureCheckResponse(String flagKey, Boolean enabled, String context) { + this.flagKey = flagKey; + this.enabled = enabled; + this.context = context; + } - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + public String getFlagKey() { + return flagKey; + } - public Boolean getEnabled() { - return enabled; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } + public Boolean getEnabled() { + return enabled; + } - public String getContext() { - return context; - } + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getContext() { + return context; + } + + public void setContext(String context) { + this.context = context; + } - public void setContext(String context) { - this.context = context; - } } 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 index 1db284bd9..dc02f6611 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagRequest.java @@ -7,89 +7,90 @@ import org.springframework.samples.petclinic.featureflag.entity.FlagType; public class FeatureFlagRequest { - private String flagKey; + private String flagKey; - private String description; + private String description; - private FlagType flagType; + private FlagType flagType; - private Boolean enabled; + private Boolean enabled; - private Integer percentage; + private Integer percentage; - private Set whitelist; + private Set whitelist; - private Set blacklist; + private Set blacklist; - // Getters and Setters - public String getFlagKey() { - return flagKey; - } + // Getters and Setters + public String getFlagKey() { + return flagKey; + } - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public String getDescription() { - return description; - } + public String getDescription() { + return description; + } - public void setDescription(String description) { - this.description = description; - } + public void setDescription(String description) { + this.description = description; + } - public FlagType getFlagType() { - return flagType; - } + public FlagType getFlagType() { + return flagType; + } - public void setFlagType(FlagType flagType) { - this.flagType = flagType; - } + public void setFlagType(FlagType flagType) { + this.flagType = flagType; + } - public Boolean getEnabled() { - return enabled; - } + public Boolean getEnabled() { + return enabled; + } - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } - public Integer getPercentage() { - return percentage; - } + public Integer getPercentage() { + return percentage; + } - public void setPercentage(Integer percentage) { - this.percentage = percentage; - } + public void setPercentage(Integer percentage) { + this.percentage = percentage; + } - public Set getWhitelist() { - return whitelist; - } + public Set getWhitelist() { + return whitelist; + } - public void setWhitelist(Set whitelist) { - this.whitelist = whitelist; - } + public void setWhitelist(Set whitelist) { + this.whitelist = whitelist; + } - public Set getBlacklist() { - return blacklist; - } + public Set getBlacklist() { + return blacklist; + } - public void setBlacklist(Set blacklist) { - this.blacklist = blacklist; - } + public void setBlacklist(Set blacklist) { + this.blacklist = blacklist; + } + + /** + * Convert DTO to Entity + */ + 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.setWhitelist(this.whitelist != null ? this.whitelist : Set.of()); + flag.setBlacklist(this.blacklist != null ? this.blacklist : Set.of()); + return flag; + } - /** - * Convert DTO to Entity - */ - 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.setWhitelist(this.whitelist != null ? this.whitelist : Set.of()); - flag.setBlacklist(this.blacklist != null ? this.blacklist : 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 index aa0950e8f..7599431bc 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/dto/FeatureFlagResponse.java @@ -7,121 +7,120 @@ import org.springframework.samples.petclinic.featureflag.entity.FlagType; public class FeatureFlagResponse { - private Long id; + private Long id; - private String flagKey; + private String flagKey; - private String description; + private String description; - private FlagType flagType; + private FlagType flagType; - private Boolean enabled; + private Boolean enabled; - private Integer percentage; + private Integer percentage; - private Set whitelist; + private Set whitelist; - private Set blacklist; + private Set blacklist; - private String createdAt; + private String createdAt; - private String updatedAt; + 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; - } + 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; - } + // Getters and Setters + public Long getId() { + return id; + } - public void setId(Long id) { - this.id = id; - } + public void setId(Long id) { + this.id = id; + } - public String getFlagKey() { - return flagKey; - } + public String getFlagKey() { + return flagKey; + } - public void setFlagKey(String flagKey) { - this.flagKey = flagKey; - } + public void setFlagKey(String flagKey) { + this.flagKey = flagKey; + } - public String getDescription() { - return description; - } + public String getDescription() { + return description; + } - public void setDescription(String description) { - this.description = description; - } + public void setDescription(String description) { + this.description = description; + } - public FlagType getFlagType() { - return flagType; - } + public FlagType getFlagType() { + return flagType; + } - public void setFlagType(FlagType flagType) { - this.flagType = flagType; - } + public void setFlagType(FlagType flagType) { + this.flagType = flagType; + } - public Boolean getEnabled() { - return enabled; - } + public Boolean getEnabled() { + return enabled; + } - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } - public Integer getPercentage() { - return percentage; - } + public Integer getPercentage() { + return percentage; + } - public void setPercentage(Integer percentage) { - this.percentage = percentage; - } + public void setPercentage(Integer percentage) { + this.percentage = percentage; + } - public Set getWhitelist() { - return whitelist; - } + public Set getWhitelist() { + return whitelist; + } - public void setWhitelist(Set whitelist) { - this.whitelist = whitelist; - } + public void setWhitelist(Set whitelist) { + this.whitelist = whitelist; + } - public Set getBlacklist() { - return blacklist; - } + public Set getBlacklist() { + return blacklist; + } - public void setBlacklist(Set blacklist) { - this.blacklist = blacklist; - } + public void setBlacklist(Set blacklist) { + this.blacklist = blacklist; + } - public String getCreatedAt() { - return createdAt; - } + public String getCreatedAt() { + return createdAt; + } - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } - public String getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(String updatedAt) { - this.updatedAt = updatedAt; - } + public String getUpdatedAt() { + return updatedAt; + } + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } } 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 index fa98d30b1..319b1a239 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java @@ -10,13 +10,13 @@ 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 + * + * 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 @@ -31,9 +31,8 @@ public class FeatureFlagService { } /** - * Main helper function to check if a feature is enabled - * Can be called from anywhere in the application - * + * 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 */ @@ -43,7 +42,6 @@ public class FeatureFlagService { /** * 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 @@ -69,12 +67,10 @@ public class FeatureFlagService { } /** - * Evaluate a feature flag based on its type and configuration - * Evaluation order (highest to lowest priority): - * 1. GLOBAL_DISABLE - always returns false - * 2. Blacklist - if context is blacklisted, return false - * 3. Enabled check - if not enabled, return false - * 4. Type-specific evaluation (WHITELIST, PERCENTAGE, SIMPLE) + * Evaluate a feature flag based on its type and configuration Evaluation order + * (highest to lowest priority): 1. GLOBAL_DISABLE - always returns false 2. Blacklist + * - if context is blacklisted, return false 3. Enabled check - if not enabled, return + * false 4. Type-specific evaluation (WHITELIST, PERCENTAGE, SIMPLE) */ private boolean evaluateFlag(FeatureFlag flag, String context) { // GLOBAL_DISABLE always returns false @@ -154,8 +150,8 @@ public class FeatureFlagService { 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); + logger.debug("Percentage flag '{}' for context '{}': bucket={}, percentage={}, enabled={}", flag.getFlagKey(), + context, bucket, flag.getPercentage(), enabled); return enabled; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index 199ca3611..a70851969 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -22,6 +22,7 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.samples.petclinic.featureflag.service.FeatureFlagService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -51,9 +52,11 @@ class OwnerController { private static final String VIEWS_OWNER_CREATE_OR_UPDATE_FORM = "owners/createOrUpdateOwnerForm"; private final OwnerRepository owners; + private final FeatureFlagService featureFlagService; - public OwnerController(OwnerRepository owners) { + public OwnerController(OwnerRepository owners, FeatureFlagService featureFlagService) { this.owners = owners; + this.featureFlagService = featureFlagService; } @InitBinder @@ -170,6 +173,10 @@ class OwnerController { Owner owner = optionalOwner.orElseThrow(() -> new IllegalArgumentException( "Owner not found with id: " + ownerId + ". Please ensure the ID is correct ")); mav.addObject(owner); + + // displaying add pet button based on feature toggle + boolean addNewPetEnabled = featureFlagService.isFeatureEnabled("ADD_NEW_PET","addNewPetEnabled"); + mav.addObject("addNewPetEnabled", addNewPetEnabled); return mav; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java index 8398e4f13..2da5efcf3 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.Objects; import java.util.Optional; +import org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.util.Assert; @@ -95,6 +96,7 @@ class PetController { dataBinder.setValidator(new PetValidator()); } + @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", disabledRedirect = "/owners/{ownerId}") @GetMapping("/pets/new") public String initCreationForm(Owner owner, ModelMap model) { Pet pet = new Pet(); @@ -102,6 +104,7 @@ class PetController { return VIEWS_PETS_CREATE_OR_UPDATE_FORM; } + @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", disabledRedirect = "/owners/{ownerId}") @PostMapping("/pets/new") public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result, RedirectAttributes redirectAttributes) { @@ -159,8 +162,9 @@ class PetController { /** * Updates the pet details if it exists or adds a new pet to the owner. + * * @param owner The owner of the pet - * @param pet The pet with updated details + * @param pet The pet with updated details */ private void updatePetDetails(Owner owner, Pet pet) { Integer id = pet.getId(); @@ -171,8 +175,7 @@ class PetController { existingPet.setName(pet.getName()); existingPet.setBirthDate(pet.getBirthDate()); existingPet.setType(pet.getType()); - } - else { + } else { owner.addPet(pet); } this.owners.save(owner); diff --git a/src/main/resources/templates/owners/ownerDetails.html b/src/main/resources/templates/owners/ownerDetails.html index cc175cd13..1f298533f 100644 --- a/src/main/resources/templates/owners/ownerDetails.html +++ b/src/main/resources/templates/owners/ownerDetails.html @@ -35,7 +35,7 @@ Edit Owner - Add + Add New Pet
From c2ab02af3d909e5b679b593949db250a983291bb Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 16:02:22 +0530 Subject: [PATCH 5/9] implemented ADD_NEW_PET, ADD_VISIT, OWNER_SEARCH flag --- .../aspect/FeatureToggleAspect.java | 1 + .../controller/FeatureFlagController.java | 4 ++-- .../service/FeatureFlagService.java | 6 +++++- .../petclinic/owner/OwnerController.java | 19 ++++++++++++++++++- .../petclinic/owner/VisitController.java | 3 +++ src/main/resources/db/mysql/data.sql | 10 +++++----- .../templates/owners/findOwners.html | 3 ++- .../templates/owners/ownerDetails.html | 2 +- 8 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java index e69471b92..00554ac82 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/aspect/FeatureToggleAspect.java @@ -46,6 +46,7 @@ public class FeatureToggleAspect { } // Check if feature is enabled + logger.debug("Checking feature toggle '{}' with context '{}'", flagKey, context); boolean isEnabled = featureFlagService.isFeatureEnabled(flagKey, context); logger.debug("Feature toggle '{}' check: enabled={}, context={}", flagKey, isEnabled, context); 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 index aa1d0a2b1..992d80b5f 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java @@ -106,7 +106,7 @@ public class FeatureFlagController { } /** - * POST /api/feature-flags/{flagKey}/toggle Toggle a feature flag on/off + * POST /feature-flags/{flagKey}/toggle Toggle a feature flag on/off */ @PostMapping("/{flagKey}/toggle") public ResponseEntity toggleFlag(@PathVariable String flagKey) { @@ -130,7 +130,7 @@ public class FeatureFlagController { } /** - * GET /api/feature-flags/check/{flagKey} Check if a feature is enabled (simple check + * GET /feature-flags/check/{flagKey} Check if a feature is enabled (simple check * without context) */ @GetMapping("/check/{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 index 319b1a239..92bad1c8a 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java @@ -106,6 +106,9 @@ public class FeatureFlagService { case PERCENTAGE: return evaluatePercentage(flag, context); + + case GLOBAL_DISABLE: + return false; default: logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType()); @@ -121,7 +124,8 @@ public class FeatureFlagService { logger.debug("Whitelist flag '{}' requires context, got null/empty", flag.getFlagKey()); return false; } - + logger.debug("Whitelist flag '{}' - checking if context '{}' is in whitelist: {}", flag.getFlagKey(), context, + flag.getWhitelist()); boolean inWhitelist = flag.getWhitelist().contains(context.trim()); logger.debug("Whitelist flag '{}' for context '{}': {}", flag.getFlagKey(), context, inWhitelist); return inWhitelist; diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index a70851969..ab4564bf6 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -22,6 +22,7 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle; import org.springframework.samples.petclinic.featureflag.service.FeatureFlagService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -90,13 +91,27 @@ class OwnerController { } @GetMapping("/owners/find") - public String initFindForm() { + public String initFindForm(Model model) { + model.addAttribute("owner", new Owner()); + + boolean ownerSearchEnabled = + featureFlagService.isFeatureEnabled("OWNER_SEARCH", null); + + model.addAttribute("ownerSearchEnabled", ownerSearchEnabled); return "owners/findOwners"; } + @FeatureToggle( + key = "OWNER_SEARCH", + disabledMessage = "Owner search is restricted", + disabledRedirect = "/owners/find" + ) @GetMapping("/owners") public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result, Model model) { + + + // allow parameterless GET request for /owners to return all records String lastName = owner.getLastName(); if (lastName == null) { @@ -176,6 +191,8 @@ class OwnerController { // displaying add pet button based on feature toggle boolean addNewPetEnabled = featureFlagService.isFeatureEnabled("ADD_NEW_PET","addNewPetEnabled"); + boolean addVisitEnabled = featureFlagService.isFeatureEnabled("ADD_VISIT","addVisitEnabled"); + mav.addObject("addVisitEnabled", addVisitEnabled); mav.addObject("addNewPetEnabled", addNewPetEnabled); return mav; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java index cc3e3ce1a..a415d8f0f 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java @@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.owner; import java.util.Map; import java.util.Optional; +import org.springframework.samples.petclinic.featureflag.annotation.FeatureToggle; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.WebDataBinder; @@ -81,6 +82,7 @@ class VisitController { // Spring MVC calls method loadPetWithVisit(...) before initNewVisitForm is // called + @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", disabledRedirect = "/owners/{ownerId}") @GetMapping("/owners/{ownerId}/pets/{petId}/visits/new") public String initNewVisitForm() { return "pets/createOrUpdateVisitForm"; @@ -88,6 +90,7 @@ class VisitController { // Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is // called + @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", disabledRedirect = "/owners/{ownerId}") @PostMapping("/owners/{ownerId}/pets/{petId}/visits/new") public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int petId, @Valid Visit visit, BindingResult result, RedirectAttributes redirectAttributes) { diff --git a/src/main/resources/db/mysql/data.sql b/src/main/resources/db/mysql/data.sql index ac50e4f77..b132c6d33 100644 --- a/src/main/resources/db/mysql/data.sql +++ b/src/main/resources/db/mysql/data.sql @@ -65,14 +65,14 @@ VALUES ('ADD_VISIT', 'Controls whether users can add new visits for pets', 'SIMP -- 3. WHITELIST flag: Owner Search (only specific users can search) INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) -VALUES ('OWNER_SEARCH', 'Controls who can search for owners', 'WHITELIST', TRUE, NULL, NOW(), NOW()); +VALUES ('OWNER_SEARCH', 'Controls who can search for owners', 'SIMPLE', TRUE, NULL, NOW(), NOW()); -- Add whitelist items for owner-search (example user contexts) -INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) -SELECT id, 'admin' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; +-- INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) +-- SELECT id, 'admin' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; -INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) -SELECT id, 'Ramprakash' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; +-- INSERT IGNORE INTO feature_flag_whitelist (feature_flag_id, whitelist) +-- SELECT id, 'Ramprakash' FROM feature_flags WHERE flag_key = 'OWNER_SEARCH'; -- 4. PERCENTAGE flag: New UI Theme (gradually roll out to 50% of users) INSERT IGNORE INTO feature_flags (flag_key, description, flag_type, enabled, percentage, created_at, updated_at) diff --git a/src/main/resources/templates/owners/findOwners.html b/src/main/resources/templates/owners/findOwners.html index 703351c7d..3a43e1ac0 100644 --- a/src/main/resources/templates/owners/findOwners.html +++ b/src/main/resources/templates/owners/findOwners.html @@ -20,13 +20,14 @@ -
+
Add Owner + diff --git a/src/main/resources/templates/owners/ownerDetails.html b/src/main/resources/templates/owners/ownerDetails.html index 1f298533f..19f49b03c 100644 --- a/src/main/resources/templates/owners/ownerDetails.html +++ b/src/main/resources/templates/owners/ownerDetails.html @@ -70,7 +70,7 @@ Edit Pet - Add Visit + Add Visit From 7106e4a41fcf65d635b030d730d5ec72a0bd371d Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 16:04:34 +0530 Subject: [PATCH 6/9] formatted the code --- .../service/FeatureFlagService.java | 4 ++-- .../petclinic/owner/OwnerController.java | 19 +++++++------------ .../petclinic/owner/PetController.java | 12 +++++++----- .../petclinic/owner/VisitController.java | 6 ++++-- 4 files changed, 20 insertions(+), 21 deletions(-) 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 index 92bad1c8a..7b73b3c2c 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java @@ -106,9 +106,9 @@ public class FeatureFlagService { case PERCENTAGE: return evaluatePercentage(flag, context); - + case GLOBAL_DISABLE: - return false; + return false; default: logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType()); diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index ab4564bf6..1dcef5e8c 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -53,6 +53,7 @@ class OwnerController { private static final String VIEWS_OWNER_CREATE_OR_UPDATE_FORM = "owners/createOrUpdateOwnerForm"; private final OwnerRepository owners; + private final FeatureFlagService featureFlagService; public OwnerController(OwnerRepository owners, FeatureFlagService featureFlagService) { @@ -94,24 +95,18 @@ class OwnerController { public String initFindForm(Model model) { model.addAttribute("owner", new Owner()); - boolean ownerSearchEnabled = - featureFlagService.isFeatureEnabled("OWNER_SEARCH", null); + boolean ownerSearchEnabled = featureFlagService.isFeatureEnabled("OWNER_SEARCH", null); - model.addAttribute("ownerSearchEnabled", ownerSearchEnabled); + model.addAttribute("ownerSearchEnabled", ownerSearchEnabled); return "owners/findOwners"; } - @FeatureToggle( - key = "OWNER_SEARCH", - disabledMessage = "Owner search is restricted", - disabledRedirect = "/owners/find" - ) + @FeatureToggle(key = "OWNER_SEARCH", disabledMessage = "Owner search is restricted", + disabledRedirect = "/owners/find") @GetMapping("/owners") public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result, Model model) { - - // allow parameterless GET request for /owners to return all records String lastName = owner.getLastName(); if (lastName == null) { @@ -190,8 +185,8 @@ class OwnerController { mav.addObject(owner); // displaying add pet button based on feature toggle - boolean addNewPetEnabled = featureFlagService.isFeatureEnabled("ADD_NEW_PET","addNewPetEnabled"); - boolean addVisitEnabled = featureFlagService.isFeatureEnabled("ADD_VISIT","addVisitEnabled"); + boolean addNewPetEnabled = featureFlagService.isFeatureEnabled("ADD_NEW_PET", "addNewPetEnabled"); + boolean addVisitEnabled = featureFlagService.isFeatureEnabled("ADD_VISIT", "addVisitEnabled"); mav.addObject("addVisitEnabled", addVisitEnabled); mav.addObject("addNewPetEnabled", addNewPetEnabled); return mav; diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java index 2da5efcf3..73fe03536 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java @@ -96,7 +96,8 @@ class PetController { dataBinder.setValidator(new PetValidator()); } - @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", disabledRedirect = "/owners/{ownerId}") + @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", + disabledRedirect = "/owners/{ownerId}") @GetMapping("/pets/new") public String initCreationForm(Owner owner, ModelMap model) { Pet pet = new Pet(); @@ -104,7 +105,8 @@ class PetController { return VIEWS_PETS_CREATE_OR_UPDATE_FORM; } - @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", disabledRedirect = "/owners/{ownerId}") + @FeatureToggle(key = "ADD_NEW_PET", disabledMessage = "Adding new pets is currently disabled", + disabledRedirect = "/owners/{ownerId}") @PostMapping("/pets/new") public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result, RedirectAttributes redirectAttributes) { @@ -162,9 +164,8 @@ class PetController { /** * Updates the pet details if it exists or adds a new pet to the owner. - * * @param owner The owner of the pet - * @param pet The pet with updated details + * @param pet The pet with updated details */ private void updatePetDetails(Owner owner, Pet pet) { Integer id = pet.getId(); @@ -175,7 +176,8 @@ class PetController { existingPet.setName(pet.getName()); existingPet.setBirthDate(pet.getBirthDate()); existingPet.setType(pet.getType()); - } else { + } + else { owner.addPet(pet); } this.owners.save(owner); diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java index a415d8f0f..59be0f3e3 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java @@ -82,7 +82,8 @@ class VisitController { // Spring MVC calls method loadPetWithVisit(...) before initNewVisitForm is // called - @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", disabledRedirect = "/owners/{ownerId}") + @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", + disabledRedirect = "/owners/{ownerId}") @GetMapping("/owners/{ownerId}/pets/{petId}/visits/new") public String initNewVisitForm() { return "pets/createOrUpdateVisitForm"; @@ -90,7 +91,8 @@ class VisitController { // Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is // called - @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", disabledRedirect = "/owners/{ownerId}") + @FeatureToggle(key = "ADD_VISIT", disabledMessage = "Adding visits is currently disabled", + disabledRedirect = "/owners/{ownerId}") @PostMapping("/owners/{ownerId}/pets/{petId}/visits/new") public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int petId, @Valid Visit visit, BindingResult result, RedirectAttributes redirectAttributes) { From 8f56b455ac9adc7f113c03a43b6f48b58fa5d6e2 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 20:04:05 +0530 Subject: [PATCH 7/9] updated Readme file --- README.md | 249 +++++++++++++++++++++++------------------------------- 1 file changed, 106 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index e8aa6f3d8..bc173e385 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,137 @@ -# Spring PetClinic Sample Application [![Build Status](https://github.com/spring-projects/spring-petclinic/actions/workflows/maven-build.yml/badge.svg)](https://github.com/spring-projects/spring-petclinic/actions/workflows/maven-build.yml)[![Build Status](https://github.com/spring-projects/spring-petclinic/actions/workflows/gradle-build.yml/badge.svg)](https://github.com/spring-projects/spring-petclinic/actions/workflows/gradle-build.yml) +# Feature Flag Enabled Spring PetClinic -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/spring-projects/spring-petclinic) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=7517918) +This project is an extension of the official **Spring PetClinic** application with a **custom-built Feature Flag service** implemented from scratch (without using libraries like FF4J or Togglz). -## Understanding the Spring Petclinic application with a few diagrams +The feature flag system allows enabling/disabling application features dynamically using database-driven flags that persist across restarts. -See the presentation here: -[Spring Petclinic Sample Application (legacy slides)](https://speakerdeck.com/michaelisvy/spring-petclinic-sample-application?slide=20) +--- -> **Note:** These slides refer to a legacy, pre–Spring Boot version of Petclinic and may not reflect the current Spring Boot–based implementation. -> For up-to-date information, please refer to this repository and its documentation. - - -## Run Petclinic locally - -Spring Petclinic is a [Spring Boot](https://spring.io/guides/gs/spring-boot) application built using [Maven](https://spring.io/guides/gs/maven/) or [Gradle](https://spring.io/guides/gs/gradle/). -Java 17 or later is required for the build, and the application can run with Java 17 or newer. - -You first need to clone the project locally: - -```bash -git clone https://github.com/spring-projects/spring-petclinic.git -cd spring-petclinic -``` -If you are using Maven, you can start the application on the command-line as follows: - -```bash -./mvnw spring-boot:run -``` -With Gradle, the command is as follows: - -```bash -./gradlew bootRun -``` - -You can then access the Petclinic at . - -petclinic-screenshot - -You can, of course, run Petclinic in your favorite IDE. -See below for more details. - -## Building a Container - -There is no `Dockerfile` in this project. You can build a container image (if you have a docker daemon) using the Spring Boot build plugin: - -```bash -./mvnw spring-boot:build-image -``` - -## In case you find a bug/suggested improvement for Spring Petclinic - -Our issue tracker is available [here](https://github.com/spring-projects/spring-petclinic/issues). - -## Database configuration - -In its default configuration, Petclinic uses an in-memory database (H2) which -gets populated at startup with data. The h2 console is exposed at `http://localhost:8080/h2-console`, -and it is possible to inspect the content of the database using the `jdbc:h2:mem:` URL. The UUID is printed at startup to the console. - -A similar setup is provided for MySQL and PostgreSQL if a persistent database configuration is needed. Note that whenever the database type changes, the app needs to run with a different profile: `spring.profiles.active=mysql` for MySQL or `spring.profiles.active=postgres` for PostgreSQL. See the [Spring Boot documentation](https://docs.spring.io/spring-boot/how-to/properties-and-configuration.html#howto.properties-and-configuration.set-active-spring-profiles) for more detail on how to set the active profile. - -You can start MySQL or PostgreSQL locally with whatever installer works for your OS or use docker: - -```bash -docker run -e MYSQL_USER=petclinic -e MYSQL_PASSWORD=petclinic -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=petclinic -p 3306:3306 mysql:9.5 -``` - -or - -```bash -docker run -e POSTGRES_USER=petclinic -e POSTGRES_PASSWORD=petclinic -e POSTGRES_DB=petclinic -p 5432:5432 postgres:18.1 -``` - -Further documentation is provided for [MySQL](https://github.com/spring-projects/spring-petclinic/blob/main/src/main/resources/db/mysql/petclinic_db_setup_mysql.txt) -and [PostgreSQL](https://github.com/spring-projects/spring-petclinic/blob/main/src/main/resources/db/postgres/petclinic_db_setup_postgres.txt). - -Instead of vanilla `docker` you can also use the provided `docker-compose.yml` file to start the database containers. Each one has a service named after the Spring profile: - -```bash -docker compose up mysql -``` - -or - -```bash -docker compose up postgres -``` - -## Test Applications - -At development time we recommend you use the test applications set up as `main()` methods in `PetClinicIntegrationTests` (using the default H2 database and also adding Spring Boot Devtools), `MySqlTestApplication` and `PostgresIntegrationTests`. These are set up so that you can run the apps in your IDE to get fast feedback and also run the same classes as integration tests against the respective database. The MySql integration tests use Testcontainers to start the database in a Docker container, and the Postgres tests use Docker Compose to do the same thing. - -## Compiling the CSS - -There is a `petclinic.css` in `src/main/resources/static/resources/css`. It was generated from the `petclinic.scss` source, combined with the [Bootstrap](https://getbootstrap.com/) library. If you make changes to the `scss`, or upgrade Bootstrap, you will need to re-compile the CSS resources using the Maven profile "css", i.e. `./mvnw package -P css`. There is no build profile for Gradle to compile the CSS. - -## Working with Petclinic in your IDE +## How to Run the Application ### Prerequisites - -The following items should be installed in your system: - -- Java 17 or newer (full JDK, not a JRE) -- [Git command line tool](https://help.github.com/articles/set-up-git) -- Your preferred IDE - - Eclipse with the m2e plugin. Note: when m2e is available, there is a m2 icon in `Help -> About` dialog. If m2e is - not there, follow the installation process [here](https://www.eclipse.org/m2e/) - - [Spring Tools Suite](https://spring.io/tools) (STS) - - [IntelliJ IDEA](https://www.jetbrains.com/idea/) - - [VS Code](https://code.visualstudio.com) +- Java 17+ +- Maven 3.8+ +- Docker & Docker Compose ### Steps +1. **Clone the repository** + ```bash + git clone https://github.com/XTiNCT-7/spring-petclinic.git + cd spring-petclinic + ``` -1. On the command line run: +2. **Start MySQL using Docker** + ```bash + docker-compose up mysql + ``` - ```bash - git clone https://github.com/spring-projects/spring-petclinic.git - ``` +3. **Build and run the application** + ```bash + mvn clean spring-boot:run + ``` -1. Inside Eclipse or STS: +4. Access the application: + - Application UI: `http://localhost:8080` + - Feature Flag APIs: `http://localhost:8080/feature-flags` - Open the project via `File -> Import -> Maven -> Existing Maven project`, then select the root directory of the cloned repo. +--- - Then either build on the command line `./mvnw generate-resources` or use the Eclipse launcher (right-click on project and `Run As -> Maven install`) to generate the CSS. Run the application's main method by right-clicking on it and choosing `Run As -> Java Application`. +## Assumptions & Design Decisions -1. Inside IntelliJ IDEA: +- Feature flags are **stored in the database** and persist across application restarts. +- No authentication is applied to feature flag management APIs (as per requirement). +- Feature flag evaluation is **centralized in a helper service** so it can be reused across controllers, services, and views. +- A **custom annotation (`@FeatureToggle`)** is used to guard controller endpoints. +- Thymeleaf views are conditionally rendered using model attributes derived from feature flag checks. +- Feature flag behavior supports more than boolean enable/disable: + - Global enable/disable + - Whitelist-based access + - Blacklist-based restriction + - Percentage rollout (future-safe design) - In the main menu, choose `File -> Open` and select the Petclinic [pom.xml](pom.xml). Click on the `Open` button. +--- - - CSS files are generated from the Maven build. You can build them on the command line `./mvnw generate-resources` or right-click on the `spring-petclinic` project then `Maven -> Generates sources and Update Folders`. +## Feature Flags Implemented - - A run configuration named `PetClinicApplication` should have been created for you if you're using a recent Ultimate version. Otherwise, run the application by right-clicking on the `PetClinicApplication` main class and choosing `Run 'PetClinicApplication'`. +| Feature Flag Key | Type | Controls | Implementation Location | +|------------------|------|----------|--------------------------| +| `ADD_NEW_PET` | SIMPLE | Enables adding a new pet to an owner | `PetController`, `ownerDetails.html` | +| `ADD_VISIT` | SIMPLE | Enables adding a visit for a pet | `VisitController`, `ownerDetails.html` | +| `OWNER_SEARCH` | SIMPLE | Enables owner search functionality | `OwnerController`, `findOwners.html` | -1. Navigate to the Petclinic +### Example +- If `ADD_NEW_PET` is disabled: + - The "Add New Pet" button is hidden in the UI + - Direct access to `/owners/{id}/pets/new` is blocked using `@FeatureToggle` - Visit [http://localhost:8080](http://localhost:8080) in your browser. +--- -## Looking for something in particular? +## Feature Flag Management APIs -|Spring Boot Configuration | Class or Java property files | -|--------------------------|---| -|The Main Class | [PetClinicApplication](https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/PetClinicApplication.java) | -|Properties Files | [application.properties](https://github.com/spring-projects/spring-petclinic/blob/main/src/main/resources) | -|Caching | [CacheConfiguration](https://github.com/spring-projects/spring-petclinic/blob/main/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java) | +Base Path: `/feature-flags` -## Interesting Spring Petclinic branches and forks +### Create Feature Flag +```http +POST /feature-flags +``` -The Spring Petclinic "main" branch in the [spring-projects](https://github.com/spring-projects/spring-petclinic) -GitHub org is the "canonical" implementation based on Spring Boot and Thymeleaf. There are -[quite a few forks](https://spring-petclinic.github.io/docs/forks.html) in the GitHub org -[spring-petclinic](https://github.com/spring-petclinic). If you are interested in using a different technology stack to implement the Pet Clinic, please join the community there. +**Request Body** +```json +{ + "flagKey": "ADD_NEW_PET", + "description": "Controls whether users can add new pets", + "enabled": true, + "flagType": "SIMPLE" +} +``` -## Interaction with other open-source projects +--- -One of the best parts about working on the Spring Petclinic application is that we have the opportunity to work in direct contact with many Open Source projects. We found bugs/suggested improvements on various topics such as Spring, Spring Data, Bean Validation and even Eclipse! In many cases, they've been fixed/implemented in just a few days. -Here is a list of them: +### Get All Feature Flags +```http +GET /feature-flags +``` -| Name | Issue | -|------|-------| -| Spring JDBC: simplify usage of NamedParameterJdbcTemplate | [SPR-10256](https://github.com/spring-projects/spring-framework/issues/14889) and [SPR-10257](https://github.com/spring-projects/spring-framework/issues/14890) | -| Bean Validation / Hibernate Validator: simplify Maven dependencies and backward compatibility |[HV-790](https://hibernate.atlassian.net/browse/HV-790) and [HV-792](https://hibernate.atlassian.net/browse/HV-792) | -| Spring Data: provide more flexibility when working with JPQL queries | [DATAJPA-292](https://github.com/spring-projects/spring-data-jpa/issues/704) | +--- -## Contributing +### Get Feature Flag by Key +```http +GET /feature-flags/{flagKey} +``` -The [issue tracker](https://github.com/spring-projects/spring-petclinic/issues) is the preferred channel for bug reports, feature requests and submitting pull requests. +--- -For pull requests, editor preferences are available in the [editor config](.editorconfig) for easy use in common text editors. Read more and download plugins at . All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin. -For additional details, please refer to the blog post [Hello DCO, Goodbye CLA: Simplifying Contributions to Spring](https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring). +### Update Feature Flag +```http +PUT /feature-flags/{id} +``` + +--- + +### Delete Feature Flag +```http +DELETE /feature-flags/{id} +``` + +--- + +## Custom Annotation Usage + +```java +@FeatureToggle( + key = "OWNER_SEARCH", + disabledMessage = "Owner search is restricted", + disabledRedirect = "/owners/find" +) +``` + +This annotation prevents access to the controller method when the feature is disabled and optionally redirects the user. + +--- + +## References +- Spring PetClinic: https://github.com/spring-projects/spring-petclinic + +--- -## License -The Spring PetClinic sample application is released under version 2.0 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0). From f6632143f6afbfd5f7b37274dcc77a2c99bf6093 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 20:23:44 +0530 Subject: [PATCH 8/9] updated comments --- .../controller/FeatureFlagController.java | 14 +++++++------- .../featureflag/service/FeatureFlagService.java | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) 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 index 992d80b5f..992d6a747 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/controller/FeatureFlagController.java @@ -28,7 +28,7 @@ public class FeatureFlagController { } /** - * GET /api/feature-flags Get all feature flags + * GET /feature-flags Get all feature flags */ @GetMapping public ResponseEntity> getAllFlags() { @@ -40,7 +40,7 @@ public class FeatureFlagController { } /** - * GET /api/feature-flags/{id} Get a specific feature flag by ID + * GET /feature-flags/{id} Get a specific feature flag by ID */ @GetMapping("/{id}") public ResponseEntity getFlagById(@PathVariable Long id) { @@ -51,7 +51,7 @@ public class FeatureFlagController { } /** - * GET /api/feature-flags/key/{flagKey} Get a specific feature flag by key + * GET /feature-flags/key/{flagKey} Get a specific feature flag by key */ @GetMapping("/key/{flagKey}") public ResponseEntity getFlagByKey(@PathVariable String flagKey) { @@ -62,7 +62,7 @@ public class FeatureFlagController { } /** - * POST /api/feature-flags Create a new feature flag + * POST /feature-flags Create a new feature flag */ @PostMapping public ResponseEntity createFlag(@RequestBody FeatureFlagRequest request) { @@ -77,7 +77,7 @@ public class FeatureFlagController { } /** - * PUT /api/feature-flags/{id} Update an existing feature flag + * PUT /feature-flags/{id} Update an existing feature flag */ @PutMapping("/{id}") public ResponseEntity updateFlag(@PathVariable Long id, @RequestBody FeatureFlagRequest request) { @@ -92,7 +92,7 @@ public class FeatureFlagController { } /** - * DELETE /api/feature-flags/{id} Delete a feature flag + * DELETE /feature-flags/{id} Delete a feature flag */ @DeleteMapping("/{id}") public ResponseEntity deleteFlag(@PathVariable Long id) { @@ -120,7 +120,7 @@ public class FeatureFlagController { } /** - * POST /api/feature-flags/check Check if a feature is enabled for a given context + * POST /feature-flags/check Check if a feature is enabled for a given context */ @PostMapping("/check") public ResponseEntity checkFeature(@RequestBody FeatureCheckRequest request) { 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 index 7b73b3c2c..4a21f9388 100644 --- a/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java +++ b/src/main/java/org/springframework/samples/petclinic/featureflag/service/FeatureFlagService.java @@ -148,7 +148,7 @@ public class FeatureFlagService { return true; } - // Use consistent hashing to ensure same context always gets same result + // Using custom 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; From c033a93a2e23652c9408262f74d1099b97e189d1 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 21:36:09 +0530 Subject: [PATCH 9/9] updates comments --- .../samples/petclinic/owner/OwnerController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index 1dcef5e8c..85eb9432e 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -186,9 +186,12 @@ class OwnerController { // displaying add pet button based on feature toggle boolean addNewPetEnabled = featureFlagService.isFeatureEnabled("ADD_NEW_PET", "addNewPetEnabled"); + mav.addObject("addNewPetEnabled", addNewPetEnabled); + + + // displaying new visit button based on feature toggle boolean addVisitEnabled = featureFlagService.isFeatureEnabled("ADD_VISIT", "addVisitEnabled"); mav.addObject("addVisitEnabled", addVisitEnabled); - mav.addObject("addNewPetEnabled", addNewPetEnabled); return mav; }