Initial commit
This commit is contained in:
315
skills/spring-boot/spring-boot-rest-api-standards/SKILL.md
Normal file
315
skills/spring-boot/spring-boot-rest-api-standards/SKILL.md
Normal file
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: spring-boot-rest-api-standards
|
||||
description: Implement REST API design standards and best practices for Spring Boot projects. Use when creating or reviewing REST endpoints, DTOs, error handling, pagination, security headers, HATEOAS and architecture patterns.
|
||||
category: backend
|
||||
tags: [spring-boot, rest-api, dto, validation, error-handling, pagination, hateoas, architecture, java]
|
||||
version: 1.1.0
|
||||
allowed-tools: Read, Write, Bash
|
||||
---
|
||||
|
||||
# Spring Boot REST API Standards
|
||||
|
||||
This skill provides comprehensive guidance for building RESTful APIs in Spring Boot applications with consistent design patterns, proper error handling, validation, and architectural best practices based on REST principles and Spring Boot conventions.
|
||||
|
||||
## Overview
|
||||
|
||||
Spring Boot REST API standards establish consistent patterns for building production-ready REST APIs. These standards cover resource-based URL design, proper HTTP method usage, status code conventions, DTO patterns, validation, error handling, pagination, security headers, and architectural layering. Implement these patterns to ensure API consistency, maintainability, and adherence to REST principles.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Creating new REST endpoints and API routes
|
||||
- Designing request/response DTOs and API contracts
|
||||
- Planning HTTP methods and status codes
|
||||
- Implementing error handling and validation
|
||||
- Setting up pagination, filtering, and sorting
|
||||
- Designing security headers and CORS policies
|
||||
- Implementing HATEOAS (Hypermedia As The Engine Of Application State)
|
||||
- Reviewing REST API architecture and design patterns
|
||||
- Building microservices with consistent API standards
|
||||
- Documenting API endpoints with clear contracts
|
||||
|
||||
## Instructions
|
||||
|
||||
### To Build RESTful API Endpoints
|
||||
|
||||
Follow these steps to create well-designed REST API endpoints:
|
||||
|
||||
1. **Design Resource-Based URLs**
|
||||
- Use plural nouns for resource names
|
||||
- Follow REST conventions: GET /users, POST /users, PUT /users/{id}
|
||||
- Avoid action-based URLs like /getUserList
|
||||
|
||||
2. **Implement Proper HTTP Methods**
|
||||
- GET: Retrieve resources (safe, idempotent)
|
||||
- POST: Create resources (not idempotent)
|
||||
- PUT: Replace entire resources (idempotent)
|
||||
- PATCH: Partial updates (not idempotent)
|
||||
- DELETE: Remove resources (idempotent)
|
||||
|
||||
3. **Use Appropriate Status Codes**
|
||||
- 200 OK: Successful GET/PUT/PATCH
|
||||
- 201 Created: Successful POST with Location header
|
||||
- 204 No Content: Successful DELETE
|
||||
- 400 Bad Request: Invalid request data
|
||||
- 404 Not Found: Resource doesn't exist
|
||||
- 409 Conflict: Duplicate resource
|
||||
- 500 Internal Server Error: Unexpected errors
|
||||
|
||||
4. **Create Request/Response DTOs**
|
||||
- Separate API contracts from domain entities
|
||||
- Use Java records or Lombok `@Data`/`@Value`
|
||||
- Apply Jakarta validation annotations
|
||||
- Keep DTOs immutable when possible
|
||||
|
||||
5. **Implement Validation**
|
||||
- Use `@Valid` annotation on `@RequestBody` parameters
|
||||
- Apply validation constraints (`@NotBlank`, `@Email`, `@Size`, etc.)
|
||||
- Handle validation errors with `MethodArgumentNotValidException`
|
||||
|
||||
6. **Set Up Error Handling**
|
||||
- Use `@RestControllerAdvice` for global exception handling
|
||||
- Return standardized error responses with status, error, message, and timestamp
|
||||
- Use `ResponseStatusException` for specific HTTP status codes
|
||||
|
||||
7. **Configure Pagination**
|
||||
- Use Pageable for large datasets
|
||||
- Include page, size, sort parameters
|
||||
- Return metadata with total elements, totalPages, etc.
|
||||
|
||||
8. **Add Security Headers**
|
||||
- Configure CORS policies
|
||||
- Set content security policy
|
||||
- Include X-Frame-Options, X-Content-Type-Options
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic CRUD Controller
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/v1/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
log.debug("Fetching users page {} size {}", page, pageSize);
|
||||
Page<UserResponse> users = userService.getAll(page, pageSize);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
UserResponse created = userService.create(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UpdateUserRequest request) {
|
||||
return ResponseEntity.ok(userService.update(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||
userService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Request/Response DTOs
|
||||
|
||||
```java
|
||||
// Request DTO
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateUserRequest {
|
||||
@NotBlank(message = "User name cannot be blank")
|
||||
private String name;
|
||||
|
||||
@Email(message = "Valid email required")
|
||||
private String email;
|
||||
}
|
||||
|
||||
// Response DTO
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserResponse {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String email;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Global Exception Handler
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidationException(
|
||||
MethodArgumentNotValidException ex, WebRequest request) {
|
||||
String errors = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(f -> f.getField() + ": " + f.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
ErrorResponse errorResponse = new ErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Validation Error",
|
||||
"Validation failed: " + errors,
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatusException(
|
||||
ResponseStatusException ex, WebRequest request) {
|
||||
ErrorResponse error = new ErrorResponse(
|
||||
ex.getStatusCode().value(),
|
||||
ex.getStatusCode().toString(),
|
||||
ex.getReason(),
|
||||
request.getDescription(false).replaceFirst("uri=", "")
|
||||
);
|
||||
return new ResponseEntity<>(error, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Constructor Injection
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final EmailService emailService;
|
||||
// Dependencies are explicit and testable
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Prefer Immutable DTOs
|
||||
```java
|
||||
// Java records (JDK 16+)
|
||||
public record UserResponse(Long id, String name, String email, LocalDateTime createdAt) {}
|
||||
|
||||
// Lombok @Value for immutability
|
||||
@Value
|
||||
public class UserResponse {
|
||||
Long id;
|
||||
String name;
|
||||
String email;
|
||||
LocalDateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Validate Input Early
|
||||
```java
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
// Validation happens automatically before method execution
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use ResponseEntity Flexibly
|
||||
```java
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.header("Location", "/api/users/" + created.getId())
|
||||
.header("X-Total-Count", String.valueOf(userService.count()))
|
||||
.body(created);
|
||||
```
|
||||
|
||||
### 5. Implement Proper Transaction Management
|
||||
```java
|
||||
@Service
|
||||
@Transactional
|
||||
public class UserService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<User> findById(Long id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public User create(User user) {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Add Meaningful Logging
|
||||
```java
|
||||
@Slf4j
|
||||
@Service
|
||||
public class UserService {
|
||||
public User create(User user) {
|
||||
log.info("Creating user with email: {}", user.getEmail());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Document APIs with Javadoc
|
||||
```java
|
||||
/**
|
||||
* Retrieves a user by id.
|
||||
*
|
||||
* @param id the user id
|
||||
* @return ResponseEntity containing a UserResponse
|
||||
* @throws ResponseStatusException with 404 if user not found
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id)
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
### 1. Never Expose Entities Directly
|
||||
Use DTOs to separate API contracts from domain models. This prevents accidental exposure of internal data structures and allows API evolution without database schema changes.
|
||||
|
||||
### 2. Follow REST Conventions Strictly
|
||||
- Use nouns for resource names, not verbs
|
||||
- Use correct HTTP methods for operations
|
||||
- Use plural resource names (/users, not /user)
|
||||
- Return appropriate HTTP status codes for each operation
|
||||
|
||||
### 3. Handle All Exceptions Globally
|
||||
Use @RestControllerAdvice to catch all exceptions consistently. Don't let raw exceptions bubble up to clients.
|
||||
|
||||
### 4. Always Paginate Large Result Sets
|
||||
For GET endpoints that might return many results, implement pagination to prevent performance issues and DDoS vulnerabilities.
|
||||
|
||||
### 5. Validate All Input Data
|
||||
Never trust client input. Use Jakarta validation annotations on all request DTOs to validate data at the controller boundary.
|
||||
|
||||
### 6. Use Constructor Injection Exclusively
|
||||
Avoid field injection (`@Autowired`) for better testability and explicit dependency declaration.
|
||||
|
||||
### 7. Keep Controllers Thin
|
||||
Controllers should only handle HTTP request/response adaptation. Delegate business logic to service layers.
|
||||
|
||||
## References
|
||||
|
||||
- See `references/` directory for comprehensive reference material including HTTP status codes, Spring annotations, and detailed examples
|
||||
- Refer to `agents/spring-boot-code-review-specialist.md` for code review guidelines
|
||||
- Review `spring-boot-dependency-injection/SKILL.md` for dependency injection patterns
|
||||
- Check `junit-test-patterns/SKILL.md` for testing REST APIs
|
||||
@@ -0,0 +1,761 @@
|
||||
# Architecture Patterns for REST APIs
|
||||
|
||||
## Layered Architecture
|
||||
|
||||
### Feature-Based Structure
|
||||
```
|
||||
feature-name/
|
||||
├── domain/
|
||||
│ ├── model/ # Domain entities (Spring-free)
|
||||
│ │ └── User.java
|
||||
│ ├── repository/ # Domain ports (interfaces)
|
||||
│ │ └── UserRepository.java
|
||||
│ └── service/ # Domain services
|
||||
│ └── UserService.java
|
||||
├── application/
|
||||
│ ├── service/ # Use cases (@Service beans)
|
||||
│ │ └── UserApplicationService.java
|
||||
│ └── dto/ # Immutable DTOs/records
|
||||
│ ├── UserRequest.java
|
||||
│ └── UserResponse.java
|
||||
├── presentation/
|
||||
│ └── rest/ # Controllers and mappers
|
||||
│ ├── UserController.java
|
||||
│ ├── UserMapper.java
|
||||
│ └── UserExceptionHandler.java
|
||||
└── infrastructure/
|
||||
└── persistence/ # JPA adapters
|
||||
└── JpaUserRepository.java
|
||||
```
|
||||
|
||||
### Domain Layer (Clean Architecture)
|
||||
|
||||
#### Domain Entity
|
||||
```java
|
||||
package com.example.domain.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private final String name;
|
||||
private final Email email;
|
||||
private final LocalDateTime createdAt;
|
||||
private final LocalDateTime updatedAt;
|
||||
|
||||
private User(UserId id, String name, Email email) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.name = Objects.requireNonNull(name);
|
||||
this.email = Objects.requireNonNull(email);
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public static User create(UserId id, String name, Email email) {
|
||||
return new User(id, name, email);
|
||||
}
|
||||
|
||||
public User updateName(String newName) {
|
||||
return new User(this.id, newName, this.email);
|
||||
}
|
||||
|
||||
public User updateEmail(Email newEmail) {
|
||||
return new User(this.id, this.name, newEmail);
|
||||
}
|
||||
|
||||
// Getters
|
||||
public UserId getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public Email getEmail() { return email; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
||||
// Value objects
|
||||
public class UserId {
|
||||
private final Long value;
|
||||
|
||||
public UserId(Long value) {
|
||||
this.value = Objects.requireNonNull(value);
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException("User ID must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public Long getValue() { return value; }
|
||||
}
|
||||
|
||||
public class Email {
|
||||
private final String value;
|
||||
|
||||
public Email(String value) {
|
||||
this.value = Objects.requireNonNull(value);
|
||||
if (!isValid(value)) {
|
||||
throw new IllegalArgumentException("Invalid email format");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isValid(String email) {
|
||||
return email.contains("@") && email.length() > 5;
|
||||
}
|
||||
|
||||
public String getValue() { return value; }
|
||||
}
|
||||
```
|
||||
|
||||
#### Domain Repository Port
|
||||
```java
|
||||
package com.example.domain.repository;
|
||||
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.model.UserId;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository {
|
||||
Optional<User> findById(UserId id);
|
||||
void save(User user);
|
||||
void delete(UserId id);
|
||||
boolean existsByEmail(Email email);
|
||||
}
|
||||
```
|
||||
|
||||
#### Domain Service
|
||||
```java
|
||||
package com.example.domain.service;
|
||||
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.model.Email;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class UserDomainService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public User registerUser(String name, String email) {
|
||||
Email emailObj = new Email(email);
|
||||
|
||||
if (userRepository.existsByEmail(emailObj)) {
|
||||
throw new BusinessException("Email already exists");
|
||||
}
|
||||
|
||||
User user = User.create(UserId.generate(), name, emailObj);
|
||||
userRepository.save(user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Application Layer (Use Cases)
|
||||
|
||||
#### Application Service
|
||||
```java
|
||||
package com.example.application.service;
|
||||
|
||||
import com.example.application.dto.UserRequest;
|
||||
import com.example.application.dto.UserResponse;
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import com.example.application.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserApplicationService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserRequest request) {
|
||||
log.info("Creating user: {}", request.getName());
|
||||
User user = userMapper.toDomain(request);
|
||||
User saved = userRepository.save(user);
|
||||
return userMapper.toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserResponse> findAllUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable)
|
||||
.map(userMapper::toResponse);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse findUserById(Long id) {
|
||||
return userRepository.findById(new UserId(id))
|
||||
.map(userMapper::toResponse)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request) {
|
||||
User user = userRepository.findById(new UserId(id))
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
|
||||
User updated = user.updateName(request.getName());
|
||||
User saved = userRepository.save(updated);
|
||||
return userMapper.toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(Long id) {
|
||||
userRepository.delete(new UserId(id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### DTOs
|
||||
```java
|
||||
package com.example.application.dto;
|
||||
|
||||
import lombok.Value;
|
||||
import jakarta.validation.constraints.*;
|
||||
|
||||
@Value
|
||||
public class UserRequest {
|
||||
@NotBlank(message = "Name is required")
|
||||
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Valid email required")
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Value
|
||||
public class UserResponse {
|
||||
Long id;
|
||||
String name;
|
||||
String email;
|
||||
LocalDateTime createdAt;
|
||||
LocalDateTime updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Presentation Layer (REST API)
|
||||
|
||||
#### Controller
|
||||
```java
|
||||
package com.example.presentation.rest;
|
||||
|
||||
import com.example.application.dto.UserRequest;
|
||||
import com.example.application.dto.UserResponse;
|
||||
import com.example.application.service.UserApplicationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserController {
|
||||
|
||||
private final UserApplicationService userApplicationService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) {
|
||||
UserResponse created = userApplicationService.createUser(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(Pageable pageable) {
|
||||
Page<UserResponse> users = userApplicationService.findAllUsers(pageable);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
|
||||
UserResponse user = userApplicationService.findUserById(id);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request) {
|
||||
UserResponse updated = userApplicationService.updateUser(id, request);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||
userApplicationService.deleteUser(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mapper
|
||||
```java
|
||||
package com.example.application.mapper;
|
||||
|
||||
import com.example.application.dto.UserRequest;
|
||||
import com.example.application.dto.UserResponse;
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.model.Email;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.NullValuePropertyMappingStrategy;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface UserMapper {
|
||||
|
||||
@Mapping(target = "id", source = "id.value")
|
||||
UserResponse toResponse(User user);
|
||||
|
||||
User toDomain(UserRequest request);
|
||||
|
||||
default Email toEmail(String email) {
|
||||
return new Email(email);
|
||||
}
|
||||
|
||||
default String toString(Email email) {
|
||||
return email.getValue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Layer (Adapters)
|
||||
|
||||
#### JPA Repository Adapter
|
||||
```java
|
||||
package com.example.infrastructure.persistence;
|
||||
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.model.UserId;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import com.example.infrastructure.entity.UserEntity;
|
||||
import com.example.infrastructure.mapper.UserEntityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class JpaUserRepository implements UserRepository {
|
||||
|
||||
private final SpringDataUserRepository springDataRepository;
|
||||
private final UserEntityMapper entityMapper;
|
||||
|
||||
@Override
|
||||
public Optional<User> findById(UserId id) {
|
||||
return springDataRepository.findById(id.getValue())
|
||||
.map(entityMapper::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(User user) {
|
||||
UserEntity entity = entityMapper.toEntity(user);
|
||||
springDataRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UserId id) {
|
||||
springDataRepository.deleteById(id.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(Email email) {
|
||||
return springDataRepository.existsByEmail(email.getValue());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Spring Data Repository
|
||||
```java
|
||||
package com.example.infrastructure.persistence;
|
||||
|
||||
import com.example.infrastructure.entity.UserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface SpringDataUserRepository extends JpaRepository<UserEntity, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
@Query("SELECT u FROM UserEntity u WHERE u.email = :email")
|
||||
Optional<UserEntity> findByEmail(@Param("email") String email);
|
||||
}
|
||||
```
|
||||
|
||||
## CQRS Pattern (Command Query Responsibility Segregation)
|
||||
|
||||
### Commands
|
||||
```java
|
||||
package com.example.application.command;
|
||||
|
||||
import lombok.Value;
|
||||
import jakarta.validation.constraints.*;
|
||||
|
||||
@Value
|
||||
public class CreateUserCommand {
|
||||
@NotBlank(message = "Name is required")
|
||||
@Size(min = 2, max = 100)
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Value
|
||||
public class UpdateUserCommand {
|
||||
@NotNull(message = "User ID is required")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
@Size(min = 2, max = 100)
|
||||
private String name;
|
||||
}
|
||||
```
|
||||
|
||||
### Command Handlers
|
||||
```java
|
||||
package com.example.application.command.handler;
|
||||
|
||||
import com.example.application.command.CreateUserCommand;
|
||||
import com.example.application.command.UpdateUserCommand;
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserCommandHandler {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
public User handle(CreateUserCommand command) {
|
||||
User user = User.create(UserId.generate(), command.getName(), new Email(command.getEmail()));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public User handle(UpdateUserCommand command) {
|
||||
User user = userRepository.findById(new UserId(command.getUserId()))
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
|
||||
User updated = user.updateName(command.getName());
|
||||
return userRepository.save(updated);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries
|
||||
```java
|
||||
package com.example.application.query;
|
||||
|
||||
import lombok.Value;
|
||||
import java.util.List;
|
||||
|
||||
@Value
|
||||
public class FindAllUsersQuery {
|
||||
int page;
|
||||
int size;
|
||||
String sortBy;
|
||||
String sortDirection;
|
||||
}
|
||||
|
||||
@Value
|
||||
public class FindUserByIdQuery {
|
||||
Long userId;
|
||||
}
|
||||
```
|
||||
|
||||
### Query Handlers
|
||||
```java
|
||||
package com.example.application.query.handler;
|
||||
|
||||
import com.example.application.query.FindAllUsersQuery;
|
||||
import com.example.application.query.FindUserByIdQuery;
|
||||
import com.example.application.dto.UserResponse;
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserQueryHandler {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public Page<UserResponse> handle(FindAllUsersQuery query) {
|
||||
Pageable pageable = PageRequest.of(
|
||||
query.getPage(),
|
||||
query.getSize(),
|
||||
Sort.by(Sort.Direction.fromString(query.getSortDirection()), query.getSortBy())
|
||||
);
|
||||
|
||||
return userRepository.findAll(pageable)
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public UserResponse handle(FindUserByIdQuery query) {
|
||||
return userRepository.findById(new UserId(query.getUserId()))
|
||||
.map(this::toResponse)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
private UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.getId().getValue(),
|
||||
user.getName(),
|
||||
user.getEmail().getValue(),
|
||||
user.getCreatedAt(),
|
||||
user.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event-Driven Architecture
|
||||
|
||||
### Domain Events
|
||||
```java
|
||||
package com.example.domain.event;
|
||||
|
||||
import lombok.Value;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Value
|
||||
public class UserCreatedEvent {
|
||||
String userId;
|
||||
String email;
|
||||
LocalDateTime timestamp;
|
||||
}
|
||||
|
||||
@Value
|
||||
public class UserUpdatedEvent {
|
||||
String userId;
|
||||
String oldName;
|
||||
String newName;
|
||||
LocalDateTime timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
### Event Publisher
|
||||
```java
|
||||
package com.example.domain.service;
|
||||
|
||||
import com.example.domain.event.UserCreatedEvent;
|
||||
import com.example.domain.event.UserUpdatedEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserEventPublisher {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public void publishUserCreated(String userId, String email) {
|
||||
UserCreatedEvent event = new UserCreatedEvent(userId, email, LocalDateTime.now());
|
||||
eventPublisher.publishEvent(event);
|
||||
log.info("Published UserCreatedEvent for user: {}", userId);
|
||||
}
|
||||
|
||||
public void publishUserUpdated(String userId, String oldName, String newName) {
|
||||
UserUpdatedEvent event = new UserUpdatedEvent(userId, oldName, newName, LocalDateTime.now());
|
||||
eventPublisher.publishEvent(event);
|
||||
log.info("Published UserUpdatedEvent for user: {}", userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Listeners
|
||||
```java
|
||||
package com.example.application.event.listener;
|
||||
|
||||
import com.example.domain.event.UserCreatedEvent;
|
||||
import com.example.domain.event.UserUpdatedEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserEventListener {
|
||||
|
||||
@EventListener
|
||||
public void handleUserCreated(UserCreatedEvent event) {
|
||||
log.info("User created: {} with email: {}", event.getUserId(), event.getEmail());
|
||||
// Send welcome email, update analytics, etc.
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handleUserUpdated(UserUpdatedEvent event) {
|
||||
log.info("User {} updated: {} -> {}", event.getUserId(), event.getOldName(), event.getNewName());
|
||||
// Update search index, send notification, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Microservices Architecture
|
||||
|
||||
### API Gateway Pattern
|
||||
```java
|
||||
package com.example.gateway;
|
||||
|
||||
import org.springframework.cloud.gateway.route.Route;
|
||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class GatewayConfig {
|
||||
|
||||
@Bean
|
||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||
return builder.routes()
|
||||
.route("user-service", r -> r.path("/api/users/**")
|
||||
.filters(f -> f.stripPrefix(1))
|
||||
.uri("lb://user-service"))
|
||||
.route("order-service", r -> r.path("/api/orders/**")
|
||||
.filters(f -> f.stripPrefix(1))
|
||||
.uri("lb://order-service"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Communication
|
||||
```java
|
||||
package com.example.application.client;
|
||||
|
||||
import com.example.application.dto.UserResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
@FeignClient(name = "user-service", url = "${user.service.url}")
|
||||
public interface UserServiceClient {
|
||||
|
||||
@GetMapping("/api/users/{id}")
|
||||
UserResponse getUserById(@PathVariable Long id);
|
||||
|
||||
@GetMapping("/api/users")
|
||||
List<UserResponse> getAllUsers();
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
```java
|
||||
package com.example.application.service;
|
||||
|
||||
import com.example.application.dto.UserRequest;
|
||||
import com.example.domain.model.User;
|
||||
import com.example.domain.repository.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserApplicationServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserApplicationService userApplicationService;
|
||||
|
||||
@Test
|
||||
void createUserShouldCreateUser() {
|
||||
// Arrange
|
||||
UserRequest request = new UserRequest("John Doe", "john@example.com");
|
||||
User user = User.create(UserId.generate(), "John Doe", new Email("john@example.com"));
|
||||
|
||||
when(userRepository.save(any(User.class))).thenReturn(user);
|
||||
|
||||
// Act
|
||||
UserResponse result = userApplicationService.createUser(request);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals("John Doe", result.getName());
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```java
|
||||
package com.example.presentation.rest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class UserControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void createUserShouldReturnCreatedStatus() throws Exception {
|
||||
// Arrange
|
||||
String requestBody = """
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content(requestBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("John Doe"))
|
||||
.andExpect(jsonPath("$.email").value("john@example.com"));
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,597 @@
|
||||
# Spring Boot REST API Examples
|
||||
|
||||
## Complete CRUD REST API with Validation
|
||||
|
||||
### Entity with Validation
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Valid email required")
|
||||
@Column(unique = true)
|
||||
private String email;
|
||||
|
||||
@Min(value = 18, message = "Must be at least 18")
|
||||
@Max(value = 120, message = "Invalid age")
|
||||
private Integer age;
|
||||
|
||||
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
|
||||
private String password;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean active = true;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service with Transaction Management
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserResponse> findAll(Pageable pageable) {
|
||||
log.debug("Fetching users page {} size {}", pageable.getPageNumber(), pageable.getPageSize());
|
||||
return userRepository.findAll(pageable)
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse findById(Long id) {
|
||||
log.debug("Looking for user with id {}", id);
|
||||
return userRepository.findById(id)
|
||||
.map(this::toResponse)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse create(CreateUserRequest request) {
|
||||
log.info("Creating user with email: {}", request.getEmail());
|
||||
|
||||
if (userRepository.existsByEmail(request.getEmail())) {
|
||||
throw new BusinessException("Email already exists");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setName(request.getName());
|
||||
user.setEmail(request.getEmail());
|
||||
user.setAge(request.getAge());
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
emailService.sendWelcomeEmail(saved.getEmail(), saved.getName());
|
||||
|
||||
return toResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse update(Long id, UpdateUserRequest request) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found"));
|
||||
|
||||
if (request.getName() != null) {
|
||||
user.setName(request.getName());
|
||||
}
|
||||
if (request.getEmail() != null) {
|
||||
if (!user.getEmail().equals(request.getEmail()) &&
|
||||
userRepository.existsByEmail(request.getEmail())) {
|
||||
throw new BusinessException("Email already exists");
|
||||
}
|
||||
user.setEmail(request.getEmail());
|
||||
}
|
||||
if (request.getAge() != null) {
|
||||
user.setAge(request.getAge());
|
||||
}
|
||||
|
||||
User updated = userRepository.save(user);
|
||||
return toResponse(updated);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
throw new EntityNotFoundException("User not found");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(id).orElseThrow();
|
||||
emailService.sendDeletionEmail(user.getEmail(), user.getName());
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getName(),
|
||||
user.getEmail(),
|
||||
user.getAge(),
|
||||
user.getActive(),
|
||||
user.getCreatedAt(),
|
||||
user.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller with Proper HTTP Methods
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sortBy,
|
||||
@RequestParam(defaultValue = "DESC") String sortDirection) {
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
Page<UserResponse> users = userService.findAll(pageable);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("X-Total-Count", String.valueOf(users.getTotalElements()));
|
||||
headers.add("X-Total-Pages", String.valueOf(users.getTotalPages()));
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(users);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.findById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
UserResponse created = userService.create(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.header("Location", "/api/users/" + created.getId())
|
||||
.body(created);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UpdateUserRequest request) {
|
||||
UserResponse updated = userService.update(id, request);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> patchUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UpdateUserRequest request) {
|
||||
UserResponse updated = userService.update(id, request);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||
userService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Versioning Examples
|
||||
|
||||
### URL Versioning
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserControllerV1 {
|
||||
// Version 1 endpoints
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/users")
|
||||
public class UserControllerV2 {
|
||||
// Version 2 endpoints with different response format
|
||||
}
|
||||
```
|
||||
|
||||
### Header Versioning
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<UserResponse> getUsers(
|
||||
@RequestHeader(value = "Accept-Version", defaultValue = "1.0") String version) {
|
||||
|
||||
if (version.equals("2.0")) {
|
||||
return ResponseEntity.ok(v2UserResponse);
|
||||
}
|
||||
return ResponseEntity.ok(v1UserResponse);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Media Type Versioning
|
||||
```java
|
||||
@GetMapping(produces = {
|
||||
"application/vnd.company.v1+json",
|
||||
"application/vnd.company.v2+json"
|
||||
})
|
||||
public ResponseEntity<UserResponse> getUsers(
|
||||
@RequestHeader("Accept") String accept) {
|
||||
|
||||
if (accept.contains("v2")) {
|
||||
return ResponseEntity.ok(v2UserResponse);
|
||||
}
|
||||
return ResponseEntity.ok(v1UserResponse);
|
||||
}
|
||||
```
|
||||
|
||||
## HATEOAS Implementation
|
||||
|
||||
### Response with Links
|
||||
```java
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserResponseWithLinks {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String email;
|
||||
private Map<String, String> _links;
|
||||
|
||||
// Lombok generates constructors/getters/setters
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponseWithLinks> getUserWithLinks(@PathVariable Long id) {
|
||||
UserResponse user = userService.findById(id);
|
||||
|
||||
Map<String, String> links = Map.of(
|
||||
"self", "/api/users/" + id,
|
||||
"all", "/api/users",
|
||||
"update", "/api/users/" + id,
|
||||
"delete", "/api/users/" + id
|
||||
);
|
||||
|
||||
UserResponseWithLinks response = new UserResponseWithLinks(
|
||||
user.getId(), user.getName(), user.getEmail(), links);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced HATEOAS with Spring HATEOAS
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final EntityLinks entityLinks;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<CollectionModel<UserResponse>> getAllUsers() {
|
||||
List<UserResponse> users = userService.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
CollectionModel<UserResponse> resource = CollectionModel.of(users);
|
||||
resource.add(entityLinks.linkToCollectionResource(UserController.class).withSelfRel());
|
||||
resource.add(entityLinks.linkToCollectionResource(UserController.class).withRel("users"));
|
||||
|
||||
return ResponseEntity.ok(resource);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<EntityModel<UserResponse>> getUserById(@PathVariable Long id) {
|
||||
UserResponse user = userService.findById(id);
|
||||
|
||||
EntityModel<UserResponse> resource = EntityModel.of(user);
|
||||
resource.add(entityLinks.linkToItemResource(UserController.class, id).withSelfRel());
|
||||
resource.add(entityLinks.linkToCollectionResource(UserController.class).withRel("users"));
|
||||
resource.add(linkTo(methodOn(UserController.class).getUserOrders(id)).withRel("orders"));
|
||||
|
||||
return ResponseEntity.ok(resource);
|
||||
}
|
||||
|
||||
private UserResponse toResponse(User user) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getName(),
|
||||
user.getEmail(),
|
||||
user.getActive(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Processing
|
||||
|
||||
### Asynchronous Controller
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AsyncUserController {
|
||||
|
||||
private final AsyncUserService asyncUserService;
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CompletableFuture<ResponseEntity<UserResponse>> getUserById(@PathVariable Long id) {
|
||||
return asyncUserService.getUserById(id)
|
||||
.thenApply(ResponseEntity::ok)
|
||||
.exceptionally(ex -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public CompletableFuture<ResponseEntity<UserResponse>> createUser(
|
||||
@Valid @RequestBody CreateUserRequest request) {
|
||||
return asyncUserService.createUser(request)
|
||||
.thenApply(created ->
|
||||
ResponseEntity.status(HttpStatus.CREATED)
|
||||
.header("Location", "/api/users/" + created.getId())
|
||||
.body(created))
|
||||
.exceptionally(ex -> {
|
||||
if (ex.getCause() instanceof BusinessException) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return ResponseEntity.internalServerError().build();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Service Implementation
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AsyncUserService {
|
||||
|
||||
private final UserService userService;
|
||||
private final ExecutorService executor;
|
||||
|
||||
@Async
|
||||
public CompletableFuture<UserResponse> getUserById(Long id) {
|
||||
return CompletableFuture.supplyAsync(() -> userService.findById(id), executor);
|
||||
}
|
||||
|
||||
@Async
|
||||
public CompletableFuture<UserResponse> createUser(CreateUserRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> userService.create(request), executor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Upload and Download
|
||||
|
||||
### File Upload Controller
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileController {
|
||||
|
||||
private final FileStorageService fileStorageService;
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<FileUploadResponse> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
throw new BusinessException("File is empty");
|
||||
}
|
||||
|
||||
String fileName = fileStorageService.storeFile(file);
|
||||
String fileDownloadUri = ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||
.path("/api/files/download/")
|
||||
.path(fileName)
|
||||
.toUriString();
|
||||
|
||||
FileUploadResponse response = new FileUploadResponse(
|
||||
fileName, fileDownloadUri, file.getContentType(), file.getSize());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/download/{fileName:.+}")
|
||||
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) {
|
||||
Resource resource = fileStorageService.loadFileAsResource(fileName);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + resource.getFilename() + "\"")
|
||||
.body(resource);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Storage Service
|
||||
```java
|
||||
@Service
|
||||
public class FileStorageService {
|
||||
|
||||
private final Path fileStorageLocation;
|
||||
|
||||
@Autowired
|
||||
public FileStorageService(FileStorageProperties fileStorageProperties) {
|
||||
this.fileStorageLocation = Paths.get(fileStorageProperties.getUploadDir())
|
||||
.toAbsolutePath().normalize();
|
||||
|
||||
try {
|
||||
Files.createDirectories(this.fileStorageLocation);
|
||||
} catch (Exception ex) {
|
||||
throw new FileStorageException("Could not create the directory where the uploaded files will be stored.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public String storeFile(MultipartFile file) {
|
||||
String fileName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
|
||||
|
||||
try {
|
||||
if (fileName.contains("..")) {
|
||||
throw new FileStorageException("Sorry! Filename contains invalid path sequence " + fileName);
|
||||
}
|
||||
|
||||
Path targetLocation = this.fileStorageLocation.resolve(fileName);
|
||||
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
return fileName;
|
||||
} catch (IOException ex) {
|
||||
throw new FileStorageException("Could not store file " + fileName + ". Please try again!", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Resource loadFileAsResource(String fileName) {
|
||||
try {
|
||||
Path filePath = this.fileStorageLocation.resolve(fileName).normalize();
|
||||
Resource resource = new UrlResource(filePath);
|
||||
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return resource;
|
||||
} else {
|
||||
throw new FileNotFoundException("File not found " + fileName);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new FileNotFoundException("File not found " + fileName, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### WebSocket Configuration
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker("/topic");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Controller
|
||||
```java
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketController {
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@MessageMapping("/chat.sendMessage")
|
||||
@SendTo("/topic/public")
|
||||
public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
|
||||
return chatMessage;
|
||||
}
|
||||
|
||||
@MessageMapping("/chat.addUser")
|
||||
@SendTo("/topic/public")
|
||||
public ChatMessage addUser(@Payload ChatMessage chatMessage,
|
||||
SimpMessageHeaderAccessor headerAccessor) {
|
||||
headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
|
||||
return chatMessage;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 5000)
|
||||
public void sendPeriodicUpdates() {
|
||||
messagingTemplate.convertAndSend("/topic/updates",
|
||||
new UpdateMessage("System update", LocalDateTime.now()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Integration Example
|
||||
```javascript
|
||||
// JavaScript WebSocket client
|
||||
class WebSocketClient {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.stompClient = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
const socket = new SockJS(this.url);
|
||||
this.stompClient = Stomp.over(socket);
|
||||
|
||||
this.stompClient.connect({}, (frame) => {
|
||||
this.connected = true;
|
||||
console.log('Connected: ' + frame);
|
||||
|
||||
// Subscribe to topics
|
||||
this.stompClient.subscribe('/topic/public', (message) => {
|
||||
this.onMessage(message);
|
||||
});
|
||||
|
||||
this.stompClient.subscribe('/topic/updates', (update) => {
|
||||
this.onUpdate(update);
|
||||
});
|
||||
}, (error) => {
|
||||
this.connected = false;
|
||||
console.error('Error: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
sendMessage(message) {
|
||||
if (this.connected) {
|
||||
this.stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(message) {
|
||||
const chatMessage = JSON.parse(message.body);
|
||||
console.log('Received message:', chatMessage);
|
||||
// Display message in UI
|
||||
}
|
||||
|
||||
onUpdate(update) {
|
||||
const updateMessage = JSON.parse(update.body);
|
||||
console.log('Received update:', updateMessage);
|
||||
// Update UI with system messages
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# HTTP Methods and Status Codes Reference
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
| Method | Idempotent | Safe | Purpose | Typical Status |
|
||||
|--------|-----------|------|---------|----------------|
|
||||
| GET | Yes | Yes | Retrieve resource | 200, 304, 404 |
|
||||
| POST | No | No | Create resource | 201, 400, 409 |
|
||||
| PUT | Yes | No | Replace resource | 200, 204, 404 |
|
||||
| PATCH | No | No | Partial update | 200, 204, 400 |
|
||||
| DELETE | Yes | No | Remove resource | 204, 404 |
|
||||
| HEAD | Yes | Yes | Like GET, no body | 200, 304, 404 |
|
||||
| OPTIONS | Yes | Yes | Describe communication options | 200 |
|
||||
|
||||
### Idempotent Operations
|
||||
An operation is idempotent if making the same request multiple times produces the same result as making it once.
|
||||
|
||||
### Safe Operations
|
||||
A safe operation doesn't change the state of the server. Safe operations are always idempotent.
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
### 2xx Success
|
||||
- `200 OK`: Successful GET/PUT/PATCH
|
||||
- `201 Created`: Successful POST (include Location header)
|
||||
- `202 Accepted`: Async processing accepted
|
||||
- `204 No Content`: Successful DELETE or POST with no content
|
||||
- `206 Partial Content`: Range request successful
|
||||
|
||||
### 3xx Redirection
|
||||
- `301 Moved Permanently`: Resource permanently moved
|
||||
- `304 Not Modified`: Cache valid, use local copy
|
||||
- `307 Temporary Redirect`: Temporary redirect
|
||||
|
||||
### 4xx Client Errors
|
||||
- `400 Bad Request`: Invalid format or parameters
|
||||
- `401 Unauthorized`: Authentication required
|
||||
- `403 Forbidden`: Authenticated but not authorized
|
||||
- `404 Not Found`: Resource doesn't exist
|
||||
- `409 Conflict`: Constraint violation or conflict
|
||||
- `422 Unprocessable Entity`: Validation failed (semantic error)
|
||||
- `429 Too Many Requests`: Rate limit exceeded
|
||||
|
||||
### 5xx Server Errors
|
||||
- `500 Internal Server Error`: Unexpected server error
|
||||
- `502 Bad Gateway`: External service unavailable
|
||||
- `503 Service Unavailable`: Server temporarily down
|
||||
- `504 Gateway Timeout`: External service timeout
|
||||
|
||||
## Common REST API Patterns
|
||||
|
||||
### Resource URLs
|
||||
```
|
||||
GET /users # List all users
|
||||
GET /users/123 # Get specific user
|
||||
POST /users # Create user
|
||||
PUT /users/123 # Update user
|
||||
DELETE /users/123 # Delete user
|
||||
GET /users/123/orders # Get user's orders
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
```
|
||||
GET /users?page=0&size=20&sort=createdAt,desc
|
||||
- page: Page number (0-based)
|
||||
- size: Number of items per page
|
||||
- sort: Sorting format (field,direction)
|
||||
```
|
||||
|
||||
### Response Headers
|
||||
```
|
||||
Location: /api/users/123 # For 201 Created responses
|
||||
X-Total-Count: 45 # Total items count
|
||||
Cache-Control: no-cache # Cache control
|
||||
Content-Type: application/json # Response format
|
||||
```
|
||||
|
||||
## Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 400,
|
||||
"error": "Bad Request",
|
||||
"message": "Validation failed: name: Name cannot be blank, email: Valid email required",
|
||||
"path": "/api/users",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,387 @@
|
||||
# Pagination and Filtering Reference
|
||||
|
||||
## Pagination with Spring Data
|
||||
|
||||
### Basic Pagination
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<UserResponse> users = userService.findAll(pageable);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination with Sorting
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sortBy,
|
||||
@RequestParam(defaultValue = "DESC") String sortDirection) {
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
Page<UserResponse> users = userService.findAll(pageable);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-field Sorting
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "name") String sortBy,
|
||||
@RequestParam(defaultValue = "name,createdAt") String sortFields) {
|
||||
|
||||
List<Sort.Order> orders = Arrays.stream(sortFields.split(","))
|
||||
.map(field -> {
|
||||
String direction = field.startsWith("-") ? "DESC" : "ASC";
|
||||
String property = field.startsWith("-") ? field.substring(1) : field;
|
||||
return new Sort.Order(Sort.Direction.fromString(direction), property);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(orders));
|
||||
Page<UserResponse> users = userService.findAll(pageable);
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
### Standard Page Response
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"createdAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"pageable": {
|
||||
"sort": {
|
||||
"empty": false,
|
||||
"sorted": true,
|
||||
"unsorted": false
|
||||
},
|
||||
"offset": 0,
|
||||
"pageNumber": 0,
|
||||
"pageSize": 10,
|
||||
"paged": true,
|
||||
"unpaged": false
|
||||
},
|
||||
"last": false,
|
||||
"totalPages": 5,
|
||||
"totalElements": 45,
|
||||
"size": 10,
|
||||
"number": 0,
|
||||
"sort": {
|
||||
"empty": false,
|
||||
"sorted": true,
|
||||
"unsorted": false
|
||||
},
|
||||
"first": true,
|
||||
"numberOfElements": 10,
|
||||
"empty": false
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Page Response Wrapper
|
||||
```java
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PageResponse<T> {
|
||||
private List<T> content;
|
||||
private PageMetadata metadata;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PageMetadata {
|
||||
private int pageNumber;
|
||||
private int pageSize;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
private boolean first;
|
||||
private boolean last;
|
||||
}
|
||||
|
||||
// Controller
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<PageResponse<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
Page<User> pageResult = userService.findAll(pageable);
|
||||
|
||||
PageMetadata metadata = new PageMetadata(
|
||||
page,
|
||||
size,
|
||||
pageResult.getTotalElements(),
|
||||
pageResult.getTotalPages(),
|
||||
pageResult.isFirst(),
|
||||
pageResult.isLast()
|
||||
);
|
||||
|
||||
PageResponse<UserResponse> response = new PageResponse<>(
|
||||
pageResult.stream()
|
||||
.map(this::toResponse)
|
||||
.collect(Collectors.toList()),
|
||||
metadata
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
```
|
||||
|
||||
## Filtering
|
||||
|
||||
### Query Parameter Filtering
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getUsers(
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String email,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
|
||||
Specification<User> spec = Specification.where(null);
|
||||
|
||||
if (name != null && !name.isEmpty()) {
|
||||
spec = spec.and((root, query, cb) ->
|
||||
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"));
|
||||
}
|
||||
|
||||
if (email != null && !email.isEmpty()) {
|
||||
spec = spec.and((root, query, cb) ->
|
||||
cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%"));
|
||||
}
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<User> pageResult = userService.findAll(spec, pageable);
|
||||
|
||||
return ResponseEntity.ok(pageResult.map(this::toResponse));
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Specification Builder
|
||||
```java
|
||||
public class UserSpecifications {
|
||||
|
||||
public static Specification<User> hasName(String name) {
|
||||
return (root, query, cb) ->
|
||||
name == null ? null :
|
||||
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
|
||||
}
|
||||
|
||||
public static Specification<User> hasEmail(String email) {
|
||||
return (root, query, cb) ->
|
||||
email == null ? null :
|
||||
cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%");
|
||||
}
|
||||
|
||||
public static Specification<User> isActive(Boolean active) {
|
||||
return (root, query, cb) ->
|
||||
active == null ? null :
|
||||
cb.equal(root.get("active"), active);
|
||||
}
|
||||
|
||||
public static Specification<User> createdAfter(LocalDate date) {
|
||||
return (root, query, cb) ->
|
||||
date == null ? null :
|
||||
cb.greaterThanOrEqualTo(root.get("createdAt"), date.atStartOfDay());
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getUsers(
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String email,
|
||||
@RequestParam(required = false) Boolean active,
|
||||
@RequestParam(required = false) LocalDate createdAfter,
|
||||
Pageable pageable) {
|
||||
|
||||
Specification<User> spec = Specification.where(UserSpecifications.hasName(name))
|
||||
.and(UserSpecifications.hasEmail(email))
|
||||
.and(UserSpecifications.isActive(active))
|
||||
.and(UserSpecifications.createdAfter(createdAfter));
|
||||
|
||||
Page<User> pageResult = userService.findAll(spec, pageable);
|
||||
return ResponseEntity.ok(pageResult.map(this::toResponse));
|
||||
}
|
||||
```
|
||||
|
||||
### Date Range Filtering
|
||||
```java
|
||||
@GetMapping("/orders")
|
||||
public ResponseEntity<Page<OrderResponse>> getOrders(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
Pageable pageable) {
|
||||
|
||||
Specification<Order> spec = Specification.where(null);
|
||||
|
||||
if (startDate != null) {
|
||||
spec = spec.and((root, query, cb) ->
|
||||
cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay()));
|
||||
}
|
||||
|
||||
if (endDate != null) {
|
||||
spec = spec.and((root, query, cb) ->
|
||||
cb.lessThanOrEqualTo(root.get("createdAt"), endDate.atEndOfDay()));
|
||||
}
|
||||
|
||||
Page<Order> pageResult = orderService.findAll(spec, pageable);
|
||||
return ResponseEntity.ok(pageResult.map(this::toResponse));
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Filtering
|
||||
|
||||
### Filter DTO Pattern
|
||||
```java
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserFilter {
|
||||
private String name;
|
||||
private String email;
|
||||
private Boolean active;
|
||||
private LocalDate createdAfter;
|
||||
private LocalDate createdBefore;
|
||||
private List<Long> roleIds;
|
||||
|
||||
public Specification<User> toSpecification() {
|
||||
Specification<User> spec = Specification.where(null);
|
||||
|
||||
if (name != null && !name.isEmpty()) {
|
||||
spec = spec.and(hasName(name));
|
||||
}
|
||||
|
||||
if (email != null && !email.isEmpty()) {
|
||||
spec = spec.and(hasEmail(email));
|
||||
}
|
||||
|
||||
if (active != null) {
|
||||
spec = spec.and(isActive(active));
|
||||
}
|
||||
|
||||
if (createdAfter != null) {
|
||||
spec = spec.and(createdAfter(createdAfter));
|
||||
}
|
||||
|
||||
if (createdBefore != null) {
|
||||
spec = spec.and(createdBefore(createdBefore));
|
||||
}
|
||||
|
||||
if (roleIds != null && !roleIds.isEmpty()) {
|
||||
spec = spec.and(hasRoles(roleIds));
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
|
||||
// Controller
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getUsers(
|
||||
UserFilter filter,
|
||||
Pageable pageable) {
|
||||
|
||||
Specification<User> spec = filter.toSpecification();
|
||||
Page<User> pageResult = userService.findAll(spec, pageable);
|
||||
return ResponseEntity.ok(pageResult.map(this::toResponse));
|
||||
}
|
||||
```
|
||||
|
||||
## Link Headers for Pagination
|
||||
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<UserResponse>> getAllUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
Pageable pageable) {
|
||||
|
||||
Page<UserResponse> pageResult = userService.findAll(pageable);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("X-Total-Count", String.valueOf(pageResult.getTotalElements()));
|
||||
headers.add("X-Total-Pages", String.valueOf(pageResult.getTotalPages()));
|
||||
headers.add("X-Page-Number", String.valueOf(pageResult.getNumber()));
|
||||
headers.add("X-Page-Size", String.valueOf(pageResult.getSize()));
|
||||
|
||||
// Link headers for pagination
|
||||
if (pageResult.hasNext()) {
|
||||
headers.add("Link", buildLinkHeader(pageResult.getNumber() + 1, size));
|
||||
}
|
||||
if (pageResult.hasPrevious()) {
|
||||
headers.add("Link", buildLinkHeader(pageResult.getNumber() - 1, size));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(pageResult);
|
||||
}
|
||||
|
||||
private String buildLinkHeader(int page, int size) {
|
||||
return String.format("<%s/api/users?page=%d&size=%d>; rel=\"next\"",
|
||||
baseUrl, page, size);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Optimization
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserResponse> findAll(Specification<User> spec, Pageable pageable) {
|
||||
// Use projection to only fetch needed fields
|
||||
Page<User> page = userRepository.findAll(spec, pageable);
|
||||
return page.stream()
|
||||
.map(this::toResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Pagination Results
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
public Page<UserResponse> findAll(Specification<User> spec, Pageable pageable) {
|
||||
String cacheKey = "users:" + spec.hashCode() + ":" + pageable.hashCode();
|
||||
|
||||
return cacheManager.getCache("users").get(cacheKey, () -> {
|
||||
Page<User> page = userRepository.findAll(spec, pageable);
|
||||
return page.map(this::toResponse);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,376 @@
|
||||
# Spring Boot REST API Standards - References
|
||||
|
||||
Complete reference for REST API development with Spring Boot.
|
||||
|
||||
## HTTP Methods and Status Codes Reference
|
||||
|
||||
### HTTP Methods
|
||||
|
||||
| Method | Idempotent | Safe | Purpose | Typical Status |
|
||||
|--------|-----------|------|---------|----------------|
|
||||
| GET | Yes | Yes | Retrieve resource | 200, 304, 404 |
|
||||
| POST | No | No | Create resource | 201, 400, 409 |
|
||||
| PUT | Yes | No | Replace resource | 200, 204, 404 |
|
||||
| PATCH | No | No | Partial update | 200, 204, 400 |
|
||||
| DELETE | Yes | No | Remove resource | 204, 404 |
|
||||
| HEAD | Yes | Yes | Like GET, no body | 200, 304, 404 |
|
||||
| OPTIONS | Yes | Yes | Describe communication options | 200 |
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
**2xx Success:**
|
||||
- `200 OK` - Successful GET/PUT/PATCH
|
||||
- `201 Created` - Successful POST (include Location header)
|
||||
- `202 Accepted` - Async processing accepted
|
||||
- `204 No Content` - Successful DELETE or POST with no content
|
||||
- `206 Partial Content` - Range request successful
|
||||
|
||||
**3xx Redirection:**
|
||||
- `301 Moved Permanently` - Resource permanently moved
|
||||
- `304 Not Modified` - Cache valid, use local copy
|
||||
- `307 Temporary Redirect` - Temporary redirect
|
||||
|
||||
**4xx Client Errors:**
|
||||
- `400 Bad Request` - Invalid format or parameters
|
||||
- `401 Unauthorized` - Authentication required
|
||||
- `403 Forbidden` - Authenticated but not authorized
|
||||
- `404 Not Found` - Resource doesn't exist
|
||||
- `409 Conflict` - Constraint violation or conflict
|
||||
- `422 Unprocessable Entity` - Validation failed (semantic error)
|
||||
|
||||
**5xx Server Errors:**
|
||||
- `500 Internal Server Error` - Unexpected server error
|
||||
- `502 Bad Gateway` - External service unavailable
|
||||
- `503 Service Unavailable` - Server temporarily down
|
||||
|
||||
## Spring Web Annotations Reference
|
||||
|
||||
### Request Mapping Annotations
|
||||
|
||||
```java
|
||||
@RestController // Combines @Controller + @ResponseBody
|
||||
@RequestMapping("/api") // Base URL path
|
||||
@GetMapping // GET requests
|
||||
@PostMapping // POST requests
|
||||
@PutMapping // PUT requests
|
||||
@PatchMapping // PATCH requests
|
||||
@DeleteMapping // DELETE requests
|
||||
```
|
||||
|
||||
### Parameter Binding Annotations
|
||||
|
||||
```java
|
||||
@PathVariable // URL path variable /{id}
|
||||
@RequestParam // Query string parameter ?page=0
|
||||
@RequestParam(required=false) // Optional parameter
|
||||
@RequestParam(defaultValue="10") // Default value
|
||||
@RequestBody // Request body JSON/XML
|
||||
@RequestHeader // HTTP header value
|
||||
@CookieValue // Cookie value
|
||||
@MatrixVariable // Matrix variable ;color=red
|
||||
@Valid // Enable validation
|
||||
```
|
||||
|
||||
### Response Annotations
|
||||
|
||||
```java
|
||||
@ResponseBody // Serialize to response body
|
||||
@ResponseStatus(status=HttpStatus.CREATED) // HTTP status
|
||||
ResponseEntity<T> // Full response control
|
||||
ResponseEntity.ok(body) // 200 OK
|
||||
ResponseEntity.created(uri).body(body) // 201 Created
|
||||
ResponseEntity.noContent().build() // 204 No Content
|
||||
ResponseEntity.notFound().build() // 404 Not Found
|
||||
```
|
||||
|
||||
## DTO Patterns Reference
|
||||
|
||||
### Request DTO (using Records)
|
||||
|
||||
```java
|
||||
public record CreateProductRequest(
|
||||
@NotBlank(message = "Name required") String name,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal price,
|
||||
String description,
|
||||
@NotNull @Min(0) Integer stock
|
||||
) {}
|
||||
```
|
||||
|
||||
### Response DTO (using Records)
|
||||
|
||||
```java
|
||||
public record ProductResponse(
|
||||
Long id,
|
||||
String name,
|
||||
BigDecimal price,
|
||||
Integer stock,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {}
|
||||
```
|
||||
|
||||
### Update DTO
|
||||
|
||||
```java
|
||||
public record UpdateProductRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal price,
|
||||
String description
|
||||
) {}
|
||||
```
|
||||
|
||||
## Validation Annotations Reference
|
||||
|
||||
### Common Constraints
|
||||
|
||||
```java
|
||||
@NotNull // Cannot be null
|
||||
@NotEmpty // Collection/String cannot be empty
|
||||
@NotBlank // String cannot be null/blank
|
||||
@Size(min=1, max=255) // Length validation
|
||||
@Min(value=1) // Minimum numeric value
|
||||
@Max(value=100) // Maximum numeric value
|
||||
@Positive // Must be positive
|
||||
@Negative // Must be negative
|
||||
@Email // Valid email format
|
||||
@Pattern(regexp="...") // Regex validation
|
||||
@Future // Date must be future
|
||||
@Past // Date must be past
|
||||
@Digits(integer=5, fraction=2) // Numeric precision
|
||||
@DecimalMin("0.01") // Decimal minimum
|
||||
@DecimalMax("9999.99") // Decimal maximum
|
||||
```
|
||||
|
||||
### Custom Validation
|
||||
|
||||
```java
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = UniqueEmailValidator.class)
|
||||
public @interface UniqueEmail {
|
||||
String message() default "Email already exists";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
||||
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
|
||||
@Autowired
|
||||
private UserRepository repository;
|
||||
|
||||
@Override
|
||||
public boolean isValid(String email, ConstraintValidatorContext context) {
|
||||
return !repository.existsByEmail(email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination Reference
|
||||
|
||||
### Pageable Request Building
|
||||
|
||||
```java
|
||||
// Basic pagination
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
|
||||
// With sorting
|
||||
Sort sort = Sort.by("createdAt").descending();
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
|
||||
// Multiple sort fields
|
||||
Sort sort = Sort.by("status").ascending()
|
||||
.and(Sort.by("createdAt").descending());
|
||||
Pageable pageable = PageRequest.of(page, size, sort);
|
||||
```
|
||||
|
||||
### Pagination Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{ "id": 1, "name": "Product 1" },
|
||||
{ "id": 2, "name": "Product 2" }
|
||||
],
|
||||
"pageable": {
|
||||
"offset": 0,
|
||||
"pageNumber": 0,
|
||||
"pageSize": 20,
|
||||
"paged": true
|
||||
},
|
||||
"totalElements": 100,
|
||||
"totalPages": 5,
|
||||
"last": false,
|
||||
"size": 20,
|
||||
"number": 0,
|
||||
"numberOfElements": 20,
|
||||
"first": true,
|
||||
"empty": false
|
||||
}
|
||||
```
|
||||
|
||||
## Error Response Format
|
||||
|
||||
### Standardized Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 400,
|
||||
"error": "Bad Request",
|
||||
"message": "Validation failed: name: Name is required",
|
||||
"path": "/api/v1/products",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Global Exception Handler Pattern
|
||||
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(
|
||||
MethodArgumentNotValidException ex) {
|
||||
// Handle validation errors
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(
|
||||
ResourceNotFoundException ex) {
|
||||
// Handle not found
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
|
||||
// Handle generic exceptions
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Content Negotiation
|
||||
|
||||
### Accept Header Examples
|
||||
|
||||
```
|
||||
Accept: application/json # JSON response
|
||||
Accept: application/xml # XML response
|
||||
Accept: application/vnd.api+json # JSON:API standard
|
||||
Accept: text/csv # CSV response
|
||||
```
|
||||
|
||||
### Controller Implementation
|
||||
|
||||
```java
|
||||
@GetMapping(produces = {MediaType.APPLICATION_JSON_VALUE, "application/xml"})
|
||||
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
|
||||
// Supports both JSON and XML
|
||||
return ResponseEntity.ok(productService.findById(id));
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination Best Practices
|
||||
|
||||
```java
|
||||
// Limit maximum page size
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ProductResponse>> getAll(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20")
|
||||
@Max(value = 100, message = "Max page size is 100") int size) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(productService.findAll(pageable));
|
||||
}
|
||||
```
|
||||
|
||||
## Maven Dependencies
|
||||
|
||||
```xml
|
||||
<!-- Spring Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Data JPA (for Pageable) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Testing REST APIs
|
||||
|
||||
### MockMvc Testing
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class ProductControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void shouldCreateProduct() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/products")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Test\",\"price\":10.00}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").exists());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TestRestTemplate Testing
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class ProductIntegrationTest {
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
void shouldFetchProduct() {
|
||||
ResponseEntity<ProductResponse> response = restTemplate.getForEntity(
|
||||
"/api/v1/products/1", ProductResponse.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **spring-boot-crud-patterns/SKILL.md** - CRUD operations following REST principles
|
||||
- **spring-boot-dependency-injection/SKILL.md** - Dependency injection in controllers
|
||||
- **spring-boot-test-patterns/SKILL.md** - Testing REST APIs
|
||||
- **spring-boot-exception-handling/SKILL.md** - Global error handling
|
||||
|
||||
## External Resources
|
||||
|
||||
### Official Documentation
|
||||
- [Spring Web MVC Documentation](https://docs.spring.io/spring-framework/reference/web/webmvc.html)
|
||||
- [Spring REST Documentation](https://spring.io/guides/gs/rest-service/)
|
||||
- [REST API Best Practices](https://restfulapi.net/)
|
||||
|
||||
### Related Standards
|
||||
- [JSON:API Specification](https://jsonapi.org/)
|
||||
- [OpenAPI Specification](https://www.openapis.org/)
|
||||
- [RFC 7231 - HTTP Semantics](https://tools.ietf.org/html/rfc7231)
|
||||
|
||||
### Books
|
||||
- "RESTful Web Services" by Leonard Richardson & Sam Ruby
|
||||
- "Spring in Action" (latest edition)
|
||||
@@ -0,0 +1,521 @@
|
||||
# Security Headers and CORS Configuration
|
||||
|
||||
## Security Headers Configuration
|
||||
|
||||
### Basic Security Headers
|
||||
```java
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp
|
||||
.policyDirectives("default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self';")
|
||||
.reportOnly(false))
|
||||
.frameOptions(frame -> frame
|
||||
.sameOrigin()
|
||||
.deny()) // Use sameOrigin() for same-origin iframes, deny() to completely block
|
||||
.httpStrictTransportSecurity(hsts -> hsts
|
||||
.maxAgeInSeconds(31536000) // 1 year
|
||||
.includeSubDomains(true)
|
||||
.preload(true))
|
||||
.xssProtection(xss -> xss
|
||||
.headerValue(XssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
|
||||
.contentTypeOptions(contentTypeOptions -> contentTypeOptions
|
||||
.and())
|
||||
)
|
||||
.cors(cors -> cors
|
||||
.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
|
||||
configuration.setExposedHeaders(Arrays.asList("X-Total-Count", "X-Content-Type-Options"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Security Configuration
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class EnhancedSecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/**")
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp
|
||||
.policyDirectives("default-src 'self'; " +
|
||||
"script-src 'self 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self' https:; " +
|
||||
"frame-src 'none'; " +
|
||||
"object-src 'none';"))
|
||||
.frameOptions(frameOptions -> frameOptions.sameOrigin())
|
||||
.httpStrictTransportSecurity(hsts -> hsts
|
||||
.maxAgeInSeconds(31536000)
|
||||
.includeSubDomains(true)
|
||||
.preload(true)
|
||||
.includeSubDomains(true))
|
||||
.permissionsPolicy(permissionsPolicy -> permissionsPolicy
|
||||
.add("camera", "()")
|
||||
.add("geolocation", "()")
|
||||
.add("microphone", "()")
|
||||
.add("payment", "()"))
|
||||
.referrerPolicy(referrerPolicy -> referrerPolicy.noReferrer())
|
||||
.and())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers("/api/auth/**")
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/public/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// Allowed origins (consider restricting to specific domains in production)
|
||||
configuration.setAllowedOriginPatterns(List.of("https://yourdomain.com", "https://app.yourdomain.com"));
|
||||
|
||||
// Allowed methods
|
||||
configuration.setAllowedMethods(Arrays.asList(
|
||||
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
||||
));
|
||||
|
||||
// Allowed headers
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"X-Requested-With",
|
||||
"X-Content-Type-Options",
|
||||
"X-Total-Count",
|
||||
"Cache-Control"
|
||||
));
|
||||
|
||||
// Exposed headers to client
|
||||
configuration.setExposedHeaders(Arrays.asList(
|
||||
"X-Total-Count",
|
||||
"X-Content-Type-Options",
|
||||
"Cache-Control"
|
||||
));
|
||||
|
||||
// Allow credentials
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Cache preflight requests for 1 hour
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Content Security Policy (CSP)
|
||||
|
||||
### Basic CSP Configuration
|
||||
```java
|
||||
@Configuration
|
||||
public class ContentSecurityPolicyConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("https://yourdomain.com")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.exposedHeaders("X-Total-Count")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new ContentSecurityPolicyInterceptor());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
public class ContentSecurityPolicyInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public void postHandle(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler, ModelAndView modelAndView) throws Exception {
|
||||
response.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-src 'none'; " +
|
||||
"object-src 'none';");
|
||||
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("X-Frame-Options", "DENY");
|
||||
response.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced CSP with Nonce
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityHeadersFilter extends OncePerRequestFilter {
|
||||
|
||||
private final AtomicLong nonceCounter = new AtomicLong(0);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// Generate nonce for each request
|
||||
String nonce = String.valueOf(nonceCounter.incrementAndGet());
|
||||
|
||||
// Set CSP header with nonce for inline scripts
|
||||
response.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-src 'none'; " +
|
||||
"object-src 'none';");
|
||||
|
||||
// Add nonce to request attributes for templates
|
||||
request.setAttribute("cspNonce", nonce);
|
||||
|
||||
// Set other security headers
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("X-Frame-Options", "SAMEORIGIN");
|
||||
response.setHeader("Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains; preload");
|
||||
response.setHeader("X-Permitted-Cross-Domain-Policies", "none");
|
||||
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
### Method-level CORS
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@CrossOrigin(origins = "https://yourdomain.com", methods = {RequestMethod.GET, RequestMethod.POST})
|
||||
public class UserController {
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<User>> getAllUsers() {
|
||||
// CORS allowed for GET requests
|
||||
return ResponseEntity.ok(userService.findAll());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@CrossOrigin(origins = "https://app.yourdomain.com")
|
||||
public ResponseEntity<User> createUser(@RequestBody User user) {
|
||||
// CORS allowed with different origin for POST requests
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(user));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic CORS Configuration
|
||||
```java
|
||||
@Configuration
|
||||
public class DynamicCorsConfig {
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
// Development configuration
|
||||
CorsConfiguration devConfig = new CorsConfiguration();
|
||||
devConfig.setAllowedOriginPatterns(List.of("*"));
|
||||
devConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
devConfig.setAllowedHeaders(Arrays.asList("*"));
|
||||
devConfig.setAllowCredentials(true);
|
||||
source.registerCorsConfiguration("/api/**", devConfig);
|
||||
|
||||
// Production configuration - restrict to specific domains
|
||||
CorsConfiguration prodConfig = new CorsConfiguration();
|
||||
prodConfig.setAllowedOriginPatterns(List.of(
|
||||
"https://yourdomain.com",
|
||||
"https://app.yourdomain.com",
|
||||
"https://api.yourdomain.com"
|
||||
));
|
||||
prodConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
prodConfig.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"X-Requested-With"
|
||||
));
|
||||
prodConfig.setExposedHeaders(Arrays.asList("X-Total-Count"));
|
||||
prodConfig.setAllowCredentials(true);
|
||||
source.registerCorsConfiguration("/api/**", prodConfig);
|
||||
|
||||
return source;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Headers Best Practices
|
||||
|
||||
### Essential Headers for Production
|
||||
1. **Content-Security-Policy**: Mitigates XSS attacks
|
||||
2. **X-Content-Type-Options**: Prevents MIME type sniffing
|
||||
3. **X-Frame-Options**: Prevents clickjacking
|
||||
4. **Strict-Transport-Security**: Enforces HTTPS
|
||||
5. **X-XSS-Protection**: Legacy browser XSS protection
|
||||
6. **Referrer-Policy**: Controls referrer information
|
||||
|
||||
### CSP Examples by Application Type
|
||||
|
||||
#### Blog/Content Site
|
||||
```java
|
||||
response.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' https://cdn.jsdelivr.net; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' https: data:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-src https://www.youtube.com; " +
|
||||
"media-src https://www.youtube.com;");
|
||||
```
|
||||
|
||||
#### Single Page Application (SPA)
|
||||
```java
|
||||
response.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self' wss:; " +
|
||||
"frame-src 'none'; " +
|
||||
"object-src 'none';");
|
||||
```
|
||||
|
||||
#### API Only
|
||||
```java
|
||||
response.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-src 'none'; " +
|
||||
"object-src 'none';");
|
||||
```
|
||||
|
||||
### Security Header Testing
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class SecurityHeadersTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void securityHeaders_shouldBeSet() throws Exception {
|
||||
mockMvc.perform(get("/api/users"))
|
||||
.andExpect(header().string("Content-Security-Policy", notNullValue()))
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
|
||||
.andExpect(header().string("X-Frame-Options", notNullValue()))
|
||||
.andExpect(header().string("Strict-Transport-Security", notNullValue()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Basic Rate Limiting
|
||||
```java
|
||||
@Component
|
||||
public class RateLimitingFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ConcurrentHashMap<String, RateLimit> rateLimits = new ConcurrentHashMap<>();
|
||||
private static final long REQUEST_LIMIT = 100;
|
||||
private static final long TIME_WINDOW = 60_000; // 1 minute
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String clientIp = request.getRemoteAddr();
|
||||
String path = request.getRequestURI();
|
||||
String key = clientIp + ":" + path;
|
||||
|
||||
RateLimit rateLimit = rateLimits.computeIfAbsent(key, k -> new RateLimit());
|
||||
|
||||
synchronized (rateLimit) {
|
||||
if (System.currentTimeMillis() - rateLimit.resetTime > TIME_WINDOW) {
|
||||
rateLimit.count = 0;
|
||||
rateLimit.resetTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
if (rateLimit.count >= REQUEST_LIMIT) {
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.getWriter().write("Rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
rateLimit.count++;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private static class RateLimit {
|
||||
long count = 0;
|
||||
long resetTime = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token-based Authentication Headers
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String jwt = getJwtFromRequest(request);
|
||||
|
||||
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
jwtTokenProvider.getAuthentication(jwt);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (Exception ex) {
|
||||
logger.error("Could not set user authentication in security context", ex);
|
||||
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
private String getJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Security
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker("/topic");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("https://yourdomain.com")
|
||||
.withSockJS();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(new ChannelInterceptor() {
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
|
||||
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
// Validate token and authenticate
|
||||
String token = accessor.getFirstNativeHeader("Authorization");
|
||||
if (!isValidToken(token)) {
|
||||
throw new UnauthorizedWebSocketException("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,309 @@
|
||||
# Spring Web Annotations Reference
|
||||
|
||||
## Controller and Mapping Annotations
|
||||
|
||||
### @RestController
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
// Returns JSON responses automatically
|
||||
}
|
||||
```
|
||||
|
||||
### @Controller
|
||||
```java
|
||||
@Controller
|
||||
@RequestMapping("/users")
|
||||
public class UserController {
|
||||
// Returns view names for MVC applications
|
||||
}
|
||||
```
|
||||
|
||||
### @RequestMapping
|
||||
```java
|
||||
// Class level
|
||||
@RequestMapping("/api")
|
||||
@RequestMapping(path = "/api", method = RequestMethod.GET)
|
||||
|
||||
// Method level
|
||||
@RequestMapping("/users")
|
||||
@RequestMapping(path = "/users", method = RequestMethod.POST)
|
||||
```
|
||||
|
||||
### HTTP Method Annotations
|
||||
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public List<User> getUsers() { ... }
|
||||
|
||||
@PostMapping("/users")
|
||||
public User createUser(@RequestBody User user) { ... }
|
||||
|
||||
@PutMapping("/users/{id}")
|
||||
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }
|
||||
|
||||
@PatchMapping("/users/{id}")
|
||||
public User patchUser(@PathVariable Long id, @RequestBody User user) { ... }
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
public void deleteUser(@PathVariable Long id) { ... }
|
||||
|
||||
@HeadMapping("/users/{id}")
|
||||
public ResponseEntity<Void> headUser(@PathVariable Long id) { ... }
|
||||
|
||||
@OptionsMapping("/users")
|
||||
public ResponseEntity<Void> optionsUsers() { ... }
|
||||
```
|
||||
|
||||
## Parameter Binding Annotations
|
||||
|
||||
### @PathVariable
|
||||
```java
|
||||
@GetMapping("/users/{id}")
|
||||
public User getUser(@PathVariable Long id) { ... }
|
||||
|
||||
// Multiple path variables
|
||||
@GetMapping("/users/{userId}/orders/{orderId}")
|
||||
public Order getOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }
|
||||
|
||||
// Custom variable name
|
||||
@GetMapping("/users/{userId}")
|
||||
public User getUser(@PathVariable("userId") Long id) { ... }
|
||||
```
|
||||
|
||||
### @RequestParam
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public List<User> getUsers(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(defaultValue = "createdAt") String sortBy,
|
||||
@RequestParam(defaultValue = "DESC") String sortDirection) {
|
||||
// Handle pagination, filtering, and sorting
|
||||
}
|
||||
```
|
||||
|
||||
### @RequestBody
|
||||
```java
|
||||
@PostMapping("/users")
|
||||
public User createUser(@RequestBody User user) { ... }
|
||||
|
||||
// With validation
|
||||
@PostMapping("/users")
|
||||
public User createUser(@Valid @RequestBody User user) { ... }
|
||||
```
|
||||
|
||||
### @RequestHeader
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public List<User> getUsers(@RequestHeader("Authorization") String authHeader) { ... }
|
||||
|
||||
// Multiple headers
|
||||
@PostMapping("/users")
|
||||
public User createUser(
|
||||
@RequestBody User user,
|
||||
@RequestHeader("X-Custom-Header") String customHeader) { ... }
|
||||
```
|
||||
|
||||
### @CookieValue
|
||||
```java
|
||||
@GetMapping("/users")
|
||||
public List<User> getUsers(@CookieValue("JSESSIONID") String sessionId) { ... }
|
||||
```
|
||||
|
||||
### @MatrixVariable
|
||||
```java
|
||||
@GetMapping("/users/{id}")
|
||||
public User getUser(
|
||||
@PathVariable Long id,
|
||||
@MatrixVariable(pathVar = "id", required = false) Map<String, String> params) {
|
||||
// Handle matrix variables: /users/123;name=John;age=30
|
||||
}
|
||||
```
|
||||
|
||||
## Response Annotations
|
||||
|
||||
### @ResponseStatus
|
||||
```java
|
||||
@PostMapping("/users")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public User createUser(@RequestBody User user) { ... }
|
||||
```
|
||||
|
||||
### @ResponseBody
|
||||
```java
|
||||
@Controller
|
||||
public class UserController {
|
||||
@GetMapping("/users")
|
||||
@ResponseBody
|
||||
public List<User> getUsers() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### ResponseEntity
|
||||
```java
|
||||
@GetMapping("/users/{id}")
|
||||
public ResponseEntity<User> getUser(@PathVariable Long id) {
|
||||
return userRepository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/users")
|
||||
public ResponseEntity<User> createUser(@RequestBody User user) {
|
||||
User created = userService.create(user);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.header("Location", "/api/users/" + created.getId())
|
||||
.body(created);
|
||||
}
|
||||
```
|
||||
|
||||
## Content Negotiation
|
||||
|
||||
### Produces and Consumes
|
||||
```java
|
||||
@GetMapping(value = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<User> getUsers() { ... }
|
||||
|
||||
@PostMapping(value = "/users", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public User createUser(@RequestBody User user) { ... }
|
||||
|
||||
// Multiple media types
|
||||
@GetMapping(value = "/users", produces = {
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
MediaType.APPLICATION_XML_VALUE
|
||||
})
|
||||
public List<User> getUsers() { ... }
|
||||
```
|
||||
|
||||
### @RequestBody with Content-Type
|
||||
```java
|
||||
@PostMapping(value = "/users", consumes = "application/json")
|
||||
public User createUserJson(@RequestBody User user) { ... }
|
||||
|
||||
@PostMapping(value = "/users", consumes = "application/xml")
|
||||
public User createUserXml(@RequestBody User user) { ... }
|
||||
```
|
||||
|
||||
## Validation Annotations
|
||||
|
||||
### @Valid
|
||||
```java
|
||||
@PostMapping("/users")
|
||||
public User createUser(@Valid @RequestBody User user) { ... }
|
||||
|
||||
// Validates individual parameters
|
||||
@GetMapping("/users")
|
||||
public User getUser(
|
||||
@Valid @Pattern(regexp = "^[a-zA-Z0-9]+$") @PathVariable String id) { ... }
|
||||
```
|
||||
|
||||
### Jakarta Bean Validation Annotations
|
||||
```java
|
||||
public class UserRequest {
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
|
||||
@Email(message = "Valid email required")
|
||||
private String email;
|
||||
|
||||
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
|
||||
private String password;
|
||||
|
||||
@Min(value = 18, message = "Must be at least 18")
|
||||
@Max(value = 120, message = "Invalid age")
|
||||
private Integer age;
|
||||
|
||||
@Pattern(regexp = "^[A-Z][a-z]+$", message = "Invalid name format")
|
||||
private String firstName;
|
||||
|
||||
@NotEmpty(message = "At least one role required")
|
||||
private Set<String> roles = new HashSet<>();
|
||||
|
||||
@Future(message = "Date must be in the future")
|
||||
private LocalDate futureDate;
|
||||
|
||||
@Past(message = "Date must be in the past")
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Positive(message = "Value must be positive")
|
||||
private Double positiveValue;
|
||||
|
||||
@PositiveOrZero(message = "Value must be positive or zero")
|
||||
private Double nonNegativeValue;
|
||||
}
|
||||
```
|
||||
|
||||
## Specialized Annotations
|
||||
|
||||
### @RestControllerAdvice
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidationException(
|
||||
MethodArgumentNotValidException ex) {
|
||||
// Handle validation errors globally
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @ExceptionHandler
|
||||
```java
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(NoHandlerFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(NoHandlerFoundException ex) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @CrossOrigin
|
||||
```java
|
||||
@CrossOrigin(origins = "http://localhost:3000")
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
// Enable CORS for specific origin
|
||||
}
|
||||
|
||||
// Or at method level
|
||||
@CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST})
|
||||
@GetMapping("/users")
|
||||
public List<User> getUsers() { ... }
|
||||
```
|
||||
|
||||
## Async Processing
|
||||
|
||||
### @Async
|
||||
```java
|
||||
@Service
|
||||
public class AsyncService {
|
||||
|
||||
@Async
|
||||
public CompletableFuture<User> processUser(User user) {
|
||||
// Long-running operation
|
||||
return CompletableFuture.completedFuture(processedUser);
|
||||
}
|
||||
}
|
||||
|
||||
@RestController
|
||||
public class UserController {
|
||||
|
||||
@GetMapping("/users/{id}/async")
|
||||
public CompletableFuture<ResponseEntity<User>> getUserAsync(@PathVariable Long id) {
|
||||
return userService.processUser(id)
|
||||
.thenApply(ResponseEntity::ok)
|
||||
.exceptionally(ex -> ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user