Привет, Хабр!
Меня зовут Дмитрий, я бэкенд-разработчик в SENSE и последние 10 лет пишу серверную часть на Java. Эта статья — продолжение серии гайдов по Spring GraphQL, где в первой части мы с нуля подняли проект и подключили GraphQL к Spring Boot, а во второй разобрались с SchemaMapping, DataLoader и реализацией запросов посложнее.
Сегодня двигаемся дальше: разберём валидацию данных, работу с заголовками (headers), обработку ошибок, подключение кастомных скаляров и директив. А ещё посмотрим, как работать с интерфейсами и union-типами и напишем клиент для GraphQL-сервиса.
Поехали!
Валидация данных
Вы уже частично коснулись этой темы при создании схемы и добавлении «!» в схему graphQL. Но что, если нужна более сложная валидация?
Для этого у нас есть два основных способа:
-
Стандартная валидация через jakarta.validation;
-
Использование 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/
Добавить комментарий