Qué es GraphQL y ejemplo para una interfaz de un servicio con Spring Boot y Java

Escrito por el , actualizado el .
java planeta-codigo programacion spring
Enlace permanente Comentarios

GraphQL es una forma alternativa a una interfaz REST para crear una interfaz de un servicio basado en la comunicación utilizando los protocolos web HTTP y JSON como formato de intercambio de datos. Tiene varias ventajas sobre REST al poder realizar varias consultas en una misma petición y devolviendo únicamente los datos que requiera el cliente. Es una especificación y hay una implementación para los lenguajes de programación más populares entre ellos Java. Este artículo es una introducción con un ejemplo completo que muestra cómo se hacen consultas y modificaciones en los datos.

GraphQL

Con anterioridad las aplicaciones que lo necesitaban ofrecían una interfaz como un servicio mediante web services, sin embargo, esta tecnología era complicada por usar XML y no de fácil utilización en clientes JavaScript. La evolución que a día de hoy sigue siendo mayoritaria son las interfaces REST que emplean la semántica de los verbos del protocolo HTTP para realizar operaciones de búsqueda, creación, modificación y eliminación y normalmente empleando JSON como formato para intercambiar los datos. Sin embargo, REST no está exento de algunos problemas como la necesidad de realizar varias peticiones a cada uno de los recursos que ofrece si se necesitan datos de varios de ellos, otro es que los datos ofrecidos por los servicios REST está prefijados en tiempo de desarrollo no adaptándose a lo que necesita el cliente. En cierta medida estas dos cosas se pueden implementar en la interfaz REST con algunos parámetros pero requiere codificarlo explícitamente.

Más recientemente ha aparecido otra forma de implementar una interfaz de un servicio con GraphQL considerándose una alternativa mejor a REST que solventa los dos problemas de las interfaces REST anteriores. REST ofrece en varios endpoints los recursos que pueden ser accedidos mediante los verbos HTTP (GET, PUT, POST, DELETE), en GraphQL por el contrario hay un único endpoint, los puntos de entrada al grafo y los tipos que se relacionan entre si que son consultados para obtener los datos con el lenguaje de consulta que ofrece GraphQL.

En GraphQL se define un esquema con la definición de los tipos en la API, se diferencia la obtención de los datos que es realizada por las queries y de las modificaciones que es realizada por los mutators, el esquema se puede definir en un archivo de texto como en este ejemplo o de forma programática con código que es necesario para algunas personalizaciones. Otras tareas que pueden ser necesarias en una API son autenticación que es posible capturando los datos del contexto provenientes en los datos o como cabeceras de la petición posiblemente en forma de token de OAuth y la autorización en la lógica del servicio en base al sujeto autenticado. Se puede usar datos propios con scalar para los cuales se ha de proporcionar una clase que realice la transformación implementando una clase GraphQLScalarType. Posee funcionalidades de introspección y también filtrado, paginación, gestión de errores y cacheo aunque esto último es menos efectivo en GraphQL al depender de los datos a devolver que solicite el cliente.

Para usar GraphQL hay que definir un schema que incluye los tipos, sus propiedades y tipos. También se pueden usar fragmentos para reutilizar partes de la definición de los tipos. Cada type representa una entidad que definen las propiedades que posee ya sean datos escalares o referencias a otras entidades formando de esta manera grafos de objetos, los tipos de las variables que poseen una exclamación al final quiere decir que son opcionales, por defecto todos los datos son distinto de nulo. Las listas se definen con corchetes y el tipo entre ellos. Este es el IDL del esquema del ejemplo.

 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
scalar Long

type Book {
    id: Long
    title: String
    author: Author
}

type Author {
    id: Long
    name: String
}

type Query {
    books: [Book]!
    authors: [Author]!
    author(id: Long): Author!
}

type Mutation {
    addBook(title: String, author: Long): Book
}

schema {
    query: Query
    mutation: Mutation
}
library.graphqls

Una definido el esquema hay que desarrollar los resolvers que son encargados de obtener los datos seguramente de una base de datos externa ya sea una base de datos SQL o NoSQL en este caso utilizando una clase que implementa el patrón repositorio y que abstrae del sistema de persistencia donde se almacenan los datos.

 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
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class Query implements GraphQLQueryResolver {

    private LibraryRepository libraryRepository;

    public Query(LibraryRepository libraryRepository) {
        this.libraryRepository = libraryRepository;
    }

    public List<Book> books(BookFilter filter, DataFetchingEnvironment environment) throws InterruptedException  {
        List<Book> books = libraryRepository.findBooks(filter);
        GraphQLContext context = environment.getGraphQlContext();

        ...

        return books;
    }

    public List<Publication> publications() {
        return libraryRepository.findPublications();
    }

    public Book book(Long id) {
        return libraryRepository.findBookById(id).orElse(null);
    }

    public List<Author> authors() {
        return libraryRepository.getAuthors();
    }

    public Author author(Long id) {
        return libraryRepository.findAuthorById(id).orElse(null);
    }

    private SelectedField getField(DataFetchingEnvironment environment, String name) {
        return environment.getSelectionSet().getFields().stream().filter(it -> it.getName().equals(name)).findFirst().orElse(null);
    }
}
Query.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
package io.github.picodotdev.blogbitix.graphql.repository;

import io.github.picodotdev.blogbitix.graphql.misc.PermissionException;
import io.github.picodotdev.blogbitix.graphql.misc.ValidationException;
import io.github.picodotdev.blogbitix.graphql.type.Author;
import io.github.picodotdev.blogbitix.graphql.type.Book;
import io.github.picodotdev.blogbitix.graphql.type.BookFilter;
import io.github.picodotdev.blogbitix.graphql.type.Comment;
import io.github.picodotdev.blogbitix.graphql.type.Magazine;
import io.github.picodotdev.blogbitix.graphql.type.Publication;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;

public class LibraryRepository {

    private long sequence;
    private List<Book> books;
    private List<Comment> comments;
    private List<Author> authors;
    private List<Magazine> magazines;

    public LibraryRepository() {
        this.sequence = 0l;
        this.books = new ArrayList<>();
        this.comments = new ArrayList<>();
        this.authors = new ArrayList<>();
        this.magazines = new ArrayList<>();

        Author a1 = new Author(nextId(), "Philip K. Dick");
        Author a2 = new Author(nextId(), "George R. R. Martin");
        Author a3 = new Author(nextId(), "Umberto Eco");
        Author a4 = new Author(nextId(), "Andreas Eschbach");
        Author a5 = new Author(nextId(), "Ernest Cline");
        Author a6 = new Author(nextId(), "Anónimo");

        this.authors.addAll(List.of(a1, a2, a3, a4, a5, a6));

        LongStream.range(1, 10).forEach(i -> this.comments.add(new Comment(i,"Comment " + i)));

        this.books.addAll(
            List.of(
                new Book(nextId(), "Ojo en el cielo", a1, LocalDate.of(1957, 1, 1), this.comments),
                new Book(nextId(), "Muerte de la luz", a2, LocalDate.of(1977, 1, 1), this.comments),
                new Book(nextId(), "El nombre de la rosa", a3, LocalDate.of(1980, 1, 1), this.comments),
                new Book(nextId(), "Los tejedores de cabellos", a4, LocalDate.of(1995, 1, 1), this.comments),
                new Book(nextId(), "Ready Player One", a5, LocalDate.of(2011, 1, 1), this.comments)
            )
        );

        this.magazines.addAll(
            List.of(
                new Magazine(nextId(), "Muy interesante", 65L),
                new Magazine(nextId(), "PC Actual", 90L)
            )
        );
    }

    public Book findBook(Long id) {
        return books.stream().filter(b -> b.getId().equals(id)).findFirst().orElse(null);
    }

    public List<Book> findBooks(BookFilter filter) {
        Stream<Book> stream = books.stream();
        if (filter != null) {
            stream = stream.filter(b -> b.getTitle().matches(filter.getTitle()));
        }
        return stream.collect(Collectors.toList());
    }

    public List<Publication> findPublications() {
        List<Publication> publications = new ArrayList<>();
        publications.addAll(books);
        publications.addAll(magazines);
        return publications;
    }

    public Optional<Book> findBookById(Long id) {
        return books.stream().filter(b -> b.getId().equals(id)).findFirst();
    }

    public List<Comment> findComments(Long idBook, Long idAfter, Long limit) {
        Book book = findBook(idBook);
        Stream<Comment> stream = book.getComments().stream();
        if (idAfter != null) {
            stream = stream.dropWhile(b -> idAfter != null && !b.getId().equals(idAfter)).skip(1);
        }
        if (limit != null) {
            stream = stream.limit(limit);
        }
        return stream.collect(Collectors.toList());
    }

    public List<Author> getAuthors() {
        return authors;
    }

    public Optional<Author> findAuthorById(Long id) {
        return authors.stream().filter(a -> a.getId().equals(id)).findFirst();
    }

    public Book addBook(String title, Long idAuthor, String user) throws PermissionException, ValidationException {
        if (user == null || !user.equals("admin")) {
            throw new PermissionException("Invalid permissions");
        }
        Optional<Author> author = findAuthorById(idAuthor);
        if (!author.isPresent()) {
            throw new ValidationException("Invalid author");
        }

        Book book = new Book(nextId(), title, author.get(), LocalDate.now(), Collections.EMPTY_LIST);
        books.add(book);
        return book;
    }

    private long nextId() {
        return ++sequence;
    }
}
LibraryRepository.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
package io.github.picodotdev.blogbitix.graphql.type;

import java.time.LocalDate;
import java.util.List;

public class Book extends Publication {
    
    private Long id;
    private String title;
    private Author author;
    private LocalDate date;
    private List<Comment> comments;

    public Book(Long id, String title, Author author, LocalDate date, List<Comment> comments) {
        this.id = id;
        this.title = title;
        this.author = author;
        this.date = date;
        this.comments = comments;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public Author getAuthor() {
        return author;
    }

    public void setAuthor(Author author) {
        this.author = author;
    }

    public LocalDate getDate() {
        return date;
    }

    public void setDate(LocalDate date) {
        this.date = date;
    }

    public List<Comment> getComments() {
        return comments;
    }

    public void setComments(List<Comment> comments) {
        this.comments = comments;
    }
}
Book.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
package io.github.picodotdev.blogbitix.graphql.type;

public class Author {

    private Long id;
    private String name;

    public Author(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
Author.java

Los mutators son los encargados de procesar las peticiones de modificación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class Mutation implements GraphQLMutationResolver {

    private LibraryRepository libraryRepository;

    public Mutation(LibraryRepository libraryRepository) {
        this.libraryRepository = libraryRepository;
    }

    public Book addBook(String title, Long author, DataFetchingEnvironment env) {
        try {
            DefaultGraphqlContext context = (DefaultGraphqlContext) env.getContext();
            return libraryRepository.addBook(title, author, context.getHttpServletRequest().getHeader("User"));
        } catch (Exception e) {
            throw Exceptions.toRuntimeException(e);
        }
    }
}
Mutation.java

Usando una aplicación de Spring Boot para ofrecer el servicio hay que realizar la contribución adecuada al contenedor de dependencias, en Java GraphQL se define como un servlet al cual hay que proporcionarle la configuración de los resolvers, mutators, procesador de contexto que en este caso se utiliza para la autenticación y definición del esquema entre otras posibles cosas.

 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
package io.github.picodotdev.blogbitix.graphql;

...

@SpringBootApplication
public class Main {

    public static final Logger logger = LoggerFactory.getLogger(Main.class);

    @Bean
    public LibraryRepository buildLibraryRepository() {
        return new LibraryRepository();
    }

    @Bean
    public GraphQLSchema graphQLSchema(LibraryRepository libraryRepository) throws IOException {
        return SchemaParser.newParser()
                .schemaString(IOUtils.resourceToString("/library.graphqls", Charset.forName("UTF-8")))
                .resolvers(new Query(libraryRepository), new Mutation(libraryRepository), new BookResolver(libraryRepository), new MagazineResolver(libraryRepository))
                .scalars(GraphQLScalarType.newScalar().name("Long").description("Long scalar").coercing(new LongCoercing()).build(), GraphQLScalarType.newScalar().name("LocalDate").description("LocalDate scalar").coercing(new LocalDateCoercing()).build())
                .dictionary(Magazine.class)
                .build()
                .makeExecutableSchema();
    }

    ...

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}
Main.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
package io.github.picodotdev.blogbitix.graphql.misc;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class AuthContext {

    private String user;
    private HttpServletRequest request;
    private HttpServletResponse response;

    public AuthContext(String user, HttpServletRequest request, HttpServletResponse response) {
        this.user = user;
        this.request = request;
        this.response = response;
    }

    public String getUser() {
        return user;
    }

    public HttpServletRequest getRequest() {
        return request;
    }

    public HttpServletResponse getResponse() {
        return response;
    }
}
AuthContext.java

El lenguaje de consulta GraphQL permite consultar el grafo de objetos y recuperar los datos deseados. En el siguiente ejemplo se obtienen los libros, los autores y los libros con los datos de sus autores de una clase que implementa el patrón repository. En el ejemplo los datos del repositorio están definidos en la propia clase de forma estática pero como su función es abstraer de donde se obtienen los datos el cambio sería sencillo para que los obtuviese de una base de datos SQL o NoSQL ya que los cambios estarían encapsulados principalmente en esa clase. Los datos son devueltos en formato JSON.

 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
$ curl -XPOST -H 'Content-Type: application/json' -d '{"query":"query{books{title}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ojo en el cielo"
      },
      {
        "title": "Muerte de la luz"
      },
      {
        "title": "El nombre de la rosa"
      },
      {
        "title": "Los tejedores de cabellos"
      },
      {
        "title": "Ready Player One"
      }
    ]
  }
}

$ curl -XPOST -H 'Content-Type: application/json' -d '{"query":"query{authors{name}}"}' http://localhost:8080/graphql
{
  "data": {
    "authors": [
      {
        "name": "Philip K. Dick"
      },
      {
        "name": "George R. R. Martin"
      },
      {
        "name": "Umberto Eco"
      },
      {
        "name": "Andreas Eschbach"
      },
      {
        "name": "Ernest Cline"
      },
      {
        "name": "Anónimo"
      }
    ]
  }
}

$ curl -XPOST -H 'Content-Type: application/json' -d '{"query":"query{books{title author{name}}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ojo en el cielo",
        "author": {
          "name": "Philip K. Dick"
        }
      },
      {
        "title": "Muerte de la luz",
        "author": {
          "name": "George R. R. Martin"
        }
      },
      {
        "title": "El nombre de la rosa",
        "author": {
          "name": "Umberto Eco"
        }
      },
      {
        "title": "Los tejedores de cabellos",
        "author": {
          "name": "Andreas Eschbach"
        }
      },
      {
        "title": "Ready Player One",
        "author": {
          "name": "Ernest Cline"
        }
      }
    ]
  }
}
curl-1.sh

Una de las ventajas de GraphQL sobre REST es que es posible realizar una única petición lo que en REST podrían ser varias. Por ejemplo, la siguiente consulta obtiene en una única consulta todos los libros, todos los autores y el autor con identificativo 1 de la biblioteca, esto mejora el rendimiento ya que en REST se hubiesen requerido varias peticiones una para obtener libros, otra para los autores y otra para el autor 1. La otra ventaja sobre REST es que se devuelven únicamente los datos que el cliente solicita y no una lista prefijada por el desarrollador de la interfaz.

 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
$ curl -XPOST -H 'Content-Type: application/json' -d '{"query":"query{books{title} authors{name} author(id:1){name}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "title": "Ojo en el cielo"
      },
      {
        "title": "Muerte de la luz"
      },
      {
        "title": "El nombre de la rosa"
      },
      {
        "title": "Los tejedores de cabellos"
      },
      {
        "title": "Ready Player One"
      }
    ],
    "authors": [
      {
        "name": "Philip K. Dick"
      },
      {
        "name": "George R. R. Martin"
      },
      {
        "name": "Umberto Eco"
      },
      {
        "name": "Andreas Eschbach"
      },
      {
        "name": "Ernest Cline"
      },
      {
        "name": "Anónimo"
      }
    ],
    "author": {
      "name": "Philip K. Dick"
    }
  }
}
curl-2.sh

Las peticiones de modificación se envían mediante POST. Este es el caso para añadir un libro a la biblioteca y los casos de que el autor del libro no sea válido o que el usuario que añade el libro no tenga permisos. En el ejemplo los errores no son descriptivos de lo que realmente ha sucedido lo que no es malo para no dar pistas de funcionamiento interno si son errores internos del servidor como un fallo de conexión a la base de datos, en el caso de validaciones hay que devolver mensajes de error descriptivos para dar al usuario de la API suficientemente información de modo que sepa por qué está fallando la petición y si es posible corrija la consulta.

 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
$ curl -XPOST -H "Content-Type: application/json" -H "User: admin" -d '{"query": "mutation addBook($title: String, $author: Long){addBook(title: $title, author: $author){title}}", "variables": { "title": "El lazarillo de Tormes", "author": "6"}}' http://localhost:8080/graphql
{
  "data": {
    "addBook": {
      "title": "El lazarillo de Tormes"
    }
  }
}

$ curl -XPOST -H "Content-Type: application/json" -H "User: admin" -d '{"query": "mutation addBook($title: String, $author: Long){addBook(title: $title, author: $author){title}}", "variables": { "title": "El lazarillo de Tormes", "author": "999"}}' http://localhost:8080/graphql
{
  "data": {
    "addBook": null
  },
  "errors": [
    {
      "message": "Internal Server Error(s) while executing query",
      "extensions": null,
      "path": null
    }
  ]
}

$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "mutation addBook($title: String, $author: Long){addBook(title: $title, author: $author){title}}", "variables": { "title": "El lazarillo de Tormes", "author": "6"}}' http://localhost:8080/graphql
{
  "data": {
    "addBook": null
  },
  "errors": [
    {
      "message": "Internal Server Error(s) while executing query",
      "extensions": null,
      "path": null
    }
  ]
}
curl-3.sh
1
2
3
4
5
6
7
8
package io.github.picodotdev.blogbitix.graphql.misc;

public class PermissionException extends Exception {

    public PermissionException(String message) {
        super(message);
    }
}
PermissionException.java
1
2
3
4
5
6
7
8
package io.github.picodotdev.blogbitix.graphql.misc;

public class ValidationException extends Exception {

    public ValidationException(String message) {
        super(message);
    }
}
ValidationException.java

La forma explicada en las guías de GraphQL para Java es que el mutator reciba los datos y este delegue la funcionalidad en una clase que implemente el patrón repository que abstrae del sistema de almacenamiento (base de datos SQL, NoSQL o cualquier otro), además, este patrón repository o clase de lógica de negocio se recomienda que implemente la funcionalidad necesaria para aplicar la autorización. En el ejemplo aunque de forma sencilla solo en usuario admin tiene permitido añadir libros, en un proyecto es posible realizar la autenticación usando Keycloak como sistema de OAuth, usar el token de OAuth para implementar la autorización y un framework de seguridad como Apache Shiro para aplicar los permisos a las funcionalidades.

Los artículos Autenticación con OAuth y Keycloak en un servicio REST con JAX-RS y Spring Boot y Integrar autenticación OAuth con Keycloak, Shiro, Apache Tapestry y Spring Boot pueden servir como base para añadir autenticación OAuth a un servicio GraphQL con Keycloak.

Finalmente, el archivo de construcción de Gradle del ejemplo con las dependencias necesarias. graphql-java contiene la implementación para Java de GraphQL, graphql-java-servlet la implementación de GraphQL mediante un servlet y graphql-java-tools son varias utilidades que facilitan en gran medida el desarrollo de un servicio para GraphQL como la construcción del esquema mediante su definición IDL o crear resolvers fácilmente.

1
2
3
4
5
6
graphql:
  servlet:
    mapping: /graphql
    enabled: true
    corsEnabled: true
    exception-handlers-enabled: true
application.yml
 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
plugins {
    id 'java'
    id 'application'
}

application {
    mainClassName = 'io.github.picodotdev.blogbitix.graphql.Main'
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    implementation platform("org.springframework.boot:spring-boot-dependencies:2.6.1")

    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:12.0.0")
    implementation("org.apache.commons:commons-lang3:3.12.0")
    implementation("commons-io:commons-io:2.11.0")
}
build.gradle
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: