From a38fac0bb1796a5779016616c3612bd625745197 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 25 Jan 2024 16:27:18 +0000 Subject: [PATCH] Introduce blocking execution in GraphQlClient See gh-771 --- .../client/AbstractGraphQlClientBuilder.java | 45 ++- .../AbstractGraphQlClientSyncBuilder.java | 195 +++++++++++ .../graphql/client/DefaultGraphQlClient.java | 114 ++++++- ... DefaultSyncHttpGraphQlClientBuilder.java} | 76 ++--- .../graphql/client/GraphQlClient.java | 158 +++++++-- .../client/HttpMessageConverterDelegate.java | 26 +- ...Client.java => HttpSyncGraphQlClient.java} | 44 ++- ...ort.java => HttpSyncGraphQlTransport.java} | 39 +-- .../client/GraphQlClientBuilderTests.java | 3 +- .../HttpSyncGraphQlClientBuilderTests.java | 322 ++++++++++++++++++ .../client/WebGraphQlClientBuilderTests.java | 165 ++------- 11 files changed, 911 insertions(+), 276 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java rename spring-graphql/src/main/java/org/springframework/graphql/client/{DefaultRestClientGraphQlClientBuilder.java => DefaultSyncHttpGraphQlClientBuilder.java} (50%) rename spring-graphql/src/main/java/org/springframework/graphql/client/{RestClientGraphQlClient.java => HttpSyncGraphQlClient.java} (63%) rename spring-graphql/src/main/java/org/springframework/graphql/client/{RestClientGraphQlTransport.java => HttpSyncGraphQlTransport.java} (62%) create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/client/HttpSyncGraphQlClientBuilderTests.java diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java index 9ecb158a..b1dc8ba9 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java @@ -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 jsonDecoder; + @Nullable + private Duration blockingTimeout; + /** * Default constructor for use from subclasses. @@ -82,7 +86,6 @@ public abstract class AbstractGraphQlClientBuilder T self() { return (T) this; @@ -169,8 +178,8 @@ public abstract class AbstractGraphQlClientBuilder 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); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java new file mode 100644 index 00000000..ce5b3c2e --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java @@ -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. + * + *

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> + implements GraphQlClient.SyncBuilder { + + protected static final boolean jackson2Present = ClassUtils.isPresent( + "com.fasterxml.jackson.databind.ObjectMapper", AbstractGraphQlClientSyncBuilder.class.getClassLoader()); + + + private final List interceptors = new ArrayList<>(); + + private DocumentSource documentSource; + + @Nullable + private HttpMessageConverter jsonConverter; + + private Scheduler scheduler = Schedulers.boundedElastic(); + + @Nullable + private Duration blockingTimeout; + + /** + * Default constructor for use from subclasses. + *

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> 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 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 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> 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 getJsonConverter() { + Assert.notNull(this.jsonConverter, "jsonConverter has not been set"); + return this.jsonConverter; + } + + + private static class DefaultJacksonConverter { + + static HttpMessageConverter initialize() { + return new MappingJackson2HttpMessageConverter(); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java index a43e4a44..29f9d249 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java @@ -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 mono = initRequest(); + ClientGraphQlRequest request = (blockingTimeout != null ? mono.block(blockingTimeout) : mono.block()); + return blockingChain.next(request); + } + @Override public Mono 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 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 toEntity(Class entityType) { + ClientResponseField field = getValidField(this.response); + return (field != null ? field.toEntity(entityType) : null); + } + + @Override + public D toEntity(ParameterizedTypeReference entityType) { + ClientResponseField field = getValidField(this.response); + return (field != null ? field.toEntity(entityType) : null); + } + + @Override + public List toEntityList(Class elementType) { + ClientResponseField field = getValidField(this.response); + return (field != null ? field.toEntityList(elementType) : Collections.emptyList()); + } + + @Override + public List toEntityList(ParameterizedTypeReference 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 responseMono; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultSyncHttpGraphQlClientBuilder.java similarity index 50% rename from spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java rename to spring-graphql/src/main/java/org/springframework/graphql/client/DefaultSyncHttpGraphQlClientBuilder.java index 37406160..61131998 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultSyncHttpGraphQlClientBuilder.java @@ -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 - implements RestClientGraphQlClient.Builder { +final class DefaultSyncHttpGraphQlClientBuilder + extends AbstractGraphQlClientSyncBuilder + implements HttpSyncGraphQlClient.Builder { 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 headersConsumer) { + public DefaultSyncHttpGraphQlClientBuilder headers(Consumer headersConsumer) { this.restClientBuilder.defaultHeaders(headersConsumer); return this; } @Override - public DefaultRestClientGraphQlClientBuilder codecConfigurer(Consumer codecConsumer) { - if (this.codecConfigurer == null) { - this.codecConfigurer = ClientCodecConfigurer.create(); - } - codecConsumer.accept(this.codecConfigurer); - return this; - } - - @Override - public DefaultRestClientGraphQlClientBuilder messageConverters(Consumer>> configurer) { + public DefaultSyncHttpGraphQlClientBuilder messageConverters(Consumer>> configurer) { this.restClientBuilder.messageConverters(configurer); return this; } @Override - public DefaultRestClientGraphQlClientBuilder restClient(Consumer configurer) { + public DefaultSyncHttpGraphQlClientBuilder restClient(Consumer 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 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> builderInitializer; + private final Consumer> builderInitializer; - DefaultRestClientGraphQlClient( + DefaultHttpSyncGraphQlClient( GraphQlClient delegate, RestClient restClient, - Consumer> builderInitializer) { + Consumer> 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; } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java index 985f834d..df78fd8a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java @@ -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> { + interface BaseBuilder> { + + /** + * Configure a {@link DocumentSource} for use with + * {@link #documentName(String)} for resolving a document by name. + *

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. + *

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> extends BaseBuilder { + + /** + * 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> interceptorsConsumer); + + /** + * The scheduler to use for non-blocking execution with + * {@link RequestSpec#execute()} and {@link RequestSpec#retrieve(String)}. + *

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> extends BaseBuilder { /** * Configure interceptors to be invoked before delegating to the @@ -106,20 +177,6 @@ public interface GraphQlClient { */ B interceptors(Consumer> interceptorsConsumer); - /** - * Configure a {@link DocumentSource} for use with - * {@link #documentName(String)} for resolving a document by name. - *

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> attributesConsumer); + /** + * Shortcut for {@link #execute()} with a field path to decode from. + *

If you want to decode the full data instead, use {@link #execute()}: + *

+		 * client.document("..").execute().map(response -> response.toEntity(..))
+		 * 
+ * @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. *

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 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 toEntity(Class entityType); + + /** + * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. + */ + @Nullable + D toEntity(ParameterizedTypeReference entityType); + + /** + * Variant of {@link #toEntity(Class)} to decode to a List of entities. + * @param elementType the type of elements in the list + */ + List toEntityList(Class elementType); + + /** + * Variant of {@link #toEntity(Class)} to decode to a List of entities. + * @param elementType the type of elements in the list + */ + List toEntityList(ParameterizedTypeReference elementType); + + } + + /** * Declares options to decode a field for a single response operation. */ diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java index fc60e531..eb5e9f98 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java @@ -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> converters) { - HttpMessageConverter converter = findJsonConverter(converters); - return new HttpMessageConverterEncoder(converter); - } - - static Decoder getJsonDecoder(List> converters) { - HttpMessageConverter converter = findJsonConverter(converters); - return new HttpMessageConverterDecoder(converter); - } - @SuppressWarnings("unchecked") - private static HttpMessageConverter findJsonConverter(List> converters) { + static HttpMessageConverter findJsonConverter(List> converters) { return (HttpMessageConverter) 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 converter) { + return new HttpMessageConverterEncoder(converter); + } + + static HttpMessageConverterDecoder asDecoder(HttpMessageConverter converter) { + return new HttpMessageConverterDecoder(converter); + } + private static class HttpMessageConverterEncoder implements Encoder { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlClient.java similarity index 63% rename from spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java rename to spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlClient.java index b0ddff08..b0bc0970 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlClient.java @@ -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> extends WebGraphQlClient.Builder { + interface Builder> extends GraphQlClient.SyncBuilder { + + /** + * 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 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(); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java similarity index 62% rename from spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlTransport.java rename to spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java index 7294321b..abb44e17 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlTransport.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java @@ -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}. * *

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_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 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 executeSubscription(GraphQlRequest request) { - throw new UnsupportedOperationException("Subscriptions not supported"); + public GraphQlResponse execute(GraphQlRequest request) { + Map 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()); } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java index 1c4d2dde..94350222 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java @@ -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(); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpSyncGraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpSyncGraphQlClientBuilderTests.java new file mode 100644 index 00000000..0a1e1215 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpSyncGraphQlClientBuilderTests.java @@ -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}. + * + *

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 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 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; + } + + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java index 0416fdf3..6e27bd55 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java @@ -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: *

    *
  • {@link HttpGraphQlClient} via {@link HttpHandlerConnector} to {@link GraphQlHttpHandler} *
  • {@link WebSocketGraphQlClient} via a {@link TestWebSocketConnection} to {@link GraphQlWebSocketHandler} *
* + *

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}. + * + *

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 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 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 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 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 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; - } - - } - }