From d460cf0b9bdc2f07a51b3caa6bad54cd12ca48ee Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 25 Jan 2024 10:02:04 +0000 Subject: [PATCH] Add RestClient transport and GraphQlClient builder See gh-771 --- ...DefaultRestClientGraphQlClientBuilder.java | 171 +++++++++++++ .../client/HttpMessageConverterDelegate.java | 235 ++++++++++++++++++ .../client/RestClientGraphQlClient.java | 100 ++++++++ .../client/RestClientGraphQlTransport.java | 90 +++++++ .../client/WebGraphQlClientBuilderTests.java | 123 ++++++++- 5 files changed, 707 insertions(+), 12 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlTransport.java diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java new file mode 100644 index 00000000..37406160 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRestClientGraphQlClientBuilder.java @@ -0,0 +1,171 @@ +/* + * 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.net.URI; +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; +import org.springframework.web.util.UriBuilderFactory; +import org.springframework.web.util.UriComponentsBuilder; + + +/** + * Default {@link RestClientGraphQlClient.Builder} implementation, a simple wrapper + * around a {@link RestClient.Builder}. + * + * @author Rossen Stoyanchev + * @since 1.3 + */ +final class DefaultRestClientGraphQlClientBuilder + extends AbstractGraphQlClientBuilder + implements RestClientGraphQlClient.Builder { + + private final RestClient.Builder restClientBuilder; + + @Nullable + private CodecConfigurer codecConfigurer; + + /** + * Constructor to start without a RestClient instance. + */ + DefaultRestClientGraphQlClientBuilder() { + this(RestClient.builder()); + } + + /** + * Constructor to start with a pre-configured {@code RestClient}. + */ + DefaultRestClientGraphQlClientBuilder(RestClient client) { + this(client.mutate()); + } + + /** + * Constructor to start with a pre-configured {@code RestClient}. + */ + DefaultRestClientGraphQlClientBuilder(RestClient.Builder clientBuilder) { + this.restClientBuilder = clientBuilder; + } + + + @Override + public DefaultRestClientGraphQlClientBuilder url(String url) { + this.restClientBuilder.baseUrl(url); + return this; + } + + @Override + public DefaultRestClientGraphQlClientBuilder url(URI url) { + UriBuilderFactory factory = new DefaultUriBuilderFactory(UriComponentsBuilder.fromUri(url)); + this.restClientBuilder.uriBuilderFactory(factory); + return this; + } + + @Override + public DefaultRestClientGraphQlClientBuilder header(String name, String... values) { + this.restClientBuilder.defaultHeader(name, values); + return this; + } + + @Override + public DefaultRestClientGraphQlClientBuilder 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) { + this.restClientBuilder.messageConverters(configurer); + return this; + } + + @Override + public DefaultRestClientGraphQlClientBuilder restClient(Consumer configurer) { + configurer.accept(this.restClientBuilder); + return this; + } + + @Override + public RestClientGraphQlClient 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)); + }); + } + + RestClient restClient = this.restClientBuilder.build(); + + GraphQlClient graphQlClient = super.buildGraphQlClient(new RestClientGraphQlTransport(restClient, null)); + return new DefaultRestClientGraphQlClient(graphQlClient, restClient, getBuilderInitializer()); + } + + + /** + * Default {@link HttpGraphQlClient} implementation. + */ + private static class DefaultRestClientGraphQlClient + extends AbstractDelegatingGraphQlClient implements RestClientGraphQlClient { + + private final RestClient restClient; + + private final Consumer> builderInitializer; + + DefaultRestClientGraphQlClient( + GraphQlClient delegate, RestClient restClient, + Consumer> builderInitializer) { + + super(delegate); + + Assert.notNull(restClient, "RestClient is required"); + Assert.notNull(builderInitializer, "`builderInitializer` is required"); + + this.restClient = restClient; + this.builderInitializer = builderInitializer; + } + + public DefaultRestClientGraphQlClientBuilder mutate() { + DefaultRestClientGraphQlClientBuilder builder = new DefaultRestClientGraphQlClientBuilder(this.restClient); + this.builderInitializer.accept(builder); + return builder; + } + } + +} 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 new file mode 100644 index 00000000..fc60e531 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpMessageConverterDelegate.java @@ -0,0 +1,235 @@ +/* + * 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.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.EncodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; + + +/** + * Helper class to adapt JSON {@link HttpMessageConverter} 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) { + return (HttpMessageConverter) converters.stream() + .filter(converter -> converter.canRead(Map.class, MediaType.APPLICATION_JSON)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON HttpMessageConverter")); + } + + @Nullable + private static MediaType toMediaType(@Nullable MimeType mimeType) { + if (mimeType instanceof MediaType mediaType) { + return mediaType; + } + return (mimeType != null ? new MediaType(mimeType) : null); + } + + + private static class HttpMessageConverterEncoder implements Encoder { + + private final HttpMessageConverter converter; + + private final List mimeTypes; + + private HttpMessageConverterEncoder(HttpMessageConverter converter) { + this.converter = converter; + this.mimeTypes = new ArrayList<>(this.converter.getSupportedMediaTypes()); + } + + @Override + public List getEncodableMimeTypes() { + return this.mimeTypes; + } + + @Override + public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) { + return this.converter.canWrite(elementType.resolve(Object.class), toMediaType(mimeType)); + } + + @Override + public DataBuffer encodeValue( + Object value, DataBufferFactory bufferFactory, ResolvableType valueType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + HttpOutputMessageAdapter messageAdapter = new HttpOutputMessageAdapter(); + try { + if (this.converter instanceof GenericHttpMessageConverter genericConverter) { + genericConverter.write(value, valueType.getType(), toMediaType(mimeType), messageAdapter); + } + else { + this.converter.write(value, toMediaType(mimeType), messageAdapter); + } + return bufferFactory.wrap(messageAdapter.toByteArray()); + } + catch (IOException ex) { + throw new EncodingException(ex.getMessage(), ex); + } + } + + @Override + public Flux encode( + Publisher inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + throw new UnsupportedOperationException(); + } + } + + + private static class HttpMessageConverterDecoder implements Decoder { + + private final HttpMessageConverter converter; + + private final List mimeTypes; + + private HttpMessageConverterDecoder(HttpMessageConverter converter) { + this.converter = converter; + this.mimeTypes = new ArrayList<>(this.converter.getSupportedMediaTypes()); + } + + @Override + public List getDecodableMimeTypes() { + return this.mimeTypes; + } + + @Override + public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType) { + return this.converter.canRead(elementType.resolve(Object.class), toMediaType(mimeType)); + } + + @Override + public Object decode(DataBuffer buffer, ResolvableType targetType, + @Nullable MimeType mimeType, @Nullable Map hints) throws DecodingException { + + try { + HttpInputMessageAdapter messageAdapter = new HttpInputMessageAdapter(buffer); + if (this.converter instanceof GenericHttpMessageConverter genericConverter) { + return genericConverter.read(targetType.getType(), null, messageAdapter); + } + else { + return this.converter.read(targetType.resolve(Object.class), messageAdapter); + } + } + catch (IOException ex) { + throw new DecodingException(ex.getMessage(), ex); + } + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + throw new UnsupportedOperationException(); + } + + @Override + public Flux decode( + Publisher inputStream, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + throw new UnsupportedOperationException(); + } + } + + + private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage { + + HttpInputMessageAdapter(DataBuffer buffer) { + super(toBytes(buffer)); + } + + private static byte[] toBytes(DataBuffer buffer) { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + return bytes; + } + + @Override + public InputStream getBody() { + return this; + } + + @Override + public HttpHeaders getHeaders() { + return HttpHeaders.EMPTY; + } + + } + + + private static class HttpOutputMessageAdapter extends ByteArrayOutputStream implements HttpOutputMessage { + + private static final HttpHeaders noOpHeaders = new HttpHeaders(); + + @Override + public OutputStream getBody() { + return this; + } + + @Override + public HttpHeaders getHeaders() { + return noOpHeaders; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java new file mode 100644 index 00000000..b0ddff08 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlClient.java @@ -0,0 +1,100 @@ +/* + * 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.util.List; +import java.util.function.Consumer; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.client.RestClient; + + +/** + * GraphQL over HTTP client that uses {@link RestClient}. + * + * @author Rossen Stoyanchev + * @since 1.3 + */ +public interface RestClientGraphQlClient extends WebGraphQlClient { + + + @Override + Builder mutate(); + + + /** + * Create an {@link RestClientGraphQlClient} that uses the given {@link RestClient}. + */ + static RestClientGraphQlClient create(RestClient client) { + return builder(client.mutate()).build(); + } + + /** + * Return a builder to initialize an {@link RestClientGraphQlClient} with. + */ + static Builder builder() { + return new DefaultRestClientGraphQlClientBuilder(); + } + + /** + * Variant of {@link #builder()} with a pre-configured {@code RestClient} + * to mutate and customize further through the returned builder. + */ + static Builder builder(RestClient client) { + return builder(client.mutate()); + } + + /** + * Variant of {@link #builder()} with a pre-configured {@code RestClient} + * to mutate and customize further through the returned builder. + */ + static Builder builder(RestClient.Builder builder) { + return new DefaultRestClientGraphQlClientBuilder(builder); + } + + + /** + * Builder for the GraphQL over HTTP client. + */ + interface Builder> extends WebGraphQlClient.Builder { + + /** + * Configure message converters for all JSON encoding and decoding needs. + * @param configurer the configurer to apply + * @return this builder + */ + B messageConverters(Consumer>> configurer); + + /** + * Customize the {@code RestClient} to use. + *

Note that some properties of {@code RestClient.Builder} like the base URL, + * headers, and message converters can be customized through this builder. + * @see #url(String) + * @see #header(String, String...) + * @see #messageConverters(Consumer) + */ + B restClient(Consumer builderConsumer); + + /** + * Build the {@code RestClientGraphQlClient} instance. + */ + @Override + RestClientGraphQlClient 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/RestClientGraphQlTransport.java new file mode 100644 index 00000000..7294321b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/RestClientGraphQlTransport.java @@ -0,0 +1,90 @@ +/* + * 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.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}. + * + *

Supports only single-response requests over HTTP POST. For subscriptions, + * see {@link WebSocketGraphQlTransport} and {@link RSocketGraphQlTransport}. + * + * @author Rossen Stoyanchev + * @since 1.3 + */ +final class RestClientGraphQlTransport implements GraphQlTransport { + + private static final ParameterizedTypeReference> MAP_TYPE = new ParameterizedTypeReference<>() {}; + + + private final RestClient restClient; + + private final MediaType contentType; + + private final Scheduler scheduler; + + + RestClientGraphQlTransport(RestClient restClient, @Nullable Scheduler scheduler) { + 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) { + HttpHeaders headers = new HttpHeaders(); + webClient.mutate().defaultHeaders(headers::putAll); + MediaType contentType = headers.getContentType(); + return (contentType != null ? contentType : MediaType.APPLICATION_JSON); + } + + + @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"); + } + +} 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 a825d91c..0416fdf3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 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,8 @@ 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; @@ -40,14 +42,25 @@ 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; @@ -81,7 +94,7 @@ public class WebGraphQlClientBuilderTests { public static Stream argumentSource() { - return Stream.of(new HttpBuilderSetup(), new WebSocketBuilderSetup()); + return Stream.of(new HttpBuilderSetup(), new RestClientBuilderSetup(), new WebSocketBuilderSetup()); } @@ -209,9 +222,15 @@ public class WebGraphQlClientBuilderTests { void codecConfigurerRegistersJsonPathMappingProvider(ClientBuilderSetup builderSetup) { TestJackson2JsonDecoder testDecoder = new TestJackson2JsonDecoder(); + TestJackson2JsonConverter testConverter = new TestJackson2JsonConverter(); - WebGraphQlClient.Builder builder = builderSetup.initBuilder() - .codecConfigurer(codecConfigurer -> codecConfigurer.customCodecs().register(testDecoder)); + 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)); + } String document = "{me {name}}"; MovieCharacter character = MovieCharacter.create("Luke Skywalker"); @@ -224,11 +243,16 @@ 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"); - assertThat(testDecoder.getLastValue()).isEqualTo(character); + + Object lastValue = (builder instanceof RestClientGraphQlClient.Builder ? + testConverter.getLastValue() : testDecoder.getLastValue()); + + assertThat(lastValue).isEqualTo(character); } @Test @@ -291,7 +315,19 @@ public class WebGraphQlClientBuilderTests { } - private static class HttpBuilderSetup extends AbstractBuilderSetup { + 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 final Map clientAttributes = new ConcurrentHashMap<>(); @@ -301,12 +337,8 @@ public class WebGraphQlClientBuilderTests { @Override public HttpGraphQlClient.Builder initBuilder() { - GraphQlHttpHandler handler = new GraphQlHttpHandler(webGraphQlHandler()); - RouterFunction routerFunction = route().POST("/**", handler::handleRequest).build(); - HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction, HandlerStrategies.withDefaults()); - HttpHandlerConnector connector = new HttpHandlerConnector(httpHandler); - return HttpGraphQlClient.builder(WebClient.builder() - .clientConnector(connector).filter(this::updateAttributes)); + WebClient client = initWebClientBuilder().filter(this::updateAttributes).build(); + return HttpGraphQlClient.builder(client); } private Mono updateAttributes(ClientRequest request, ExchangeFunction next) { @@ -318,6 +350,18 @@ public class WebGraphQlClientBuilderTests { } + 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 static class WebSocketBuilderSetup extends AbstractBuilderSetup { @Override @@ -330,6 +374,37 @@ 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 @@ -355,4 +430,28 @@ 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; + } + + } + }