GH-3501: Add GraphQL support

Fixes https://github.com/spring-projects/spring-integration/issues/3501

* make current with latest changes in 'spring-graphql'
* fix checkstyle issues
* implement reactive endpoint for GraphQL Query
* refactor to handle GraphQL Query and Mutation requests
* refactor to handle GraphQL Subscription requests
* implement expressions to handle for various RequestInput parameters
* convert classes to records in tests, remove unneeded datatype modifiers on channels
* replace executionId with idExpression SpEL evaluator
* adjust name and default expression for executionId
* rename for consistency, remove unneeded null check
* Clean up code style
* Remove redundant variables
* Add `What's New` entry
* Add `package-info.java`
This commit is contained in:
Daniel Frey
2022-02-15 16:27:45 -05:00
committed by Artem Bilan
parent c661d7925e
commit f09c665db4
10 changed files with 592 additions and 0 deletions

View File

@@ -99,6 +99,7 @@ ext {
smackVersion = '4.3.5'
springAmqpVersion = project.hasProperty('springAmqpVersion') ? project.springAmqpVersion : '3.0.0-M1'
springDataVersion = project.hasProperty('springDataVersion') ? project.springDataVersion : '2022.0.0-M1'
springGraphqlVersion = '1.0.0-M5'
springKafkaVersion = '3.0.0-M1'
springRetryVersion = '1.3.1'
springSecurityVersion = project.hasProperty('springSecurityVersion') ? project.springSecurityVersion : '6.0.0-M1'
@@ -613,6 +614,14 @@ project('spring-integration-gemfire') {
}
}
project('spring-integration-graphql') {
description = 'Spring Integration GraphQL Support'
dependencies {
api project(':spring-integration-core')
api "org.springframework.graphql:spring-graphql:$springGraphqlVersion"
}
}
project('spring-integration-groovy') {
description = 'Spring Integration Groovy Support'
dependencies {

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.graphql.outbound;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.expression.Expression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.graphql.GraphQlService;
import org.springframework.graphql.RequestInput;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.expression.FunctionExpression;
import org.springframework.integration.expression.SupplierExpression;
import org.springframework.integration.handler.AbstractReplyProducingMessageHandler;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.util.Assert;
/**
* An {@link AbstractReplyProducingMessageHandler} capable of fielding
* GraphQL Query, Mutation and Subscription requests.
*
* @author Daniel Frey
*
* @since 6.0
*/
public class GraphQlMessageHandler extends AbstractReplyProducingMessageHandler {
private final GraphQlService graphQlService;
private StandardEvaluationContext evaluationContext;
private Expression operationExpression;
private Expression operationNameExpression = new SupplierExpression<>(() -> null);
private Expression variablesExpression = new SupplierExpression<>(() -> null);
@Nullable
private Locale locale;
private Expression executionIdExpression =
new FunctionExpression<Message<?>>(message -> message.getHeaders().getId());
public GraphQlMessageHandler(final GraphQlService graphQlService) {
Assert.notNull(graphQlService, "'graphQlService' must not be null");
this.graphQlService = graphQlService;
setAsync(true);
}
/**
* Specify a GraphQL Operation.
* @param operation the GraphQL operation to use.
*/
public void setOperation(String operation) {
Assert.hasText(operation, "'operation' must not be empty");
setOperationExpression(new LiteralExpression(operation));
}
/**
* Specify a SpEL expression to evaluate a GraphQL Operation
* @param operationExpression the expression to evaluate a GraphQL Operation.
*/
public void setOperationExpression(Expression operationExpression) {
Assert.notNull(operationExpression, "'queryExpression' must not be null");
this.operationExpression = operationExpression;
}
/**
* Set a GraphQL Operation Name to execute.
* @param operationName the GraphQL Operation Name to use.
*/
public void setOperationName(String operationName) {
setOperationNameExpression(new LiteralExpression(operationName));
}
/**
* Set a SpEL expression to evaluate a GraphQL Operation Name to execute.
* @param operationNameExpression the expression to use.
*/
public void setOperationNameExpression(Expression operationNameExpression) {
Assert.notNull(operationNameExpression, "'operationNameExpression' must not be null");
this.operationNameExpression = operationNameExpression;
}
/**
* Set a SpEL expression to evaluate Variables for GraphQL Operation to execute.
* @param variablesExpression the expression to use.
*/
public void setVariablesExpression(Expression variablesExpression) {
Assert.notNull(variablesExpression, "'variablesExpression' must not be null");
this.variablesExpression = variablesExpression;
}
/**
* Set a Locale for GraphQL Operation to execute.
* @param locale the locale to use.
*/
public void setLocale(@Nullable Locale locale) {
this.locale = locale;
}
/**
* Set a SpEL expression to evaluate Execution Id for GraphQL Operation Request to execute.
* @param executionIdExpression the executionIdExpression to use.
*/
public void setExecutionIdExpression(Expression executionIdExpression) {
Assert.notNull(executionIdExpression, "'executionIdExpression' must not be null");
this.executionIdExpression = executionIdExpression;
}
@Override
protected final void doInit() {
BeanFactory beanFactory = getBeanFactory();
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(beanFactory);
}
@Override
protected Object handleRequestMessage(Message<?> requestMessage) {
RequestInput requestInput;
if (requestMessage.getPayload() instanceof RequestInput) {
requestInput = (RequestInput) requestMessage.getPayload();
}
else {
Assert.notNull(this.operationExpression, "'operationExpression' must not be null");
String query = evaluateOperationExpression(requestMessage);
String operationName = evaluateOperationNameExpression(requestMessage);
Map<String, Object> variables = evaluateVariablesExpression(requestMessage);
String id = evaluateExecutionIdExpression(requestMessage);
requestInput = new RequestInput(query, operationName, variables, this.locale, id);
}
return this.graphQlService.execute(requestInput);
}
private String evaluateOperationExpression(Message<?> message) {
String operation = this.operationExpression.getValue(this.evaluationContext, message, String.class);
Assert.notNull(operation, "'operationExpression' must not evaluate to null");
return operation;
}
private String evaluateOperationNameExpression(Message<?> message) {
return this.operationNameExpression.getValue(this.evaluationContext, message, String.class);
}
@SuppressWarnings("unchecked")
private Map<String, Object> evaluateVariablesExpression(Message<?> message) {
return this.variablesExpression.getValue(this.evaluationContext, message, Map.class);
}
private String evaluateExecutionIdExpression(Message<?> message) {
return this.executionIdExpression.getValue(this.evaluationContext, message, String.class);
}
}

View File

@@ -0,0 +1,4 @@
/**
* Provides classes for GraphQL outbound channel adapters.
*/
package org.springframework.integration.graphql.outbound;

View File

@@ -0,0 +1,337 @@
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.graphql.outbound;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.graphql.GraphQlService;
import org.springframework.graphql.RequestInput;
import org.springframework.graphql.RequestOutput;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
import org.springframework.graphql.execution.ExecutionGraphQlService;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.integration.channel.FluxMessageChannel;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.dsl.MessageChannels;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandlingException;
import org.springframework.messaging.PollableChannel;
import org.springframework.messaging.support.ErrorMessage;
import org.springframework.messaging.support.GenericMessage;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.Repository;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import graphql.execution.reactive.SubscriptionPublisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
/**
*
* @author Daniel Frey
*
* @since 6.0
*/
@SpringJUnitConfig
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class GraphQlMessageHandlerTests {
@Autowired
private FluxMessageChannel inputChannel;
@Autowired
private FluxMessageChannel resultChannel;
@Autowired
private PollableChannel errorChannel;
@Autowired
private GraphQlMessageHandler graphQlMessageHandler;
@Autowired
private UpdateRepository updateRepository;
@Test
@SuppressWarnings("unchecked")
void testHandleMessageForQueryWithRequestInputProvided() {
StepVerifier verifier =
StepVerifier.create(
Flux.from(this.resultChannel)
.map(Message::getPayload)
.cast(RequestOutput.class)
)
.consumeNextWith(result -> {
assertThat(result).isInstanceOf(RequestOutput.class);
Map<String, Object> data = result.getData();
Map<String, Object> testQuery = (Map<String, Object>) data.get("testQuery");
assertThat(testQuery.get("id")).isEqualTo("test-data");
})
.thenCancel()
.verifyLater();
RequestInput payload = new RequestInput("{ testQuery { id } }", null, null, null, UUID.randomUUID().toString());
this.inputChannel.send(MessageBuilder.withPayload(payload).build());
verifier.verify(Duration.ofSeconds(10));
}
@Test
@SuppressWarnings("unchecked")
void testHandleMessageForQueryWithQueryProvided() {
String fakeQuery = "{ testQuery { id } }";
this.graphQlMessageHandler.setOperation(fakeQuery);
Locale locale = Locale.getDefault();
this.graphQlMessageHandler.setLocale(locale);
Mono<RequestOutput> resultMono =
(Mono<RequestOutput>) this.graphQlMessageHandler.handleRequestMessage(new GenericMessage<>(fakeQuery));
StepVerifier.create(resultMono)
.consumeNextWith(result -> {
assertThat(result).isInstanceOf(RequestOutput.class);
Map<String, Object> data = result.getData();
Map<String, Object> testQuery = (Map<String, Object>) data.get("testQuery");
assertThat(testQuery.get("id")).isEqualTo("test-data");
})
.expectComplete()
.verify();
}
@Test
@SuppressWarnings("unchecked")
void testHandleMessageForMutationWithRequestInputProvided() {
String fakeId = UUID.randomUUID().toString();
Update expected = new Update(fakeId);
StepVerifier verifier = StepVerifier.create(
Flux.from(this.resultChannel)
.map(Message::getPayload)
.cast(RequestOutput.class)
)
.consumeNextWith(result -> {
assertThat(result).isInstanceOf(RequestOutput.class);
Map<String, Object> data = result.getData();
Map<String, Object> update = (Map<String, Object>) data.get("update");
assertThat(update.get("id")).isEqualTo(fakeId);
assertThat(this.updateRepository.current().block()).isEqualTo(expected);
}
)
.thenCancel()
.verifyLater();
RequestInput payload =
new RequestInput("mutation { update(id: \"" + fakeId + "\") { id } }", null, null, null,
UUID.randomUUID().toString());
this.inputChannel.send(MessageBuilder.withPayload(payload).build());
verifier.verify(Duration.ofSeconds(10));
StepVerifier.create(this.updateRepository.current())
.expectNext(expected)
.expectComplete()
.verify();
}
@Test
@SuppressWarnings("unchecked")
void testHandleMessageForSubscriptionWithRequestInputProvided() {
StepVerifier verifier = StepVerifier.create(
Flux.from(this.resultChannel)
.map(Message::getPayload)
.cast(RequestOutput.class)
.mapNotNull(RequestOutput::getData)
.cast(SubscriptionPublisher.class)
.map(Flux::from)
.flatMap(data -> data)
)
.consumeNextWith(requestOutput -> {
Map<String, Object> results = requestOutput.getData();
assertThat(results).containsKey("results");
Map<String, Object> operationResult = (Map<String, Object>) results.get("results");
assertThat(operationResult)
.containsKey("id")
.containsValue("test-data-01");
})
.expectNextCount(9)
.thenCancel()
.verifyLater();
RequestInput payload =
new RequestInput("subscription { results { id } }", null, null, null, UUID.randomUUID().toString());
this.inputChannel.send(MessageBuilder.withPayload(payload).build());
verifier.verify(Duration.ofSeconds(10));
}
@Test
void testHandleMessageWithInvalidPayload() {
this.inputChannel.send(MessageBuilder.withPayload(new Object()).build());
Message<?> errorMessage = errorChannel.receive(10_000);
assertThat(errorMessage).isNotNull()
.isInstanceOf(ErrorMessage.class)
.extracting(Message::getPayload)
.isInstanceOf(MessageHandlingException.class)
.satisfies((ex) -> assertThat((Exception) ex)
.hasMessageContaining(
"'operationExpression' must not be null"));
}
@Controller
static class GraphQlController {
final UpdateRepository updateRepository;
GraphQlController(UpdateRepository updateRepository) {
this.updateRepository = updateRepository;
}
@QueryMapping
public Mono<QueryResult> testQuery() {
return Mono.just(new QueryResult("test-data"));
}
@QueryMapping
public Mono<QueryResult> testQueryById(@Argument String id) {
return Mono.just(new QueryResult("test-data"));
}
@MutationMapping
public Mono<Update> update(@Argument String id) {
return this.updateRepository.save(new Update(id));
}
@SubscriptionMapping
public Flux<QueryResult> results() {
return Flux.just(
new QueryResult("test-data-01"),
new QueryResult("test-data-02"),
new QueryResult("test-data-03"),
new QueryResult("test-data-04"),
new QueryResult("test-data-05"),
new QueryResult("test-data-06"),
new QueryResult("test-data-07"),
new QueryResult("test-data-08"),
new QueryResult("test-data-09"),
new QueryResult("test-data-10")
);
}
}
@Repository
static class UpdateRepository {
private Update current;
Mono<Update> save(Update update) {
this.current = update;
return Mono.justOrEmpty(this.current);
}
Mono<Update> current() {
return Mono.just(this.current);
}
}
@Configuration
@EnableIntegration
static class TestConfig {
@Bean
GraphQlMessageHandler handler(GraphQlService graphQlService) {
return new GraphQlMessageHandler(graphQlService);
}
@Bean
IntegrationFlow graphqlQueryMessageHandlerFlow(GraphQlMessageHandler handler) {
return IntegrationFlows.from(MessageChannels.flux("inputChannel"))
.handle(handler)
.channel(c -> c.flux("resultChannel"))
.get();
}
@Bean
PollableChannel errorChannel() {
return new QueueChannel();
}
@Bean
UpdateRepository updateRepository() {
return new UpdateRepository();
}
@Bean
GraphQlController graphqlQueryController(UpdateRepository updateRepository) {
return new GraphQlController(updateRepository);
}
@Bean
GraphQlService graphQlService(GraphQlSource graphQlSource) {
return new ExecutionGraphQlService(graphQlSource);
}
@Bean
GraphQlSource graphQlSource(AnnotatedControllerConfigurer annotatedDataFetcherConfigurer) {
return GraphQlSource.builder()
.schemaResources(new ClassPathResource("graphql/test-schema.graphqls"))
.configureRuntimeWiring(annotatedDataFetcherConfigurer)
.build();
}
@Bean
AnnotatedControllerConfigurer annotatedDataFetcherConfigurer() {
return new AnnotatedControllerConfigurer();
}
}
record QueryResult(String id) {
}
record Update(String id) {
}
}

View File

@@ -0,0 +1,20 @@
type Query {
testQuery: QueryResult
testQueryById(id: String): QueryResult
}
type Mutation {
update(id: String!): Update!
}
type Subscription {
results: QueryResult
}
type QueryResult {
id: String
}
type Update {
id: String
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout pattern="%d %p [%t] [%c] - %m%n" />
</Console>
</Appenders>
<Loggers>
<Logger name="org.springframework" level="warn"/>
<Logger name="org.springframework.graphql" level="warn"/>
<Logger name="org.springframework.integration.graphql" level="warn"/>
<Root level="warn">
<AppenderRef ref="STDOUT" />
</Root>
</Loggers>
</Configuration>

View File

@@ -0,0 +1,23 @@
[[graphql]]
== GraphQL Support
Spring Integration provides support for GraphQL.
You need to include this dependency into your project:
====
[source, xml, subs="normal", role="primary"]
.Maven
----
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-graphql</artifactId>
<version>{project-version}</version>
</dependency>
----
[source, groovy, subs="normal", role="secondary"]
.Gradle
----
compile "org.springframework.integration:spring-integration-graphql:{project-version}"
----
====

View File

@@ -41,6 +41,8 @@ include::./ftp.adoc[]
include::./gemfire.adoc[]
include::./graphql.adoc[]
include::./http.adoc[]
include::./jdbc.adoc[]

View File

@@ -30,6 +30,7 @@ This documentation is also available as single searchable link:index-single.html
<<./file.adoc#files,File Support>> ::
<<./ftp.adoc#ftp,FTP/FTPS Adapters>> ::
<<./gemfire.adoc#gemfire,Pivotal GemFire and Apache Geode Support>> ::
<<./graphql.adoc#graphql,GraphQL Support>> ::
<<./http.adoc#http,HTTP Support>> ::
<<./jdbc.adoc#jdbc,JDBC Support>> ::
<<./jpa.adoc#jpa,JPA Support>> ::

View File

@@ -17,6 +17,12 @@ In general the project has been moved to Java 17 baseline and migrated from Java
[[x6.0-new-components]]
=== New Components
[[x6.0-graphql]]
=== GraphQL Support
The GraphQL support has been added.
See <<./graphql.adoc#graphql,GraphQL Support>> for more information.
[[x6.0-general]]
=== General Changes