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.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
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"
manifest: skills/development/spring-rest-api/SKILL.md
source content

Spring 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).

PatternPurposeExample
GET /api/v1/users
List collectionFetch all users (paginated)
GET /api/v1/users/{id}
Get single resourceFetch user by ID
POST /api/v1/users
Create resourceCreate a new user
PUT /api/v1/users/{id}
Full replaceReplace entire user
PATCH /api/v1/users/{id}
Partial updateUpdate specific fields
DELETE /api/v1/users/{id}
Delete resourceRemove user
GET /api/v1/users/{id}/orders
Sub-resource collectionUser's orders
GET /api/v1/users/{id}/orders/{orderId}
Sub-resource itemSpecific order

Rules:

  • Use plural nouns:
    /users
    not
    /user
  • Use kebab-case for multi-word resources:
    /order-items
    not
    /orderItems
  • Nest sub-resources at most one level deep
  • Use query parameters for filtering, sorting, and pagination
  • Prefix all API routes with
    /api/v1
    (or versioned equivalent)

HTTP Methods

MethodIdempotentSafeRequest BodyTypical Use
GET
YesYesNoRetrieve resource(s)
POST
NoNoYesCreate resource
PUT
YesNoYesFull replacement
PATCH
NoNoYesPartial update
DELETE
YesNoNoRemove resource
HEAD
YesYesNoCheck existence
OPTIONS
YesYesNoCORS preflight

HTTP Status Codes

CodeWhen to Use
200 OK
Successful GET, PUT, PATCH, or DELETE returning body
201 Created
Successful POST creating a resource (include
Location
header)
204 No Content
Successful DELETE or PUT/PATCH with no response body
400 Bad Request
Malformed request syntax, invalid JSON
401 Unauthorized
Missing or invalid authentication
403 Forbidden
Authenticated but lacking permission
404 Not Found
Resource does not exist
409 Conflict
State conflict (duplicate email, concurrent edit)
422 Unprocessable Entity
Validation errors on well-formed request
500 Internal Server Error
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

AnnotationPurpose
@RestController
Combines
@Controller
+
@ResponseBody
@RequestMapping("/path")
Base path for all endpoints in controller
@GetMapping
Handle GET requests
@PostMapping
Handle POST requests
@PutMapping
Handle PUT requests
@PatchMapping
Handle PATCH requests
@DeleteMapping
Handle DELETE requests
@PathVariable
Extract value from URL path segment
@RequestParam
Extract query parameter
@RequestBody
Deserialize request body
@Valid
Trigger Bean Validation on parameter
@ResponseStatus
Set default HTTP status for handler
@ParameterObject
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
    @Schema
    on every field for OpenAPI documentation
  • Apply Bean Validation annotations on request DTOs
  • Use
    Instant
    for timestamps, let Jackson serialize to ISO-8601

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
    Location
    header pointing to the created resource
  • Request DTOs have Bean Validation annotations (
    @NotBlank
    ,
    @Size
    ,
    @Email
    , etc.)
  • 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
  • @Valid
    is present on all
    @RequestBody
    parameters
  • 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