Гайд по использованию Spring GraphQL. Часть 3

от автора

Привет, Хабр!

Меня зовут Дмитрий, я бэкенд-разработчик в SENSE и последние 10 лет пишу серверную часть на Java. Эта статья — продолжение серии гайдов по Spring GraphQL, где в первой части мы с нуля подняли проект и подключили GraphQL к Spring Boot, а во второй разобрались с SchemaMapping, DataLoader и реализацией запросов посложнее.

Сегодня двигаемся дальше: разберём валидацию данных, работу с заголовками (headers), обработку ошибок, подключение кастомных скаляров и директив. А ещё посмотрим, как работать с интерфейсами и union-типами и напишем клиент для GraphQL-сервиса.

Поехали!

Валидация данных

Вы уже частично коснулись этой темы при создании схемы и добавлении «!» в схему graphQL. Но что, если нужна более сложная валидация? 

Для этого у нас есть два основных способа:

  1. Стандартная валидация через jakarta.validation;

  2. Использование graphQL directive.

Рассмотрим плюсы каждого подхода:

Jakarta Validation

GraphQL-директива

1. Стандартизированная валидация: Jakarta Validation является стандартизированным API для валидации данных в Java, это означает, что вы можете использовать его для валидации данных в любом приложении, не только в GraphQL.

1. Прямая интеграция с GraphQL: GraphQL-директива является частью GraphQL-схемы, это означает, что вы можете использовать ее напрямую в ваших GraphQL-запросах.

2. Легкая интеграция: Jakarta Validation легко интегрируется с большинством фреймворков и библиотек, включая GraphQL.

2. Легкая валидация: GraphQL-директива позволяет легко валидировать данные в GraphQL-запросах, без необходимости дополнительной конфигурации.

Для примера: реализуем валидацию на превышение максимального значения Integer.

Реализация через Jakarta Validation

Подключите стартер:

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId> </dependency>

Реализуйте аннотацию:

@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = MaxIntValidator.class) public @interface MaxInt {    int value();    String message() default "Превышено максимальное значение";    Class<?>[] groups() default {};    Class<? extends Payload>[] payload() default {}; }
public class MaxIntValidator implements ConstraintValidator<MaxInt, Integer> {     private int max;     @Override    public void initialize(MaxInt maxInt) {        max = maxInt.value();    }     @Override    public boolean isValid(Integer value, ConstraintValidatorContext context) {        return value <= max;    } }

Добавьте валидацию запросу:

@QueryMapping public List<Author> getAuthors(@Argument @MaxInt(1) Integer filter) {    return dataBaseService.getAuthors(filter); }

Реализация через директиву

Создайте непосредственно обработчик:

public class MaxIntDirective implements SchemaDirectiveWiring {     @Override    public GraphQLArgument onArgument(SchemaDirectiveWiringEnvironment<GraphQLArgument> env) {        GraphQLArgument argument = env.getElement();        DataFetcher<?> originalDataFetcher = env.getFieldDataFetcher();         // Получаем параметры директивы        GraphQLAppliedDirective directive = env.getAppliedDirective();        Integer maxLength = directive.getArgument("value").getValue();         // Оборачиваем оригинальный DataFetcher        DataFetcher<?> wrappedDataFetcher = dataFetchingEnvironment -> {            Integer argumentValue = dataFetchingEnvironment.getArgument(argument.getName());             // Валидация            if (argumentValue > maxLength) {                throw new IllegalArgumentException(                        "Превышение длины " + maxLength);            }             return originalDataFetcher.get(dataFetchingEnvironment);        };         // Заменяем DataFetcher        env.setFieldDataFetcher(wrappedDataFetcher);         return argument;    } }

Зарегистрируйте его в схеме:

@Configuration public class GraphqlExtendedConfig {     @Bean    public RuntimeWiringConfigurer runtimeWiringConfigurer() {        return wiringBuilder -> wiringBuilder                .directive("MaxInt", new MaxIntDirective());    }  }

Создайте файл directives.graphqls с содержимым:

directive @MaxInt(value: Int!) on ARGUMENT_DEFINITION

Добавьте директиву к запросу:

"Получить всех авторов" getAuthors(filter: Int @MaxInt(value: 4)): [Author!]!

Также есть дополнительная библиотека для работы с директивами и реализацией через AbstractDirectiveConstraint:

<dependency>    <groupId>com.graphql-java</groupId>    <artifactId>graphql-java-extended-validation</artifactId>    <version>${graphql-java.version}</version> </dependency>

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

Ранее вы реализовали валидацию, но не реализовали корректную обработку ошибок.

Сейчас, при превышении MaxInt, вы получите следующую ошибку:

{  "errors": [    {      "message": "INTERNAL_ERROR for 7e3b7163-20da-2136-545a-38e1eef5f90e",      "locations": [        {          "line": 2,          "column": 3        }      ],      "path": [        "getAuthors"      ],      "extensions": {        "classification": "INTERNAL_ERROR"      }    }  ],  "data": null }

Для обработки ошибок вы можете использовать DataFetcherExceptionResolverAdapter и выбрасывать GraphQLError (интерфейс из библиотеки graphql-java):

@Component @Slf4j public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {     @Override    @SuppressWarnings(value = "checkstyle:CyclomaticComplexity")    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {       if (ex instanceof ConstraintViolationException                || ex instanceof MyError) {            return createError(ex, env, ValidationError);        } else {            return null;        }    }     private GraphQLError createError(            Throwable ex,            DataFetchingEnvironment env,            ErrorClassification classification) {        log.warn(ex.getMessage());        return GraphqlErrorBuilder.newError()                .errorType(classification)                .extensions(Map.of("myField", "my text"))                .message(ex.getMessage())                .path(env.getExecutionStepInfo().getPath())                .location(env.getField().getSourceLocation())                .build();    }  }

В errorType вы можете передавать готовые типы:

Или реализовать свой enum:

public enum CustomErrorType implements ErrorClassification {    MY_ERROR("MY_ERROR"),    MY_ERROR2("MY_ERROR2")     private final String errorClassification;     CustomErrorType(String errorClassification) {        this.errorClassification = errorClassification;    }     @Override    public String toString() {        return errorClassification;    } }

Добавление своих скаляров

Скалярные типы (Scalar) в GraphQL — это примитивные типы данных, которые представляют конкретные значения, а не объекты или коллекции. Они являются «листьями» в GraphQL-запросах, т.е. не содержат вложенных полей.

Переделайте id автора на uuid. По умолчанию схема graphQL не поддерживает данный тип. Мы можем создать его сами или воспользоваться одним из, реализованных в библиотеке, вариантов:

<dependency>    <groupId>com.graphql-java</groupId>    <artifactId>graphql-java-extended-scalars</artifactId>    <version>22.0</version> </dependency>

Зарегистрируйте его:

@Bean public RuntimeWiringConfigurer runtimeWiringConfigurer() {    return wiringBuilder -> wiringBuilder            .directive("MaxInt", new MaxIntDirective())            .scalar(ExtendedScalars.UUID); }

Добавьте в схему:

scalar UUID

Используйте в типе:

"Автор" type Author {    "id автора"    id: UUID!    "Имя автора"    name: String!    "Фамилия автора"    surname: String!    "День рождения автора"    birthday: String!    "Книги автора"    books(id: Int): [Book!]! }
type Query {    helloWorld(text: String!): String!    "Получить всех авторов"    getAuthors(filter: UUID): [Author!]! }

Поправьте контроллер:

@QueryMapping public List<Author> getAuthors(@Argument UUID filter) {    return dataBaseService.getAuthors(filter); }

Скаляр UUID теперь валидируется и вернет ошибку, если вы отправите не uuid:

{  "errors": [    {      "message": "Validation error (WrongType@[getAuthors]) : argument 'filter' with value 'StringValue{value='215697a7-e4f7-4030-b158'}' is not a valid 'UUID' - Expected something that we can convert to a UUID but was invalid",      "locations": [        {          "line": 2,          "column": 14        }      ],      "extensions": {        "classification": "ValidationError"      }    }  ] }

Также вы можете сделать самостоятельно свой скаляр.

Пример реализации своего скаляра даты:

@Slf4j public class InstantCoercing implements Coercing<Instant, String> {     private static final String EXCEPTION_MESSAGE = "Значение должно быть передано как строка в формате ISO-8601 UTC";     @Override    public String serialize(Object dataFetcherResult, GraphQLContext graphQLContext, Locale locale)            throws CoercingSerializeException {        return dataFetcherResult.toString();    }     @Override    public Instant parseLiteral(Value<?> input, CoercedVariables variables, GraphQLContext context, Locale locale)            throws CoercingParseLiteralException {        try {            if (input instanceof StringValue stringValue) {                return Instant.parse(stringValue.getValue());            } else {                throw createException();            }        } catch (CoercingParseLiteralException e) {            throw e;        } catch (Exception e) {            log.warn("Problem with parse Instant from %s".formatted(input), e);            throw createException();        }    }     @Override    public Value<?> valueToLiteral(Object input, GraphQLContext graphQLContext, Locale locale) {        return GraphQLString.getCoercing().valueToLiteral(input, graphQLContext, locale);    }     private CoercingParseLiteralException createException() {        return new CoercingParseLiteralException(EXCEPTION_MESSAGE);    } }

Регистрация:

@Bean public RuntimeWiringConfigurer runtimeWiringConfigurer() {    return wiringBuilder -> wiringBuilder            .directive("MaxInt", new MaxIntDirective())            .scalar(ExtendedScalars.UUID)            .scalar(                    GraphQLScalarType.newScalar()                            .name("DateTime")                            .description("Represents date and time at UTC")                            .coercing(new InstantCoercing())                            .build()); }

Добавление в схему:

scalar DateTime

Работа с header запроса

По умолчанию данные header не попадают в метод контроллера. Если они вам нужны, то необходимо их добавить к запросу. Для этого используется WebGraphQlInterceptor:

@Component @RequiredArgsConstructor @Slf4j public class RequestGraphQLHeaderInterceptor implements WebGraphQlInterceptor {     @Override    public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {        Map<String, Object> context = new HashMap<>();        request.getHeaders().forEach((key, value) -> {            switch (key.toLowerCase()) {                case "test":                    context.put(key.toLowerCase(), value.getFirst());                    break;                default:                    break;            }         });         if (!context.isEmpty()) {            request.configureExecutionInput((executionInput, builder) ->                    builder.graphQLContext(context).build());        }        return chain.next(request);    } }

После этого header можно получить в методе контроллера. Также можно установить обязательность:

@QueryMapping public List<Author> getAuthors(        @Argument UUID filter,        @ContextValue(required = true, name = "test")        String myHeader) {    return dataBaseService.getAuthors(filter); }

Тестирование запроса с header

Вам не подойдет graphQlTester из примера выше, так как его не модифицировать.

Поэтому, придется поднять тестер самостоятельно:

@Autowired private WebApplicationContext context;  WebTestClient client;  HttpGraphQlTester testerGraphQlWithHeader;  @PostConstruct void init() {    client = MockMvcWebTestClient.bindToApplicationContext(context)            .configureClient()            .baseUrl("/graphql")            .build();     testerGraphQlWithHeader = HttpGraphQlTester.create(client)            .mutate().header("test", "myValue")            .build(); }  @Autowired protected GraphQlTester graphQlTester; @Test void queryWithHeader() {    String query = """                query MyQuery {                  getAuthors{                    id                  }                }            """;     List<Author> authors = testerGraphQlWithHeader.document(query).execute()            .path("getAuthors").entityList(Author.class).get();    assertThat(authors).isNotEmpty(); }  @Test void queryWithoutHeader() {    String query = """                query MyQuery {                  getAuthors{                    id                  }                }            """;     graphQlTester.document(query).execute()            .errors().expect(e -> e.getErrorType().equals(INTERNAL_ERROR)); }

Интерфейсы и union-типы

Предположим, вы хотите одним запросом вернуть не только авторов, но и читателей.

Добавьте в схему graphQL тип читателя и объедините его с типом автора интерфейсом:

type Query {    helloWorld(text: String!): String!    "Получить всех авторов"    getAuthors(filter: UUID): [Author!]!    getPeople: [Person!]! }  interface Person {    id: UUID!    name: String!    surname: String! }  type Reader implements Person{    id: UUID!,    name: String!,    surname: String! }  "Автор" type Author implements Person{    "id автора"    id: UUID!    "Имя автора"    name: String!    "Фамилия автора"    surname: String!    "День рождения автора"    birthday: String!    "Книги автора"    books(id: Int): [Book!]! }

В коде создайте новый тип и интерфейс:

public interface Person { }
public record Reader (        UUID id,        String name,        String surname ) implements Person { }

Не забудьте заимплементить его в Author.

Реализуйте метод в контроллере:

@QueryMapping public List<Person> getPeople(){    return dataBaseService.getPersons(); }

Union реализуется очень похоже. Отличия только в объявлении в схеме

union Person = Reader | Author

По сути, разницы между ними нет. Но обычно union объединяет совсем непохожие типы, а interface подобные.

Как написать client graphql

Добавьте зависимость:

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-webflux</artifactId> </dependency>

Сконфигурируйте клиент:

import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.graphql.client.HttpGraphQlClient; import org.springframework.graphql.support.CachingDocumentSource; import org.springframework.graphql.support.DocumentSource; import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient;   @Configuration public class GraphQLClientConfiguration {     private final String graphQlURI = "http://localhost:8080/graphql";     @Bean    public HttpGraphQlClient testHttpGraphQlClient(WebClient testWebClient,                                                              DocumentSource testDocumentSource) {        return HttpGraphQlClient.builder(testWebClient)                .documentSource(testDocumentSource)                .build();    }     @Bean    public WebClient testWebClient(HttpClient httpClient) {        return WebClient.builder()                .baseUrl(graphQlURI)                .defaultHeader("test", "test")                .clientConnector(new ReactorClientHttpConnector(httpClient))                .build();    }     @Bean    public DocumentSource testDocumentSource() {        return new CachingDocumentSource(                new ResourceDocumentSource(                        List.of(new ClassPathResource("graphql-templates/test/")),                        ResourceDocumentSource.FILE_EXTENSIONS));    }     @Bean    public HttpClient httpClient() {        return HttpClient.create();    } }

В ClassPathResource укажите путь к папке с template запросов,также добавьте header, т. к. в нашем примере он обязателен.

Создайте в указанной папке template: testFile.graphql (обратите внимание, что тип без «s»):

query MyQuery($inp: UUID) {    getAuthors(filter: $inp) {        id    } }

Создайте клиент:

@Component @RequiredArgsConstructor public class GraphQLClient {     private final HttpGraphQlClient testHttpGraphQlClient;     public List<Author> test(UUID uuid) {        return testHttpGraphQlClient.documentName("testFile")                .variable("inp", uuid)                .retrieve("getAuthors")                .toEntityList(Author.class)                .block();    } }

Заключение

Итак, мы подошли к концу серии статей по Spring GraphQL — мощным инструментом для создания современного и гибкого API, а его интеграция со Spring-экосистемой делает разработку удобной и расширяемой.

Вместе мы прошли путь от настройки простого проекта до построения полноценного API: реализовали запросы и мутации, добавили фильтрацию и DataLoader, подключили валидацию, обработку ошибок, собственные скаляры и директивы, а также научились работать с заголовками, интерфейсами и union-типами. А в завершение собрали клиент для GraphQL. Надеюсь, эта серия гайдов была полезна и поможет вам быстрее стартовать с GraphQL в реальных проектах.

P.S. Продолжение следует. Если у вас есть идеи или запросы на темы по Java, о которых стоит рассказать, пишите в комментариях — обсудим и вместе выберем, что разобрать в следующий раз.


ссылка на оригинал статьи https://habr.com/ru/articles/938600/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *