1. java
  2. /spring
  3. /rest-apis

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 is based on several key principles that guide API design:

Core REST Principles

  1. Stateless: Each request contains all information needed to process it
  2. Resource-based: APIs expose resources identified by URIs
  3. HTTP Methods: Use standard HTTP methods (GET, POST, PUT, DELETE)
  4. Representation: Resources can have multiple representations (JSON, XML)
  5. 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.