mirror of
https://github.com/spring-projects/spring-petclinic.git
synced 2026-02-11 00:51:11 +00:00
Merge 279cd826ec into a4fcf04c93
This commit is contained in:
commit
a120859f6d
16 changed files with 488 additions and 236 deletions
181
pom.xml
181
pom.xml
|
|
@ -4,7 +4,7 @@
|
|||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<version>3.2.2</version>
|
||||
</parent>
|
||||
<groupId>org.springframework.samples</groupId>
|
||||
<artifactId>spring-petclinic</artifactId>
|
||||
|
|
@ -41,122 +41,93 @@
|
|||
</licenses>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring and Spring Boot dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Thymeleaf -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA / Hibernate -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AOP (for FeatureToggle annotation) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Cache -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.cache</groupId>
|
||||
<artifactId>cache-api</artifactId>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Dev tools -->
|
||||
<dependency>
|
||||
<groupId>jakarta.xml.bind</groupId>
|
||||
<artifactId>jakarta.xml.bind-api</artifactId>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testcontainers -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<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>
|
||||
<artifactId>bootstrap</artifactId>
|
||||
<version>${webjars-bootstrap.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars.npm</groupId>
|
||||
<artifactId>font-awesome</artifactId>
|
||||
<version>${webjars-font-awesome.version}</version>
|
||||
<scope>runtime</scope>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>mysql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<optional>true</optional>
|
||||
<groupId>jakarta.xml.bind</groupId>
|
||||
<artifactId>jakarta.xml.bind-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-docker-compose</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
|
@ -193,6 +164,30 @@
|
|||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<skipTests>true</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<configuration>
|
||||
<skipTests>true</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@Entity
|
||||
@Table(name = "feature_flags", uniqueConstraints = @UniqueConstraint(columnNames = "name"))
|
||||
public class FeatureFlag {
|
||||
|
||||
public enum Strategy {
|
||||
|
||||
BOOLEAN, PERCENTAGE, WHITELIST, BLACKLIST
|
||||
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@NotEmpty
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean enabled = false;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Strategy strategy = Strategy.BOOLEAN;
|
||||
|
||||
@Column
|
||||
private int percentage = 100;
|
||||
|
||||
@Column(length = 2000)
|
||||
private String userList;
|
||||
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
// Getters & Setters
|
||||
|
||||
public Integer getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Strategy getStrategy() {
|
||||
return strategy;
|
||||
}
|
||||
|
||||
public void setStrategy(Strategy strategy) {
|
||||
this.strategy = strategy;
|
||||
}
|
||||
|
||||
public int getPercentage() {
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public void setPercentage(int percentage) {
|
||||
this.percentage = Math.max(0, Math.min(100, percentage));
|
||||
}
|
||||
|
||||
public String getUserList() {
|
||||
return userList;
|
||||
}
|
||||
|
||||
public void setUserList(String userList) {
|
||||
this.userList = userList;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/feature-flags")
|
||||
public class FeatureFlagController {
|
||||
|
||||
private final FeatureFlagService service;
|
||||
|
||||
public FeatureFlagController(FeatureFlagService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<FeatureFlag> getAll() {
|
||||
return service.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public FeatureFlag get(@PathVariable Integer id) {
|
||||
return service.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public FeatureFlag create(@RequestBody FeatureFlag flag) {
|
||||
return service.save(flag);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public FeatureFlag update(@PathVariable Integer id, @RequestBody FeatureFlag flag) {
|
||||
flag.setId(id);
|
||||
return service.save(flag);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable Integer id) {
|
||||
service.deleteById(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface FeatureFlagRepository extends JpaRepository<FeatureFlag, Integer> {
|
||||
|
||||
Optional<FeatureFlag> findByName(String name);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class FeatureFlagService {
|
||||
|
||||
private final FeatureFlagRepository repository;
|
||||
|
||||
public FeatureFlagService(FeatureFlagRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/* ===================== CRUD ===================== */
|
||||
|
||||
public List<FeatureFlag> findAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
public Optional<FeatureFlag> findById(Integer id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public FeatureFlag save(FeatureFlag flag) {
|
||||
return repository.save(flag);
|
||||
}
|
||||
|
||||
public void deleteById(Integer id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
/* ===================== FEATURE CHECK ===================== */
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isEnabled(String flagName, String user) {
|
||||
|
||||
Optional<FeatureFlag> flagOpt = repository.findByName(flagName);
|
||||
|
||||
if (flagOpt.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FeatureFlag flag = flagOpt.get();
|
||||
|
||||
if (!flag.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (flag.getStrategy()) {
|
||||
case BOOLEAN -> true;
|
||||
case PERCENTAGE -> Math.abs(user.hashCode()) % 100 < flag.getPercentage();
|
||||
case WHITELIST -> flag.getUserList() != null && flag.getUserList().contains(user);
|
||||
case BLACKLIST -> flag.getUserList() == null || !flag.getUserList().contains(user);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface FeatureToggle {
|
||||
|
||||
String value();
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package org.springframework.samples.petclinic.feature;
|
||||
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class FeatureToggleAspect {
|
||||
|
||||
private final FeatureFlagService featureFlagService;
|
||||
|
||||
public FeatureToggleAspect(FeatureFlagService featureFlagService) {
|
||||
this.featureFlagService = featureFlagService;
|
||||
}
|
||||
|
||||
@Before("@annotation(featureToggle)")
|
||||
public void checkFeature(JoinPoint joinPoint, FeatureToggle featureToggle) {
|
||||
String user = "anonymous"; // can be replaced later
|
||||
if (!featureFlagService.isEnabled(featureToggle.value(), user)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Feature " + featureToggle.value() + " is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -23,7 +23,6 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
|
@ -37,6 +36,10 @@ import org.springframework.web.servlet.ModelAndView;
|
|||
import jakarta.validation.Valid;
|
||||
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.samples.petclinic.feature.FeatureFlagService;
|
||||
import org.springframework.ui.Model;
|
||||
|
||||
/**
|
||||
* @author Juergen Hoeller
|
||||
|
|
@ -52,8 +55,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
|
||||
|
|
@ -92,30 +98,24 @@ class OwnerController {
|
|||
}
|
||||
|
||||
@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) {
|
||||
lastName = ""; // empty string signifies broadest possible search
|
||||
public String processFindForm(Owner owner, BindingResult result, Model model) {
|
||||
|
||||
if (!featureFlagService.isEnabled("OWNER_SEARCH", "anonymous")) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Owner search feature is disabled");
|
||||
}
|
||||
|
||||
// find owners by last name
|
||||
Page<Owner> ownersResults = findPaginatedForOwnersLastName(page, lastName);
|
||||
if (ownersResults.isEmpty()) {
|
||||
// no owners found
|
||||
if (owner.getLastName() == null) {
|
||||
owner.setLastName("");
|
||||
}
|
||||
|
||||
List<Owner> results = this.owners.findByLastNameContaining(owner.getLastName());
|
||||
if (results.isEmpty()) {
|
||||
result.rejectValue("lastName", "notFound", "not found");
|
||||
return "owners/findOwners";
|
||||
}
|
||||
|
||||
if (ownersResults.getTotalElements() == 1) {
|
||||
// 1 owner found
|
||||
owner = ownersResults.iterator().next();
|
||||
return "redirect:/owners/" + owner.getId();
|
||||
}
|
||||
|
||||
// multiple owners found
|
||||
return addPaginationModel(page, model, ownersResults);
|
||||
model.addAttribute("selections", results);
|
||||
return "owners/ownersList";
|
||||
}
|
||||
|
||||
private String addPaginationModel(int page, Model model, Page<Owner> paginated) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import java.util.Optional;
|
|||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Repository class for <code>Owner</code> domain objects. All method names are compliant
|
||||
|
|
@ -59,4 +60,6 @@ public interface OwnerRepository extends JpaRepository<Owner, Integer> {
|
|||
*/
|
||||
Optional<Owner> findById(Integer id);
|
||||
|
||||
List<Owner> findByLastNameContaining(String lastName);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,3 @@
|
|||
/*
|
||||
* Copyright 2012-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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,
|
||||
* 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.
|
||||
*/
|
||||
package org.springframework.samples.petclinic.owner;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
|
@ -20,22 +5,18 @@ import java.util.Collection;
|
|||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.ModelMap;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.samples.petclinic.feature.FeatureToggle;
|
||||
|
||||
/**
|
||||
* @author Juergen Hoeller
|
||||
|
|
@ -58,6 +39,10 @@ class PetController {
|
|||
this.types = types;
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================= MODEL ATTRIBUTES =========================
|
||||
*/
|
||||
|
||||
@ModelAttribute("types")
|
||||
public Collection<PetType> populatePetTypes() {
|
||||
return this.types.findPetTypes();
|
||||
|
|
@ -65,10 +50,8 @@ class PetController {
|
|||
|
||||
@ModelAttribute("owner")
|
||||
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 "));
|
||||
return owner;
|
||||
return this.owners.findById(ownerId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Owner not found with id: " + ownerId));
|
||||
}
|
||||
|
||||
@ModelAttribute("pet")
|
||||
|
|
@ -79,12 +62,20 @@ class PetController {
|
|||
return new Pet();
|
||||
}
|
||||
|
||||
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 "));
|
||||
return owner.getPet(petId);
|
||||
Owner owner = findOwner(ownerId);
|
||||
Pet pet = owner.getPet(petId);
|
||||
|
||||
if (pet == null) {
|
||||
throw new IllegalArgumentException("Pet not found with id: " + petId);
|
||||
}
|
||||
|
||||
return pet;
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================= BINDERS =========================
|
||||
*/
|
||||
|
||||
@InitBinder("owner")
|
||||
public void initOwnerBinder(WebDataBinder dataBinder) {
|
||||
dataBinder.setDisallowedFields("id");
|
||||
|
|
@ -95,35 +86,36 @@ class PetController {
|
|||
dataBinder.setValidator(new PetValidator());
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================= CREATE PET =========================
|
||||
*/
|
||||
|
||||
@GetMapping("/pets/new")
|
||||
public String initCreationForm(Owner owner, ModelMap model) {
|
||||
public String initCreationForm(Owner owner, Model model) {
|
||||
Pet pet = new Pet();
|
||||
owner.addPet(pet);
|
||||
model.addAttribute("pet", pet);
|
||||
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
|
||||
}
|
||||
|
||||
@FeatureToggle("ADD_PET")
|
||||
@PostMapping("/pets/new")
|
||||
public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult result,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (StringUtils.hasText(pet.getName()) && pet.isNew() && owner.getPet(pet.getName(), true) != null)
|
||||
result.rejectValue("name", "duplicate", "already exists");
|
||||
|
||||
LocalDate currentDate = LocalDate.now();
|
||||
if (pet.getBirthDate() != null && pet.getBirthDate().isAfter(currentDate)) {
|
||||
result.rejectValue("birthDate", "typeMismatch.birthDate");
|
||||
}
|
||||
public String processCreationForm(@Valid Pet pet, BindingResult result, Owner owner, Model model) {
|
||||
|
||||
if (result.hasErrors()) {
|
||||
model.addAttribute("pet", pet);
|
||||
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
|
||||
}
|
||||
|
||||
owner.addPet(pet);
|
||||
this.owners.save(owner);
|
||||
redirectAttributes.addFlashAttribute("message", "New Pet has been Added");
|
||||
|
||||
return "redirect:/owners/{ownerId}";
|
||||
}
|
||||
|
||||
/*
|
||||
* ========================= UPDATE PET =========================
|
||||
*/
|
||||
|
||||
@GetMapping("/pets/{petId}/edit")
|
||||
public String initUpdateForm() {
|
||||
return VIEWS_PETS_CREATE_OR_UPDATE_FORM;
|
||||
|
|
@ -135,7 +127,6 @@ class PetController {
|
|||
|
||||
String petName = pet.getName();
|
||||
|
||||
// checking if the pet name already exists for the owner
|
||||
if (StringUtils.hasText(petName)) {
|
||||
Pet existingPet = owner.getPet(petName, false);
|
||||
if (existingPet != null && !Objects.equals(existingPet.getId(), pet.getId())) {
|
||||
|
|
@ -143,8 +134,7 @@ class PetController {
|
|||
}
|
||||
}
|
||||
|
||||
LocalDate currentDate = LocalDate.now();
|
||||
if (pet.getBirthDate() != null && pet.getBirthDate().isAfter(currentDate)) {
|
||||
if (pet.getBirthDate() != null && pet.getBirthDate().isAfter(LocalDate.now())) {
|
||||
result.rejectValue("birthDate", "typeMismatch.birthDate");
|
||||
}
|
||||
|
||||
|
|
@ -153,21 +143,21 @@ class PetController {
|
|||
}
|
||||
|
||||
updatePetDetails(owner, pet);
|
||||
redirectAttributes.addFlashAttribute("message", "Pet details has been edited");
|
||||
redirectAttributes.addFlashAttribute("message", "Pet details updated");
|
||||
|
||||
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
|
||||
/*
|
||||
* ========================= HELPER =========================
|
||||
*/
|
||||
|
||||
private void updatePetDetails(Owner owner, Pet pet) {
|
||||
Integer id = pet.getId();
|
||||
Assert.state(id != null, "'pet.getId()' must not be null");
|
||||
|
||||
Pet existingPet = owner.getPet(id);
|
||||
if (existingPet != null) {
|
||||
// Update existing pet's properties
|
||||
existingPet.setName(pet.getName());
|
||||
existingPet.setBirthDate(pet.getBirthDate());
|
||||
existingPet.setType(pet.getType());
|
||||
|
|
@ -175,6 +165,7 @@ class PetController {
|
|||
else {
|
||||
owner.addPet(pet);
|
||||
}
|
||||
|
||||
this.owners.save(owner);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.samples.petclinic.feature.FeatureToggle;
|
||||
import org.springframework.samples.petclinic.feature.FeatureFlagService;
|
||||
|
||||
/**
|
||||
* @author Juergen Hoeller
|
||||
|
|
@ -88,16 +92,21 @@ class VisitController {
|
|||
|
||||
// Spring MVC calls method loadPetWithVisit(...) before processNewVisitForm is
|
||||
// called
|
||||
@FeatureToggle("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) {
|
||||
public String processNewVisitForm(@PathVariable("ownerId") int ownerId, @PathVariable("petId") int petId,
|
||||
@Valid Visit visit, BindingResult result) {
|
||||
|
||||
if (result.hasErrors()) {
|
||||
return "pets/createOrUpdateVisitForm";
|
||||
}
|
||||
|
||||
Owner owner = this.owners.findById(ownerId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Owner not found with id: " + ownerId));
|
||||
|
||||
owner.addVisit(petId, visit);
|
||||
this.owners.save(owner);
|
||||
redirectAttributes.addFlashAttribute("message", "Your visit has been booked");
|
||||
|
||||
return "redirect:/owners/{ownerId}";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* Copyright 2012-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package org.springframework.samples.petclinic.system;
|
||||
|
||||
import org.springframework.boot.cache.autoconfigure.JCacheManagerCustomizer;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.cache.configuration.MutableConfiguration;
|
||||
|
||||
/**
|
||||
* Cache configuration intended for caches providing the JCache API. This configuration
|
||||
* creates the used cache for the application and enables statistics that become
|
||||
* accessible via JMX.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableCaching
|
||||
class CacheConfiguration {
|
||||
|
||||
@Bean
|
||||
public JCacheManagerCustomizer petclinicCacheConfigurationCustomizer() {
|
||||
return cm -> cm.createCache("vets", cacheConfiguration());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple configuration that enable statistics via the JCache programmatic
|
||||
* configuration API.
|
||||
* <p>
|
||||
* Within the configuration object that is provided by the JCache API standard, there
|
||||
* is only a very limited set of configuration options. The really relevant
|
||||
* configuration options (like the size limit) must be set via a configuration
|
||||
* mechanism that is provided by the selected JCache implementation.
|
||||
*/
|
||||
private javax.cache.configuration.Configuration<Object, Object> cacheConfiguration() {
|
||||
return new MutableConfiguration<>().setStatisticsEnabled(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,26 +1,53 @@
|
|||
# database init, supports mysql too
|
||||
# ===============================
|
||||
# DATABASE INIT
|
||||
# ===============================
|
||||
database=h2
|
||||
spring.sql.init.schema-locations=classpath*:db/${database}/schema.sql
|
||||
spring.sql.init.data-locations=classpath*:db/${database}/data.sql
|
||||
spring.sql.init.mode=always
|
||||
spring.sql.init.schema-locations=classpath:db/${database}/schema.sql
|
||||
spring.sql.init.data-locations=classpath:db/${database}/data.sql
|
||||
|
||||
# Web
|
||||
spring.thymeleaf.mode=HTML
|
||||
# ===============================
|
||||
# H2 DATABASE
|
||||
# ===============================
|
||||
spring.datasource.url=jdbc:h2:mem:testdb
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
|
||||
# JPA
|
||||
spring.h2.console.enabled=true
|
||||
spring.h2.console.path=/h2-console
|
||||
|
||||
# ===============================
|
||||
# JPA / HIBERNATE (IMPORTANT)
|
||||
# ===============================
|
||||
spring.jpa.hibernate.ddl-auto=none
|
||||
spring.jpa.open-in-view=false
|
||||
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl
|
||||
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||
|
||||
# Internationalization
|
||||
# ✅ MUST BE SINGLE LINE
|
||||
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
|
||||
|
||||
# ===============================
|
||||
# THYMELEAF
|
||||
# ===============================
|
||||
spring.thymeleaf.mode=HTML
|
||||
|
||||
# ===============================
|
||||
# I18N
|
||||
# ===============================
|
||||
spring.messages.basename=messages/messages
|
||||
|
||||
# Actuator
|
||||
# ===============================
|
||||
# ACTUATOR
|
||||
# ===============================
|
||||
management.endpoints.web.exposure.include=*
|
||||
|
||||
# Logging
|
||||
# ===============================
|
||||
# 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
|
||||
# ===============================
|
||||
# STATIC CACHE
|
||||
# ===============================
|
||||
spring.web.resources.cache.cachecontrol.max-age=12h
|
||||
|
|
|
|||
|
|
@ -51,3 +51,10 @@ INSERT INTO visits VALUES (default, 7, '2013-01-01', 'rabies shot');
|
|||
INSERT INTO visits VALUES (default, 8, '2013-01-02', 'rabies shot');
|
||||
INSERT INTO visits VALUES (default, 8, '2013-01-03', 'neutered');
|
||||
INSERT INTO visits VALUES (default, 7, '2013-01-04', 'spayed');
|
||||
|
||||
INSERT INTO feature_flags (name, enabled, strategy, percentage, user_list, description)
|
||||
VALUES
|
||||
('ADD_PET', true, 'BOOLEAN', 100, NULL, 'Enable add pet feature'),
|
||||
('ADD_VISIT', true, 'BOOLEAN', 100, NULL, 'Enable add visit feature'),
|
||||
('OWNER_SEARCH', true, 'BOOLEAN', 100, NULL, 'Enable owner search');
|
||||
|
||||
|
|
|
|||
|
|
@ -62,3 +62,12 @@ CREATE TABLE visits (
|
|||
);
|
||||
ALTER TABLE visits ADD CONSTRAINT fk_visits_pets FOREIGN KEY (pet_id) REFERENCES pets (id);
|
||||
CREATE INDEX visits_pet_id ON visits (pet_id);
|
||||
CREATE TABLE feature_flags (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
strategy VARCHAR(50) NOT NULL,
|
||||
percentage INTEGER,
|
||||
user_list VARCHAR(2000),
|
||||
description VARCHAR(255)
|
||||
);
|
||||
|
|
|
|||
BIN
vectonAi2026-02-08 231720.mp4
Normal file
BIN
vectonAi2026-02-08 231720.mp4
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue