From c518db81a26417aaf57f3c9024cf7d4c4deed162 Mon Sep 17 00:00:00 2001 From: XTiNCT Date: Sun, 8 Feb 2026 13:17:13 +0530 Subject: [PATCH] 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