diff --git a/dependencies.toml b/dependencies.toml index 99d097c6856..ee8677f051d 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -1191,9 +1191,18 @@ module = "org.slf4j:slf4j-api" version.ref = "slf4j2" javadocs = "https://www.javadoc.io/doc/org.slf4j/slf4j-api/2.0.7/" +[libraries.spring6-context] +module = "org.springframework:spring-context" +version.ref = "spring6" +[libraries.spring6-test] +module = "org.springframework:spring-test" +version.ref = "spring6" [libraries.spring6-web] module = "org.springframework:spring-web" version.ref = "spring6" +[libraries.spring6-webflux] +module = "org.springframework:spring-webflux" +version.ref = "spring6" [libraries.spring-boot2-actuator-autoconfigure] module = "org.springframework.boot:spring-boot-actuator-autoconfigure" diff --git a/settings.gradle b/settings.gradle index ee8d2c8b377..6220e91c5d3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -156,6 +156,7 @@ includeWithFlags ':spring:boot3-autoconfigure', 'java17', 'publish', 'r includeWithFlags ':spring:boot3-starter', 'java17', 'publish', 'relocate' includeWithFlags ':spring:boot3-webflux-autoconfigure', 'java17', 'publish', 'relocate' includeWithFlags ':spring:boot3-webflux-starter', 'java17', 'publish', 'relocate' +includeWithFlags ':spring:spring6', 'java17', 'publish', 'relocate' includeWithFlags ':dropwizard1', 'java', 'publish', 'relocate', 'no_aggregation' includeWithFlags ':dropwizard2', 'java', 'publish', 'relocate' diff --git a/spring/boot2-webflux-autoconfigure/build.gradle b/spring/boot2-webflux-autoconfigure/build.gradle index b0c2436dd4f..7d39bb3b929 100644 --- a/spring/boot2-webflux-autoconfigure/build.gradle +++ b/spring/boot2-webflux-autoconfigure/build.gradle @@ -64,9 +64,20 @@ task copyBoot3Sources(type: Copy) { into "${project.ext.genSrcDir}/main/java" } +task copySpring6Sources(type: Copy) { + from("${rootProject.projectDir}/spring/spring6/src/main/java") { + include '**/ArmeriaClientHttpRequest.java' + include '**/ArmeriaClientHttpResponse.java' + include '**/DataBufferFactoryWrapper.java' + } + + into "${project.ext.genSrcDir}/main/java" +} + // Copy the main and test sources from ':spring:boot3-webflux-autoconfigure'. task generateSources(type: Copy) { dependsOn(tasks.copyBoot3Sources) + dependsOn(tasks.copySpring6Sources) from("${"${rootProject.projectDir}/spring/boot3-webflux-autoconfigure"}/src") { exclude '**/AbstractServerHttpResponseVersionSpecific.java' exclude '**/AbstractServerHttpRequestVersionSpecific.java' diff --git a/spring/boot3-webflux-autoconfigure/build.gradle b/spring/boot3-webflux-autoconfigure/build.gradle index 9fc879e1c89..8fccde7e96e 100644 --- a/spring/boot3-webflux-autoconfigure/build.gradle +++ b/spring/boot3-webflux-autoconfigure/build.gradle @@ -10,6 +10,7 @@ dependencies { // To let a user choose between thrift and thrift0.9. compileOnly project(':thrift0.18') implementation project(':logback') + implementation project(':spring:spring6') api libs.spring.boot3.starter.webflux api libs.jakarta.inject diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientAutoConfiguration.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientAutoConfiguration.java index 357df736179..df3ed3c73f6 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientAutoConfiguration.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientAutoConfiguration.java @@ -28,6 +28,8 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.Builder; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; + /** * An auto-configuration for Armeria-based {@link WebClient}. */ diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpConnector.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpConnector.java index 42f1222d116..f04adeb9e7f 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpConnector.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpConnector.java @@ -36,6 +36,9 @@ import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.client.WebClientBuilder; import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpRequest; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpResponse; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import reactor.core.publisher.Mono; @@ -105,7 +108,7 @@ private ArmeriaClientHttpRequest createRequest(HttpMethod method, URI uri) { checkArgument(!Strings.isNullOrEmpty(path), "path is undefined: %s", uri); final String pathAndQuery = Strings.isNullOrEmpty(query) ? path : path + '?' + query; - return new ArmeriaClientHttpRequest(webClient, method, pathAndQuery, uri, factoryWrapper); + return new ArmeriaClientHttpRequest(webClient, method, pathAndQuery, uri, factoryWrapper, null); } private CompletableFuture createResponse(HttpResponse response) { diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java index 40a73b50c92..f8dd3ae00e5 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java @@ -29,6 +29,7 @@ import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import reactor.core.publisher.Mono; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaReactiveWebServerFactory.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaReactiveWebServerFactory.java index 1014ca92429..7f34b2a631f 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaReactiveWebServerFactory.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaReactiveWebServerFactory.java @@ -69,6 +69,7 @@ import com.linecorp.armeria.spring.ArmeriaSettings; import com.linecorp.armeria.spring.InternalServices; import com.linecorp.armeria.spring.MetricCollectingServiceConfigurator; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.micrometer.core.instrument.MeterRegistry; import io.netty.handler.ssl.ClientAuth; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java index d84abe15a35..6c2c8971459 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequest.java @@ -43,6 +43,7 @@ import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.netty.handler.codec.http.cookie.ServerCookieDecoder; import reactor.core.publisher.Flux; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java index cb839437149..ab8e630ecb1 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java @@ -45,6 +45,7 @@ import com.linecorp.armeria.common.stream.AbortedStreamException; import com.linecorp.armeria.common.stream.CancelledSubscriptionException; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperConfiguration.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperConfiguration.java index 1205ab693d6..5d3248069e1 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperConfiguration.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperConfiguration.java @@ -22,6 +22,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.buffer.DataBufferFactory; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; + /** * A configuration class which creates an {@link DataBufferFactoryWrapper}. */ diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequestTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequestTest.java index 662775e1069..08eeb4aeda8 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequestTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequestTest.java @@ -17,9 +17,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import java.net.URI; import java.util.List; @@ -27,6 +24,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpCookie; import org.springframework.http.HttpMethod; @@ -38,6 +36,11 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.server.Route; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpRequest; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -47,17 +50,28 @@ class ArmeriaClientHttpRequestTest { private static final String TEST_PATH_AND_QUERY = "/index.html?q=1"; + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.service(Route.ofCatchAll(), (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; static WebClient webClient; @BeforeAll public static void beforeClass() { - webClient = mock(WebClient.class); - when(webClient.execute((HttpRequest) any())).thenReturn(HttpResponse.of(HttpStatus.OK)); + webClient = WebClient.builder() + .decorator((delegate, ctx, req) -> { + return HttpResponse.of(HttpStatus.OK); + }) + .build(); } private static ArmeriaClientHttpRequest request() { return new ArmeriaClientHttpRequest(webClient, HttpMethod.GET, TEST_PATH_AND_QUERY, - URI.create("http://localhost"), DataBufferFactoryWrapper.DEFAULT); + URI.create("http://localhost"), DataBufferFactoryWrapper.DEFAULT, + null); } @Test diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponseTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponseTest.java index 29fc0e16e30..2d0555fae93 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponseTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponseTest.java @@ -35,6 +35,8 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.stream.CancelledSubscriptionException; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpResponse; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.netty.util.concurrent.ImmediateEventExecutor; import reactor.core.publisher.Flux; diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequestTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequestTest.java index 2a52c4ddd4c..13d14a91248 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequestTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpRequestTest.java @@ -36,6 +36,7 @@ import com.linecorp.armeria.common.stream.CancelledSubscriptionException; import com.linecorp.armeria.common.util.EventLoopGroups; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java index d8067afe20d..4a9e8ea2368 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java @@ -46,6 +46,7 @@ import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.stream.CancelledSubscriptionException; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.netty.buffer.PooledByteBufAllocator; import reactor.core.publisher.Flux; diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperTest.java index f14671b3df8..c9bbea396a9 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapperTest.java @@ -27,6 +27,7 @@ import org.springframework.core.io.buffer.NettyDataBufferFactory; import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; diff --git a/spring/spring6/build.gradle.kts b/spring/spring6/build.gradle.kts new file mode 100644 index 00000000000..de16e615a25 --- /dev/null +++ b/spring/spring6/build.gradle.kts @@ -0,0 +1,5 @@ +dependencies { + api(libs.spring6.webflux) + testImplementation(libs.spring6.context) + testImplementation(libs.spring6.test) +} diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapter.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapter.java new file mode 100644 index 00000000000..d38121f6200 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapter.java @@ -0,0 +1,282 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring.client; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.Map; + +import org.reactivestreams.Publisher; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.service.invoker.AbstractReactorHttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.ReactiveHttpRequestValues; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilderFactory; + +import com.google.common.base.Strings; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.RequestOptions; +import com.linecorp.armeria.client.RequestOptionsBuilder; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.SplitHttpResponse; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpRequest; +import com.linecorp.armeria.spring.internal.client.ArmeriaClientHttpResponse; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * A {@link ReactorHttpExchangeAdapter} implementation for the Armeria {@link WebClient}. + * This class is used to create + * Spring HTTP Interface on top of Armeria {@link WebClient}. + * + *

Example usage: + *

{@code
+ * import com.linecorp.armeria.client.WebClient;
+ * import org.springframework.web.service.invoker.HttpServiceProxyFactory;
+ *
+ * WebClient webClient = ...;
+ * ArmeriaHttpExchangeAdapter adapter =
+ *   ArmeriaHttpExchangeAdapter.of(webClient);
+ * MyService service =
+ *   HttpServiceProxyFactory.builderFor(adapter)
+ *                          .build()
+ *                          .createClient(MyService.class);
+ * }
+ */ +@UnstableApi +public final class ArmeriaHttpExchangeAdapter extends AbstractReactorHttpExchangeAdapter { + + /** + * Creates a new instance with the specified {@link WebClient}. + */ + public static ArmeriaHttpExchangeAdapter of(WebClient webClient) { + return builder(webClient).build(); + } + + /** + * Returns a new {@link ArmeriaHttpExchangeAdapterBuilder} with the specified {@link WebClient}. + */ + public static ArmeriaHttpExchangeAdapterBuilder builder(WebClient webClient) { + return new ArmeriaHttpExchangeAdapterBuilder(webClient); + } + + private final WebClient webClient; + private final ExchangeStrategies exchangeStrategies; + private final UriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(); + @Nullable + private final StatusHandler statusHandler; + + ArmeriaHttpExchangeAdapter(WebClient webClient, ExchangeStrategies exchangeStrategies, + @Nullable StatusHandler statusHandler) { + this.webClient = webClient; + this.exchangeStrategies = exchangeStrategies; + this.statusHandler = statusHandler; + } + + @Override + public Mono exchangeForMono(HttpRequestValues requestValues) { + return execute(requestValues).flatMap(ClientResponse::releaseBody); + } + + @Override + public Mono exchangeForHeadersMono(HttpRequestValues requestValues) { + return execute(requestValues).map(response -> response.headers().asHttpHeaders()); + } + + @Override + public Mono exchangeForBodyMono(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + return execute(requestValues).flatMap(response -> response.bodyToMono(bodyType)); + } + + @Override + public Flux exchangeForBodyFlux(HttpRequestValues requestValues, + ParameterizedTypeReference bodyType) { + return execute(requestValues).flatMapMany(response -> response.bodyToFlux(bodyType)); + } + + @Override + public Mono> exchangeForBodilessEntityMono( + HttpRequestValues requestValues) { + return execute(requestValues).flatMap(ClientResponse::toBodilessEntity); + } + + @Override + public Mono> exchangeForEntityMono( + HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return execute(requestValues).flatMap(response -> response.toEntity(bodyType)); + } + + @Override + public Mono>> exchangeForEntityFlux( + HttpRequestValues requestValues, ParameterizedTypeReference bodyType) { + return execute(requestValues).map(response -> { + final Flux body = response.bodyToFlux(bodyType); + return ResponseEntity.status(response.statusCode()) + .headers(response.headers().asHttpHeaders()) + .body(body); + }); + } + + /** + * Returns {@code true} because Armeria supports {@link RequestAttribute}. The attributes can be accessed + * in the Armeria decorators using {@link RequestAttributeAccess#get(ClientRequestContext, String)}. + */ + @Override + public boolean supportsRequestAttributes() { + return true; + } + + /** + * Sends the specified {@link HttpRequestValues} to the {@link WebClient} and returns the response. + * + *

Implementation note

+ * In order to encode {@link HttpRequestValues#getBodyValue()} to {@link HttpData}, we need to convert + * the {@link HttpRequestValues} to {@link ClientRequest} first. The serialization process is delegated to + * the {@link ClientRequest}. After that, the request is written to {@link ArmeriaClientHttpRequest} to send + * the request via the {@link WebClient}. + * + *

The response handling has to be done in the reverse order. Armeria {@link HttpResponse} is converted + * into {@link ArmeriaClientHttpResponse} first and then converted into {@link ClientResponse}. + */ + private Mono execute(HttpRequestValues requestValues) { + final URI uri; + if (requestValues.getUri() != null) { + uri = requestValues.getUri(); + } else { + final String uriTemplate = requestValues.getUriTemplate(); + if (uriTemplate != null) { + final UriBuilderFactory uriBuilderFactory = requestValues.getUriBuilderFactory(); + final Map uriVariables = requestValues.getUriVariables(); + if (uriBuilderFactory != null) { + uri = uriBuilderFactory.expand(uriTemplate, uriVariables); + } else { + uri = this.uriBuilderFactory.expand(uriTemplate, uriVariables); + } + } else { + throw new IllegalStateException("Neither full URL nor URI template"); + } + } + + final Map attributes = requestValues.getAttributes(); + final RequestOptions requestOptions; + if (attributes.isEmpty()) { + requestOptions = null; + } else { + final RequestOptionsBuilder requestOptionsBuilder = RequestOptions.builder(); + // Don't need to copy the attributes because it is immutable. + RequestAttributeAccess.set(requestOptionsBuilder, attributes); + requestOptions = requestOptionsBuilder.build(); + } + final String path = uri.getRawPath(); + final String query = uri.getRawQuery(); + checkArgument(path != null, "path is undefined: %s", uri); + final String pathAndQuery = Strings.isNullOrEmpty(query) ? path : path + '?' + query; + final HttpMethod httpMethod = requestValues.getHttpMethod(); + checkArgument(httpMethod != null, "HTTP method is undefined. requestValues: %s", requestValues); + final ArmeriaClientHttpRequest request = + new ArmeriaClientHttpRequest(webClient, httpMethod, pathAndQuery, uri, + DataBufferFactoryWrapper.DEFAULT, requestOptions); + + final Mono response = Mono.fromFuture(request.future()); + return toClientRequest(requestValues, httpMethod, uri) + .writeTo(request, exchangeStrategies) + .then(response) + .flatMap(res -> { + final ClientRequestContext context = request.context(); + assert context != null; + return toClientResponse(res, context); + }); + } + + private static ClientRequest toClientRequest(HttpRequestValues requestValues, HttpMethod httpMethod, + URI uri) { + final ClientRequest.Builder builder = + ClientRequest.create(httpMethod, uri) + .headers(headers -> headers.addAll(requestValues.getHeaders())) + .cookies(cookies -> cookies.addAll(requestValues.getCookies())); + + final Object bodyValue = requestValues.getBodyValue(); + if (bodyValue != null) { + builder.body(BodyInserters.fromValue(bodyValue)); + } else if (requestValues instanceof ReactiveHttpRequestValues) { + final ReactiveHttpRequestValues reactiveRequestValues = (ReactiveHttpRequestValues) requestValues; + @SuppressWarnings("unchecked") + final Publisher body = (Publisher) reactiveRequestValues.getBodyPublisher(); + if (body != null) { + @SuppressWarnings("unchecked") + final ParameterizedTypeReference elementType = + (ParameterizedTypeReference) reactiveRequestValues.getBodyPublisherElementType(); + requireNonNull(elementType, "Publisher body element type is required"); + builder.body(body, elementType); + } + } + + return builder.build(); + } + + private Mono toClientResponse(HttpResponse response, ClientRequestContext ctx) { + final SplitHttpResponse splitResponse = response.split(); + return Mono.fromFuture(splitResponse.headers()).flatMap(headers -> { + if (statusHandler != null) { + final Throwable cause = statusHandler.handle(ctx, headers.status()); + if (cause != null) { + // It would be better to release the resources by consuming than sending an RST stream by + // aborting. Because: + // - The server may have sent a response body already. + // - In general, a stream response is not expected with an error status. + // - If too many RSTs are sent, it can be mistaken for 'Rapid Reset' attacks. + splitResponse.body().subscribe(); + return Mono.error(cause); + } + } + + final ArmeriaClientHttpResponse httpResponse = + new ArmeriaClientHttpResponse(headers, splitResponse, + DataBufferFactoryWrapper.DEFAULT); + + final HttpStatusCode statusCode = HttpStatusCode.valueOf(httpResponse.getRawStatusCode()); + final ClientResponse clientResponse = + ClientResponse.create(statusCode, exchangeStrategies) + .cookies(cookies -> cookies.addAll(httpResponse.getCookies())) + .headers(headers0 -> headers0.addAll(httpResponse.getHeaders())) + .body(httpResponse.getBody()) + .build(); + return Mono.just(clientResponse); + }); + } +} diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterBuilder.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterBuilder.java new file mode 100644 index 00000000000..caaeab32a99 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterBuilder.java @@ -0,0 +1,74 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring.client; + +import static java.util.Objects.requireNonNull; + +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A builder for creating a new instance of {@link ArmeriaHttpExchangeAdapter}. + */ +@UnstableApi +public final class ArmeriaHttpExchangeAdapterBuilder { + + private final WebClient webClient; + private ExchangeStrategies exchangeStrategies = ExchangeStrategies.withDefaults(); + @Nullable + private StatusHandler statusHandler; + + ArmeriaHttpExchangeAdapterBuilder(WebClient webClient) { + this.webClient = requireNonNull(webClient, "webClient"); + } + + /** + * Sets the {@link ExchangeStrategies} that overrides the + * {@linkplain ExchangeStrategies#withDefaults() default strategies}. + */ + public ArmeriaHttpExchangeAdapterBuilder exchangeStrategies(ExchangeStrategies exchangeStrategies) { + requireNonNull(exchangeStrategies, "exchangeStrategies"); + this.exchangeStrategies = exchangeStrategies; + return this; + } + + /** + * Adds the {@link StatusHandler} that converts specific error {@link HttpStatus}s to a {@link Throwable} + * to be propagated downstream instead of the response. + */ + public ArmeriaHttpExchangeAdapterBuilder statusHandler(StatusHandler statusHandler) { + requireNonNull(statusHandler, "statusHandler"); + if (this.statusHandler == null) { + this.statusHandler = statusHandler; + } else { + this.statusHandler = this.statusHandler.orElse(statusHandler); + } + return this; + } + + /** + * Returns a newly-created {@link ArmeriaHttpExchangeAdapter} based on the properties of this builder. + */ + public ArmeriaHttpExchangeAdapter build() { + return new ArmeriaHttpExchangeAdapter(webClient, exchangeStrategies, statusHandler); + } +} diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/RequestAttributeAccess.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/RequestAttributeAccess.java new file mode 100644 index 00000000000..4a571fa24e9 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/RequestAttributeAccess.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring.client; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; + +import org.springframework.web.bind.annotation.RequestAttribute; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.RequestOptionsBuilder; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.netty.util.AttributeKey; + +/** + * Provides access to {@link RequestAttribute}s specified when sending a request. + * + *

Example: + *

{@code
+ * interface GreetingService {
+ *    @GetExchange("/hello")
+ *    Mono hello(@RequestAttribute("requestId") String requestId);
+ * }
+ *
+ * WebClient
+ *   .builder("http://example.com")
+ *   .decorator((delegate, ctx, req) -> {
+ *      final String requestId = RequestAttributeAccess.get(ctx, "requestId");
+ *      ctx.addAdditionalRequestHeader("X-Request-Id", requestId);
+ *     return delegate.execute(ctx, req);
+ *   })
+ *   .build();
+ *
+ * GreetingService greetingService = ...
+ * // The request attribute will be set to the "X-Request-Id" header.
+ * greetingService.hello("123");
+ * }
+ */ +@UnstableApi +public final class RequestAttributeAccess { + + private static final AttributeKey> KEY = + AttributeKey.valueOf(RequestAttributeAccess.class, "ATTRIBUTES_KEY"); + + /** + * Returns the {@link RequestAttribute} value associated with the name. + */ + @Nullable + public static Object get(ClientRequestContext ctx, String name) { + requireNonNull(ctx, "ctx"); + requireNonNull(name, "name"); + final Map attrs = ctx.attr(KEY); + if (attrs == null) { + return null; + } + return attrs.get(name); + } + + static void set(RequestOptionsBuilder requestOptionsBuilder, Map attributes) { + requestOptionsBuilder.attr(KEY, attributes); + } + + private RequestAttributeAccess() {} +} diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/StatusHandler.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/StatusHandler.java new file mode 100644 index 00000000000..9f4a4686342 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/StatusHandler.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you 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 com.linecorp.armeria.spring.client; + +import static java.util.Objects.requireNonNull; + +import java.util.function.Function; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A handler that converts specific error {@link HttpStatus}s to a {@link Throwable} to be propagated + * downstream instead of the response. + */ +@UnstableApi +@FunctionalInterface +public interface StatusHandler { + + /** + * Returns a new {@link StatusHandler} that converts the specified {@link HttpStatus} to + * a {@link Throwable}. + */ + static StatusHandler of(HttpStatus status, + Function errorFunction) { + requireNonNull(status, "status"); + requireNonNull(errorFunction, "errorFunction"); + return (ctx, s) -> s.equals(status) ? errorFunction.apply(ctx) : null; + } + + /** + * Converts the specified {@link HttpStatus} to a {@link Throwable}. + * If the {@link HttpStatus} is not handled by this handler, it must return {@code null}. + */ + @Nullable + Throwable handle(ClientRequestContext ctx, HttpStatus status); + + /** + * Returns a new {@link StatusHandler} that tries this handler first and then the specified {@code other} + * handler if this handler does not handle the specified {@link HttpStatus}. + */ + default StatusHandler orElse(StatusHandler other) { + requireNonNull(other, "other"); + return (ctx, status) -> { + final Throwable cause = handle(ctx, status); + return cause != null ? cause : other.handle(ctx, status); + }; + } +} diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/package-info.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/package-info.java new file mode 100644 index 00000000000..6535d480e07 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/client/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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. + */ + +/** + * + * Spring 6 HTTP interface integration. + */ +@NonNullByDefault +package com.linecorp.armeria.spring.client; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequest.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpRequest.java similarity index 75% rename from spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequest.java rename to spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpRequest.java index 87e694b4bf6..1d473e0f14e 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpRequest.java +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.spring.web.reactive; +package com.linecorp.armeria.spring.internal.client; import static com.google.common.collect.ImmutableList.toImmutableList; import static java.util.Objects.requireNonNull; @@ -35,6 +35,10 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.ClientRequestContextCaptor; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.RequestOptions; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; @@ -43,6 +47,7 @@ import com.linecorp.armeria.common.RequestHeaders; import com.linecorp.armeria.common.RequestHeadersBuilder; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -50,34 +55,44 @@ /** * A {@link ClientHttpRequest} implementation for the Armeria HTTP client. */ -final class ArmeriaClientHttpRequest extends AbstractClientHttpRequest { +public final class ArmeriaClientHttpRequest extends AbstractClientHttpRequest { private final WebClient client; private final RequestHeadersBuilder headers; private final DataBufferFactoryWrapper factoryWrapper; + @Nullable + private final RequestOptions requestOptions; private final HttpMethod httpMethod; private final URI uri; private final CompletableFuture future = new CompletableFuture<>(); + @Nullable + private ClientRequestContext ctx; @Nullable private HttpRequest request; - ArmeriaClientHttpRequest(WebClient client, HttpMethod httpMethod, String pathAndQuery, - URI uri, DataBufferFactoryWrapper factoryWrapper) { + public ArmeriaClientHttpRequest(WebClient client, HttpMethod httpMethod, String pathAndQuery, + URI uri, DataBufferFactoryWrapper factoryWrapper, + @Nullable RequestOptions requestOptions) { this.client = requireNonNull(client, "client"); this.httpMethod = requireNonNull(httpMethod, "httpMethod"); this.uri = requireNonNull(uri, "uri"); this.factoryWrapper = requireNonNull(factoryWrapper, "factoryWrapper"); + this.requestOptions = requestOptions; headers = RequestHeaders.builder() .add(HttpHeaderNames.METHOD, httpMethod.name()) - .add(HttpHeaderNames.SCHEME, uri.getScheme()) - .add(HttpHeaderNames.AUTHORITY, uri.getRawAuthority()) .add(HttpHeaderNames.PATH, requireNonNull(pathAndQuery, "pathAndQuery")); + if (uri.getScheme() != null) { + headers.add(HttpHeaderNames.SCHEME, uri.getScheme()); + } + if (uri.getRawAuthority() != null) { + headers.add(HttpHeaderNames.AUTHORITY, uri.getRawAuthority()); + } } @Override @@ -127,6 +142,16 @@ public CompletableFuture future() { return future; } + /** + * Returns the {@link ClientRequestContext} associated with this request. + * This method returns {@code null} until the request is sent. + * A non-{@code null} value is available only after {@link #future()} is complete. + */ + @Nullable + public ClientRequestContext context() { + return ctx; + } + @Override public Mono writeWith(Publisher body) { return write(Flux.from(body)); @@ -152,14 +177,23 @@ private Supplier> execute(Supplier supplier) { return () -> Mono.defer(() -> { assert request == null : request; request = supplier.get(); - future.complete(client.execute(request)); + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + final HttpResponse response; + if (requestOptions == null) { + response = client.execute(request); + } else { + response = client.execute(request, requestOptions); + } + ctx = captor.get(); + future.complete(response); + } return Mono.empty(); }); } @VisibleForTesting @Nullable - HttpRequest request() { + public HttpRequest request() { return request; } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponse.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpResponse.java similarity index 90% rename from spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponse.java rename to spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpResponse.java index 2d517c46fe1..6bbaf0d306e 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaClientHttpResponse.java +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/ArmeriaClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.spring.web.reactive; +package com.linecorp.armeria.spring.internal.client; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.toHttp1Headers; import static java.util.Objects.requireNonNull; @@ -32,6 +32,7 @@ import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SplitHttpResponse; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; import io.netty.handler.codec.http.cookie.ClientCookieDecoder; import reactor.core.publisher.Flux; @@ -39,7 +40,7 @@ /** * A {@link ClientHttpResponse} implementation for the Armeria HTTP client. */ -final class ArmeriaClientHttpResponse implements ClientHttpResponse { +public final class ArmeriaClientHttpResponse implements ClientHttpResponse { private final com.linecorp.armeria.common.HttpStatus status; private final ResponseHeaders headers; @@ -51,9 +52,9 @@ final class ArmeriaClientHttpResponse implements ClientHttpResponse { @Nullable private HttpHeaders httpHeaders; - ArmeriaClientHttpResponse(ResponseHeaders headers, - SplitHttpResponse response, - DataBufferFactoryWrapper factoryWrapper) { + public ArmeriaClientHttpResponse(ResponseHeaders headers, + SplitHttpResponse response, + DataBufferFactoryWrapper factoryWrapper) { this.headers = requireNonNull(headers, "headers"); status = headers.status(); diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/package-info.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/package-info.java new file mode 100644 index 00000000000..ba3539a8598 --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/client/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +package com.linecorp.armeria.spring.internal.client; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapper.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/DataBufferFactoryWrapper.java similarity index 89% rename from spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapper.java rename to spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/DataBufferFactoryWrapper.java index 774dc3ac0da..516f25682dc 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/DataBufferFactoryWrapper.java +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/DataBufferFactoryWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.spring.web.reactive; +package com.linecorp.armeria.spring.internal.common; import static java.util.Objects.requireNonNull; @@ -36,15 +36,15 @@ * A wrapper of the configured {@link DataBufferFactory}. This wrapper is in charge of converting objects * between {@link DataBuffer} of Spring framework and {@link HttpData} of Armeria. */ -final class DataBufferFactoryWrapper { +public final class DataBufferFactoryWrapper { - static final DataBufferFactoryWrapper DEFAULT = + public static final DataBufferFactoryWrapper DEFAULT = new DataBufferFactoryWrapper<>(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)); private final T delegate; private final Function converter; - DataBufferFactoryWrapper(T delegate) { + public DataBufferFactoryWrapper(T delegate) { this.delegate = requireNonNull(delegate, "delegate"); converter = delegate instanceof NettyDataBufferFactory ? this::withNettyDataBufferFactory : this::withDataBufferFactory; @@ -60,7 +60,7 @@ public T delegate() { /** * Converts a {@link DataBuffer} into an {@link HttpData}. */ - HttpData toHttpData(DataBuffer dataBuffer) { + public HttpData toHttpData(DataBuffer dataBuffer) { if (dataBuffer instanceof NettyDataBuffer) { return HttpData.wrap(((NettyDataBuffer) dataBuffer).getNativeBuffer()); } @@ -73,7 +73,7 @@ HttpData toHttpData(DataBuffer dataBuffer) { /** * Converts an {@link HttpData} into a {@link DataBuffer}. */ - DataBuffer toDataBuffer(HttpData httpData) { + public DataBuffer toDataBuffer(HttpData httpData) { requireNonNull(httpData, "httpData"); if (!httpData.isPooled()) { return delegate.wrap(ByteBuffer.wrap(httpData.array())); diff --git a/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/package-info.java b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/package-info.java new file mode 100644 index 00000000000..f5f9f0a729f --- /dev/null +++ b/spring/spring6/src/main/java/com/linecorp/armeria/spring/internal/common/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +package com.linecorp.armeria.spring.internal.common; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/spring/spring6/src/test/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterTest.java b/spring/spring6/src/test/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterTest.java new file mode 100644 index 00000000000..76f21f14d53 --- /dev/null +++ b/spring/spring6/src/test/java/com/linecorp/armeria/spring/client/ArmeriaHttpExchangeAdapterTest.java @@ -0,0 +1,463 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you 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. + */ +/* + * Copyright 2002-2023 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 com.linecorp.armeria.spring.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.UriBuilderFactory; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.endpoint.DynamicEndpointGroup; +import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy; +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpResponseBuilder; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.testing.junit5.server.mock.MockWebServerExtension; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class ArmeriaHttpExchangeAdapterTest { + + // Forked from https://github.com/spring-projects/spring-framework/blob/02e32baa804636116d0e010442324139cf6c1092/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java + + @RegisterExtension + static MockWebServerExtension server = new MockWebServerExtension(); + + @RegisterExtension + static MockWebServerExtension anotherServer = new MockWebServerExtension() { + @Override + protected void configureServer(ServerBuilder sb) throws Exception { + sb.decorator(LoggingService.newDecorator()); + } + }; + + @Test + void greeting() { + prepareResponse(response -> response.status(HttpStatus.OK) + .header("Content-Type", "text/plain") + .content("Hello Spring!")); + + StepVerifier.create(initService().getGreeting()) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @Test + void greetingWithRequestAttribute() { + final AtomicReference ctxRef = new AtomicReference<>(); + + final WebClient webClient = + WebClient.builder(server.httpUri()) + .decorator((delegate, ctx, req) -> { + ctxRef.set(ctx); + return delegate.execute(ctx, req); + }) + .build(); + + prepareResponse(response -> response.status(HttpStatus.OK) + .header("Content-Type", "text/plain") + .content("Hello Spring!")); + + StepVerifier.create(initService(webClient).getGreetingWithAttribute("myAttributeValue")) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + + assertThat(RequestAttributeAccess.get(ctxRef.get(), "myAttribute")) + .isEqualTo("myAttributeValue"); + } + + @Test + void greetingWithoutMethod() { + prepareResponse(response -> response.status(HttpStatus.OK) + .header("Content-Type", "text/plain") + .content("Hello Spring!")); + + assertThatThrownBy(() -> initService().greetingWithoutMethod()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("HTTP method is undefined"); + } + + @ValueSource(strings = { "", "/", "/foo", "/foo/bar" }) + @ParameterizedTest + void greetingWithPrefix(String prefix) { + prepareResponse(response -> response.status(HttpStatus.OK) + .header("Content-Type", "text/plain") + .content("Hello Spring!")); + final WebClient client = WebClient.of(server.httpUri().resolve(prefix)); + StepVerifier.create(initService(client).getGreeting()) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + + String expectedPath = "/greeting"; + if (!"/".equals(prefix)) { + expectedPath = prefix + expectedPath; + } + assertThat(server.takeRequest().context().path()).isEqualTo(expectedPath); + } + + @ParameterizedTest + @ValueSource(strings = { "", "/", "/foo", "/foo/bar" }) + void greetingWithEndpointGroup(String prefix) throws InterruptedException { + prepareResponse(response -> response.status(HttpStatus.OK) + .header("Content-Type", "text/plain") + .content("Hello Spring!")); + + final SettableEndpointGroup endpointGroup = new SettableEndpointGroup(); + final WebClient client; + if (!prefix.isEmpty()) { + client = WebClient.of(SessionProtocol.HTTP, endpointGroup, prefix); + } else { + client = WebClient.of(SessionProtocol.HTTP, endpointGroup); + } + + final Service service = initService(client); + final Mono greeting = service.getGreeting(); + // Lazily add the endpoint after the request has been made. + endpointGroup.add(server.httpEndpoint()); + StepVerifier.create(greeting) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + final ServiceRequestContext ctx = server.requestContextCaptor().take(); + if (prefix.isEmpty() || "/".equals(prefix)) { + assertThat(ctx.path()).isEqualTo("/greeting"); + } else { + assertThat(ctx.path()).isEqualTo(prefix + "/greeting"); + } + } + + @Test + void greetingWithAbsoluteUri() { + // A pre-defined port should be used for testing the absolute URI. + final int serverPort = 65493; + final Server server = Server.builder() + .http(serverPort) + .service("/greeting", (ctx, req) -> HttpResponse.of("Hello Spring!")) + .build(); + + // Try to start the server and wait until it is ready to avoid port conflicts. + await().untilAsserted(() -> { + assertThatCode(() -> server.start().join()) + .doesNotThrowAnyException(); + }); + + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory(eventLoopGroup -> { + return MockAddressResolverGroup.localhost(); + }).build()) { + final WebClient nonBaseUriClient = + WebClient.builder() + .factory(factory) + .build(); + StepVerifier.create(initService(nonBaseUriClient).getGreetingAbsoluteUri()) + .expectNext("Hello Spring!") + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + server.closeAsync(); + } + + // gh-29624 + @Test + void uri() throws Exception { + final String expectedBody = "hello"; + prepareResponse(response -> response.status(200).content(expectedBody)); + + final URI dynamicUri = server.httpUri().resolve("/greeting/123"); + final String actualBody = initService().getGreetingById(dynamicUri, "456"); + + assertThat(actualBody).isEqualTo(expectedBody); + assertThat(server.takeRequest().context().uri()).isEqualTo(dynamicUri); + } + + @Test + void formData() throws Exception { + prepareResponse(response -> response.status(201)); + + final MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("param1", "value 1"); + map.add("param2", "value 2"); + + initService().postForm(map); + + final AggregatedHttpRequest request = server.takeRequest().request(); + assertThat(request.headers().get("Content-Type")) + .isEqualTo("application/x-www-form-urlencoded;charset=UTF-8"); + assertThat(request.contentUtf8()).isEqualTo("param1=value+1¶m2=value+2"); + } + + // gh-30342 + @Test + void multipart() throws InterruptedException { + prepareResponse(response -> response.status(201)); + final String fileName = "testFileName"; + final String originalFileName = "originalTestFileName"; + final MultipartFile file = + new MockMultipartFile(fileName, originalFileName, + MediaType.APPLICATION_JSON_VALUE, "test".getBytes()); + + initService().postMultipart(file, "test2"); + + final AggregatedHttpRequest request = server.takeRequest().request(); + assertThat(request.headers().get("Content-Type")).startsWith("multipart/form-data;boundary="); + assertThat(request.contentUtf8()) + .containsSubsequence( + "Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", + "Content-Type: application/json", "Content-Length: 4", "test", + "Content-Disposition: form-data; name=\"anotherPart\"", + "Content-Type: text/plain;charset=UTF-8", "Content-Length: 5", "test2"); + } + + @ValueSource(strings = { "/", "/foo", "/foo/bar" }) + @ParameterizedTest + void uriBuilderFactory(String prefix) throws Exception { + final String ignoredResponseBody = "hello"; + prepareResponse(response -> response.status(200).content(ignoredResponseBody)); + final UriBuilderFactory factory = new DefaultUriBuilderFactory( + anotherServer.httpUri().resolve(prefix).toString()); + + final String responseBody = "Hello Spring 2!"; + prepareAnotherResponse(response -> response.ok() + .header("Content-Type", "text/plain") + .content(responseBody)); + + // Unlike the original test, we need a non-baseURI client to use a custom UriBuilderFactory. + final String actualBody = initService(WebClient.of()).getWithUriBuilderFactory(factory); + + assertThat(actualBody).isEqualTo(responseBody); + String expectedPath = "/greeting"; + if (!"/".equals(prefix)) { + expectedPath = prefix + expectedPath; + } + assertThat(anotherServer.takeRequest().context().path()).isEqualTo(expectedPath); + assertThat(server.takeRequest(1, TimeUnit.SECONDS)).isNull(); + } + + @Test + void uriBuilderFactoryWithPathVariableAndRequestParam() throws Exception { + final UriBuilderFactory factory = new DefaultUriBuilderFactory( + anotherServer.httpUri().resolve("/").toString()); + + final String responseBody = "Hello Spring over Armeria!"; + prepareAnotherResponse(response -> response.ok() + .header("Content-Type", "text/plain") + .content(responseBody)); + // Unlike the original test, we need a non-baseURI client to use a custom UriBuilderFactory. + final String actualBody = initService(WebClient.of()).getWithUriBuilderFactory(factory, "123", "test"); + + assertThat(actualBody).isEqualTo(responseBody); + assertThat(anotherServer.takeRequest().request().path()).isEqualTo("/greeting/123?param=test"); + assertThat(server.takeRequest(1, TimeUnit.SECONDS)).isNull(); + } + + @Test + void ignoredUriBuilderFactory() throws Exception { + final String expectedResponseBody = "hello"; + prepareResponse(response -> response.status(200).content(expectedResponseBody)); + final URI dynamicUri = server.httpUri().resolve("/greeting/123"); + final UriBuilderFactory factory = new DefaultUriBuilderFactory( + anotherServer.httpUri().resolve("/").toString()); + + final String actualBody = initService().getWithIgnoredUriBuilderFactory(dynamicUri, factory); + + assertThat(actualBody).isEqualTo(expectedResponseBody); + assertThat(server.takeRequest().request().uri()).isEqualTo(dynamicUri); + assertThat(server.takeRequest(1, TimeUnit.SECONDS)).isNull(); + } + + @Test + void testStatusHandler() { + final ArmeriaHttpExchangeAdapter adapter = + ArmeriaHttpExchangeAdapter + .builder(server.webClient()) + .statusHandler( + StatusHandler.of(HttpStatus.BAD_REQUEST, + ctx -> new IllegalArgumentException("bad input"))) + .statusHandler((ctx, status) -> { + if (status.isServerError()) { + return new IllegalStateException("server error"); + } + return null; + }).statusHandler((ctx, status) -> { + if (!status.isSuccess()) { + return new RuntimeException("unexpected status"); + } + return null; + }) + .build(); + + final Service service = + HttpServiceProxyFactory.builderFor(adapter) + .build() + .createClient(Service.class); + + prepareResponse(response -> response.status(HttpStatus.BAD_REQUEST) + .header("Content-Type", "text/plain") + .content("Bad request")); + StepVerifier.create(service.getGreeting()) + .expectErrorSatisfies(throwable -> { + assertThat(throwable).isInstanceOf(IllegalArgumentException.class) + .hasMessage("bad input"); + }) + .verify(Duration.ofSeconds(5)); + + prepareResponse(response -> response.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header("Content-Type", "text/plain") + .content("Internal server error")); + StepVerifier.create(service.getGreeting()) + .expectErrorSatisfies(throwable -> { + assertThat(throwable).isInstanceOf(IllegalStateException.class) + .hasMessage("server error"); + }) + .verify(Duration.ofSeconds(5)); + + prepareResponse(response -> response.status(HttpStatus.NOT_FOUND) + .header("Content-Type", "text/plain") + .content("Not found")); + StepVerifier.create(service.getGreeting()) + .expectErrorSatisfies(throwable -> { + assertThat(throwable).isInstanceOf(RuntimeException.class) + .hasMessage("unexpected status"); + }) + .verify(Duration.ofSeconds(5)); + } + + private static void prepareResponse(Consumer consumer) { + final HttpResponseBuilder builder = HttpResponse.builder(); + consumer.accept(builder); + server.enqueue(builder.build()); + } + + private static void prepareAnotherResponse(Consumer consumer) { + final HttpResponseBuilder builder = HttpResponse.builder(); + consumer.accept(builder); + anotherServer.enqueue(builder.build()); + } + + private static Service initService() { + return initService(server.webClient()); + } + + private static Service initService(WebClient webClient) { + final ArmeriaHttpExchangeAdapter adapter = ArmeriaHttpExchangeAdapter.of(webClient); + return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); + } + + private interface Service { + + @HttpExchange("/greeting") + Mono greetingWithoutMethod(); + + @GetExchange("/greeting") + Mono getGreeting(); + + // A pre-defined port should be used for testing the absolute URI. + @GetExchange("http://foo.com:65493/greeting") + Mono getGreetingAbsoluteUri(); + + @GetExchange("/greeting") + Mono getGreetingWithAttribute(@RequestAttribute String myAttribute); + + @GetExchange("/greetings/{id}") + String getGreetingById(@Nullable URI uri, @PathVariable String id); + + @PostExchange(contentType = "application/x-www-form-urlencoded") + void postForm(@RequestParam MultiValueMap params); + + @PostExchange + void postMultipart(MultipartFile file, @RequestPart String anotherPart); + + @GetExchange("/greeting") + String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory); + + @GetExchange("/greeting/{id}") + String getWithUriBuilderFactory(UriBuilderFactory uriBuilderFactory, + @PathVariable String id, @RequestParam String param); + + @GetExchange("/greeting") + String getWithIgnoredUriBuilderFactory(URI uri, UriBuilderFactory uriBuilderFactory); + } + + private static class SettableEndpointGroup extends DynamicEndpointGroup { + + SettableEndpointGroup() { + super(EndpointSelectionStrategy.roundRobin()); + } + + void add(Endpoint endpoint) { + addEndpoint(endpoint); + } + } +}