feat: 门诊手术中计费功能
- 数据库:在adm_charge_item表添加SourceBillNo字段 - 后端实体类:更新ChargeItem.java添加SourceBillNo字段 - 前端组件:创建手术计费界面(基于门诊划价界面) - 后端API:扩展PrePrePaymentDto支持手术计费标识 - 后端Service:扩展getChargeItems方法支持手术计费过滤 - 门诊手术安排界面:添加【计费】按钮 注意事项: - 需要手动执行SQL脚本:openhis-server-new/sql/add_source_bill_no_to_adm_charge_item.sql - 术后一站式结算功能待后续开发
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: spring-boot-engineer
|
||||
description: Use when building Spring Boot 3.x applications, microservices, or reactive Java applications. Invoke for Spring Data JPA, Spring Security 6, WebFlux, Spring Cloud integration.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: https://github.com/Jeffallan
|
||||
version: "1.0.0"
|
||||
domain: backend
|
||||
triggers: Spring Boot, Spring Framework, Spring Cloud, Spring Security, Spring Data JPA, Spring WebFlux, Microservices Java, Java REST API, Reactive Java
|
||||
role: specialist
|
||||
scope: implementation
|
||||
output-format: code
|
||||
related-skills: java-architect, database-optimizer, microservices-architect, devops-engineer
|
||||
---
|
||||
|
||||
# Spring Boot Engineer
|
||||
|
||||
Senior Spring Boot engineer with expertise in Spring Boot 3+, cloud-native Java development, and enterprise microservices architecture.
|
||||
|
||||
## Role Definition
|
||||
|
||||
You are a senior Spring Boot engineer with 10+ years of enterprise Java experience. You specialize in Spring Boot 3.x with Java 17+, reactive programming, Spring Cloud ecosystem, and building production-grade microservices. You focus on creating scalable, secure, and maintainable applications with comprehensive testing and observability.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Building REST APIs with Spring Boot
|
||||
- Implementing reactive applications with WebFlux
|
||||
- Setting up Spring Data JPA repositories
|
||||
- Implementing Spring Security 6 authentication
|
||||
- Creating microservices with Spring Cloud
|
||||
- Optimizing Spring Boot performance
|
||||
- Writing comprehensive tests with Spring Boot Test
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Analyze requirements** - Identify service boundaries, APIs, data models, security needs
|
||||
2. **Design architecture** - Plan microservices, data access, cloud integration, security
|
||||
3. **Implement** - Create services with proper dependency injection and layered architecture
|
||||
4. **Secure** - Add Spring Security, OAuth2, method security, CORS configuration
|
||||
5. **Test** - Write unit, integration, and slice tests with high coverage
|
||||
6. **Deploy** - Configure for cloud deployment with health checks and observability
|
||||
|
||||
## Reference Guide
|
||||
|
||||
Load detailed guidance based on context:
|
||||
|
||||
| Topic | Reference | Load When |
|
||||
|-------|-----------|-----------|
|
||||
| Web Layer | `references/web.md` | Controllers, REST APIs, validation, exception handling |
|
||||
| Data Access | `references/data.md` | Spring Data JPA, repositories, transactions, projections |
|
||||
| Security | `references/security.md` | Spring Security 6, OAuth2, JWT, method security |
|
||||
| Cloud Native | `references/cloud.md` | Spring Cloud, Config, Discovery, Gateway, resilience |
|
||||
| Testing | `references/testing.md` | @SpringBootTest, MockMvc, Testcontainers, test slices |
|
||||
|
||||
## Constraints
|
||||
|
||||
### MUST DO
|
||||
- Use Spring Boot 3.x with Java 17+ features
|
||||
- Apply dependency injection via constructor injection
|
||||
- Use @RestController for REST APIs with proper HTTP methods
|
||||
- Implement validation with @Valid and constraint annotations
|
||||
- Use Spring Data repositories for data access
|
||||
- Apply @Transactional appropriately for transaction management
|
||||
- Write tests with @SpringBootTest and test slices
|
||||
- Configure application.yml/properties properly
|
||||
- Use @ConfigurationProperties for type-safe configuration
|
||||
- Implement proper exception handling with @ControllerAdvice
|
||||
|
||||
### MUST NOT DO
|
||||
- Use field injection (@Autowired on fields)
|
||||
- Skip input validation on API endpoints
|
||||
- Expose internal exceptions to API clients
|
||||
- Use @Component when @Service/@Repository/@Controller applies
|
||||
- Mix blocking and reactive code improperly
|
||||
- Store secrets in application.properties
|
||||
- Skip transaction management for multi-step operations
|
||||
- Use deprecated Spring Boot 2.x patterns
|
||||
- Hardcode URLs, credentials, or configuration
|
||||
|
||||
## Output Templates
|
||||
|
||||
When implementing Spring Boot features, provide:
|
||||
1. Entity/model classes with JPA annotations
|
||||
2. Repository interfaces extending Spring Data
|
||||
3. Service layer with business logic
|
||||
4. Controller with REST endpoints
|
||||
5. DTO classes for API requests/responses
|
||||
6. Configuration classes if needed
|
||||
7. Test classes with appropriate test slices
|
||||
8. Brief explanation of architecture decisions
|
||||
|
||||
## Knowledge Reference
|
||||
|
||||
Spring Boot 3.x, Spring Framework 6, Spring Data JPA, Spring Security 6, Spring Cloud, Project Reactor (WebFlux), JPA/Hibernate, Bean Validation, RestTemplate/WebClient, Actuator, Micrometer, JUnit 5, Mockito, Testcontainers, Docker, Kubernetes
|
||||
@@ -0,0 +1,498 @@
|
||||
# Cloud Native - Spring Cloud
|
||||
|
||||
## Spring Cloud Config Server
|
||||
|
||||
```java
|
||||
// Config Server
|
||||
@SpringBootApplication
|
||||
@EnableConfigServer
|
||||
public class ConfigServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ConfigServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml
|
||||
server:
|
||||
port: 8888
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
config:
|
||||
server:
|
||||
git:
|
||||
uri: https://github.com/example/config-repo
|
||||
default-label: main
|
||||
search-paths: '{application}'
|
||||
username: ${GIT_USERNAME}
|
||||
password: ${GIT_PASSWORD}
|
||||
native:
|
||||
search-locations: classpath:/config
|
||||
security:
|
||||
user:
|
||||
name: config-user
|
||||
password: ${CONFIG_PASSWORD}
|
||||
|
||||
// Config Client
|
||||
@SpringBootApplication
|
||||
public class ClientApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ClientApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml (Config Client)
|
||||
spring:
|
||||
application:
|
||||
name: user-service
|
||||
config:
|
||||
import: "configserver:http://localhost:8888"
|
||||
cloud:
|
||||
config:
|
||||
username: config-user
|
||||
password: ${CONFIG_PASSWORD}
|
||||
fail-fast: true
|
||||
retry:
|
||||
max-attempts: 6
|
||||
initial-interval: 1000
|
||||
```
|
||||
|
||||
## Dynamic Configuration Refresh
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RefreshScope
|
||||
public class ConfigController {
|
||||
@Value("${app.feature.enabled:false}")
|
||||
private boolean featureEnabled;
|
||||
|
||||
@Value("${app.max-connections:100}")
|
||||
private int maxConnections;
|
||||
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> getConfig() {
|
||||
return Map.of(
|
||||
"featureEnabled", featureEnabled,
|
||||
"maxConnections", maxConnections
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh configuration via Actuator endpoint:
|
||||
// POST /actuator/refresh
|
||||
```
|
||||
|
||||
## Service Discovery - Eureka
|
||||
|
||||
```java
|
||||
// Eureka Server
|
||||
@SpringBootApplication
|
||||
@EnableEurekaServer
|
||||
public class EurekaServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EurekaServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml (Eureka Server)
|
||||
server:
|
||||
port: 8761
|
||||
|
||||
eureka:
|
||||
instance:
|
||||
hostname: localhost
|
||||
client:
|
||||
register-with-eureka: false
|
||||
fetch-registry: false
|
||||
service-url:
|
||||
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
|
||||
|
||||
// Eureka Client
|
||||
@SpringBootApplication
|
||||
@EnableDiscoveryClient
|
||||
public class UserServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(UserServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml (Eureka Client)
|
||||
spring:
|
||||
application:
|
||||
name: user-service
|
||||
|
||||
eureka:
|
||||
client:
|
||||
service-url:
|
||||
defaultZone: http://localhost:8761/eureka/
|
||||
registry-fetch-interval-seconds: 5
|
||||
instance:
|
||||
prefer-ip-address: true
|
||||
lease-renewal-interval-in-seconds: 10
|
||||
lease-expiration-duration-in-seconds: 30
|
||||
```
|
||||
|
||||
## Spring Cloud Gateway
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
public class GatewayApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(GatewayApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||
return builder.routes()
|
||||
.route("user-service", r -> r
|
||||
.path("/api/users/**")
|
||||
.filters(f -> f
|
||||
.rewritePath("/api/users/(?<segment>.*)", "/users/${segment}")
|
||||
.addRequestHeader("X-Gateway", "Spring-Cloud-Gateway")
|
||||
.circuitBreaker(config -> config
|
||||
.setName("userServiceCircuitBreaker")
|
||||
.setFallbackUri("forward:/fallback/users")
|
||||
)
|
||||
.retry(config -> config
|
||||
.setRetries(3)
|
||||
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
)
|
||||
)
|
||||
.uri("lb://user-service")
|
||||
)
|
||||
.route("order-service", r -> r
|
||||
.path("/api/orders/**")
|
||||
.filters(f -> f
|
||||
.rewritePath("/api/orders/(?<segment>.*)", "/orders/${segment}")
|
||||
.requestRateLimiter(config -> config
|
||||
.setRateLimiter(redisRateLimiter())
|
||||
.setKeyResolver(userKeyResolver())
|
||||
)
|
||||
)
|
||||
.uri("lb://order-service")
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RedisRateLimiter redisRateLimiter() {
|
||||
return new RedisRateLimiter(10, 20); // replenishRate, burstCapacity
|
||||
}
|
||||
|
||||
@Bean
|
||||
public KeyResolver userKeyResolver() {
|
||||
return exchange -> Mono.just(
|
||||
exchange.getRequest().getHeaders().getFirst("X-User-Id")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml (Gateway)
|
||||
spring:
|
||||
cloud:
|
||||
gateway:
|
||||
discovery:
|
||||
locator:
|
||||
enabled: true
|
||||
lower-case-service-id: true
|
||||
default-filters:
|
||||
- DedupeResponseHeader=Access-Control-Allow-Origin
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
allowed-origins: "*"
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
allowed-headers: "*"
|
||||
```
|
||||
|
||||
## Circuit Breaker - Resilience4j
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalApiService {
|
||||
private final WebClient webClient;
|
||||
|
||||
@CircuitBreaker(name = "externalApi", fallbackMethod = "getFallbackData")
|
||||
@Retry(name = "externalApi")
|
||||
@RateLimiter(name = "externalApi")
|
||||
public Mono<ExternalData> getData(String id) {
|
||||
return webClient
|
||||
.get()
|
||||
.uri("/data/{id}", id)
|
||||
.retrieve()
|
||||
.bodyToMono(ExternalData.class)
|
||||
.timeout(Duration.ofSeconds(3));
|
||||
}
|
||||
|
||||
private Mono<ExternalData> getFallbackData(String id, Exception e) {
|
||||
log.warn("Fallback triggered for id: {}, error: {}", id, e.getMessage());
|
||||
return Mono.just(new ExternalData(id, "Fallback data", LocalDateTime.now()));
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
externalApi:
|
||||
register-health-indicator: true
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
automatic-transition-from-open-to-half-open-enabled: true
|
||||
wait-duration-in-open-state: 5s
|
||||
failure-rate-threshold: 50
|
||||
event-consumer-buffer-size: 10
|
||||
|
||||
retry:
|
||||
instances:
|
||||
externalApi:
|
||||
max-attempts: 3
|
||||
wait-duration: 1s
|
||||
enable-exponential-backoff: true
|
||||
exponential-backoff-multiplier: 2
|
||||
|
||||
ratelimiter:
|
||||
instances:
|
||||
externalApi:
|
||||
limit-for-period: 10
|
||||
limit-refresh-period: 1s
|
||||
timeout-duration: 0s
|
||||
```
|
||||
|
||||
## Distributed Tracing - Micrometer Tracing
|
||||
|
||||
```java
|
||||
// application.yml
|
||||
management:
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 1.0
|
||||
zipkin:
|
||||
tracing:
|
||||
endpoint: http://localhost:9411/api/v2/spans
|
||||
|
||||
logging:
|
||||
pattern:
|
||||
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
|
||||
|
||||
// Custom spans
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderService {
|
||||
private final Tracer tracer;
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
public Order processOrder(OrderRequest request) {
|
||||
Span span = tracer.nextSpan().name("processOrder").start();
|
||||
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
|
||||
span.tag("order.type", request.type());
|
||||
span.tag("order.items", String.valueOf(request.items().size()));
|
||||
|
||||
// Business logic
|
||||
Order order = createOrder(request);
|
||||
|
||||
span.event("order.created");
|
||||
return order;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Load Balancing with Spring Cloud LoadBalancer
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@LoadBalancerClient(name = "user-service", configuration = UserServiceLoadBalancerConfig.class)
|
||||
public class LoadBalancerConfiguration {
|
||||
}
|
||||
|
||||
@Configuration
|
||||
public class UserServiceLoadBalancerConfig {
|
||||
|
||||
@Bean
|
||||
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
|
||||
LoadBalancerClientFactory clientFactory,
|
||||
ObjectProvider<LoadBalancerProperties> properties) {
|
||||
return new RandomLoadBalancer(
|
||||
clientFactory.getLazyProvider("user-service", ServiceInstanceListSupplier.class),
|
||||
"user-service"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserClientService {
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
|
||||
public Mono<User> getUser(Long id) {
|
||||
return webClientBuilder
|
||||
.baseUrl("http://user-service")
|
||||
.build()
|
||||
.get()
|
||||
.uri("/users/{id}", id)
|
||||
.retrieve()
|
||||
.bodyToMono(User.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Checks & Actuator
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class CustomHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
boolean serviceUp = checkExternalService();
|
||||
|
||||
if (serviceUp) {
|
||||
return Health.up()
|
||||
.withDetail("externalService", "Available")
|
||||
.withDetail("timestamp", LocalDateTime.now())
|
||||
.build();
|
||||
} else {
|
||||
return Health.down()
|
||||
.withDetail("externalService", "Unavailable")
|
||||
.withDetail("error", "Connection timeout")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkExternalService() {
|
||||
// Check external dependency
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// application.yml
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
probes:
|
||||
enabled: true
|
||||
health:
|
||||
livenessState:
|
||||
enabled: true
|
||||
readinessState:
|
||||
enabled: true
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
tags:
|
||||
application: ${spring.application.name}
|
||||
```
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
```yaml
|
||||
# deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: user-service
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: user-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: user-service
|
||||
spec:
|
||||
containers:
|
||||
- name: user-service
|
||||
image: user-service:1.0.0
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
value: "kubernetes"
|
||||
- name: JAVA_OPTS
|
||||
value: "-Xmx512m -Xms256m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8080
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: user-service
|
||||
spec:
|
||||
selector:
|
||||
app: user-service
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
type: ClusterIP
|
||||
```
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile (Multi-stage)
|
||||
FROM eclipse-temurin:17-jdk-alpine AS build
|
||||
WORKDIR /workspace/app
|
||||
|
||||
COPY mvnw .
|
||||
COPY .mvn .mvn
|
||||
COPY pom.xml .
|
||||
COPY src src
|
||||
|
||||
RUN ./mvnw install -DskipTests
|
||||
RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar)
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
VOLUME /tmp
|
||||
ARG DEPENDENCY=/workspace/app/target/dependency
|
||||
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
|
||||
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
|
||||
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
|
||||
|
||||
ENTRYPOINT ["java","-cp","app:app/lib/*","com.example.Application"]
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **Config Server** | Centralized configuration management |
|
||||
| **Eureka** | Service discovery and registration |
|
||||
| **Gateway** | API gateway with routing, filtering, load balancing |
|
||||
| **Circuit Breaker** | Fault tolerance and fallback patterns |
|
||||
| **Load Balancer** | Client-side load balancing |
|
||||
| **Tracing** | Distributed tracing across services |
|
||||
| **Actuator** | Production-ready monitoring and management |
|
||||
| **Kubernetes** | Container orchestration and deployment |
|
||||
@@ -0,0 +1,381 @@
|
||||
# Data Access - Spring Data JPA
|
||||
|
||||
## JPA Entity Pattern
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users", indexes = {
|
||||
@Index(name = "idx_email", columnList = "email", unique = true),
|
||||
@Index(name = "idx_username", columnList = "username")
|
||||
})
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Getter @Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean active = true;
|
||||
|
||||
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<Address> addresses = new ArrayList<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "user_roles",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
@Builder.Default
|
||||
private Set<Role> roles = new HashSet<>();
|
||||
|
||||
@CreatedDate
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
|
||||
// Helper methods for bidirectional relationships
|
||||
public void addAddress(Address address) {
|
||||
addresses.add(address);
|
||||
address.setUser(this);
|
||||
}
|
||||
|
||||
public void removeAddress(Address address) {
|
||||
addresses.remove(address);
|
||||
address.setUser(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Spring Data JPA Repository
|
||||
|
||||
```java
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long>,
|
||||
JpaSpecificationExecutor<User> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
||||
Optional<User> findByEmailWithRoles(@Param("email") String email);
|
||||
|
||||
@Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt >= :since")
|
||||
List<User> findActiveUsersSince(@Param("since") LocalDateTime since);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :threshold")
|
||||
int deactivateInactiveUsers(@Param("threshold") LocalDateTime threshold);
|
||||
|
||||
// Projection for read-only DTOs
|
||||
@Query("SELECT new com.example.dto.UserSummary(u.id, u.username, u.email) " +
|
||||
"FROM User u WHERE u.active = true")
|
||||
List<UserSummary> findAllActiveSummaries();
|
||||
}
|
||||
```
|
||||
|
||||
## Repository with Specifications
|
||||
|
||||
```java
|
||||
public class UserSpecifications {
|
||||
|
||||
public static Specification<User> hasEmail(String email) {
|
||||
return (root, query, cb) ->
|
||||
email == null ? null : cb.equal(root.get("email"), email);
|
||||
}
|
||||
|
||||
public static Specification<User> isActive() {
|
||||
return (root, query, cb) -> cb.isTrue(root.get("active"));
|
||||
}
|
||||
|
||||
public static Specification<User> createdAfter(LocalDateTime date) {
|
||||
return (root, query, cb) ->
|
||||
date == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), date);
|
||||
}
|
||||
|
||||
public static Specification<User> hasRole(String roleName) {
|
||||
return (root, query, cb) -> {
|
||||
Join<User, Role> roles = root.join("roles", JoinType.INNER);
|
||||
return cb.equal(roles.get("name"), roleName);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in service
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public Page<User> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
|
||||
Specification<User> spec = Specification
|
||||
.where(UserSpecifications.hasEmail(criteria.email()))
|
||||
.and(UserSpecifications.isActive())
|
||||
.and(UserSpecifications.createdAfter(criteria.createdAfter()));
|
||||
|
||||
return userRepository.findAll(spec, pageable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transaction Management
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class OrderService {
|
||||
private final OrderRepository orderRepository;
|
||||
private final PaymentService paymentService;
|
||||
private final InventoryService inventoryService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
@Transactional
|
||||
public Order createOrder(OrderCreateRequest request) {
|
||||
// All operations in single transaction
|
||||
Order order = Order.builder()
|
||||
.customerId(request.customerId())
|
||||
.status(OrderStatus.PENDING)
|
||||
.build();
|
||||
|
||||
request.items().forEach(item -> {
|
||||
inventoryService.reserveStock(item.productId(), item.quantity());
|
||||
order.addItem(item);
|
||||
});
|
||||
|
||||
order = orderRepository.save(order);
|
||||
|
||||
try {
|
||||
paymentService.processPayment(order);
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
} catch (PaymentException e) {
|
||||
order.setStatus(OrderStatus.PAYMENT_FAILED);
|
||||
throw e; // Transaction will rollback
|
||||
}
|
||||
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void logOrderEvent(Long orderId, String event) {
|
||||
// Separate transaction - will commit even if parent rolls back
|
||||
OrderEvent orderEvent = new OrderEvent(orderId, event);
|
||||
orderEventRepository.save(orderEvent);
|
||||
}
|
||||
|
||||
@Transactional(noRollbackFor = NotificationException.class)
|
||||
public void completeOrder(Long orderId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
|
||||
|
||||
order.setStatus(OrderStatus.COMPLETED);
|
||||
orderRepository.save(order);
|
||||
|
||||
// Won't rollback transaction if notification fails
|
||||
try {
|
||||
notificationService.sendCompletionEmail(order);
|
||||
} catch (NotificationException e) {
|
||||
log.error("Failed to send notification for order {}", orderId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auditing Configuration
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableJpaAuditing
|
||||
public class JpaAuditingConfig {
|
||||
|
||||
@Bean
|
||||
public AuditorAware<String> auditorProvider() {
|
||||
return () -> {
|
||||
Authentication authentication = SecurityContextHolder
|
||||
.getContext()
|
||||
.getAuthentication();
|
||||
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return Optional.of("system");
|
||||
}
|
||||
|
||||
return Optional.of(authentication.getName());
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Getter @Setter
|
||||
public abstract class AuditableEntity {
|
||||
|
||||
@CreatedDate
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@CreatedBy
|
||||
@Column(nullable = false, updatable = false, length = 100)
|
||||
private String createdBy;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(nullable = false, length = 100)
|
||||
private String updatedBy;
|
||||
}
|
||||
```
|
||||
|
||||
## Projections
|
||||
|
||||
```java
|
||||
// Interface-based projection
|
||||
public interface UserSummary {
|
||||
Long getId();
|
||||
String getUsername();
|
||||
String getEmail();
|
||||
|
||||
@Value("#{target.firstName + ' ' + target.lastName}")
|
||||
String getFullName();
|
||||
}
|
||||
|
||||
// Class-based projection (DTO)
|
||||
public record UserSummaryDto(
|
||||
Long id,
|
||||
String username,
|
||||
String email
|
||||
) {}
|
||||
|
||||
// Usage
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
List<UserSummary> findAllBy();
|
||||
|
||||
<T> List<T> findAllBy(Class<T> type);
|
||||
}
|
||||
|
||||
// Service usage
|
||||
List<UserSummary> summaries = userRepository.findAllBy();
|
||||
List<UserSummaryDto> dtos = userRepository.findAllBy(UserSummaryDto.class);
|
||||
```
|
||||
|
||||
## Query Optimization
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class UserQueryService {
|
||||
private final UserRepository userRepository;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
// N+1 problem solved with JOIN FETCH
|
||||
@Query("SELECT DISTINCT u FROM User u " +
|
||||
"LEFT JOIN FETCH u.addresses " +
|
||||
"LEFT JOIN FETCH u.roles " +
|
||||
"WHERE u.active = true")
|
||||
List<User> findAllActiveWithAssociations();
|
||||
|
||||
// Batch fetching
|
||||
@BatchSize(size = 25)
|
||||
@OneToMany(mappedBy = "user")
|
||||
private List<Order> orders;
|
||||
|
||||
// EntityGraph for dynamic fetching
|
||||
@EntityGraph(attributePaths = {"addresses", "roles"})
|
||||
List<User> findAllByActiveTrue();
|
||||
|
||||
// Pagination to avoid loading all data
|
||||
public Page<User> findAllUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
// Native query for complex queries
|
||||
@Query(value = """
|
||||
SELECT u.* FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.created_at >= :since
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(o.id) >= :minOrders
|
||||
""", nativeQuery = true)
|
||||
List<User> findFrequentBuyers(@Param("since") LocalDateTime since,
|
||||
@Param("minOrders") int minOrders);
|
||||
}
|
||||
```
|
||||
|
||||
## Database Migrations (Flyway)
|
||||
|
||||
```sql
|
||||
-- V1__create_users_table.sql
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password VARCHAR(100) NOT NULL,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_active ON users(active);
|
||||
|
||||
-- V2__create_addresses_table.sql
|
||||
CREATE TABLE addresses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
street VARCHAR(200) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
country VARCHAR(2) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Annotation | Purpose |
|
||||
|------------|---------|
|
||||
| `@Entity` | Marks class as JPA entity |
|
||||
| `@Table` | Specifies table details and indexes |
|
||||
| `@Id` | Marks primary key field |
|
||||
| `@GeneratedValue` | Auto-generated primary key strategy |
|
||||
| `@Column` | Column constraints and mapping |
|
||||
| `@OneToMany/@ManyToOne` | One-to-many/many-to-one relationships |
|
||||
| `@ManyToMany` | Many-to-many relationships |
|
||||
| `@JoinColumn/@JoinTable` | Join column/table configuration |
|
||||
| `@Transactional` | Declares transaction boundaries |
|
||||
| `@Query` | Custom JPQL/native queries |
|
||||
| `@Modifying` | Marks query as UPDATE/DELETE |
|
||||
| `@EntityGraph` | Defines fetch graph for associations |
|
||||
| `@Version` | Optimistic locking version field |
|
||||
@@ -0,0 +1,459 @@
|
||||
# Security - Spring Security 6
|
||||
|
||||
## Security Configuration
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers("/api/auth/**")
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(authenticationEntryPoint())
|
||||
.accessDeniedHandler(accessDeniedHandler())
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter(),
|
||||
UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(
|
||||
AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JWT Authentication Filter
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final JwtService jwtService;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletRequest response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
final String authHeader = request.getHeader("Authorization");
|
||||
final String jwt;
|
||||
final String username;
|
||||
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
jwt = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
username = jwtService.extractUsername(jwt);
|
||||
|
||||
if (username != null && SecurityContextHolder.getContext()
|
||||
.getAuthentication() == null) {
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
if (jwtService.isTokenValid(jwt, userDetails)) {
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userDetails,
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
|
||||
authToken.setDetails(
|
||||
new WebAuthenticationDetailsSource().buildDetails(request)
|
||||
);
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
}
|
||||
}
|
||||
} catch (JwtException e) {
|
||||
log.error("JWT validation failed", e);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JWT Service
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class JwtService {
|
||||
@Value("${jwt.secret}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${jwt.expiration}")
|
||||
private long jwtExpiration;
|
||||
|
||||
@Value("${jwt.refresh-expiration}")
|
||||
private long refreshExpiration;
|
||||
|
||||
public String extractUsername(String token) {
|
||||
return extractClaim(token, Claims::getSubject);
|
||||
}
|
||||
|
||||
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
public String generateToken(UserDetails userDetails) {
|
||||
Map<String, Object> extraClaims = new HashMap<>();
|
||||
extraClaims.put("roles", userDetails.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
return generateToken(extraClaims, userDetails);
|
||||
}
|
||||
|
||||
public String generateToken(
|
||||
Map<String, Object> extraClaims,
|
||||
UserDetails userDetails) {
|
||||
return buildToken(extraClaims, userDetails, jwtExpiration);
|
||||
}
|
||||
|
||||
public String generateRefreshToken(UserDetails userDetails) {
|
||||
return buildToken(new HashMap<>(), userDetails, refreshExpiration);
|
||||
}
|
||||
|
||||
private String buildToken(
|
||||
Map<String, Object> extraClaims,
|
||||
UserDetails userDetails,
|
||||
long expiration) {
|
||||
return Jwts
|
||||
.builder()
|
||||
.setClaims(extraClaims)
|
||||
.setSubject(userDetails.getUsername())
|
||||
.setIssuedAt(new Date(System.currentTimeMillis()))
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration))
|
||||
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public boolean isTokenValid(String token, UserDetails userDetails) {
|
||||
final String username = extractUsername(token);
|
||||
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
|
||||
}
|
||||
|
||||
private boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(new Date());
|
||||
}
|
||||
|
||||
private Date extractExpiration(String token) {
|
||||
return extractClaim(token, Claims::getExpiration);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token) {
|
||||
return Jwts
|
||||
.parserBuilder()
|
||||
.setSigningKey(getSignInKey())
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
private Key getSignInKey() {
|
||||
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UserDetailsService Implementation
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByEmailWithRoles(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException(
|
||||
"User not found with email: " + username));
|
||||
|
||||
return org.springframework.security.core.userdetails.User
|
||||
.builder()
|
||||
.username(user.getEmail())
|
||||
.password(user.getPassword())
|
||||
.authorities(user.getRoles().stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
|
||||
.collect(Collectors.toList()))
|
||||
.accountExpired(false)
|
||||
.accountLocked(!user.getActive())
|
||||
.credentialsExpired(false)
|
||||
.disabled(!user.getActive())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Controller
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthenticationController {
|
||||
private final AuthenticationService authenticationService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthenticationResponse> register(
|
||||
@Valid @RequestBody RegisterRequest request) {
|
||||
AuthenticationResponse response = authenticationService.register(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthenticationResponse> login(
|
||||
@Valid @RequestBody LoginRequest request) {
|
||||
AuthenticationResponse response = authenticationService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<AuthenticationResponse> refreshToken(
|
||||
@RequestBody RefreshTokenRequest request) {
|
||||
AuthenticationResponse response = authenticationService.refreshToken(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Void> logout() {
|
||||
SecurityContextHolder.clearContext();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Service
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class AuthenticationService {
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final AuthenticationManager authenticationManager;
|
||||
|
||||
public AuthenticationResponse register(RegisterRequest request) {
|
||||
if (userRepository.existsByEmail(request.email())) {
|
||||
throw new DuplicateResourceException("Email already registered");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.email(request.email())
|
||||
.password(passwordEncoder.encode(request.password()))
|
||||
.username(request.username())
|
||||
.active(true)
|
||||
.roles(Set.of(Role.builder().name("USER").build()))
|
||||
.build();
|
||||
|
||||
user = userRepository.save(user);
|
||||
|
||||
String accessToken = jwtService.generateToken(convertToUserDetails(user));
|
||||
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
|
||||
|
||||
return new AuthenticationResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
public AuthenticationResponse login(LoginRequest request) {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
request.email(),
|
||||
request.password()
|
||||
)
|
||||
);
|
||||
|
||||
User user = userRepository.findByEmail(request.email())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
String accessToken = jwtService.generateToken(convertToUserDetails(user));
|
||||
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
|
||||
|
||||
return new AuthenticationResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
public AuthenticationResponse refreshToken(RefreshTokenRequest request) {
|
||||
String username = jwtService.extractUsername(request.refreshToken());
|
||||
|
||||
User user = userRepository.findByEmail(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
UserDetails userDetails = convertToUserDetails(user);
|
||||
|
||||
if (!jwtService.isTokenValid(request.refreshToken(), userDetails)) {
|
||||
throw new InvalidTokenException("Invalid refresh token");
|
||||
}
|
||||
|
||||
String accessToken = jwtService.generateToken(userDetails);
|
||||
|
||||
return new AuthenticationResponse(accessToken, request.refreshToken());
|
||||
}
|
||||
|
||||
private UserDetails convertToUserDetails(User user) {
|
||||
return org.springframework.security.core.userdetails.User
|
||||
.builder()
|
||||
.username(user.getEmail())
|
||||
.password(user.getPassword())
|
||||
.authorities(user.getRoles().stream()
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Method Security
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public List<User> getAllUsers() {
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
|
||||
public User getUserById(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@PostAuthorize("returnObject.email == authentication.principal.username")
|
||||
public User updateProfile(Long userId, UserUpdateRequest request) {
|
||||
User user = getUserById(userId);
|
||||
// Update logic
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
|
||||
public void deleteUser(Long userId) {
|
||||
userRepository.deleteById(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OAuth2 Resource Server (JWT)
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2ResourceServerConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/public/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt
|
||||
.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
)
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder() {
|
||||
return JwtDecoders.fromIssuerLocation("https://auth.example.com");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
|
||||
new JwtGrantedAuthoritiesConverter();
|
||||
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
|
||||
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
|
||||
|
||||
JwtAuthenticationConverter jwtAuthenticationConverter =
|
||||
new JwtAuthenticationConverter();
|
||||
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
|
||||
grantedAuthoritiesConverter);
|
||||
|
||||
return jwtAuthenticationConverter;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Annotation | Purpose |
|
||||
|------------|---------|
|
||||
| `@EnableWebSecurity` | Enables Spring Security |
|
||||
| `@EnableMethodSecurity` | Enables method-level security annotations |
|
||||
| `@PreAuthorize` | Checks authorization before method execution |
|
||||
| `@PostAuthorize` | Checks authorization after method execution |
|
||||
| `@Secured` | Role-based method security |
|
||||
| `@WithMockUser` | Mock authenticated user in tests |
|
||||
| `@AuthenticationPrincipal` | Inject current user in controller |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Store JWT secret in environment variables
|
||||
- Use strong password encoding (BCrypt with strength 12+)
|
||||
- Implement token refresh mechanism
|
||||
- Add rate limiting to authentication endpoints
|
||||
- Validate all user inputs
|
||||
- Log security events
|
||||
- Keep dependencies updated
|
||||
- Use CSRF protection for state-changing operations
|
||||
- Implement proper session timeout
|
||||
@@ -0,0 +1,545 @@
|
||||
# Testing - Spring Boot Test
|
||||
|
||||
## Unit Testing with JUnit 5
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
@DisplayName("Should create user successfully")
|
||||
void shouldCreateUser() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
User user = User.builder()
|
||||
.id(1L)
|
||||
.email(request.email())
|
||||
.username(request.username())
|
||||
.build();
|
||||
|
||||
when(userRepository.existsByEmail(request.email())).thenReturn(false);
|
||||
when(passwordEncoder.encode(request.password())).thenReturn("encodedPassword");
|
||||
when(userRepository.save(any(User.class))).thenReturn(user);
|
||||
|
||||
// When
|
||||
UserResponse response = userService.create(request);
|
||||
|
||||
// Then
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.email()).isEqualTo(request.email());
|
||||
|
||||
verify(userRepository).existsByEmail(request.email());
|
||||
verify(passwordEncoder).encode(request.password());
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when email already exists")
|
||||
void shouldThrowExceptionWhenEmailExists() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
when(userRepository.existsByEmail(request.email())).thenReturn(true);
|
||||
|
||||
// When & Then
|
||||
assertThatThrownBy(() -> userService.create(request))
|
||||
.isInstanceOf(DuplicateResourceException.class)
|
||||
.hasMessageContaining("Email already registered");
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Testing with @SpringBootTest
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class UserIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("Should create user via API")
|
||||
void shouldCreateUserViaApi() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
// When
|
||||
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
|
||||
"/api/v1/users",
|
||||
request,
|
||||
UserResponse.class
|
||||
);
|
||||
|
||||
// Then
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().email()).isEqualTo(request.email());
|
||||
assertThat(response.getHeaders().getLocation()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("Should return validation error for invalid request")
|
||||
void shouldReturnValidationError() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"invalid-email",
|
||||
"short",
|
||||
"u",
|
||||
15
|
||||
);
|
||||
|
||||
// When
|
||||
ResponseEntity<ValidationErrorResponse> response = restTemplate.postForEntity(
|
||||
"/api/v1/users",
|
||||
request,
|
||||
ValidationErrorResponse.class
|
||||
);
|
||||
|
||||
// Then
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().errors()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Web Layer Testing with MockMvc
|
||||
|
||||
```java
|
||||
@WebMvcTest(UserController.class)
|
||||
@Import(SecurityConfig.class)
|
||||
class UserControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
@DisplayName("Should get all users")
|
||||
void shouldGetAllUsers() throws Exception {
|
||||
// Given
|
||||
Page<UserResponse> users = new PageImpl<>(List.of(
|
||||
new UserResponse(1L, "user1@example.com", "user1", 25, true, null, null),
|
||||
new UserResponse(2L, "user2@example.com", "user2", 30, true, null, null)
|
||||
));
|
||||
|
||||
when(userService.findAll(any(Pageable.class))).thenReturn(users);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get("/api/v1/users")
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray())
|
||||
.andExpect(jsonPath("$.content.length()").value(2))
|
||||
.andExpect(jsonPath("$.content[0].email").value("user1@example.com"))
|
||||
.andDo(print());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
@DisplayName("Should create user")
|
||||
void shouldCreateUser() throws Exception {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
UserResponse response = new UserResponse(
|
||||
1L,
|
||||
request.email(),
|
||||
request.username(),
|
||||
request.age(),
|
||||
true,
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
|
||||
when(userService.create(any(UserCreateRequest.class))).thenReturn(response);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(post("/api/v1/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(header().exists("Location"))
|
||||
.andExpect(jsonPath("$.email").value(request.email()))
|
||||
.andExpect(jsonPath("$.username").value(request.username()))
|
||||
.andDo(print());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
@DisplayName("Should return 403 for non-admin user")
|
||||
void shouldReturn403ForNonAdmin() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/users")
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data JPA Testing
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager entityManager;
|
||||
|
||||
@Test
|
||||
@DisplayName("Should find user by email")
|
||||
void shouldFindUserByEmail() {
|
||||
// Given
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.password("password")
|
||||
.username("testuser")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
entityManager.persistAndFlush(user);
|
||||
|
||||
// When
|
||||
Optional<User> found = userRepository.findByEmail("test@example.com");
|
||||
|
||||
// Then
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should check if email exists")
|
||||
void shouldCheckIfEmailExists() {
|
||||
// Given
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.password("password")
|
||||
.username("testuser")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
entityManager.persistAndFlush(user);
|
||||
|
||||
// When
|
||||
boolean exists = userRepository.existsByEmail("test@example.com");
|
||||
|
||||
// Then
|
||||
assertThat(exists).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should fetch user with roles")
|
||||
void shouldFetchUserWithRoles() {
|
||||
// Given
|
||||
Role adminRole = Role.builder().name("ADMIN").build();
|
||||
entityManager.persist(adminRole);
|
||||
|
||||
User user = User.builder()
|
||||
.email("admin@example.com")
|
||||
.password("password")
|
||||
.username("admin")
|
||||
.active(true)
|
||||
.roles(Set.of(adminRole))
|
||||
.build();
|
||||
|
||||
entityManager.persistAndFlush(user);
|
||||
entityManager.clear();
|
||||
|
||||
// When
|
||||
Optional<User> found = userRepository.findByEmailWithRoles("admin@example.com");
|
||||
|
||||
// Then
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getRoles()).hasSize(1);
|
||||
assertThat(found.get().getRoles()).extracting(Role::getName).contains("ADMIN");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testcontainers for Database
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@ActiveProfiles("test")
|
||||
class UserServiceIntegrationTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
|
||||
.withDatabaseName("testdb")
|
||||
.withUsername("test")
|
||||
.withPassword("test");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should create and find user in real database")
|
||||
void shouldCreateAndFindUser() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
// When
|
||||
UserResponse created = userService.create(request);
|
||||
UserResponse found = userService.findById(created.id());
|
||||
|
||||
// Then
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.email()).isEqualTo(request.email());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Reactive Endpoints with WebTestClient
|
||||
|
||||
```java
|
||||
@WebFluxTest(UserReactiveController.class)
|
||||
class UserReactiveControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@MockBean
|
||||
private UserReactiveService userService;
|
||||
|
||||
@Test
|
||||
@DisplayName("Should get user reactively")
|
||||
void shouldGetUserReactively() {
|
||||
// Given
|
||||
UserResponse user = new UserResponse(
|
||||
1L,
|
||||
"test@example.com",
|
||||
"testuser",
|
||||
25,
|
||||
true,
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
|
||||
when(userService.findById(1L)).thenReturn(Mono.just(user));
|
||||
|
||||
// When & Then
|
||||
webTestClient.get()
|
||||
.uri("/api/v1/users/{id}", 1L)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(UserResponse.class)
|
||||
.value(response -> {
|
||||
assertThat(response.id()).isEqualTo(1L);
|
||||
assertThat(response.email()).isEqualTo("test@example.com");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should create user reactively")
|
||||
void shouldCreateUserReactively() {
|
||||
// Given
|
||||
UserCreateRequest request = new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
|
||||
UserResponse response = new UserResponse(
|
||||
1L,
|
||||
request.email(),
|
||||
request.username(),
|
||||
request.age(),
|
||||
true,
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
|
||||
when(userService.create(any(UserCreateRequest.class))).thenReturn(Mono.just(response));
|
||||
|
||||
// When & Then
|
||||
webTestClient.post()
|
||||
.uri("/api/v1/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Mono.just(request), UserCreateRequest.class)
|
||||
.exchange()
|
||||
.expectStatus().isCreated()
|
||||
.expectHeader().exists("Location")
|
||||
.expectBody(UserResponse.class)
|
||||
.value(user -> {
|
||||
assertThat(user.email()).isEqualTo(request.email());
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
```java
|
||||
// application-test.yml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb
|
||||
driver-class-name: org.h2.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
security:
|
||||
user:
|
||||
name: test
|
||||
password: test
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.hibernate.SQL: DEBUG
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
|
||||
// Test Configuration Class
|
||||
@TestConfiguration
|
||||
public class TestConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(4); // Faster for tests
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Clock fixedClock() {
|
||||
return Clock.fixed(
|
||||
Instant.parse("2024-01-01T00:00:00Z"),
|
||||
ZoneId.of("UTC")
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Fixtures with @DataJpaTest
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class TestDataFactory {
|
||||
|
||||
public static User createUser(String email, String username) {
|
||||
return User.builder()
|
||||
.email(email)
|
||||
.password("encodedPassword")
|
||||
.username(username)
|
||||
.active(true)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static UserCreateRequest createUserRequest() {
|
||||
return new UserCreateRequest(
|
||||
"test@example.com",
|
||||
"Password123",
|
||||
"testuser",
|
||||
25
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Annotation | Purpose |
|
||||
|------------|---------|
|
||||
| `@SpringBootTest` | Full application context integration test |
|
||||
| `@WebMvcTest` | Test MVC controllers with mocked services |
|
||||
| `@WebFluxTest` | Test reactive controllers |
|
||||
| `@DataJpaTest` | Test JPA repositories with in-memory database |
|
||||
| `@MockBean` | Add mock bean to Spring context |
|
||||
| `@WithMockUser` | Mock authenticated user for security tests |
|
||||
| `@Testcontainers` | Enable Testcontainers support |
|
||||
| `@ActiveProfiles` | Activate specific Spring profiles for test |
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
- Write tests following AAA pattern (Arrange, Act, Assert)
|
||||
- Use descriptive test names with @DisplayName
|
||||
- Mock external dependencies, use real DB with Testcontainers
|
||||
- Achieve 85%+ code coverage
|
||||
- Test happy path and edge cases
|
||||
- Use @Transactional for test data cleanup
|
||||
- Separate unit tests from integration tests
|
||||
- Use parameterized tests for multiple scenarios
|
||||
- Test security rules and validation
|
||||
- Keep tests fast and independent
|
||||
@@ -0,0 +1,295 @@
|
||||
# Web Layer - Controllers & REST APIs
|
||||
|
||||
## REST Controller Pattern
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<UserResponse>> getUsers(
|
||||
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
|
||||
Page<UserResponse> users = userService.findAll(pageable);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
|
||||
UserResponse user = userService.findById(id);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createUser(
|
||||
@Valid @RequestBody UserCreateRequest request) {
|
||||
UserResponse user = userService.create(request);
|
||||
URI location = ServletUriComponentsBuilder
|
||||
.fromCurrentRequest()
|
||||
.path("/{id}")
|
||||
.buildAndExpand(user.id())
|
||||
.toUri();
|
||||
return ResponseEntity.created(location).body(user);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserUpdateRequest request) {
|
||||
UserResponse user = userService.update(id, request);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteUser(@PathVariable Long id) {
|
||||
userService.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request DTOs with Validation
|
||||
|
||||
```java
|
||||
public record UserCreateRequest(
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Email must be valid")
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
|
||||
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).*$",
|
||||
message = "Password must contain uppercase, lowercase, and digit")
|
||||
String password,
|
||||
|
||||
@NotBlank(message = "Username is required")
|
||||
@Size(min = 3, max = 50)
|
||||
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must be alphanumeric")
|
||||
String username,
|
||||
|
||||
@Min(value = 18, message = "Must be at least 18")
|
||||
@Max(value = 120, message = "Must be at most 120")
|
||||
Integer age
|
||||
) {}
|
||||
|
||||
public record UserUpdateRequest(
|
||||
@Email(message = "Email must be valid")
|
||||
String email,
|
||||
|
||||
@Size(min = 3, max = 50)
|
||||
String username
|
||||
) {}
|
||||
```
|
||||
|
||||
## Response DTOs
|
||||
|
||||
```java
|
||||
public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String username,
|
||||
Integer age,
|
||||
Boolean active,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
public static UserResponse from(User user) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getUsername(),
|
||||
user.getAge(),
|
||||
user.getActive(),
|
||||
user.getCreatedAt(),
|
||||
user.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Global Exception Handling
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(
|
||||
ResourceNotFoundException ex, WebRequest request) {
|
||||
log.error("Resource not found: {}", ex.getMessage());
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.NOT_FOUND.value(),
|
||||
ex.getMessage(),
|
||||
request.getDescription(false),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ValidationErrorResponse> handleValidation(
|
||||
MethodArgumentNotValidException ex) {
|
||||
Map<String, String> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(
|
||||
FieldError::getField,
|
||||
error -> error.getDefaultMessage() != null
|
||||
? error.getDefaultMessage()
|
||||
: "Invalid value"
|
||||
));
|
||||
|
||||
ValidationErrorResponse response = new ValidationErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Validation failed",
|
||||
errors,
|
||||
LocalDateTime.now()
|
||||
);
|
||||
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDataIntegrity(
|
||||
DataIntegrityViolationException ex, WebRequest request) {
|
||||
log.error("Data integrity violation", ex);
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.CONFLICT.value(),
|
||||
"Data integrity violation - resource may already exist",
|
||||
request.getDescription(false),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGlobalException(
|
||||
Exception ex, WebRequest request) {
|
||||
log.error("Unexpected error", ex);
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
||||
"An unexpected error occurred",
|
||||
request.getDescription(false),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
record ErrorResponse(
|
||||
int status,
|
||||
String message,
|
||||
String path,
|
||||
LocalDateTime timestamp
|
||||
) {}
|
||||
|
||||
record ValidationErrorResponse(
|
||||
int status,
|
||||
String message,
|
||||
Map<String, String> errors,
|
||||
LocalDateTime timestamp
|
||||
) {}
|
||||
```
|
||||
|
||||
## Custom Validation
|
||||
|
||||
```java
|
||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = UniqueEmailValidator.class)
|
||||
public @interface UniqueEmail {
|
||||
String message() default "Email already exists";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public boolean isValid(String email, ConstraintValidatorContext context) {
|
||||
if (email == null) return true;
|
||||
return !userRepository.existsByEmail(email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebClient for External APIs
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
@Bean
|
||||
public WebClient webClient(WebClient.Builder builder) {
|
||||
return builder
|
||||
.baseUrl("https://api.example.com")
|
||||
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.filter(logRequest())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ExchangeFilterFunction logRequest() {
|
||||
return ExchangeFilterFunction.ofRequestProcessor(request -> {
|
||||
log.info("Request: {} {}", request.method(), request.url());
|
||||
return Mono.just(request);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalApiService {
|
||||
private final WebClient webClient;
|
||||
|
||||
public Mono<ExternalDataResponse> fetchData(String id) {
|
||||
return webClient
|
||||
.get()
|
||||
.uri("/data/{id}", id)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::is4xxClientError, response ->
|
||||
Mono.error(new ResourceNotFoundException("External resource not found")))
|
||||
.onStatus(HttpStatusCode::is5xxServerError, response ->
|
||||
Mono.error(new ServiceUnavailableException("External service unavailable")))
|
||||
.bodyToMono(ExternalDataResponse.class)
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.retry(3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:3000", "https://example.com")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Annotation | Purpose |
|
||||
|------------|---------|
|
||||
| `@RestController` | Marks class as REST controller (combines @Controller + @ResponseBody) |
|
||||
| `@RequestMapping` | Maps HTTP requests to handler methods |
|
||||
| `@GetMapping/@PostMapping` | HTTP method-specific mappings |
|
||||
| `@PathVariable` | Extracts values from URI path |
|
||||
| `@RequestParam` | Extracts query parameters |
|
||||
| `@RequestBody` | Binds request body to method parameter |
|
||||
| `@Valid` | Triggers validation on request body |
|
||||
| `@RestControllerAdvice` | Global exception handling for REST controllers |
|
||||
| `@ResponseStatus` | Sets HTTP status code for method |
|
||||
Reference in New Issue
Block a user