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.
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<ClientEvent>
*/
@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
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