Статьи
Утилиты 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 предоставляет простой способ кастомизации ошибок, а наличие общего базового исключения для всех ошибок бизнес-логики позволяет избежать лишнего дублирования кода и при этом несёт дополнительную информацию.

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



Комментарии

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

×

devmark.ru