| name | api-contract-review |
| description | Review REST API contracts for HTTP semantics, versioning, backward compatibility, and response consistency. Use when user asks "review API", "check endpoints", "REST review", or before releasing API changes. |
API Contract Review
Quick Reference - Common Issues
| Issue | Impact | Fix |
|---|
| POST for retrieval | Breaks HTTP semantics | Use GET |
| 200 with error body | Clients miss errors | Use proper 4xx/5xx |
| Entity as response | Exposes internals | Use DTOs |
| No pagination | Memory/performance issues | Add page/size params |
| No versioning | Breaking changes hit all clients | Version from day one |
| Inconsistent naming | Confusing API | Pick one convention (camelCase or snake_case) |
| No error format | Unpredictable error handling | Standardize ErrorResponse |
HTTP Verb Semantics
| Verb | Purpose | Idempotent | Safe | Request Body |
|---|
| GET | Retrieve resource(s) | Yes | Yes | No |
| POST | Create resource | No | No | Yes |
| PUT | Full update/replace | Yes | No | Yes |
| PATCH | Partial update | No | No | Yes |
| DELETE | Remove resource | Yes | No | No |
Common Mistakes
@PostMapping("/users/search")
public List<User> searchUsers(@RequestBody SearchCriteria criteria) { ... }
@GetMapping("/users")
public Page<UserResponse> searchUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { ... }
@GetMapping("/users/{id}/activate")
public void activateUser(@PathVariable Long id) { ... }
@PatchMapping("/users/{id}/status")
public UserResponse updateUserStatus(
@PathVariable Long id,
@RequestBody @Valid UpdateStatusRequest request) { ... }
@PutMapping("/users/{id}")
public UserResponse updateUser(
@PathVariable Long id,
@RequestBody UpdateUserRequest request) {
}
@PatchMapping("/users/{id}")
public UserResponse patchUser(
@PathVariable Long id,
@RequestBody @Valid PatchUserRequest request) { ... }
@PutMapping("/users/{id}")
public UserResponse replaceUser(
@PathVariable Long id,
@RequestBody @Valid CreateUserRequest request) { ... }
API Versioning
| Strategy | Example | Pros | Cons |
|---|
| URL Path | /api/v1/users | Simple, visible, cacheable | URL changes |
| Header | X-API-Version: 1 | Clean URLs | Hidden, harder to test |
| Accept Header | Accept: application/vnd.api.v1+json | RESTful | Complex |
| Query Param | /users?version=1 | Easy to add | Clutters params |
Recommended: URL Path Versioning
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserResponseV1 getUser(@PathVariable Long id) { ... }
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserResponseV2 getUser(@PathVariable Long id) {
...
}
}
Request/Response Design
DTO vs Entity - Never Expose Entities
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userService.findById(id);
return UserResponse.from(user);
}
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedAt()
);
}
}
Response Consistency
@GetMapping("/users")
public List<User> getUsers() { ... }
@GetMapping("/users/{id}")
public Map<String, Object> getUser() { ... }
@GetMapping("/orders")
public OrderPage getOrders() { ... }
@GetMapping("/users")
public Page<UserResponse> getUsers(Pageable pageable) { ... }
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) { ... }
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
UserResponse created = userService.createUser(request);
URI location = URI.create("/api/v1/users/" + created.id());
return ResponseEntity.created(location).body(created);
}
Pagination
@GetMapping
public Page<UserResponse> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort) {
if (size > 100) {
throw new BadRequestException("Page size cannot exceed 100");
}
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortOrders(sort)));
return userService.findAll(pageable).map(UserResponse::from);
}
HTTP Status Codes
Success Codes
| Code | When | Example |
|---|
| 200 OK | Successful GET, PUT, PATCH | Return updated resource |
| 201 Created | Successful POST | Return created resource + Location header |
| 204 No Content | Successful DELETE | No response body |
Error Codes
| Code | When | Example |
|---|
| 400 Bad Request | Invalid input | Validation failed |
| 401 Unauthorized | No/invalid authentication | Missing or expired token |
| 403 Forbidden | Authenticated but not allowed | Insufficient permissions |
| 404 Not Found | Resource doesn't exist | Unknown ID |
| 409 Conflict | State conflict | Duplicate email |
| 422 Unprocessable Entity | Semantic validation failed | Business rule violation |
| 429 Too Many Requests | Rate limit exceeded | Include Retry-After header |
| 500 Internal Server Error | Unexpected server error | Unhandled exception |
Anti-Pattern: 200 with Error Body
@PostMapping("/users")
public ResponseEntity<Map<String, Object>> createUser(@RequestBody UserRequest request) {
try {
User user = userService.create(request);
return ResponseEntity.ok(Map.of("success", true, "data", user));
} catch (Exception e) {
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
UserResponse user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
Error Response Format
Consistent ErrorResponse Class
public record ErrorResponse(
int status,
String error,
String message,
String path,
LocalDateTime timestamp,
List<FieldError> fieldErrors
) {
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
public static ErrorResponse of(int status, String error, String message, String path) {
return new ErrorResponse(status, error, message, path, LocalDateTime.now(), null);
}
public static ErrorResponse withFieldErrors(int status, String error, String message,
String path, List<FieldError> fieldErrors) {
return new ErrorResponse(status, error, message, path, LocalDateTime.now(), fieldErrors);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> new ErrorResponse.FieldError(
fe.getField(), fe.getDefaultMessage(), fe.getRejectedValue()))
.toList();
return ResponseEntity.badRequest().body(
ErrorResponse.withFieldErrors(400, "Validation Failed",
"Request validation failed", request.getRequestURI(), fieldErrors));
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
return ResponseEntity.status(404).body(
ErrorResponse.of(404, "Not Found", ex.getMessage(), request.getRequestURI()));
}
}
Security - Don't Expose Internals
{
"error": "org.hibernate.exception.SQLGrammarException: could not execute statement",
"trace": "at org.hibernate.internal.SessionImpl.doFlush..."
}
{
"status": 500,
"error": "Internal Server Error",
"message": "An unexpected error occurred. Please try again.",
"path": "/api/v1/users",
"timestamp": "2024-01-15T10:30:00"
}
Backward Compatibility
Breaking Changes (AVOID)
| Change | Why It Breaks |
|---|
| Remove a field | Clients parsing it will fail |
| Rename a field | Same as removal |
| Change field type | Deserialization fails |
| Remove an endpoint | Clients get 404 |
| Change URL structure | Clients get 404 |
| Add required parameter | Existing requests fail |
| Change status codes | Client error handling breaks |
| Change error format | Client error parsing breaks |
Non-Breaking Changes (SAFE)
| Change | Why It's Safe |
|---|
| Add optional field to response | Clients ignore unknown fields |
| Add optional query parameter | Existing requests still work |
| Add new endpoint | No existing client uses it |
| Add new enum value | If client handles unknown values |
| Widen parameter type (int -> long) | All existing values still valid |
| Add optional header | Existing requests still work |
Deprecation Pattern
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@Deprecated
@GetMapping("/{id}")
public ResponseEntity<UserResponseV1> getUser(@PathVariable Long id) {
UserResponseV1 response = userService.getUserV1(id);
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "2025-06-01")
.header("Link", "</api/v2/users/" + id + ">; rel=\"successor-version\"")
.body(response);
}
}
API Review Checklist
HTTP Semantics
URL Design
Request Handling
Response Design
Error Handling
Compatibility