Introduce blocking execution in GraphQlClient

See gh-771
This commit is contained in:
rstoyanchev
2024-01-25 16:27:18 +00:00
parent 117445b962
commit a38fac0bb1
11 changed files with 911 additions and 276 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 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.
@@ -16,6 +16,7 @@
package org.springframework.graphql.client;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -66,6 +67,9 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
@Nullable
private Decoder<?> jsonDecoder;
@Nullable
private Duration blockingTimeout;
/**
* Default constructor for use from subclasses.
@@ -82,7 +86,6 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
ResourceDocumentSource.FILE_EXTENSIONS));
}
@Override
public B interceptor(GraphQlClientInterceptor... interceptors) {
this.interceptors.addAll(Arrays.asList(interceptors));
@@ -101,6 +104,12 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
return self();
}
@Override
public B blockingTimeout(@Nullable Duration blockingTimeout) {
this.blockingTimeout = blockingTimeout;
return self();
}
@SuppressWarnings("unchecked")
private <T extends B> T self() {
return (T) this;
@@ -169,8 +178,8 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
this.jsonDecoder = (this.jsonDecoder == null ? DefaultJackson2Codecs.decoder() : this.jsonDecoder);
}
return new DefaultGraphQlClient(
this.documentSource, createExecuteChain(transport), createExecuteSubscriptionChain(transport));
return new DefaultGraphQlClient(this.documentSource,
createExecuteChain(transport), createSubscriptionChain(transport), this.blockingTimeout);
}
/**
@@ -186,23 +195,25 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
private Chain createExecuteChain(GraphQlTransport transport) {
Chain chain = request -> transport.execute(request).map(response ->
new DefaultClientGraphQlResponse(request, response, getEncoder(), getDecoder()));
return this.interceptors.stream()
.reduce(GraphQlClientInterceptor::andThen)
.map(interceptor -> (Chain) (request) -> interceptor.intercept(request, chain))
.orElse(chain);
}
private SubscriptionChain createExecuteSubscriptionChain(GraphQlTransport transport) {
SubscriptionChain chain = request -> transport.executeSubscription(request)
Chain chain = request -> transport
.execute(request)
.map(response -> new DefaultClientGraphQlResponse(request, response, getEncoder(), getDecoder()));
return this.interceptors.stream()
.reduce(GraphQlClientInterceptor::andThen)
.map(interceptor -> (SubscriptionChain) (request) -> interceptor.interceptSubscription(request, chain))
.map(i -> (Chain) (request) -> i.intercept(request, chain))
.orElse(chain);
}
private SubscriptionChain createSubscriptionChain(GraphQlTransport transport) {
SubscriptionChain chain = request -> transport
.executeSubscription(request)
.map(response -> new DefaultClientGraphQlResponse(request, response, getEncoder(), getDecoder()));
return this.interceptors.stream()
.reduce(GraphQlClientInterceptor::andThen)
.map(i -> (SubscriptionChain) (request) -> i.interceptSubscription(request, chain))
.orElse(chain);
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright 2002-2024 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.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import org.springframework.core.codec.Decoder;
import org.springframework.core.codec.Encoder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.graphql.client.SyncGraphQlClientInterceptor.Chain;
import org.springframework.graphql.support.CachingDocumentSource;
import org.springframework.graphql.support.DocumentSource;
import org.springframework.graphql.support.ResourceDocumentSource;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
/**
* Abstract, base class for transport specific {@link GraphQlClient.SyncBuilder}
* implementations.
*
* <p>Subclasses must implement {@link #build()} and call
* {@link #buildGraphQlClient(SyncGraphQlTransport)} to obtain a default, transport
* agnostic {@code GraphQlClient}. A transport specific extension can then wrap
* this default tester by extending {@link AbstractDelegatingGraphQlClient}.
*
* @author Rossen Stoyanchev
* @since 1.3
* @see AbstractDelegatingGraphQlClient
*/
public abstract class AbstractGraphQlClientSyncBuilder<B extends AbstractGraphQlClientSyncBuilder<B>>
implements GraphQlClient.SyncBuilder<B> {
protected static final boolean jackson2Present = ClassUtils.isPresent(
"com.fasterxml.jackson.databind.ObjectMapper", AbstractGraphQlClientSyncBuilder.class.getClassLoader());
private final List<SyncGraphQlClientInterceptor> interceptors = new ArrayList<>();
private DocumentSource documentSource;
@Nullable
private HttpMessageConverter<Object> jsonConverter;
private Scheduler scheduler = Schedulers.boundedElastic();
@Nullable
private Duration blockingTimeout;
/**
* Default constructor for use from subclasses.
* <p>Subclasses must set the transport to use before {@link #build()} or
* during, by overriding {@link #build()}.
*/
protected AbstractGraphQlClientSyncBuilder() {
this.documentSource = initDocumentSource();
}
private static DocumentSource initDocumentSource() {
return new CachingDocumentSource(new ResourceDocumentSource(
Collections.singletonList(new ClassPathResource("graphql-documents/")),
ResourceDocumentSource.FILE_EXTENSIONS));
}
@Override
public B interceptor(SyncGraphQlClientInterceptor... interceptors) {
Collections.addAll(this.interceptors, interceptors);
return self();
}
@Override
public B interceptors(Consumer<List<SyncGraphQlClientInterceptor>> interceptorsConsumer) {
interceptorsConsumer.accept(this.interceptors);
return self();
}
@Override
public B documentSource(DocumentSource contentLoader) {
this.documentSource = contentLoader;
return self();
}
@Override
public B scheduler(Scheduler scheduler) {
Assert.notNull(scheduler, "Scheduler is required");
this.scheduler = scheduler;
return self();
}
@Override
public B blockingTimeout(@Nullable Duration blockingTimeout) {
this.blockingTimeout = blockingTimeout;
return self();
}
@SuppressWarnings("unchecked")
private <T extends B> T self() {
return (T) this;
}
// Protected methods for use from build() in subclasses
/**
* Transport-specific subclasses can provide their JSON {@code Encoder} and
* {@code Decoder} for use at the client level, for mapping response data
* to some target entity type.
*/
protected void setJsonConverter(HttpMessageConverter<Object> converter) {
this.jsonConverter = converter;
}
/**
* Build the default transport-agnostic client that subclasses can then wrap
* with {@link AbstractDelegatingGraphQlClient}.
*/
protected GraphQlClient buildGraphQlClient(SyncGraphQlTransport transport) {
if (jackson2Present) {
this.jsonConverter = (this.jsonConverter == null ?
DefaultJacksonConverter.initialize() : this.jsonConverter);
}
return new DefaultGraphQlClient(
this.documentSource, createExecuteChain(transport), this.scheduler, this.blockingTimeout);
}
/**
* Return a {@code Consumer} to initialize new builders from "this" builder.
*/
protected Consumer<AbstractGraphQlClientSyncBuilder<?>> getBuilderInitializer() {
return builder -> {
builder.interceptors(interceptorList -> interceptorList.addAll(interceptors));
builder.documentSource(documentSource);
builder.setJsonConverter(getJsonConverter());
};
}
private Chain createExecuteChain(SyncGraphQlTransport transport) {
Encoder<?> encoder = HttpMessageConverterDelegate.asEncoder(getJsonConverter());
Decoder<?> decoder = HttpMessageConverterDelegate.asDecoder(getJsonConverter());
Chain chain = request -> {
GraphQlResponse response = transport.execute(request);
return new DefaultClientGraphQlResponse(request, response, encoder, decoder);
};
return this.interceptors.stream()
.reduce(SyncGraphQlClientInterceptor::andThen)
.map(i -> (Chain) (request) -> i.intercept(request, chain))
.orElse(chain);
}
private HttpMessageConverter<Object> getJsonConverter() {
Assert.notNull(this.jsonConverter, "jsonConverter has not been set");
return this.jsonConverter;
}
private static class DefaultJacksonConverter {
static HttpMessageConverter<Object> initialize() {
return new MappingJackson2HttpMessageConverter();
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 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.
@@ -16,6 +16,7 @@
package org.springframework.graphql.client;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@@ -24,6 +25,7 @@ import java.util.function.Consumer;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.graphql.support.DocumentSource;
@@ -40,22 +42,60 @@ final class DefaultGraphQlClient implements GraphQlClient {
private final DocumentSource documentSource;
private final GraphQlClientInterceptor.Chain executeChain;
private final SyncGraphQlClientInterceptor.Chain blockingChain;
private final GraphQlClientInterceptor.Chain nonBlockingChain;
private final GraphQlClientInterceptor.SubscriptionChain executeSubscriptionChain;
@Nullable
private final Duration blockingTimeout;
DefaultGraphQlClient(
DocumentSource documentSource, GraphQlClientInterceptor.Chain executeChain,
GraphQlClientInterceptor.SubscriptionChain executeSubscriptionChain) {
DocumentSource documentSource, SyncGraphQlClientInterceptor.Chain blockingChain,
Scheduler scheduler, @Nullable Duration blockingTimeout) {
Assert.notNull(documentSource, "DocumentSource is required");
Assert.notNull(executeChain, "GraphQlClientInterceptor.Chain is required");
Assert.notNull(executeSubscriptionChain, "GraphQlClientInterceptor.SubscriptionChain is required");
Assert.notNull(blockingChain, "Execution chain is required");
Assert.notNull(scheduler, "Scheduler is required");
this.documentSource = documentSource;
this.executeChain = executeChain;
this.executeSubscriptionChain = executeSubscriptionChain;
this.blockingChain = blockingChain;
this.nonBlockingChain = adaptToNonBlockingChain(blockingChain, scheduler);
this.executeSubscriptionChain = request -> Flux.error(new IllegalStateException("Subscriptions on supported"));
this.blockingTimeout = blockingTimeout;
}
DefaultGraphQlClient(
DocumentSource documentSource,
GraphQlClientInterceptor.Chain nonBlockingChain,
GraphQlClientInterceptor.SubscriptionChain subscriptionChain,
@Nullable Duration blockingTimeout) {
Assert.notNull(documentSource, "DocumentSource is required");
Assert.notNull(nonBlockingChain, "Execution chain is required");
Assert.notNull(subscriptionChain, "Subscription execution chain is required");
this.documentSource = documentSource;
this.blockingChain = adaptToBlockingChain(nonBlockingChain, blockingTimeout);
this.nonBlockingChain = nonBlockingChain;
this.executeSubscriptionChain = subscriptionChain;
this.blockingTimeout = blockingTimeout;
}
private static GraphQlClientInterceptor.Chain adaptToNonBlockingChain(
SyncGraphQlClientInterceptor.Chain blockingChain, Scheduler scheduler) {
return request -> Mono.fromCallable(() -> blockingChain.next(request)).subscribeOn(scheduler);
}
@SuppressWarnings("DataFlowIssue")
private static SyncGraphQlClientInterceptor.Chain adaptToBlockingChain(
GraphQlClientInterceptor.Chain executeChain, @Nullable Duration blockingTimeout) {
return (request -> blockingTimeout != null ?
executeChain.next(request).block(blockingTimeout) : executeChain.next(request).block());
}
@@ -120,7 +160,7 @@ final class DefaultGraphQlClient implements GraphQlClient {
}
@Override
public RequestSpec extension(String name, Object value) {
public RequestSpec extension(String name, @Nullable Object value) {
this.extensions.put(name, value);
return this;
}
@@ -143,6 +183,12 @@ final class DefaultGraphQlClient implements GraphQlClient {
return this;
}
@Override
public RetrieveSyncSpec retrieveSync(String path) {
ClientGraphQlResponse response = executeSync();
return new DefaultRetrieveSyncSpec(response, path);
}
@Override
public RetrieveSpec retrieve(String path) {
return new DefaultRetrieveSpec(execute(), path);
@@ -153,9 +199,17 @@ final class DefaultGraphQlClient implements GraphQlClient {
return new DefaultRetrieveSubscriptionSpec(executeSubscription(), path);
}
@SuppressWarnings("DataFlowIssue")
@Override
public ClientGraphQlResponse executeSync() {
Mono<ClientGraphQlRequest> mono = initRequest();
ClientGraphQlRequest request = (blockingTimeout != null ? mono.block(blockingTimeout) : mono.block());
return blockingChain.next(request);
}
@Override
public Mono<ClientGraphQlResponse> execute() {
return initRequest().flatMap(request -> executeChain.next(request)
return initRequest().flatMap(request -> nonBlockingChain.next(request)
.onErrorResume(
ex -> !(ex instanceof GraphQlClientException),
ex -> Mono.error(new GraphQlTransportException(ex, request))));
@@ -170,8 +224,8 @@ final class DefaultGraphQlClient implements GraphQlClient {
}
private Mono<ClientGraphQlRequest> initRequest() {
return this.documentMono.map(document ->
new DefaultClientGraphQlRequest(document, this.operationName, this.variables, this.extensions, this.attributes));
return this.documentMono.map(document -> new DefaultClientGraphQlRequest(
document, this.operationName, this.variables, this.extensions, this.attributes));
}
}
@@ -204,6 +258,42 @@ final class DefaultGraphQlClient implements GraphQlClient {
}
private static class DefaultRetrieveSyncSpec extends RetrieveSpecSupport implements RetrieveSyncSpec {
private final ClientGraphQlResponse response;
DefaultRetrieveSyncSpec(ClientGraphQlResponse response, String path) {
super(path);
this.response = response;
}
@Override
public <D> D toEntity(Class<D> entityType) {
ClientResponseField field = getValidField(this.response);
return (field != null ? field.toEntity(entityType) : null);
}
@Override
public <D> D toEntity(ParameterizedTypeReference<D> entityType) {
ClientResponseField field = getValidField(this.response);
return (field != null ? field.toEntity(entityType) : null);
}
@Override
public <D> List<D> toEntityList(Class<D> elementType) {
ClientResponseField field = getValidField(this.response);
return (field != null ? field.toEntityList(elementType) : Collections.emptyList());
}
@Override
public <D> List<D> toEntityList(ParameterizedTypeReference<D> elementType) {
ClientResponseField field = getValidField(this.response);
return (field != null ? field.toEntityList(elementType) : Collections.emptyList());
}
}
private static class DefaultRetrieveSpec extends RetrieveSpecSupport implements RetrieveSpec {
private final Mono<ClientGraphQlResponse> responseMono;

View File

@@ -21,10 +21,7 @@ import java.util.List;
import java.util.function.Consumer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.codec.ClientCodecConfigurer;
import org.springframework.http.codec.CodecConfigurer;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.DefaultUriBuilderFactory;
@@ -33,124 +30,107 @@ import org.springframework.web.util.UriComponentsBuilder;
/**
* Default {@link RestClientGraphQlClient.Builder} implementation, a simple wrapper
* Default {@link HttpSyncGraphQlClient.Builder} implementation, a simple wrapper
* around a {@link RestClient.Builder}.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
final class DefaultRestClientGraphQlClientBuilder
extends AbstractGraphQlClientBuilder<DefaultRestClientGraphQlClientBuilder>
implements RestClientGraphQlClient.Builder<DefaultRestClientGraphQlClientBuilder> {
final class DefaultSyncHttpGraphQlClientBuilder
extends AbstractGraphQlClientSyncBuilder<DefaultSyncHttpGraphQlClientBuilder>
implements HttpSyncGraphQlClient.Builder<DefaultSyncHttpGraphQlClientBuilder> {
private final RestClient.Builder restClientBuilder;
@Nullable
private CodecConfigurer codecConfigurer;
/**
* Constructor to start without a RestClient instance.
*/
DefaultRestClientGraphQlClientBuilder() {
DefaultSyncHttpGraphQlClientBuilder() {
this(RestClient.builder());
}
/**
* Constructor to start with a pre-configured {@code RestClient}.
*/
DefaultRestClientGraphQlClientBuilder(RestClient client) {
DefaultSyncHttpGraphQlClientBuilder(RestClient client) {
this(client.mutate());
}
/**
* Constructor to start with a pre-configured {@code RestClient}.
*/
DefaultRestClientGraphQlClientBuilder(RestClient.Builder clientBuilder) {
DefaultSyncHttpGraphQlClientBuilder(RestClient.Builder clientBuilder) {
this.restClientBuilder = clientBuilder;
}
@Override
public DefaultRestClientGraphQlClientBuilder url(String url) {
public DefaultSyncHttpGraphQlClientBuilder url(String url) {
this.restClientBuilder.baseUrl(url);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder url(URI url) {
public DefaultSyncHttpGraphQlClientBuilder url(URI url) {
UriBuilderFactory factory = new DefaultUriBuilderFactory(UriComponentsBuilder.fromUri(url));
this.restClientBuilder.uriBuilderFactory(factory);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder header(String name, String... values) {
public DefaultSyncHttpGraphQlClientBuilder header(String name, String... values) {
this.restClientBuilder.defaultHeader(name, values);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder headers(Consumer<HttpHeaders> headersConsumer) {
public DefaultSyncHttpGraphQlClientBuilder headers(Consumer<HttpHeaders> headersConsumer) {
this.restClientBuilder.defaultHeaders(headersConsumer);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder codecConfigurer(Consumer<CodecConfigurer> codecConsumer) {
if (this.codecConfigurer == null) {
this.codecConfigurer = ClientCodecConfigurer.create();
}
codecConsumer.accept(this.codecConfigurer);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder messageConverters(Consumer<List<HttpMessageConverter<?>>> configurer) {
public DefaultSyncHttpGraphQlClientBuilder messageConverters(Consumer<List<HttpMessageConverter<?>>> configurer) {
this.restClientBuilder.messageConverters(configurer);
return this;
}
@Override
public DefaultRestClientGraphQlClientBuilder restClient(Consumer<RestClient.Builder> configurer) {
public DefaultSyncHttpGraphQlClientBuilder restClient(Consumer<RestClient.Builder> configurer) {
configurer.accept(this.restClientBuilder);
return this;
}
@Override
public RestClientGraphQlClient build() {
public HttpSyncGraphQlClient build() {
// Pass the codecs to the parent for response decoding
if (this.codecConfigurer != null) {
setJsonEncoder(CodecDelegate.findJsonEncoder(this.codecConfigurer));
setJsonDecoder(CodecDelegate.findJsonDecoder(this.codecConfigurer));
}
else {
this.restClientBuilder.messageConverters(converters -> {
setJsonEncoder(HttpMessageConverterDelegate.getJsonEncoder(converters));
setJsonDecoder(HttpMessageConverterDelegate.getJsonDecoder(converters));
});
}
this.restClientBuilder.messageConverters(converters -> {
HttpMessageConverter<Object> converter = HttpMessageConverterDelegate.findJsonConverter(converters);
setJsonConverter(converter);
});
RestClient restClient = this.restClientBuilder.build();
HttpSyncGraphQlTransport syncTransport = new HttpSyncGraphQlTransport(restClient);
GraphQlClient graphQlClient = super.buildGraphQlClient(new RestClientGraphQlTransport(restClient, null));
return new DefaultRestClientGraphQlClient(graphQlClient, restClient, getBuilderInitializer());
GraphQlClient graphQlClient = super.buildGraphQlClient(syncTransport);
return new DefaultHttpSyncGraphQlClient(graphQlClient, restClient, getBuilderInitializer());
}
/**
* Default {@link HttpGraphQlClient} implementation.
*/
private static class DefaultRestClientGraphQlClient
extends AbstractDelegatingGraphQlClient implements RestClientGraphQlClient {
private static class DefaultHttpSyncGraphQlClient
extends AbstractDelegatingGraphQlClient implements HttpSyncGraphQlClient {
private final RestClient restClient;
private final Consumer<AbstractGraphQlClientBuilder<?>> builderInitializer;
private final Consumer<AbstractGraphQlClientSyncBuilder<?>> builderInitializer;
DefaultRestClientGraphQlClient(
DefaultHttpSyncGraphQlClient(
GraphQlClient delegate, RestClient restClient,
Consumer<AbstractGraphQlClientBuilder<?>> builderInitializer) {
Consumer<AbstractGraphQlClientSyncBuilder<?>> builderInitializer) {
super(delegate);
@@ -161,8 +141,8 @@ final class DefaultRestClientGraphQlClientBuilder
this.builderInitializer = builderInitializer;
}
public DefaultRestClientGraphQlClientBuilder mutate() {
DefaultRestClientGraphQlClientBuilder builder = new DefaultRestClientGraphQlClientBuilder(this.restClient);
public DefaultSyncHttpGraphQlClientBuilder mutate() {
DefaultSyncHttpGraphQlClientBuilder builder = new DefaultSyncHttpGraphQlClientBuilder(this.restClient);
this.builderInitializer.accept(builder);
return builder;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 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.
@@ -15,12 +15,15 @@
*/
package org.springframework.graphql.client;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.graphql.GraphQlResponse;
@@ -69,7 +72,7 @@ public interface GraphQlClient {
* Return a builder initialized from the configuration of "this" client
* to use to build a new, independently configured client instance.
*/
GraphQlClient.Builder<?> mutate();
BaseBuilder<?> mutate();
/**
@@ -86,9 +89,77 @@ public interface GraphQlClient {
/**
* Defines a builder for creating {@link GraphQlClient} instances.
* Base builder to create a {@link GraphQlClient}.
* @since 1.3
*/
interface Builder<B extends Builder<B>> {
interface BaseBuilder<B extends BaseBuilder<B>> {
/**
* Configure a {@link DocumentSource} for use with
* {@link #documentName(String)} for resolving a document by name.
* <p>By default, this is set to {@link ResourceDocumentSource} with
* classpath location {@code "graphql-documents/"} and
* {@link ResourceDocumentSource#FILE_EXTENSIONS} as extensions.
*/
B documentSource(DocumentSource contentLoader);
/**
* Configure a timeout to use for blocking execution.
* <p>By default this is not set, in which case the behavior depends on
* connection and request timeout settings of the underlying transport.
* We recommend configuring timeout values directly on the underlying
* transport, which provides more control over such settings.
* @param blockingTimeout the timeout to use
*/
B blockingTimeout(@Nullable Duration blockingTimeout);
/**
* Build the {@code GraphQlClient} instance.
*/
GraphQlClient build();
}
/**
* Builder to create a {@link GraphQlClient} instance with a
* synchronous transport and interceptors.
* @since 1.3
* @see SyncGraphQlTransport
*/
interface SyncBuilder<B extends SyncBuilder<B>> extends BaseBuilder<B> {
/**
* Configure interceptors to be invoked before delegating to the
* {@link SyncGraphQlTransport} to perform the request.
* @param interceptors the interceptors to add
* @return this builder
*/
B interceptor(SyncGraphQlClientInterceptor... interceptors);
/**
* Customize the list of interceptors. The provided list is "live", so
* the consumer can inspect and insert interceptors accordingly.
* @param interceptorsConsumer consumer to customize the interceptors with
* @return this builder
*/
B interceptors(Consumer<List<SyncGraphQlClientInterceptor>> interceptorsConsumer);
/**
* The scheduler to use for non-blocking execution with
* {@link RequestSpec#execute()} and {@link RequestSpec#retrieve(String)}.
* <p>By default this is set to {@link Schedulers#boundedElastic()}.
* @param scheduler the scheduler
*/
B scheduler(Scheduler scheduler);
}
/**
* Builder to create {@link GraphQlClient} instances with a non-blocking
* {@link GraphQlTransport} and interceptors.
*/
interface Builder<B extends Builder<B>> extends BaseBuilder<B> {
/**
* Configure interceptors to be invoked before delegating to the
@@ -106,20 +177,6 @@ public interface GraphQlClient {
*/
B interceptors(Consumer<List<GraphQlClientInterceptor>> interceptorsConsumer);
/**
* Configure a {@link DocumentSource} for use with
* {@link #documentName(String)} for resolving a document by name.
* <p>By default, this is set to {@link ResourceDocumentSource} with
* classpath location {@code "graphql-documents/"} and
* {@link ResourceDocumentSource#FILE_EXTENSIONS} as extensions.
*/
B documentSource(DocumentSource contentLoader);
/**
* Build the {@code GraphQlClient} instance.
*/
GraphQlClient build();
}
@@ -184,6 +241,19 @@ public interface GraphQlClient {
*/
RequestSpec attributes(Consumer<Map<String, Object>> attributesConsumer);
/**
* Shortcut for {@link #execute()} with a field path to decode from.
* <p>If you want to decode the full data instead, use {@link #execute()}:
* <pre>
* client.document("..").execute().map(response -> response.toEntity(..))
* </pre>
* @return a spec with decoding options
* @throws FieldAccessException if the field has any field errors,
* including errors at, above or below the field path.
* @since 1.3
*/
RetrieveSyncSpec retrieveSync(String path);
/**
* Shortcut for {@link #execute()} with a field path to decode from.
* <p>If you want to decode the full data instead, use {@link #execute()}:
@@ -207,12 +277,22 @@ public interface GraphQlClient {
*/
RetrieveSubscriptionSpec retrieveSubscription(String path);
/**
* Execute request with a single response, e.g. "query" or "mutation", and
* return a response for further options.
* @return a {@code ClientGraphQlResponse} for further decoding of the response.
* @throws GraphQlTransportException in case of errors due to transport or
* other issues related to encoding and decoding the request and response.
* @since 1.3
*/
ClientGraphQlResponse executeSync();
/**
* Execute request with a single response, e.g. "query" or "mutation", and
* return a response for further options.
* @return a {@code Mono} with a {@code ClientGraphQlResponse} for further
* decoding of the response. The {@code Mono} may end wth an error due
* to transport level issues.
* decoding of the response. The {@code Mono} may end with a
* .
*/
Mono<ClientGraphQlResponse> execute();
@@ -235,6 +315,44 @@ public interface GraphQlClient {
}
/**
* Declares options to decode a field for a single response operation.
*/
interface RetrieveSyncSpec {
/**
* Decode the field to an entity of the given type.
* @param entityType the type to convert to
* @return {@code Mono} with the decoded entity; completes with
* {@link FieldAccessException} in case of {@link ResponseField field
* errors} or an {@link GraphQlResponse#isValid() invalid} response;
* completes empty if the field is {@code null} but has no errors.
* @see ResponseField#getErrors()
*/
@Nullable
<D> D toEntity(Class<D> entityType);
/**
* Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}.
*/
@Nullable
<D> D toEntity(ParameterizedTypeReference<D> entityType);
/**
* Variant of {@link #toEntity(Class)} to decode to a List of entities.
* @param elementType the type of elements in the list
*/
<D> List<D> toEntityList(Class<D> elementType);
/**
* Variant of {@link #toEntity(Class)} to decode to a List of entities.
* @param elementType the type of elements in the list
*/
<D> List<D> toEntityList(ParameterizedTypeReference<D> elementType);
}
/**
* Declares options to decode a field for a single response operation.
*/

View File

@@ -48,26 +48,18 @@ import org.springframework.util.MimeType;
/**
* Helper class to adapt JSON {@link HttpMessageConverter} to
* {@link Encoder} and {@link Decoder}.
* {@link DefaultClientGraphQlResponse} uses {@link Encoder} and {@link Decoder}
* to encode the response map to JSON and then encode it into higher level
* objects. This delegate helps with finding an {@link HttpMessageConverter}
* for JSON and adapt it to {@link Encoder} and {@link Decoder}.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
final class HttpMessageConverterDelegate {
static Encoder<?> getJsonEncoder(List<HttpMessageConverter<?>> converters) {
HttpMessageConverter<Object> converter = findJsonConverter(converters);
return new HttpMessageConverterEncoder(converter);
}
static Decoder<?> getJsonDecoder(List<HttpMessageConverter<?>> converters) {
HttpMessageConverter<Object> converter = findJsonConverter(converters);
return new HttpMessageConverterDecoder(converter);
}
@SuppressWarnings("unchecked")
private static HttpMessageConverter<Object> findJsonConverter(List<HttpMessageConverter<?>> converters) {
static HttpMessageConverter<Object> findJsonConverter(List<HttpMessageConverter<?>> converters) {
return (HttpMessageConverter<Object>) converters.stream()
.filter(converter -> converter.canRead(Map.class, MediaType.APPLICATION_JSON))
.findFirst()
@@ -82,6 +74,14 @@ final class HttpMessageConverterDelegate {
return (mimeType != null ? new MediaType(mimeType) : null);
}
static HttpMessageConverterEncoder asEncoder(HttpMessageConverter<Object> converter) {
return new HttpMessageConverterEncoder(converter);
}
static HttpMessageConverterDecoder asDecoder(HttpMessageConverter<Object> converter) {
return new HttpMessageConverterDecoder(converter);
}
private static class HttpMessageConverterEncoder implements Encoder<Object> {

View File

@@ -16,9 +16,11 @@
package org.springframework.graphql.client;
import java.net.URI;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.client.RestClient;
@@ -29,7 +31,7 @@ import org.springframework.web.client.RestClient;
* @author Rossen Stoyanchev
* @since 1.3
*/
public interface RestClientGraphQlClient extends WebGraphQlClient {
public interface HttpSyncGraphQlClient extends GraphQlClient {
@Override
@@ -37,17 +39,17 @@ public interface RestClientGraphQlClient extends WebGraphQlClient {
/**
* Create an {@link RestClientGraphQlClient} that uses the given {@link RestClient}.
* Create an {@link HttpSyncGraphQlClient} that uses the given {@link RestClient}.
*/
static RestClientGraphQlClient create(RestClient client) {
static HttpSyncGraphQlClient create(RestClient client) {
return builder(client.mutate()).build();
}
/**
* Return a builder to initialize an {@link RestClientGraphQlClient} with.
* Return a builder to initialize an {@link HttpSyncGraphQlClient} with.
*/
static Builder<?> builder() {
return new DefaultRestClientGraphQlClientBuilder();
return new DefaultSyncHttpGraphQlClientBuilder();
}
/**
@@ -63,14 +65,40 @@ public interface RestClientGraphQlClient extends WebGraphQlClient {
* to mutate and customize further through the returned builder.
*/
static Builder<?> builder(RestClient.Builder builder) {
return new DefaultRestClientGraphQlClientBuilder(builder);
return new DefaultSyncHttpGraphQlClientBuilder(builder);
}
/**
* Builder for the GraphQL over HTTP client.
*/
interface Builder<B extends Builder<B>> extends WebGraphQlClient.Builder<B> {
interface Builder<B extends Builder<B>> extends GraphQlClient.SyncBuilder<B> {
/**
* Set the GraphQL endpoint URL as a String.
* @param url the url to send HTTP requests to or connect over WebSocket
*/
B url(String url);
/**
* Set the GraphQL endpoint URL.
* @param url the url to send HTTP requests to or connect over WebSocket
*/
B url(URI url);
/**
* Add the given header to HTTP requests or to the WebSocket handshake request.
* @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);
/**
* Configure message converters for all JSON encoding and decoding needs.
@@ -93,7 +121,7 @@ public interface RestClientGraphQlClient extends WebGraphQlClient {
* Build the {@code RestClientGraphQlClient} instance.
*/
@Override
RestClientGraphQlClient build();
HttpSyncGraphQlClient build();
}

View File

@@ -16,25 +16,20 @@
package org.springframework.graphql.client;
import java.util.Collections;
import java.util.Map;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.graphql.GraphQlRequest;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClient;
/**
* Transport to execute GraphQL requests over HTTP via {@link RestClient}.
* Transport for executing GraphQL over HTTP requests via {@link RestClient}.
*
* <p>Supports only single-response requests over HTTP POST. For subscriptions,
* see {@link WebSocketGraphQlTransport} and {@link RSocketGraphQlTransport}.
@@ -42,7 +37,7 @@ import org.springframework.web.client.RestClient;
* @author Rossen Stoyanchev
* @since 1.3
*/
final class RestClientGraphQlTransport implements GraphQlTransport {
final class HttpSyncGraphQlTransport implements SyncGraphQlTransport {
private static final ParameterizedTypeReference<Map<String, Object>> MAP_TYPE = new ParameterizedTypeReference<>() {};
@@ -51,14 +46,11 @@ final class RestClientGraphQlTransport implements GraphQlTransport {
private final MediaType contentType;
private final Scheduler scheduler;
RestClientGraphQlTransport(RestClient restClient, @Nullable Scheduler scheduler) {
HttpSyncGraphQlTransport(RestClient restClient) {
Assert.notNull(restClient, "RestClient is required");
this.restClient = restClient;
this.contentType = initContentType(restClient);
this.scheduler = (scheduler!= null ? scheduler : Schedulers.boundedElastic());
}
private static MediaType initContentType(RestClient webClient) {
@@ -70,21 +62,14 @@ final class RestClientGraphQlTransport implements GraphQlTransport {
@Override
public Mono<GraphQlResponse> execute(GraphQlRequest request) {
return Mono
.fromCallable(() -> this.restClient.post()
.contentType(this.contentType)
.accept(MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL_RESPONSE)
.body(request.toMap())
.retrieve()
.body(MAP_TYPE))
.map(responseMap -> (GraphQlResponse) new ResponseMapGraphQlResponse(responseMap))
.subscribeOn(this.scheduler);
}
@Override
public Flux<GraphQlResponse> executeSubscription(GraphQlRequest request) {
throw new UnsupportedOperationException("Subscriptions not supported");
public GraphQlResponse execute(GraphQlRequest request) {
Map<String, Object> body = this.restClient.post()
.contentType(this.contentType)
.accept(MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL_RESPONSE)
.body(request.toMap())
.retrieve()
.body(MAP_TYPE);
return new ResponseMapGraphQlResponse(body != null ? body : Collections.emptyMap());
}
}

View File

@@ -97,7 +97,8 @@ public class GraphQlClientBuilderTests extends GraphQlClientTestSupport {
// Mutate
savedAttributes.clear();
client = client.mutate().interceptors(interceptors -> interceptors.add(0, changingInterceptor)).build();
builder = (GraphQlClient.Builder<?>) client.mutate();
client = builder.interceptors(interceptors -> interceptors.add(0, changingInterceptor)).build();
response = client.document(DOCUMENT).attribute(name, value).execute().block(TIMEOUT);
assertThat(response).isNotNull();

View File

@@ -0,0 +1,322 @@
/*
* Copyright 2002-2024 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.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.Collections;
import graphql.ExecutionResultImpl;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.graphql.execution.MockExecutionGraphQlService;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.graphql.server.webflux.GraphQlHttpHandler;
import org.springframework.graphql.support.DocumentSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.test.web.reactive.server.HttpHandlerConnector;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
/**
* Tests for the {@link HttpSyncGraphQlClient} builder performing requests to a
* {@link GraphQlHttpHandler} with a {@link MockExecutionGraphQlService} that
* always returns an empty GraphQL response. The main goal however is to capture
* the WebInput on the server side through a {@link WebGraphQlInterceptor}.
*
* <p>The equivalent of {@link WebGraphQlClientBuilderTests} but for
* {@link HttpSyncGraphQlClient}.
*
* @author Rossen Stoyanchev
*/
public class HttpSyncGraphQlClientBuilderTests {
private static final String DOCUMENT = "{ Query }";
private final ClientBuilderSetup setup = new ClientBuilderSetup();
@Test
void mutateUrlHeaders() {
String url = "/graphql-one";
// Original
HttpSyncGraphQlClient.Builder<?> builder = this.setup.initBuilder()
.url(url)
.headers(headers -> headers.add("h", "one"));
HttpSyncGraphQlClient client = builder.build();
client.document(DOCUMENT).executeSync();
WebGraphQlRequest request = this.setup.getActualRequest();
assertThat(request.getUri().toString()).isEqualTo(url);
assertThat(request.getHeaders().get("h")).containsExactly("one");
// Mutate to add header value
builder = client.mutate().headers(headers -> headers.add("h", "two"));
client = builder.build();
client.document(DOCUMENT).executeSync();
assertThat(setup.getActualRequest().getHeaders().get("h")).containsExactly("one", "two");
// Mutate to replace header
builder = client.mutate().header("h", "three", "four");
client = builder.build();
client.document(DOCUMENT).executeSync();
request = this.setup.getActualRequest();
assertThat(request.getUri().toString()).isEqualTo(url);
assertThat(request.getHeaders().get("h")).containsExactly("three", "four");
}
@Test
void mutateWebTestClientViaConsumer() {
// Original header value
HttpSyncGraphQlClient.Builder<?> builder = this.setup.initBuilder()
.restClient(testClientBuilder -> testClientBuilder.defaultHeaders(h -> h.add("h", "one")));
HttpSyncGraphQlClient client = builder.build();
client.document(DOCUMENT).executeSync();
assertThat(setup.getActualRequest().getHeaders().get("h")).containsExactly("one");
// Mutate to add header value
HttpSyncGraphQlClient.Builder<?> builder2 = client.mutate()
.restClient(testClientBuilder -> testClientBuilder.defaultHeaders(h -> h.add("h", "two")));
client = builder2.build();
client.document(DOCUMENT).executeSync();
assertThat(setup.getActualRequest().getHeaders().get("h")).containsExactly("one", "two");
// Mutate to replace header
HttpSyncGraphQlClient.Builder<?> builder3 = client.mutate()
.restClient(testClientBuilder -> testClientBuilder.defaultHeader("h", "three"));
client = builder3.build();
client.document(DOCUMENT).executeSync();
assertThat(setup.getActualRequest().getHeaders().get("h")).containsExactly("three");
}
@Test
void mutateDocumentSource() {
DocumentSource documentSource = name -> name.equals("name") ?
Mono.just(DOCUMENT) : Mono.error(new IllegalArgumentException());
// Original
HttpSyncGraphQlClient.Builder<?> builder = this.setup.initBuilder().documentSource(documentSource);
HttpSyncGraphQlClient client = builder.build();
client.documentName("name").executeSync();
WebGraphQlRequest request = this.setup.getActualRequest();
assertThat(request.getDocument()).isEqualTo(DOCUMENT);
// Mutate
client = client.mutate().build();
client.documentName("name").executeSync();
request = this.setup.getActualRequest();
assertThat(request.getDocument()).isEqualTo(DOCUMENT);
}
@Test
void urlEncoding() {
HttpSyncGraphQlClient client = this.setup.initBuilder().url("/graphql one").build();
client.document(DOCUMENT).executeSync();
assertThat(this.setup.getActualRequest().getUri().toString()).isEqualTo("/graphql%20one");
}
@Test
void contentTypeDefault() {
this.setup.initBuilder().build().document(DOCUMENT).executeSync();
WebGraphQlRequest request = this.setup.getActualRequest();
assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
}
@Test
void contentTypeOverride() {
MediaType testMediaType = new MediaType("application", "graphql-request+json");
setup.initBuilder()
.header(HttpHeaders.CONTENT_TYPE, "application/graphql-request+json").build()
.document(DOCUMENT).executeSync();
WebGraphQlRequest request = this.setup.getActualRequest();
assertThat(request.getHeaders().getContentType()).isEqualTo(testMediaType);
}
@Test
void codecConfigurerRegistersJsonPathMappingProvider() {
TestJackson2JsonConverter testConverter = new TestJackson2JsonConverter();
HttpSyncGraphQlClient.Builder<?> builder = this.setup.initBuilder();
builder.messageConverters(converters -> converters.add(0, testConverter));
String document = "{me {name}}";
MovieCharacter character = MovieCharacter.create("Luke Skywalker");
this.setup.getGraphQlService().setResponse(document,
ExecutionResultImpl.newExecutionResult()
.data(Collections.singletonMap("me", character))
.build());
HttpSyncGraphQlClient client = builder.build();
ClientGraphQlResponse response = client.document(document).executeSync();
testConverter.resetLastValue();
assertThat(response).isNotNull();
assertThat(response.field("me").toEntity(MovieCharacter.class).getName()).isEqualTo("Luke Skywalker");
Object lastValue = testConverter.getLastValue();
assertThat(lastValue).isEqualTo(character);
}
private static class ClientBuilderSetup {
private final MockExecutionGraphQlService graphQlService = new MockExecutionGraphQlService();
@Nullable
private WebGraphQlRequest graphQlRequest;
public ClientBuilderSetup() {
this.graphQlService.setDefaultResponse("{}");
}
public MockExecutionGraphQlService getGraphQlService() {
return this.graphQlService;
}
public WebGraphQlRequest getActualRequest() {
Assert.state(this.graphQlRequest != null, "No saved WebGraphQlRequest");
return this.graphQlRequest;
}
public HttpSyncGraphQlClient.Builder<?> initBuilder() {
HttpHandler httpHandler = initServer();
ClientHttpRequestFactory requestFactory = new HttpHandlerClientHttpRequestFactory(httpHandler);
return HttpSyncGraphQlClient.builder(RestClient.builder().requestFactory(requestFactory));
}
private HttpHandler initServer() {
GraphQlHttpHandler handler = new GraphQlHttpHandler(WebGraphQlHandler.builder(this.graphQlService)
.interceptor((request, chain) -> {
this.graphQlRequest = request;
return chain.next(graphQlRequest);
})
.build());
RouterFunction<ServerResponse> routerFunction = route().POST("/**", handler::handleRequest).build();
return RouterFunctions.toHttpHandler(routerFunction, HandlerStrategies.withDefaults());
}
}
private static final class HttpHandlerClientHttpRequestFactory implements ClientHttpRequestFactory {
private final WebClient webClient;
private HttpHandlerClientHttpRequestFactory(HttpHandler httpHandler) {
ClientHttpConnector connector = new HttpHandlerConnector(httpHandler);
this.webClient = WebClient.builder().clientConnector(connector).build();
}
@Override
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) {
return new MockClientHttpRequest(httpMethod, uri) {
@Override
protected ClientHttpResponse executeInternal() {
return getClientHttpResponse(httpMethod, uri, getHeaders(), getBodyAsBytes());
}
};
}
private ClientHttpResponse getClientHttpResponse(
HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) {
ResponseEntity<byte[]> entity = this.webClient.method(httpMethod).uri(uri)
.headers(headers -> headers.putAll(requestHeaders))
.bodyValue(requestBody)
.retrieve()
.toEntity(byte[].class)
.block();
byte[] body = (entity.getBody() != null ? entity.getBody() : new byte[0]);
MockClientHttpResponse response = new MockClientHttpResponse(body, entity.getStatusCode());
response.getHeaders().putAll(entity.getHeaders());
return response;
}
}
private static class TestJackson2JsonConverter extends MappingJackson2HttpMessageConverter {
@Nullable
private Object lastValue;
@Nullable
Object getLastValue() {
return this.lastValue;
}
@Override
public Object read(Type type, Class<?> contextClass, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
this.lastValue = super.read(type, contextClass, inputMessage);
return this.lastValue;
}
void resetLastValue() {
this.lastValue = null;
}
}
}

View File

@@ -16,8 +16,6 @@
package org.springframework.graphql.client;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.URI;
import java.time.Duration;
import java.util.Collections;
@@ -42,25 +40,14 @@ import org.springframework.graphql.server.webflux.GraphQlHttpHandler;
import org.springframework.graphql.server.webflux.GraphQlWebSocketHandler;
import org.springframework.graphql.support.DocumentSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.codec.ClientCodecConfigurer;
import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.lang.Nullable;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.test.web.reactive.server.HttpHandlerConnector;
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFunction;
@@ -75,15 +62,19 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
/**
* Tests for the builders of Web {@code GraphQlClient} extensions, using a
* {@link WebGraphQlInterceptor} to capture the WebInput on the server
* side, and optionally returning a mock response, or an empty response.
*
* Tests for the builders of web {@code GraphQlClient}'s performing requests as follows:
* <ul>
* <li>{@link HttpGraphQlClient} via {@link HttpHandlerConnector} to {@link GraphQlHttpHandler}
* <li>{@link WebSocketGraphQlClient} via a {@link TestWebSocketConnection} to {@link GraphQlWebSocketHandler}
* </ul>
*
* <p>GraphQL requests are handled with a {@link MockExecutionGraphQlService} that
* always returns an empty GraphQL response. The main goal however is to capture
* the WebInput on the server side through a {@link WebGraphQlInterceptor}.
*
* <p>See also {@link HttpSyncGraphQlClientBuilderTests} for the same tests with
* {@link HttpSyncGraphQlClient}.
*
* @author Rossen Stoyanchev
*/
public class WebGraphQlClientBuilderTests {
@@ -94,25 +85,25 @@ public class WebGraphQlClientBuilderTests {
public static Stream<ClientBuilderSetup> argumentSource() {
return Stream.of(new HttpBuilderSetup(), new RestClientBuilderSetup(), new WebSocketBuilderSetup());
return Stream.of(new HttpBuilderSetup(), new WebSocketBuilderSetup());
}
@ParameterizedTest
@MethodSource("argumentSource")
void mutateUrlHeaders(ClientBuilderSetup builderSetup) {
void mutateUrlHeaders(ClientBuilderSetup setup) {
String url = "/graphql-one";
// Original
WebGraphQlClient.Builder<?> builder = builderSetup.initBuilder()
WebGraphQlClient.Builder<?> builder = setup.initBuilder()
.url(url)
.headers(headers -> headers.add("h", "one"));
WebGraphQlClient client = builder.build();
client.document(DOCUMENT).execute().block(TIMEOUT);
WebGraphQlRequest request = builderSetup.getActualRequest();
WebGraphQlRequest request = setup.getActualRequest();
assertThat(request.getUri().toString()).isEqualTo(url);
assertThat(request.getHeaders().get("h")).containsExactly("one");
@@ -120,14 +111,14 @@ public class WebGraphQlClientBuilderTests {
builder = client.mutate().headers(headers -> headers.add("h", "two"));
client = builder.build();
client.document(DOCUMENT).execute().block(TIMEOUT);
assertThat(builderSetup.getActualRequest().getHeaders().get("h")).containsExactly("one", "two");
assertThat(setup.getActualRequest().getHeaders().get("h")).containsExactly("one", "two");
// Mutate to replace header
builder = client.mutate().header("h", "three", "four");
client = builder.build();
client.document(DOCUMENT).execute().block(TIMEOUT);
request = builderSetup.getActualRequest();
request = setup.getActualRequest();
assertThat(request.getUri().toString()).isEqualTo(url);
assertThat(request.getHeaders().get("h")).containsExactly("three", "four");
}
@@ -163,35 +154,35 @@ public class WebGraphQlClientBuilderTests {
@ParameterizedTest
@MethodSource("argumentSource")
void mutateDocumentSource(ClientBuilderSetup builderSetup) {
void mutateDocumentSource(ClientBuilderSetup setup) {
DocumentSource documentSource = name -> name.equals("name") ?
Mono.just(DOCUMENT) : Mono.error(new IllegalArgumentException());
// Original
WebGraphQlClient.Builder<?> builder = builderSetup.initBuilder().documentSource(documentSource);
WebGraphQlClient.Builder<?> builder = setup.initBuilder().documentSource(documentSource);
WebGraphQlClient client = builder.build();
client.documentName("name").execute().block(TIMEOUT);
WebGraphQlRequest request = builderSetup.getActualRequest();
WebGraphQlRequest request = setup.getActualRequest();
assertThat(request.getDocument()).isEqualTo(DOCUMENT);
// Mutate
client = client.mutate().build();
client.documentName("name").execute().block(TIMEOUT);
request = builderSetup.getActualRequest();
request = setup.getActualRequest();
assertThat(request.getDocument()).isEqualTo(DOCUMENT);
}
@ParameterizedTest
@MethodSource("argumentSource")
void urlEncoding(ClientBuilderSetup builderSetup) {
void urlEncoding(ClientBuilderSetup setup) {
WebGraphQlClient client = builderSetup.initBuilder().url("/graphql one").build();
WebGraphQlClient client = setup.initBuilder().url("/graphql one").build();
client.document(DOCUMENT).execute().block(TIMEOUT);
assertThat(builderSetup.getActualRequest().getUri().toString()).isEqualTo("/graphql%20one");
assertThat(setup.getActualRequest().getUri().toString()).isEqualTo("/graphql%20one");
}
@Test
@@ -219,22 +210,16 @@ public class WebGraphQlClientBuilderTests {
@ParameterizedTest
@MethodSource("argumentSource")
void codecConfigurerRegistersJsonPathMappingProvider(ClientBuilderSetup builderSetup) {
void codecConfigurerRegistersJsonPathMappingProvider(ClientBuilderSetup setup) {
TestJackson2JsonDecoder testDecoder = new TestJackson2JsonDecoder();
TestJackson2JsonConverter testConverter = new TestJackson2JsonConverter();
WebGraphQlClient.Builder<?> builder = builderSetup.initBuilder();
if (builder instanceof RestClientGraphQlClient.Builder<?> restClientBuilder) {
restClientBuilder.messageConverters(converters -> converters.add(0, testConverter));
}
else {
builder.codecConfigurer(codecConfigurer -> codecConfigurer.customCodecs().register(testDecoder));
}
WebGraphQlClient.Builder<?> builder = setup.initBuilder();
builder.codecConfigurer(codecConfigurer -> codecConfigurer.customCodecs().register(testDecoder));
String document = "{me {name}}";
MovieCharacter character = MovieCharacter.create("Luke Skywalker");
builderSetup.getGraphQlService().setResponse(document,
setup.getGraphQlService().setResponse(document,
ExecutionResultImpl.newExecutionResult()
.data(Collections.singletonMap("me", character))
.build());
@@ -243,34 +228,28 @@ public class WebGraphQlClientBuilderTests {
ClientGraphQlResponse response = client.document(document).execute().block(TIMEOUT);
testDecoder.resetLastValue();
testConverter.resetLastValue();
assertThat(testDecoder.getLastValue()).isNull();
assertThat(response).isNotNull();
assertThat(response.field("me").toEntity(MovieCharacter.class).getName()).isEqualTo("Luke Skywalker");
Object lastValue = (builder instanceof RestClientGraphQlClient.Builder<?> ?
testConverter.getLastValue() : testDecoder.getLastValue());
assertThat(lastValue).isEqualTo(character);
}
@Test
void attributes() {
HttpBuilderSetup builderSetup = new HttpBuilderSetup();
HttpBuilderSetup setup = new HttpBuilderSetup();
builderSetup.initBuilder().url("/graphql-one").headers(headers -> headers.add("h", "one")).build()
setup.initBuilder().url("/graphql-one").headers(headers -> headers.add("h", "one")).build()
.document(DOCUMENT)
.attribute("id", 123)
.execute()
.block(TIMEOUT);
assertThat(builderSetup.getClientAttributes()).containsEntry("id", 123);
assertThat(setup.getClientAttributes()).containsEntry("id", 123);
}
private interface ClientBuilderSetup {
interface ClientBuilderSetup {
MockExecutionGraphQlService getGraphQlService();
@@ -315,19 +294,7 @@ public class WebGraphQlClientBuilderTests {
}
private abstract static class AbstractHttpBuilderSetup extends AbstractBuilderSetup {
protected WebClient.Builder initWebClientBuilder() {
GraphQlHttpHandler handler = new GraphQlHttpHandler(webGraphQlHandler());
RouterFunction<ServerResponse> routerFunction = route().POST("/**", handler::handleRequest).build();
HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction, HandlerStrategies.withDefaults());
return WebClient.builder().clientConnector(new HttpHandlerConnector(httpHandler));
}
}
private static class HttpBuilderSetup extends AbstractHttpBuilderSetup {
private static class HttpBuilderSetup extends AbstractBuilderSetup {
private final Map<String, Object> clientAttributes = new ConcurrentHashMap<>();
@@ -347,18 +314,12 @@ public class WebGraphQlClientBuilderTests {
return next.exchange(request);
}
}
private static class RestClientBuilderSetup extends AbstractHttpBuilderSetup {
@Override
public RestClientGraphQlClient.Builder<?> initBuilder() {
WebClient webClient = initWebClientBuilder().build();
WebClientHttpRequestFactoryAdapter requestFactory = new WebClientHttpRequestFactoryAdapter(webClient);
return RestClientGraphQlClient.builder(RestClient.builder().requestFactory(requestFactory));
private WebClient.Builder initWebClientBuilder() {
GraphQlHttpHandler handler = new GraphQlHttpHandler(webGraphQlHandler());
RouterFunction<ServerResponse> routerFunction = route().POST("/**", handler::handleRequest).build();
HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction, HandlerStrategies.withDefaults());
return WebClient.builder().clientConnector(new HttpHandlerConnector(httpHandler));
}
}
@@ -374,37 +335,6 @@ public class WebGraphQlClientBuilderTests {
}
private record WebClientHttpRequestFactoryAdapter(WebClient webClient) implements ClientHttpRequestFactory {
@Override
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) {
return new MockClientHttpRequest(httpMethod, uri) {
@Override
protected ClientHttpResponse executeInternal() {
return getClientHttpResponse(httpMethod, uri, getHeaders(), getBodyAsBytes());
}
};
}
private ClientHttpResponse getClientHttpResponse(
HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) {
ResponseEntity<byte[]> entity = this.webClient.method(httpMethod).uri(uri)
.headers(headers -> headers.putAll(requestHeaders))
.bodyValue(requestBody)
.retrieve()
.toEntity(byte[].class)
.block();
byte[] body = (entity.getBody() != null ? entity.getBody() : new byte[0]);
MockClientHttpResponse response = new MockClientHttpResponse(body, entity.getStatusCode());
response.getHeaders().putAll(entity.getHeaders());
return response;
}
}
private static class TestJackson2JsonDecoder extends Jackson2JsonDecoder {
@Nullable
@@ -429,29 +359,4 @@ public class WebGraphQlClientBuilderTests {
}
private static class TestJackson2JsonConverter extends MappingJackson2HttpMessageConverter {
@Nullable
private Object lastValue;
@Nullable
Object getLastValue() {
return this.lastValue;
}
@Override
public Object read(Type type, Class<?> contextClass, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
this.lastValue = super.read(type, contextClass, inputMessage);
return this.lastValue;
}
void resetLastValue() {
this.lastValue = null;
}
}
}