From 5a73e0504044dc484ca579c7bda49d2b3377deb3 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 2 Mar 2022 09:10:33 +0000 Subject: [PATCH] Add transport specific GraphQlClient extensions Add HttpGraphQlClient and WebSocketGraphQlClient extensions along with a builder hierarchy that combines GraphQlClient and transport specific configuration. See gh-10 --- .../AbstractDelegatingGraphQlClient.java | 52 +++++ .../graphql/client/DefaultGraphQlClient.java | 40 +++- .../client/DefaultGraphQlClientBuilder.java | 74 +++++-- .../client/DefaultHttpGraphQlClient.java | 206 ++++++++++++++++++ .../client/DefaultWebSocketGraphQlClient.java | 135 ++++++++++++ .../graphql/client/GraphQlClient.java | 70 +++--- .../graphql/client/HttpGraphQlClient.java | 122 +++++++++++ .../client/WebSocketGraphQlClient.java | 101 +++++++++ .../WebSocketGraphQlClientBuilderTests.java | 103 +++++++++ .../client/WebSocketGraphQlClientTests.java | 135 ++++++++++++ 10 files changed, 975 insertions(+), 63 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlClient.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlClient.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientBuilderTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientTests.java diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java new file mode 100644 index 00000000..527ea0de --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-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.graphql.client; + + +import org.springframework.util.Assert; + +/** + * Base class for extensions of {@link GraphQlClient} that mainly assist with + * building the underlying transport, but otherwise delegate to the default + * {@link GraphQlClient} implementation for actual request execution. + * + *

Subclasses must implement {@link GraphQlClient#mutate()} to allow mutation + * of both {@code GraphQlClient} and {@code GraphQlTransport} configuration. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public abstract class AbstractDelegatingGraphQlClient implements GraphQlClient { + + private final GraphQlClient graphQlClient; + + + protected AbstractDelegatingGraphQlClient(GraphQlClient graphQlClient) { + Assert.notNull(graphQlClient, "GraphQlClient is required"); + this.graphQlClient = graphQlClient; + } + + + public GraphQlClient.RequestSpec document(String document) { + return this.graphQlClient.document(document); + } + + public GraphQlClient.RequestSpec documentName(String name) { + return this.graphQlClient.documentName(name); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java index c04303e5..984c08eb 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java @@ -18,6 +18,7 @@ package org.springframework.graphql.client; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.DocumentContext; @@ -35,7 +36,11 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; /** - * Default implementation of {@link GraphQlClient}. + * Default {@link GraphQlClient} implementation with the logic to initialize + * requests and handle responses, and delegates to a {@link GraphQlTransport} + * for actual request execution. + * + *

This class is final but works with any transport. * * @author Rossen Stoyanchev * @since 1.0.0 @@ -48,9 +53,12 @@ final class DefaultGraphQlClient implements GraphQlClient { private final DocumentSource documentSource; + private final Consumer> builderInitializer; + DefaultGraphQlClient( - GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource) { + GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource, + Consumer> builderInitializer) { Assert.notNull(transport, "GraphQlTransport is required"); Assert.notNull(jsonPathConfig, "Configuration is required"); @@ -59,15 +67,10 @@ final class DefaultGraphQlClient implements GraphQlClient { this.transport = transport; this.jsonPathConfig = jsonPathConfig; this.documentSource = documentSource; + this.builderInitializer = builderInitializer; } - @SuppressWarnings("unchecked") - @Override - public T getTransport(Class requiredType) { - return (requiredType.isInstance(this.transport) ? (T) this.transport : null); - } - @Override public RequestSpec document(String document) { return new DefaultRequestSpec(Mono.just(document), this.transport, this.jsonPathConfig); @@ -79,6 +82,13 @@ final class DefaultGraphQlClient implements GraphQlClient { return new DefaultRequestSpec(document, this.transport, this.jsonPathConfig); } + @Override + public Builder mutate() { + DefaultGraphQlClientBuilder builder = new DefaultGraphQlClientBuilder<>(this.transport); + this.builderInitializer.accept(builder); + return builder; + } + private static final class DefaultRequestSpec implements RequestSpec { @@ -112,6 +122,12 @@ final class DefaultGraphQlClient implements GraphQlClient { return this; } + @Override + public RequestSpec variables(Map variables) { + this.variables.putAll(variables); + return this; + } + @Override public Mono execute() { return getRequestMono() @@ -136,11 +152,14 @@ final class DefaultGraphQlClient implements GraphQlClient { private static class DefaultResponseSpec implements ResponseSpec { + private final ExecutionResult result; + private final DocumentContext jsonPathDocument; private final List errors; private DefaultResponseSpec(ExecutionResult result, Configuration jsonPathConfig) { + this.result = result; this.jsonPathDocument = JsonPath.parse(result.toSpecification(), jsonPathConfig); this.errors = result.getErrors(); } @@ -180,6 +199,11 @@ final class DefaultGraphQlClient implements GraphQlClient { return this.errors; } + @Override + public ExecutionResult andReturn() { + return this.result; + } + } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java index 67ac8e61..36142938 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java @@ -13,25 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.springframework.graphql.client; +import java.util.function.Consumer; + import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.spi.json.JacksonJsonProvider; import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; +import org.springframework.graphql.support.CachingDocumentSource; import org.springframework.graphql.support.DocumentSource; import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; + /** - * Default implementation of {@link GraphQlClient.Builder}. + * Default {@link GraphQlClient.Builder} implementation that builds a + * {@link GraphQlClient} for use with any transport. + * + *

Intended for use as a base class for builders that do assist with building + * the underlying transport. Such extension * * @author Rossen Stoyanchev * @since 1.0.0 */ -class DefaultGraphQlClientBuilder implements GraphQlClient.Builder { +public class DefaultGraphQlClientBuilder> implements GraphQlClient.Builder { private static final boolean jackson2Present; @@ -42,53 +51,70 @@ class DefaultGraphQlClientBuilder implements GraphQlClient.Builder { } - private final GraphQlTransport transport; - @Nullable - private Configuration jsonPathConfig; + private GraphQlTransport transport; @Nullable private DocumentSource documentSource; - + /** + * Constructor with a given transport instance. + */ DefaultGraphQlClientBuilder(GraphQlTransport transport) { Assert.notNull(transport, "GraphQlTransport is required"); this.transport = transport; } + /** + * Constructor for subclass builders that will call + * {@link #transport(GraphQlTransport)} to set the transport instance + * before {@link #build()}. + */ + DefaultGraphQlClientBuilder() { + } - @Override - public GraphQlClient.Builder jsonPathConfig(@Nullable Configuration config) { - this.jsonPathConfig = config; - return this; + protected void transport(GraphQlTransport transport) { + this.transport = transport; } @Override - public GraphQlClient.Builder documentSource(@Nullable DocumentSource contentLoader) { + public B documentSource(@Nullable DocumentSource contentLoader) { this.documentSource = contentLoader; - return this; + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; } @Override public GraphQlClient build() { - return new DefaultGraphQlClient(this.transport, initJsonPathConfig(), initRequestNameResolver()); + Assert.notNull(this.transport, "No GraphQlTransport. Has a subclass not initialized it?"); + return new DefaultGraphQlClient(this.transport, initJsonPathConfig(), initDocumentSource(), getBuilderInitializer()); } private Configuration initJsonPathConfig() { - if (this.jsonPathConfig != null) { - return this.jsonPathConfig; - } - else if (jackson2Present) { - return Jackson2Configuration.create(); - } - else { - return Configuration.builder().build(); - } + // Allow configuring JSONPath with codecs from transport subclasses + return (jackson2Present ? Jackson2Configuration.create() : Configuration.builder().build()); } - private DocumentSource initRequestNameResolver() { + private DocumentSource initDocumentSource() { return (this.documentSource == null ? - new ResourceDocumentSource() : this.documentSource); + new CachingDocumentSource(new ResourceDocumentSource()) : this.documentSource); + } + + /** + * Exposes a {@code Consumer} to subclasses to initialize new builder instances + * from the configuration of "this" builder. + */ + protected Consumer> getBuilderInitializer() { + return builder -> { + if (this.documentSource != null) { + builder.documentSource(documentSource); + } + }; + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java new file mode 100644 index 00000000..13c634ad --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java @@ -0,0 +1,206 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.net.URI; +import java.util.Arrays; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.codec.ClientCodecConfigurer; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Default {@link HttpGraphQlClient} implementation. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +final class DefaultHttpGraphQlClient extends AbstractDelegatingGraphQlClient implements HttpGraphQlClient { + + private final Supplier mutateBuilder; + + + DefaultHttpGraphQlClient(GraphQlClient graphQlClient, Supplier mutateBuilder) { + super(graphQlClient); + this.mutateBuilder = mutateBuilder; + } + + + public Builder mutate() { + return this.mutateBuilder.get(); + } + + + static class BaseBuilder> extends DefaultGraphQlClientBuilder + implements HttpGraphQlClient.BaseBuilder { + + @Nullable + private URI url; + + private final HttpHeaders headers = new HttpHeaders(); + + @Nullable + private Consumer codecConfigurerConsumer; + + + @Override + public B url(@Nullable String url) { + this.url = (url != null ? URI.create(url) : null); + return self(); + } + + @Override + public B url(@Nullable URI url) { + this.url = url; + return self(); + } + + @Override + public B header(String name, String... values) { + Arrays.stream(values).forEach(value -> this.headers.add(name, value)); + return self(); + } + + @Override + public B headers(Consumer headersConsumer) { + headersConsumer.accept(this.headers); + return self(); + } + + @Override + public B codecConfigurer(Consumer codecConsumer) { + this.codecConfigurerConsumer = codecConsumer; + return self(); + } + + @Nullable + protected URI getUrl() { + return this.url; + } + + protected HttpHeaders getHeaders() { + return this.headers; + } + + @Nullable + protected Consumer getCodecConfigurerConsumer() { + return this.codecConfigurerConsumer; + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /** + * Exposes a {@code Consumer} to subclasses to initialize new builder instances + * from the configuration of "this" builder. + */ + protected Consumer> getWebBuilderInitializer() { + Consumer> parentInitializer = getBuilderInitializer(); + HttpHeaders headersCopy = new HttpHeaders(); + headersCopy.putAll(getHeaders()); + return builder -> { + builder.url(getUrl()).headers(headers -> headers.putAll(headersCopy)); + if (getCodecConfigurerConsumer() != null) { + builder.codecConfigurer(getCodecConfigurerConsumer()); + } + parentInitializer.accept(builder); + }; + } + + } + + + /** + * Default {@link HttpGraphQlClient.Builder} implementation. + */ + static final class Builder extends BaseBuilder implements HttpGraphQlClient.Builder { + + @Nullable + private WebClient webClient; + + @Nullable + private Consumer webClientConfigurers; + + + /** + * Constructor to start without a WebClient instance. + */ + Builder() { + } + + /** + * Constructor to start with a pre-configured {@code WebClient}. + */ + Builder(WebClient webClient) { + this.webClient = webClient; + } + + + @Override + public Builder webClient(Consumer configurer) { + this.webClientConfigurers = (this.webClientConfigurers != null ? this.webClientConfigurers.andThen(configurer) : configurer); + return this; + } + + @Override + public HttpGraphQlClient build() { + + WebClient webClient = initWebClient(); + HttpGraphQlTransport transport = new HttpGraphQlTransport(webClient); + transport(transport); + + GraphQlClient graphQlClient = super.build(); + return new DefaultHttpGraphQlClient(graphQlClient, initMutateBuilderFactory(webClient)); + } + + private WebClient initWebClient() { + WebClient.Builder builder = (this.webClient != null ? this.webClient.mutate() : WebClient.builder()); + + if (getUrl() != null) { + builder.baseUrl(getUrl().toASCIIString()); + } + + builder.defaultHeaders(headers -> headers.putAll(getHeaders())); + + if (getCodecConfigurerConsumer() != null) { + builder.codecs(getCodecConfigurerConsumer()); + } + + if (this.webClientConfigurers != null) { + this.webClientConfigurers.accept(builder); + } + + return builder.build(); + } + + private Supplier initMutateBuilderFactory(WebClient webClient) { + Consumer> parentInitializer = getWebBuilderInitializer(); + return () -> { + Builder builder = new Builder(webClient); + parentInitializer.accept(builder); + return builder; + }; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java new file mode 100644 index 00000000..6fa7dff0 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.http.codec.ClientCodecConfigurer; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.reactive.socket.client.WebSocketClient; + + +/** + * Default {@link WebSocketGraphQlClient} implementation. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +final class DefaultWebSocketGraphQlClient extends AbstractDelegatingGraphQlClient implements WebSocketGraphQlClient { + + private final WebSocketGraphQlTransport transport; + + private final Supplier mutateBuilderFactory; + + + DefaultWebSocketGraphQlClient( + GraphQlClient delegate, WebSocketGraphQlTransport transport, Supplier mutateBuilderFactory) { + + super(delegate); + this.transport = transport; + this.mutateBuilderFactory = mutateBuilderFactory; + } + + + @Override + public Mono start() { + return this.transport.start(); + } + + @Override + public Mono stop() { + return this.transport.stop(); + } + + @Override + public Builder mutate() { + return this.mutateBuilderFactory.get(); + } + + + /** + * Default {@link WebSocketGraphQlClient.Builder} implementation. + */ + static final class Builder extends DefaultHttpGraphQlClient.BaseBuilder + implements WebSocketGraphQlClient.Builder { + + private final WebSocketClient webSocketClient; + + @Nullable + private Object initPayload; + + private Consumer> connectionAckHandler = ackPayload -> {}; + + + Builder(WebSocketClient client) { + this.webSocketClient = client; + } + + + @Override + public Builder connectionInitPayload(@Nullable Object connectionInitPayload) { + this.initPayload = connectionInitPayload; + return this; + } + + @Override + public Builder connectionAckHandler(Consumer> ackHandler) { + this.connectionAckHandler = ackHandler; + return this; + } + + @Override + public WebSocketGraphQlClient build() { + Assert.notNull(getUrl(), "GraphQL endpoint URI is required"); + + WebSocketGraphQlTransport transport = new WebSocketGraphQlTransport( + getUrl(), getHeaders(), this.webSocketClient, initClientCodecConfigurer(), + this.initPayload, this.connectionAckHandler); + + transport(transport); + GraphQlClient graphQlClient = super.build(); + + return new DefaultWebSocketGraphQlClient(graphQlClient, transport, mutateBuilderFactory()); + } + + private ClientCodecConfigurer initClientCodecConfigurer() { + ClientCodecConfigurer configurer = ClientCodecConfigurer.create(); + if (getCodecConfigurerConsumer() != null) { + getCodecConfigurerConsumer().accept(configurer); + } + return configurer; + } + + private Supplier mutateBuilderFactory() { + Consumer> parentBuilderInitializer = getWebBuilderInitializer(); + return () -> { + Builder builder = new Builder(this.webSocketClient); + builder.connectionInitPayload(this.initPayload); + builder.connectionAckHandler(this.connectionAckHandler); + parentBuilderInitializer.accept(builder); + return builder; + }; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java index 8935ab0a..1fa668dd 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java @@ -16,8 +16,9 @@ package org.springframework.graphql.client; import java.util.List; +import java.util.Map; -import com.jayway.jsonpath.Configuration; +import graphql.ExecutionResult; import graphql.GraphQLError; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -28,29 +29,22 @@ import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.lang.Nullable; /** - * Defines a workflow to prepare and execute GraphQL requests and to decode and - * handle responses. + * Defines workflow to execute GraphQL requests, independent of the transport. * - *

To create a {@link GraphQlClient}, use the builder in this class, and see - * examples in {@link HttpGraphQlTransport} and {@link WebSocketGraphQlTransport} - * for initializing both the client and the transport it runs over. + *

In most cases, you'll want to use a transport specific extension: + *

+ * + *

Alternatively, use {@link #builder(GraphQlTransport)} to create an instance + * with any other transport. Or create a transport specific extension. * * @author Rossen Stoyanchev * @since 1.0.0 - * @see HttpGraphQlTransport - * @see WebSocketGraphQlTransport */ public interface GraphQlClient { - - /** - * Return the underlying transport or {@code null} if the required type - * does not match the transport type. See {@link GraphQlTransport} - */ - @Nullable - T getTransport(Class requiredType); - - /** * Start defining a GraphQL request with the given document, which is the * textual representation of an operation (or operations) to perform, @@ -68,35 +62,37 @@ public interface GraphQlClient { */ RequestSpec documentName(String name); + /** + * Return a builder initialized from the configuration of "this" client + * to use to build a new, independently configured client instance. + */ + GraphQlClient.Builder mutate(); + /** - * Return a builder to initialize a {@link GraphQlClient} instance. - * @param transport the transport for executing requests over - * @see HttpGraphQlTransport - * @see WebSocketGraphQlTransport + * Create a builder with the given {@code GraphQlTransport}. + *

For GraphQL over HTTP and WebSocket, consider using the extensions + * {@link HttpGraphQlClient} and {@link WebSocketGraphQlClient}. + * This allows plugging in any other transport implementation. + * @param transport the transport to execute requests with + * @return the builder for further initialization */ - static Builder builder(GraphQlTransport transport) { - return new DefaultGraphQlClientBuilder(transport); + static Builder builder(GraphQlTransport transport) { + return new DefaultGraphQlClientBuilder<>(transport); } /** * Defines a builder for creating {@link GraphQlClient} instances. */ - interface Builder { - - /** - * Provide JSONPath configuration settings. - *

By default, the Jackson JSON library is used if present. - */ - Builder jsonPathConfig(@Nullable Configuration config); + interface Builder> { /** * Configure a {@link DocumentSource} for use with * {@link #documentName(String)} for resolving a document by name. *

By default, {@link ResourceDocumentSource} is used. */ - Builder documentSource(@Nullable DocumentSource contentLoader); + B documentSource(@Nullable DocumentSource contentLoader); /** * Build the {@code GraphQlClient} instance. @@ -160,6 +156,13 @@ public interface GraphQlClient { */ RequestSpec variable(String name, Object value); + /** + * Add all given values for variables defined by the operation. + * @param variables the variable values + * @return this request spec + */ + RequestSpec variables(Map variables); + } @@ -220,6 +223,11 @@ public interface GraphQlClient { */ List errors(); + /** + * Return the underlying {@link ExecutionResult} for the response. + */ + ExecutionResult andReturn(); + } } \ No newline at end of file diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlClient.java new file mode 100644 index 00000000..b376bc69 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlClient.java @@ -0,0 +1,122 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.net.URI; +import java.util.function.Consumer; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.codec.ClientCodecConfigurer; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.WebClient; + + +/** + * {@code GraphQlClient} for GraphQL over HTTP via {@link WebClient}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface HttpGraphQlClient extends GraphQlClient { + + + @Override + Builder mutate(); + + + /** + * Create an {@link HttpGraphQlClient} that uses the given {@link WebClient}. + */ + static HttpGraphQlClient create(WebClient webClient) { + return builder(webClient).build(); + } + + /** + * Return a builder to initialize an {@link HttpGraphQlClient} with. + */ + static Builder builder() { + return new DefaultHttpGraphQlClient.Builder(); + } + + /** + * Variant of {@link #builder()} with a pre-configured {@code WebClient} + * which may be mutated and further customized through the returned builder. + */ + static Builder builder(WebClient webClient) { + return new DefaultHttpGraphQlClient.Builder(webClient); + } + + + /** + * Base builder for GraphQL clients over a Web transport. + */ + interface BaseBuilder> extends GraphQlClient.Builder { + + /** + * Set the GraphQL endpoint URL. + * @param url the url to make requests to + */ + B url(@Nullable String url); + + /** + * Set the GraphQL endpoint URL. + * @param url the url to make requests to + */ + B url(@Nullable URI url); + + /** + * Add the given header to HTTP requests to the endpoint URL. + * @param name the header name + * @param values the header values + */ + B header(String name, String... values); + + /** + * Variant of {@link #header(String, String...)} that provides access + * to the underlying headers to inspect or modify directly. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + */ + B headers(Consumer headersConsumer); + + /** + * Provide a {@code Consumer} to customize the {@code ClientCodecConfigurer} + * for JSON encoding and decoding of GraphQL payloads. + */ + B codecConfigurer(Consumer codecsConsumer); + + } + + + /** + * Builder for a GraphQL over HTTP client. + */ + interface Builder> extends BaseBuilder { + + /** + * Customize the {@code WebClient} to use. + */ + B webClient(Consumer webClient); + + /** + * Build the {@code HttpGraphQlClient}. + */ + @Override + HttpGraphQlClient build(); + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlClient.java new file mode 100644 index 00000000..d2e55d83 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlClient.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.net.URI; +import java.util.Map; +import java.util.function.Consumer; + +import reactor.core.publisher.Mono; + +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.socket.client.WebSocketClient; + + +/** + * {@code GraphQlClient} for GraphQL over Web via {@link WebSocketClient}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface WebSocketGraphQlClient extends GraphQlClient { + + /** + * Start the transport by connecting the WebSocket, sending the + * "connection_init" and waiting for the "connection_ack" message. + * @return {@code Mono} that completes when the WebSocket is connected and + * ready to begin sending GraphQL requests + */ + Mono start(); + + /** + * Stop the transport by closing the WebSocket with + * {@link org.springframework.web.reactive.socket.CloseStatus#NORMAL} and + * terminating in-progress requests with an error signal. + *

New requests are rejected from the time of this call. If necessary, + * call {@link #start()} to allow requests again. + * @return {@code Mono} that completes when the underlying session is closed + */ + Mono stop(); + + @Override + Builder mutate(); + + + /** + * Create a {@link WebSocketGraphQlClient} that uses the given + * {@code WebSocketClient} to connect to the given URL. + * @param url the GraphQL endpoint URL + * @param webSocketClient the transport client to use + */ + static WebSocketGraphQlClient create(URI url, WebSocketClient webSocketClient) { + return builder(webSocketClient).url(url).build(); + } + + /** + * Return a builder to initialize a {@link WebSocketGraphQlClient} with. + * @param webSocketClient the transport client to use + */ + static Builder builder(WebSocketClient webSocketClient) { + return new DefaultWebSocketGraphQlClient.Builder(webSocketClient); + } + + + /** + * Builder for a GraphQL over WebSocket client. + */ + interface Builder> extends HttpGraphQlClient.BaseBuilder { + + /** + * The payload to send with the "connection_init" message. + */ + B connectionInitPayload(@Nullable Object connectionInitPayload); + + /** + * Handler for the payload received with the "connection_ack" message. + */ + B connectionAckHandler(Consumer> ackHandler); + + /** + * Build the {@code WebSocketGraphQlClient}. + */ + @Override + WebSocketGraphQlClient build(); + + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientBuilderTests.java new file mode 100644 index 00000000..82c47302 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientBuilderTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; + +import graphql.ExecutionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.graphql.support.MapExecutionResult; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link GraphQlClient.Builder}. + * @author Rossen Stoyanchev + */ +public class WebSocketGraphQlClientBuilderTests { + + private TestWebSocketClient webSocketClient; + + + @BeforeEach + void setUp() { + MockGraphQlWebSocketServer mockServer = new MockGraphQlWebSocketServer(); + this.webSocketClient = new TestWebSocketClient(mockServer); + + ExecutionResult result = MapExecutionResult.forDataOnly(Collections.singletonMap("key1", "value1")); + mockServer.expectOperation("{Query1}").andRespond(result); + } + + + @Test + void mutate() { + + // Original + + URI url = URI.create("/graphql"); + + WebSocketGraphQlClient graphQlClient = WebSocketGraphQlClient.builder(this.webSocketClient) + .url(url) + .header("header1", "value1") + .header("header2", "value2") + .build(); + + graphQlClient.document("{Query1}").execute().block(Duration.ofSeconds(5)); + + assertThat(this.webSocketClient.getConnectionCount()).isEqualTo(1); + assertThat(this.webSocketClient.getConnection(0).getUrl()).isEqualTo(url); + assertThat(this.webSocketClient.getConnection(0).getHeaders()).hasSize(2) + .containsEntry("header1", Collections.singletonList("value1")) + .containsEntry("header2", Collections.singletonList("value2")); + + // Mutate + + URI anotherUrl = URI.create("/another-graphql"); + + WebSocketGraphQlClient anotherClient = graphQlClient.mutate() + .url(anotherUrl) + .headers(headers -> { + headers.set("header1", "anotherValue1"); + headers.set("header2", "anotherValue2"); + }) + .build(); + + anotherClient.document("{Query1}").execute().block(Duration.ofSeconds(5)); + + assertThat(this.webSocketClient.getConnectionCount()).isEqualTo(2); + assertThat(this.webSocketClient.getConnection(1).getUrl()).isEqualTo(anotherUrl); + assertThat(this.webSocketClient.getConnection(1).getHeaders()).hasSize(2) + .containsEntry("header1", Collections.singletonList("anotherValue1")) + .containsEntry("header2", Collections.singletonList("anotherValue2")); + + // Original not affected (stop + start original client, to connect again) + + graphQlClient.stop().block(Duration.ofSeconds(5)); + graphQlClient.start().block(Duration.ofSeconds(5)); + graphQlClient.document("{Query1}").execute().block(); + + assertThat(this.webSocketClient.getConnection(0).getUrl()).isEqualTo(url); + assertThat(this.webSocketClient.getConnection(0).getHeaders()).hasSize(2) + .containsEntry("header1", Collections.singletonList("value1")) + .containsEntry("header2", Collections.singletonList("value2")); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientTests.java new file mode 100644 index 00000000..974e4f44 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/WebSocketGraphQlClientTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-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.graphql.client; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.graphql.Book; +import org.springframework.graphql.BookCriteria; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler; +import org.springframework.http.codec.ClientCodecConfigurer; +import org.springframework.stereotype.Controller; +import org.springframework.web.reactive.socket.WebSocketHandler; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test making requests through {@link WebSocketGraphQlClient} to + * {@link GraphQlWebSocketHandler} via a mock WebSocket connection. + * + * @author Rossen Stoyanchev + */ +public class WebSocketGraphQlClientTests { + + private final TestWebSocketClient webSocketClient = initWebSocketClient(); + + + @Test + void query() { + String document = "{ " + + " booksByCriteria(criteria: {author:\"Orwell\"}) { " + + " id" + + " name" + + " }" + + "}"; + + List books = WebSocketGraphQlClient.create(URI.create("/"), this.webSocketClient) + .document(document) + .execute() + .map(response -> response.toEntityList("booksByCriteria", Book.class)) + .block(Duration.ofSeconds(5)); + + assertThat(books).hasSize(2); + assertThat(books.get(0).getName()).isEqualTo("Nineteen Eighty-Four"); + assertThat(books.get(1).getName()).isEqualTo("Animal Farm"); + } + + @Test + void subscription() { + String document = "subscription { " + + " bookSearch(author:\"Orwell\") { " + + " id" + + " name" + + " }" + + "}"; + + Flux bookFlux = WebSocketGraphQlClient.create(URI.create("/"), this.webSocketClient) + .document(document) + .executeSubscription() + .map(response -> response.toEntity("bookSearch", Book.class)); + + StepVerifier.create(bookFlux) + .consumeNextWith(book -> { + assertThat(book.getId()).isEqualTo(1); + assertThat(book.getName()).isEqualTo("Nineteen Eighty-Four"); + }) + .consumeNextWith(book -> { + assertThat(book.getId()).isEqualTo(5); + assertThat(book.getName()).isEqualTo("Animal Farm"); + }) + .verifyComplete(); + } + + + private TestWebSocketClient initWebSocketClient() { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(BookController.class); + context.refresh(); + + WebGraphQlHandler webGraphQlHandler = GraphQlSetup.schemaResource(BookSource.schema) + .runtimeWiringForAnnotatedControllers(context) + .toWebGraphQlHandler(); + + WebSocketHandler webSocketServerHandler = new GraphQlWebSocketHandler( + webGraphQlHandler, ClientCodecConfigurer.create(), Duration.ofSeconds(5)); + + return new TestWebSocketClient(webSocketServerHandler); + } + + + + @SuppressWarnings("unused") + @Controller + private static class BookController { + + @QueryMapping + public List booksByCriteria(@Argument BookCriteria criteria) { + return BookSource.findBooksByAuthor(criteria.getAuthor()); + } + + @SubscriptionMapping + public Flux bookSearch(@Argument String author) { + return Flux.fromIterable(BookSource.findBooksByAuthor(author)).delayElements(Duration.ofMillis(50)); + } + + } + +}