Awesome-omni-skill spring-rest-api
RESTful API design with Spring Boot including OpenAPI/Swagger documentation, content negotiation, CORS, pagination, HATEOAS, and API versioning patterns.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/spring-rest-api" ~/.claude/skills/diegosouzapw-awesome-omni-skill-spring-rest-api && rm -rf "$T"
skills/development/spring-rest-api/SKILL.mdSpring REST API
Build production-ready RESTful APIs with Spring Boot following industry best practices for design, documentation, error handling, and security.
When to Use
- Building HTTP APIs consumed by web/mobile frontends, third-party integrations, or microservices
- Exposing CRUD operations over domain entities with pagination, sorting, and filtering
- Creating public or internal APIs that require OpenAPI/Swagger documentation
- Projects needing standardized error responses (RFC 7807 Problem Detail)
- APIs requiring content negotiation (JSON, XML), CORS, or versioning
When NOT to Use
- Real-time bidirectional communication -- use WebSockets or SSE instead
- File-heavy streaming endpoints -- consider Spring WebFlux or dedicated file services
- GraphQL APIs -- use
spring-boot-starter-graphql - gRPC services -- use
grpc-spring-boot-starter - Simple internal method calls between co-located services -- direct invocation is simpler
Dependencies
Maven
<dependencies> <!-- Core web starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- OpenAPI / Swagger UI --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.8.4</version> </dependency> <!-- HATEOAS (optional, for hypermedia-driven APIs) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> <!-- Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- XML content negotiation (optional) --> <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> </dependency> </dependencies>
Gradle
dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' implementation 'org.springframework.boot:spring-boot-starter-hateoas' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml' // optional }
REST Conventions
URL Patterns
Follow resource-oriented URL design. URLs represent nouns (resources), not verbs (actions).
| Pattern | Purpose | Example |
|---|---|---|
| List collection | Fetch all users (paginated) |
| Get single resource | Fetch user by ID |
| Create resource | Create a new user |
| Full replace | Replace entire user |
| Partial update | Update specific fields |
| Delete resource | Remove user |
| Sub-resource collection | User's orders |
| Sub-resource item | Specific order |
Rules:
- Use plural nouns:
not/users/user - Use kebab-case for multi-word resources:
not/order-items/orderItems - Nest sub-resources at most one level deep
- Use query parameters for filtering, sorting, and pagination
- Prefix all API routes with
(or versioned equivalent)/api/v1
HTTP Methods
| Method | Idempotent | Safe | Request Body | Typical Use |
|---|---|---|---|---|
| Yes | Yes | No | Retrieve resource(s) |
| No | No | Yes | Create resource |
| Yes | No | Yes | Full replacement |
| No | No | Yes | Partial update |
| Yes | No | No | Remove resource |
| Yes | Yes | No | Check existence |
| Yes | Yes | No | CORS preflight |
HTTP Status Codes
| Code | When to Use |
|---|---|
| Successful GET, PUT, PATCH, or DELETE returning body |
| Successful POST creating a resource (include header) |
| Successful DELETE or PUT/PATCH with no response body |
| Malformed request syntax, invalid JSON |
| Missing or invalid authentication |
| Authenticated but lacking permission |
| Resource does not exist |
| State conflict (duplicate email, concurrent edit) |
| Validation errors on well-formed request |
| Unhandled server exception |
See
references/http-status-codes.md for complete reference with Spring examples.
Controller Patterns
Basic REST Controller
@RestController @RequestMapping("/api/v1/users") @Tag(name = "Users", description = "User management operations") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping public ResponseEntity<PageResponse<UserResponse>> list( @ParameterObject Pageable pageable, @RequestParam(required = false) String search) { Page<UserResponse> page = userService.findAll(search, pageable); return ResponseEntity.ok(PageResponse.of(page)); } @GetMapping("/{id}") public ResponseEntity<UserResponse> getById(@PathVariable Long id) { return ResponseEntity.ok(userService.findById(id)); } @PostMapping public ResponseEntity<UserResponse> create( @Valid @RequestBody CreateUserRequest request) { UserResponse created = userService.create(request); URI location = ServletUriComponentsBuilder.fromCurrentRequest() .path("/{id}").buildAndExpand(created.id()).toUri(); return ResponseEntity.created(location).body(created); } @PutMapping("/{id}") public ResponseEntity<UserResponse> replace( @PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) { return ResponseEntity.ok(userService.replace(id, request)); } @PatchMapping("/{id}") public ResponseEntity<UserResponse> patch( @PathVariable Long id, @Valid @RequestBody PatchUserRequest request) { return ResponseEntity.ok(userService.patch(id, request)); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } }
Key Annotations
| Annotation | Purpose |
|---|---|
| Combines + |
| Base path for all endpoints in controller |
| Handle GET requests |
| Handle POST requests |
| Handle PUT requests |
| Handle PATCH requests |
| Handle DELETE requests |
| Extract value from URL path segment |
| Extract query parameter |
| Deserialize request body |
| Trigger Bean Validation on parameter |
| Set default HTTP status for handler |
| Expand Pageable into individual query params in OpenAPI |
Request/Response DTOs
Use Java records for immutable, concise DTOs. Separate request and response representations.
// Request DTO -- what the client sends public record CreateUserRequest( @NotBlank @Size(max = 100) @Schema(description = "User's full name", example = "Jane Doe") String name, @NotBlank @Email @Size(max = 255) @Schema(description = "Unique email address", example = "jane@example.com") String email, @NotBlank @Size(min = 8, max = 72) @Schema(description = "Password (8-72 characters)", example = "s3cur3P@ss!") String password ) {} // Response DTO -- what the client receives (never expose passwords, internal IDs, etc.) public record UserResponse( @Schema(description = "Unique identifier", example = "42") Long id, @Schema(description = "User's full name", example = "Jane Doe") String name, @Schema(description = "Email address", example = "jane@example.com") String email, @Schema(description = "Account status", example = "ACTIVE") String status, @Schema(description = "Account creation timestamp") Instant createdAt ) {}
Rules:
- Never reuse the same DTO for create, update, and response
- Never expose sensitive fields (passwords, internal flags) in responses
- Use
on every field for OpenAPI documentation@Schema - Apply Bean Validation annotations on request DTOs
- Use
for timestamps, let Jackson serialize to ISO-8601Instant
Pagination
Using Spring's Pageable
Spring Data provides
Pageable and Page out of the box. Clients pass page, size, and sort query parameters.
GET /api/v1/users?page=0&size=20&sort=name,asc&sort=createdAt,desc
Custom PageResponse Wrapper
Wrap Spring's
Page in a consistent, API-friendly format:
public record PageResponse<T>( List<T> content, PageMetadata metadata ) { public record PageMetadata( int page, int size, long totalElements, int totalPages, boolean first, boolean last ) {} public static <T> PageResponse<T> of(Page<T> page) { return new PageResponse<>( page.getContent(), new PageMetadata( page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages(), page.isFirst(), page.isLast() ) ); } }
Pagination Configuration
spring: data: web: pageable: default-page-size: 20 max-page-size: 100 one-indexed-parameters: false
Sorting
Accept
sort as a query parameter. Spring Data parses sort=field,direction automatically.
@GetMapping public ResponseEntity<PageResponse<UserResponse>> list( @ParameterObject Pageable pageable) { // pageable.getSort() contains parsed Sort object Page<UserResponse> page = userService.findAll(pageable); return ResponseEntity.ok(PageResponse.of(page)); }
To restrict sortable fields, validate in the service layer:
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("name", "email", "createdAt"); private Pageable validateSort(Pageable pageable) { for (Sort.Order order : pageable.getSort()) { if (!ALLOWED_SORT_FIELDS.contains(order.getProperty())) { throw new InvalidSortFieldException(order.getProperty(), ALLOWED_SORT_FIELDS); } } return pageable; }
Filtering
Simple Query Parameters
@GetMapping public ResponseEntity<PageResponse<UserResponse>> list( @RequestParam(required = false) String search, @RequestParam(required = false) UserStatus status, @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate createdAfter, @ParameterObject Pageable pageable) { Page<UserResponse> page = userService.findAll(search, status, createdAfter, pageable); return ResponseEntity.ok(PageResponse.of(page)); }
JPA Specification Pattern
For complex filtering, use Spring Data JPA Specifications:
public class UserSpecifications { public static Specification<User> hasStatus(UserStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } public static Specification<User> nameLike(String search) { return (root, query, cb) -> search == null ? null : cb.like(cb.lower(root.get("name")), "%" + search.toLowerCase() + "%"); } public static Specification<User> createdAfter(LocalDate date) { return (root, query, cb) -> date == null ? null : cb.greaterThanOrEqualTo( root.get("createdAt"), date.atStartOfDay().toInstant(ZoneOffset.UTC)); } }
Combine in service:
public Page<UserResponse> findAll(String search, UserStatus status, LocalDate createdAfter, Pageable pageable) { Specification<User> spec = Specification .where(UserSpecifications.nameLike(search)) .and(UserSpecifications.hasStatus(status)) .and(UserSpecifications.createdAfter(createdAfter)); return userRepository.findAll(spec, pageable).map(userMapper::toResponse); }
CORS Configuration
Profile-Based Configuration
@Configuration public class CorsConfig { @Bean @Profile("dev") public WebMvcConfigurer devCorsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }; } @Bean @Profile("prod") public WebMvcConfigurer prodCorsConfigurer( @Value("${app.cors.allowed-origins}") List<String> allowedOrigins) { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins(allowedOrigins.toArray(String[]::new)) .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("Authorization", "Content-Type", "Accept") .exposedHeaders("Location", "X-Total-Count") .allowCredentials(true) .maxAge(3600); } }; } }
Content Negotiation
Spring Boot supports JSON by default. Add XML support with
jackson-dataformat-xml.
spring: mvc: contentnegotiation: favor-parameter: false favor-path-extension: false
Clients use the
Accept header:
GET /api/v1/users Accept: application/json # JSON response (default) Accept: application/xml # XML response
To restrict to JSON only on a specific controller:
@RestController @RequestMapping(path = "/api/v1/users", produces = MediaType.APPLICATION_JSON_VALUE) public class UserController { ... }
API Versioning
Strategy 1: URL Path (Recommended for most projects)
@RestController @RequestMapping("/api/v1/users") public class UserControllerV1 { ... } @RestController @RequestMapping("/api/v2/users") public class UserControllerV2 { ... }
Strategy 2: Custom Request Header
@GetMapping(headers = "X-API-Version=1") public ResponseEntity<UserResponseV1> getUserV1(@PathVariable Long id) { ... } @GetMapping(headers = "X-API-Version=2") public ResponseEntity<UserResponseV2> getUserV2(@PathVariable Long id) { ... }
Strategy 3: Media Type (Accept Header)
@GetMapping(produces = "application/vnd.myapp.v1+json") public ResponseEntity<UserResponseV1> getUserV1(@PathVariable Long id) { ... } @GetMapping(produces = "application/vnd.myapp.v2+json") public ResponseEntity<UserResponseV2> getUserV2(@PathVariable Long id) { ... }
Recommendation
Use URL path versioning (
/api/v1/) for simplicity and discoverability. Reserve header/media-type versioning for APIs with strict backward-compatibility contracts.
OpenAPI / Swagger
Configuration
@Configuration public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("My Application API") .version("1.0.0") .description("RESTful API documentation") .contact(new Contact() .name("API Support") .email("support@example.com"))) .addSecurityItem(new SecurityRequirement().addList("Bearer")) .components(new Components() .addSecuritySchemes("Bearer", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))); } }
Key Annotations
@Operation(summary = "Get user by ID", description = "Retrieves a single user by their unique identifier") @ApiResponse(responseCode = "200", description = "User found") @ApiResponse(responseCode = "404", description = "User not found", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) @GetMapping("/{id}") public ResponseEntity<UserResponse> getById(@PathVariable Long id) { ... }
See
references/openapi-annotations.md for complete annotation reference.
Swagger UI Access
After starting the application:
- Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON:
http://localhost:8080/v3/api-docs - OpenAPI YAML:
http://localhost:8080/v3/api-docs.yaml
Error Responses -- ProblemDetail (RFC 7807)
Spring 6+ natively supports RFC 7807
ProblemDetail responses.
Enable globally
spring: mvc: problemdetails: enabled: true
Custom Exception Handler
@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ProblemDetail handleNotFound(ResourceNotFoundException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.NOT_FOUND, ex.getMessage()); problem.setTitle("Resource Not Found"); problem.setProperty("resource", ex.getResourceName()); problem.setProperty("identifier", ex.getIdentifier()); return problem; } }
Response Format
{ "type": "about:blank", "title": "Resource Not Found", "status": 404, "detail": "User with id 42 not found", "instance": "/api/v1/users/42", "resource": "User", "identifier": "42" }
HATEOAS
Add hypermedia links to responses for API discoverability.
@GetMapping("/{id}") public EntityModel<UserResponse> getById(@PathVariable Long id) { UserResponse user = userService.findById(id); return EntityModel.of(user, linkTo(methodOn(UserController.class).getById(id)).withSelfRel(), linkTo(methodOn(UserController.class).list(Pageable.unpaged(), null)) .withRel("users"), linkTo(methodOn(OrderController.class).listByUser(id, Pageable.unpaged())) .withRel("orders")); }
Response:
{ "id": 42, "name": "Jane Doe", "email": "jane@example.com", "_links": { "self": { "href": "/api/v1/users/42" }, "users": { "href": "/api/v1/users" }, "orders": { "href": "/api/v1/users/42/orders" } } }
Use HATEOAS when:
- Building public APIs consumed by diverse, evolving clients
- API discoverability is a requirement
- You want clients to navigate the API without hardcoded URLs
Skip HATEOAS when:
- Internal APIs with known consumers (e.g., your own SPA)
- Simplicity is more important than discoverability
Code Quality Checklist
Before submitting a REST API for review, verify:
- All endpoints return appropriate HTTP status codes (201 for POST, 204 for DELETE, etc.)
- POST endpoints return
header pointing to the created resourceLocation - Request DTOs have Bean Validation annotations (
,@NotBlank
,@Size
, etc.)@Email - Response DTOs never expose sensitive fields (passwords, tokens, internal flags)
- Separate DTOs for create, update, and response -- never reuse a single DTO
- Pagination is applied to all collection endpoints (never return unbounded lists)
- Sort fields are validated against an allow-list
- All endpoints have OpenAPI annotations (
,@Operation
,@ApiResponse
)@Schema - Error responses use ProblemDetail (RFC 7807) format
- CORS is configured per environment (permissive dev, restrictive prod)
- No business logic in controllers -- delegate to service layer
-
is present on all@Valid
parameters@RequestBody - Path variables and query parameters have clear, documented names
- API version prefix is consistent across all controllers (
)/api/v1/ - Integration tests cover happy path, validation errors, not-found, and conflict scenarios