Generar clientes REST con su interfaz OpenAPI

Escrito por el .
java planeta-codigo
Enlace permanente Comentarios

Para hacer uso de una interfaz REST es necesario crear un cliente en el mismo lenguaje de programación de la aplicación. Dada una interfaz REST compuesta por sus endpoints, parámetros, headers y payloads de entrada y de salida asi como sus códigos de estado de respuesta es posible automatizar con un generador de código la creación de un cliente para cualquiera de los lenguajes que se necesite y el generador soporte.

Java

Una de las características más destacadas de REST es que utiliza el protocolo http de la web y habitualmente JSON como formato para los datos. Por estas propiedades de REST hace que sean muy fáciles de consumir por los clientes, los clientes pueden utilizar un lenguaje diferente del que emplea el servidor, los servicios pueden ser reemplazados manteniendo el contrato de la interfaz del servicio REST, al mismo tiempo que hay muchas herramientas de infraestructura web para soportar esos servicios por ejemplo para implementar seguridad, cacheo, enrutado entre otras funcionalidades.

Dado que los servicios REST están destinados a ser consumidos por los clientes estos van a necesitar implementar la interfaz del servicio, cosa que hay que hacer en cada cliente. Esta integración en cada cliente puede hacer la tarea de integración repetitiva para la que además se necesita la definición del servicio REST.

Para la definición del servicio REST está la especificación OpenAPI y para la integración en los clientes hay generadores de clientes para diferentes lenguajes, usando diferentes librerías cliente REST y librerías de JSON.

La especificación de OpenAPI

OpenAPI es una especificación que define la sintaxis, propiedades y tipos del lenguaje. La especificación recoge en un documento la definición completa de un servicio REST.

Incluye los endpoints y sus URLs, los parámetros en el path y query, el body de la petición y el body de la petición y la respuesta incluyendo sus propiedades, estructura y tipos, los códigos de estado de las respuesta y finalmente opcionalmente documentación en prosa a diferentes niveles de la especificación.

La especificación OpenAPI es simplemente un documento de texto en formato YAML que sigue un esquema. Como editor que ofrece resaltado de sintaxis y detección de errores se puede usar Swagger Editor.

En vez de generar el documento a través de una aproximación api first, usando Spring es posible obtener el mismo documento a través de una aproximación code first mediante la cual el documento se genera a partir del código de la implementación del servidor, en Spring con anotaciones.

Generadores de clientes REST a partir de la especificación OpenAPI

Tener el documento de la especificación REST ya aporta una buena cantidad de valor como documentación y como documento de referencia.

Pero además tener la especificación permite generar los clientes lo que ahorra una enorme cantidad de tiempo en la implementación de los mismos, no solo en la creación sino también cuando hay que hacerles cambios.

Para Java hay dos generadores, swagger codegen y openapi generator, el primero es desarrollado por una compañía y el segundo con un modelo de desarrollo más comunitario. Ambos son muy parecidos ya que en realidad openapi generator es un fork por parte de los desarrolladores originales de ambos.

Los dos generadores permiten crear clientes para diferentes lenguajes como Java, JavaScript, TypeScript, C#, Kotlin, Swift. En el ámbito de Java es posible configurar la generación de clientes, seleccionando el cliente http, Retrofit junto con diferentes librerías para el procesado de JSON por ejemplo Jackson.

Ejemplo de especificación OpenAPI

Este es un ejemplo de especificación OpenAPI con un recurso y diferentes métodos http para las diferentes operaciones del recurso, como creación, actualización, obtención y eliminación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
openapi: 3.0.3
info:
  title: Catalog
  version: 1.0.0
servers:
  - url: https://picodotdev.github.io/catalog/
    variables:
      host:
        default: picodotdev.github.io
        enum:
          - picodotdev.github.io
tags:
  - name: Catalog
paths:
  /events/v1/{id}:
    get:
      tags:
        - Catalog
      summary: Get an event
      description: Get an event
      operationId: getEvent
      parameters:
        - name: id
          in: path
          required: true
          description: Id of the event
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
        '400':
          description: Bad request
        '404':
          description: Not found
      security:
        - bearer: []
components:
  schemas:
    Event:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        status:
          type: string
        locale:
          type: string
        date:
          type: string
        timezone:
          type: string
        uri:
          type: string
        seoURI:
          type: string
        categories:
          type: array
          items:
            $ref: '#/components/schemas/Category'
    Category:
      type: object
      properties:
        id:
          type: integer
          format: int64
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
catalog.yaml

Cliente generado con openapi-generator

El cliente en este ejemplo se genera usando Gradle usando las librerías OkHttp, Retrofit, Jackson y las anotaciones de Jakarta junto con una configuración para Gradle y el plugin para openapi-generator.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
import java.security.MessageDigest
import java.util.HexFormat
import org.gradle.internal.extensions.stdlib.capitalized

plugins {
    id("java-library")
    id("org.openapi.generator") version "7.12.0"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")

    implementation("com.squareup.retrofit2:retrofit:2.11.0") {
        exclude(group = "org.apache.oltu.oauth2", module = "org.apache.oltu.oauth2.common")
    }
    implementation("org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1")

    implementation("com.squareup.retrofit2:converter-scalars:2.11.0")
    implementation("com.squareup.retrofit2:converter-jackson:2.11.0")
    implementation("jakarta.annotation:jakarta.annotation-api:3.0.0")
    implementation("jakarta.ws.rs:jakarta.ws.rs-api:4.0.0")

    implementation("com.fasterxml.jackson.core:jackson-core:2.18.3")
    implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.3")
    implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.3")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.3")
    implementation("org.openapitools:jackson-databind-nullable:0.2.6")
}

sourceSets {
    main {
        java {
            srcDir("${projectDir}/build/generate/openapi/src/main/java")
        }
    }
}

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.compilerArgs.add("-parameters")
    File("${project.layout.projectDirectory}/src/main/openapi").walk().filter({ it -> it.isFile() }).forEach { it ->
        val name = it.name.removeSuffix(".yaml")
        dependsOn("generate${name.capitalized()}Client")
    }
}

File("${project.layout.projectDirectory}/src/main/openapi").walk().filter({ it -> it.isFile() }).forEach { it ->
    val name = it.name.removeSuffix(".yaml")
    tasks.register<GenerateTask>("generate${name.capitalized()}Client") {
        generatorName.set("java")
        library.set("retrofit2")
        apiNameSuffix.set("Client")
        modelNamePrefix.set("Client")
        inputSpec.set("${project.layout.projectDirectory}/src/main/openapi/${name}.yaml")
        outputDir.set("${project.layout.projectDirectory}/build/generate/openapi")
        apiPackage.set("io.github.picodotdev.${name}.client.api")
        modelPackage.set("io.github.picodotdev.${name}.client.model")
        invokerPackage.set("io.github.picodotdev.${name}.client.invoker")
        configOptions.put("dateLibrary", "java8")
        configOptions.put("useJakartaEe", "true")
        additionalProperties.put("serializationLibrary", "jackson")

        onlyIf {
            val file = File("${project.layout.projectDirectory.asFile}/src/main/openapi/${name}.yaml")
            val hashFile = File("${project.layout.buildDirectory.get().asFile}/tmp/${name}.yaml.hash")
            !hashMatches(file, hashFile)
        }
    }
}

fun hashMatches(file: File, hashFile: File): Boolean {
    val fileContent = file.readText()
    val fileContentHash = if (!hashFile.exists()) {
        null
    } else {
        hashFile.readText()
    }
    val hash = hashString(fileContent)
    hashFile.parentFile.mkdirs()
    hashFile.writeText(hash)
    return hash == fileContentHash
}

fun hashString(data: String): String {
    val md = MessageDigest.getInstance("SHA-256")
    val digest = md.digest(data.toByteArray())
    return HexFormat.of().formatHex(digest)
}
build.gradle.kts

El código generado del cliente queda en la carpeta openapi-generator/client/build/generate/openapi/src junto con las entidades devueltas en la respuesta.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package io.github.picodotdev.catalog.client.api;

import io.github.picodotdev.catalog.client.invoker.CollectionFormats.*;

import retrofit2.Call;
import retrofit2.http.*;

import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import okhttp3.MultipartBody;

import io.github.picodotdev.catalog.client.model.ClientEvent;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public interface CatalogClient {
  /**
   * Get an event
   * Get an event
   * @param id Id of the event (required)
   * @return Call&lt;ClientEvent&gt;
   */
  @GET("events/v1/{id}")
  Call<ClientEvent> getEvent(
    @retrofit2.http.Path("id") Long id
  );

}
CatalogClient.java
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/*
 * Catalog
 * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
 *
 * The version of the OpenAPI document: 1.0.0
 * 
 *
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */


package io.github.picodotdev.catalog.client.model;

import java.util.Objects;
import java.util.Arrays;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.annotation.JsonValue;
import io.github.picodotdev.catalog.client.model.ClientCategory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonTypeName;

/**
 * ClientEvent
 */
@JsonPropertyOrder({
  ClientEvent.JSON_PROPERTY_ID,
  ClientEvent.JSON_PROPERTY_NAME,
  ClientEvent.JSON_PROPERTY_STATUS,
  ClientEvent.JSON_PROPERTY_LOCALE,
  ClientEvent.JSON_PROPERTY_DATE,
  ClientEvent.JSON_PROPERTY_TIMEZONE,
  ClientEvent.JSON_PROPERTY_URI,
  ClientEvent.JSON_PROPERTY_SEO_U_R_I,
  ClientEvent.JSON_PROPERTY_CATEGORIES
})
@JsonTypeName("Event")
@jakarta.annotation.Generated(value = "org.openapitools.codegen.languages.JavaClientCodegen", date = "2025-05-18T12:11:21.115053824+02:00[Europe/Madrid]", comments = "Generator version: 7.12.0")
public class ClientEvent {
  public static final String JSON_PROPERTY_ID = "id";
  @jakarta.annotation.Nullable
  private Long id;

  public static final String JSON_PROPERTY_NAME = "name";
  @jakarta.annotation.Nullable
  private String name;

  public static final String JSON_PROPERTY_STATUS = "status";
  @jakarta.annotation.Nullable
  private String status;

  public static final String JSON_PROPERTY_LOCALE = "locale";
  @jakarta.annotation.Nullable
  private String locale;

  public static final String JSON_PROPERTY_DATE = "date";
  @jakarta.annotation.Nullable
  private String date;

  public static final String JSON_PROPERTY_TIMEZONE = "timezone";
  @jakarta.annotation.Nullable
  private String timezone;

  public static final String JSON_PROPERTY_URI = "uri";
  @jakarta.annotation.Nullable
  private String uri;

  public static final String JSON_PROPERTY_SEO_U_R_I = "seoURI";
  @jakarta.annotation.Nullable
  private String seoURI;

  public static final String JSON_PROPERTY_CATEGORIES = "categories";
  @jakarta.annotation.Nullable
  private List<ClientCategory> categories = new ArrayList<>();

  public ClientEvent() {
  }

  public ClientEvent id(@jakarta.annotation.Nullable Long id) {
    
    this.id = id;
    return this;
  }

  /**
   * Get id
   * @return id
   */
  @jakarta.annotation.Nullable
  @JsonProperty(JSON_PROPERTY_ID)
  @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)

  public Long getId() {
    return id;
  }


  @JsonProperty(JSON_PROPERTY_ID)
  @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)
  public void setId(@jakarta.annotation.Nullable Long id) {
    this.id = id;
  }

  public ClientEvent name(@jakarta.annotation.Nullable String name) {
    
    this.name = name;
    return this;
  }

  /**
   * Get name
   * @return name
   */
  @jakarta.annotation.Nullable
  @JsonProperty(JSON_PROPERTY_NAME)
  @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)

  public String getName() {
    return name;
  }


  @JsonProperty(JSON_PROPERTY_NAME)
  @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)
  public void setName(@jakarta.annotation.Nullable String name) {
    this.name = name;
  }

  public ClientEvent status(@jakarta.annotation.Nullable String status) {
    
    this.status = status;
    return this;
  }

  ...
}

ClientEvent.java

Finalmente, el uso del cliente es el siguiente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package io.github.picodotdev.blogbitix;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Predicate;

import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import retrofit2.Call;
import retrofit2.Response;

import io.github.picodotdev.catalog.client.api.CatalogClient;
import io.github.picodotdev.catalog.client.invoker.ApiClient;
import io.github.picodotdev.catalog.client.invoker.auth.HttpBearerAuth;
import io.github.picodotdev.catalog.client.model.ClientEvent;

public class Main {

    private static final int CALL_TIMEOUT = 15;
    private static final String BEARER_SCHEME = "Bearer";

    public static void main(String[] args) {
        OkHttpClient okhttp = new OkHttpClient.Builder().callTimeout(CALL_TIMEOUT, TimeUnit.SECONDS).build();

        ApiClient apiClient = new ApiClient();
        apiClient.getAdapterBuilder().baseUrl("https://picodotdev.github.io/catalog/");
        apiClient.setApiAuthorizations(Map.of(HttpBearerAuth.class.getSimpleName(), new HttpBearerAuth(BEARER_SCHEME)));
        apiClient.configureFromOkclient(okhttp);
        apiClient.getOkBuilder().addInterceptor(buildClientInterceptor("xxx"));
        CatalogClient catalogClient = apiClient.createService(CatalogClient.class);

        ClientEvent event = Main.execute(catalogClient.getEvent(1L));

        System.out.println(event.getName());
    }

    private static Interceptor buildClientInterceptor(String token) {
        return chain -> {
            Request.Builder builder = chain.request().newBuilder();
            builder.header("Accept", "application/json");
            builder.header("Content-Type", "application/json");
            builder.header("Authorization", "Bearer " + token);
            return chain.proceed(builder.build());
        };
    }

    public static <T> T execute(Call<T> call) {
        Predicate<Response<T>> success = (it) -> it.isSuccessful();
        Consumer<Response<T>> successHandler = (response) -> {
            throw new RuntimeException(String.valueOf(response.code()));
        };
        Consumer<Exception> exceptionHandler = (exception) -> {
            throw new RuntimeException(exception);
        };
        return execute(call, success, successHandler, exceptionHandler);
    }

    public static <T> T execute(Call<T> call, Predicate<Response<T>> success, Consumer<Response<T>> successHandler, Consumer<Exception> exceptionHandler) {
        Response<T> response = executeResponse(call, exceptionHandler);
        if (!success.test(response)) {
            successHandler.accept(response);
        }
        return response.body();
    }

    private static <T> Response<T> executeResponse(Call<T> call, Consumer<Exception> exceptionHandler) {
        try {
            return call.execute();
        } catch (IOException e) {
            exceptionHandler.accept(e);
            return null;
        }
    }
}
Main.java
Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew run


Comparte el artículo: