Spring Boot
Building REST APIs
Spring Boot is the most widely used framework for building REST APIs in Java. It provides everything needed out of the box — HTTP routing, JSON serialization, input validation, error handling, and content negotiation — with minimal configuration. This guide covers the complete picture: designing endpoints, handling requests and responses, validation, error handling, pagination, and the patterns that make REST APIs production-ready.
REST API Foundations in Spring Boot
A REST API in Spring Boot is built around three core annotations: @RestController marks the class as a REST endpoint handler, @RequestMapping defines the base URL path for the controller, and the HTTP method annotations (@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping) map specific HTTP methods and paths to handler methods.
Spring Boot's embedded Tomcat handles all HTTP concerns — parsing request bodies, serializing response objects to JSON (via Jackson), setting response headers, and managing HTTP status codes. You write plain Java methods that return Java objects — Spring Boot handles the HTTP layer automatically.
Jackson is auto-configured by Spring Boot and handles JSON serialization transparently. A Java object returned from a @RestController method is automatically serialized to JSON. A JSON request body annotated with @RequestBody is automatically deserialized to a Java object.
Controller Structure and HTTP Method Mapping
A complete REST controller covering all five HTTP methods with proper URL design, status codes, and response types.
Java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Slf4j
public class ProductController {
private final ProductService productService;
// GET /api/v1/products — list all (with pagination):
@GetMapping
public Page<ProductResponse> getAllProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String direction) {
Sort sort = direction.equalsIgnoreCase("desc")
? Sort.by(sortBy).descending()
: Sort.by(sortBy).ascending();
return productService.findAll(PageRequest.of(page, size, sort));
}
// GET /api/v1/products/42 — get one by ID:
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// GET /api/v1/products/search?query=laptop&category=electronics — search:
@GetMapping("/search")
public List<ProductResponse> searchProducts(
@RequestParam String query,
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "10") int limit) {
return productService.search(query, category, limit);
}
// POST /api/v1/products — create new:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse createProduct(
@RequestBody @Valid CreateProductRequest request) {
log.info("Creating product: {}", request.name());
return productService.create(request);
}
// PUT /api/v1/products/42 — full replacement:
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable Long id,
@RequestBody @Valid UpdateProductRequest request) {
return productService.update(id, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// PATCH /api/v1/products/42 — partial update:
@PatchMapping("/{id}")
public ResponseEntity<ProductResponse> patchProduct(
@PathVariable Long id,
@RequestBody @Valid PatchProductRequest request) {
return productService.patch(id, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// DELETE /api/v1/products/42 — delete:
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProduct(@PathVariable Long id) {
productService.delete(id);
}
}Request DTOs — Input Validation
Request DTOs define what the API accepts. They carry validation annotations enforced by @Valid — invalid input is rejected with HTTP 400 before reaching any business logic.
Java
// Create request — all required fields:
public record CreateProductRequest(
@NotBlank(message = "Product name is required")
@Size(min = 2, max = 200, message = "Name must be 2-200 characters")
String name,
@NotBlank(message = "Description is required")
@Size(max = 2000, message = "Description cannot exceed 2000 characters")
String description,
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than 0")
@Digits(integer = 8, fraction = 2, message = "Invalid price format")
BigDecimal price,
@NotNull(message = "Category is required")
Long categoryId,
@Min(value = 0, message = "Stock cannot be negative")
int stockQuantity,
@Size(max = 10, message = "Maximum 10 tags allowed")
@NotNull
List<@NotBlank @Size(max = 50) String> tags
) { }
// Update request — all fields required (PUT = full replacement):
public record UpdateProductRequest(
@NotBlank
@Size(min = 2, max = 200)
String name,
@NotBlank
@Size(max = 2000)
String description,
@NotNull
@DecimalMin("0.01")
BigDecimal price,
@NotNull
Long categoryId,
@Min(0)
int stockQuantity
) { }
// Patch request — all fields optional (PATCH = partial update):
public record PatchProductRequest(
@Size(min = 2, max = 200)
String name, // null = don't update
@Size(max = 2000)
String description, // null = don't update
@DecimalMin("0.01")
BigDecimal price, // null = don't update
Long categoryId, // null = don't update
@Min(0)
Integer stockQuantity // null = don't update — Integer not int
) { }
// Custom cross-field validation:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PriceRangeValidator.class)
public @interface ValidPriceRange {
String message() default "Sale price must be less than regular price";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@ValidPriceRange
public record CreateSaleRequest(
@NotNull BigDecimal regularPrice,
@NotNull BigDecimal salePrice
) { }
public class PriceRangeValidator
implements ConstraintValidator<ValidPriceRange, CreateSaleRequest> {
@Override
public boolean isValid(CreateSaleRequest req, ConstraintValidatorContext ctx) {
if (req.regularPrice() == null || req.salePrice() == null) return true;
return req.salePrice().compareTo(req.regularPrice()) < 0;
}
}Response DTOs and ResponseEntity
Response DTOs define what the API returns. They expose exactly the fields clients need — no more, no less. ResponseEntity gives you control over HTTP status codes and response headers.
Java
// Response DTO — never expose JPA entities directly:
public record ProductResponse(
Long id,
String name,
String description,
BigDecimal price,
String category,
int stockQuantity,
boolean inStock,
List<String> tags,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
// Factory method — converts entity to DTO:
public static ProductResponse from(Product product) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getCategory().getName(),
product.getStockQuantity(),
product.getStockQuantity() > 0,
product.getTags().stream().map(Tag::getName).toList(),
product.getCreatedAt(),
product.getUpdatedAt()
);
}
}
// Paginated response — wraps Page<T> for consistent API shape:
public record PagedResponse<T>(
List<T> content,
int pageNumber,
int pageSize,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}
// ResponseEntity — full control over response:
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
// 200 OK with body:
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); // 404
// Custom status:
// return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).body(dto);
// With headers:
// return ResponseEntity.ok()
// .header("X-Product-Version", "2")
// .header("Cache-Control", "max-age=3600")
// .body(dto);
// Created with Location header:
// URI location = URI.create("/api/v1/products/" + saved.getId());
// return ResponseEntity.created(location).body(saved);
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@RequestBody @Valid CreateProductRequest request,
UriComponentsBuilder uriBuilder) {
ProductResponse created = productService.create(request);
// 201 Created with Location header pointing to the new resource:
URI location = uriBuilder
.path("/api/v1/products/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}Global Exception Handling
@RestControllerAdvice centralizes exception handling. Every exception thrown anywhere in the application is caught here and converted to a consistent error response — no try-catch blocks in controllers.
Java
// Standard error response structure:
public record ErrorResponse(
String code,
String message,
List<FieldError> errors,
LocalDateTime timestamp,
String path
) {
public record FieldError(String field, String message) { }
// Constructor for simple errors without field details:
public static ErrorResponse of(String code, String message, String path) {
return new ErrorResponse(code, message, List.of(),
LocalDateTime.now(), path);
}
}
// Global exception handler:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 404 — resource not found:
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex,
HttpServletRequest request) {
return ErrorResponse.of("RESOURCE_NOT_FOUND", ex.getMessage(),
request.getRequestURI());
}
// 409 — duplicate/conflict:
@ExceptionHandler(DuplicateResourceException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleDuplicate(DuplicateResourceException ex,
HttpServletRequest request) {
return ErrorResponse.of("DUPLICATE_RESOURCE", ex.getMessage(),
request.getRequestURI());
}
// 400 — validation failure (@Valid on @RequestBody):
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest request) {
List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> new ErrorResponse.FieldError(e.getField(), e.getDefaultMessage()))
.toList();
return new ErrorResponse(
"VALIDATION_FAILED",
"Request validation failed",
fieldErrors,
LocalDateTime.now(),
request.getRequestURI()
);
}
// 400 — malformed JSON or type mismatch:
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMalformedJson(HttpMessageNotReadableException ex,
HttpServletRequest request) {
return ErrorResponse.of("MALFORMED_REQUEST",
"Request body is malformed or contains invalid values",
request.getRequestURI());
}
// 400 — path variable or query param type mismatch:
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleTypeMismatch(MethodArgumentTypeMismatchException ex,
HttpServletRequest request) {
String message = String.format("Parameter '%s' must be of type %s",
ex.getName(), ex.getRequiredType().getSimpleName());
return ErrorResponse.of("INVALID_PARAMETER", message,
request.getRequestURI());
}
// 405 — wrong HTTP method:
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ErrorResponse handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex,
HttpServletRequest request) {
return ErrorResponse.of("METHOD_NOT_ALLOWED",
ex.getMessage(), request.getRequestURI());
}
// 403 — access denied:
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDenied(AccessDeniedException ex,
HttpServletRequest request) {
return ErrorResponse.of("ACCESS_DENIED",
"You do not have permission to access this resource",
request.getRequestURI());
}
// 500 — catch-all for unexpected exceptions:
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex, HttpServletRequest request) {
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
return ErrorResponse.of("INTERNAL_ERROR",
"An unexpected error occurred", request.getRequestURI());
}
}
// Custom exception classes:
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " not found with id: " + id);
}
}
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}Service Layer
The service layer contains all business logic. Controllers delegate to services — services never deal with HTTP. This separation keeps controllers thin and business logic testable.
Java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
@Transactional(readOnly = true)
public Page<ProductResponse> findAll(Pageable pageable) {
return productRepository.findAll(pageable)
.map(ProductResponse::from);
}
@Transactional(readOnly = true)
public Optional<ProductResponse> findById(Long id) {
return productRepository.findById(id)
.map(ProductResponse::from);
}
@Transactional(readOnly = true)
public List<ProductResponse> search(String query, String category, int limit) {
return productRepository
.searchByNameAndCategory(query, category, PageRequest.of(0, limit))
.stream()
.map(ProductResponse::from)
.toList();
}
public ProductResponse create(CreateProductRequest request) {
// Validate business rules:
if (productRepository.existsByName(request.name())) {
throw new DuplicateResourceException(
"Product already exists with name: " + request.name()
);
}
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", request.categoryId()));
Product product = Product.builder()
.name(request.name())
.description(request.description())
.price(request.price())
.category(category)
.stockQuantity(request.stockQuantity())
.build();
Product saved = productRepository.save(product);
log.info("Product created: id={}, name={}", saved.getId(), saved.getName());
return ProductResponse.from(saved);
}
public Optional<ProductResponse> update(Long id, UpdateProductRequest request) {
return productRepository.findById(id)
.map(product -> {
product.setName(request.name());
product.setDescription(request.description());
product.setPrice(request.price());
product.setStockQuantity(request.stockQuantity());
return ProductResponse.from(productRepository.save(product));
});
}
public Optional<ProductResponse> patch(Long id, PatchProductRequest request) {
return productRepository.findById(id)
.map(product -> {
// Only update fields that are non-null in the request:
if (request.name() != null) product.setName(request.name());
if (request.description() != null) product.setDescription(request.description());
if (request.price() != null) product.setPrice(request.price());
if (request.stockQuantity() != null) product.setStockQuantity(request.stockQuantity());
return ProductResponse.from(productRepository.save(product));
});
}
public void delete(Long id) {
if (!productRepository.existsById(id)) {
throw new ResourceNotFoundException("Product", id);
}
productRepository.deleteById(id);
log.info("Product deleted: id={}", id);
}
}Content Negotiation and Headers
Spring Boot's REST controllers support content negotiation — serving different response formats based on the Accept header — and give full control over request and response headers.
Java
@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
// Content negotiation — serve JSON or XML based on Accept header:
@GetMapping(
value = "/{id}",
produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
}
)
public ReportResponse getReport(@PathVariable Long id) {
return reportService.findById(id);
// Jackson serializes to JSON or XML based on Accept header:
// Accept: application/json → JSON response
// Accept: application/xml → XML response (add jackson-dataformat-xml)
}
// Restrict to specific content type:
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ReportResponse createReport(@RequestBody @Valid CreateReportRequest request) {
return reportService.create(request);
}
// Read request headers:
@GetMapping("/download")
public ResponseEntity<byte[]> downloadReport(
@RequestHeader("Accept-Language") String language,
@RequestHeader(value = "X-Report-Format", defaultValue = "PDF") String format,
@PathVariable Long id) {
byte[] data = reportService.generateReport(id, language, format);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename="report-" + id + "." + format.toLowerCase() + """)
.header(HttpHeaders.CONTENT_TYPE, "application/pdf")
.header("X-Report-Id", id.toString())
.header("X-Generated-At", LocalDateTime.now().toString())
.contentLength(data.length)
.body(data);
}
// Read cookies:
@GetMapping("/preferences")
public UserPreferences getPreferences(
@CookieValue(value = "user_prefs", defaultValue = "{}") String prefsJson) {
return parsePreferences(prefsJson);
}
}
// CORS configuration — allow cross-origin requests:
@RestController
@CrossOrigin(
origins = {"https://myapp.com", "http://localhost:3000"},
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE},
allowedHeaders = {"Content-Type", "Authorization"},
maxAge = 3600
)
@RequestMapping("/api/v1/products")
public class ProductController { }
// Or globally via WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://myapp.com", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}Pagination and Sorting
Spring Data provides Pageable for automatic pagination and sorting. The PagedResponse wrapper standardizes the paginated response structure across all list endpoints.
Java
// Controller — Pageable auto-resolved from request parameters:
@GetMapping
public ResponseEntity<PagedResponse<ProductResponse>> getProducts(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<ProductResponse> page = productService.findAll(pageable);
return ResponseEntity.ok(PagedResponse.from(page));
}
// Client usage:
// GET /api/v1/products → page 0, size 20, default sort
// GET /api/v1/products?page=2&size=10 → page 2, 10 per page
// GET /api/v1/products?sort=price,desc → sorted by price descending
// GET /api/v1/products?sort=name,asc&sort=price,desc → multi-sort
// PagedResponse:
public record PagedResponse<T>(
List<T> content,
int pageNumber,
int pageSize,
long totalElements,
int totalPages,
boolean first,
boolean last,
boolean empty
) {
public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
page.isEmpty()
);
}
}
// Enable Pageable parameter resolution in application.properties:
// spring.data.web.pageable.one-indexed-parameters=false (default: 0-indexed)
// spring.data.web.pageable.max-page-size=100 (max allowed page size)
// spring.data.web.pageable.default-page-size=20 (default page size)
// Repository with Pageable:
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByCategoryId(Long categoryId, Pageable pageable);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
Page<Product> findByPriceRange(
@Param("min") BigDecimal min,
@Param("max") BigDecimal max,
Pageable pageable
);
}
// JSON response shape:
// {
// "content": [...],
// "pageNumber": 0,
// "pageSize": 20,
// "totalElements": 150,
// "totalPages": 8,
// "first": true,
// "last": false,
// "empty": false
// }API Versioning
REST API versioning allows evolving an API without breaking existing clients. Spring Boot supports three versioning strategies — URL path versioning, header versioning, and parameter versioning.
Java
// ── STRATEGY 1: URL path versioning (most common, most visible) ──────
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserResponseV1 getUser(@PathVariable Long id) {
return userService.findByIdV1(id);
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserResponseV2 getUser(@PathVariable Long id) {
return userService.findByIdV2(id); // new response shape
}
}
// GET /api/v1/users/1 → old response shape
// GET /api/v2/users/1 → new response shape
// ── STRATEGY 2: Header versioning (clean URLs, less discoverable) ────
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}",
headers = "X-API-Version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) {
return userService.findByIdV1(id);
}
@GetMapping(value = "/{id}",
headers = "X-API-Version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) {
return userService.findByIdV2(id);
}
}
// GET /api/users/1 X-API-Version: 1 → V1 response
// GET /api/users/1 X-API-Version: 2 → V2 response
// ── STRATEGY 3: Accept header versioning (content negotiation) ───────
@GetMapping(value = "/{id}",
produces = "application/vnd.myapp.v1+json")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}",
produces = "application/vnd.myapp.v2+json")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }
// GET /api/users/1 Accept: application/vnd.myapp.v2+json → V2
// ── STRATEGY 4: Request parameter versioning ─────────────────────────
@GetMapping(value = "/{id}", params = "version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}", params = "version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }
// GET /api/users/1?version=2 → V2 response
// ── Recommended approach: URL versioning ─────────────────────────────
// Pros: visible in URL, bookmarkable, easy to test in browser
// Cons: URL "pollution", changes resource identity
// Most teams use URL versioning for its simplicity and discoverabilityTesting REST APIs
Spring Boot provides @WebMvcTest for fast controller-layer tests and @SpringBootTest with TestRestTemplate or MockMvc for full integration tests.
Java
// Controller slice test — only loads MVC layer, no DB:
@WebMvcTest(ProductController.class)
@AutoConfigureMockMvc
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getProduct_whenExists_returns200() throws Exception {
ProductResponse product = new ProductResponse(
1L, "Laptop Pro", "High-performance laptop",
new BigDecimal("1299.99"), "Electronics",
10, true, List.of("laptop", "premium"),
LocalDateTime.now(), LocalDateTime.now()
);
when(productService.findById(1L)).thenReturn(Optional.of(product));
mockMvc.perform(get("/api/v1/products/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Laptop Pro"))
.andExpect(jsonPath("$.price").value(1299.99))
.andExpect(jsonPath("$.inStock").value(true));
}
@Test
void getProduct_whenNotFound_returns404() throws Exception {
when(productService.findById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/products/99"))
.andExpect(status().isNotFound());
}
@Test
void createProduct_withValidRequest_returns201() throws Exception {
CreateProductRequest request = new CreateProductRequest(
"New Laptop", "Description", new BigDecimal("999.99"),
1L, 5, List.of("laptop")
);
ProductResponse created = new ProductResponse(
2L, "New Laptop", "Description",
new BigDecimal("999.99"), "Electronics",
5, true, List.of("laptop"),
LocalDateTime.now(), LocalDateTime.now()
);
when(productService.create(any())).thenReturn(created);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.name").value("New Laptop"))
.andExpect(header().exists("Location"));
}
@Test
void createProduct_withInvalidRequest_returns400() throws Exception {
CreateProductRequest invalid = new CreateProductRequest(
"", // blank name — @NotBlank violation
"Description",
new BigDecimal("-10.00"), // negative price — @DecimalMin violation
1L, 0, List.of()
);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalid)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(2));
}
}
// Full integration test:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class ProductApiIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ProductRepository productRepository;
@Test
@Transactional
void fullCrudFlow() {
// Create:
CreateProductRequest createReq = new CreateProductRequest(
"Test Product", "Description", new BigDecimal("49.99"),
1L, 100, List.of("test")
);
ResponseEntity<ProductResponse> createRes = restTemplate
.postForEntity("/api/v1/products", createReq, ProductResponse.class);
assertThat(createRes.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = createRes.getBody().id();
// Read:
ResponseEntity<ProductResponse> getRes = restTemplate
.getForEntity("/api/v1/products/" + id, ProductResponse.class);
assertThat(getRes.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getRes.getBody().name()).isEqualTo("Test Product");
// Delete:
restTemplate.delete("/api/v1/products/" + id);
ResponseEntity<ProductResponse> deletedRes = restTemplate
.getForEntity("/api/v1/products/" + id, ProductResponse.class);
assertThat(deletedRes.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}