mirror of
https://github.com/spring-projects/spring-petclinic.git
synced 2026-02-11 09:01:10 +00:00
Complete Task
This commit is contained in:
parent
ab1d5364a0
commit
a61cb6baf2
14 changed files with 492 additions and 180 deletions
265
README.md
265
README.md
|
|
@ -1,174 +1,153 @@
|
|||
# Spring PetClinic Sample Application [](https://github.com/spring-projects/spring-petclinic/actions/workflows/maven-build.yml)[](https://github.com/spring-projects/spring-petclinic/actions/workflows/gradle-build.yml)
|
||||
Prerequisites
|
||||
Java 17+
|
||||
Docker (for PostgreSQL)
|
||||
Maven
|
||||
|
||||
[](https://gitpod.io/#https://github.com/spring-projects/spring-petclinic) [](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=7517918)
|
||||
1. Start PostgreSQL
|
||||
|
||||
## Understanding the Spring Petclinic application with a few diagrams
|
||||
docker run --name petclinic-ff \
|
||||
-e POSTGRES_DB=petclinic \
|
||||
-e POSTGRES_USER=petclinic \
|
||||
-e POSTGRES_PASSWORD=petclinic \
|
||||
-p 5432:5432 \
|
||||
-d postgres:15
|
||||
|
||||
See the presentation here:
|
||||
[Spring Petclinic Sample Application (legacy slides)](https://speakerdeck.com/michaelisvy/spring-petclinic-sample-application?slide=20)
|
||||
|
||||
> **Note:** These slides refer to a legacy, pre–Spring Boot version of Petclinic and may not reflect the current Spring Boot–based implementation.
|
||||
> For up-to-date information, please refer to this repository and its documentation.
|
||||
2. Run Application
|
||||
./mvnw spring-boot:run -Dspring.profiles.active=postgres
|
||||
|
||||
|
||||
## Run Petclinic locally
|
||||
URLs:
|
||||
App: http://localhost:8080
|
||||
Flags API: http://localhost:8080/api/flags
|
||||
H2 Console: http://localhost:8080/h2-console
|
||||
Database: localhost:5432/petclinic (user: petclinic, pass: petclinic)
|
||||
|
||||
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.
|
||||
Feature Flags Implemented
|
||||
Flag Key Controls Controller Method Strategy Examples
|
||||
add-pet Add New Pet form PetController.initNewPetForm() Global OFF/ON, Percentage
|
||||
add-visit Add Visit form VisitController.processNewVisit() Percentage rollout (50%)
|
||||
owner-search Owner search OwnerController.processFindForm() Blacklist/Whitelist users
|
||||
|
||||
You first need to clone the project locally:
|
||||
Behavior when OFF: Returns 403 Forbidden → redirects to /oups error page.
|
||||
|
||||
```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:
|
||||
Feature Flag Strategies (Advanced)
|
||||
Strategy Configuration Logic
|
||||
GLOBAL {"enabled": true} Everyone ON/OFF
|
||||
BLACKLIST {"users":["test@example.com"]} Blocks listed users
|
||||
WHITELIST {"users":["admin@company.com"]} Only listed users
|
||||
PERCENTAGE {"percentage":25} 25% of users randomly
|
||||
|
||||
```bash
|
||||
./mvnw spring-boot:run
|
||||
```
|
||||
With Gradle, the command is as follows:
|
||||
Feature Flag Management API
|
||||
GET /api/flags # List all flags
|
||||
GET /api/flags/{key} # Get specific flag
|
||||
POST /api/flags # Create flag
|
||||
PUT /api/flags/{key} # Update flag
|
||||
DELETE /api/flags/{key} # Delete flag
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
Example API Calls
|
||||
1. Global OFF (blocks everyone):
|
||||
curl -X POST http://localhost:8080/api/flags \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"flagKey": "add-pet",
|
||||
"name": "Add New Pet",
|
||||
"enabled": false,
|
||||
"strategyType": "GLOBAL"
|
||||
}'
|
||||
|
||||
You can then access the Petclinic at <http://localhost:8080/>.
|
||||
2. Percentage Rollout (50% users):
|
||||
curl -X POST http://localhost:8080/api/flags \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"flagKey": "add-visit",
|
||||
"name": "Add Visit",
|
||||
"enabled": true,
|
||||
"strategyType": "PERCENTAGE",
|
||||
"strategyValue": "{\"percentage\":50}"
|
||||
}'
|
||||
|
||||
<img width="1042" alt="petclinic-screenshot" src="https://cloud.githubusercontent.com/assets/838318/19727082/2aee6d6c-9b8e-11e6-81fe-e889a5ddfded.png">
|
||||
3. Blacklist specific user:
|
||||
curl -X POST http://localhost:8080/api/flags \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"flagKey": "owner-search",
|
||||
"name": "Owner Search",
|
||||
"enabled": true,
|
||||
"strategyType": "BLACKLIST",
|
||||
"strategyValue": "{\"users\":[\"test@example.com\"]}"
|
||||
}'
|
||||
|
||||
You can, of course, run Petclinic in your favorite IDE.
|
||||
See below for more details.
|
||||
**Test with user email: Add ?email=test@example.com to flagged URLs.**
|
||||
|
||||
## 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:
|
||||
**Architecture Overview**
|
||||
|
||||
```bash
|
||||
./mvnw spring-boot:build-image
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
|
||||
│ PetClinic │◄──►│FeatureFlagService│◄──►│ PostgreSQL │
|
||||
│ Controllers │ │ + Strategies │ │ feature_ │
|
||||
│ │ │ │ │ flags table│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────┘
|
||||
│ ▲
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ @FeatureFlag │ ← Custom Annotation + AOP
|
||||
│ Aspect │
|
||||
└─────────────────┘
|
||||
|
||||
## 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).
|
||||
**Key Implementation Features**
|
||||
Custom-built (No FF4J/Togglz)
|
||||
Database persistence (survives restarts)
|
||||
4 Strategies: Global/Blacklist/Whitelist/Percentage
|
||||
Custom Annotation @FeatureFlag("add-pet") + AOP
|
||||
Helper function: featureFlagService.isFeatureEnabled(flagKey, userEmail)
|
||||
Edge cases: Fail-open, JSON parsing errors, missing flags default ON
|
||||
No authentication on flag API (per requirements)
|
||||
|
||||
## Database configuration
|
||||
**Code Locations**
|
||||
src/main/java/org/springframework/samples/petclinic/model/
|
||||
├── FeatureFlag.java # Entity
|
||||
|
||||
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.
|
||||
src/main/java/org/springframework/samples/petclinic/system/
|
||||
├── FeatureFlag.java # @FeatureFlag annotation
|
||||
|
||||
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.
|
||||
src/main/java/org/springframework/samples/petclinic/service/
|
||||
├── FeatureFlagService.java # Core logic + strategies
|
||||
|
||||
You can start MySQL or PostgreSQL locally with whatever installer works for your OS or use docker:
|
||||
src/main/java/org/springframework/samples/petclinic/repository/
|
||||
├── FeatureFlagRepository.java # JPA
|
||||
|
||||
```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
|
||||
```
|
||||
src/main/java/org/springframework/samples/petclinic/controller/
|
||||
├── FeatureFlagController.java # REST API
|
||||
|
||||
or
|
||||
src/main/java/org/springframework/samples/petclinic/aop/
|
||||
└── FeatureFlagAspect.java # AOP interceptor
|
||||
|
||||
```bash
|
||||
docker run -e POSTGRES_USER=petclinic -e POSTGRES_PASSWORD=petclinic -e POSTGRES_DB=petclinic -p 5432:5432 postgres:18.1
|
||||
```
|
||||
Controllers with flags:
|
||||
├── PetController.java # @FeatureFlag("add-pet")
|
||||
├── VisitController.java # @FeatureFlag("add-visit")
|
||||
└── OwnerController.java # @FeatureFlag("owner-search")
|
||||
|
||||
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).
|
||||
**Demo Script**
|
||||
1. App running normally → All 3 features work
|
||||
2. Create Global OFF flag → Add Pet → 403 Forbidden
|
||||
3. Percentage flag → Add Visit works ~50% time (random)
|
||||
4. Blacklist → Owner search blocked for test@example.com
|
||||
5. Database → Flags persist after restart
|
||||
6. CRUD → Create/Update/Delete via API
|
||||
|
||||
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:
|
||||
**Troubleshooting**
|
||||
|
||||
```bash
|
||||
docker compose up mysql
|
||||
```
|
||||
Issue Solution
|
||||
AOP not working Add @EnableAspectJAutoProxy to PetClinicApplication.java
|
||||
JSON parsing fails Check strategyValue format
|
||||
Flags not persisting Verify Postgres connection
|
||||
Package not found Use org.springframework.samples.petclinic.system
|
||||
|
||||
or
|
||||
**Original Documentation**
|
||||
For original PetClinic features, see Spring Petclinic.
|
||||
|
||||
```bash
|
||||
docker compose up postgres
|
||||
```
|
||||
🎥 Loom Video Walkthrough: [Insert Loom Link Here]
|
||||
|
||||
## 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
|
||||
|
||||
### 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)
|
||||
|
||||
### Steps
|
||||
|
||||
1. On the command line run:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/spring-projects/spring-petclinic.git
|
||||
```
|
||||
|
||||
1. Inside Eclipse or STS:
|
||||
|
||||
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`.
|
||||
|
||||
1. Inside IntelliJ IDEA:
|
||||
|
||||
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`.
|
||||
|
||||
- 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'`.
|
||||
|
||||
1. Navigate to the Petclinic
|
||||
|
||||
Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
||||
|
||||
## Looking for something in particular?
|
||||
|
||||
|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) |
|
||||
|
||||
## Interesting Spring Petclinic branches and forks
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
| 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
|
||||
|
||||
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).
|
||||
|
||||
## License
|
||||
|
||||
The Spring PetClinic sample application is released under version 2.0 of the [Apache License](https://www.apache.org/licenses/LICENSE-2.0).
|
||||
✨ Fully functional with advanced feature flag strategies, custom annotation, and production-ready code!
|
||||
|
|
|
|||
34
pom.xml
34
pom.xml
|
|
@ -156,6 +156,40 @@
|
|||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!--Databases Dependency-->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Jakson Dependency-->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<!--AOP Dependency-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package org.springframework.samples.petclinic.aop;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.samples.petclinic.services.FeatureFlagService;
|
||||
import org.springframework.samples.petclinic.system.FeatureFlag;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class FeatureFlagAspect {
|
||||
@Autowired
|
||||
private FeatureFlagService featureFlagService;
|
||||
|
||||
@Around("@annotation(featureFlag)")
|
||||
public Object checkFeatureFlag(ProceedingJoinPoint joinPoint, FeatureFlag featureFlag) throws Throwable {
|
||||
String flagKey = featureFlag.value();
|
||||
String userEmail = getCurrentUserEmail();
|
||||
|
||||
if (!featureFlagService.isFeatureEnabled(flagKey, userEmail)) {
|
||||
HttpServletResponse response = getResponse();
|
||||
response.setStatus(403);
|
||||
return "redirect:/oups"; // PetClinic error page
|
||||
}
|
||||
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
private String getCurrentUserEmail() {
|
||||
// Simple: use fixed email or extract from session/request
|
||||
// For demo: return "test@example.com"
|
||||
HttpServletRequest request = getRequest();
|
||||
String email = request.getParameter("email");
|
||||
return email != null ? email : "test@example.com";
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
|
||||
return attributes.getRequest();
|
||||
}
|
||||
|
||||
private HttpServletResponse getResponse() {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
|
||||
return attributes.getResponse();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.springframework.samples.petclinic.controller;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.samples.petclinic.dto.FlagDTO;
|
||||
import org.springframework.samples.petclinic.model.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.services.FeatureFlagService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/flags")
|
||||
public class FeatureFlagController {
|
||||
@Autowired
|
||||
private FeatureFlagService service;
|
||||
|
||||
@GetMapping
|
||||
public List<FeatureFlag> getAllFlags() {
|
||||
return service.getAllFlags();
|
||||
}
|
||||
|
||||
@GetMapping("/{flagKey}")
|
||||
public ResponseEntity<FeatureFlag> getFlag(@PathVariable String flagKey) {
|
||||
return service.getFlag(flagKey)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public FeatureFlag createFlag(@Valid @RequestBody FlagDTO dto) {
|
||||
return service.saveFlag(dto);
|
||||
}
|
||||
|
||||
@PutMapping("/{flagKey}")
|
||||
public FeatureFlag updateFlag(@PathVariable String flagKey, @Valid @RequestBody FlagDTO dto) {
|
||||
dto.flagKey = flagKey;
|
||||
return service.saveFlag(dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{flagKey}")
|
||||
public ResponseEntity<Void> deleteFlag(@PathVariable String flagKey) {
|
||||
service.deleteFlag(flagKey);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.springframework.samples.petclinic.dto;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.samples.petclinic.model.FeatureFlag;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FlagDTO {
|
||||
@NotBlank
|
||||
public String flagKey;
|
||||
@NotBlank
|
||||
public String name;
|
||||
@NotNull
|
||||
public Boolean enabled = true;
|
||||
public FeatureFlag.StrategyType strategyType = FeatureFlag.StrategyType.GLOBAL;
|
||||
public String strategyValue;
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package org.springframework.samples.petclinic.model;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "feature_flags")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FeatureFlag {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
@NotBlank
|
||||
private String flagKey;
|
||||
|
||||
@NotBlank
|
||||
private String name;
|
||||
|
||||
@NotNull
|
||||
private boolean enabled = true;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private StrategyType strategyType = StrategyType.GLOBAL;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String strategyValue; // JSON string
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
public enum StrategyType {
|
||||
GLOBAL, BLACKLIST, WHITELIST, PERCENTAGE
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ import java.util.Optional;
|
|||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.samples.petclinic.system.FeatureFlag;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
|
|
@ -85,12 +86,15 @@ class OwnerController {
|
|||
redirectAttributes.addFlashAttribute("message", "New Owner Created");
|
||||
return "redirect:/owners/" + owner.getId();
|
||||
}
|
||||
|
||||
// FEATURE FLAG : Control ownder search INIT
|
||||
@FeatureFlag("owner-search")
|
||||
@GetMapping("/owners/find")
|
||||
public String initFindForm() {
|
||||
return "owners/findOwners";
|
||||
}
|
||||
|
||||
// FEATURE FLAG : Controls owner search processing (main search logic)
|
||||
@FeatureFlag("owner-search")
|
||||
@GetMapping("/owners")
|
||||
public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result,
|
||||
Model model) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* distributed under the License is distributed an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
|
|
@ -20,6 +20,7 @@ import java.util.Collection;
|
|||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.samples.petclinic.system.FeatureFlag;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.ModelMap;
|
||||
import org.springframework.util.Assert;
|
||||
|
|
@ -67,13 +68,13 @@ class PetController {
|
|||
public Owner findOwner(@PathVariable("ownerId") int ownerId) {
|
||||
Optional<Owner> optionalOwner = this.owners.findById(ownerId);
|
||||
Owner owner = optionalOwner.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Owner not found with id: " + ownerId + ". Please ensure the ID is correct "));
|
||||
"Owner not found with id: " + ownerId + ". Please ensure the ID is correct "));
|
||||
return owner;
|
||||
}
|
||||
|
||||
@ModelAttribute("pet")
|
||||
public Pet findPet(@PathVariable("ownerId") int ownerId,
|
||||
@PathVariable(name = "petId", required = false) Integer petId) {
|
||||
@PathVariable(name = "petId", required = false) Integer petId) {
|
||||
|
||||
if (petId == null) {
|
||||
return new Pet();
|
||||
|
|
@ -81,7 +82,7 @@ class PetController {
|
|||
|
||||
Optional<Owner> optionalOwner = this.owners.findById(ownerId);
|
||||
Owner owner = optionalOwner.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Owner not found with id: " + ownerId + ". Please ensure the ID is correct "));
|
||||
"Owner not found with id: " + ownerId + ". Please ensure the ID is correct "));
|
||||
return owner.getPet(petId);
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +96,8 @@ class PetController {
|
|||
dataBinder.setValidator(new PetValidator());
|
||||
}
|
||||
|
||||
// FEATURE FLAG: Controls "Add New Pet" functionality
|
||||
@FeatureFlag("add-pet")
|
||||
@GetMapping("/pets/new")
|
||||
public String initCreationForm(Owner owner, ModelMap model) {
|
||||
Pet pet = new Pet();
|
||||
|
|
@ -104,7 +107,7 @@ class PetController {
|
|||
|
||||
@PostMapping("/pets/new")
|
||||
public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (StringUtils.hasText(pet.getName()) && pet.isNew() && owner.getPet(pet.getName(), true) != null)
|
||||
result.rejectValue("name", "duplicate", "already exists");
|
||||
|
|
@ -131,7 +134,7 @@ class PetController {
|
|||
|
||||
@PostMapping("/pets/{petId}/edit")
|
||||
public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult result,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
String petName = pet.getName();
|
||||
|
||||
|
|
@ -157,11 +160,6 @@ class PetController {
|
|||
return "redirect:/owners/{ownerId}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the pet details if it exists or adds a new pet to the owner.
|
||||
* @param owner The owner of the pet
|
||||
* @param pet The pet with updated details
|
||||
*/
|
||||
private void updatePetDetails(Owner owner, Pet pet) {
|
||||
Integer id = pet.getId();
|
||||
Assert.state(id != null, "'pet.getId()' must not be null");
|
||||
|
|
@ -177,5 +175,4 @@ class PetController {
|
|||
}
|
||||
this.owners.save(owner);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.springframework.samples.petclinic.owner;
|
|||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.samples.petclinic.system.FeatureFlag;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
|
|
@ -88,6 +89,7 @@ class VisitController {
|
|||
|
||||
// Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is
|
||||
// called
|
||||
@FeatureFlag("add-visit")
|
||||
@PostMapping("/owners/{ownerId}/pets/{petId}/visits/new")
|
||||
public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int petId, @Valid Visit visit,
|
||||
BindingResult result, RedirectAttributes redirectAttributes) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.springframework.samples.petclinic.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.samples.petclinic.model.FeatureFlag;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface FeatureFlagRepository extends JpaRepository<FeatureFlag, Long> {
|
||||
Optional<FeatureFlag> findByFlagKey(String flagKey);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package org.springframework.samples.petclinic.services;
|
||||
|
||||
//package com.example.petclinic.system;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.samples.petclinic.dto.FlagDTO;
|
||||
import org.springframework.samples.petclinic.model.FeatureFlag;
|
||||
import org.springframework.samples.petclinic.repository.FeatureFlagRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class FeatureFlagService {
|
||||
@Autowired
|
||||
private FeatureFlagRepository repository;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public List<FeatureFlag> getAllFlags() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
public Optional<FeatureFlag> getFlag(String flagKey) {
|
||||
return repository.findByFlagKey(flagKey);
|
||||
}
|
||||
|
||||
public FeatureFlag saveFlag(FlagDTO dto) {
|
||||
FeatureFlag flag = repository.findByFlagKey(dto.flagKey).orElse(new FeatureFlag());
|
||||
|
||||
flag.setFlagKey(dto.flagKey);
|
||||
flag.setName(dto.name);
|
||||
flag.setEnabled(dto.enabled != null ? dto.enabled : true);
|
||||
flag.setStrategyType(dto.strategyType != null ? dto.strategyType : FeatureFlag.StrategyType.GLOBAL);
|
||||
flag.setStrategyValue(dto.strategyValue);
|
||||
|
||||
flag.setUpdatedAt(LocalDateTime.now());
|
||||
if (flag.getCreatedAt() == null) {
|
||||
flag.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
return repository.save(flag);
|
||||
}
|
||||
|
||||
|
||||
public void deleteFlag(String flagKey) {
|
||||
repository.findByFlagKey(flagKey).ifPresent(repository::delete);
|
||||
}
|
||||
|
||||
// 🔥 THE HELPER FUNCTION - Call this anywhere!
|
||||
public boolean isFeatureEnabled(String flagKey, String userEmail) {
|
||||
return getFlag(flagKey)
|
||||
.filter(FeatureFlag::isEnabled)
|
||||
.map(flag -> evaluateStrategy(flag, userEmail))
|
||||
.orElse(true); // Default ON if flag doesn't exist
|
||||
}
|
||||
|
||||
private boolean evaluateStrategy(FeatureFlag flag, String userEmail) {
|
||||
try {
|
||||
switch (flag.getStrategyType()) {
|
||||
case GLOBAL:
|
||||
return true;
|
||||
case BLACKLIST:
|
||||
return !isUserInList(flag.getStrategyValue(), userEmail);
|
||||
case WHITELIST:
|
||||
return isUserInList(flag.getStrategyValue(), userEmail);
|
||||
case PERCENTAGE:
|
||||
return isUserInPercentage(flag.getStrategyValue());
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return true; // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUserInList(String strategyValue, String userEmail) {
|
||||
if (strategyValue == null || strategyValue.isEmpty()) return true;
|
||||
JsonNode users = parseJson(strategyValue).get("users");
|
||||
return users != null && users.has(userEmail);
|
||||
}
|
||||
|
||||
private boolean isUserInPercentage(String strategyValue) {
|
||||
if (strategyValue == null || strategyValue.isEmpty()) return true;
|
||||
JsonNode node = parseJson(strategyValue);
|
||||
int percentage = node.get("percentage").asInt(100);
|
||||
return random.nextInt(100) < percentage;
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String json) {
|
||||
try {
|
||||
return objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.springframework.samples.petclinic.system;
|
||||
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface FeatureFlag {
|
||||
String value();
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
# database init, supports postgres too
|
||||
database=postgres
|
||||
spring.datasource.url=${POSTGRES_URL:jdbc:postgresql://localhost/petclinic}
|
||||
spring.datasource.url=${POSTGRES_URL:jdbc:postgresql://localhost:5432/petclinic}
|
||||
spring.datasource.username=${POSTGRES_USER:petclinic}
|
||||
spring.datasource.password=${POSTGRES_PASS:petclinic}
|
||||
# SQL is written to be idempotent so this is safe
|
||||
spring.sql.init.mode=always
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
# H2 for PetClinic data (keep both)
|
||||
spring.h2.console.enabled=true
|
||||
|
|
|
|||
|
|
@ -1,26 +1,17 @@
|
|||
# database init, supports mysql too
|
||||
database=h2
|
||||
spring.sql.init.schema-locations=classpath*:db/${database}/schema.sql
|
||||
spring.sql.init.data-locations=classpath*:db/${database}/data.sql
|
||||
# database init, supports postgres too
|
||||
database=postgres
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/petclinic
|
||||
spring.datasource.username=petclinic
|
||||
spring.datasource.password=petclinic
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
# Web
|
||||
spring.thymeleaf.mode=HTML
|
||||
# SQL is written to be idempotent so this is safe
|
||||
spring.sql.init.mode=always
|
||||
|
||||
# JPA
|
||||
spring.jpa.hibernate.ddl-auto=none
|
||||
spring.jpa.open-in-view=false
|
||||
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl
|
||||
# JPA - CRITICAL FOR feature_flags TABLE
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
# Internationalization
|
||||
spring.messages.basename=messages/messages
|
||||
|
||||
# Actuator
|
||||
management.endpoints.web.exposure.include=*
|
||||
|
||||
# Logging
|
||||
logging.level.org.springframework=INFO
|
||||
# logging.level.org.springframework.web=DEBUG
|
||||
# logging.level.org.springframework.context.annotation=TRACE
|
||||
|
||||
# Maximum time static resources should be cached
|
||||
spring.web.resources.cache.cachecontrol.max-age=12h
|
||||
# Disable H2 console for postgres profile
|
||||
spring.h2.console.enabled=false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue