Build REST APIs with Spring Boot and Spring MVC
Spring REST APIs
REST (Representational State Transfer) is an architectural style for designing networked applications, and Spring Boot provides excellent support for building RESTful web services. Spring's REST capabilities, primarily through Spring MVC, make it straightforward to create APIs that follow REST principles.
Table of Contents
- REST Fundamentals
- REST Controllers
- HTTP Methods and Request Mapping
- Request and Response Handling
- Data Validation
- Exception Handling
- Content Negotiation
- HATEOAS
- API Documentation
- Testing REST APIs
- Security Considerations
- Best Practices
REST Fundamentals
REST is based on several key principles that guide API design:
Core REST Principles
- Stateless: Each request contains all information needed to process it
- Resource-based: APIs expose resources identified by URIs
- HTTP Methods: Use standard HTTP methods (GET, POST, PUT, DELETE)
- Representation: Resources can have multiple representations (JSON, XML)
- Uniform Interface: Consistent API design patterns
Resource Naming Conventions
// Good REST endpoint examples
GET /api/users // Get all users
GET /api/users/123 // Get user with ID 123
POST /api/users // Create new user
PUT /api/users/123 // Update user with ID 123
DELETE /api/users/123 // Delete user with ID 123
// Nested resources
GET /api/users/123/orders // Get orders for user 123
POST /api/users/123/orders // Create order for user 123
REST Controllers
Spring provides @RestController
annotation for creating REST endpoints:
Basic REST Controller
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.findAll();
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userService.findById(id);
return user.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
User savedUser = userService.save(user);
return ResponseEntity.status(HttpStatus.CREATED).body(savedUser);
}
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id,
@RequestBody User user) {
Optional<User> existingUser = userService.findById(id);
if (existingUser.isPresent()) {
user.setId(id);
User updatedUser = userService.save(user);
return ResponseEntity.ok(updatedUser);
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userService.existsById(id)) {
userService.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
Advanced Controller Features
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "http://localhost:3000") // CORS support
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// Query parameters
@GetMapping
public ResponseEntity<Page<Product>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productService.findProducts(
category, search, pageable);
return ResponseEntity.ok(products);
}
// Multiple path variables
@GetMapping("/category/{categoryId}/products/{productId}")
public ResponseEntity<Product> getProductInCategory(
@PathVariable Long categoryId,
@PathVariable Long productId) {
Optional<Product> product = productService
.findByIdAndCategory(productId, categoryId);
return product.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// Request headers
@PostMapping
public ResponseEntity<Product> createProduct(
@RequestBody Product product,
@RequestHeader("X-User-ID") String userId) {
product.setCreatedBy(userId);
Product savedProduct = productService.save(product);
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedProduct);
}
}
HTTP Methods and Request Mapping
Standard HTTP Methods
@RestController
@RequestMapping("/api/books")
public class BookController {
// GET - Retrieve data
@GetMapping // GET /api/books
public List<Book> getAllBooks() {
return bookService.findAll();
}
@GetMapping("/{id}") // GET /api/books/123
public ResponseEntity<Book> getBook(@PathVariable Long id) {
return bookService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST - Create new resource
@PostMapping // POST /api/books
public ResponseEntity<Book> createBook(@RequestBody Book book) {
Book savedBook = bookService.save(book);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedBook.getId())
.toUri();
return ResponseEntity.created(location).body(savedBook);
}
// PUT - Update entire resource
@PutMapping("/{id}") // PUT /api/books/123
public ResponseEntity<Book> updateBook(@PathVariable Long id,
@RequestBody Book book) {
return bookService.findById(id)
.map(existingBook -> {
book.setId(id);
return ResponseEntity.ok(bookService.save(book));
})
.orElse(ResponseEntity.notFound().build());
}
// PATCH - Partial update
@PatchMapping("/{id}") // PATCH /api/books/123
public ResponseEntity<Book> partialUpdateBook(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
return bookService.findById(id)
.map(book -> {
// Apply partial updates
updates.forEach((key, value) -> {
switch (key) {
case "title":
book.setTitle((String) value);
break;
case "price":
book.setPrice((Double) value);
break;
// Add more fields as needed
}
});
return ResponseEntity.ok(bookService.save(book));
})
.orElse(ResponseEntity.notFound().build());
}
// DELETE - Remove resource
@DeleteMapping("/{id}") // DELETE /api/books/123
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
if (bookService.existsById(id)) {
bookService.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
Custom Request Mappings
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// Custom path with multiple variables
@GetMapping("/customer/{customerId}/year/{year}")
public List<Order> getOrdersByCustomerAndYear(
@PathVariable Long customerId,
@PathVariable int year) {
return orderService.findByCustomerAndYear(customerId, year);
}
// Multiple HTTP methods
@RequestMapping(value = "/{id}/status",
method = {RequestMethod.GET, RequestMethod.POST})
public ResponseEntity<OrderStatus> handleOrderStatus(
@PathVariable Long id,
@RequestBody(required = false) OrderStatus newStatus) {
if (newStatus != null) {
// POST - Update status
orderService.updateStatus(id, newStatus);
return ResponseEntity.ok(newStatus);
} else {
// GET - Retrieve status
OrderStatus status = orderService.getStatus(id);
return ResponseEntity.ok(status);
}
}
// Headers and content type constraints
@PostMapping(value = "/import",
consumes = MediaType.APPLICATION_XML_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE,
headers = "X-API-Version=1.0")
public ResponseEntity<ImportResult> importOrders(
@RequestBody String xmlData,
@RequestHeader("X-User-ID") String userId) {
ImportResult result = orderService.importFromXml(xmlData, userId);
return ResponseEntity.ok(result);
}
}
Request and Response Handling
Request Body Binding
// Entity classes
public class CreateUserRequest {
@NotBlank
private String username;
@Email
private String email;
@Size(min = 8)
private String password;
// getters and setters
}
public class UserResponse {
private Long id;
private String username;
private String email;
private LocalDateTime createdAt;
// getters and setters
}
@RestController
@RequestMapping("/api/users")
public class UserController {
// Request body with validation
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
UserResponse response = convertToResponse(user);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// Multiple request parameters
@GetMapping("/search")
public ResponseEntity<List<UserResponse>> searchUsers(
@RequestParam String query,
@RequestParam(defaultValue = "username") String sortBy,
@RequestParam(defaultValue = "asc") String sortDirection,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Sort sort = Sort.by(
sortDirection.equals("desc") ?
Sort.Direction.DESC : Sort.Direction.ASC,
sortBy
);
Pageable pageable = PageRequest.of(page, size, sort);
Page<User> users = userService.searchUsers(query, pageable);
List<UserResponse> responses = users.getContent()
.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
return ResponseEntity.ok(responses);
}
private UserResponse convertToResponse(User user) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setUsername(user.getUsername());
response.setEmail(user.getEmail());
response.setCreatedAt(user.getCreatedAt());
return response;
}
}
Response Customization
@RestController
@RequestMapping("/api/files")
public class FileController {
// Custom response headers
@GetMapping("/{id}/download")
public ResponseEntity<Resource> downloadFile(@PathVariable Long id) {
FileInfo fileInfo = fileService.getFileInfo(id);
Resource resource = fileService.loadAsResource(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + fileInfo.getFileName() + "\"")
.header(HttpHeaders.CONTENT_TYPE, fileInfo.getContentType())
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getSize()))
.body(resource);
}
// Conditional responses
@GetMapping("/{id}")
public ResponseEntity<FileResponse> getFile(
@PathVariable Long id,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
FileInfo fileInfo = fileService.getFileInfo(id);
String etag = "\"" + fileInfo.getChecksum() + "\"";
// Return 304 Not Modified if ETag matches
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(etag)
.build();
}
FileResponse response = convertToResponse(fileInfo);
return ResponseEntity.ok()
.eTag(etag)
.lastModified(fileInfo.getLastModified())
.body(response);
}
}
Data Validation
Bean Validation with Spring
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
public class ProductRequest {
@NotBlank(message = "Name is required")
@Size(max = 100, message = "Name must not exceed 100 characters")
private String name;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.0", inclusive = false, message = "Price must be positive")
private BigDecimal price;
@Email(message = "Invalid email format")
private String contactEmail;
@Pattern(regexp = "^[A-Z]{2}-\\d{4}$", message = "Invalid product code format")
private String productCode;
@Valid
@NotNull
private CategoryRequest category;
// getters and setters
}
@RestController
@RequestMapping("/api/products")
public class ProductController {
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@Valid @RequestBody ProductRequest request,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// Spring automatically handles validation errors
// and returns 400 Bad Request with error details
}
Product product = productService.createProduct(request);
ProductResponse response = convertToResponse(product);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Custom Validation
// Custom validator annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
String message() default "Username already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator implementation
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null) {
return true; // Let @NotNull handle null values
}
return !userRepository.existsByUsername(username);
}
}
// Usage in request class
public class CreateUserRequest {
@NotBlank
@UniqueUsername
private String username;
// other fields
}
Exception Handling
Global Exception Handler
@ControllerAdvice
public class GlobalExceptionHandler {
// Handle validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
ErrorResponse errorResponse = new ErrorResponse(
"VALIDATION_FAILED",
"Validation failed for one or more fields",
errors
);
return ResponseEntity.badRequest().body(errorResponse);
}
// Handle resource not found
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(
ResourceNotFoundException ex) {
ErrorResponse errorResponse = new ErrorResponse(
"RESOURCE_NOT_FOUND",
ex.getMessage(),
null
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
// Handle business logic errors
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(
BusinessException ex) {
ErrorResponse errorResponse = new ErrorResponse(
ex.getErrorCode(),
ex.getMessage(),
null
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
// Handle all other exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex) {
ErrorResponse errorResponse = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"An unexpected error occurred",
null
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
}
// Error response class
public class ErrorResponse {
private String errorCode;
private String message;
private Object details;
private LocalDateTime timestamp;
public ErrorResponse(String errorCode, String message, Object details) {
this.errorCode = errorCode;
this.message = message;
this.details = details;
this.timestamp = LocalDateTime.now();
}
// getters and setters
}
Content Negotiation
Multiple Response Formats
@RestController
@RequestMapping("/api/users")
public class UserController {
// Supports both JSON and XML
@GetMapping(value = "/{id}",
produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
// Content type based on Accept header
@GetMapping("/{id}/profile")
public ResponseEntity<Object> getUserProfile(
@PathVariable Long id,
@RequestHeader("Accept") String acceptHeader) {
User user = userService.findById(id);
if (acceptHeader.contains("application/xml")) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(convertToXml(user));
} else {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(convertToJson(user));
}
}
}
Custom Message Converters
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// Add custom CSV converter
converters.add(new CsvHttpMessageConverter());
}
}
public class CsvHttpMessageConverter extends AbstractHttpMessageConverter<List<Object>> {
public CsvHttpMessageConverter() {
super(new MediaType("text", "csv"));
}
@Override
protected boolean supports(Class<?> clazz) {
return List.class.isAssignableFrom(clazz);
}
@Override
protected List<Object> readInternal(Class<? extends List<Object>> clazz,
HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
// Implement CSV reading logic
return new ArrayList<>();
}
@Override
protected void writeInternal(List<Object> objects,
HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
// Implement CSV writing logic
try (PrintWriter writer = new PrintWriter(outputMessage.getBody())) {
objects.forEach(obj -> writer.println(convertToCsv(obj)));
}
}
private String convertToCsv(Object obj) {
// Convert object to CSV format
return obj.toString();
}
}
HATEOAS
Implementing HATEOAS with Spring
import org.springframework.hateoas.*;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
@RestController
@RequestMapping("/api/books")
public class BookController {
@GetMapping("/{id}")
public ResponseEntity<EntityModel<Book>> getBook(@PathVariable Long id) {
Book book = bookService.findById(id);
EntityModel<Book> bookModel = EntityModel.of(book);
// Add self link
bookModel.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(BookController.class).getBook(id))
.withSelfRel());
// Add related links
bookModel.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(BookController.class).getAllBooks())
.withRel("books"));
bookModel.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(AuthorController.class)
.getAuthor(book.getAuthorId()))
.withRel("author"));
// Conditional links based on business logic
if (book.getStatus() == BookStatus.AVAILABLE) {
bookModel.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(BookController.class)
.borrowBook(id))
.withRel("borrow"));
}
return ResponseEntity.ok(bookModel);
}
@GetMapping
public ResponseEntity<CollectionModel<EntityModel<Book>>> getAllBooks() {
List<Book> books = bookService.findAll();
List<EntityModel<Book>> bookModels = books.stream()
.map(book -> EntityModel.of(book)
.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(BookController.class)
.getBook(book.getId()))
.withSelfRel()))
.collect(Collectors.toList());
CollectionModel<EntityModel<Book>> collectionModel =
CollectionModel.of(bookModels);
collectionModel.add(WebMvcLinkBuilder
.linkTo(WebMvcLinkBuilder.methodOn(BookController.class).getAllBooks())
.withSelfRel());
return ResponseEntity.ok(collectionModel);
}
}
API Documentation
OpenAPI/Swagger Integration
// Add to pom.xml
/*
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.0.2</version>
</dependency>
*/
@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "APIs for managing users")
public class UserController {
@Operation(
summary = "Get user by ID",
description = "Retrieves a user by their unique identifier",
responses = {
@ApiResponse(responseCode = "200", description = "User found",
content = @Content(schema = @Schema(implementation = User.class))),
@ApiResponse(responseCode = "404", description = "User not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
}
)
@GetMapping("/{id}")
public ResponseEntity<User> getUser(
@Parameter(description = "User ID", required = true)
@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@Operation(summary = "Create new user")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "User created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid input data")
})
@PostMapping
public ResponseEntity<User> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "User data to create",
required = true,
content = @Content(schema = @Schema(implementation = CreateUserRequest.class))
)
@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}
// Configuration
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "User Management API",
version = "1.0",
description = "API for managing users in the system",
contact = @Contact(name = "API Support", email = "[email protected]")
),
servers = {
@Server(url = "http://localhost:8080", description = "Development server"),
@Server(url = "https://api.example.com", description = "Production server")
}
)
public class OpenApiConfig {
// Additional configuration if needed
}
Testing REST APIs
Unit Testing Controllers
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
@Test
void getUserById_ShouldReturnUser_WhenUserExists() {
// Given
Long userId = 1L;
User user = new User(userId, "john.doe", "[email protected]");
when(userService.findById(userId)).thenReturn(Optional.of(user));
// When
ResponseEntity<User> response = userController.getUserById(userId);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(user, response.getBody());
verify(userService).findById(userId);
}
@Test
void getUserById_ShouldReturnNotFound_WhenUserDoesNotExist() {
// Given
Long userId = 1L;
when(userService.findById(userId)).thenReturn(Optional.empty());
// When
ResponseEntity<User> response = userController.getUserById(userId);
// Then
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNull(response.getBody());
}
}
Integration Testing with MockMvc
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void createUser_ShouldReturnCreatedUser() throws Exception {
// Given
CreateUserRequest request = new CreateUserRequest();
request.setUsername("testuser");
request.setEmail("[email protected]");
request.setPassword("password123");
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("testuser"))
.andExpect(jsonPath("$.email").value("[email protected]"))
.andExpect(jsonPath("$.id").exists());
// Verify user was saved
assertTrue(userRepository.existsByUsername("testuser"));
}
@Test
void createUser_ShouldReturnBadRequest_WhenValidationFails() throws Exception {
// Given
CreateUserRequest request = new CreateUserRequest();
request.setUsername(""); // Invalid - blank
request.setEmail("invalid-email"); // Invalid format
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpected(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.details.username").exists())
.andExpect(jsonPath("$.details.email").exists());
}
}
API Testing with RestTemplate
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiTest {
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
private String getBaseUrl() {
return "http://localhost:" + port + "/api/users";
}
@Test
void getUserById_ShouldReturnUser() {
// Given
User savedUser = createTestUser();
// When
ResponseEntity<User> response = restTemplate.getForEntity(
getBaseUrl() + "/" + savedUser.getId(), User.class);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(savedUser.getUsername(), response.getBody().getUsername());
}
@Test
void createUser_ShouldReturnCreatedUser() {
// Given
CreateUserRequest request = new CreateUserRequest();
request.setUsername("newuser");
request.setEmail("[email protected]");
request.setPassword("password123");
// When
ResponseEntity<User> response = restTemplate.postForEntity(
getBaseUrl(), request, User.class);
// Then
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody().getId());
assertEquals("newuser", response.getBody().getUsername());
}
}
Security Considerations
Basic Security Configuration
@RestController
@RequestMapping("/api/secure")
@PreAuthorize("hasRole('USER')")
public class SecureController {
@GetMapping("/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<UserProfile> getProfile(Authentication authentication) {
String username = authentication.getName();
UserProfile profile = userService.getProfile(username);
return ResponseEntity.ok(profile);
}
@PostMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> adminOperation() {
return ResponseEntity.ok("Admin operation successful");
}
@GetMapping("/user/{id}")
@PreAuthorize("hasRole('ADMIN') or @userService.isOwner(#id, authentication.name)")
public ResponseEntity<User> getUser(@PathVariable Long id,
Authentication authentication) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
}
Rate Limiting
@Component
public class RateLimitingInterceptor implements HandlerInterceptor {
private final Map<String, List<Long>> requestCounts = new ConcurrentHashMap<>();
private final int maxRequests = 100; // per minute
private final long timeWindow = 60000; // 1 minute in milliseconds
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String clientIp = getClientIp(request);
long currentTime = System.currentTimeMillis();
requestCounts.putIfAbsent(clientIp, new ArrayList<>());
List<Long> requests = requestCounts.get(clientIp);
// Remove old requests outside time window
requests.removeIf(time -> currentTime - time > timeWindow);
if (requests.size() >= maxRequests) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return false;
}
requests.add(currentTime);
return true;
}
private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null) {
return xForwardedFor.split(",")[0];
}
return request.getRemoteAddr();
}
}
Best Practices
1. Resource Design
// Good: Resource-oriented URLs
GET /api/users // Get all users
GET /api/users/123 // Get specific user
POST /api/users // Create user
PUT /api/users/123 // Update user
DELETE /api/users/123 // Delete user
// Good: Nested resources
GET /api/users/123/orders // Get orders for user 123
POST /api/users/123/orders // Create order for user 123
// Avoid: Action-oriented URLs
POST /api/createUser // Bad
POST /api/users/123/activate // Bad (use PATCH instead)
2. HTTP Status Codes
@RestController
public class BestPracticesController {
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok(user)) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(user.getId())
.toUri();
return ResponseEntity.created(location).body(user); // 201 Created
}
@PutMapping("/users/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id,
@Valid @RequestBody User user) {
return userService.findById(id)
.map(existingUser -> {
user.setId(id);
User updated = userService.save(user);
return ResponseEntity.ok(updated); // 200 OK
})
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userService.existsById(id)) {
userService.deleteById(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
return ResponseEntity.notFound().build(); // 404 Not Found
}
}
3. API Versioning
// URL versioning
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
// Version 1 implementation
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
// Version 2 implementation
}
// Header versioning
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(headers = "API-Version=1")
public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
// Version 1 implementation
}
@GetMapping(headers = "API-Version=2")
public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
// Version 2 implementation
}
}
4. Response Consistency
// Consistent response wrapper
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.success = true;
response.data = data;
response.timestamp = LocalDateTime.now();
return response;
}
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = false;
response.message = message;
response.timestamp = LocalDateTime.now();
return response;
}
// getters and setters
}
@RestController
@RequestMapping("/api/users")
public class ConsistentUserController {
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<User>> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok(ApiResponse.success(user)))
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("User not found")));
}
}
5. Performance Optimization
@RestController
@RequestMapping("/api/users")
public class OptimizedUserController {
// Pagination for large datasets
@GetMapping
public ResponseEntity<Page<UserSummary>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sortBy) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy));
Page<User> users = userService.findAll(pageable);
// Convert to lightweight DTOs
Page<UserSummary> summaries = users.map(this::convertToSummary);
return ResponseEntity.ok(summaries);
}
// Caching for frequently accessed data
@GetMapping("/{id}")
@Cacheable(value = "users", key = "#id")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// Async processing for long-running operations
@PostMapping("/batch-import")
public ResponseEntity<BatchImportResponse> importUsers(
@RequestBody List<CreateUserRequest> requests) {
String jobId = userService.startBatchImport(requests);
BatchImportResponse response = new BatchImportResponse();
response.setJobId(jobId);
response.setStatus("PROCESSING");
response.setEstimatedCompletion(LocalDateTime.now().plusMinutes(5));
return ResponseEntity.accepted().body(response);
}
@GetMapping("/batch-import/{jobId}/status")
public ResponseEntity<BatchImportStatus> getBatchImportStatus(
@PathVariable String jobId) {
BatchImportStatus status = userService.getBatchImportStatus(jobId);
return ResponseEntity.ok(status);
}
}
Summary
Spring REST APIs provide a powerful and flexible way to build web services that follow REST principles. Key takeaways include:
Core Features:
@RestController
for creating REST endpoints- Automatic JSON/XML serialization and deserialization
- Built-in validation with Bean Validation
- Comprehensive exception handling capabilities
Best Practices:
- Follow REST conventions for URL design and HTTP methods
- Implement proper status codes and error handling
- Use pagination for large datasets
- Implement security and rate limiting
- Provide comprehensive API documentation
Testing:
- Unit test controllers with mocked dependencies
- Integration test with MockMvc and test containers
- End-to-end testing with RestTemplate or WebTestClient
Advanced Features:
- HATEOAS for discoverable APIs
- Content negotiation for multiple formats
- Caching for performance optimization
- Async processing for long-running operations
Spring's REST capabilities, combined with Spring Boot's auto-configuration, make it an excellent choice for building modern web APIs that are maintainable, scalable, and follow industry best practices.