--- name: rest-api-conventions description: > Use when generating REST controllers, DTOs, success response contracts, pagination, HTTP status mapping, or API versioning. For RFC 9457 exception and error response formatting, use problem-details-rfc9457 unless the project explicitly requires a legacy error envelope. --- # REST API Conventions ## Project Contract Inspect existing controllers, tests and OpenAPI before choosing a response contract. Preserve the project's IDs, response shape and versioning strategy. Do not migrate unrelated endpoints while adding one route. The envelope below is an optional convention for a project that uses it; plain success DTOs are equally valid. A 204 response has no body. Success and error formats are independent: an existing success envelope can coexist with RFC 9457 errors. Use one consistent error policy; choose the legacy error examples below only when the project already requires that format. Example success envelope: ```json { "success": true, "data": { }, "error": null, "timestamp": "2026-04-13T10:00:00Z" } ``` Error response: ```json { "success": false, "data": null, "error": { "code": "ORDER_NOT_FOUND", "message": "Order with id 123 not found", "details": [] }, "timestamp": "2026-04-13T10:00:00Z" } ``` ## ApiResponse Wrapper ```java @JsonInclude(JsonInclude.Include.NON_NULL) public record ApiResponse( boolean success, T data, ApiError error, Instant timestamp ) { public static ApiResponse ok(T data) { return new ApiResponse<>(true, data, null, Instant.now()); } public static ApiResponse error(String code, String message) { return new ApiResponse<>(false, null, new ApiError(code, message, List.of()), Instant.now()); } } public record ApiError(String code, String message, List details) {} ``` ## HTTP Status Mapping | Scenario | Status | |----------|--------| | GET — found | 200 | | POST — created resource | 201 | | PUT/PATCH — updated | 200 | | DELETE — deleted | 204 (no body) | | Validation failure | 400 | | Unauthenticated | 401 | | Forbidden | 403 | | Not found | 404 | | Conflict (duplicate) | 409 | | Unhandled server error | 500 | ## URL Conventions - **Plural nouns** for resources: `/orders`, `/users`, `/products` - **Kebab-case** for multi-word: `/order-items`, not `/orderItems` - **Versioning in path**: `/api/v1/orders` - **Nested resources** max 2 levels: `/orders/{id}/items` ✅, `/orders/{id}/items/{itemId}/notes` ❌ — flatten to `/order-item-notes/{id}` - **IDs**: preserve the existing ID type. UUIDs are an option, not an authorization mechanism. ``` GET /api/v1/orders → list (paginated) POST /api/v1/orders → create GET /api/v1/orders/{id} → get one PUT /api/v1/orders/{id} → full update PATCH /api/v1/orders/{id} → partial update DELETE /api/v1/orders/{id} → delete GET /api/v1/orders/{id}/items → nested resource ``` ## Pagination ```json { "success": true, "data": { "content": [...], "page": 0, "size": 20, "totalElements": 150, "totalPages": 8, "last": false } } ``` Query params: `?page=0&size=20&sort=createdAt,desc` Use Spring Data `Pageable` in controllers: ```java @GetMapping public ApiResponse> list(Pageable pageable) { return ApiResponse.ok(PageResponse.from(orderService.findAll(pageable).map(OrderResponse::from))); } ``` **Cap the page size.** A bare `Pageable` accepts `?size=100000` from any client — one request can drag your whole table into memory. Spring's default cap is 2000, still too high for most APIs: ```yaml spring: data: web: pageable: default-page-size: 20 max-page-size: 100 # requests above this are silently clamped ``` The compiled [PageResponse](templates/PageResponse.java) fixes the JSON pagination contract. Mapping entities to DTOs alone does not stabilize Spring Data `PageImpl` serialization. Spring Data's `org.springframework.data.web.PagedModel` is another option when its shape fits the API. Validate sort fields against an allowlist and add an ID tie-breaker to non-unique sorts. ## Legacy Envelope Exception Handler ```java @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity> handleNotFound(EntityNotFoundException ex) { return ResponseEntity.status(404).body(ApiResponse.error("NOT_FOUND", ex.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { List details = ex.getBindingResult().getFieldErrors().stream() .map(e -> e.getField() + ": " + e.getDefaultMessage()).toList(); return ResponseEntity.status(400) .body(new ApiResponse<>(false, null, new ApiError("VALIDATION_FAILED", "Invalid input", details), Instant.now())); } @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneric(Exception ex) { return ResponseEntity.status(500).body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred")); } } ``` ## Gotchas - Agent imposes a new envelope - preserve the existing success contract. - Agent invents competing error handlers - use the project's established error contract. - Agent puts exception handlers in controllers — always use `@RestControllerAdvice` - Agent changes ID types while adding an endpoint - retain the existing ID scheme. - Agent accepts unbounded `Pageable` — set `spring.data.web.pageable.max-page-size` or one request can pull the whole table - Agent serializes PageImpl directly - map entities to DTOs, then wrap in PageResponse.