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