Статьи Утилиты Telegram YouTube Отзывы

Обработка ошибок в GraphQL

Видеогайд Исходники

13 мая 2024

Тэги: Collections, GraphQL, json, Kotlin, Spring Boot, руководство.

Содержание

  1. Пример: поиск книги по id
  2. Тестируем новый метод
  3. Меняем формат ответа при ошибке
  4. Итоги

Продолжаем цикл статей про работу с GraphQL в проекте, написанном на Kotlin и Spring Boot. В первой статье GraphQL в Spring Boot мы создали проект, который возвращает информацию о книгах и их авторах. Во второй части Мутации в GraphQL мы научились менять данные.

Обработка ошибок в Spring Boot Graphql

Теперь рассмотрим как можно обрабатывать ошибки в GraphQL и менять их формат.

Пример: поиск книги по id

Добавим в наш проект новый метод, возвращающий информацию о книге по её id.

Модифицируем файл schema.graphqls, добавив в тип Query второй метод под названием getBookById():

# Публичные методы GraphQL для чтения данных
type Query {
    # Список книг
    allBooks(limit: Int!): [Book]
    # Получить книгу по её id
    getBookById(id: Int!): Book!
}

Этот метод всегда должен возвращать книгу.

Теперь добавим новый метод findById() в BookRepository. В реальном проекте этот репозиторий читал бы данные из БД, но в нашем примере данные захардкожены. Для удобства список всех книг превратим в мапу, чтобы быстрее делать выборку по id.

@Repository
class BookRepository {
    // ...
    fun findById(id: Int): Book? =
        BOOKS[id]

    private companion object {
        val BOOKS = listOf(
            Book(11, "Война и мир", 1, BigDecimal("1000.25")),
            Book(12, "Евгений Онегин", 2, BigDecimal("301.08")),
            Book(13, "Воскресение", 1, BigDecimal("517.30")),
            Book(14, "Анна Каренина", 1, BigDecimal("755.02")),
        ).associateBy { it.id }
    }
}

Обратите внимание, что если книга не найдена, то данный метод вернёт null и никакой ошибки не возникнет. Это сделано специально, т.к. на уровне репозитория недостаточно контекста для принятия решения о том, допустимо ли возвращать null или нет. Обработку ошибок мы сделаем на уровне выше. Благодаря такому подходу один и тот же метод репозитория может быть переиспользован в разных «бизнесовых» методах.

Теперь осталось добавить метод getBookById() в класс BookQuery. Сигнатура метода должна соответствовать той, которую мы объявили в graphql-схеме. Каждый аргумент graphql-метода должен быть помечен аннотацией @Argument.

@QueryMapping
fun getBookById(@Argument id: Int): Book =
    bookRepository.findById(id)
        ?: throw RuntimeException("Book with id=$id not found!")

Здесь мы вызываем метод репозитория. Если книга с указанным id найдена – мы вернём эту книгу. Если не найдена – репозиторий вернёт null и благодаря элвис-оператору мы кидаем RuntimeException с соответствующим описанием ошибки.

Тестируем новый метод

Теперь запустим проект и протестируем новый метод через веб-интерфейс, доступный по адресу http://127.0.0.1:8080/graphiql. Запрос может выглядеть как-то так:

{
  getBookById(id: 11) {
    name
    price
    author {
      lastName
    }
  }
}

В ответ получим одну книгу из тех, что мы захардкодили:

{
  "data": {
    "getBookById": {
      "name": "Война и мир",
      "price": 1000.25,
      "author": {
        "lastName": "Толстой"
      }
    }
  }
}

Обратите внимание, что тут мы вместе с книгой в новом методе запрашиваем ещё и информацию об авторе, хотя никакой логики по работе с авторами мы сейчас не написали. Graphql автоматически переиспользует логику, которую мы делали ранее для списка всех книг. Он уже знает, как связаны между собой книги и авторы! И в этом проявляется вся мощь механизма graphql.

Теперь, если мы запросим книгу с каким-то произвольным id, то в ответ получим ошибку:

{
  "errors": [
    {
      "message": "INTERNAL_ERROR for dbc32b73-4657-e262-76a4-354b6a0ccaa7",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "getBookById"
      ],
      "extensions": {
        "classification": "INTERNAL_ERROR"
      }
    }
  ],
  "data": null
}

Здесь мы наблюдаем стандартный формат ответа graphql. Он содержит по сути лишь название метода, в котором произошла ошибка. Возможно, с точки зрения безопасности вы и не захотите отдавать больше информации клиенту. Но как быть, если всё-таки нам нужно вернуть более детальную ошибку? Ответ прост: надо создать собственный формат ответа при ошибке.

Меняем формат ответа при ошибке

Хоть это и не обязательно, но очень удобно иметь базовый абстрактный класс исключения для всех ошибок в бизнес-логике.

abstract class BusinessLogicException(
    val code: String,
    val errorType: ErrorType,
    val params: Map<String, Any>,
    override val message: String,
) : RuntimeException(message)

Этот класс мы наследуем от RuntimeException, в который передаем текст сообщения об ошибке. Помимо текста, наше исключение также содержит расширенную информацию: код ошибки, тип ошибки graphql и дополнительные опциональные параметры.

Код ошибки обычно делают числовым, но я предпочитаю текстовый, т.к. он более читаемый. Это именно код, т.е. заранее определённое значение строки, которое является частью протокола взаимодействия между клиентом и сервером.

Тип ошибки graphql – это перечисление ErrorType из библиотеки graphql, в котором всего 5 возможных значений.

Теперь создадим исключение BookNotFoundException специально для ситуации, когда книга не найдена. Оно наследуется от нашего базового исключения.

class BookNotFoundException(bookId: Int) : BusinessLogicException(
    "book.not.found",
    ErrorType.NOT_FOUND,
    mapOf("bookId" to bookId),
    "Book with id=$bookId not found",
)

Исключение принимает на вход лишь id книги, по которому производился поиск. Всё остальное определяем в самом исключении, в том числе текстовый код ошибки book.not.found и текстовое описание.

Такой подход позволяет сократить объём кода в месте возникновения ошибки:

@QueryMapping
fun getBookById(@Argument id: Int): Book =
    bookRepository.findById(id)
        ?: throw BookNotFoundException(id)

Теперь осталось определить бин, который меняет формат ответа. Его мы унаследуем от DataFetcherExceptionResolverAdapter.

@Component
class GqlExceptionResolver : DataFetcherExceptionResolverAdapter() {
    override fun resolveToSingleError(ex: Throwable, env: DataFetchingEnvironment): GraphQLError? {
        return if (ex is BusinessLogicException) {
            ex.getBusinessError(env)
        } else {
            ex.getInternalError(env)
        }
    }
    // ...
}

Логика работы метода проста: если мы получили исключение бизнес-логики (т.е. исключение, производное от BusinessLogicException), то заполняем параметры ошибки из него. Если это любое другое исключение – значит оно, скорее всего, общее или техническое, поэтому подставляем там какие-то параметры по умолчанию.

Метод расширения getBusinessError() выглядит так:

private fun BusinessLogicException.getBusinessError(env: DataFetchingEnvironment) =
    GraphqlErrorBuilder.newError()
        .errorType(this.errorType)
        .message(this.message)
        .path(env.executionStepInfo.path)
        .location(env.field.sourceLocation)
        .extensions(
            this.params +
                    mapOf(
                        "code" to this.code,
                    )
        )
        .build()

Описание ошибки, тип ошибки мы извлекаем из исключения и помещаем в соответствующие методы. А всю дополнительную информацию, в том числе код ошибки, мы передаём как мапу в секцию extensions.

Метод расширения getInternalError() похож на предыдущий, но недостающие параметры мы заполняем значениями по умолчанию:

private fun Throwable.getInternalError(env: DataFetchingEnvironment) =
    GraphqlErrorBuilder.newError()
        .errorType(ErrorType.INTERNAL_ERROR)
        .message(this.message)
        .path(env.executionStepInfo.path)
        .location(env.field.sourceLocation)
        .extensions(
            mapOf(
                "code" to "internal.error",
            )
        )
        .build()

Все подобные исключения получают общий код ошибки internal.error.

Теперь выполним запрос несуществующей книги ещё раз и убедимся, что ответ стал более информативным:

{
  "errors": [
    {
      "message": "Book with id=123 not found",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "getBookById"
      ],
      "extensions": {
        "bookId": 123,
        "code": "book.not.found",
        "classification": "NOT_FOUND"
      }
    }
  ],
  "data": null
}

По сравнению с изначальным вариантом, тут мы видим уже текстовое описание, а также код ошибки и id, по которому выполняется поиск в секции extensions.

Итоги

Мы создали новый метод для поиска книги по её id и ещё раз убедились, что graphql автоматически переиспользует ранее написанный код для подгрузки связанной сущности автора книги. Затем увидели, что Spring Boot Graphql предоставляет простой способ кастомизации ошибок, а наличие общего базового исключения для всех ошибок бизнес-логики позволяет избежать лишнего дублирования кода и при этом несёт дополнительную информацию.

Однако всегда надо помнить, что добавление деталей ошибки в ответ клиенту может нарушить безопасность приложения.


Облако тэгов

Kotlin, Java, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Linux, Hibernate, Collections, Stream API, многопоточность, файлы, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.

Последние статьи


Комментарии

Добавить комментарий

×

devmark.ru