From f09c665db4291854765a536f50e3cfda1ed0369c Mon Sep 17 00:00:00 2001 From: Daniel Frey Date: Tue, 15 Feb 2022 16:27:45 -0500 Subject: [PATCH] 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` --- build.gradle | 9 + .../outbound/GraphQlMessageHandler.java | 174 +++++++++ .../graphql/outbound/package-info.java | 4 + .../outbound/GraphQlMessageHandlerTests.java | 337 ++++++++++++++++++ .../resources/graphql/test-schema.graphqls | 20 ++ .../src/test/resources/log4j2-test.xml | 16 + src/reference/asciidoc/graphql.adoc | 23 ++ src/reference/asciidoc/index-single.adoc | 2 + src/reference/asciidoc/index.adoc | 1 + src/reference/asciidoc/whats-new.adoc | 6 + 10 files changed, 592 insertions(+) create mode 100644 spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandler.java create mode 100644 spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/package-info.java create mode 100644 spring-integration-graphql/src/test/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandlerTests.java create mode 100644 spring-integration-graphql/src/test/resources/graphql/test-schema.graphqls create mode 100644 spring-integration-graphql/src/test/resources/log4j2-test.xml create mode 100644 src/reference/asciidoc/graphql.adoc diff --git a/build.gradle b/build.gradle index 9a3a624941..9ffd1a5615 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { diff --git a/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandler.java b/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandler.java new file mode 100644 index 0000000000..a12d42d045 --- /dev/null +++ b/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandler.java @@ -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.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 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 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); + } + +} diff --git a/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/package-info.java b/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/package-info.java new file mode 100644 index 0000000000..07a6db100a --- /dev/null +++ b/spring-integration-graphql/src/main/java/org/springframework/integration/graphql/outbound/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides classes for GraphQL outbound channel adapters. + */ +package org.springframework.integration.graphql.outbound; diff --git a/spring-integration-graphql/src/test/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandlerTests.java b/spring-integration-graphql/src/test/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandlerTests.java new file mode 100644 index 0000000000..9caffa62e8 --- /dev/null +++ b/spring-integration-graphql/src/test/java/org/springframework/integration/graphql/outbound/GraphQlMessageHandlerTests.java @@ -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 data = result.getData(); + Map testQuery = (Map) 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 resultMono = + (Mono) this.graphQlMessageHandler.handleRequestMessage(new GenericMessage<>(fakeQuery)); + StepVerifier.create(resultMono) + .consumeNextWith(result -> { + assertThat(result).isInstanceOf(RequestOutput.class); + Map data = result.getData(); + Map testQuery = (Map) 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 data = result.getData(); + Map update = (Map) 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 results = requestOutput.getData(); + assertThat(results).containsKey("results"); + + Map operationResult = (Map) 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 testQuery() { + return Mono.just(new QueryResult("test-data")); + } + + @QueryMapping + public Mono testQueryById(@Argument String id) { + return Mono.just(new QueryResult("test-data")); + } + + @MutationMapping + public Mono update(@Argument String id) { + return this.updateRepository.save(new Update(id)); + } + + @SubscriptionMapping + public Flux 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 save(Update update) { + this.current = update; + return Mono.justOrEmpty(this.current); + } + + Mono 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) { + + } + +} diff --git a/spring-integration-graphql/src/test/resources/graphql/test-schema.graphqls b/spring-integration-graphql/src/test/resources/graphql/test-schema.graphqls new file mode 100644 index 0000000000..ba5421d51f --- /dev/null +++ b/spring-integration-graphql/src/test/resources/graphql/test-schema.graphqls @@ -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 +} diff --git a/spring-integration-graphql/src/test/resources/log4j2-test.xml b/spring-integration-graphql/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000000..d00585c819 --- /dev/null +++ b/spring-integration-graphql/src/test/resources/log4j2-test.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/reference/asciidoc/graphql.adoc b/src/reference/asciidoc/graphql.adoc new file mode 100644 index 0000000000..a9befd66ed --- /dev/null +++ b/src/reference/asciidoc/graphql.adoc @@ -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 +---- + + org.springframework.integration + spring-integration-graphql + {project-version} + +---- +[source, groovy, subs="normal", role="secondary"] +.Gradle +---- +compile "org.springframework.integration:spring-integration-graphql:{project-version}" +---- +==== diff --git a/src/reference/asciidoc/index-single.adoc b/src/reference/asciidoc/index-single.adoc index 39f5b67577..65643c562c 100644 --- a/src/reference/asciidoc/index-single.adoc +++ b/src/reference/asciidoc/index-single.adoc @@ -41,6 +41,8 @@ include::./ftp.adoc[] include::./gemfire.adoc[] +include::./graphql.adoc[] + include::./http.adoc[] include::./jdbc.adoc[] diff --git a/src/reference/asciidoc/index.adoc b/src/reference/asciidoc/index.adoc index a40c608a94..b8c3a83432 100644 --- a/src/reference/asciidoc/index.adoc +++ b/src/reference/asciidoc/index.adoc @@ -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>> :: diff --git a/src/reference/asciidoc/whats-new.adoc b/src/reference/asciidoc/whats-new.adoc index 9b428ec20d..b030a4cd67 100644 --- a/src/reference/asciidoc/whats-new.adoc +++ b/src/reference/asciidoc/whats-new.adoc @@ -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