getFlagByKey(String flagKey) {
+ return repository.findByFlagKey(flagKey.toUpperCase());
+ }
+
+ public FeatureFlag createFlag(FeatureFlag flag) {
+ if (repository.existsByFlagKey(flag.getFlagKey().toUpperCase())) {
+ 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.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/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java
index 199ca3611..85eb9432e 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,8 @@ 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;
import org.springframework.validation.BindingResult;
@@ -52,8 +54,11 @@ class OwnerController {
private final OwnerRepository owners;
- public OwnerController(OwnerRepository owners) {
+ private final FeatureFlagService featureFlagService;
+
+ public OwnerController(OwnerRepository owners, FeatureFlagService featureFlagService) {
this.owners = owners;
+ this.featureFlagService = featureFlagService;
}
@InitBinder
@@ -87,13 +92,21 @@ 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) {
@@ -170,6 +183,15 @@ 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);
+
+
+ // displaying new visit button based on feature toggle
+ boolean addVisitEnabled = featureFlagService.isFeatureEnabled("ADD_VISIT", "addVisitEnabled");
+ mav.addObject("addVisitEnabled", addVisitEnabled);
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..73fe03536 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,8 @@ 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 +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}")
@PostMapping("/pets/new")
public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result,
RedirectAttributes redirectAttributes) {
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..59be0f3e3 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,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}")
@GetMapping("/owners/{ownerId}/pets/{petId}/visits/new")
public String initNewVisitForm() {
return "pets/createOrUpdateVisitForm";
@@ -88,6 +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}")
@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 3f1dcf8ea..b132c6d33 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', '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, '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
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 @@
-