Saltar al contenido principal

OpenAPI Generator

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, en el/los commit(s) OpenAPI Generation (spring_java), OpenAPI Generation (spring_kotlin), OpenAPI Generation (spring_groovy).

El problema con los Primary Adapters Manuales

A menos que trabajes en microservicios, un proyecto suele tener más que unos pocos endpoints. A medida que tu dominio crece, crear primary adapters manualmente se convierte en una tarea monumental. Viene con muchos inconvenientes:

  • Consume mucho tiempo: Cada endpoint, cada modelo, todo a mano. Es una molestia.
  • Propenso a errores humanos: Un typo, un campo olvidado, y de repente tu API no está haciendo lo que debería.
  • Compromete la documentación y mantenibilidad: En el momento en que tu código se desvía de tu spec, tu documentación es una mentira, y tu mantenibilidad se va al tacho.
Escenario de caso real

El proyecto SIGEM (un monolito Grails) presume 76 controladores y 1102 endpoints.

Controllers Count Sigem 73f335f981504a788cd7019c83079745

Archivos involucrados

Archivos a Crear/Modificar
File Tree

├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ ├── common
│ │ │ ├── util
│ │ │ │ └── EnumUtils.java
│ │ │ └── ValuedEnum.java
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ ├── domain
│ │ │ │ ├── model
│ │ │ │ │ ├── Film.java
│ │ │ │ │ ├── FilmLanguage.java
│ │ │ │ │ └── FilmRating.java
│ │ │ │ └── service
│ │ │ │ └── FilmUseCasesImpl.java
│ │ │ └── ...
│ │ └── ...
│ └── ...
└── ...

Generando código

Ahorrémosnos algunos problemas usando una de las mejores librerías que existen: openapi-generator.

build.gradle (plugin block)
id 'org.openapi.generator' version '7.20.0'
build.gradle (dependencies block)
implementation 'io.swagger.core.v3:swagger-annotations:2.2.45'
implementation 'org.openapitools:jackson-databind-nullable:0.2.9'
implementation 'org.springframework.boot:spring-boot-starter-validation'
build.gradle (new openApiGenerate block)
openApiGenerate {
generatorName = "spring"
inputSpec = layout.projectDirectory.file("src/main/resources/openapi.yaml").asFile.toString()
outputDir = layout.buildDirectory.dir("generated/sources/openapi").get().asFile.toString()

def basePackage = "${project.group}.${project.name}.sakila.generated".toString()
apiPackage = "${basePackage}.api"
modelPackage = "${basePackage}.model"

configOptions = [
interfaceOnly : "true",
requestMappingMode : "api_interface",
skipDefaultInterface : "true",
useJakartaEe : "true",
useSpringBoot3 : "true",
useTags : "true",
]
}

sourceSets {
main {
java {
srcDir(layout.buildDirectory.dir("generated/sources/openapi/src/main/java"))
}
}
}

tasks.named('compileJava') {
dependsOn 'openApiGenerate'
}
Documentación del Spring Generator

Podés encontrar más información sobre las diferentes configuraciones en la Documentación para el generador spring.

aviso

inputspec debe estar apuntando al archivo YAML de OpenAPI Specification deseado (src/main/resources/openapi.yaml).

Ahora que todo está configurado, ejecutá la tarea Build. Cuando la tarea termine, revisá la carpeta build\generated\sources\openapi. Vas a encontrar la representación de la OpenAPI Specification (nuestro contrato) en clases, listas para ser usadas.

Entendiendo el código generado

  • ¿Qué hay adentro del código generado?
    • Models: Clases Java (o Kotlin) que reflejan tus schemas de OpenAPI (ej., Film). Estas incluyen anotaciones de validación, lógica de serialización y patrones builder.
    • API Interfaces: Interfaces Spring @RestController (ej., FilmApi) que definen tus endpoints y sus firmas de métodos.
  • ¿Por qué se ve tan complicado? El código generado incluye:
    • Boilerplate para compatibilidad con OpenAPI/Spring (ej., anotaciones @Validated, @Generated).
    • Lógica de validación (ej., @NotNull, @Size) para hacer cumplir tu contrato.
    • Soporte de serialización/deserialización (ej., mapeo JSON ↔ objeto Java (o Kotlin)).
  • ¿Debería importarme? No.
    • Es autogenerado: Trátalo como una dependencia compilada. Lo usás, no lo modificás.
    • Filosofía contract-first: El código coincide exactamente con tu spec de OpenAPI. Si necesitás cambios, actualizá el archivo YAML y regenerá.
    • Libre de mantenimiento: El generador maneja las actualizaciones, así que evitás refactoring manual.

Usando el código generado

  1. Hacé que la clase @RestController implemente la interfaz Api generada:
    java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
    package dev.pollito.spring_java.sakila.film.adapter.in.rest;

    import static io.opentelemetry.api.trace.Span.current;
    import static java.time.OffsetDateTime.now;
    import static org.springframework.http.HttpStatus.OK;
    import static org.springframework.http.ResponseEntity.ok;

    import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
    import dev.pollito.spring_java.sakila.generated.api.FilmsApi;
    import dev.pollito.spring_java.sakila.generated.model.FilmFields;
    import dev.pollito.spring_java.sakila.generated.model.FilmListResponse;
    import dev.pollito.spring_java.sakila.generated.model.FilmResponse;
    import jakarta.servlet.http.HttpServletRequest;
    import java.util.List;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.RestController;

    @RestController
    @RequiredArgsConstructor
    public class FilmRestController implements FilmsApi {
    private final FilmUseCases useCases;
    private final FilmRestMapper mapper;
    private final HttpServletRequest request;

    @Override
    public ResponseEntity<FilmResponse> createFilm(FilmFields filmFields) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<Void> deleteFilm(Integer id) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<FilmResponse> getFilm(Integer id) {
    return ok(
    new FilmResponse()
    .data(mapper.map(useCases.getFilm(id)))
    .instance(request.getRequestURI())
    .timestamp(now())
    .trace(current().getSpanContext().getTraceId())
    .status(OK.value()));
    }

    @Override
    public ResponseEntity<FilmListResponse> getFilms(Integer page, Integer size, List<String> sort) {
    throw new RuntimeException("Not implemented");
    }

    @Override
    public ResponseEntity<FilmResponse> updateFilm(Integer id, FilmFields filmFields) {
    throw new RuntimeException("Not implemented");
    }
    }
  2. Actualizá la interfaz Mapper así retorna el modelo generated.model.Film generado en lugar del escrito a mano:
    java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
    package dev.pollito.spring_java.sakila.film.adapter.in.rest;

    import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

    import dev.pollito.spring_java.sakila.film.domain.model.Film;
    import org.mapstruct.Mapper;

    @Mapper(componentModel = SPRING)
    public interface FilmRestMapper {
    dev.pollito.spring_java.sakila.generated.model.Film map(Film source);
    }
  3. Actualizá la clase @RestControllerAdvice así retorna el modelo Error generado (que modela ProblemDetail) para mantener consistencia:
    java/dev/pollito/spring_java/config/web/ControllerAdvice.java
    package dev.pollito.spring_java.config.web;

    import static io.opentelemetry.api.trace.Span.current;
    import static java.time.OffsetDateTime.now;
    import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
    import static org.springframework.http.HttpStatus.NOT_FOUND;
    import static org.springframework.http.ResponseEntity.status;

    import dev.pollito.spring_java.sakila.generated.model.Error;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.jspecify.annotations.NonNull;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    import org.springframework.web.servlet.resource.NoResourceFoundException;

    @RestControllerAdvice
    @RequiredArgsConstructor
    @Slf4j
    public class ControllerAdvice {
    private final HttpServletRequest request;

    private @NonNull ResponseEntity<Error> buildProblemDetail(
    @NonNull Exception e, @NonNull HttpStatus status) {
    String exceptionSimpleName = e.getClass().getSimpleName();
    String logMessage = "{} being handled";

    switch (status.series()) {
    case SERVER_ERROR -> log.error(logMessage, exceptionSimpleName, e);
    case CLIENT_ERROR -> log.warn(logMessage, exceptionSimpleName, e);
    default -> log.info(logMessage, exceptionSimpleName, e);
    }

    return status(status)
    .body(
    new Error()
    .detail(e.getLocalizedMessage())
    .instance(request.getRequestURI())
    .status(status.value())
    .timestamp(now())
    .title(status.getReasonPhrase())
    .trace(current().getSpanContext().getTraceId()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Error> handle(Exception e) {
    return buildProblemDetail(e, INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<Error> handle(NoResourceFoundException e) {
    return buildProblemDetail(e, NOT_FOUND);
    }
    }
  4. Actualizá los modelos del domain para que representen mejor los casos de uso:
    • Patrón Enum interface + utility class:
    java/dev/pollito/spring_java/common/ValuedEnum.java
    package dev.pollito.spring_java.common;

    public interface ValuedEnum<T> {
    T getValue();
    }
    java/dev/pollito/spring_java/common/util/EnumUtils.java
    package dev.pollito.spring_java.common.util;

    import dev.pollito.spring_java.common.ValuedEnum;
    import org.jspecify.annotations.NonNull;

    public final class EnumUtils {

    private EnumUtils() {}

    public static <E extends Enum<E> & ValuedEnum<V>, V> @NonNull E fromValue(
    @NonNull Class<E> enumClass, V value) {
    for (E constant : enumClass.getEnumConstants()) {
    if (constant.getValue().equals(value)) {
    return constant;
    }
    }
    throw new IllegalArgumentException("Unknown " + enumClass.getSimpleName() + " value: " + value);
    }
    }
    • Actualización del modelo de dominio:
    java/dev/pollito/spring_java/sakila/film/domain/model/Film.java
    package dev.pollito.spring_java.sakila.film.domain.model;

    import static lombok.AccessLevel.*;

    import java.math.BigDecimal;
    import java.time.OffsetDateTime;
    import lombok.*;
    import lombok.experimental.FieldDefaults;
    import org.jspecify.annotations.NonNull;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @FieldDefaults(level = PRIVATE)
    public class Film {
    @NonNull Integer id;
    @NonNull String title;
    String description;
    Integer releaseYear;
    FilmRating rating;
    Integer length;
    @NonNull FilmLanguage language;
    FilmLanguage originalLanguage;
    @NonNull Integer rentalDuration;
    @NonNull BigDecimal rentalRate;
    @NonNull BigDecimal replacementCost;
    String specialFeatures;
    @NonNull OffsetDateTime lastUpdate;
    }
  5. Borrá el FilmResponse escrito a mano, ya no es necesario.

Compilá y ejecutá la aplicación. Después andá a http://localhost:8080/api/films/42

Terminal
curl -s http://localhost:8080/api/films/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-02-19T16:41:30.343577085Z",
"trace": "0342323f19e37da6b13a009854548007",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 42,
"language": "English",
"lengthMinutes": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}

¿Cuál es el punto de generar código?

Configurar este OpenAPI Generator puede parecer como si estuvieras realizando rituales antiguos solo para sacar algo de código. Quizás estés pensando, "¿No se suponía que esto debía ahorrarme tiempo?" Y a eso, te digo: Sí, estás invirtiendo un poco de esfuerzo inicial para evitar mucho dolor después.

El OpenAPI Generator no solo genera código; actúa como un guardián muy estricto de tu contrato de API.

  • No más creación manual de DTOs: Olvidate de la tarea tediosa de escribir Data Transfer Objects (DTOs) a mano. Tu especificación de API los define, y el generador los construye perfectamente, cada vez. Esto elimina una categoría entera de typos e inconsistencias.
  • Implementaciones triviales de controladores: Tus interfaces Spring @RestController, con todas sus anotaciones de routing, también se generan. Esto significa que tus clases controller pasan de ser fábricas de boilerplate a componentes minimalistas que simplemente implementan una interfaz predefinida.
  • Contrato forzado por el compilador: Como tus DTOs e interfaces de controller son reflejos directos de tu spec de OpenAPI, el compilador se convierte en tu aliado más estricto. Un campo faltante, un tipo cambiado, una firma de endpoint alterada, resultará en un error de compilación. Es imposible romper tu contrato de API sin feedback inmediato.

Así que, sí, es un poco de configuración, pero ¿la recompensa? Absolutamente vale la pena.