CicloUrbana now has a complete API: thirteen endpoints, validation at the edge, DTOs that separate the domain from the contract and uniform errors in Problem Details format. And yet, everything we know about it —which fields each endpoint accepts, what it returns, which errors it can produce— lives in the code and in our heads. If tomorrow the Ribalta mobile app team or the council's open data portal team wanted to integrate, they would have to read our code or ask us endpoint by endpoint. In this lesson we turn that tacit knowledge into a formal contract, generated automatically from the code itself, readable by humans in a web interface and by machines to generate clients. With it module 3 closes and Ribalta's API is ready for others to use.

Contents

  1. What OpenAPI is and why a machine-readable contract
  2. Code-first versus design-first
  3. Integrating springdoc-openapi
  4. Global metadata: the OpenAPI bean
  5. Documenting operations: @Tag and @Operation
  6. Parameters and responses: @Parameter and @ApiResponse
  7. Documenting the DTOs with @Schema
  8. Bean Validation in the schema, automatically
  9. Documenting the Problem Details errors
  10. Grouping endpoints with GroupedOpenApi
  11. Swagger UI: testing the API from the browser
  12. Exporting the contract and generating a client
  13. Not exposing Swagger UI in production
  14. Common Mistakes and Tips
  15. Exercises

  1. What OpenAPI is and why a machine-readable contract

OpenAPI is a specification for describing HTTP APIs in a structured document, in JSON or YAML. The current version is OpenAPI 3.1, which unlike 3.0 is fully compatible with JSON Schema, allowing data structures to be described precisely. A fragment of CicloUrbana's document:

openapi: 3.1.0
info: { title: CicloUrbana API, version: "1.0.0" }
paths:
  /api/v1/stations/{id}:
    get:
      tags: [Stations]
      summary: Get the detail of a station
      parameters:
        - { name: id, in: path, required: true,
            schema: { type: integer, format: int64, minimum: 1 } }
      responses:
        "200":
          description: Station found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StationDetailResponse" }
        "404":
          description: The station does not exist
          content:
            application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } }

Being machine-readable is what changes the way you work:

Benefit What it enables in practice
Generated clients The Android team generates its Kotlin client without writing it
Living documentation Swagger UI updates on every deployment: it never goes stale
Contract tests A pipeline detects when a change breaks it (module 8)
Mock servers The frontend works against a mock before the backend exists
Developer portal The council publishes its API with browsable documentation
Automatic validation A gateway rejects requests that do not match the schema

The contrast with the alternative —a text document or a wiki page— is that such documentation goes out of sync on day one: nobody remembers to update it when adding a field, and three months later it lies. A contract generated from the code cannot lie.

  1. Code-first versus design-first

There are two ways of arriving at the OpenAPI document:

Aspect Code-first Design-first
Starting point The Java code The openapi.yaml file
The contract is... Generated from the code Written by hand, with code generated from it
Synchronisation Guaranteed by construction Requires discipline and verification
API design Emerges from the code Decided and negotiated beforehand
Teams in parallel The client waits for the backend Both start at the same time
Typical risk An API that mirrors the internal model Divergence between contract and code

The course uses code-first with springdoc for two reasons: a pedagogical one, that you can see the direct relationship between each annotation and the resulting document, and a practical one, that for a small team with a single backend maintaining a thousand-line openapi.yaml by hand costs more than it gives.

That said, it is worth understanding why design-first dominates in large organisations: when five teams consume your API, the contract is a prior negotiation and not a by-product. Writing it first lets the mobile team start against a mock the same day the backend starts implementing it, and it avoids the most cited risk of code-first: that the API ends up being a reflection of the internal model instead of a design made for whoever consumes it. It is worth noting that in this module we have in fact worked design-first without tools: the table of thirteen endpoints from 03-01 was written before any controller, only it lived in a Markdown table and now it becomes a formal artefact.

  1. Integrating springdoc-openapi

springdoc-openapi inspects the @RestControllers, their annotations and their types at runtime, and builds the OpenAPI document. A single dependency:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.7.0</version>
</dependency>

The suffix matters: -ui includes Swagger UI, whereas springdoc-openapi-starter-webmvc-api generates only the document; for a reactive application it would be webflux. We start up and there are already two new endpoints, without writing a line:

curl -s http://localhost:8080/v3/api-docs | jq '.paths | keys'
# "/api/v1/rentals", "/api/v1/rentals/{id}/finish", "/api/v1/bikes",
# "/api/v1/stations", "/api/v1/stations/{id}/bikes", ...

The thirteen endpoints are there, with their parameters and their response schemas deduced from the DTOs, and at http://localhost:8080/swagger-ui.html there is a browsable interface. All the work of the previous lessons —precise types, specific DTOs, declarative validation— is what makes that automatic deduction good: a controller returning Map<String, Object> would produce nothing useful.

The configuration in YAML:

springdoc:
  api-docs:
    path: /v3/api-docs           # path of the JSON document
    version: openapi_3_1         # 3.1 instead of the default 3.0
  swagger-ui:
    path: /swagger-ui.html
    operations-sorter: method    # sorts by verb: GET, POST, PUT, DELETE
    tags-sorter: alpha
    display-request-duration: true
    doc-expansion: none          # starts fully collapsed: more readable
    try-it-out-enabled: true
  show-actuator: false           # do not document the Actuator endpoints
  packages-to-scan: com.ciclourbana
  paths-to-match: /api/**        # the API only, nothing else

doc-expansion: none deserves a comment: with thirteen endpoints expanded the landing page is a wall of text, and collapsed you see the API's structure at a glance.

  1. Global metadata: the OpenAPI bean

Annotations document endpoints; global metadata —title, version, contact, licence and servers— is declared in a bean:

package com.ciclourbana.common;

@Configuration
public class OpenApiConfig {

    @Bean
    OpenAPI cicloUrbanaApi(@Value("${ciclourbana.version:1.0.0}") String version) {
        return new OpenAPI()
                .info(new Info()
                        .title("CicloUrbana API")
                        .version(version)
                        .description("""
                                Public API of Ribalta's municipal electric bike
                                network. Errors follow RFC 7807.""")
                        .contact(new Contact().name("CicloUrbana platform team")
                                .email("[email protected]"))
                        .license(new License()
                                .name("Ribalta City Council Open Licence")
                                .url("https://ribalta.example/open-data/licence")))
                .servers(List.of(
                        new Server().url("https://api.ciclourbana.ribalta.example")
                                .description("Production"),
                        new Server().url("https://api-pre.ciclourbana.ribalta.example")
                                .description("Pre-production"),
                        new Server().url("http://localhost:8080")
                                .description("Local development")))
                .externalDocs(new ExternalDocumentation()
                        .description("CicloUrbana integration guide")
                        .url("https://ciclourbana.ribalta.example/docs/integration"));
    }
}

Three useful details. The list of Servers appears in Swagger UI as a dropdown, so whoever tries the API chooses which environment to fire requests at without editing URLs. The version is injected with @Value rather than hard-coded, so the documentation always states which version is deployed (in module 7 it will be able to come from the pom.xml via Actuator). And the description accepts Markdown: it is the place for cross-cutting conventions, such as the error format, pagination or authentication.

  1. Documenting operations: @Tag and @Operation

Tags group the endpoints into sections inside Swagger UI and are declared at class level: @Tag(name = "Stations", description = "Querying and managing Ribalta's docking stations") on StationController.

And @Operation describes each method:

@Operation(
    summary = "Get the detail of a station",
    operationId = "getStationById",
    description = """
            Returns a station with its complete data and the list of bikes
            docked at it, with the free docks and whether it is full.

            The availability data is computed in real time and must not be
            cached for more than 60 seconds.""")
@GetMapping("/{id:\\d+}")
public StationDetailResponse getById(@PathVariable("id") @Positive Long id) { ... }
Attribute What it does Advice
summary One-line title in the list Start with a verb, no full stop
description Long explanation, accepts Markdown The nuances go here, not in summary
operationId Unique identifier of the operation It determines the generated method name
deprecated Marks the operation as obsolete Use it before removing, never instead of

operationId is the most neglected attribute and the one with the greatest consequences: it is the name the method will have in the generated clients. Without it, springdoc invents something like getById_1, and that ugly name ends up in every client team's code. With operationId = "getStationById", the Android team's Kotlin client will have a readable getStationById(id).

  1. Parameters and responses: @Parameter and @ApiResponse

@Parameter documents each input:

@Operation(summary = "List stations", operationId = "listStations")
@GetMapping
public List<StationResponse> list(
        @Parameter(description = "Filter by name; partial match",
                   example = "north")
        @RequestParam(required = false) @Size(max = 80) String name,
        @Parameter(description = "Page number, starting at 0", example = "0")
        @RequestParam(defaultValue = "0") @Min(0) int page,
        @Parameter(description = "Elements per page, maximum 100", example = "20")
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { ... }

The examples are not decorative: Swagger UI preloads them into the "Try it out" form, so whoever tries the API for the first time gets a call that works without inventing values. And @ApiResponse describes each possible response:

@Operation(summary = "Register a station", operationId = "createStation")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Station created successfully",
        headers = @Header(name = "Location", description = "URI of the created station",
                          schema = @Schema(type = "string")),
        content = @Content(schema = @Schema(implementation = StationResponse.class))),
    @ApiResponse(responseCode = "400", description = "Invalid input data",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class))),
    @ApiResponse(responseCode = "409", description = "A station with that name already exists",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class)))})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<StationResponse> create(@Valid @RequestBody CreateStationRequest r) { }

With @ExampleObject you document concrete bodies, which is especially valuable when the same status code has several causes:

@ApiResponse(responseCode = "409", description = "Conflict with the current state",
    content = @Content(mediaType = "application/problem+json",
        examples = {
            @ExampleObject(name = "Duplicate name", value = """
                { "title": "Duplicate station", "status": 409,
                  "detail": "A station named 'Main Square' already exists",
                  "code": "DUPLICATE_STATION", "existingId": 1 }"""),
            @ExampleObject(name = "Station full", value = """
                { "title": "Conflict with the current state", "status": 409,
                  "detail": "Station University has no free docks",
                  "code": "STATION_FULL" }""")}))

Swagger UI shows a dropdown with the two examples: it is the most effective way of explaining the project's error codes without writing a separate document.

  1. Documenting the DTOs with @Schema

The DTOs from 03-05 become OpenAPI schemas, and @Schema enriches them:

@Schema(name = "StationResponse", description = "Summary view for the listings")
public record StationResponse(

        @Schema(description = "Unique identifier", example = "1",
                requiredMode = Schema.RequiredMode.REQUIRED) Long id,

        @Schema(description = "Public name", example = "Main Square",
                minLength = 3, maxLength = 80) String name,

        @Schema(description = "Postal address", example = "Main Square 1") String address,

        @Schema(description = "Total docks", example = "24",
                minimum = "1", maximum = "60") int capacity,

        @Schema(description = "Bikes available right now; computed in "
                            + "real time", example = "7",
                accessMode = Schema.AccessMode.READ_ONLY) int availableBikes,

        @Schema(description = "Geographic coordinates") LocationResponse location) {}
Attribute Effect
description Explanatory text next to the field
example Example value in the documentation and in "Try it out"
requiredMode REQUIRED, NOT_REQUIRED or AUTO (the default)
accessMode READ_ONLY (responses only), WRITE_ONLY (requests only)
defaultValue / deprecated Default value and obsolescence marker
allowableValues Allowed values, for enums or closed strings

accessMode = READ_ONLY is the most useful and least known: it marks a field as server-generated, and client generators exclude it from request objects, so the client cannot try to send availableBikes. It is the contract-level translation of the separation between request and response DTOs.

In the request DTOs, requiredMode and the examples are what matter:

@Schema(description = "Data for registering a station in the Ribalta network")
public record CreateStationRequest(

        @Schema(description = "Public name, unique across the network",
                example = "Central Market", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(min = 3, max = 80) String name,

        @Schema(description = "Full postal address", example = "Market Street 8",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(max = 120) String address,

        @Schema(description = "Total docks. A multiple of 6", example = "24",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @Positive @Max(60) int capacity,

        @Schema(description = "Latitude within the Ribalta boundary", example = "41.3902")
        @DecimalMin("-90.0") @DecimalMax("90.0") double latitude,

        @Schema(description = "Longitude", example = "2.1655")
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitude) {}

Enums document themselves with their list of values, but it is worth describing each state of the Ribalta domain:

@Schema(description = "Operational status of a bike in the network")
public enum BikeStatus {
    @Schema(description = "Docked and ready to rent") AVAILABLE,
    @Schema(description = "Rented, out on the city streets") IN_USE,
    @Schema(description = "Temporarily withdrawn by the workshop") MAINTENANCE,
    @Schema(description = "Permanently withdrawn from the network") RETIRED
}

  1. Bean Validation in the schema, automatically

Here comes the reward for lesson 03-04. springdoc reads the Bean Validation annotations and translates them into OpenAPI schema constraints, with no effort at all.

Annotation from 03-04 Constraint in the schema
@NotNull, @NotBlank, @NotEmpty The field appears in required
@Size(min, max) minLength / maxLength (or minItems / maxItems)
@Min / @Max / @Positive minimum / maximum / exclusiveMinimum: 0
@DecimalMin / @DecimalMax minimum / maximum with decimals
@Pattern(regexp) / @Email pattern / format: email

The schema generated for CreateStationRequest:

"CreateStationRequest": {
  "type": "object", "required": ["name", "address", "capacity"],
  "properties": {
    "name": { "type": "string", "minLength": 3, "maxLength": 80,
              "description": "Public name, unique across the network",
              "example": "Central Market" },
    "capacity": { "type": "integer", "exclusiveMinimum": 0, "maximum": 60 },
    "latitude": { "type": "number", "minimum": -90.0, "maximum": 90.0 } } }

None of those constraints was written for the documentation: they all came from the validation annotations. A single source of truth that validates at runtime and documents at the same time, with no chance of the two drifting apart.

The custom constraints we built —@BikePlate, @MultipleOf— are unknown to springdoc, so they have to be documented by hand with @Schema(pattern = "^RB-\\d{4}$", example = "RB-0142"). That is a good argument for composing your own constraints on top of the standard ones: if @BikePlate had been defined as a composed annotation including @Pattern, springdoc would have deduced the pattern on its own.

  1. Documenting the Problem Details errors

Repeating three error @ApiResponses on each of the thirteen endpoints is exactly the kind of repetition that ends up out of sync. The solution is your own composed annotations:

package com.ciclourbana.common.openapi;

/** Error responses common to the read operations. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Malformed or invalid request",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "404", description = "The requested resource does not exist",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "500", description = "Internal server error",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
public @interface StandardErrorResponses {}

// And in the controller, the three errors in one word:
@GetMapping("/{id:\\d+}")
@Operation(summary = "Get the detail of a station", operationId = "getStationById")
@StandardErrorResponses
public StationDetailResponse getById(@PathVariable("id") @Positive Long id) { ... }

Since ProblemDetail is a Spring class, its generated schema does not include our extensions (code, trace, errors), so the right move is to declare a schema of our own that documents them:

/**
 * Used for documentation only: the real responses are built by
 * GlobalExceptionHandler with ProblemDetail. It exists so that the
 * contract describes our RFC 7807 extensions.
 */
@Schema(name = "CicloUrbanaError",
        description = "Error in RFC 7807 format with the CicloUrbana extensions")
public record CicloUrbanaErrorSchema(

        @Schema(description = "URI identifying the problem type",
                example = "https://api.ciclourbana.example/errors/resource-not-found")
        String type,

        @Schema(description = "Stable summary of the type", example = "Resource not found")
        String title,

        @Schema(description = "HTTP status code", example = "404") int status,

        @Schema(description = "Explanation of this specific occurrence",
                example = "No station found with identifier 999") String detail,

        @Schema(description = "Path that caused the error",
                example = "/api/v1/stations/999") String instance,

        @Schema(description = "Stable CicloUrbana error code. Use it in your "
                            + "logic instead of the 'detail' text",
                example = "RESOURCE_NOT_FOUND") String code,

        @Schema(description = "Trace; quote it when contacting support",
                example = "a3f5c9e1") String trace,

        @Schema(description = "Errors by field, only in 400 responses")
        Map<String, List<String>> errors) {}

The code field documented with "use it in your logic instead of the text" is exactly the kind of guidance that stops a client from comparing strings and breaking when we translate a message.

  1. Grouping endpoints with GroupedOpenApi

As the API grows, a single document with everything mixed together becomes hard to navigate. GroupedOpenApi produces several documents from the same application:

@Bean GroupedOpenApi publicGroup() {
    return GroupedOpenApi.builder().group("public")
            .displayName("Ribalta public API")
            .pathsToMatch("/api/v1/stations/**", "/api/v1/bikes/**").build();
}

@Bean GroupedOpenApi rentalsGroup() {
    return GroupedOpenApi.builder().group("rentals")
            .displayName("Rentals (authentication required)")
            .pathsToMatch("/api/v1/rentals/**").build();
}

@Bean GroupedOpenApi internalGroup() {
    return GroupedOpenApi.builder().group("internal")
            .displayName("Operator panel")
            .pathsToMatch("/api/v1/internal/**").build();
}

Each group has its own document at /v3/api-docs/public, /v3/api-docs/rentals and /v3/api-docs/internal, and Swagger UI shows a dropdown for switching between them. The three usual uses: separating audiences, publishing only the public group on the council's portal; separating versions, with a /api/v1/** group and an /api/v2/** one; and generating different clients, one per group, so that the citizen app does not drag along the operator panel's operations.

  1. Swagger UI: testing the API from the browser

At http://localhost:8080/swagger-ui.html the complete API appears, grouped by the tags from section 5, and each operation expands to show its description, its parameters with examples, the body schema and every possible response. The "Try it out" button turns the documentation into an HTTP client: it fills the form with the examples we declared, lets you edit them and fires the real request against the server chosen in the dropdown, showing the response, its headers, the status code and —very handy for sharing— the equivalent curl command.

It is worth understanding what makes it genuinely useful, because it is not the tool itself: it is that the examples are properly set. An endpoint documented without an example forces you to invent values, and trying POST /api/v1/stations without knowing the capacity must be a multiple of 6 ends in a 400. With the examples, any new developer's first call works. A detail for module 5: when we add JWT, Swagger UI will show an "Authorize" button if we declare the security scheme, and from then on it will include the token in every call.

  1. Exporting the contract and generating a client

The document can be dumped to a file during the build with the springdoc plugin, which starts the application, downloads the JSON and stops it:

<plugin>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-maven-plugin</artifactId>
    <version>1.4</version>
    <executions><execution>
        <id>generate-contract</id>
        <phase>integration-test</phase>
        <goals><goal>generate</goal></goals>
    </execution></executions>
    <configuration>
        <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
        <outputFileName>openapi.json</outputFileName>
        <outputDir>${project.build.directory}</outputDir>
    </configuration>
</plugin>

It requires the spring-boot-maven-plugin with the start and stop goals bound to pre-integration-test and post-integration-test. The result, target/openapi.json, is the artefact that gets published: it is uploaded to the council's portal, compared with the previous version to detect incompatible changes (module 8) and it feeds client generation with the openapi-generator-maven-plugin:

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.10.0</version>
    <executions><execution>
        <goals><goal>generate</goal></goals>
        <configuration>
            <inputSpec>${project.build.directory}/openapi.json</inputSpec>
            <generatorName>java</generatorName>
            <library>resttemplate</library>
            <apiPackage>com.ciclourbana.client.api</apiPackage>
            <modelPackage>com.ciclourbana.client.model</modelPackage>
            <configOptions>
                <useJakartaEe>true</useJakartaEe>
                <serializationLibrary>jackson</serializationLibrary>
            </configOptions>
        </configuration>
    </execution></executions>
</plugin>

The generator supports more than fifty languages: java, kotlin, typescript-axios, python, swift5, go. Ribalta's Android app team generates its Kotlin client from the same openapi.json, and the web portal team its TypeScript one.

The generated code looks like this:

// Automatically generated. Do not edit.
public class StationsApi {
    public StationDetailResponse getStationById(Long id) { ... }
    public List<StationResponse> listStations(String name, Integer page,
                                              Integer size) { ... }
    public StationResponse createStation(CreateStationRequest createStationRequest) { ... }
}

Here you can see why we insisted on operationId: those method names come straight from it. And the StationDetailResponse and CreateStationRequest types are generated from our schemas, with the same validation constraints, so that the client validates before sending because the contract carries them inside. An additional benefit that shows up over time: if we remove a field from a DTO, the generated client stops compiling on the next update; an incompatible change that in an API without a contract is discovered in production is discovered here at compile time.

  1. Not exposing Swagger UI in production

Swagger UI is a development tool. In production it is a detailed map of your attack surface: every endpoint, every parameter, every expected format, and a form for trying them out.

Scenario Recommendation
Public API documented on purpose Publish the document on a portal, not Swagger UI
Internal company API Swagger UI only behind the VPN or with authentication
API with personal data Neither the document nor the interface publicly reachable

The simplest way of switching it off is springdoc.api-docs.enabled: false and springdoc.swagger-ui.enabled: false.

And the correct approach, with profiles: leave it active in the base application.yml —used by development and the tests— and disable it in application-prod.yml, which overrides it when starting with --spring.profiles.active=prod. Profiles are covered in 07-02, where we will pick this configuration up again.

If Ribalta city council wants to publish the documentation of its open API, the right route is to export the openapi.json during the build (section 12) and serve it from a separate static portal, which exposes neither the real application nor the ability to fire requests at it. And one last risk: springdoc generates the documentation by inspecting every controller in the context, so a forgotten internal controller ends up published; springdoc.paths-to-match: /api/** is a cheap defence against that oversight.

Common Mistakes and Tips

Documenting only the happy path. An endpoint with only the 200 documented forces the client to discover the errors in production. The error @ApiResponses are half the value of the contract.

Forgetting operationId. The generated clients' methods come out with automatic, ugly names, and they change on their own when the code is reordered.

Leaving Swagger UI reachable in production. It is a complete description of your attack surface.

Putting in examples that do not work. An example with capacity 25 when the constraint demands multiples of 6 makes everybody's first attempt fail. Copy the examples from real requests.

Documenting the entity instead of the DTO. If @Schema is placed on Station and the endpoint returns StationResponse, the documentation describes something the API does not return.

Repeating the same @ApiResponses in every method. Thirteen copies that drift apart: use composed annotations. And do not expect springdoc to guess your own constraints: @BikePlate does not appear in the schema, so add the pattern by hand or compose your constraint on top of @Pattern.

Tip: read the /v3/api-docs as if you were an external client. If a field cannot be understood without opening the code, a description is missing; it is the most effective review there is and it takes ten minutes. And version the openapi.json in the repository: comparing the generated one with the previous one on every build turns any incompatible change into a continuous integration failure, before it reaches the clients.

Exercises

Exercise 1: Document the complete rental cycle

RentalController has no OpenAPI annotations at all. Document the three operations —start, finish and query— with their tags, summaries, operationIds, parameters, examples and every possible error response, including the business ones from 03-06.

Exercise 2: Composed annotation for the write operations

The operations that write (POST, PUT, PATCH, DELETE) share a different set of errors from the read ones: besides 400 and 500, they can give 409 and 422. Create @WriteErrorResponses and apply it, avoiding duplication of the common error definitions.

Exercise 3: Publish the contract and detect incompatible changes

Configure the build to export openapi.json and add a check that fails when a change breaks the contract. Explain which changes it must detect and which it must allow.

Solutions

Solution 1.

@RestController
@RequestMapping(path = "/api/v1/rentals", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Rentals",
     description = "Life cycle of a rental: start, query and finish")
public class RentalController {

    @Operation(summary = "Start a rental", operationId = "startRental",
        description = """
                Undocks a bike and opens a rental in the user's name.
                The bike must be `AVAILABLE` and with a charge equal to or above
                the network threshold (20% by default). The amount is not known until
                the rental is finished.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Rental started",
            headers = @Header(name = "Location", description = "URI of the created rental",
                              schema = @Schema(type = "string"))),
        @ApiResponse(responseCode = "404", description = "The user or the bike does not exist",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = CicloUrbanaErrorSchema.class))),
        @ApiResponse(responseCode = "422", description = "The bike cannot be rented",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = CicloUrbanaErrorSchema.class),
                examples = {
                    @ExampleObject(name = "Under maintenance", value = """
                        { "status": 422, "code": "BIKE_UNAVAILABLE",
                          "detail": "Bike RB-0143 is not available" }"""),
                    @ExampleObject(name = "Insufficient battery", value = """
                        { "status": 422, "code": "INSUFFICIENT_BATTERY",
                          "detail": "Bike RB-0151 has 12% battery" }""")}))
    })
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<RentalResponse> start(
            @Valid @RequestBody StartRentalRequest request) { ... }

    @Operation(summary = "Finish a rental", operationId = "finishRental",
        description = """
                Docks the bike at the destination station, closes the rental and
                computes the amount according to the user type's fare. A
                **non-idempotent** operation: finishing twice returns `422`.""")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Rental finished, with its amount"),
        @ApiResponse(responseCode = "404", description = "The rental or the station does not exist"),
        @ApiResponse(responseCode = "409", description = "The destination station is full"),
        @ApiResponse(responseCode = "422", description = "The rental was already finished")})
    @PostMapping(path = "/{id:\\d+}/finish", consumes = MediaType.APPLICATION_JSON_VALUE)
    public RentalResponse finish(
            @Parameter(description = "Identifier of the rental in progress", example = "7")
            @PathVariable("id") @Positive Long id,
            @Valid @RequestBody FinishRentalRequest request) { ... }
}

Two practical warnings. The first, a name clash: @RequestBody exists both in OpenAPI (io.swagger.v3.oas.annotations.parameters) and in Spring, so if you need the OpenAPI one you will have to fully qualify one of the two; the cleanest thing is to put the examples in the DTO's @Schema, as the code above does. The second, and it is the point of the exercise: the documentation explains the semantics, not just the types —that finishing is not idempotent, that the amount is not known until the end, that the minimum charge depends on configuration. None of that can be deduced from the Java signatures, and it is exactly what a client team needs to know.

Solution 2.

We take advantage of the fact that composed annotations can be nested:

/** Errors any API operation may produce. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Malformed or invalid request",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = CicloUrbanaErrorSchema.class)))
@ApiResponse(responseCode = "500", description = "Internal server error",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = CicloUrbanaErrorSchema.class)))
public @interface CommonErrorResponses {}

/** Common errors + the ones specific to write operations. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@CommonErrorResponses                         // inherits 400 and 500
@ApiResponse(responseCode = "409", description = "Conflict with the current state",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = CicloUrbanaErrorSchema.class)))
@ApiResponse(responseCode = "422", description = "Business rule violated",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = CicloUrbanaErrorSchema.class)))
public @interface WriteErrorResponses {}

// And in the controller: 400, 409, 422 and 500 in one go
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Register a station", operationId = "createStation")
@ApiResponse(responseCode = "201", description = "Station created")
@WriteErrorResponses
public ResponseEntity<StationResponse> create(@Valid @RequestBody CreateStationRequest r) { }

Composition is what makes this solution maintainable: the day the error format changes —by adding a support field to the schema, say—, you touch CommonErrorResponses and the change reaches the thirteen endpoints; had we copied the @ApiResponses, there would be thirteen places to update and one would be left behind. A practical warning: springdoc reads nested annotations, but it is worth verifying it after creating them, and the check is straightforward:

curl -s http://localhost:8080/v3/api-docs \
  | jq '.paths."/api/v1/stations".post.responses | keys'
# ["201", "400", "409", "422", "500"]

Solution 3.

The export is the one from section 12, additionally binding the application's start and stop to the integration cycle with the spring-boot-maven-plugin (start on pre-integration-test, stop on post-integration-test). Then, the compatibility check with openapi-diff against the published contract:

#!/usr/bin/env bash
# scripts/check-contract.sh
set -euo pipefail

docker run --rm -v "$PWD:/repo" openapitools/openapi-diff:latest \
    /repo/src/main/resources/openapi/openapi-published.json \
    /repo/target/openapi.json --fail-on-incompatible

--fail-on-incompatible returns a non-zero exit code if it detects a change that breaks clients, which makes continuous integration fail (module 8).

Change Does it break? Why
Removing an endpoint or operation Yes Clients receive 404 or 405
Removing or renaming a response field Yes The client gets null or fails
Adding a mandatory field to a request Yes Existing requests give 400
Changing the type of a field Yes Deserialisation failure in the client
Tightening a validation (maxLength 80 → 40) Yes Valid requests start failing
Removing a value from a request enum Yes The client sends it and receives 400
Changing the success status code Yes The client checks for 201 and gets 200
Adding an endpoint No Nobody was calling it
Adding a field to a response No Clients ignore it by configuration
Adding an optional parameter No Existing requests stay the same
Relaxing a validation (maxLength 80 → 120) No Everything that was valid still is
Adding a value to a response enum It depends An exhaustive switch may fail
Changing a description or an example No It does not affect behaviour

This table is, point for point, the compatible and incompatible changes table from lesson 03-01. The difference is that there it was a guideline you had to remember and here it is an automatic check that runs on every build. That is the real jump in value of having a formal contract: it turns a rule of discipline into a technical barrier.

The complete process when publishing: target/openapi.json is generated, compared with the published one and, if the change is compatible, copied over openapi-published.json and uploaded to the council's portal. If it is incompatible, continuous integration fails and the team consciously decides whether an /api/v2 needs negotiating.

Conclusion

Module 3 closes with the CicloUrbana API described in a formal contract that generates itself. You know what OpenAPI 3.1 is and what a machine-readable contract unlocks: generated clients, documentation that does not drift, contract tests, mock servers and developer portals. You have compared code-first and design-first with the judgement to choose according to team size. You integrated springdoc-openapi with a single dependency and saw the thirteen endpoints appear documented without writing anything, because the work had been done beforehand: precise types, specific DTOs and declarative validation. You configured the global metadata with the OpenAPI bean, documented operations with @Tag and @Operation —taking care of operationId, which ends up being the method name in other teams' clients—, the parameters with @Parameter and examples that make the first call work, and the responses with @ApiResponse and @ExampleObject. You enriched the DTOs with @Schema, including accessMode = READ_ONLY for the fields the server computes, and you checked that the Bean Validation constraints from 03-04 appear in the schema on their own: a single source of truth that validates and documents at once. You documented the Problem Details from 03-06 with composed annotations that avoid thirteen copies, grouped with GroupedOpenApi, exported the openapi.json during the build and generated a Java client with the openapi-generator-maven-plugin. And you know why Swagger UI must not be left exposed in production.

Look back at the whole module. It began with a single endpoint, GET /api/v1/stations, returning a list of raw domain objects. It ends with thirteen endpoints designed around the REST constraints and Richardson level 2, with the right verbs and status codes, the Location header on creations and ETag for concurrency control. With declarative validation at the edge, constraints specific to the Ribalta domain and internationalisation in three languages. With a DTO hierarchy that cleanly separates what CicloUrbana knows from what it promises, and a mapping that allows renaming in the domain without breaking a single client. With uniform RFC 7807 errors, a global handler, stable codes and traces correlatable with the log. And with a publishable OpenAPI contract from which other teams generate their client without asking us a thing. The API Ribalta's citizens will see is complete.

It is missing, though, the most elementary thing: when the application restarts, everything disappears. The four stations are loaded again from DemoStationLoader, the bikes that were registered vanish and the day's rentals are lost. Everything lives in a ConcurrentHashMap that exists as long as the process does. We have been able to get this far thanks to having hidden that provisional nature behind the StationRepository interface since lesson 02-01, a decision that now collects its reward.

Module 4, Data Access with Spring Boot, replaces it with real persistence. We will see what JPA, Hibernate and Spring Data are and how they relate; we will configure data sources and a connection pool; we will turn Station, Bike and Rental into JPA entities with their identifiers and their optimistic version —the one that will replace the ShallowEtagHeaderFilter from 03-03—; we will model the relationships between them along with the problems we already anticipated, lazy loading and N+1; we will use Spring Data repositories and their derived query methods; we will understand transactions and why @Transactional on the service changes the rules of the game; and we will manage schema evolution with Flyway. Ribalta's four stations are about to survive a restart.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved