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, bajo el tag openapi-spec.

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 una asombrosa cantidad de 1102 endpoints.

Controllers Count Sigem 73f335f981504a788cd7019c83079745

Archivos involucrados

Archivos a Crear/Modificar
File Tree

├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ ├── advice
│ │ │ │ └── ControllerAdvice.java
│ │ │ └── ...
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ └── ...
│ │ └── ...
│ └── ...
└── ...

Generando Código

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

build.gradle
plugins {
// ...
id 'org.openapi.generator' version '7.17.0'
}
// ...
dependencies {
// ...
implementation 'io.swagger.core.v3:swagger-annotations:2.2.41'
implementation 'org.openapitools:jackson-databind-nullable:0.2.8'
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
// ...
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}.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'
}
info

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.generated.api.FilmsApi;
    import dev.pollito.spring_java.generated.model.FilmListResponse;
    import dev.pollito.spring_java.generated.model.FilmResponse;
    import dev.pollito.spring_java.sakila.film.domain.port.in.FindByIdPortIn;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.RestController;

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

    @Override
    public ResponseEntity<FilmListResponse> findAll() {
    throw new UnsupportedOperationException();
    }

    @Override
    public ResponseEntity<FilmResponse> findById(Integer id) {
    return ok(
    new FilmResponse()
    .data(mapper.convert(findByIdPortIn.findById(id)))
    .instance(request.getRequestURI())
    .timestamp(now())
    .trace(current().getSpanContext().getTraceId())
    .status(OK.value()));
    }
    }
  2. Actualizá la interfaz Mapper así retorna el modelo Response 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 dev.pollito.spring_java.config.mapper.MapperSpringConfig;
    import dev.pollito.spring_java.sakila.film.domain.model.Film;
    import org.jspecify.annotations.Nullable;
    import org.mapstruct.Mapper;
    import org.springframework.core.convert.converter.Converter;

    @Mapper(config = MapperSpringConfig.class)
    public interface FilmRestMapper extends Converter<Film, dev.pollito.spring_java.generated.model.Film> {
    @Override
    dev.pollito.spring_java.generated.model.Film convert(@Nullable 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/advice/ControllerAdvice.java
    package dev.pollito.spring_java.config.advice;

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