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
This commit is contained in:
rstoyanchev
2022-03-02 09:10:33 +00:00
parent 328d5c2b3e
commit 5a73e05040
10 changed files with 975 additions and 63 deletions

View File

@@ -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.
*
* <p>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);
}
}

View File

@@ -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.
*
* <p>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<Builder<?>> builderInitializer;
DefaultGraphQlClient(
GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource) {
GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource,
Consumer<Builder<?>> 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 extends GraphQlTransport> T getTransport(Class<T> 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<String, Object> variables) {
this.variables.putAll(variables);
return this;
}
@Override
public Mono<ResponseSpec> 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<GraphQLError> 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;
}
}
}

View File

@@ -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.
*
* <p>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<B extends DefaultGraphQlClientBuilder<B>> implements GraphQlClient.Builder<B> {
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 extends B> 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<GraphQlClient.Builder<?>> getBuilderInitializer() {
return builder -> {
if (this.documentSource != null) {
builder.documentSource(documentSource);
}
};
}

View File

@@ -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<Builder> mutateBuilder;
DefaultHttpGraphQlClient(GraphQlClient graphQlClient, Supplier<Builder> mutateBuilder) {
super(graphQlClient);
this.mutateBuilder = mutateBuilder;
}
public Builder mutate() {
return this.mutateBuilder.get();
}
static class BaseBuilder<B extends BaseBuilder<B>> extends DefaultGraphQlClientBuilder<B>
implements HttpGraphQlClient.BaseBuilder<B> {
@Nullable
private URI url;
private final HttpHeaders headers = new HttpHeaders();
@Nullable
private Consumer<ClientCodecConfigurer> 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<HttpHeaders> headersConsumer) {
headersConsumer.accept(this.headers);
return self();
}
@Override
public B codecConfigurer(Consumer<ClientCodecConfigurer> codecConsumer) {
this.codecConfigurerConsumer = codecConsumer;
return self();
}
@Nullable
protected URI getUrl() {
return this.url;
}
protected HttpHeaders getHeaders() {
return this.headers;
}
@Nullable
protected Consumer<ClientCodecConfigurer> getCodecConfigurerConsumer() {
return this.codecConfigurerConsumer;
}
@SuppressWarnings("unchecked")
private <T extends B> T self() {
return (T) this;
}
/**
* Exposes a {@code Consumer} to subclasses to initialize new builder instances
* from the configuration of "this" builder.
*/
protected Consumer<HttpGraphQlClient.BaseBuilder<?>> getWebBuilderInitializer() {
Consumer<GraphQlClient.Builder<?>> 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<Builder> implements HttpGraphQlClient.Builder<Builder> {
@Nullable
private WebClient webClient;
@Nullable
private Consumer<WebClient.Builder> 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<WebClient.Builder> 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<Builder> initMutateBuilderFactory(WebClient webClient) {
Consumer<HttpGraphQlClient.BaseBuilder<?>> parentInitializer = getWebBuilderInitializer();
return () -> {
Builder builder = new Builder(webClient);
parentInitializer.accept(builder);
return builder;
};
}
}
}

View File

@@ -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<Builder> mutateBuilderFactory;
DefaultWebSocketGraphQlClient(
GraphQlClient delegate, WebSocketGraphQlTransport transport, Supplier<Builder> mutateBuilderFactory) {
super(delegate);
this.transport = transport;
this.mutateBuilderFactory = mutateBuilderFactory;
}
@Override
public Mono<Void> start() {
return this.transport.start();
}
@Override
public Mono<Void> 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<Builder>
implements WebSocketGraphQlClient.Builder<Builder> {
private final WebSocketClient webSocketClient;
@Nullable
private Object initPayload;
private Consumer<Map<String, Object>> 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<Map<String, Object>> 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<Builder> mutateBuilderFactory() {
Consumer<HttpGraphQlClient.BaseBuilder<?>> parentBuilderInitializer = getWebBuilderInitializer();
return () -> {
Builder builder = new Builder(this.webSocketClient);
builder.connectionInitPayload(this.initPayload);
builder.connectionAckHandler(this.connectionAckHandler);
parentBuilderInitializer.accept(builder);
return builder;
};
}
}
}

View File

@@ -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.
*
* <p>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.
* <p>In most cases, you'll want to use a transport specific extension:
* <ul>
* <li>{@link HttpGraphQlClient}
* <li>{@link WebSocketGraphQlClient}
* </ul>
*
* <p>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 extends GraphQlTransport> T getTransport(Class<T> 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}.
* <p>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.
* <p>By default, the Jackson JSON library is used if present.
*/
Builder jsonPathConfig(@Nullable Configuration config);
interface Builder<B extends Builder<B>> {
/**
* Configure a {@link DocumentSource} for use with
* {@link #documentName(String)} for resolving a document by name.
* <p>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<String, Object> variables);
}
@@ -220,6 +223,11 @@ public interface GraphQlClient {
*/
List<GraphQLError> errors();
/**
* Return the underlying {@link ExecutionResult} for the response.
*/
ExecutionResult andReturn();
}
}

View File

@@ -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<B extends BaseBuilder<B>> extends GraphQlClient.Builder<B> {
/**
* 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<HttpHeaders> headersConsumer);
/**
* Provide a {@code Consumer} to customize the {@code ClientCodecConfigurer}
* for JSON encoding and decoding of GraphQL payloads.
*/
B codecConfigurer(Consumer<ClientCodecConfigurer> codecsConsumer);
}
/**
* Builder for a GraphQL over HTTP client.
*/
interface Builder<B extends Builder<B>> extends BaseBuilder<B> {
/**
* Customize the {@code WebClient} to use.
*/
B webClient(Consumer<WebClient.Builder> webClient);
/**
* Build the {@code HttpGraphQlClient}.
*/
@Override
HttpGraphQlClient build();
}
}

View File

@@ -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<Void> 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.
* <p>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<Void> 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<B extends Builder<B>> extends HttpGraphQlClient.BaseBuilder<B> {
/**
* 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<Map<String, Object>> ackHandler);
/**
* Build the {@code WebSocketGraphQlClient}.
*/
@Override
WebSocketGraphQlClient build();
}
}

View File

@@ -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"));
}
}

View File

@@ -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<Book> 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<Book> 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<Book> booksByCriteria(@Argument BookCriteria criteria) {
return BookSource.findBooksByAuthor(criteria.getAuthor());
}
@SubscriptionMapping
public Flux<Book> bookSearch(@Argument String author) {
return Flux.fromIterable(BookSource.findBooksByAuthor(author)).delayElements(Duration.ofMillis(50));
}
}
}