Documentación de APIs con Spring REST Docs

5 de marzo de 2018

Contexto

El día a día de la mayoría de desarrolladores backend está protagonizado por el uso y consumo de APIs. A medida que avanzamos hacia nuevas tecnologías, patrones o arquitecturas, se hace más necesario aprender a crear APIs que faciliten su uso y comprensión por parte de terceros.

Este hecho se hace mucho más evidente al trabajar en arquitecturas orientadas a microservicios, donde conviven un gran número de APIs, tanto para comunicación interna como para consumo por parte de otras aplicaciones. Generalmente, estas APIs son desarrolladas por equipos distintos, por lo que disponer de una documentación apropiada y actualizada es sumamente importante.

Existen diferentes formas de documentar APIs: desde el vintage Word de turno en el que nunca sabes a ciencia cierta si estará todo actualizado, hasta herramientas basadas en la especificación OpenAPI, como por ejemplo Swagger, con la que se puede generar documentación atractiva de una manera muy sencilla en paralelo al desarrollo de las APIs, por ejemplo, mediante anotaciones. Esto previene que tengamos una API diferente a la que esté definida en la documentación, pero hace que nuestro código tenga mucha más información adicional no relacionada con la funcionalidad que desempeña.

Afortunadamente, para aquellos que utilizamos Spring como framework para desarrollar aplicaciones, encontramos Spring REST Docs, que facilita e integra de manera sencilla la elaboración y, lo más importante, el mantenimiento de la documentación de las APIs.

Spring REST Docs garantiza que la documentación de una API esté siempre actualizada, siempre y cuando existan tests unitarios que validen los controllers del proyecto, ya que la parte importante de la documentación se genera a partir de ellos.

Wow! Parece que es la solución a nuestros problemas, ¿no? A continuación, mostraremos un ejemplo de cómo Spring genera esta documentación.

¡Manos a la obra!

Generación de snippets

En primer lugar, es necesario configurar tanto las dependencias como los plugins necesarios para que Spring REST Docs funcione en nuestro proyecto. En la documentación del proyecto) tenéis las instrucciones detalladas para configurarlo tanto con Maven como con Gradle. Los tests unitarios se pueden implementar con Spring MVC Test, WebTestClient de Spring Webflux o REST Assured 3. En este ejemplo se ha utilizado Gradle y Spring MVC Test.

Una vez configurado, partiremos de una API existente que define el siguiente método para crear Customers:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Customer create(@RequestBody @Valid Customer customer) {
    Assert.isNull(customer.getId(), "Id must be null to create a new customer");
    return this.customersRepository.save(customer);
}

Ahora pasamos a los tests unitarios. En primer lugar, tenemos que configurar el objeto MockMvc para que automáticamente genere los snippets por defecto de cada uno de nuestros tests:

@Before
public void setUp() {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
            // …
            .alwaysDo(document("{method-name}",
                    preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())))
            .build();
}

A continuación, se muestra un test unitario correspondiente al método create:

@Test
public void createCustomer() throws Exception {

    Customer customer = new Customer();
    customer.setAge(23);
    customer.setFirstName("Liam");
    customer.setGender(Gender.MALE);
    customer.setPhoneNumber("1119992");

    Customer savedCustomer = new Customer(customer);
    savedCustomer.setId(34L);

    when(this.customersRepository.save(any(Customer.class))).thenReturn(savedCustomer);

    this.mockMvc.perform(post("/customers").content(this.objectMapper.writeValueAsString(customer))
            .contentType(MediaType.APPLICATION\_JSON\_UTF8))
            .andExpect(status().isCreated())
    .andExpect(jsonPath("$.firstName").value("Liam"))
    .andExpect(jsonPath("$.lastName").isEmpty())
    .andExpect(jsonPath("$.gender").value(Gender.MALE.name()))
    .andExpect(jsonPath("$.phoneNumber").value("1119992"))
    .andExpect(jsonPath("$.age").value(23));

}

Si ejecutamos el test, comprobaremos que dentro de la carpeta build se ha generado una carpeta generated-snippets, que a su vez contiene una carpeta create-customer con una serie de ficheros asciidoc generados por defecto:

Snippets por defecto

Snippets generados por defecto

Cada uno de esos ficheros contiene la documentación del método create de nuestra Customers API, generada a partir de la información proporcionada en el test unitario.

Podemos mejorar la documentación generada añadiendo una nueva acción andDo(document(…)) y añadir detalles tanto de la petición de entrada como de la respuesta mediante los métodos requestFields y responseFields, respectivamente:

@Test
public void createCustomer() throws Exception {

    Customer customer = new Customer();
    customer.setAge(23);
    customer.setFirstName("Liam");
    customer.setGender(Gender.MALE);
    customer.setPhoneNumber("1119992");

    Customer savedCustomer = new Customer(customer);
    savedCustomer.setId(34L);
    
    when(this.customersRepository.save(any(Customer.class))).thenReturn(savedCustomer);

    FieldDescriptor[] customerDescriptor = getCustomerFieldDescriptor();

    this.mockMvc.perform(post("/customers", 330).content(this.objectMapper.writeValueAsString(customer))
            .contentType(MediaType.APPLICATION\_JSON\_UTF8))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.firstName").value("Liam"))
            .andExpect(jsonPath("$.lastName").isEmpty())
            .andExpect(jsonPath("$.gender").value(Gender.MALE.name()))
            .andExpect(jsonPath("$.phoneNumber").value("1119992"))
            .andExpect(jsonPath("$.age").value(23))
    .andDo(document("create-customer",
            requestFields(customerDescriptor),
            responseFields(customerDescriptor)));

}

Los métodos requestFields y responseFields permiten documentar los campos de la petición y la respuesta utilizando el método fieldWithPath, donde podemos indicar una descripción, el tipo de dato, si es opcional o no, etc. En este caso se envía y se devuelve el mismo objeto, por lo que se utiliza un FieldDescriptor para no repetir código:

private FieldDescriptor[] getCustomerFieldDescriptor() {
    return new FieldDescriptor[]{fieldWithPath("age").description("The age of the customer").type(Integer.class.getSimpleName()),
                fieldWithPath("firstName").description("The first name of the customer").type(String.class.getSimpleName()),
                fieldWithPath("gender").description("The gender of the customer (FEMALE or MALE)").type(Gender.class.getSimpleName()),
                fieldWithPath("phoneNumber").description("The cell phone number of the customer").type(String.class.getSimpleName()),
                fieldWithPath("id").description("The unique id of the customer").optional().type(Long.class.getSimpleName()),
                fieldWithPath("lastName").description("The last name of the customer").optional().type(String.class.getSimpleName())};
}

Con esto, al ejecutar el test se generan dos nuevos ficheros: request-fields.adoc y response-fields.adoc, que contienen la descripción de los body de entrada y salida, respectivamente.

Generación de documentación

Todo esto parece fácil, pero, ¿qué hago con estos snippets? Una vez generados, podemos crearnos una o múltiples páginas en formato asciidoc que incluyan dichos snippets. El plugin asciidoc se encargará de generar contenido en formato HTML a partir de éstas.

Vamos a crear una página index.adoc en src/docs/asciidoc donde aparezca una breve descripción de la API y algunos de los snippets generados en el paso anterior. El fichero index.adoc resultante sería:

= Getting started with Spring REST Docs
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toc-title: Index
:toclevels: 4
:sectlinks:

This is the documentation of the resource Customers API.

=== POST /customers

.Request sample with curl
include::{snippets}/create-customer/curl-request.adoc[]
.HTTP response
include::{snippets}/create-customer/http-response.adoc[]
.Response sample
include::{snippets}/create-customer/response-body.adoc[]
.Input fields
include::{snippets}/create-customer/request-fields.adoc[]
.Output fields
include::{snippets}/create-customer/response-fields.adoc[]

Las primeras líneas que comienzan con : se encargan de configurar la apariencia de la documentación (resaltado de texto, configuración de la tabla de contenido…). Podéis encontrar más información sobre la sintaxis en la documentación de asciidoc.

Al construir la aplicación, en nuestro caso con gradle build, se generará una carpeta asciidoc/html5 en la carpeta build, con un fichero index.html. Si abrimos este fichero en el navegador, comprobaremos que nos muestra algo como:

Documentación generada

Ejemplo de documentación generada

Mola, ¿no? Ahora vamos a cambiar la API, vamos a modificar el método create, añadiéndole una PathVariable de carácter obligatorio:

@PostMapping("/{salesRepId}")
@ResponseStatus(HttpStatus.CREATED)
public Customer create(@RequestBody @Valid Customer customer, @PathVariable Long salesRepId) {
    Assert.isNull(customer.getId(), "Id must be null to create a new customer");
    validate(salesRepId, customer);
    return this.customersRepository.save(customer);
}

Como era de esperar, el test existente ahora falla. Debemos adaptarlo para que cubra los cambios que se han realizado sobre el método:

@Test
public void createCustomer() throws Exception {

    Customer customer = new Customer();
    customer.setAge(23);
    customer.setFirstName("Liam");
    customer.setGender(Gender.MALE);
    customer.setPhoneNumber("1119992");

    Customer savedCustomer = new Customer(customer);
    savedCustomer.setId(34L);

    when(this.customersRepository.save(any(Customer.class))).thenReturn(savedCustomer);
    FieldDescriptor[] customerDescriptor = getCustomerFieldDescriptor();

    this.mockMvc.perform(RestDocumentationRequestBuilders.post("/customers/{salesRepId}", 330).content(this.objectMapper.writeValueAsString(customer))
            .contentType(MediaType.APPLICATION\_JSON\_UTF8))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.firstName").value("Liam"))
            .andExpect(jsonPath("$.lastName").isEmpty())
            .andExpect(jsonPath("$.gender").value(Gender.MALE.name()))
            .andExpect(jsonPath("$.phoneNumber").value("1119992"))
            .andExpect(jsonPath("$.age").value(23))
    .andDo(document("create-customer",
            requestFields(customerDescriptor),
            responseFields(customerDescriptor)));

}

Los cambios que hemos realizado son añadir el salesRepId a la URL que se ejecuta en el test y utilizar el método post de RestDocumentationRequestBuilders para poder documentar la path variable. Ahora que el test vuelve a ejecutarse con éxito, podemos comprobar que los snippets se han actualizado con la URL nueva. Sin embargo, nos falta el snippet relativo al Path Variable que hemos añadido, por lo que haría falta completar el test con lo siguiente:

@Test
public void createCustomer() throws Exception {

    //…
    .andDo(document("create-customer",
            requestFields(customerDescriptor),
            pathParameters(
                    parameterWithName("salesRepId").description("Unique identifier of the Sales Rep responsible of the customer")
            ),
            responseFields(customerDescriptor)));

}

Ahora, cuando ejecutemos este test, el snippet path-parameters.adoc se generará y podrá ser incluido como parte de la documentación.

Spring REST Docs permite generar otros snippets en función de lo que contenga cada endpoint. Podéis comprobar todas las posibilidades que ofrece en esta sección de la documentación oficial.

Conclusión

Spring REST Docs facilita la generación y el mantenimiento de la documentación de las APIs en proyectos implementados con Spring, garantizando que, mientras que la API tenga una buena cobertura de tests unitarios, la documentación siempre estará actualizada. Así que, si estás trabajando en un proyecto con Spring, ¿a qué estás esperando para incluirlo?

Puedes ver el código completo del proyecto de ejemplo en: https://github.com/mimacom/spring-rest-docs.

Sobre el autor: Sandra Gomez
Comments
Únete a nosotros