Initial commit
This commit is contained in:
@@ -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