Как стать автором
Поиск
Написать публикацию
Обновить
78.17
SENSE
SENSE — кадровый системный интегратор

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

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров157

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

Меня зовут Дмитрий, я бэкенд-разработчик в 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, о которых стоит рассказать, пишите в комментариях — обсудим и вместе выберем, что разобрать в следующий раз.

Теги:
Хабы:
+3
Комментарии0

Публикации

Информация

Сайт
sense-group.ru
Дата регистрации
Дата основания
Численность
201–500 человек