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:
+ *
+ * - {@link java.net.URI} -- dynamic URL
+ *
- {@link org.springframework.http.HttpMethod} - dynamic HTTP method
+ *
- {@link org.springframework.http.HttpHeaders} - request headers
+ *
- {@link org.springframework.http.HttpCookie} - request headers
+ *
- ...
+ *
+ *
+ * @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();
+
+ }
+
+
+}