diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/GetRequest.java b/spring-web/src/main/java/org/springframework/web/service/annotation/GetRequest.java new file mode 100644 index 0000000000..58cba90964 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/GetRequest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + + +/** + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@HttpRequest(method = "GET") +public @interface GetRequest { + + /** + * Alias for {@link HttpRequest#value}. + */ + @AliasFor(annotation = HttpRequest.class) + String value() default ""; + + /** + * Alias for {@link HttpRequest#url()}. + */ + @AliasFor(annotation = HttpRequest.class) + String url() default ""; + + /** + * Alias for {@link HttpRequest#accept()}. + */ + @AliasFor(annotation = HttpRequest.class) + String[] accept() default {}; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpRequest.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpRequest.java new file mode 100644 index 0000000000..388f0761a5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpRequest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.web.bind.annotation.Mapping; + +/** + * Supported method parameters: + * + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Mapping +public @interface HttpRequest { + + /** + * This is an alias for {@link #url}. + */ + @AliasFor("url") + String value() default ""; + + /** + * The URL for the request, either a full URL or a path only that is relative + * to a URL declared in a type-level {@code @HttpRequest}, and/or a globally + * configured base URL. + *

By default, this is empty. + */ + @AliasFor("value") + String url() default ""; + + /** + * The HTTP method to use. + *

Supported at the type level as well as at the method level. + * When used at the type level, all method-level mappings inherit this value. + *

By default, this is empty. + */ + String method() default ""; + + + /** + * The media type for the {@code "Content-Type"} header. + *

Supported at the type level as well as at the method level, in which + * case the method-level values override type-level values. + *

By default, this is empty. + */ + String contentType() default ""; + + /** + * The media types for the {@code "Accept"} header. + *

Supported at the type level as well as at the method level, in which + * case the method-level values override type-level values. + *

By default, this is empty. + */ + String[] accept() default {}; + + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PostRequest.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PostRequest.java new file mode 100644 index 0000000000..5fb3d950a0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PostRequest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@HttpRequest(method = "POST") +public @interface PostRequest { + + /** + * Alias for {@link HttpRequest#value}. + */ + @AliasFor(annotation = HttpRequest.class) + String value() default ""; + + /** + * Alias for {@link HttpRequest#url()}. + */ + @AliasFor(annotation = HttpRequest.class) + String url() default ""; + + /** + * Alias for {@link HttpRequest#contentType()}. + */ + @AliasFor(annotation = HttpRequest.class) + String contentType() default ""; + + /** + * Alias for {@link HttpRequest#accept()}. + */ + @AliasFor(annotation = HttpRequest.class) + String[] accept() default {}; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PutRequest.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PutRequest.java new file mode 100644 index 0000000000..91c173d798 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PutRequest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@HttpRequest(method = "PUT") +public @interface PutRequest { + + /** + * Alias for {@link HttpRequest#value}. + */ + @AliasFor(annotation = HttpRequest.class) + String[] value() default {}; + + /** + * Alias for {@link HttpRequest#url()}. + */ + @AliasFor(annotation = HttpRequest.class) + String[] url() default {}; + + /** + * Alias for {@link HttpRequest#contentType()}. + */ + @AliasFor(annotation = HttpRequest.class) + String contentType() default ""; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/package-info.java b/spring-web/src/main/java/org/springframework/web/service/annotation/package-info.java new file mode 100644 index 0000000000..395dedcde5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/package-info.java @@ -0,0 +1,9 @@ +/** + * Annotations to declare HTTP service, request methods. + */ +@NonNullApi +@NonNullFields +package org.springframework.web.service.annotation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java new file mode 100644 index 0000000000..fba817a677 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpClientAdapter.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; + + +/** + * Decouple an {@link HttpServiceProxyFactory#createService(Class) HTTP Service proxy} + * from the underlying HTTP client. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public interface HttpClientAdapter { + + Mono requestToVoid(HttpRequestDefinition requestDefinition); + + Mono requestToHeaders(HttpRequestDefinition requestDefinition); + + Mono requestToBody(HttpRequestDefinition requestDefinition, ParameterizedTypeReference bodyType); + + Flux requestToBodyFlux(HttpRequestDefinition requestDefinition, ParameterizedTypeReference bodyType); + + Mono> requestToBodilessEntity(HttpRequestDefinition requestDefinition); + + Mono> requestToEntity(HttpRequestDefinition requestDefinition, ParameterizedTypeReference bodyType); + + Mono>> requestToEntityFlux(HttpRequestDefinition requestDefinition, ParameterizedTypeReference bodyType); + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestDefinition.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestDefinition.java new file mode 100644 index 0000000000..846815a7db --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestDefinition.java @@ -0,0 +1,192 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.reactivestreams.Publisher; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + + +/** + * Container for HTTP request values accumulated from an + * {@link HttpRequest @HttpRequest}-annotated method and arguments passed to it. + * This allows an {@link HttpClientAdapter} adapt these inputs as it sees fit + * to the API of the underlying client. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public class HttpRequestDefinition { + + private static final MultiValueMap EMPTY_COOKIES_MAP = + CollectionUtils.toMultiValueMap(Collections.emptyMap()); + + + @Nullable + private URI uri; + + @Nullable + private String uriTemplate; + + @Nullable + private Map uriVariables; + + @Nullable + private List uriVariablesList; + + @Nullable + private HttpMethod httpMethod; + + @Nullable + private HttpHeaders headers; + + @Nullable + private MultiValueMap cookies; + + @Nullable + private Object bodyValue; + + @Nullable + private Publisher bodyPublisher; + + @Nullable + private ParameterizedTypeReference bodyPublisherElementType; + + private boolean complete; + + + public void setUri(URI uri) { + checkComplete(); + this.uri = uri; + } + + @Nullable + public URI getUri() { + return this.uri; + } + + public void setUriTemplate(String uriTemplate) { + checkComplete(); + this.uriTemplate = uriTemplate; + } + + @Nullable + public String getUriTemplate() { + return this.uriTemplate; + } + + public Map getUriVariables() { + this.uriVariables = (this.uriVariables != null ? this.uriVariables : new LinkedHashMap<>()); + return this.uriVariables; + } + + public List getUriVariableValues() { + this.uriVariablesList = (this.uriVariablesList != null ? this.uriVariablesList : new ArrayList<>()); + return this.uriVariablesList; + } + + public void setHttpMethod(HttpMethod httpMethod) { + checkComplete(); + this.httpMethod = httpMethod; + } + + @Nullable + public HttpMethod getHttpMethod() { + return this.httpMethod; + } + + public HttpMethod getHttpMethodRequired() { + Assert.notNull(this.httpMethod, "No HttpMethod"); + return this.httpMethod; + } + + public HttpHeaders getHeaders() { + this.headers = (this.headers != null ? this.headers : new HttpHeaders()); + return this.headers; + } + + public MultiValueMap getCookies() { + this.cookies = (this.cookies != null ? this.cookies : new LinkedMultiValueMap<>()); + return this.cookies; + } + + public void setBodyValue(Object bodyValue) { + checkComplete(); + this.bodyValue = bodyValue; + } + + @Nullable + public Object getBodyValue() { + return this.bodyValue; + } + + public > void setBodyPublisher(Publisher

bodyPublisher, MethodParameter parameter) { + checkComplete(); + // Adapt to Mono/Flux and nest MethodParameter for element type + this.bodyPublisher = bodyPublisher; + this.bodyPublisherElementType = ParameterizedTypeReference.forType(parameter.nested().getGenericParameterType()); + } + + @Nullable + public Publisher getBodyPublisher() { + return this.bodyPublisher; + } + + public ParameterizedTypeReference getBodyPublisherElementType() { + Assert.state(this.bodyPublisherElementType != null, "No body Publisher"); + return this.bodyPublisherElementType; + } + + private void checkComplete() { + Assert.isTrue(!this.complete, "setComplete already called"); + } + + + void setComplete() { + + this.uriVariables = (this.uriVariables != null ? + Collections.unmodifiableMap(this.uriVariables) : Collections.emptyMap()); + + this.uriVariablesList = (this.uriVariablesList != null ? + Collections.unmodifiableList(this.uriVariablesList) : Collections.emptyList()); + + this.headers = (this.headers != null ? + HttpHeaders.readOnlyHttpHeaders(this.headers) : HttpHeaders.EMPTY); + + this.cookies = (this.cookies != null ? + CollectionUtils.unmodifiableMultiValueMap(this.cookies) : EMPTY_COOKIES_MAP); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java new file mode 100644 index 0000000000..c6eea95b5e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -0,0 +1,361 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.service.annotation.HttpRequest; + + +/** + * Implements the invocation of an {@link HttpRequest @HttpRequest} annotated, + * {@link HttpServiceProxyFactory#createService(Class) HTTP Service proxy} method + * by delegating to an {@link HttpClientAdapter} to perform actual requests. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +final class HttpServiceMethod { + + private final Method method; + + private final MethodParameter[] parameters; + + private final List argumentResolvers; + + private final HttpRequestDefinitionFactory requestDefinitionFactory; + + private final ResponseFunction responseFunction; + + + HttpServiceMethod( + Method method, Class containingClass, List argumentResolvers, + HttpClientAdapter client, ReactiveAdapterRegistry reactiveRegistry, + Duration blockTimeout) { + + this.method = method; + this.parameters = initMethodParameters(method); + this.argumentResolvers = argumentResolvers; + this.requestDefinitionFactory = HttpRequestDefinitionFactory.create(method, containingClass); + this.responseFunction = ResponseFunction.create(client, method, reactiveRegistry, blockTimeout); + } + + private static MethodParameter[] initMethodParameters(Method method) { + int count = method.getParameterCount(); + MethodParameter[] parameters = new MethodParameter[count]; + for (int i = 0; i < count; i++) { + parameters[i] = new MethodParameter(method, i); + } + return parameters; + } + + + public Method getMethod() { + return this.method; + } + + + @Nullable + public Object invoke(Object[] arguments) { + HttpRequestDefinition requestDefinition = this.requestDefinitionFactory.initializeRequest(); + applyArguments(requestDefinition, arguments); + requestDefinition.setComplete(); + return this.responseFunction.execute(requestDefinition); + } + + private void applyArguments(HttpRequestDefinition requestDefinition, Object[] arguments) { + Assert.isTrue(arguments.length == this.parameters.length, "Method argument mismatch"); + for (int i = 0; i < this.parameters.length; i++) { + Object argumentValue = arguments[i]; + for (HttpServiceMethodArgumentResolver resolver : this.argumentResolvers) { + resolver.resolve(argumentValue, this.parameters[i], requestDefinition); + } + } + } + + + /** + * Factory for an {@link HttpRequestDefinition} with values extracted from + * the type and method-level {@link HttpRequest @HttpRequest} annotations. + */ + private record HttpRequestDefinitionFactory( + @Nullable HttpMethod httpMethod, @Nullable String url, + @Nullable MediaType contentType, @Nullable List acceptMediaTypes) { + + private HttpRequestDefinitionFactory( + @Nullable HttpMethod httpMethod, @Nullable String url, + @Nullable MediaType contentType, @Nullable List acceptMediaTypes) { + + this.url = url; + this.httpMethod = httpMethod; + this.contentType = contentType; + this.acceptMediaTypes = acceptMediaTypes; + } + + public HttpRequestDefinition initializeRequest() { + HttpRequestDefinition requestDefinition = new HttpRequestDefinition(); + if (this.httpMethod != null) { + requestDefinition.setHttpMethod(this.httpMethod); + } + if (this.url != null) { + requestDefinition.setUriTemplate(this.url); + } + if (this.contentType != null) { + requestDefinition.getHeaders().setContentType(this.contentType); + } + if (this.acceptMediaTypes != null) { + requestDefinition.getHeaders().setAccept(this.acceptMediaTypes); + } + return requestDefinition; + } + + + /** + * Introspect the method and create the request factory for it. + */ + public static HttpRequestDefinitionFactory create(Method method, Class containingClass) { + + HttpRequest annot1 = AnnotatedElementUtils.findMergedAnnotation(containingClass, HttpRequest.class); + HttpRequest annot2 = AnnotatedElementUtils.findMergedAnnotation(method, HttpRequest.class); + + Assert.notNull(annot2, "Expected HttpRequest annotation"); + + HttpMethod httpMethod = initHttpMethod(annot1, annot2); + String url = initUrl(annot1, annot2); + MediaType contentType = initContentType(annot1, annot2); + List acceptableMediaTypes = initAccept(annot1, annot2); + + return new HttpRequestDefinitionFactory(httpMethod, url, contentType, acceptableMediaTypes); + } + + + @Nullable + private static HttpMethod initHttpMethod(@Nullable HttpRequest typeAnnot, HttpRequest annot) { + + String value1 = (typeAnnot != null ? typeAnnot.method() : null); + String value2 = annot.method(); + + if (StringUtils.hasText(value2)) { + return HttpMethod.valueOf(value2); + } + + if (StringUtils.hasText(value1)) { + return HttpMethod.valueOf(value1); + } + + return null; + } + + @Nullable + private static String initUrl(@Nullable HttpRequest typeAnnot, HttpRequest annot) { + + String url1 = (typeAnnot != null ? typeAnnot.url() : null); + String url2 = annot.url(); + + boolean hasUrl1 = StringUtils.hasText(url1); + boolean hasUrl2 = StringUtils.hasText(url2); + + if (hasUrl1 && hasUrl2) { + return (url1 + (!url1.endsWith("/") && !url2.startsWith("/") ? "/" : "") + url2); + } + + if (!hasUrl1 && !hasUrl2) { + return null; + } + + return (hasUrl2 ? url2 : url1); + } + + @Nullable + private static MediaType initContentType(@Nullable HttpRequest typeAnnot, HttpRequest annot) { + + String value1 = (typeAnnot != null ? typeAnnot.contentType() : null); + String value2 = annot.contentType(); + + if (StringUtils.hasText(value2)) { + return MediaType.parseMediaType(value2); + } + + if (StringUtils.hasText(value1)) { + return MediaType.parseMediaType(value1); + } + + return null; + } + + @Nullable + private static List initAccept(@Nullable HttpRequest typeAnnot, HttpRequest annot) { + + String[] value1 = (typeAnnot != null ? typeAnnot.accept() : null); + String[] value2 = annot.accept(); + + if (!ObjectUtils.isEmpty(value2)) { + return MediaType.parseMediaTypes(Arrays.asList(value2)); + } + + if (!ObjectUtils.isEmpty(value1)) { + return MediaType.parseMediaTypes(Arrays.asList(value1)); + } + + return null; + } + + } + + + /** + * Function to execute a request, obtain a response, and adapt to the expected + * return type blocking if necessary. + */ + private record ResponseFunction( + Function> responseFunction, + @Nullable ReactiveAdapter returnTypeAdapter, + boolean blockForOptional, Duration blockTimeout) { + + private ResponseFunction( + Function> responseFunction, + @Nullable ReactiveAdapter returnTypeAdapter, + boolean blockForOptional, Duration blockTimeout) { + + this.responseFunction = responseFunction; + this.returnTypeAdapter = returnTypeAdapter; + this.blockForOptional = blockForOptional; + this.blockTimeout = blockTimeout; + } + + @Nullable + public Object execute(HttpRequestDefinition requestDefinition) { + + Publisher responsePublisher = this.responseFunction.apply(requestDefinition); + + if (this.returnTypeAdapter != null) { + return this.returnTypeAdapter.fromPublisher(responsePublisher); + } + + return (this.blockForOptional ? + ((Mono) responsePublisher).blockOptional(this.blockTimeout) : + ((Mono) responsePublisher).block(this.blockTimeout)); + } + + + /** + * Create the {@code ResponseFunction} that matches method return type. + */ + public static ResponseFunction create( + HttpClientAdapter client, Method method, ReactiveAdapterRegistry reactiveRegistry, + Duration blockTimeout) { + + MethodParameter returnParam = new MethodParameter(method, -1); + Class returnType = returnParam.getParameterType(); + ReactiveAdapter reactiveAdapter = reactiveRegistry.getAdapter(returnType); + + MethodParameter actualParam = (reactiveAdapter != null ? returnParam.nested() : returnParam.nestedIfOptional()); + Class actualType = actualParam.getNestedParameterType(); + + Function> responseFunction; + if (actualType.equals(void.class) || actualType.equals(Void.class)) { + responseFunction = client::requestToVoid; + } + else if (reactiveAdapter != null && reactiveAdapter.isNoValue()) { + responseFunction = client::requestToVoid; + } + else if (actualType.equals(HttpHeaders.class)) { + responseFunction = client::requestToHeaders; + } + else if (actualType.equals(ResponseEntity.class)) { + MethodParameter bodyParam = actualParam.nested(); + Class bodyType = bodyParam.getNestedParameterType(); + if (bodyType.equals(Void.class)) { + responseFunction = client::requestToBodilessEntity; + } + else { + ReactiveAdapter bodyAdapter = reactiveRegistry.getAdapter(bodyType); + responseFunction = initResponseEntityFunction(client, bodyParam, bodyAdapter); + } + } + else { + responseFunction = initBodyFunction(client, actualParam, reactiveAdapter); + } + + boolean blockForOptional = actualType.equals(Optional.class); + return new ResponseFunction(responseFunction, reactiveAdapter, blockForOptional, blockTimeout); + } + + @SuppressWarnings("ConstantConditions") + private static Function> initResponseEntityFunction( + HttpClientAdapter client, MethodParameter methodParam, @Nullable ReactiveAdapter reactiveAdapter) { + + if (reactiveAdapter == null) { + return request -> client.requestToEntity( + request, ParameterizedTypeReference.forType(methodParam.getNestedGenericParameterType())); + } + + Assert.isTrue(reactiveAdapter.isMultiValue(), + "ResponseEntity body must be a concrete value or a multi-value Publisher"); + + ParameterizedTypeReference bodyType = + ParameterizedTypeReference.forType(methodParam.nested().getNestedGenericParameterType()); + + // Shortcut for Flux + if (reactiveAdapter.getReactiveType().equals(Flux.class)) { + return request -> client.requestToEntityFlux(request, bodyType); + } + + return request -> client.requestToEntityFlux(request, bodyType) + .map(entity -> { + Object body = reactiveAdapter.fromPublisher(entity.getBody()); + return new ResponseEntity<>(body, entity.getHeaders(), entity.getStatusCode()); + }); + } + + private static Function> initBodyFunction( + HttpClientAdapter client, MethodParameter methodParam, @Nullable ReactiveAdapter reactiveAdapter) { + + ParameterizedTypeReference bodyType = + ParameterizedTypeReference.forType(methodParam.getNestedGenericParameterType()); + + return (reactiveAdapter != null && reactiveAdapter.isMultiValue() ? + request -> client.requestToBodyFlux(request, bodyType) : + request -> client.requestToBody(request, bodyType)); + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethodArgumentResolver.java new file mode 100644 index 0000000000..f46b40e311 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethodArgumentResolver.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.web.service.annotation.HttpRequest; + + +/** + * Resolve an argument from an {@link HttpRequest @HttpRequest} annotated method + * to one or more HTTP request values. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public interface HttpServiceMethodArgumentResolver { + + /** + * Resolve the argument value. + * @param argument the argument value + * @param parameter the method parameter for the argument + * @param requestDefinition container to add HTTP request values to + */ + void resolve(@Nullable Object argument, MethodParameter parameter, HttpRequestDefinition requestDefinition); + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java new file mode 100644 index 0000000000..02b2aaf48b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.service.annotation.HttpRequest; + + +/** + * Factory to create a proxy for an HTTP service with {@link HttpRequest} methods. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public class HttpServiceProxyFactory { + + private final List argumentResolvers; + + private final HttpClientAdapter clientAdapter; + + private final ReactiveAdapterRegistry reactiveAdapterRegistry; + + private final Duration blockTimeout; + + + public HttpServiceProxyFactory( + List argumentResolvers, HttpClientAdapter clientAdapter, + ReactiveAdapterRegistry reactiveAdapterRegistry, Duration blockTimeout) { + + this.argumentResolvers = argumentResolvers; + this.clientAdapter = clientAdapter; + this.reactiveAdapterRegistry = reactiveAdapterRegistry; + this.blockTimeout = blockTimeout; + } + + + /** + * Create a proxy for executing requests to the given HTTP service. + * @param serviceType the HTTP service to create a proxy for + * @param the service type + * @return the created proxy + */ + public S createService(Class serviceType) { + + List methods = + MethodIntrospector.selectMethods(serviceType, this::isHttpRequestMethod) + .stream() + .map(method -> initServiceMethod(method, serviceType)) + .toList(); + + return ProxyFactory.getProxy(serviceType, new HttpServiceMethodInterceptor(methods)); + } + + private boolean isHttpRequestMethod(Method method) { + return AnnotatedElementUtils.hasAnnotation(method, HttpRequest.class); + } + + private HttpServiceMethod initServiceMethod(Method method, Class serviceType) { + return new HttpServiceMethod( + method, serviceType, this.argumentResolvers, + this.clientAdapter, this.reactiveAdapterRegistry, this.blockTimeout); + } + + + /** + * + */ + private static final class HttpServiceMethodInterceptor implements MethodInterceptor { + + private final Map serviceMethodMap = new HashMap<>(); + + private HttpServiceMethodInterceptor(List methods) { + methods.forEach(serviceMethod -> this.serviceMethodMap.put(serviceMethod.getMethod(), serviceMethod)); + } + + @Nullable + @Override + public Object invoke(@NotNull MethodInvocation invocation) throws Throwable { + Method method = invocation.getMethod(); + HttpServiceMethod httpServiceMethod = this.serviceMethodMap.get(method); + return httpServiceMethod.invoke(invocation.getArguments()); + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/package-info.java b/spring-web/src/main/java/org/springframework/web/service/invoker/package-info.java new file mode 100644 index 0000000000..e55a459862 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/package-info.java @@ -0,0 +1,9 @@ +/** + * + */ +@NonNullApi +@NonNullFields +package org.springframework.web.service.invoker; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-web/src/main/java/org/springframework/web/service/package-info.java b/spring-web/src/main/java/org/springframework/web/service/package-info.java new file mode 100644 index 0000000000..4fbe50a5af --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/package-info.java @@ -0,0 +1,10 @@ +/** + * Annotations to declare an HTTP service contract with request methods along + * with a proxy factory backed by client-driven implementation. + */ +@NonNullApi +@NonNullFields +package org.springframework.web.service; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java new file mode 100644 index 0000000000..98fd65aff6 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java @@ -0,0 +1,378 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.service.invoker; + + +import java.time.Duration; +import java.util.Collections; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.web.service.annotation.GetRequest; +import org.springframework.web.service.annotation.HttpRequest; +import org.springframework.web.service.annotation.PostRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_CBOR_VALUE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + + +/** + * Tests for {@link HttpServiceMethod} with a test {@link TestHttpClientAdapter} + * that stubs the client invocations. + * + *

The tests do not create nor invoke {@code HttpServiceMethod} directly but + * rather use {@link HttpServiceProxyFactory} to create a service proxy in order to + * use a strongly typed interface without the need for class casts. + * + * @author Rossen Stoyanchev + */ +public class HttpServiceMethodTests { + + private static final ParameterizedTypeReference BODY_TYPE = new ParameterizedTypeReference<>() {}; + + + private final TestHttpClientAdapter clientAdapter = new TestHttpClientAdapter(); + + + @Test + void reactorService() { + + ReactorService service = createService(ReactorService.class); + + Mono voidMono = service.execute(); + StepVerifier.create(voidMono).verifyComplete(); + verifyClientInvocation("requestToVoid", null); + + Mono headersMono = service.getHeaders(); + StepVerifier.create(headersMono).expectNextCount(1).verifyComplete(); + verifyClientInvocation("requestToHeaders", null); + + Mono body = service.getBody(); + StepVerifier.create(body).expectNext("requestToBody").verifyComplete(); + verifyClientInvocation("requestToBody", BODY_TYPE); + + Flux fluxBody = service.getFluxBody(); + StepVerifier.create(fluxBody).expectNext("request", "To", "Body", "Flux").verifyComplete(); + verifyClientInvocation("requestToBodyFlux", BODY_TYPE); + + Mono> voidEntity = service.getVoidEntity(); + StepVerifier.create(voidEntity).expectNext(ResponseEntity.ok().build()).verifyComplete(); + verifyClientInvocation("requestToBodilessEntity", null); + + Mono> entity = service.getEntity(); + StepVerifier.create(entity).expectNext(ResponseEntity.ok("requestToEntity")); + verifyClientInvocation("requestToEntity", BODY_TYPE); + + Mono>> fluxEntity= service.getFluxEntity(); + StepVerifier.create(fluxEntity.flatMapMany(HttpEntity::getBody)).expectNext("request", "To", "Entity", "Flux").verifyComplete(); + verifyClientInvocation("requestToEntityFlux", BODY_TYPE); + } + + @Test + void rxJavaService() { + + RxJavaService service = createService(RxJavaService.class); + + Completable completable = service.execute(); + assertThat(completable).isNotNull(); + + Single headersSingle = service.getHeaders(); + assertThat(headersSingle.blockingGet()).isNotNull(); + + Single bodySingle = service.getBody(); + assertThat(bodySingle.blockingGet()).isEqualTo("requestToBody"); + + Flowable bodyFlow = service.getFlowableBody(); + assertThat(bodyFlow.toList().blockingGet()).asList().containsExactly("request", "To", "Body", "Flux"); + + Single> voidEntity = service.getVoidEntity(); + assertThat(voidEntity.blockingGet().getBody()).isNull(); + + Single> entitySingle = service.getEntity(); + assertThat(entitySingle.blockingGet().getBody()).isEqualTo("requestToEntity"); + + Single>> entityFlow = service.getFlowableEntity(); + Flowable body = (entityFlow.blockingGet()).getBody(); + assertThat(body.toList().blockingGet()).containsExactly("request", "To", "Entity", "Flux"); + } + + @Test + void blockingService() { + + BlockingService service = createService(BlockingService.class); + + service.execute(); + + HttpHeaders headers = service.getHeaders(); + assertThat(headers).isNotNull(); + + String body = service.getBody(); + assertThat(body).isEqualTo("requestToBody"); + + ResponseEntity entity = service.getEntity(); + assertThat(entity.getBody()).isEqualTo("requestToEntity"); + + ResponseEntity voidEntity = service.getVoidEntity(); + assertThat(voidEntity.getBody()).isNull(); + } + + @Test + void methodAnnotatedService() { + + MethodAnnotatedService service = createService(MethodAnnotatedService.class); + + service.performGet(); + + HttpRequestDefinition request = this.clientAdapter.getRequestDefinition(); + assertThat(request.getHttpMethod()).isEqualTo(HttpMethod.GET); + assertThat(request.getUriTemplate()).isNull(); + assertThat(request.getHeaders().getContentType()).isNull(); + assertThat(request.getHeaders().getAccept()).isEmpty(); + + service.performPost(); + + request = this.clientAdapter.getRequestDefinition(); + assertThat(request.getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(request.getUriTemplate()).isEqualTo("/url"); + assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(request.getHeaders().getAccept()).containsExactly(MediaType.APPLICATION_JSON); + } + + @Test + void typeAndMethodAnnotatedService() { + + MethodAnnotatedService service = createService(TypeAndMethodAnnotatedService.class); + + service.performGet(); + + HttpRequestDefinition request = this.clientAdapter.getRequestDefinition(); + assertThat(request.getHttpMethod()).isEqualTo(HttpMethod.GET); + assertThat(request.getUriTemplate()).isEqualTo("/base"); + assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_CBOR); + assertThat(request.getHeaders().getAccept()).containsExactly(MediaType.APPLICATION_CBOR); + + service.performPost(); + + request = this.clientAdapter.getRequestDefinition(); + assertThat(request.getHttpMethod()).isEqualTo(HttpMethod.POST); + assertThat(request.getUriTemplate()).isEqualTo("/base/url"); + assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(request.getHeaders().getAccept()).containsExactly(MediaType.APPLICATION_JSON); + } + + private S createService(Class serviceType) { + + HttpServiceProxyFactory factory = new HttpServiceProxyFactory( + Collections.emptyList(), this.clientAdapter, ReactiveAdapterRegistry.getSharedInstance(), + Duration.ofSeconds(5)); + + return factory.createService(serviceType); + } + + private void verifyClientInvocation(String methodName, @Nullable ParameterizedTypeReference expectedBodyType) { + assertThat((this.clientAdapter.getMethodName())).isEqualTo(methodName); + assertThat(this.clientAdapter.getBodyType()).isEqualTo(expectedBodyType); + } + + + @SuppressWarnings("unused") + private interface ReactorService { + + @HttpRequest + Mono execute(); + + @GetRequest + Mono getHeaders(); + + @GetRequest + Mono getBody(); + + @GetRequest + Flux getFluxBody(); + + @GetRequest + Mono> getVoidEntity(); + + @GetRequest + Mono> getEntity(); + + @GetRequest + Mono>> getFluxEntity(); + } + + + @SuppressWarnings("unused") + private interface RxJavaService { + + @HttpRequest + Completable execute(); + + @GetRequest + Single getHeaders(); + + @GetRequest + Single getBody(); + + @GetRequest + Flowable getFlowableBody(); + + @GetRequest + Single> getVoidEntity(); + + @GetRequest + Single> getEntity(); + + @GetRequest + Single>> getFlowableEntity(); + } + + + @SuppressWarnings("unused") + private interface BlockingService { + + @HttpRequest + void execute(); + + @GetRequest + HttpHeaders getHeaders(); + + @GetRequest + String getBody(); + + @GetRequest + ResponseEntity getVoidEntity(); + + @GetRequest + ResponseEntity getEntity(); + } + + + @SuppressWarnings("unused") + private interface MethodAnnotatedService { + + @GetRequest + void performGet(); + + @PostRequest(url = "/url", contentType = APPLICATION_JSON_VALUE, accept = APPLICATION_JSON_VALUE) + void performPost(); + + } + + + @SuppressWarnings("unused") + @HttpRequest(url = "/base", contentType = APPLICATION_CBOR_VALUE, accept = APPLICATION_CBOR_VALUE) + private interface TypeAndMethodAnnotatedService extends MethodAnnotatedService { + } + + + @SuppressWarnings("unchecked") + private static class TestHttpClientAdapter implements HttpClientAdapter { + + @Nullable + private String methodName; + + @Nullable + private HttpRequestDefinition requestDefinition; + + @Nullable + private ParameterizedTypeReference bodyType; + + + public String getMethodName() { + assertThat(this.methodName).isNotNull(); + return this.methodName; + } + + public HttpRequestDefinition getRequestDefinition() { + assertThat(this.requestDefinition).isNotNull(); + return this.requestDefinition; + } + + @Nullable + public ParameterizedTypeReference getBodyType() { + return this.bodyType; + } + + + @Override + public Mono requestToVoid(HttpRequestDefinition def) { + saveInput("requestToVoid", def, null); + return Mono.empty(); + } + + @Override + public Mono requestToHeaders(HttpRequestDefinition def) { + saveInput("requestToHeaders", def, null); + return Mono.just(new HttpHeaders()); + } + + @Override + public Mono requestToBody(HttpRequestDefinition def, ParameterizedTypeReference bodyType) { + saveInput("requestToBody", def, bodyType); + return (Mono) Mono.just(getMethodName()); + } + + @Override + public Flux requestToBodyFlux(HttpRequestDefinition def, ParameterizedTypeReference bodyType) { + saveInput("requestToBodyFlux", def, bodyType); + return (Flux) Flux.just("request", "To", "Body", "Flux"); + } + + @Override + public Mono> requestToBodilessEntity(HttpRequestDefinition def) { + saveInput("requestToBodilessEntity", def, null); + return Mono.just(ResponseEntity.ok().build()); + } + + @Override + public Mono> requestToEntity(HttpRequestDefinition def, ParameterizedTypeReference bodyType) { + saveInput("requestToEntity", def, bodyType); + return Mono.just((ResponseEntity) ResponseEntity.ok("requestToEntity")); + } + + @Override + public Mono>> requestToEntityFlux(HttpRequestDefinition def, ParameterizedTypeReference bodyType) { + saveInput("requestToEntityFlux", def, bodyType); + return Mono.just(ResponseEntity.ok((Flux) Flux.just("request", "To", "Entity", "Flux"))); + } + + private void saveInput( + String methodName, HttpRequestDefinition definition, @Nullable ParameterizedTypeReference bodyType) { + + this.methodName = methodName; + this.requestDefinition = definition; + this.bodyType = bodyType; + } + + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java new file mode 100644 index 0000000000..c76f4132df --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.function.client.support; + + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.HttpClientAdapter; +import org.springframework.web.service.invoker.HttpRequestDefinition; + + +/** + * {@link HttpClientAdapter} implementation for {@link WebClient}. + * + * @author Rossen Stoyanchev + * @since 6.0 + */ +public class WebClientAdapter implements HttpClientAdapter { + + private final WebClient webClient; + + + public WebClientAdapter(WebClient webClient) { + this.webClient = webClient; + } + + + @Override + public Mono requestToVoid(HttpRequestDefinition request) { + return toBodySpec(request).exchangeToMono(ClientResponse::releaseBody); + } + + @Override + public Mono requestToHeaders(HttpRequestDefinition request) { + return toBodySpec(request).retrieve().toBodilessEntity().map(ResponseEntity::getHeaders); + } + + @Override + public Mono requestToBody(HttpRequestDefinition request, ParameterizedTypeReference bodyType) { + return toBodySpec(request).retrieve().bodyToMono(bodyType); + } + + @Override + public Flux requestToBodyFlux(HttpRequestDefinition request, ParameterizedTypeReference bodyType) { + return toBodySpec(request).retrieve().bodyToFlux(bodyType); + } + + @Override + public Mono> requestToBodilessEntity(HttpRequestDefinition request) { + return toBodySpec(request).retrieve().toBodilessEntity(); + } + + @Override + public Mono> requestToEntity(HttpRequestDefinition request, ParameterizedTypeReference bodyType) { + return toBodySpec(request).retrieve().toEntity(bodyType); + } + + @Override + public Mono>> requestToEntityFlux(HttpRequestDefinition request, ParameterizedTypeReference bodyType) { + return toBodySpec(request).retrieve().toEntityFlux(bodyType); + } + + @SuppressWarnings("ReactiveStreamsUnusedPublisher") + private WebClient.RequestBodySpec toBodySpec(HttpRequestDefinition request) { + + HttpMethod httpMethod = request.getHttpMethodRequired(); + WebClient.RequestBodyUriSpec uriSpec = this.webClient.method(httpMethod); + + WebClient.RequestBodySpec bodySpec; + if (request.getUri() != null) { + bodySpec = uriSpec.uri(request.getUri()); + } + else if (request.getUriTemplate() != null) { + bodySpec = (!request.getUriVariables().isEmpty() ? + uriSpec.uri(request.getUriTemplate(), request.getUriVariables()) : + uriSpec.uri(request.getUriTemplate(), request.getUriVariableValues())); + } + else { + bodySpec = uriSpec.uri(""); + } + + bodySpec.headers(headers -> headers.putAll(request.getHeaders())); + bodySpec.cookies(cookies -> cookies.putAll(request.getCookies())); + + if (request.getBodyValue() != null) { + bodySpec.bodyValue(request.getBodyValue()); + } + else if (request.getBodyPublisher() != null) { + bodySpec.body(request.getBodyPublisher(), request.getBodyPublisherElementType()); + } + + return bodySpec; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/service/package-info.java b/spring-webflux/src/main/java/org/springframework/web/reactive/service/package-info.java new file mode 100644 index 0000000000..c640e5eb4d --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/service/package-info.java @@ -0,0 +1,9 @@ +/** + * Support for an HTTP service proxy created from an interface declaration. + */ +@NonNullApi +@NonNullFields +package org.springframework.web.reactive.service; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java new file mode 100644 index 0000000000..709ac7383e --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.function.client.support; + + +import java.io.IOException; +import java.time.Duration; +import java.util.Collections; +import java.util.function.Consumer; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.annotation.GetRequest; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + + +/** + * Integration tests for {@link HttpServiceProxyFactory HTTP Service proxy} + * using {@link WebClient} and {@link MockWebServer}. + * + * @author Rossen Stoyanchev + */ +public class WebClientHttpServiceProxyTests { + + private MockWebServer server; + + private TestHttpService httpService; + + + @BeforeEach + void setUp() { + this.server = new MockWebServer(); + WebClient webClient = WebClient + .builder() + .clientConnector(new ReactorClientHttpConnector()) + .baseUrl(this.server.url("/").toString()) + .build(); + + WebClientAdapter webClientAdapter = new WebClientAdapter(webClient); + + HttpServiceProxyFactory proxyFactory = new HttpServiceProxyFactory( + Collections.emptyList(), webClientAdapter, ReactiveAdapterRegistry.getSharedInstance(), + Duration.ofSeconds(5)); + + this.httpService = proxyFactory.createService(TestHttpService.class); + } + + @SuppressWarnings("ConstantConditions") + @AfterEach + void shutdown() throws IOException { + if (this.server != null) { + this.server.shutdown(); + } + } + + + @Test + void greeting() { + + prepareResponse(response -> + response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + StepVerifier.create(this.httpService.getGreeting()) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + private void prepareResponse(Consumer consumer) { + MockResponse response = new MockResponse(); + consumer.accept(response); + this.server.enqueue(response); + } + + + private interface TestHttpService { + + @GetRequest("/greeting") + Mono getGreeting(); + + } + + +}