Block Image

In questo tutorial vedremo come applicare l'approccio API-first con Spring Boot 4 e l'ultima versione di OpenAPI Generator che lo supporta.

È utile sia per chi parte da un nuovo progetto sia per chi deve migrare da Spring Boot 3 a Spring Boot 4 e quindi alla nuova versione di OpenAPI Generator.

Progetteremo API per la risorsa book (per brevità implementeremo GET su singola risorsa, GET paginata e POST).

Trovi il codice completo sul repository Git: https://github.com/vincenzo-racca/boot4-apifirst.

L'approccio API-first

Quando si progettano le API, possiamo utilizzare due approcci:

  • Approccio tradizionale: le API vengono implementate dopo aver scritto il codice. Il file OpenAPI può essere generato dal codice esistente (ad esempio in Java, utilizzando le annotazioni di Swagger sulle classi).
  • Approccio API-first: prima di scrivere il codice dell'applicazione, gli sviluppatori e gli stakeholder definiscono il contratto dell'API. Dal file OpenAPI così progettato il codice può poi essere generato da plugin come OpenAPI Generator.

Il secondo approccio presenta diversi vantaggi rispetto al primo. A differenza dell'approccio tradizionale, in cui si programma prima l'applicazione e poi se ne ricava l'API (spesso con scelte di design frettolose), l'API-first dà priorità alla progettazione fin dal primo momento, trattando le API come i veri e propri "mattoni" fondamentali del sistema anziché come un'aggiunta finale.

Inoltre, progettando prima il file OpenAPI, il contratto è disponibile da subito: i client possono iniziare l'integrazione senza aspettare lo sviluppo del server.

Block Image

Il file OpenAPI

Il file OpenAPI book-openapi.yml è il seguente e possiamo incollarlo nella directory docs, da creare nella root del progetto.

Endpoints:

paths:
  /books:
    get:
      tags:
        - Books
      summary: Retrieve all books
      operationId: getAllBooks
      parameters:
        - name: page
          in: query
          description: Page number (starting from 0)
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0

        - name: size
          in: query
          description: Number of items per page
          required: false
          schema:
            type: integer
            minimum: 1
            default: 20

        - name: sort
          in: query
          description: >
            Sorting criteria in the format `field,(asc|desc)`.
            Can be specified multiple times.
            Examples: `title,asc`, `author,desc`
          required: false
          explode: true
          schema:
            type: array
            items:
              type: string
          example:
            - title,asc
            - author,desc
      responses:
        '200':
          description: List of books retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookPage'
        '400':
          $ref: '#/components/responses/BadRequest'

    post:
      tags:
        - Books
      summary: Create a new book
      operationId: createBook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookRequest'
      responses:
        '201':
          description: Book created successfully
          headers:
            Location:
              description: URI of the created book
              schema:
                type: string
                format: uri
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

  /books/{isbn}:
    parameters:
      - name: isbn
        in: path
        required: true
        description: ISBN code of the book
        schema:
          type: string
          example: "9780132350884"

    get:
      tags:
        - Books
      summary: Retrieve a book by ISBN
      operationId: getBookByIsbn
      responses:
        '200':
          description: Book found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookResponse'
        '404':
          $ref: '#/components/responses/NotFound'

Schemas:

components:
  responses:
    BadRequest:
      description: Invalid request
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

    Conflict:
      description: Book already exists
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

    NotFound:
      description: Book not found
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

  schemas:
    BookDto:
      type: object
      required:
        - bookType
        - isbn
        - title
        - author
      properties:
        bookType:
          type: string
          example: EBOOK
        isbn:
          type: string
          example: "9780132350884"
        title:
          type: string
          example: Clean Code
        author:
          type: string
          example: Robert C. Martin
        price:
          type: number
          example: 45.00
        publicationDate:
          type: string
          format: date
          example: "2008-08-01"
      discriminator:
        propertyName: bookType
        mapping:
          PAPERBACK: '#/components/schemas/PaperbackDto'
          EBOOK: '#/components/schemas/EBookDto'
          AUDIOBOOK: '#/components/schemas/AudioBookDto'

    PaperbackDto:
      description: Physical book; carries paperback-specific fields
      allOf:
        - $ref: '#/components/schemas/BookDto'
        - type: object
          required:
            - pages
          properties:
            pages:
              type: integer
              description: Number of physical pages
              example: 464
            weightGrams:
              type: number
              description: Weight of the book in grams
              example: 730.0

    EBookDto:
      description: Digital book; carries e-book-specific fields
      allOf:
        - $ref: '#/components/schemas/BookDto'
        - type: object
          required:
            - format
            - fileSizeMb
          properties:
            format:
              type: string
              description: Digital format
              example: EPUB
            fileSizeMb:
              type: number
              description: File size in MB
              example: 8.2

    AudioBookDto:
      description: Audio book; carries audiobook-specific fields
      allOf:
        - $ref: '#/components/schemas/BookDto'
        - type: object
          required:
            - narrator
            - durationMinutes
          properties:
            narrator:
              type: string
              description: Narrator name
              example: Luca Ward
            durationMinutes:
              type: integer
              description: Duration in minutes
              example: 450

    CreateBookRequest:
      oneOf:
        - $ref: '#/components/schemas/PaperbackDto'
        - $ref: '#/components/schemas/EBookDto'
        - $ref: '#/components/schemas/AudioBookDto'

    BookResponse:
      description: >
      oneOf:
        - $ref: '#/components/schemas/PaperbackDto'
        - $ref: '#/components/schemas/EBookDto'
        - $ref: '#/components/schemas/AudioBookDto'

    BookPage:
      type: object
      properties:
        content:
          type: array
          items:
            $ref: '#/components/schemas/BookResponse'

        pageable:
          $ref: '#/components/schemas/Pageable'

        totalElements:
          type: integer
          format: int64
          example: 100

        totalPages:
          type: integer
          example: 5

        size:
          type: integer
          example: 20

        number:
          type: integer
          example: 0

        numberOfElements:
          type: integer
          example: 20

        first:
          type: boolean
          example: true

        last:
          type: boolean
          example: false

        empty:
          type: boolean
          example: false

    Pageable:
      type: object
      properties:
        pageNumber:
          type: integer
          example: 0

        pageSize:
          type: integer
          example: 20

        offset:
          type: integer
          format: int64
          example: 0

        paged:
          type: boolean
          example: true

        unpaged:
          type: boolean
          example: false

    ProblemDetail:
      type: object
      properties:
        type:
          type: string
          format: uri
          example: about:blank
        title:
          type: string
          example: Bad Request
        status:
          type: integer
          example: 400
        detail:
          type: string
          example: Invalid request
        instance:
          type: string
          format: uri
          example: /api/books

Nota che sfruttiamo il polimorfismo della specifica OpenAPI tramite il campo discriminator bookType. La risorsa book può quindi essere di tre tipi: PaperbackDto, EBookDto o AudioBookDto.

I DTO CreateBookRequest e BookResponse, invece, sono "contenitori" di BookDto e non sottotipi, quindi usano oneOf anziché allOf.

Da questa specifica il plugin genererà la classe astratta BookDto, estesa dalle classi concrete PaperbackDto, EBookDto e AudioBookDto, e le interfacce CreateBookRequest e BookResponse, implementate dagli stessi DTO concreti.

apifirst

Per la paginazione abbiamo implementato un DTO simile a quello restituito da Spring Data REST. Essendo un DTO custom, può avere più o meno campi rispetto a quello di Spring: un mapping (realizzato qui con la libreria MapStruct) converte il DTO di Spring in quello custom.

Ho utilizzato intenzionalmente il discriminator per mostrarne il funzionamento, sebbene non sia sempre la scelta più appropriata: in certi contesti è preferibile duplicare i campi nei singoli schemi. La differenza principale è che il discriminator rende il contratto più pulito ma anche più complesso, offrendo un controllo superiore sui dati inseriti dal client. Ad esempio, inviando una POST con bookType=AUDIOBOOK e inserendo erroneamente il campo fileSizeMb (esclusivo degli E-book), quest'ultimo verrebbe automaticamente ignorato in fase di parsing. Senza il discriminator, invece, quel campo verrebbe comunque mappato nella classe Java durante la deserializzazione, costringendo lo sviluppatore a gestire la pulizia o la validazione dei dati lato codice per evitare incongruenze sul database. Trovi la versione senza discriminator sul branch feature/nodiscriminator.
Le response riutilizzabili servono per risposte comuni a più endpoint. Vedi qui per saperne di più: https://swagger.io/docs/specification/v3_0/describing-responses/#reusing-responses.

Creazione del progetto

Possiamo usare il seguente comando per scaricare lo scheletro del progetto:

curl https://start.spring.io/starter.zip -d groupId=com.vincenzoracca \
      -d artifactId=boot4-apifirst \
      -d name=boot4-apifirst \
      -d packageName=com.vincenzoracca.boot4apifirst \
      -d dependencies=web,validation,spring-restclient,opentelemetry,actuator,h2,data-jdbc \
      -d javaVersion=25 -d bootVersion=4.1.0 -d type=maven-project -o boot4-apifirst.zip

Le dipendenze rilevanti sono:

  • spring-boot-starter-webmvc: è la nuova dipendenza di Spring Boot 4 per creare API REST con l'approccio Servlet e non Reactive (sostituisce spring-boot-starter-web).
  • spring-boot-starter-restclient: poiché Spring Boot 4 usa un approccio più modulare, i client REST RestClient e RestTemplate hanno una dipendenza dedicata. Non useremo questa dipendenza nel progetto. L'ho inclusa solo per sottolineare il nuovo approccio di Spring Boot 4.
  • spring-boot-starter-validation: dipendenza necessaria per applicare la validazione Java sulle API. Quando una chiamata a un nostro endpoint non rispetta i parametri richiesti (ad esempio mancano parametri obbligatori), Spring genererà automaticamente un 400 Bad Request. Dipendenza necessaria anche per l'utilizzo del plugin di OpenAPI Generator.
  • spring-boot-starter-opentelemetry: utilizzata per il tracing delle API con OpenTelemetry.

Il resto delle dipendenze è poco rilevante. Useremo H2 come database in-memory e Spring Data JDBC per interfacciarci al nostro database.

Aggiunta del plugin OpenAPI Generator

Aggiungiamo nel pom.xml la versione 7.20.0 del plugin di OpenAPI Generator che supporta Spring Boot 4:

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.20.0</version>
    <executions>
        <execution>
            <id>generate-book-api</id>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <inputSpec>${project.basedir}/docs/openapi/book-openapi.yml</inputSpec>
                <generatorName>spring</generatorName>
                <configOptions>
                    <interfaceOnly>true</interfaceOnly>
                    <openApiNullable>false</openApiNullable>
                    <useSpringBoot4>true</useSpringBoot4>
                    <useJackson3>true</useJackson3>
                    <documentationProvider>none</documentationProvider>
                    <annotationLibrary>none</annotationLibrary>
                    <apiPackage>com.vincenzoracca.boot4.api.generated</apiPackage>
                    <modelPackage>com.vincenzoracca.boot4.model.generated</modelPackage>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

Analizziamo le configurazioni del plugin:

  1. interfaceOnly=true: permette di generare solo le interfacce delle API senza i metodi stub. Segue l'approccio API-first.
  2. openApiNullable=false: permette di non richiedere la libreria Jackson Nullable, che serve a distinguere se un campo è null oppure non è presente nel payload.
  3. useSpringBoot4=true: permette di generare le classi per Spring Boot 4. Implicitamente imposta useJackson3=true e useJakartaEe=true.
  4. useJackson3=true: permette al generatore di usare le librerie di Jackson 3, utilizzate di default da Spring Boot 4.
  5. documentationProvider=none: disabilita il provider della documentazione delle API tramite annotazioni. Impostandola a none, non vogliamo creare annotazioni: nell'approccio tradizionale dalle annotazioni si può generare il file OpenAPI.
  6. annotationLibrary=none: stessa cosa di sopra, ma con documentazione aggiuntiva tramite Swagger 1 o Swagger 2.
Il codice autogenerato non cambia se si omette useJackson3=true (il default è false). Questo perché la dipendenza jackson-annotations non cambia il nome del package dalla versione 2 alla 3 (annotazioni generate dal plugin). Tuttavia, impostandola a false, il plugin aggiunge le librerie di Jackson 2, che sono inutili (vedi pom.xml generato nel path target/generated-sources/openapi/pom.xml).

Generazione delle classi

Per generare le classi model e API dal plugin, dalla root del progetto eseguiamo il comando ./mvnw clean compile.

Vediamo le classi autogenerate. L'unica interfaccia API è la seguente:

@Validated
public interface BooksApi {
    String PATH_CREATE_BOOK = "/books";
    String PATH_GET_ALL_BOOKS = "/books";
    String PATH_GET_BOOK_BY_ISBN = "/books/{isbn}";
    

    @RequestMapping(
        method = {RequestMethod.POST},
        value = {"/books"},
        produces = {"application/problem+json"},
        consumes = {"application/json"}
    )
    default ResponseEntity<Void> createBook(@RequestBody @Valid CreateBookRequest createBookRequest) {
      // ...
    }

    @RequestMapping(
        method = {RequestMethod.GET},
        value = {"/books"},
        produces = {"application/json", "application/problem+json"}
    )
    default ResponseEntity<BookPage> getAllBooks(@RequestParam(value = "page",required = false,defaultValue = "0") @Min(0L) @Valid Integer page, @RequestParam(value = "size",required = false,defaultValue = "20") @Min(1L) @Valid Integer size, @RequestParam(value = "sort",required = false) @Nullable @Valid List<String> sort) {
      // ...
    }

    @RequestMapping(
        method = {RequestMethod.GET},
        value = {"/books/{isbn}"},
        produces = {"application/json", "application/problem+json"}
    )
    default ResponseEntity<BookResponse> getBookByIsbn(@PathVariable("isbn") @NotNull String isbn) {
        // ...
    }
}

Questa è l'interfaccia che dobbiamo implementare nella nostra classe Controller:

@RestController
public class BookingController implements BooksApi {

    private final BookService bookService;

    public BookingController(BookService bookService) {
        this.bookService = bookService;
    }

    @Override
    public ResponseEntity<Void> createBook(CreateBookRequest request) {
        Book saved = bookService.save(request);
        URI uri = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{isbn}")
                .buildAndExpand(saved.isbn())
                .toUri();

        return ResponseEntity.created(uri).build();
    }

    @Override
    public ResponseEntity<BookResponse> getBookByIsbn(String isbn) {
        BookResponse response = bookService.findByIsbn(isbn);
        return ResponseEntity.ok(response);
    }

    @Override
    public ResponseEntity<BookPage> getAllBooks(
            Integer page,
            Integer size,
            List<String> sort
    ) {
        BookPage bookPage = bookService.findAll(page, size, sort);
        return ResponseEntity.ok(bookPage);
    }
}

I modelli generati sono:

@JsonIgnoreProperties(
    value = {"bookType"},
    allowSetters = true
)
@JsonTypeInfo(
    use = Id.NAME,
    include = As.PROPERTY,
    property = "bookType",
    visible = true
)
@JsonSubTypes({@Type(
    value = AudioBookDto.class,
    name = "AUDIOBOOK"
), @Type(
    value = EBookDto.class,
    name = "EBOOK"
), @Type(
    value = PaperbackDto.class,
    name = "PAPERBACK"
)})
public class BookDto {
    // ...
}

@JsonIgnoreProperties(
        value = {"bookType"},
        allowSetters = true
)
@JsonTypeInfo(
        use = Id.NAME,
        include = As.PROPERTY,
        property = "bookType",
        visible = true
)
@JsonSubTypes({@Type(
        value = AudioBookDto.class,
        name = "AUDIOBOOK"
), @Type(
        value = EBookDto.class,
        name = "EBOOK"
), @Type(
        value = PaperbackDto.class,
        name = "PAPERBACK"
)})
public interface BookResponse {
  String getBookType();
}

// CreateBookRequest is equivalent to BookResponse

public class PaperbackDto extends BookDto implements BookResponse, CreateBookRequest {
    // ...
}

// EBookDto and AudioBookDto have the same structure as PaperbackDto

Conclusioni

In questo tutorial abbiamo applicato l'approccio API-first con Spring Boot 4 e OpenAPI Generator 7.20.0, partendo dal contratto OpenAPI per generare automaticamente interfacce e modelli. Al controller resta così solo la logica applicativa, mentre il contratto rimane l'unica fonte di verità condivisa tra server e client.

Abbiamo anche visto come modellare il polimorfismo con il discriminator e come configurare il plugin per Spring Boot 4 (Jackson 3, Jakarta EE e generazione delle sole interfacce).

Questo approccio richiede più cura nella specifica iniziale, ma ripaga con API più coerenti, meno ambiguità di integrazione e una migrazione a Spring Boot 4 più lineare. Trovi il codice completo sul repository GitHub.

Se vuoi approfondire la progettazione delle API, con architettura ed esempi pratici, ho scritto il libro Spring Boot 3 API Mastery, disponibile su Amazon.