| 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. Use when this capability is needed. |
| metadata | {"author":"decebals"} |
API Contract Review Skill
Audit REST API design for correctness, consistency, and compatibility.
When to Use
- User asks "review this API" / "check REST endpoints"
- Before releasing API changes
- Reviewing PR with controller changes
- Checking backward compatibility
Quick Reference: Common Issues
| Issue | Symptom | Impact |
|---|
| Wrong HTTP verb | POST for idempotent operation | Confusion, caching issues |
| Missing versioning | /users instead of /v1/users | Breaking changes affect all clients |
| Entity leak | JPA entity in response | Exposes internals, N+1 risk |
| 200 with error | {"status": 200, "error": "..."} | Breaks error handling |
| Inconsistent naming | /getUsers vs /users | Hard to learn API |
HTTP Verb Semantics
Verb Selection Guide
| Verb | Use For | Idempotent | Safe | Request Body |
|---|
| GET | Retrieve resource | Yes | Yes | No |
| POST | Create new resource | No | No | Yes |
| PUT | Replace entire resource | Yes | No | Yes |
| PATCH | Partial update | No* | No | Yes |
| DELETE | Remove resource | Yes | No | Optional |
*PATCH can be idempotent depending on implementation
Common Mistakes
@PostMapping("/users/search")
public List<User> searchUsers(@RequestBody SearchCriteria criteria) { }
@GetMapping("/users")
public List<User> searchUsers(
@RequestParam String name,
@RequestParam(required = false) String email) { }
@GetMapping("/users/{id}/activate")
public void activateUser(@PathVariable Long id) { }
@PostMapping("/users/{id}/activate")
public ResponseEntity<Void> activateUser(@PathVariable Long id) { }
@PostMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UserDto dto) { }
@PutMapping("/users/{id}")
public User replaceUser(@PathVariable Long id, @RequestBody UserDto dto) { }
@PatchMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UserPatchDto dto) { }
API Versioning
Strategies
| Strategy | Example | Pros | Cons |
|---|
| URL path | /v1/users | Clear, easy routing | URL changes |
| Header | Accept: application/vnd.api.v1+json | Clean URLs | Hidden, harder to test |
| Query param | /users?version=1 | Easy to add | Easy to forget |
Recommended: URL Path
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 { }
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 { }
@RestController
@RequestMapping("/api/users")
public class UserController { }
Version Checklist
Request/Response Design
DTO vs Entity
@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);
}
Response Consistency
@GetMapping("/users")
public List<User> getUsers() { }
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { }
@GetMapping("/users/count")
public int countUsers() { }
@GetMapping("/users")
public ApiResponse<List<UserResponse>> getUsers() {
return ApiResponse.success(userService.findAll());
}
Pagination
@GetMapping("/users")
public List<User> getAllUsers() {
return userRepository.findAll();
}
@GetMapping("/users")
public Page<UserResponse> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(PageRequest.of(page, size));
}
HTTP Status Codes
Success Codes
| Code | When to Use | Response Body |
|---|
| 200 OK | Successful GET, PUT, PATCH | Resource or result |
| 201 Created | Successful POST (created) | Created resource + Location header |
| 204 No Content | Successful DELETE, or PUT with no body | Empty |
Error Codes
| Code | When to Use | Common Mistake |
|---|
| 400 Bad Request | Invalid input, validation failed | Using for "not found" |
| 401 Unauthorized | Not authenticated | Confusing with 403 |
| 403 Forbidden | Authenticated but not allowed | Using 401 instead |
| 404 Not Found | Resource doesn't exist | Using 400 |
| 409 Conflict | Duplicate, concurrent modification | Using 400 |
| 422 Unprocessable | Semantic error (valid syntax, invalid meaning) | Using 400 |
| 500 Internal Error | Unexpected server error | Exposing stack traces |
Anti-Pattern: 200 with Error Body
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(Map.of("status", "success", "data", user));
} catch (NotFoundException e) {
return ResponseEntity.ok(Map.of(
"status", "error",
"message", "User not found"
));
}
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
Error Response Format
Consistent Error Structure
public class ErrorResponse {
private String code;
private String message;
private Instant timestamp;
private String path;
private List<FieldError> errors;
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.builder()
.code("RESOURCE_NOT_FOUND")
.message(ex.getMessage())
.timestamp(Instant.now())
.path(request.getRequestURI())
.build());
}
Security: Don't Expose Internals
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleAll(Exception ex) {
return ResponseEntity.status(500)
.body(ex.getStackTrace().toString());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(500)
.body(ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred"));
}
Backward Compatibility
Breaking Changes (Avoid in Same Version)
| Change | Breaking? | Migration |
|---|
| Remove endpoint | Yes | Deprecate first, remove in next version |
| Remove field from response | Yes | Keep field, return null/default |
| Add required field to request | Yes | Make optional with default |
| Change field type | Yes | Add new field, deprecate old |
| Rename field | Yes | Support both temporarily |
| Change URL path | Yes | Redirect old to new |
Non-Breaking Changes (Safe)
- Add optional field to request
- Add field to response
- Add new endpoint
- Add new optional query parameter
Deprecation Pattern
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@Deprecated
@GetMapping("/by-email")
public UserResponse getByEmailOld(@RequestParam String email) {
return getByEmail(email);
}
@GetMapping(params = "email")
public UserResponse getByEmail(@RequestParam String email) {
return userService.findByEmail(email);
}
}
API Review Checklist
1. HTTP Semantics
2. URL Design
3. Request Handling
4. Response Design
5. Error Handling
6. Compatibility
Token Optimization
For large APIs:
- List all controllers:
find . -name "*Controller.java"
- Sample 2-3 controllers for pattern analysis
- Check
@ExceptionHandler configuration once
- Grep for specific anti-patterns:
grep -r "public.*Entity.*@GetMapping" --include="*.java"
grep -r "ResponseEntity.ok.*error" --include="*.java"
grep -r "@RequestMapping.*api" --include="*.java" | grep -v "/v[0-9]"
Converted and distributed by TomeVault — claim your Tome and manage your conversions.