This commit is contained in:
Ramprakash nadar 2026-02-08 16:06:17 +00:00 committed by GitHub
commit 874e316787
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1386 additions and 158 deletions

249
README.md
View file

@ -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, preSpring Boot version of Petclinic and may not reflect the current Spring Bootbased 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 <http://localhost:8080/>.
<img width="1042" alt="petclinic-screenshot" src="https://cloud.githubusercontent.com/assets/838318/19727082/2aee6d6c-9b8e-11e6-81fe-e889a5ddfded.png">
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:<uuid>` 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 <https://editorconfig.org>. 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).

View file

@ -1,6 +1,6 @@
services:
mysql:
image: mysql:9.5
image: mysql:9.6
ports:
- "3306:3306"
environment:

18
pom.xml
View file

@ -1,10 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.1</version>
<version>4.0.2</version>
</parent>
<groupId>org.springframework.samples</groupId>
<artifactId>spring-petclinic</artifactId>
@ -13,7 +15,7 @@
<properties>
<!-- Generic properties -->
<java.version>17</java.version>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- Important for reproducible builds. Update using e.g. ./mvnw versions:set -DnewVersion=... -->
@ -99,8 +101,6 @@
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-lite</artifactId>
<version>${webjars-locator.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.webjars.npm</groupId>
@ -172,7 +172,8 @@
<configuration>
<rules>
<requireJavaVersion>
<message>This build requires at least Java ${java.version}, update your JVM, and run the build again</message>
<message>This build requires at least Java ${java.version}, update your JVM, and
run the build again</message>
<version>${java.version}</version>
</requireJavaVersion>
</rules>
@ -326,7 +327,8 @@
<configuration>
<inputPath>${basedir}/src/main/scss/</inputPath>
<outputPath>${basedir}/src/main/resources/static/resources/css/</outputPath>
<includePath>${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/</includePath>
<includePath>
${project.build.directory}/webjars/META-INF/resources/webjars/bootstrap/${webjars-bootstrap.version}/scss/</includePath>
</configuration>
<executions>
<execution>
@ -408,4 +410,4 @@
</build>
</profile>
</profiles>
</project>
</project>

View file

@ -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");
}
}

View file

@ -0,0 +1,45 @@
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 toggle
*
* 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 "";
}

View file

@ -0,0 +1,118 @@
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
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);
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
}
}

View file

@ -0,0 +1,164 @@
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 /feature-flags Get all feature flags
*/
@GetMapping
public ResponseEntity<List<FeatureFlagResponse>> getAllFlags() {
List<FeatureFlagResponse> flags = featureFlagService.getAllFlags()
.stream()
.map(FeatureFlagResponse::fromEntity)
.collect(Collectors.toList());
return ResponseEntity.ok(flags);
}
/**
* GET /feature-flags/{id} Get a specific feature flag by ID
*/
@GetMapping("/{id}")
public ResponseEntity<FeatureFlagResponse> getFlagById(@PathVariable Long id) {
return featureFlagService.getFlagById(id)
.map(FeatureFlagResponse::fromEntity)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* GET /feature-flags/key/{flagKey} Get a specific feature flag by key
*/
@GetMapping("/key/{flagKey}")
public ResponseEntity<FeatureFlagResponse> getFlagByKey(@PathVariable String flagKey) {
return featureFlagService.getFlagByKey(flagKey)
.map(FeatureFlagResponse::fromEntity)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* POST /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 /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 /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 /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 /feature-flags/check Check if a feature is enabled for a given context
*/
@PostMapping("/check")
public ResponseEntity<FeatureCheckResponse> 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 /feature-flags/check/{flagKey} Check if a feature is enabled (simple check
* without context)
*/
@GetMapping("/check/{flagKey}")
public ResponseEntity<FeatureCheckResponse> 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;
}
}
}

View file

@ -0,0 +1,25 @@
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;
}
}

View file

@ -0,0 +1,41 @@
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;
}
}

View file

@ -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<String> whitelist = new HashSet<>();
private Set<String> 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<String> getWhitelist() {
return whitelist;
}
public void setWhitelist(Set<String> whitelist) {
this.whitelist = whitelist;
}
public Set<String> getBlacklist() {
return blacklist;
}
public void setBlacklist(Set<String> blacklist) {
this.blacklist = blacklist;
}
}

View file

@ -0,0 +1,96 @@
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<String> whitelist;
private Set<String> blacklist;
// 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<String> getWhitelist() {
return whitelist;
}
public void setWhitelist(Set<String> whitelist) {
this.whitelist = whitelist;
}
public Set<String> getBlacklist() {
return blacklist;
}
public void setBlacklist(Set<String> 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;
}
}

View file

@ -0,0 +1,126 @@
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<String> whitelist;
private Set<String> 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<String> getWhitelist() {
return whitelist;
}
public void setWhitelist(Set<String> whitelist) {
this.whitelist = whitelist;
}
public Set<String> getBlacklist() {
return blacklist;
}
public void setBlacklist(Set<String> 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;
}
}

View file

@ -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<String> 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<String> 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<String> getWhitelist() {
return whitelist;
}
public void setWhitelist(Set<String> whitelist) {
this.whitelist = whitelist;
}
public Set<String> getBlacklist() {
return blacklist;
}
public void setBlacklist(Set<String> blacklist) {
this.blacklist = blacklist;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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<FeatureFlag, Long> {
Optional<FeatureFlag> findByFlagKey(String flagKey);
boolean existsByFlagKey(String flagKey);
}

View file

@ -0,0 +1,238 @@
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<FeatureFlag> flagOpt = repository.findByFlagKey(flagKey.toUpperCase());
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 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;
}
// 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;
}
}
// 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:
// If we got here, context is not in blacklist and flag is enabled
return true;
case PERCENTAGE:
return evaluatePercentage(flag, context);
case GLOBAL_DISABLE:
return false;
default:
logger.warn("Unknown flag type for '{}': {}", flag.getFlagKey(), flag.getFlagType());
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;
}
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;
}
/**
* 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;
}
// 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;
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<FeatureFlag> getAllFlags() {
return repository.findAll();
}
public Optional<FeatureFlag> getFlagById(Long id) {
return repository.findById(id);
}
public Optional<FeatureFlag> 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());
}
}
}
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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());

View file

@ -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;

View file

@ -20,13 +20,14 @@
</div>
</div>
</div>
<div class="form-group">
<div class="form-group" th:if="${ownerSearchEnabled}">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" th:text="#{findOwner}">Find Owner</button>
</div>
</div>
<a class="btn btn-primary" th:href="@{/owners/new}" th:text="#{addOwner}">Add Owner</a>
</form>

View file

@ -35,7 +35,7 @@
<a th:href="@{__${owner.id}__/edit}" class="btn btn-primary" th:text="#{editOwner}">Edit
Owner</a>
<a th:href="@{__${owner.id}__/pets/new}" class="btn btn-primary" th:text="#{addNewPet}">Add
<a th:if="${addNewPetEnabled}" th:href="@{__${owner.id}__/pets/new}" class="btn btn-primary" th:text="#{addNewPet}">Add
New Pet</a>
<br />
@ -70,7 +70,7 @@
</tr>
<tr>
<td><a th:href="@{__${owner.id}__/pets/__${pet.id}__/edit}" th:text="#{editPet}">Edit Pet</a></td>
<td><a th:href="@{__${owner.id}__/pets/__${pet.id}__/visits/new}" th:text="#{addVisit}">Add Visit</a></td>
<td><a th:if="${addVisitEnabled}" th:href="@{__${owner.id}__/pets/__${pet.id}__/visits/new}" th:text="#{addVisit}">Add Visit</a></td>
</tr>
</table>
</td>