Ejemplo de GraphQL para una interfaz de un servicio con Spring Boot y Java

Escrito por el , actualizado el .
blog-stack java planeta-codigo programacion spring
Comentarios

GraphQL es una alternativa a una interfaz REST con las ventajas de 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
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
}

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

import com.coxautodev.graphql.tools.GraphQLQueryResolver;

import java.util.Collection;

public class Query implements GraphQLQueryResolver {
    
    private LibraryRepository libraryRepository;

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

    public Collection<Book> books() {
        return libraryRepository.findBooks();
    }

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

    public Author author(Long id) {
        return libraryRepository.findAuthorById(id).orElse(null);
    }
}
 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
package io.github.picodotdev.blogbitix.graphql;

import graphql.schema.DataFetchingEnvironment;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.*;

public class LibraryRepository {

    private long sequence;
    private Collection<Book> books;
    private Collection<Author> authors;

    public LibraryRepository() {
        this.sequence = 0l;
        this.books = new ArrayList<>();
        this.authors = 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));

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

    public Collection<Book> findBooks() {
        return books;
    }

    public Collection<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, AuthContext context) throws PermissionException, ValidationException {
        if (context.getUser() == null || !context.getUser().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());
        books.add(book);
        return book;
    }

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

public class Book {
    
    private Long id;
    private String title;
    private Author author;

    public Book(Long id, String title, Author author) {
        this.id = id;
        this.title = title;
        this.author = author;
    }

    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;
    }
}
 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;

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;
    }
}

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

import com.coxautodev.graphql.tools.GraphQLMutationResolver;
import com.coxautodev.graphql.tools.GraphQLQueryResolver;
import graphql.schema.DataFetchingEnvironment;

import java.util.Collection;

public class Mutation implements GraphQLMutationResolver {

    private LibraryRepository libraryRepository;

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

    public Book addBook(String title, Long author, DataFetchingEnvironment env) throws Exception {
        return libraryRepository.addBook(title, author, env.<AuthContext>getContext());
    }
}

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

import com.coxautodev.graphql.tools.SchemaParser;
import graphql.ErrorType;
import graphql.ExceptionWhileDataFetching;
import graphql.GraphQLError;
import graphql.schema.GraphQLSchema;
import graphql.servlet.*;
import org.apache.commons.io.IOUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@SpringBootApplication
@ServletComponentScan
public class Main {

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

    @Bean
    public ServletRegistrationBean graphQLServletRegistrationBean(LibraryRepository libraryRepository) throws Exception {
        GraphQLSchema schema = SchemaParser.newParser()
                .schemaString(IOUtils.resourceToString("/library.graphqls", Charset.forName("UTF-8")))
                .resolvers(new Query(libraryRepository), new Mutation(libraryRepository))
                .build()
                .makeExecutableSchema();

        GraphQLContextBuilder contextBuilder = new GraphQLContextBuilder() {
            @Override
            public GraphQLContext build(Optional<HttpServletRequest> request, Optional<HttpServletResponse> response) {
                String user = request.get().getHeader("User");
                return new AuthContext(user, request, response);
            }
        };

        return new ServletRegistrationBean(new SimpleGraphQLServlet(schema, new DefaultExecutionStrategyProvider(), null, null, null, null, contextBuilder, null), "/library");
    }

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

import graphql.servlet.GraphQLContext;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;

public class AuthContext extends GraphQLContext {

    private String user;
    private Optional<HttpServletRequest> request;
    private Optional<HttpServletResponse> response;

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

    public String getUser() {
        return user;
    }

    public void setUser(String user) {
        this.user = user;
    }

    public Optional<HttpServletRequest> getRequest() {
        return request;
    }

    public void setRequest(Optional<HttpServletRequest> request) {
        this.request = request;
    }

    public Optional<HttpServletResponse> getResponse() {
        return response;
    }

    public void setResponse(Optional<HttpServletResponse> response) {
        this.response = response;
    }
}

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 "http://localhost:8080/library?query=\{books\{title\}\}"
{
  "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 "http://localhost:8080/library?query=\{authors\{name\}\}"
{
  "data": {
    "authors": [
      {
        "name": "Philip K. Dick"
      },
      {
        "name": "George R. R. Martin"
      },
      {
        "name": "Umberto Eco"
      },
      {
        "name": "Andreas Eschbach"
      },
      {
        "name": "Ernest Cline"
      },
      {
        "name": "Anónimo"
      }
    ]
  }
}

$ curl "http://localhost:8080/library?query=\{books\{title+author\{name\}\}\}"
{
  "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"
        }
      }
    ]
  }
}

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 "http://localhost:8080/library?query=\{books\{title\}authors\{name\}author(id:1)\{name\}\}"
{
  "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"
    }
  }
}

Las consultas puede hacerse mediante una petición GET o POST de HTTP.

1
2
3
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books{title}}"}' http://localhost:8080/library
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Authors{authors{name}}"}' http://localhost:8080/library
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query BooksWithAuthors{books{title author{name}}}"}' http://localhost:8080/library

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, habría que hacer el tratamiento de errores adecuado para que los mensajes fuesen más descriptivos.

 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
$ 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/library
{
  "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": 99}}' http://localhost:8080/library
{
  "data": {
    "addBook": null
  },
  "errors": [
    {
      "message": "Internal Server Error(s) while executing query",
      "extensions": null,
      "path": null
    }
  ]
}

io.github.picodotdev.blogbitix.graphql.ValidationException: Invalid author
    at io.github.picodotdev.blogbitix.graphql.LibraryRepository.addBook(LibraryRepository.java:57) ~[main/:na]
    at io.github.picodotdev.blogbitix.graphql.Mutation.addBook(Mutation.java:18) ~[main/:na]
    at io.github.picodotdev.blogbitix.graphql.MutationMethodAccess.invoke(Unknown Source) ~[reflectasm-1.11.3.jar:na]
    at com.coxautodev.graphql.tools.MethodFieldResolverDataFetcher.get(MethodFieldResolver.kt:111) ~[graphql-java-tools-4.1.2.jar:na]
  ...

$ 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/library
{
  "data": {
    "addBook": null
  },
  "errors": [
    {
      "message": "Internal Server Error(s) while executing query",
      "extensions": null,
      "path": null
    }
  ]
}

io.github.picodotdev.blogbitix.graphql.PermissionException: Invalid permissions
    at io.github.picodotdev.blogbitix.graphql.LibraryRepository.addBook(LibraryRepository.java:53) ~[main/:na]
    at io.github.picodotdev.blogbitix.graphql.Mutation.addBook(Mutation.java:18) ~[main/:na]
    at io.github.picodotdev.blogbitix.graphql.MutationMethodAccess.invoke(Unknown Source) ~[reflectasm-1.11.3.jar:na]
    at com.coxautodev.graphql.tools.MethodFieldResolverDataFetcher.get(MethodFieldResolver.kt:111) ~[graphql-java-tools-4.1.2.jar:na]
  ...
1
2
3
4
5
6
7
8
package io.github.picodotdev.blogbitix.graphql;

public class PermissionException extends Exception {

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

public class ValidationException extends Exception {

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

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
 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
buildscript {
    ext {
        springBootVersion = '1.5.9.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'idea'
apply plugin: 'java'
apply plugin: 'application'
apply plugin: 'org.springframework.boot'

repositories {
    jcenter()
    mavenCentral()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter')
    compile "org.springframework.boot:spring-boot-starter-web"
    compile "com.graphql-java:graphql-java:8.0"
    compile "com.graphql-java:graphql-java-servlet:5.0.0"
    compile "com.graphql-java:graphql-java-tools:5.1.0"
    compile 'org.reactivestreams:reactive-streams:1.0.0'
    compile "org.apache.commons:commons-lang3:3.7"
    compile "commons-io:commons-io:2.6"
}

mainClassName = 'io.github.picodotdev.blogbitix.graphql.Main'

run {
    systemProperty "tomcat.util.http.parser.HttpParser.requestTargetAllow", "|{}"
}

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 el comando ./gradlew run. Requiere Java 9+ o Docker.