Devolver mensajes de error descriptivos en GraphQL

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

Por defecto GraphQL devuelve errores con mensajes descriptivos para los errores del cliente como son los errores de sintaxis en la sentencia de consulta o mutación, en el caso de que el campo solicitado no exista o no se ha indicado ninguno. En el caso de Java si se lanza una excepción en la clase repositorio que guarda los datos o en la lógica de negocio y no se captura GraphQL indicará que se ha producido un error interno en el servidor. Esto no es muy descriptivo y es mejor indicar errores más útiles para el usuario de la API como podría ser que no se tienen permisos para realizar la modificación o el error que se ha producido al validar los datos y por los que la operación no se ha completado.

En esta consulta de mutación que añade un nuevo libreo a la librería se puede producir dos excepciones, uno en el caso de que el usuario que lanza la consulta no tenga permisos y otro en el caso de que el autor no exista.

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

...

public class LibraryRepository {

    ...

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

    ...
}
Library.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class Mutation implements GraphQLMutationResolver {

    private LibraryRepository libraryRepository;

    ...

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

En el caso de no personalizar los mensajes de error se devuelve un error genérico de error interno del servidor nada descriptivo para el usuario de que cual es el motivo del error devuelvo como respuesta, la respuesta debería indicar el autor no existe y el usuario no tiene permisos.

 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
$ 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-generic-errors.sh

Para alguos tipos de error como una consulta cuya sintaxis no es correcta o se hace referencia a campos que no existen se devuelven errores más descriptivos.

1
2
3
4
5
6
7
8
9
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books{none}}"}' http://localhost:8080/graphql
{
  "errors": [
    {
      "message": "Validation error of type FieldUndefined: Field 'none' in type 'Book' is undefined @ 'books/none'"
    }
  ],
  "data": null
}
curl-default-errors.sh

Los errores en GraphQL usando el lenguaje Java se gestionan haciendo uso de la clase la interfaz GrapQLError, que contiene los datos que se devuelven como respuesta como el mensaje de error, el tipo de error, la ubicación en el ćodigo fuente donde se ha producido además de otros datos personalizados adicionales que se quieran incluir.

Para adaptar las clases excepción que se lanzan desde el servicio de persistencia a las clases GrapQLError que utiliza GraphQL hay que utilizar métodos con la anotación @ExceptionHandler que básicamente transofrman una RuntimeException a un GraphQLError.

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

import graphql.ErrorClassification;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.language.SourceLocation;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.xml.transform.Source;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

@Component
public class ExceptionHandlers {

    public enum DefaultErrorClassification implements ErrorClassification {
        ServerError
    }

    @ExceptionHandler(RuntimeException.class)
    public GraphQLError exceptionHandler(RuntimeException exception) {
        Throwable e = exception.getCause();
        if (e instanceof PermissionException) {
            return exceptionHandler((PermissionException) e);
        } else if (e instanceof ValidationException) {
            return exceptionHandler((ValidationException) e);
        } else {
            return GraphqlErrorBuilder.newError().message("Internal Server Error(s) while executing query").build();
        }
    }

    @ExceptionHandler(PermissionException.class)
    public GraphQLError exceptionHandler(PermissionException exception) {
                return GraphqlErrorBuilder.newError().message(exception.getMessage()).errorType(DefaultErrorClassification.ServerError).extensions(Map.of("source", toSourceLocation(exception), "foo", "bar", "fizz", "whizz")).build();
    }

    @ExceptionHandler(ValidationException.class)
    public GraphQLError exceptionHandler(ValidationException exception) {
        return GraphqlErrorBuilder.newError().message(exception.getMessage()).errorType(DefaultErrorClassification.ServerError).extensions(Map.of("source", toSourceLocation(exception))).build();
    }

    private SourceLocation toSourceLocation(Throwable t) {
        if (t.getStackTrace().length == 0) {
            return null;
        }
        StackTraceElement st = t.getStackTrace()[0];
        return new SourceLocation(st.getLineNumber(), -1, st.toString());
    }
}
ExceptionHandlers.java

En el caso de este ejemplo solo un usuario de nombre admin tiene permitido hacer modificaciones en la colección de libros guardados en la clase repositorio LibraryRepository. Por otro lado, cuando se añade un libro se hace una validación de los datos comprobando que el autor del libro a añadir exista en la librería. Estas son las peticiones válidas.

 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
$ curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{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" -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.sh

Y estas las inválidas que devuelven los mensajes propios más descriptivos de los errores o validaciones realizadas en el servidor de más utilidad para un usuario del servicio.

 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
$ curl -s -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 2", "author": "999"}}' http://localhost:8080/graphql
{
  "errors": [
    {
      "message": "Invalid author",
      "locations": [],
      "extensions": {
        "source": {
          "line": 114,
          "column": -1,
          "sourceName": "io.github.picodotdev.blogbitix.graphql.repository.LibraryRepository.addBook(LibraryRepository.java:114)"
        },
        "classification": "ServerError"
      }
    }
  ],
  "data": {
    "addBook": null
  }
}

$ curl -s -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 2", "author": "6"}}' http://localhost:8080/graphql
{
  "errors": [
    {
      "message": "Invalid permissions",
      "locations": [],
      "extensions": {
        "source": {
          "line": 110,
          "column": -1,
          "sourceName": "io.github.picodotdev.blogbitix.graphql.repository.LibraryRepository.addBook(LibraryRepository.java:110)"
        },
        "foo": "bar",
        "fizz": "whizz",
        "classification": "ServerError"
      }
    }
  ],
  "data": {
    "addBook": null
  }
}
curl-custom-errors.sh

La interfaz GraphQLError posee el método getMessage() para devolver la descripción del mensaje pero con el método getExtensions() es posible incluir cualquier dato en forma de clave-valor que deseemos como un código de error o cualquier otra información deseada. El caso de la excepción PermissionException devuelve dos datos adicionales foo y fizz, en un caso real se implementaría una lógica más útil para devolver estos datos adicionales posiblemente proporcionándolos en el constructor u obteniéndolos con la referencia a algún objeto, podría ser incluso el stacktrace completo de la excepción.

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

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

Referencia:
Comparte el artículo: