| name | api-contract-review |
| description | Use when designing, reviewing, or modifying REST API endpoints to enforce proper HTTP verbs, status codes, versioning, and API contract best practices. |
REST API Contract Review
HTTP Verbs
| Verb | Purpose | Idempotent | Request Body | Typical Response |
|---|
| GET | Retrieve resource | Yes | No | 200 OK |
| POST | Create resource | No | Yes | 201 Created |
| PUT | Full update | Yes | Yes | 200 OK |
| PATCH | Partial update | No | Yes | 200 OK |
| DELETE | Remove resource | Yes | No | 204 No Content |
Status Codes
| Code | Meaning | When to Use |
|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation errors, malformed request |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource, optimistic lock failure |
| 422 | Unprocessable Entity | Semantic validation errors |
| 500 | Internal Server Error | Unexpected server errors |
Controller Examples
Full CRUD Controller
@RestController
@RequestMapping("/api/v1/employees")
public class EmployeeController {
private final EmployeeService employeeService;
public EmployeeController(EmployeeService employeeService) {
this.employeeService = employeeService;
}
@GetMapping
public ResponseEntity<Page<EmployeeResponse>> findAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id,asc") String[] sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(
Sort.Direction.fromString(sort[1]), sort[0]));
return ResponseEntity.ok(employeeService.findAll(pageable));
}
@GetMapping("/{id}")
public ResponseEntity<EmployeeResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(employeeService.findById(id));
}
@GetMapping("/search")
public ResponseEntity<List<EmployeeResponse>> search(
@RequestParam(required = false) String department,
@RequestParam(required = false) String position) {
return ResponseEntity.ok(employeeService.search(department, position));
}
@PostMapping
public ResponseEntity<EmployeeResponse> create(
@Valid @RequestBody EmployeeRequest request) {
EmployeeResponse created = employeeService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<EmployeeResponse> update(
@PathVariable Long id,
@Valid @RequestBody EmployeeRequest request) {
return ResponseEntity.ok(employeeService.update(id, request));
}
@PatchMapping("/{id}")
public ResponseEntity<EmployeeResponse> partialUpdate(
@PathVariable Long id,
@Valid @RequestBody EmployeePatchRequest request) {
return ResponseEntity.ok(employeeService.partialUpdate(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
employeeService.delete(id);
return ResponseEntity.noContent().build();
}
}
Request / Response DTOs
public record EmployeeRequest(
@NotBlank(message = "First name is required")
@Size(max = 100, message = "First name must be at most 100 characters")
String firstName,
@NotBlank(message = "Last name is required")
@Size(max = 100, message = "Last name must be at most 100 characters")
String lastName,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@NotBlank(message = "Department is required")
String department,
@NotNull(message = "Salary is required")
@Positive(message = "Salary must be positive")
BigDecimal salary
) {}
public record EmployeePatchRequest(
@Size(max = 100) String firstName,
@Size(max = 100) String lastName,
@Email String email,
String department,
@Positive BigDecimal salary
) {}
public record EmployeeResponse(
Long id,
String firstName,
String lastName,
String email,
String department,
BigDecimal salary,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
API Versioning
URI Versioning (Recommended)
@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<ProductResponseV1> findById(@PathVariable Long id) {
}
}
@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<ProductResponseV2> findById(@PathVariable Long id) {
}
}
Header-Based Versioning
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<ProductResponseV1> findByIdV1(@PathVariable Long id) {
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public ResponseEntity<ProductResponseV2> findByIdV2(@PathVariable Long id) {
}
}
Error Response Format
public record ApiError(
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime timestamp,
int status,
String error,
String message,
String path,
List<FieldError> fieldErrors
) {
public ApiError(HttpStatus status, String message, String path) {
this(LocalDateTime.now(), status.value(), status.getReasonPhrase(),
message, path, List.of());
}
public record FieldError(String field, String message, Object rejectedValue) {}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ApiError.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(f -> new ApiError.FieldError(
f.getField(), f.getDefaultMessage(), f.getRejectedValue()))
.toList();
ApiError error = new ApiError(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(),
"Validation failed",
request.getRequestURI(),
fieldErrors
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
ApiError error = new ApiError(HttpStatus.NOT_FOUND, ex.getMessage(),
request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
Pagination Response
@GetMapping
public ResponseEntity<Page<EmployeeResponse>> findAll(Pageable pageable) {
return ResponseEntity.ok(employeeService.findAll(pageable));
}
HATEOAS (When Required)
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping("/{id}")
public EntityModel<OrderResponse> findById(@PathVariable Long id) {
OrderResponse order = orderService.findById(id);
return EntityModel.of(order,
linkTo(methodOn(OrderController.class).findById(id)).withSelfRel(),
linkTo(methodOn(OrderController.class).findAll(Pageable.unpaged()))
.withRel("orders"),
linkTo(methodOn(OrderController.class).cancel(id))
.withRel("cancel"));
}
}
Checklist