From 6a314d4fd709981c4bb4a3ce3360d7c4faf128fc Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Mon, 21 Aug 2023 17:48:10 +0900 Subject: [PATCH 01/10] Do not add protocol-gRPC when sending a request from docs client (#5124) Motivation: I miss Armeria docs service a lot. So I am working on an implementation of the server side in Go to be able to reuse the excellent client :) https://github.com/curioswitch/go-docs-handler/blob/main/examples/connect/main.go Currently still WIP, the features of simple GreetService are implemented but still just a bit more work to finish it up. Normally I would wait until more completeness but I saw the "Update dependencies" PR :P `protocol=gRPC` is added for better differentiation within the docs UI but is not necessary for actually making requests. Coincidentally, not including it provides compatibility with this Go gRPC server implementation called connect. While I doubt Armeria could officially implement servers in other languages / frameworks like this, may small best effort tweaks are still ok? Modifications: - Don't send gRPC=protocol in the actual request to a gRPC service - Also added a logging statement when parsing a specification fails, currently it is completely squashed and hard to debug server side issues Result: Go API developers (and maybe other languages that take a similar approach) can take advantage of some of Armeria's awesomeness. --- docs-client/src/containers/App/index.tsx | 1 + docs-client/src/lib/transports/grpc-unframed.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs-client/src/containers/App/index.tsx b/docs-client/src/containers/App/index.tsx index 754a7df4e4fe..763d229ded46 100644 --- a/docs-client/src/containers/App/index.tsx +++ b/docs-client/src/containers/App/index.tsx @@ -474,6 +474,7 @@ const App: React.FunctionComponent = (props) => { }); setSpecification(initialSpecification); } catch (e) { + console.log(e); setSpecLoadingStatus(SpecLoadingStatus.FAILED); return; } diff --git a/docs-client/src/lib/transports/grpc-unframed.ts b/docs-client/src/lib/transports/grpc-unframed.ts index a352fbc21df9..c1fc0e5eae7c 100644 --- a/docs-client/src/lib/transports/grpc-unframed.ts +++ b/docs-client/src/lib/transports/grpc-unframed.ts @@ -42,7 +42,11 @@ export default class GrpcUnframedTransport extends Transport { const endpoint = this.getDebugMimeTypeEndpoint(method, endpointPath); const hdrs = new Headers(); - hdrs.set('content-type', GRPC_UNFRAMED_MIME_TYPE); + // protocol=gRPC is added to mime types for the purpose of endpoint detection + // but is not actually used by unframed gRPC services. We use a simpler mime + // type when actually sending the request to better represent what a real client + // would do. + hdrs.set('content-type', 'application/json; charset=utf-8'); for (const [name, value] of Object.entries(headers)) { hdrs.set(name, value); } From ffd16636634d9c113d8b144986329531ccc249a9 Mon Sep 17 00:00:00 2001 From: minux Date: Mon, 21 Aug 2023 18:32:04 +0900 Subject: [PATCH 02/10] Update dependencies (#5121) Dependencies - gRPC 1.57.1 -> 1.57.2 - Guava 32.0.1-jre -> 32.1.2-jre - Logback 1.4.8 -> 1.4.11 - Micrometer 1.11.2 -> 1.11.3 - Protobuf 3.23.4 -> 3.24.0 - Tomcat 10.1.11 -> 10.1.12 Build and test - Errorprone 2.20.0 -> 2.21.1 - gax-grpc 2.31.1 -> 2.32.0 - Gradle 8.2.1 -> 8.3 - JMH 1.36 -> 1.37 - ktlint-gradle-plugin 11.5.0 -> 11.5.1 - micrometer-tracing 1.1.3 -> 1.1.4 --------- Co-authored-by: Ikhun Um --- dependencies.toml | 29 ++++++++++------------- gradle/wrapper/gradle-wrapper.jar | Bin 62076 -> 63375 bytes gradle/wrapper/gradle-wrapper.properties | 3 ++- gradlew | 5 +++- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/dependencies.toml b/dependencies.toml index 1ca3eed55b96..cd44cd974b92 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -22,7 +22,7 @@ dgs = "7.3.6" dropwizard1 = "1.3.29" dropwizard2 = "2.1.6" dropwizard-metrics = "4.2.19" -errorprone = "2.20.0" +errorprone = "2.21.1" errorprone-gradle-plugin = "3.1.0" eureka = "2.0.1" fastutil = "8.5.12" @@ -30,12 +30,12 @@ finagle = "22.12.0" findbugs = "3.0.2" futures-completable = "0.3.5" futures-extra = "4.3.1" -gax-grpc = "2.31.1" +gax-grpc = "2.32.0" graphql-java = "20.4" graphql-kotlin = "6.5.3" -grpc-java = "1.57.1" +grpc-java = "1.57.2" grpc-kotlin = "1.3.0" -guava = "32.0.1-jre" +guava = "32.1.2-jre" hamcrest = "2.2" hbase = "1.2.6" hibernate-validator6 = "6.2.5.Final" @@ -60,7 +60,7 @@ jetty11-jstl = "11.0.0" jetty93 = "9.3.30.v20211001" jetty94 = "9.4.51.v20230217" jetty-alpn-api = "1.1.3.v20160715" -jmh-core = "1.36" +jmh-core = "1.37" jmh-extras = "0.3.7" jmh-gradle-plugin = "0.7.1" joor = "0.9.14" @@ -74,12 +74,12 @@ kafka = "3.5.1" kotlin = "1.9.0" kotlin-coroutine = "1.7.3" krotodc = "1.0.5" -ktlint-gradle-plugin = "11.5.0" +ktlint-gradle-plugin = "11.5.1" logback12 = "1.2.11" -logback13 = "1.3.8" -logback14 = "1.4.8" -micrometer = "1.11.2" -micrometer-tracing = "1.1.3" +logback13 = "1.3.11" +logback14 = "1.4.11" +micrometer = "1.11.3" +micrometer-tracing = "1.1.4" micrometer-docs-generator = "1.0.2" micrometer13 = "1.3.20" mockito = "4.11.0" @@ -96,7 +96,7 @@ opensaml = "3.4.6" osdetector = "1.7.3" proguard = "7.3.1" prometheus = "0.16.0" -protobuf = "3.23.4" +protobuf = "3.24.0" protobuf-gradle-plugin = "0.8.19" protobuf-jackson = "2.2.0" reactive-grpc = "1.2.4" @@ -141,7 +141,7 @@ thrift017 = { strictly = "0.17.0" } thrift018 = { strictly = "0.18.1" } tomcat8 = "8.5.85" tomcat9 = "9.0.71" -tomcat10 = "10.1.11" +tomcat10 = "10.1.12" xml-apis = "1.4.01" zookeeper = "3.8.2" zookeeper-junit = "1.2" @@ -479,10 +479,7 @@ version.ref = "krotodc" [libraries.guava] module = "com.google.guava:guava" version.ref = "guava" -exclusions = [ - "com.google.errorprone:error_prone_annotations", - "com.google.j2objc:j2objc-annotations", - "org.codehaus.mojo:animal-sniffer-annotations"] +exclusions = "org.codehaus.mojo:animal-sniffer-annotations" relocations = [ { from = "com.google.common", to = "com.linecorp.armeria.internal.shaded.guava" }, { from = "com.google.thirdparty.publicsuffix", to = "com.linecorp.armeria.internal.shaded.publicsuffix" }] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79e29d3e0ab67b14947c167a862655af9b..033e24c4cdf41af1ab109bc7f253b2b887023340 100644 GIT binary patch delta 16170 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 delta 14892 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9b0a13f0fb34..d11cdd907dd9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index aeb74cbb43e3..fcb6fca147c0 100755 --- a/gradlew +++ b/gradlew @@ -130,10 +130,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. From d861d34f3887a3303851664c3a80c1b376babad4 Mon Sep 17 00:00:00 2001 From: injae kim Date: Mon, 21 Aug 2023 21:57:34 +0900 Subject: [PATCH 03/10] Add `SurroundingPublisher` and `HttpResponse.of(headers, pub, trailers)` (#4727) **Related Issue** #3959 ### Motivation: ``` We have PrependingPublisher to publish ResponseHeaders and body in a StreamMessage. Similarly, we can add AppendingPublisher to publish body and HTTP trailers more efficiently. ``` - On #3941, we added `PrependingPublisher` to publish `ResponseHeaders` and body in a `StreamMessage` - So it's nice to add `SurroundingPublisher` to publish body and `HTTP trailers` too! ### Modifications: - Add `SurroundingPublisher` to append trailers - Add `HttpResponse.of(header, pub, trailers)` that using `SurroundingPublisher` ### Result: - You can now publish body and `HTTP trailers` easily by using `SurroundingPublisher` - Close #3959 issue --------- Co-authored-by: Ikhun Um Co-authored-by: Ikhun Um --- .../linecorp/armeria/common/HttpRequest.java | 21 + .../linecorp/armeria/common/HttpResponse.java | 17 + .../armeria/common/HttpResponseBuilder.java | 4 +- .../common/PublisherBasedHttpResponse.java | 13 +- .../common/stream/PrependingPublisher.java | 163 ------- .../common/stream/SurroundingPublisher.java | 457 ++++++++++++++++++ .../common/HttpResponseBuilderTest.java | 33 ++ ....java => SurroundingPublisherTckTest.java} | 73 +-- .../stream/SurroundingPublisherTest.java | 309 ++++++++++++ .../reactive/AbstractServerHttpResponse.java | 1 - .../reactive/ArmeriaServerHttpResponse.java | 19 +- .../web/reactive/ChannelSendOperator.java | 438 +++++++++++++++++ .../spring/web/reactive/ByteBufLeakTest.java | 6 +- .../web/reactive/MatrixVariablesTest.java | 8 + 14 files changed, 1362 insertions(+), 200 deletions(-) delete mode 100644 core/src/main/java/com/linecorp/armeria/internal/common/stream/PrependingPublisher.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisher.java rename core/src/test/java/com/linecorp/armeria/internal/common/stream/{PrependingPublisherTckTest.java => SurroundingPublisherTckTest.java} (69%) create mode 100644 core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTest.java create mode 100644 spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ChannelSendOperator.java diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java index 72de35fef455..6aa86c2ee2b0 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java @@ -50,6 +50,7 @@ import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.internal.common.DefaultHttpRequest; import com.linecorp.armeria.internal.common.DefaultSplitHttpRequest; +import com.linecorp.armeria.internal.common.stream.SurroundingPublisher; import com.linecorp.armeria.unsafe.PooledObjects; import io.netty.buffer.ByteBufAllocator; @@ -282,6 +283,26 @@ static HttpRequest of(RequestHeaders headers, Publisher pu } } + /** + * Creates a new instance from an existing {@link RequestHeaders}, {@link Publisher} and trailers. + * + *

Note that the {@link HttpData}s in the {@link Publisher} are not released when + * {@link Subscription#cancel()} or {@link #abort()} is called. You should add a hook in order to + * release the elements. See {@link PublisherBasedStreamMessage} for more information. + */ + @UnstableApi + static HttpRequest of(RequestHeaders headers, + Publisher publisher, + HttpHeaders trailers) { + requireNonNull(headers, "headers"); + requireNonNull(publisher, "publisher"); + requireNonNull(trailers, "trailers"); + if (trailers.isEmpty()) { + return of(headers, publisher); + } + return of(headers, new SurroundingPublisher<>(null, publisher, trailers)); + } + /** * Creates a new HTTP request whose {@link Publisher} is produced by the specified * {@link CompletionStage}. If the specified {@link CompletionStage} fails, the returned request will be diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java index 23cd7249311e..ecc8fe74685e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java @@ -488,6 +488,23 @@ static HttpResponse of(ResponseHeaders headers, Publisher return PublisherBasedHttpResponse.from(headers, publisher); } + /** + * Creates a new HTTP response with the specified headers and trailers + * whose stream is produced from an existing {@link Publisher}. + * + *

Note that the {@link HttpData}s in the {@link Publisher} are not released when + * {@link Subscription#cancel()} or {@link #abort()} is called. You should add a hook in order to + * release the elements. See {@link PublisherBasedStreamMessage} for more information. + */ + static HttpResponse of(ResponseHeaders headers, + Publisher publisher, + HttpHeaders trailers) { + requireNonNull(headers, "headers"); + requireNonNull(publisher, "publisher"); + requireNonNull(trailers, "trailers"); + return PublisherBasedHttpResponse.from(headers, publisher, trailers); + } + /** * Creates a new HTTP response that delegates to the {@link HttpResponse} produced by the specified * {@link CompletionStage}. If the specified {@link CompletionStage} fails, the returned response will be diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java b/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java index dd7b795f33bf..98d918906aae 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java @@ -28,7 +28,6 @@ import com.google.errorprone.annotations.FormatString; import com.linecorp.armeria.common.annotation.UnstableApi; -import com.linecorp.armeria.common.stream.StreamMessage; /** * Builds a new {@link HttpResponse}. @@ -299,8 +298,7 @@ public HttpResponse build() { if (trailers == null) { return HttpResponse.of(responseHeaders, publisher); } else { - return HttpResponse.of(responseHeaders, - StreamMessage.concat(publisher, StreamMessage.of(trailers.build()))); + return HttpResponse.of(responseHeaders, publisher, trailers.build()); } } } diff --git a/core/src/main/java/com/linecorp/armeria/common/PublisherBasedHttpResponse.java b/core/src/main/java/com/linecorp/armeria/common/PublisherBasedHttpResponse.java index aab05a2c4e87..d1fd409e0b74 100644 --- a/core/src/main/java/com/linecorp/armeria/common/PublisherBasedHttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/common/PublisherBasedHttpResponse.java @@ -21,12 +21,21 @@ import org.reactivestreams.Publisher; import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; -import com.linecorp.armeria.internal.common.stream.PrependingPublisher; +import com.linecorp.armeria.internal.common.stream.SurroundingPublisher; final class PublisherBasedHttpResponse extends PublisherBasedStreamMessage implements HttpResponse { static PublisherBasedHttpResponse from(ResponseHeaders headers, Publisher publisher) { - return new PublisherBasedHttpResponse(new PrependingPublisher<>(headers, publisher)); + return new PublisherBasedHttpResponse(new SurroundingPublisher<>(headers, publisher, null)); + } + + static PublisherBasedHttpResponse from(ResponseHeaders headers, + Publisher publisher, + HttpHeaders trailers) { + if (trailers.isEmpty()) { + return from(headers, publisher); + } + return new PublisherBasedHttpResponse(new SurroundingPublisher<>(headers, publisher, trailers)); } PublisherBasedHttpResponse(Publisher publisher) { diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/stream/PrependingPublisher.java b/core/src/main/java/com/linecorp/armeria/internal/common/stream/PrependingPublisher.java deleted file mode 100644 index 4abd85bfc0e9..000000000000 --- a/core/src/main/java/com/linecorp/armeria/internal/common/stream/PrependingPublisher.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright 2020 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.internal.common.stream; - -import static java.util.Objects.requireNonNull; - -import java.util.concurrent.atomic.AtomicLongFieldUpdater; - -import org.reactivestreams.Publisher; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; - -import com.google.common.math.LongMath; - -import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.stream.NoopSubscriber; - -public final class PrependingPublisher implements Publisher { - - private final T first; - private final Publisher rest; - - public PrependingPublisher(T first, Publisher rest) { - this.first = first; - this.rest = rest; - } - - @Override - public void subscribe(Subscriber subscriber) { - requireNonNull(subscriber, "subscriber"); - final RestSubscriber restSubscriber = new RestSubscriber<>(first, rest, subscriber); - subscriber.onSubscribe(restSubscriber); - } - - static final class RestSubscriber implements Subscriber, Subscription { - - @SuppressWarnings("rawtypes") - private static final AtomicLongFieldUpdater demandUpdater = - AtomicLongFieldUpdater.newUpdater(RestSubscriber.class, "demand"); - - private final T first; - private final Publisher rest; - private Subscriber downstream; - @Nullable - private volatile Subscription upstream; - private volatile long demand; - private boolean firstSent; - private boolean subscribed; - private volatile boolean cancelled; - - RestSubscriber(T first, Publisher rest, Subscriber downstream) { - this.first = first; - this.rest = rest; - this.downstream = downstream; - } - - @Override - public void request(long n) { - if (n <= 0) { - downstream.onError(new IllegalArgumentException("non-positive request signals are illegal")); - return; - } - if (cancelled) { - return; - } - for (;;) { - final long demand = this.demand; - final long newDemand = LongMath.saturatedAdd(demand, n); - if (demandUpdater.compareAndSet(this, demand, newDemand)) { - if (demand > 0) { - return; - } - break; - } - } - if (!firstSent) { - firstSent = true; - downstream.onNext(first); - if (demand != Long.MAX_VALUE) { - demandUpdater.decrementAndGet(this); - } - } - if (!subscribed) { - subscribed = true; - rest.subscribe(this); - } - if (demand == 0) { - return; - } - final Subscription upstream = this.upstream; - if (upstream != null) { - final long demand = this.demand; - if (demand > 0) { - if (demandUpdater.compareAndSet(this, demand, 0)) { - upstream.request(demand); - } - } - } - } - - @Override - public void cancel() { - if (cancelled) { - return; - } - cancelled = true; - downstream = NoopSubscriber.get(); - final Subscription upstream = this.upstream; - if (upstream != null) { - upstream.cancel(); - } - } - - @Override - public void onSubscribe(Subscription subscription) { - if (cancelled) { - subscription.cancel(); - return; - } - upstream = subscription; - for (;;) { - final long demand = this.demand; - if (demand == 0) { - break; - } - if (demandUpdater.compareAndSet(this, demand, 0)) { - subscription.request(demand); - } - } - } - - @Override - public void onNext(T t) { - requireNonNull(t, "element"); - downstream.onNext(t); - } - - @Override - public void onError(Throwable t) { - requireNonNull(t, "throwable"); - downstream.onError(t); - } - - @Override - public void onComplete() { - downstream.onComplete(); - } - } -} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisher.java b/core/src/main/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisher.java new file mode 100644 index 000000000000..8d003f691071 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisher.java @@ -0,0 +1,457 @@ +/* + * Copyright 2023 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.internal.common.stream; + +import static com.linecorp.armeria.internal.common.stream.InternalStreamMessageUtil.containsNotifyCancellation; +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.google.common.math.LongMath; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.stream.AbortedStreamException; +import com.linecorp.armeria.common.stream.CancelledSubscriptionException; +import com.linecorp.armeria.common.stream.NoopSubscriber; +import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; +import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.SubscriptionOption; +import com.linecorp.armeria.common.util.EventLoopCheckingFuture; + +import io.netty.util.concurrent.EventExecutor; + +public final class SurroundingPublisher implements StreamMessage { + + @SuppressWarnings("rawtypes") + private static final AtomicIntegerFieldUpdater subscribedUpdater = + AtomicIntegerFieldUpdater.newUpdater(SurroundingPublisher.class, "subscribed"); + + @Nullable + private final T head; + private final StreamMessage publisher; + @Nullable + private final T tail; + + private volatile int subscribed; + private final CompletableFuture completionFuture = new EventLoopCheckingFuture<>(); + + @Nullable + private volatile SurroundingSubscriber surroundingSubscriber; + + @SuppressWarnings("unchecked") + public SurroundingPublisher(@Nullable T head, Publisher publisher, @Nullable T tail) { + requireNonNull(publisher, "publisher"); + this.head = head; + if (publisher instanceof StreamMessage) { + this.publisher = (StreamMessage) publisher; + } else { + this.publisher = new PublisherBasedStreamMessage<>(publisher); + } + this.tail = tail; + } + + @Override + public boolean isOpen() { + return !completionFuture.isDone(); + } + + @Override + public boolean isEmpty() { + if (isOpen()) { + return false; + } + final SurroundingSubscriber surroundingSubscriber = this.surroundingSubscriber; + return surroundingSubscriber == null || !surroundingSubscriber.publishedAny; + } + + @Override + public long demand() { + final SurroundingSubscriber surroundingSubscriber = this.surroundingSubscriber; + if (surroundingSubscriber != null) { + return surroundingSubscriber.requested; + } else { + return 0; + } + } + + @Override + public CompletableFuture whenComplete() { + return completionFuture; + } + + @Override + public void subscribe(Subscriber subscriber, EventExecutor executor, + SubscriptionOption... options) { + requireNonNull(subscriber, "subscriber"); + requireNonNull(executor, "executor"); + requireNonNull(options, "options"); + + if (!subscribedUpdater.compareAndSet(this, 0, 1)) { + subscriber.onSubscribe(NoopSubscription.get()); + if (completionFuture.isCompletedExceptionally()) { + completionFuture.exceptionally(cause -> { + subscriber.onError(cause); + return null; + }); + } else { + subscriber.onError(new IllegalStateException("Only single subscriber is allowed!")); + } + return; + } + + if (executor.inEventLoop()) { + subscribe0(subscriber, executor, options); + } else { + executor.execute(() -> subscribe0(subscriber, executor, options)); + } + } + + private void subscribe0(Subscriber subscriber, EventExecutor executor, + SubscriptionOption... options) { + + final SurroundingSubscriber surroundingSubscriber = new SurroundingSubscriber<>( + head, publisher, tail, subscriber, executor, completionFuture, options); + this.surroundingSubscriber = surroundingSubscriber; + subscriber.onSubscribe(surroundingSubscriber); + + // To make sure to close the SurroundingSubscriber when this is aborted. + if (completionFuture.isCompletedExceptionally()) { + completionFuture.exceptionally(cause -> { + surroundingSubscriber.close(cause); + return null; + }); + } + } + + @Override + public void abort() { + abort(AbortedStreamException.get()); + } + + @Override + public void abort(Throwable cause) { + requireNonNull(cause, "cause"); + + // `completionFuture` should be set before `SurroundingSubscriber` publishes data + // to guarantee the visibility of the abortion `cause` after + // SurroundingSubscriber is set in `subscriber0()`. + completionFuture.completeExceptionally(cause); + + if (subscribedUpdater.compareAndSet(this, 0, 1)) { + publisher.abort(cause); + if (head != null) { + StreamMessageUtil.closeOrAbort(head, cause); + } + if (tail != null) { + StreamMessageUtil.closeOrAbort(tail, cause); + } + return; + } + + final SurroundingSubscriber surroundingSubscriber = this.surroundingSubscriber; + if (surroundingSubscriber != null) { + surroundingSubscriber.close(cause); + } + } + + private static final class SurroundingSubscriber implements Subscriber, Subscription { + + enum State { + REQUIRE_HEAD, + REQUIRE_BODY, + REQUIRE_TAIL, + DONE, + } + + private State state; + + @Nullable + private T head; + private final StreamMessage publisher; + @Nullable + private T tail; + + private Subscriber downstream; + private final EventExecutor executor; + @Nullable + private volatile Subscription upstream; + + private long requested; + private long upstreamRequested; + private boolean subscribed; + private volatile boolean publishedAny; + + private final CompletableFuture completionFuture; + private final SubscriptionOption[] options; + + SurroundingSubscriber(@Nullable T head, StreamMessage publisher, @Nullable T tail, + Subscriber downstream, EventExecutor executor, + CompletableFuture completionFuture, SubscriptionOption... options) { + requireNonNull(publisher, "publisher"); + requireNonNull(downstream, "downstream"); + requireNonNull(executor, "executor"); + state = head != null ? State.REQUIRE_HEAD : State.REQUIRE_BODY; + this.head = head; + this.publisher = publisher; + this.tail = tail; + this.downstream = downstream; + this.executor = executor; + this.completionFuture = completionFuture; + this.options = options; + } + + @Override + public void request(long n) { + if (n <= 0) { + close(new IllegalArgumentException("non-positive request signals are illegal")); + return; + } + if (executor.inEventLoop()) { + request0(n); + } else { + executor.execute(() -> request0(n)); + } + } + + private void request0(long n) { + if (state == State.DONE) { + return; + } + + final long oldRequested = requested; + if (oldRequested == Long.MAX_VALUE) { + return; + } + if (n == Long.MAX_VALUE) { + requested = Long.MAX_VALUE; + } else { + requested = LongMath.saturatedAdd(oldRequested, n); + } + + if (oldRequested > 0) { + // SurroundingSubscriber is publishing data. + // New requests will be handled by 'publishDownstream(item)'. + return; + } + + publish(); + } + + private void publish() { + if (state == State.DONE || requested <= 0 && upstreamRequested <= 0) { + return; + } + + switch (state) { + case REQUIRE_HEAD: { + sendHead(); + break; + } + case REQUIRE_BODY: { + if (!subscribed) { + subscribed = true; + publisher.subscribe(this, executor, options); + return; + } + if (upstreamRequested > 0) { + return; + } + final Subscription upstream = this.upstream; + if (upstream != null) { + requestUpstream(upstream); + } + break; + } + case REQUIRE_TAIL: { + sendTail(); + break; + } + } + } + + private void sendHead() { + setState(State.REQUIRE_HEAD, State.REQUIRE_BODY); + assert head != null; + final T head = this.head; + this.head = null; + publishDownstream(head, true); + } + + private void sendTail() { + assert state == State.REQUIRE_TAIL; + if (tail != null) { + final T tail = this.tail; + this.tail = null; + downstream.onNext(tail); + } + close0(null); + } + + private void requestUpstream(Subscription subscription) { + if (requested <= 0) { + return; + } + assert upstreamRequested == 0; + upstreamRequested = requested; + if (requested < Long.MAX_VALUE) { + requested = 0; + } + subscription.request(upstreamRequested); + } + + private void publishDownstream(T item, boolean head) { + requireNonNull(item, "item"); + if (state == State.DONE) { + StreamMessageUtil.closeOrAbort(item); + return; + } + downstream.onNext(item); + + if (head) { + if (requested < Long.MAX_VALUE) { + requested--; + } + subscribed = true; + publisher.subscribe(this, executor, options); + } else { + assert upstreamRequested > 0; + if (upstreamRequested < Long.MAX_VALUE) { + upstreamRequested--; + } + } + + if (!publishedAny) { + publishedAny = true; + } + + publish(); + } + + @Override + public void onSubscribe(Subscription subscription) { + requireNonNull(subscription, "subscription"); + if (state == State.DONE) { + subscription.cancel(); + return; + } + upstream = subscription; + requestUpstream(subscription); + } + + @Override + public void onNext(T item) { + requireNonNull(item, "item"); + publishDownstream(item, false); + } + + @Override + public void onError(Throwable cause) { + requireNonNull(cause, "cause"); + close(cause); + } + + @Override + public void onComplete() { + if (state == State.DONE) { + return; + } + setState(State.REQUIRE_BODY, State.REQUIRE_TAIL); + if (tail != null) { + publish(); + } else { + close0(null); + } + } + + @Override + public void cancel() { + if (executor.inEventLoop()) { + cancel0(); + } else { + executor.execute(this::cancel0); + } + } + + private void cancel0() { + if (state == State.DONE) { + return; + } + state = State.DONE; + + final Subscription upstream = this.upstream; + if (upstream != null) { + upstream.cancel(); + } + final CancelledSubscriptionException cause = CancelledSubscriptionException.get(); + if (containsNotifyCancellation(options)) { + downstream.onError(cause); + } + downstream = NoopSubscriber.get(); + completionFuture.completeExceptionally(cause); + release(null); + } + + private void close(@Nullable Throwable cause) { + if (executor.inEventLoop()) { + close0(cause); + } else { + executor.execute(() -> close0(cause)); + } + } + + private void close0(@Nullable Throwable cause) { + if (state == State.DONE) { + return; + } + state = State.DONE; + + if (cause == null) { + downstream.onComplete(); + completionFuture.complete(null); + } else { + final Subscription upstream = this.upstream; + if (upstream != null) { + upstream.cancel(); + } + downstream.onError(cause); + completionFuture.completeExceptionally(cause); + } + release(cause); + } + + private void release(@Nullable Throwable cause) { + if (head != null) { + StreamMessageUtil.closeOrAbort(head, cause); + } + if (tail != null) { + StreamMessageUtil.closeOrAbort(tail, cause); + } + } + + private void setState(State oldState, State newState) { + assert state == oldState + : "curState: " + state + ", oldState: " + oldState + ", newState: " + newState; + assert newState != State.REQUIRE_HEAD : "oldState: " + oldState + ", newState: " + newState; + state = newState; + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/HttpResponseBuilderTest.java b/core/src/test/java/com/linecorp/armeria/common/HttpResponseBuilderTest.java index 4c7eedfeea93..1bcd166db746 100644 --- a/core/src/test/java/com/linecorp/armeria/common/HttpResponseBuilderTest.java +++ b/core/src/test/java/com/linecorp/armeria/common/HttpResponseBuilderTest.java @@ -261,6 +261,39 @@ void buildComplex() { assertThat(aggregatedRes.trailers().get("trailer-name")).isEqualTo("trailer-value"); } + @Test + void buildWithHeadersAndPublisherContentAndTrailers() { + final HttpResponse res = HttpResponse.builder() + .ok() + .headers(HttpHeaders.of("header-1", + "header-value1", + "header-2", + "header-value2")) + .content(MediaType.PLAIN_TEXT_UTF_8, + StreamMessage.of( + HttpData.ofUtf8( + "Armeriaはいろんな使い方がアルメリア" + ) + )) + .trailers(HttpHeaders.of("trailer-1", + "trailer-value1", + "trailer-2", + "trailer-value2")) + .build(); + final AggregatedHttpResponse aggregatedRes = res.aggregate().join(); + assertThat(aggregatedRes.status()).isEqualTo(HttpStatus.OK); + assertThat(aggregatedRes.headers().contains("header-1")).isTrue(); + assertThat(aggregatedRes.headers().contains("header-2")).isTrue(); + assertThat(aggregatedRes.headers().get("header-1")).isEqualTo("header-value1"); + assertThat(aggregatedRes.headers().get("header-2")).isEqualTo("header-value2"); + assertThat(aggregatedRes.contentUtf8()).isEqualTo("Armeriaはいろんな使い方がアルメリア"); + assertThat(aggregatedRes.contentType()).isEqualTo(MediaType.PLAIN_TEXT_UTF_8); + assertThat(aggregatedRes.trailers().contains("trailer-1")).isTrue(); + assertThat(aggregatedRes.trailers().contains("trailer-2")).isTrue(); + assertThat(aggregatedRes.trailers().get("trailer-1")).isEqualTo("trailer-value1"); + assertThat(aggregatedRes.trailers().get("trailer-2")).isEqualTo("trailer-value2"); + } + static class SampleObject { private final int id; diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/stream/PrependingPublisherTckTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTckTest.java similarity index 69% rename from core/src/test/java/com/linecorp/armeria/internal/common/stream/PrependingPublisherTckTest.java rename to core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTckTest.java index 3f73de8e2798..9269b986dfe2 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/common/stream/PrependingPublisherTckTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTckTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2023 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 @@ -16,48 +16,80 @@ package com.linecorp.armeria.internal.common.stream; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.LongStream; -import org.reactivestreams.Publisher; import org.reactivestreams.Subscription; -import org.reactivestreams.tck.PublisherVerification; import org.reactivestreams.tck.TestEnvironment; import org.reactivestreams.tck.flow.support.PublisherVerificationRules; -import org.testng.Assert; import org.testng.SkipException; import org.testng.annotations.Test; +import com.google.common.math.LongMath; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamMessageVerification; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @SuppressWarnings("checkstyle:LineLength") -public class PrependingPublisherTckTest extends PublisherVerification { +public class SurroundingPublisherTckTest extends StreamMessageVerification { - public PrependingPublisherTckTest() { + public SurroundingPublisherTckTest() { super(new TestEnvironment(200)); } @Override - public Publisher createPublisher(long elements) { + public StreamMessage createPublisher(long elements) { if (elements == 0) { - return Mono.empty(); + final StreamMessage publisher = new SurroundingPublisher<>(null, Mono.empty(), null); + // `SurroundingPublisher` doesn't check head, tail, publisher's availability before subscribed so manually set complete. + publisher.whenComplete().complete(null); + return publisher; + } + if (elements == 1) { + return new SurroundingPublisher<>("head", Mono.empty(), null); } - return new PrependingPublisher<>("Hello", Flux.fromStream(LongStream.range(0, elements - 1).boxed())); + if (elements == 2) { + return new SurroundingPublisher<>(null, Mono.just(1), "tail"); + } + return new SurroundingPublisher<>("head", Flux.fromStream(LongStream.range(0, elements - 2).boxed()), "tail"); } /** * Rule 1.4 and 1.9 ensure a Publisher's ability to signal error to the Subscriber, however the * implementation expects such error to occur immediately after subscribing, i.e. {@code onError()} is - * called after {@code onSubscribe()}. The {@link PrependingPublisher} however always serves at least one - * element before failing, therefore for the error to be signaled, we must make requests first. + * called after {@code onSubscribe()}. + * The {@link SurroundingPublisher} however subscribes publisher and signals error after the first request, + * therefore for the error to be signaled, we must make requests first. * * {@link PublisherVerificationRules#optional_spec104_mustSignalOnErrorWhenFails()} and * {@link PublisherVerificationRules#required_spec109_mayRejectCallsToSubscribeIfPublisherIsUnableOrUnwillingToServeThemRejectionMustTriggerOnErrorAfterOnSubscribe()} * are overridden below to call {@link Subscription#request(long)} after subscribing. */ @Override - public Publisher createFailedPublisher() { - return new PrependingPublisher<>("Hello", Mono.error(new RuntimeException())); + public StreamMessage createFailedPublisher() { + return new SurroundingPublisher<>(null, Mono.error(new RuntimeException()), "tail"); + } + + @Override + public @Nullable StreamMessage createAbortedPublisher(long elements) { + if (elements == 0) { + final StreamMessage publisher = new SurroundingPublisher<>(null, Mono.empty(), null); + publisher.abort(); + return publisher; + } + + final StreamMessage publisher = createPublisher(LongMath.saturatedAdd(elements, 1)); + final AtomicLong produced = new AtomicLong(); + return publisher + .peek(item -> { + if (produced.getAndIncrement() >= elements) { + publisher.abort(); + } + }); } @Test @@ -66,7 +98,6 @@ public void optional_spec104_mustSignalOnErrorWhenFails() { try { final TestEnvironment env = new TestEnvironment(200); whenHasErrorPublisherTest(pub -> { - final TestEnvironment.Latch onNextLatch = new TestEnvironment.Latch(env); final TestEnvironment.Latch onErrorLatch = new TestEnvironment.Latch(env); final TestEnvironment.Latch onSubscribeLatch = new TestEnvironment.Latch(env); pub.subscribe(new TestEnvironment.TestSubscriber(env) { @@ -77,23 +108,14 @@ public void onSubscribe(Subscription subs) { subs.request(Long.MAX_VALUE); } - @Override - public void onNext(Object element) { - onSubscribeLatch.assertClosed("onSubscribe should be called prior to onNext always"); - Assert.assertEquals(element, "Hello"); - onNextLatch.close(); - } - @Override public void onError(Throwable cause) { onSubscribeLatch.assertClosed("onSubscribe should be called prior to onError always"); - onNextLatch.assertClosed("onNext should already be called"); onErrorLatch.assertOpen(String.format("Error-state Publisher %s called `onError` twice on new Subscriber", pub)); onErrorLatch.close(); } }); onSubscribeLatch.expectClose("Should have received onSubscribe"); - onNextLatch.expectClose("Should have received onNext"); onErrorLatch.expectClose(String.format("Error-state Publisher %s did not call `onError` on new Subscriber", pub)); env.verifyNoAsyncErrors(); @@ -120,11 +142,6 @@ public void onSubscribe(Subscription subs) { subs.request(Long.MAX_VALUE); } - @Override - public void onNext(Object e) { - onSubscribeLatch.assertClosed("onSubscribe should be called prior to onNext always"); - } - @Override public void onError(Throwable cause) { onSubscribeLatch.assertClosed("onSubscribe should be called prior to onError always"); diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTest.java new file mode 100644 index 000000000000..0a561ff5dc9c --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/internal/common/stream/SurroundingPublisherTest.java @@ -0,0 +1,309 @@ +/* + * Copyright 2023 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.internal.common.stream; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import com.linecorp.armeria.common.stream.StreamMessage; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class SurroundingPublisherTest { + + @Test + void zeroElementSurroundingPublisher() { + // given + final StreamMessage zeroElement = new SurroundingPublisher<>(null, Mono.empty(), null); + + // when & then + StepVerifier.create(zeroElement, 1) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(OneElementSurroundingPublisherProvider.class) + void oneElementSurroundingPublisher_request1(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(OneElementSurroundingPublisherProvider.class) + void oneElementSurroundingPublisher_requestAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher) + .expectNext(1) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(TwoElementsSurroundingPublisherProvider.class) + void twoElementsSurroundingPublisher_request1(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(1) + .expectNext(2) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(TwoElementsSurroundingPublisherProvider.class) + void twoElementsSurroundingPublisher_request1AndAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(Long.MAX_VALUE) + .expectNext(2) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(TwoElementsSurroundingPublisherProvider.class) + void twoElementSurroundingPublisher_request2(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 2) + .expectNext(1, 2) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(TwoElementsSurroundingPublisherProvider.class) + void twoElementSurroundingPublisher_requestAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher) + .expectNext(1, 2) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_request1(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(1) + .expectNext(2) + .thenRequest(1) + .expectNext(3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_request1And2(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(2) + .expectNext(2, 3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_request3(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 3) + .expectNext(1, 2, 3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_request1AndAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(Long.MAX_VALUE) + .expectNext(2, 3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_request2(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 2) + .expectNext(1, 2) + .thenRequest(1) + .expectNext(3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(ThreeElementsSurroundingPublisherProvider.class) + void threeElementsSurroundingPublisher_requestAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher) + .expectNext(1, 2, 3) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_request1(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(1) + .expectNext(2) + .thenRequest(1) + .expectNext(3) + .thenRequest(1) + .expectNext(4) + .thenRequest(1) + .expectNext(5) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_request1And3And1(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(3) + .expectNext(2, 3, 4) + .thenRequest(1) + .expectNext(5) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_request1AndAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 1) + .expectNext(1) + .thenRequest(Long.MAX_VALUE) + .expectNext(2, 3, 4, 5) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_request2And3(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 2) + .expectNext(1, 2) + .thenRequest(3) + .expectNext(3, 4, 5) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_request5(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher, 5) + .expectNext(1, 2, 3, 4, 5) + .expectComplete() + .verify(); + } + + @ParameterizedTest + @ArgumentsSource(FiveElementsSurroundingPublisherProvider.class) + void fiveElementsSurroundingPublisher_requestAll(StreamMessage surroundingPublisher) { + // when & then + StepVerifier.create(surroundingPublisher) + .expectNext(1, 2, 3, 4, 5) + .expectComplete() + .verify(); + } + + private static class OneElementSurroundingPublisherProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of(new SurroundingPublisher<>(1, Mono.empty(), null), + new SurroundingPublisher<>(null, Mono.just(1), null), + new SurroundingPublisher<>(null, Mono.empty(), 1)) + .map(Arguments::of); + } + } + + private static class TwoElementsSurroundingPublisherProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of(new SurroundingPublisher<>(1, Mono.just(2), null), + new SurroundingPublisher<>(1, Mono.empty(), 2), + new SurroundingPublisher<>(null, Mono.just(1), 2), + new SurroundingPublisher<>(null, Flux.just(1, 2), null)) + .map(Arguments::of); + } + } + + private static class ThreeElementsSurroundingPublisherProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of(new SurroundingPublisher<>(1, Flux.just(2, 3), null), + new SurroundingPublisher<>(1, Mono.just(2), 3), + new SurroundingPublisher<>(null, Flux.just(1, 2), 3), + new SurroundingPublisher<>(null, Flux.just(1, 2, 3), null)) + .map(Arguments::of); + } + } + + private static class FiveElementsSurroundingPublisherProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of( + new SurroundingPublisher<>( + 1, Flux.fromStream(IntStream.range(2, 6).boxed()), null), + new SurroundingPublisher<>( + 1, Flux.fromStream(IntStream.range(2, 5).boxed()), 5), + new SurroundingPublisher<>( + null, Flux.fromStream(IntStream.range(1, 5).boxed()), 5), + new SurroundingPublisher<>( + null, Flux.fromStream(IntStream.range(1, 6).boxed()), null)) + .map(Arguments::of); + } + } +} diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java index 7074c23fc8a7..e0978e5847c1 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java @@ -45,7 +45,6 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; -import org.springframework.http.server.reactive.ChannelSendOperator; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.lang.Nullable; import org.springframework.util.CollectionUtils; 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 51430886e16f..2043a875cfd0 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 @@ -87,7 +87,24 @@ private Mono write(Flux publisher) { HttpResponse.of(armeriaHeaders.build(), publisher.map(factoryWrapper::toHttpData) .contextWrite(contextView) - .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release)); + .doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release) + .doOnCancel(() -> { + logger.debug("{} Response stream cancelled", ctx, + new RuntimeException()); + }) + .doOnError(cause -> { + logger.debug("{} Response stream aborted. cause: {}", ctx, + cause, new RuntimeException()); + }) + .doOnComplete(() -> { + logger.debug("{} Response stream completed", ctx, + new RuntimeException()); + }) + .doFinally(signalType -> { + logger.debug("{} Response stream has been finished", ctx, + new RuntimeException()); + }) + ); future.complete(response); return Mono.fromFuture(response.whenComplete()) .onErrorResume(cause -> cause instanceof CancelledSubscriptionException || diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ChannelSendOperator.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ChannelSendOperator.java new file mode 100644 index 000000000000..b965ee473fd8 --- /dev/null +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ChannelSendOperator.java @@ -0,0 +1,438 @@ +/* + * Copyright 2023 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-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 com.linecorp.armeria.spring.web.reactive; + +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import reactor.core.CoreSubscriber; +import reactor.core.Scannable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Operators; +import reactor.util.context.Context; + +/** + * Given a write function that accepts a source {@code Publisher} to write + * with and returns {@code Publisher} for the result, this operator helps + * to defer the invocation of the write function, until we know if the source + * publisher will begin publishing without an error. If the first emission is + * an error, the write function is bypassed, and the error is sent directly + * through the result publisher. Otherwise the write function is invoked. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + * @param the type of element signaled + * @since 5.0 + */ +final class ChannelSendOperator extends Mono implements Scannable { + + // Forked from https://github.com/spring-projects/spring-framework/blob/1e3099759e2d823b6dd1c0c43895abcbe3e02a12/spring-web/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java + // and modified at L370 for not publishing the cache item before receiving request(n) from the subscriber. + private final Function, Publisher> writeFunction; + + private final Flux source; + + ChannelSendOperator(Publisher source, + Function, Publisher> writeFunction) { + this.source = Flux.from(source); + this.writeFunction = writeFunction; + } + + @Override + @Nullable + @SuppressWarnings("rawtypes") + public Object scanUnsafe(Attr key) { + if (key == Attr.PREFETCH) { + return Integer.MAX_VALUE; + } + if (key == Attr.PARENT) { + return source; + } + return null; + } + + @Override + public void subscribe(CoreSubscriber actual) { + source.subscribe(new WriteBarrier(actual)); + } + + private enum State { + + /** No emissions from the upstream source yet. */ + NEW, + + /** + * At least one signal of any kind has been received; we're ready to + * call the write function and proceed with actual writing. + */ + FIRST_SIGNAL_RECEIVED, + + /** + * The write subscriber has subscribed and requested; we're going to + * emit the cached signals. + */ + EMITTING_CACHED_SIGNALS, + + /** + * The write subscriber has subscribed, and cached signals have been + * emitted to it; we're ready to switch to a simple pass-through mode + * for all remaining signals. + **/ + READY_TO_WRITE + } + + /** + * A barrier inserted between the write source and the write subscriber + * (i.e. the HTTP server adapter) that pre-fetches and waits for the first + * signal before deciding whether to hook in to the write subscriber. + * + *

Acts as: + *

    + *
  • Subscriber to the write source. + *
  • Subscription to the write subscriber. + *
  • Publisher to the write subscriber. + *
+ * + *

Also uses {@link WriteCompletionBarrier} to communicate completion + * and detect cancel signals from the completion subscriber. + */ + private class WriteBarrier implements CoreSubscriber, Subscription, Publisher { + + /* Bridges signals to and from the completionSubscriber */ + private final WriteCompletionBarrier writeCompletionBarrier; + + /* Upstream write source subscription */ + @Nullable + private Subscription subscription; + + /** Cached data item before readyToWrite. */ + @Nullable + private T item; + + /** Cached error signal before readyToWrite. */ + @Nullable + private Throwable error; + + /** Cached onComplete signal before readyToWrite. */ + private boolean completed; + + /** Recursive demand while emitting cached signals. */ + private long demandBeforeReadyToWrite; + + /** Current state. */ + private State state = State.NEW; + + /** The actual writeSubscriber from the HTTP server adapter. */ + @Nullable + private Subscriber writeSubscriber; + + WriteBarrier(CoreSubscriber completionSubscriber) { + writeCompletionBarrier = new WriteCompletionBarrier(completionSubscriber, this); + } + + // Subscriber methods (we're the subscriber to the write source).. + + @Override + public final void onSubscribe(Subscription s) { + if (Operators.validate(subscription, s)) { + subscription = s; + writeCompletionBarrier.connect(); + s.request(1); + } + } + + @Override + public final void onNext(T item) { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + return; + } + //FIXME revisit in case of reentrant sync deadlock + synchronized (this) { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + } else if (state == State.NEW) { + this.item = item; + state = State.FIRST_SIGNAL_RECEIVED; + final Publisher result; + try { + result = writeFunction.apply(this); + } catch (Throwable ex) { + writeCompletionBarrier.onError(ex); + return; + } + result.subscribe(writeCompletionBarrier); + } else { + if (subscription != null) { + subscription.cancel(); + } + writeCompletionBarrier.onError(new IllegalStateException("Unexpected item.")); + } + } + } + + private Subscriber requiredWriteSubscriber() { + Assert.state(writeSubscriber != null, "No write subscriber"); + return writeSubscriber; + } + + @Override + public final void onError(Throwable ex) { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + return; + } + synchronized (this) { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + } else if (state == State.NEW) { + state = State.FIRST_SIGNAL_RECEIVED; + writeCompletionBarrier.onError(ex); + } else { + error = ex; + } + } + } + + @Override + public final void onComplete() { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + return; + } + synchronized (this) { + if (state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + } else if (state == State.NEW) { + completed = true; + state = State.FIRST_SIGNAL_RECEIVED; + final Publisher result; + try { + result = writeFunction.apply(this); + } catch (Throwable ex) { + writeCompletionBarrier.onError(ex); + return; + } + result.subscribe(writeCompletionBarrier); + } else { + completed = true; + } + } + } + + @Override + public Context currentContext() { + return writeCompletionBarrier.currentContext(); + } + + // Subscription methods (we're the Subscription to the writeSubscriber).. + + @Override + public void request(long n) { + final Subscription s = subscription; + if (s == null) { + return; + } + if (state == State.READY_TO_WRITE) { + s.request(n); + return; + } + synchronized (this) { + if (writeSubscriber != null) { + if (state == State.EMITTING_CACHED_SIGNALS) { + demandBeforeReadyToWrite = n; + return; + } + try { + state = State.EMITTING_CACHED_SIGNALS; + if (emitCachedSignals()) { + return; + } + n = n + demandBeforeReadyToWrite - 1; + if (n == 0) { + return; + } + } finally { + state = State.READY_TO_WRITE; + } + } + } + s.request(n); + } + + private boolean emitCachedSignals() { + if (error != null) { + try { + requiredWriteSubscriber().onError(error); + } finally { + releaseCachedItem(); + } + return true; + } + final T item = this.item; + this.item = null; + if (item != null) { + requiredWriteSubscriber().onNext(item); + } + if (completed) { + requiredWriteSubscriber().onComplete(); + return true; + } + return false; + } + + @Override + public void cancel() { + final Subscription s = subscription; + if (s != null) { + subscription = null; + try { + s.cancel(); + } finally { + releaseCachedItem(); + } + } + } + + private void releaseCachedItem() { + synchronized (this) { + final Object item = this.item; + if (item instanceof DataBuffer) { + DataBufferUtils.release((DataBuffer) item); + } + this.item = null; + } + } + + // Publisher methods (we're the Publisher to the writeSubscriber).. + + @Override + public void subscribe(Subscriber writeSubscriber) { + synchronized (this) { + Assert.state(this.writeSubscriber == null, "Only one write subscriber supported"); + this.writeSubscriber = writeSubscriber; + if (error != null || (completed && item == null)) { + this.writeSubscriber.onSubscribe(Operators.emptySubscription()); + emitCachedSignals(); + } else { + this.writeSubscriber.onSubscribe(this); + } + } + } + } + + /** + * We need an extra barrier between the WriteBarrier itself and the actual + * completion subscriber. + * + *

The completionSubscriber is subscribed initially to the WriteBarrier. + * Later after the first signal is received, we need one more subscriber + * instance (per spec can only subscribe once) to subscribe to the write + * function and switch to delegating completion signals from it. + */ + private class WriteCompletionBarrier implements CoreSubscriber, Subscription { + + /* Downstream write completion subscriber */ + private final CoreSubscriber completionSubscriber; + + private final WriteBarrier writeBarrier; + + @Nullable + private Subscription subscription; + + WriteCompletionBarrier(CoreSubscriber subscriber, WriteBarrier writeBarrier) { + completionSubscriber = subscriber; + this.writeBarrier = writeBarrier; + } + + /** + * Connect the underlying completion subscriber to this barrier in order + * to track cancel signals and pass them on to the write barrier. + */ + public void connect() { + completionSubscriber.onSubscribe(this); + } + + // Subscriber methods (we're the subscriber to the write function).. + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + } + + @Override + public void onError(Throwable ex) { + try { + completionSubscriber.onError(ex); + } finally { + writeBarrier.releaseCachedItem(); + } + } + + @Override + public void onComplete() { + completionSubscriber.onComplete(); + } + + @Override + public Context currentContext() { + return completionSubscriber.currentContext(); + } + + @Override + public void request(long n) { + // Ignore: we don't produce data + } + + @Override + public void cancel() { + writeBarrier.cancel(); + final Subscription subscription = this.subscription; + if (subscription != null) { + subscription.cancel(); + } + } + } +} diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ByteBufLeakTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ByteBufLeakTest.java index 0e6774dccaca..d7105a7eb335 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ByteBufLeakTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ByteBufLeakTest.java @@ -96,8 +96,10 @@ Mono empty() { private static void addListenerForCountingCompletedRequests() { ServiceRequestContext.current().log().whenComplete() - .thenAccept(log -> completed.incrementAndGet()); - requestReceived.set(true); + .thenAccept(log -> { + completed.incrementAndGet(); + requestReceived.set(true); + }); } } } diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java index f952c2638b9f..a65584a78469 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/MatrixVariablesTest.java @@ -22,6 +22,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.MatrixVariable; @@ -29,6 +30,8 @@ import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; import reactor.core.publisher.Flux; @@ -54,6 +57,11 @@ Flux findPet( return Flux.just(q1, q2); } } + + @Bean + public ArmeriaServerConfigurator serverConfigurator() { + return sb -> sb.decorator(LoggingService.newDecorator()); + } } @LocalServerPort From 92d82689a449ab42e29d9ba7fc2a9023f6f4a21c Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Tue, 22 Aug 2023 11:08:57 +0900 Subject: [PATCH 04/10] Sort jmods before adding to proguard jars for easier caching (#5116) Motivation: `trimShadedJar` may induce a build-cache miss due to a different file order. It may be worth sorting the libraryjars before adding them to avoid such cache misses. ref: https://ge.armeria.dev/c/klccgbghrflqk/zbz73bzpwscrg/task-inputs?expanded=WyJkZ3hnY3Zsc2hua3R3LWluamFyZmlsZWNvbGxlY3Rpb24iLCJkZ3hnY3Zsc2hua3R3LWxpYnJhcnlqYXJmaWxlY29sbGVjdGlvbiIsImRneGdjdmxzaG5rdHctbGlicmFyeUphckZpbGVDb2xsZWN0aW9uLTEtZmlsZS1vcmRlciJd&task-text=trim ![Screenshot 2023-08-16 at 1 23 28 PM](https://github.com/line/armeria/assets/8510579/da708ab2-142f-49dc-90d9-7851c7f07eba) Modifications: - Sort each jmod before adding to `libraryjars` for proguard Result: - More stability for build cache hits --- gradle/scripts/lib/java-shade.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/scripts/lib/java-shade.gradle b/gradle/scripts/lib/java-shade.gradle index 07b8577a8932..dc371be3b49b 100644 --- a/gradle/scripts/lib/java-shade.gradle +++ b/gradle/scripts/lib/java-shade.gradle @@ -137,7 +137,7 @@ configure(relocatedProjects) { if (jmodDir.isDirectory()) { jmodDir.listFiles().findAll { File f -> f.isFile() && f.name.toLowerCase(Locale.ENGLISH).endsWith(".jmod") - }.each { libraryjars it } + }.sort().each { libraryjars it } } else { libraryjars file("${System.getProperty('java.home')}/lib/rt.jar") } From 0c0fb4f72897b15c4f959e13e9fcdda5c627d261 Mon Sep 17 00:00:00 2001 From: Trustin Lee Date: Tue, 22 Aug 2023 13:23:58 +0900 Subject: [PATCH 05/10] Change the retention policy of `@UnstableApi` to `CLASS` (#5131) Motivation: The retention policy of the `@UnstableApi` annotation is currently `SOURCE`, which means `.class` files in our JAR will not be able to tell bytecode-level analysis tools about whether a certain class or method is unstable or not. Therefore, we cannot exclude the unstable API elements from the API backward compatibility checks we'd like to introduce in the future. Modifications: - Change the retention policy from `SOURCE` to `CLASS` so that our `.class` files contains enough metadata about the stability of our API. Result: - We can exclude the unstable API elements from our future API backward compatibility tests. --- .../com/linecorp/armeria/common/annotation/UnstableApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/com/linecorp/armeria/common/annotation/UnstableApi.java b/core/src/main/java/com/linecorp/armeria/common/annotation/UnstableApi.java index 5a5091952e6c..432324dcc14a 100644 --- a/core/src/main/java/com/linecorp/armeria/common/annotation/UnstableApi.java +++ b/core/src/main/java/com/linecorp/armeria/common/annotation/UnstableApi.java @@ -25,7 +25,7 @@ * Indicates the API of the target is not mature enough to guarantee the compatibility between releases. * Its behavior, signature or even existence might change without a prior notice at any point. */ -@Retention(RetentionPolicy.SOURCE) +@Retention(RetentionPolicy.CLASS) @Target({ ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, From dc2bdc6107bb1c6784773781cac60cac6ca39203 Mon Sep 17 00:00:00 2001 From: minux Date: Tue, 22 Aug 2023 14:07:49 +0900 Subject: [PATCH 06/10] Add WebSocketClient (#4972) Motivation It would be nice if we also support WebSocket clients. Modifications - Add `SerializationFormat.WS` for WebSocket. - Add `WebSocketClient` and its builder. - Extract the common part of `HttpRequestSubciber` to `AbsractHttpRequestSubciber` and add `WebSocketHttp1RequestSubscriber` - Add `ClientOpions.AUTO_FILL_ORIGIN_HEADER` for adding the header automatically. - Add the pipeline Channel handler for WebSocket Result - You can now send and receive WebSocket frames using `WebSocketClient`. To-Do - Add `WebSocketClientEventHandler` - A lot of todos that are in this PR --- .../client/AbstractHttpRequestHandler.java | 24 +- .../client/AbstractHttpRequestSubscriber.java | 129 ++++++ .../client/AbstractHttpResponseDecoder.java | 162 +++++++ .../client/AbstractWebClientBuilder.java | 100 ++--- .../client/AggregatedHttpRequestHandler.java | 2 +- .../BlockingWebClientRequestPreparation.java | 6 + .../client/ClientHttp1ObjectEncoder.java | 10 +- .../armeria/client/ClientOptions.java | 19 + .../com/linecorp/armeria/client/Clients.java | 3 +- .../FutureTransformingRequestPreparation.java | 6 + .../armeria/client/Http1ResponseDecoder.java | 78 ++-- .../armeria/client/Http2ResponseDecoder.java | 9 +- .../armeria/client/HttpChannelPool.java | 176 +++++--- .../armeria/client/HttpClientDelegate.java | 29 +- .../armeria/client/HttpClientFactory.java | 4 +- .../HttpClientPipelineConfigurator.java | 21 +- .../armeria/client/HttpRequestSubscriber.java | 74 +--- .../armeria/client/HttpResponseDecoder.java | 410 +----------------- .../armeria/client/HttpResponseWrapper.java | 328 ++++++++++++++ .../armeria/client/HttpSessionHandler.java | 87 ++-- .../armeria/client/RestClientPreparation.java | 6 + .../TransformingRequestPreparation.java | 6 + .../client/WebClientRequestPreparation.java | 5 + .../WebSocketHttp1ClientChannelHandler.java | 263 +++++++++++ .../WebSocketHttp1RequestSubscriber.java | 58 +++ .../client/WebSocketHttp1ResponseWrapper.java | 47 ++ .../WebSocketHttp2RequestSubscriber.java | 50 +++ .../websocket/DefaultWebSocketClient.java | 261 +++++++++++ .../client/websocket/WebSocketClient.java | 222 ++++++++++ .../websocket/WebSocketClientBuilder.java | 374 ++++++++++++++++ .../WebSocketClientFrameDecoder.java | 48 ++ .../WebSocketClientHandshakeException.java | 48 ++ .../client/websocket/WebSocketSession.java | 144 ++++++ .../client/websocket/package-info.java | 23 + .../common/AbstractHttpMessageBuilder.java | 8 + .../common/AbstractHttpRequestBuilder.java | 9 +- .../armeria/common/DeferredHttpResponse.java | 2 +- .../armeria/common/HttpMessageSetters.java | 5 + .../armeria/common/HttpRequestBuilder.java | 5 + .../armeria/common/HttpRequestSetters.java | 6 + .../armeria/common/HttpResponseBuilder.java | 8 + .../com/linecorp/armeria/common/Scheme.java | 7 + .../armeria/common/SerializationFormat.java | 20 + .../armeria/common/SessionProtocol.java | 12 + .../common/logging/DefaultRequestLog.java | 12 + .../common/stream/DeferredStreamMessage.java | 2 +- .../armeria/common/stream/StreamMessage.java | 4 +- .../common/stream/StreamMessageUtil.java | 2 +- .../armeria/internal/client/ClientUtil.java | 7 + .../client/DefaultClientRequestContext.java | 35 +- .../armeria/internal/client/HttpSession.java | 14 + .../client/websocket/WebSocketClientUtil.java | 48 ++ .../client/websocket/package-info.java | 24 + .../common/DefaultSplitHttpResponse.java | 30 +- .../internal/common/HttpHeadersUtil.java | 13 + .../websocket/WebSocketFrameDecoder.java | 42 +- .../common/websocket/WebSocketUtil.java | 21 +- .../AbstractHttpResponseSubscriber.java | 1 + .../armeria/server/Http1RequestDecoder.java | 2 +- .../armeria/server/ServiceConfigBuilder.java | 4 +- ...va => WebSocketServiceChannelHandler.java} | 9 +- .../server/websocket/WebSocketService.java | 20 +- .../websocket/WebSocketServiceBuilder.java | 4 +- .../WebSocketServiceFrameDecoder.java | 48 ++ .../armeria/client/DefaultWebClientTest.java | 7 +- .../client/Http1ResponseDecoderTest.java | 7 +- .../client/HttpResponseDecoderTest.java | 5 +- .../client/HttpResponseWrapperTest.java | 8 +- .../websocket/WebSocketClientBuilderTest.java | 111 +++++ .../WebSocketClientHandshakeTest.java | 74 ++++ .../client/websocket/WebSocketClientTest.java | 196 +++++++++ .../WebSocketFrameEncoderAndDecoderTest.java | 26 +- .../websocket/WebSocketServiceConfigTest.java | 8 +- dependencies.toml | 7 + it/websocket/build.gradle | 4 + .../it/websocket/WebSocketClientItTest.java | 202 +++++++++ .../scala/ScalaRestClientPreparation.scala | 5 + settings.gradle | 2 +- 78 files changed, 3517 insertions(+), 801 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestSubscriber.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/AbstractHttpResponseDecoder.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/HttpResponseWrapper.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ClientChannelHandler.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1RequestSubscriber.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ResponseWrapper.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/WebSocketHttp2RequestSubscriber.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/DefaultWebSocketClient.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClient.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientFrameDecoder.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeException.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketSession.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/websocket/package-info.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/client/websocket/WebSocketClientUtil.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/client/websocket/package-info.java rename core/src/main/java/com/linecorp/armeria/server/{WebSocketSessionChannelHandler.java => WebSocketServiceChannelHandler.java} (94%) create mode 100644 core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceFrameDecoder.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientTest.java create mode 100644 it/websocket/src/test/java/com/linecorp/armeria/it/websocket/WebSocketClientItTest.java diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestHandler.java b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestHandler.java index 45c7dd3251b5..0ece3e2a9bc5 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestHandler.java +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestHandler.java @@ -26,7 +26,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.linecorp.armeria.client.HttpResponseDecoder.HttpResponseWrapper; import com.linecorp.armeria.common.ClosedSessionException; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; @@ -59,6 +58,7 @@ abstract class AbstractHttpRequestHandler implements ChannelFutureListener { enum State { NEEDS_TO_WRITE_FIRST_HEADER, + NEEDS_DATA, NEEDS_DATA_OR_TRAILERS, DONE } @@ -71,6 +71,8 @@ enum State { private final RequestLogBuilder logBuilder; private final long timeoutMillis; private final boolean headersOnly; + private final boolean allowTrailers; + private final boolean keepAlive; // session, id and responseWrapper are assigned in tryInitialize() @Nullable @@ -86,7 +88,8 @@ enum State { AbstractHttpRequestHandler(Channel ch, ClientHttpObjectEncoder encoder, HttpResponseDecoder responseDecoder, DecodedHttpResponse originalRes, - ClientRequestContext ctx, long timeoutMillis, boolean headersOnly) { + ClientRequestContext ctx, long timeoutMillis, boolean headersOnly, + boolean allowTrailers, boolean keepAlive) { this.ch = ch; this.encoder = encoder; this.responseDecoder = responseDecoder; @@ -95,6 +98,8 @@ enum State { logBuilder = ctx.logBuilder(); this.timeoutMillis = timeoutMillis; this.headersOnly = headersOnly; + this.allowTrailers = allowTrailers; + this.keepAlive = keepAlive; } abstract void onWriteSuccess(); @@ -169,7 +174,7 @@ final boolean tryInitialize() { } this.session = session; - addResponseToDecoder(); + responseWrapper = responseDecoder.addResponse(id, originalRes, ctx, ch.eventLoop()); if (timeoutMillis > 0) { // The timer would be executed if the first message has not been sent out within the timeout. @@ -180,13 +185,6 @@ final boolean tryInitialize() { return true; } - private void addResponseToDecoder() { - final long responseTimeoutMillis = ctx.responseTimeoutMillis(); - final long maxContentLength = ctx.maxResponseLength(); - responseWrapper = responseDecoder.addResponse(id, originalRes, ctx, - ch.eventLoop(), responseTimeoutMillis, maxContentLength); - } - /** * Writes the {@link RequestHeaders} to the {@link Channel}. * The {@link RequestHeaders} is merged with {@link ClientRequestContext#additionalRequestHeaders()} @@ -199,8 +197,10 @@ final void writeHeaders(RequestHeaders headers) { assert protocol != null; if (headersOnly) { state = State.DONE; - } else { + } else if (allowTrailers) { state = State.NEEDS_DATA_OR_TRAILERS; + } else { + state = State.NEEDS_DATA; } final HttpHeaders internalHeaders; @@ -215,7 +215,7 @@ final void writeHeaders(RequestHeaders headers) { logBuilder.requestHeaders(merged); final String connectionOption = headers.get(HttpHeaderNames.CONNECTION); - if (CLOSE_STRING.equalsIgnoreCase(connectionOption)) { + if (CLOSE_STRING.equalsIgnoreCase(connectionOption) || !keepAlive) { // Make the session unhealthy so that subsequent requests do not use it. // In HTTP/2 request, the "Connection: close" is just interpreted as a signal to close the // connection by sending a GOAWAY frame that will be sent after receiving the corresponding diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestSubscriber.java b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestSubscriber.java new file mode 100644 index 000000000000..4ff251be998b --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpRequestSubscriber.java @@ -0,0 +1,129 @@ +/* + * 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. + */ + +package com.linecorp.armeria.client; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpObject; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; + +import io.netty.channel.Channel; + +abstract class AbstractHttpRequestSubscriber extends AbstractHttpRequestHandler + implements Subscriber { + + private static final HttpData EMPTY_EOS = HttpData.empty().withEndOfStream(); + + static AbstractHttpRequestSubscriber of(Channel channel, ClientHttpObjectEncoder requestEncoder, + HttpResponseDecoder responseDecoder, SessionProtocol protocol, + ClientRequestContext ctx, HttpRequest req, + DecodedHttpResponse res, long writeTimeoutMillis, + boolean webSocket) { + if (webSocket) { + if (protocol.isExplicitHttp1()) { + return new WebSocketHttp1RequestSubscriber( + channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); + } + assert protocol.isExplicitHttp2(); + return new WebSocketHttp2RequestSubscriber( + channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); + } + return new HttpRequestSubscriber( + channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); + } + + private final HttpRequest request; + + @Nullable + private Subscription subscription; + private boolean isSubscriptionCompleted; + + AbstractHttpRequestSubscriber(Channel ch, ClientHttpObjectEncoder encoder, + HttpResponseDecoder responseDecoder, + HttpRequest request, DecodedHttpResponse originalRes, + ClientRequestContext ctx, long timeoutMillis, boolean allowTrailers, + boolean keepAlive) { + super(ch, encoder, responseDecoder, originalRes, ctx, timeoutMillis, request.isEmpty(), allowTrailers, + keepAlive); + this.request = request; + } + + @Override + public void onSubscribe(Subscription subscription) { + assert this.subscription == null; + this.subscription = subscription; + if (state() == State.DONE) { + cancel(); + return; + } + + if (!tryInitialize()) { + return; + } + + // NB: This must be invoked at the end of this method because otherwise the callback methods in this + // class can be called before the member fields (subscription, id, responseWrapper and + // timeoutFuture) are initialized. + // It is because the successful write of the first headers will trigger subscription.request(1). + writeHeaders(mapHeaders(request.headers())); + channel().flush(); + } + + RequestHeaders mapHeaders(RequestHeaders headers) { + return headers; + } + + @Override + public void onError(Throwable cause) { + isSubscriptionCompleted = true; + failRequest(cause); + } + + @Override + public void onComplete() { + isSubscriptionCompleted = true; + + if (state() != State.DONE) { + writeData(EMPTY_EOS); + channel().flush(); + } + } + + @Override + void onWriteSuccess() { + // Request more messages regardless whether the state is DONE. It makes the producer have + // a chance to produce the last call such as 'onComplete' and 'onError' when there are + // no more messages it can produce. + if (!isSubscriptionCompleted) { + assert subscription != null; + subscription.request(1); + } + } + + @Override + void cancel() { + isSubscriptionCompleted = true; + assert subscription != null; + subscription.cancel(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractHttpResponseDecoder.java b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpResponseDecoder.java new file mode 100644 index 000000000000..93a16941f0d3 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractHttpResponseDecoder.java @@ -0,0 +1,162 @@ +/* + * 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. + */ + +package com.linecorp.armeria.client; + +import java.util.Iterator; + +import com.linecorp.armeria.common.ContentTooLargeException; +import com.linecorp.armeria.common.ContentTooLargeExceptionBuilder; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; +import com.linecorp.armeria.internal.client.HttpSession; +import com.linecorp.armeria.internal.common.InboundTrafficController; +import com.linecorp.armeria.internal.common.KeepAliveHandler; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoop; +import io.netty.util.collection.IntObjectHashMap; +import io.netty.util.collection.IntObjectMap; + +abstract class AbstractHttpResponseDecoder implements HttpResponseDecoder { + + private final IntObjectMap responses = new IntObjectHashMap<>(); + private final Channel channel; + private final InboundTrafficController inboundTrafficController; + + @Nullable + private HttpSession httpSession; + + private int unfinishedResponses; + private boolean closing; + + AbstractHttpResponseDecoder(Channel channel, InboundTrafficController inboundTrafficController) { + this.channel = channel; + this.inboundTrafficController = inboundTrafficController; + } + + @Override + public Channel channel() { + return channel; + } + + @Override + public InboundTrafficController inboundTrafficController() { + return inboundTrafficController; + } + + @Override + public HttpResponseWrapper addResponse( + int id, DecodedHttpResponse res, ClientRequestContext ctx, EventLoop eventLoop) { + final HttpResponseWrapper newRes = + new HttpResponseWrapper(res, eventLoop, ctx, + ctx.responseTimeoutMillis(), ctx.maxResponseLength()); + final HttpResponseWrapper oldRes = responses.put(id, newRes); + final KeepAliveHandler keepAliveHandler = keepAliveHandler(); + if (keepAliveHandler != null) { + keepAliveHandler.increaseNumRequests(); + } + + assert oldRes == null : "addResponse(" + id + ", " + res + ", " + ctx + "): " + oldRes; + onResponseAdded(id, eventLoop, newRes); + return newRes; + } + + abstract void onResponseAdded(int id, EventLoop eventLoop, HttpResponseWrapper responseWrapper); + + @Nullable + @Override + public HttpResponseWrapper getResponse(int id) { + return responses.get(id); + } + + @Nullable + @Override + public HttpResponseWrapper removeResponse(int id) { + if (closing) { + // `unfinishedResponses` will be removed by `failUnfinishedResponses()` + return null; + } + + final HttpResponseWrapper removed = responses.remove(id); + if (removed != null) { + unfinishedResponses--; + assert unfinishedResponses >= 0 : unfinishedResponses; + } + return removed; + } + + @Override + public boolean hasUnfinishedResponses() { + return unfinishedResponses != 0; + } + + @Override + public boolean reserveUnfinishedResponse(int maxUnfinishedResponses) { + if (unfinishedResponses >= maxUnfinishedResponses) { + return false; + } + + unfinishedResponses++; + return true; + } + + @Override + public void decrementUnfinishedResponses() { + unfinishedResponses--; + } + + @Override + public void failUnfinishedResponses(Throwable cause) { + if (closing) { + return; + } + closing = true; + + for (final Iterator iterator = responses.values().iterator(); + iterator.hasNext();) { + final HttpResponseWrapper res = iterator.next(); + // To avoid calling removeResponse by res.close(cause), remove before closing. + iterator.remove(); + unfinishedResponses--; + res.close(cause); + } + } + + @Override + public HttpSession session() { + if (httpSession != null) { + return httpSession; + } + return httpSession = HttpSession.get(channel); + } + + @Override + public boolean needsToDisconnectNow() { + return !session().isAcquirable() && !hasUnfinishedResponses(); + } + + static ContentTooLargeException contentTooLargeException(HttpResponseWrapper res, long transferred) { + final ContentTooLargeExceptionBuilder builder = + ContentTooLargeException.builder() + .maxContentLength(res.maxContentLength()) + .transferred(transferred); + if (res.contentLengthHeaderValue() >= 0) { + builder.contentLength(res.contentLengthHeaderValue()); + } + return builder.build(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java index b22f40d2b52f..04ca85236113 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java @@ -16,15 +16,13 @@ package com.linecorp.armeria.client; import static com.google.common.base.Preconditions.checkArgument; +import static com.linecorp.armeria.common.SessionProtocol.httpAndHttpsValues; +import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; import static java.util.Objects.requireNonNull; import java.net.URI; -import java.util.Set; import java.util.function.Function; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Sets; - import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.Scheme; import com.linecorp.armeria.common.SerializationFormat; @@ -36,18 +34,6 @@ */ public abstract class AbstractWebClientBuilder extends AbstractClientOptionsBuilder { - /** - * An undefined {@link URI} to create {@link WebClient} without specifying {@link URI}. - */ - static final URI UNDEFINED_URI = URI.create("http://undefined"); - - private static final Set SUPPORTED_PROTOCOLS = - Sets.immutableEnumSet( - ImmutableList.builder() - .addAll(SessionProtocol.httpValues()) - .addAll(SessionProtocol.httpsValues()) - .build()); - @Nullable private final URI uri; @Nullable @@ -61,10 +47,7 @@ public abstract class AbstractWebClientBuilder extends AbstractClientOptionsBuil * Creates a new instance. */ protected AbstractWebClientBuilder() { - uri = UNDEFINED_URI; - scheme = null; - endpointGroup = null; - path = null; + this(UNDEFINED_URI, null, null, null); } /** @@ -74,24 +57,7 @@ protected AbstractWebClientBuilder() { * in {@link SessionProtocol} */ protected AbstractWebClientBuilder(URI uri) { - if (Clients.isUndefinedUri(uri)) { - this.uri = uri; - } else { - final String givenScheme = requireNonNull(uri, "uri").getScheme(); - final Scheme scheme = validateScheme(givenScheme); - if (scheme.uriText().equals(givenScheme)) { - // No need to replace the user-specified scheme because it's already in its normalized form. - this.uri = uri; - } else { - // Replace the user-specified scheme with the normalized one. - // e.g. http://foo.com/ -> none+http://foo.com/ - this.uri = URI.create(scheme.uriText() + - uri.toString().substring(givenScheme.length())); - } - } - scheme = null; - endpointGroup = null; - path = null; + this(validateUri(uri), null, null, null); } /** @@ -102,29 +68,65 @@ protected AbstractWebClientBuilder(URI uri) { */ protected AbstractWebClientBuilder(SessionProtocol sessionProtocol, EndpointGroup endpointGroup, @Nullable String path) { - validateScheme(requireNonNull(sessionProtocol, "sessionProtocol").uriText()); - if (path != null) { - checkArgument(path.startsWith("/"), - "path: %s (expected: an absolute path starting with '/')", path); + this(null, validateSessionProtocol(sessionProtocol), + requireNonNull(endpointGroup, "endpointGroup"), path); + } + + /** + * Creates a new instance. + */ + protected AbstractWebClientBuilder(@Nullable URI uri, @Nullable Scheme scheme, + @Nullable EndpointGroup endpointGroup, @Nullable String path) { + assert uri != null || (scheme != null && endpointGroup != null); + assert path == null || uri == null; + this.uri = uri; + this.scheme = scheme; + this.endpointGroup = endpointGroup; + this.path = validatePath(path); + } + + private static URI validateUri(URI uri) { + requireNonNull(uri, "uri"); + if (Clients.isUndefinedUri(uri)) { + return uri; + } + final String givenScheme = requireNonNull(uri, "uri").getScheme(); + final Scheme scheme = validateScheme(givenScheme); + if (scheme.uriText().equals(givenScheme)) { + // No need to replace the user-specified scheme because it's already in its normalized form. + return uri; } + // Replace the user-specified scheme with the normalized one. + // e.g. http://foo.com/ -> none+http://foo.com/ + return URI.create(scheme.uriText() + uri.toString().substring(givenScheme.length())); + } - uri = null; - scheme = Scheme.of(SerializationFormat.NONE, sessionProtocol); - this.endpointGroup = requireNonNull(endpointGroup, "endpointGroup"); - this.path = path; + private static Scheme validateSessionProtocol(SessionProtocol sessionProtocol) { + requireNonNull(sessionProtocol, "sessionProtocol"); + validateScheme(sessionProtocol.uriText()); + return Scheme.of(SerializationFormat.NONE, sessionProtocol); } private static Scheme validateScheme(String scheme) { final Scheme parsedScheme = Scheme.tryParse(scheme); if (parsedScheme != null) { if (parsedScheme.serializationFormat() == SerializationFormat.NONE && - SUPPORTED_PROTOCOLS.contains(parsedScheme.sessionProtocol())) { + httpAndHttpsValues().contains(parsedScheme.sessionProtocol())) { return parsedScheme; } } - throw new IllegalArgumentException("scheme : " + scheme + - " (expected: one of " + SUPPORTED_PROTOCOLS + ')'); + throw new IllegalArgumentException("scheme: " + scheme + + " (expected: one of " + httpAndHttpsValues() + ')'); + } + + @Nullable + private static String validatePath(@Nullable String path) { + if (path != null) { + checkArgument(path.startsWith("/"), + "path: %s (expected: an absolute path starting with '/')", path); + } + return path; } /** diff --git a/core/src/main/java/com/linecorp/armeria/client/AggregatedHttpRequestHandler.java b/core/src/main/java/com/linecorp/armeria/client/AggregatedHttpRequestHandler.java index 50a4be802654..bc90fc4561cf 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AggregatedHttpRequestHandler.java +++ b/core/src/main/java/com/linecorp/armeria/client/AggregatedHttpRequestHandler.java @@ -37,7 +37,7 @@ final class AggregatedHttpRequestHandler extends AbstractHttpRequestHandler HttpResponseDecoder responseDecoder, HttpRequest request, DecodedHttpResponse originalRes, ClientRequestContext ctx, long timeoutMillis) { - super(ch, encoder, responseDecoder, originalRes, ctx, timeoutMillis, request.isEmpty()); + super(ch, encoder, responseDecoder, originalRes, ctx, timeoutMillis, request.isEmpty(), true, true); } @Override diff --git a/core/src/main/java/com/linecorp/armeria/client/BlockingWebClientRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/BlockingWebClientRequestPreparation.java index 302c91dbbd70..014e5deb1bc5 100644 --- a/core/src/main/java/com/linecorp/armeria/client/BlockingWebClientRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/BlockingWebClientRequestPreparation.java @@ -336,6 +336,12 @@ public BlockingWebClientRequestPreparation content(MediaType contentType, HttpDa return this; } + @Override + public BlockingWebClientRequestPreparation content(Publisher content) { + delegate.content(content); + return this; + } + @Override public BlockingWebClientRequestPreparation content(MediaType contentType, Publisher content) { diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientHttp1ObjectEncoder.java b/core/src/main/java/com/linecorp/armeria/client/ClientHttp1ObjectEncoder.java index a80b7b4b9e32..3ebb1e14a098 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientHttp1ObjectEncoder.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientHttp1ObjectEncoder.java @@ -42,12 +42,14 @@ final class ClientHttp1ObjectEncoder extends Http1ObjectEncoder implements Clien private final Http1HeaderNaming http1HeaderNaming; private final KeepAliveHandler keepAliveHandler; + private final boolean webSocket; ClientHttp1ObjectEncoder(Channel ch, SessionProtocol protocol, Http1HeaderNaming http1HeaderNaming, - KeepAliveHandler keepAliveHandler) { + KeepAliveHandler keepAliveHandler, boolean webSocket) { super(ch, protocol); this.http1HeaderNaming = http1HeaderNaming; this.keepAliveHandler = keepAliveHandler; + this.webSocket = webSocket; } @Override @@ -71,6 +73,12 @@ private HttpObject convertHeaders(RequestHeaders headers, boolean endStream) { protocol().defaultPort())); } + if (webSocket) { + nettyHeaders.remove(HttpHeaderNames.TRANSFER_ENCODING); + nettyHeaders.remove(HttpHeaderNames.CONTENT_LENGTH); + return req; + } + if (endStream) { nettyHeaders.remove(HttpHeaderNames.TRANSFER_ENCODING); diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientOptions.java b/core/src/main/java/com/linecorp/armeria/client/ClientOptions.java index 84719264ce70..e422acd9c33c 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientOptions.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientOptions.java @@ -82,6 +82,16 @@ public final class ClientOptions ClientOption.define("REQUEST_AUTO_ABORT_DELAY_MILLIS", Flags.defaultRequestAutoAbortDelayMillis()); + /** + * Whether to add an {@link HttpHeaderNames#ORIGIN} header automatically when sending + * an {@link HttpRequest} when the {@link HttpRequest#headers()} does not have it. + * + * @see The Web Origin Concept + */ + @UnstableApi + public static final ClientOption AUTO_FILL_ORIGIN_HEADER = + ClientOption.define("AUTO_FILL_ORIGIN_HEADER", false); // TODO(minwoox): Add to Flags + /** * The redirect configuration. */ @@ -306,6 +316,15 @@ public long requestAutoAbortDelayMillis() { return get(REQUEST_AUTO_ABORT_DELAY_MILLIS); } + /** + * Returns whether to add an {@link HttpHeaderNames#ORIGIN} header automatically when sending + * an {@link HttpRequest} when the {@link HttpRequest#headers()} does not have it. + */ + @UnstableApi + public boolean autoFillOriginHeader() { + return get(AUTO_FILL_ORIGIN_HEADER); + } + /** * Returns the {@link RedirectConfig}. */ diff --git a/core/src/main/java/com/linecorp/armeria/client/Clients.java b/core/src/main/java/com/linecorp/armeria/client/Clients.java index 9765583bac64..ee54d9f4ee85 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Clients.java +++ b/core/src/main/java/com/linecorp/armeria/client/Clients.java @@ -15,6 +15,7 @@ */ package com.linecorp.armeria.client; +import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; import static java.util.Objects.requireNonNull; import java.net.URI; @@ -603,7 +604,7 @@ public static ClientRequestContextCaptor newContextCaptor() { * {@code isUndefinedUri(WebClient.of().uri())} will return {@code true}. */ public static boolean isUndefinedUri(URI uri) { - return uri == AbstractWebClientBuilder.UNDEFINED_URI; + return uri == UNDEFINED_URI; } private Clients() {} diff --git a/core/src/main/java/com/linecorp/armeria/client/FutureTransformingRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/FutureTransformingRequestPreparation.java index f4ff35d42d0e..633ee0b4180a 100644 --- a/core/src/main/java/com/linecorp/armeria/client/FutureTransformingRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/FutureTransformingRequestPreparation.java @@ -274,6 +274,12 @@ public FutureTransformingRequestPreparation content(MediaType contentType, Ht return this; } + @Override + public FutureTransformingRequestPreparation content(Publisher content) { + delegate.content(content); + return this; + } + @Override public FutureTransformingRequestPreparation content(MediaType contentType, Publisher content) { diff --git a/core/src/main/java/com/linecorp/armeria/client/Http1ResponseDecoder.java b/core/src/main/java/com/linecorp/armeria/client/Http1ResponseDecoder.java index 508e30c39bec..c341df6e5e2d 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Http1ResponseDecoder.java +++ b/core/src/main/java/com/linecorp/armeria/client/Http1ResponseDecoder.java @@ -16,21 +16,30 @@ package com.linecorp.armeria.client; +import static com.linecorp.armeria.internal.common.KeepAliveHandlerUtil.needsKeepAliveHandler; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.collect.ImmutableList; import com.google.common.math.LongMath; import com.linecorp.armeria.common.ClosedSessionException; import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpStatusClass; import com.linecorp.armeria.common.ProtocolViolationException; import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.metric.MoreMeters; import com.linecorp.armeria.internal.common.ArmeriaHttpUtil; import com.linecorp.armeria.internal.common.InboundTrafficController; import com.linecorp.armeria.internal.common.KeepAliveHandler; +import com.linecorp.armeria.internal.common.NoopKeepAliveHandler; import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; @@ -41,12 +50,11 @@ import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpObject; import io.netty.handler.codec.http.HttpResponse; -import io.netty.handler.codec.http.HttpStatusClass; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http.LastHttpContent; import io.netty.util.ReferenceCountUtil; -final class Http1ResponseDecoder extends HttpResponseDecoder implements ChannelInboundHandler { +final class Http1ResponseDecoder extends AbstractHttpResponseDecoder implements ChannelInboundHandler { private static final Logger logger = LoggerFactory.getLogger(Http1ResponseDecoder.class); @@ -60,14 +68,34 @@ private enum State { /** The response being decoded currently. */ @Nullable private HttpResponseWrapper res; - @Nullable - private KeepAliveHandler keepAliveHandler; + private final KeepAliveHandler keepAliveHandler; private int resId = 1; private int lastPingReqId = -1; private State state = State.NEED_HEADERS; - Http1ResponseDecoder(Channel channel) { + Http1ResponseDecoder(Channel channel, HttpClientFactory clientFactory, SessionProtocol protocol) { super(channel, InboundTrafficController.ofHttp1(channel)); + final long idleTimeoutMillis = clientFactory.idleTimeoutMillis(); + final long pingIntervalMillis = clientFactory.pingIntervalMillis(); + final long maxConnectionAgeMillis = clientFactory.maxConnectionAgeMillis(); + final int maxNumRequestsPerConnection = clientFactory.maxNumRequestsPerConnection(); + final boolean keepAliveOnPing = clientFactory.keepAliveOnPing(); + final boolean needsKeepAliveHandler = + needsKeepAliveHandler(idleTimeoutMillis, pingIntervalMillis, + maxConnectionAgeMillis, maxNumRequestsPerConnection); + + if (needsKeepAliveHandler) { + final Timer keepAliveTimer = + MoreMeters.newTimer(clientFactory.meterRegistry(), + "armeria.client.connections.lifespan", + ImmutableList.of(Tag.of("protocol", protocol.uriText()))); + keepAliveHandler = new Http1ClientKeepAliveHandler( + channel, this, keepAliveTimer, idleTimeoutMillis, + pingIntervalMillis, maxConnectionAgeMillis, maxNumRequestsPerConnection, + keepAliveOnPing); + } else { + keepAliveHandler = new NoopKeepAliveHandler(); + } } @Override @@ -100,7 +128,7 @@ public void handlerAdded(ChannelHandlerContext ctx) throws Exception { @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { - destroyKeepAliveHandler(); + keepAliveHandler.destroy(); } @Override @@ -125,7 +153,7 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { if (res != null) { res.close(ClosedSessionException.get()); } - destroyKeepAliveHandler(); + keepAliveHandler.destroy(); ctx.fireChannelInactive(); } @@ -141,6 +169,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception ReferenceCountUtil.release(msg); return; } + keepAliveHandler.onReadOrWrite(); try { switch (state) { @@ -168,7 +197,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception res.startResponse(); final ResponseHeaders responseHeaders = ArmeriaHttpUtil.toArmeria(nettyRes); final boolean written; - if (nettyRes.status().codeClass() == HttpStatusClass.INFORMATIONAL) { + if (responseHeaders.status().codeClass() == HttpStatusClass.INFORMATIONAL) { state = State.NEED_INFORMATIONAL_DATA; written = res.tryWrite(responseHeaders); } else { @@ -250,6 +279,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception } private void failWithUnexpectedMessageType(ChannelHandlerContext ctx, Object msg, Class expected) { + final String message; try (TemporaryThreadLocals tempThreadLocals = TemporaryThreadLocals.acquire()) { final StringBuilder buf = tempThreadLocals.stringBuilder(); buf.append("unexpected message type: " + msg.getClass().getName() + @@ -260,8 +290,9 @@ private void failWithUnexpectedMessageType(ChannelHandlerContext ctx, Object msg } else { buf.append(", lastPingReqId: " + lastPingReqId + ')'); } - fail(ctx, new ProtocolViolationException(buf.toString())); + message = buf.toString(); } + fail(ctx, new ProtocolViolationException(message)); } private void fail(ChannelHandlerContext ctx, Throwable cause) { @@ -305,40 +336,19 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws E } @Override - KeepAliveHandler keepAliveHandler() { + public KeepAliveHandler keepAliveHandler() { return keepAliveHandler; } - void setKeepAliveHandler(ChannelHandlerContext ctx, KeepAliveHandler keepAliveHandler) { - this.keepAliveHandler = keepAliveHandler; - if (keepAliveHandler instanceof Http1ClientKeepAliveHandler) { - maybeInitializeKeepAliveHandler(ctx); - } - } - - private void maybeInitializeKeepAliveHandler(ChannelHandlerContext ctx) { + void maybeInitializeKeepAliveHandler(ChannelHandlerContext ctx) { if (ctx.channel().isActive()) { - final KeepAliveHandler keepAliveHandler = keepAliveHandler(); - if (keepAliveHandler != null) { - keepAliveHandler.initialize(ctx); - } - } - } - - private void destroyKeepAliveHandler() { - final KeepAliveHandler keepAliveHandler = keepAliveHandler(); - if (keepAliveHandler != null) { - keepAliveHandler.destroy(); + keepAliveHandler.initialize(ctx); } } private void onPingRead(Object msg) { if (msg instanceof HttpResponse) { - final KeepAliveHandler keepAliveHandler = keepAliveHandler(); - // Ping can not be activated with NoopKeepAliveHandler. - if (keepAliveHandler instanceof Http1ClientKeepAliveHandler) { - keepAliveHandler.onPing(); - } + keepAliveHandler.onPing(); } if (msg instanceof LastHttpContent) { onPingComplete(); diff --git a/core/src/main/java/com/linecorp/armeria/client/Http2ResponseDecoder.java b/core/src/main/java/com/linecorp/armeria/client/Http2ResponseDecoder.java index 14e6c0e115f4..9560d8cf89b8 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Http2ResponseDecoder.java +++ b/core/src/main/java/com/linecorp/armeria/client/Http2ResponseDecoder.java @@ -20,8 +20,6 @@ import static io.netty.handler.codec.http2.Http2Error.PROTOCOL_ERROR; import static io.netty.handler.codec.http2.Http2Exception.connectionError; -import javax.annotation.Nonnull; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,8 +52,8 @@ import io.netty.handler.codec.http2.Http2Settings; import io.netty.handler.codec.http2.Http2Stream; -final class Http2ResponseDecoder extends HttpResponseDecoder implements Http2Connection.Listener, - Http2FrameListener { +final class Http2ResponseDecoder extends AbstractHttpResponseDecoder implements Http2Connection.Listener, + Http2FrameListener { private static final Logger logger = LoggerFactory.getLogger(Http2ResponseDecoder.class); @@ -345,9 +343,8 @@ public void onWindowUpdateRead(ChannelHandlerContext ctx, int streamId, int wind public void onUnknownFrame(ChannelHandlerContext ctx, byte frameType, int streamId, Http2Flags flags, ByteBuf payload) {} - @Nonnull @Override - KeepAliveHandler keepAliveHandler() { + public KeepAliveHandler keepAliveHandler() { return keepAliveHandler; } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java index eb0b4683035f..3556290eb2fa 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java @@ -15,6 +15,8 @@ */ package com.linecorp.armeria.client; +import static com.linecorp.armeria.common.SessionProtocol.httpAndHttpsValues; + import java.lang.reflect.Array; import java.net.InetSocketAddress; import java.net.SocketAddress; @@ -26,16 +28,18 @@ import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.BiFunction; import java.util.function.Consumer; -import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.collect.ImmutableSet; + import com.linecorp.armeria.client.proxy.ConnectProxyConfig; import com.linecorp.armeria.client.proxy.HAProxyConfig; import com.linecorp.armeria.client.proxy.ProxyConfig; @@ -44,6 +48,7 @@ import com.linecorp.armeria.client.proxy.Socks4ProxyConfig; import com.linecorp.armeria.client.proxy.Socks5ProxyConfig; import com.linecorp.armeria.common.ClosedSessionException; +import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.logging.ClientConnectionTimingsBuilder; @@ -88,9 +93,9 @@ final class HttpChannelPool implements AsyncCloseable { private final ConnectionPoolListener listener; // Fields for creating a new connection: - private final Bootstrap[] inetBootstraps; + private final Bootstrap[][] inetBootstraps; @Nullable - private final Bootstrap[] unixBootstraps; + private final Bootstrap[][] unixBootstraps; private final int connectTimeoutMillis; private final SslContext sslCtxHttp1Or2; @@ -101,17 +106,9 @@ final class HttpChannelPool implements AsyncCloseable { ConnectionPoolListener listener) { this.clientFactory = clientFactory; this.eventLoop = eventLoop; - pool = newEnumMap( - Map.class, - unused -> new HashMap<>(), - SessionProtocol.H1, SessionProtocol.H1C, - SessionProtocol.H2, SessionProtocol.H2C); - pendingAcquisitions = newEnumMap( - Map.class, - unused -> new HashMap<>(), - SessionProtocol.HTTP, SessionProtocol.HTTPS, - SessionProtocol.H1, SessionProtocol.H1C, - SessionProtocol.H2, SessionProtocol.H2C); + pool = newEnumMap(ImmutableSet.of(SessionProtocol.H1, SessionProtocol.H1C, + SessionProtocol.H2, SessionProtocol.H2C)); + pendingAcquisitions = newEnumMap(httpAndHttpsValues()); allChannels = new IdentityHashMap<>(); this.listener = listener; this.sslCtxHttp1Only = sslCtxHttp1Only; @@ -131,26 +128,41 @@ final class HttpChannelPool implements AsyncCloseable { .get(ChannelOption.CONNECT_TIMEOUT_MILLIS); } - private Bootstrap[] newBootstrapMap(Bootstrap baseBootstrap, - HttpClientFactory clientFactory, - EventLoop eventLoop) { + private Bootstrap[][] newBootstrapMap(Bootstrap baseBootstrap, + HttpClientFactory clientFactory, + EventLoop eventLoop) { baseBootstrap.group(eventLoop); - return newEnumMap(Bootstrap.class, - desiredProtocol -> { - final SslContext sslCtx = determineSslContext(desiredProtocol); - final Bootstrap bootstrap = baseBootstrap.clone(); - bootstrap.handler(new ChannelInitializer() { - @Override - protected void initChannel(Channel ch) throws Exception { - ch.pipeline().addLast( - new HttpClientPipelineConfigurator(clientFactory, desiredProtocol, sslCtx)); - } - }); - return bootstrap; - }, - SessionProtocol.HTTP, SessionProtocol.HTTPS, - SessionProtocol.H1, SessionProtocol.H1C, - SessionProtocol.H2, SessionProtocol.H2C); + final Set sessionProtocols = httpAndHttpsValues(); + final Bootstrap[][] maps = (Bootstrap[][]) Array.newInstance( + Bootstrap.class, SessionProtocol.values().length, 2); + // Attempting to access the array with an unallowed protocol will trigger NPE, + // which will help us find a bug. + for (SessionProtocol p : sessionProtocols) { + final SslContext sslCtx = determineSslContext(p); + setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, true); + setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, false); + } + return maps; + } + + private static void setBootstrap(Bootstrap bootstrap, HttpClientFactory clientFactory, Bootstrap[][] maps, + SessionProtocol p, SslContext sslCtx, boolean webSocket) { + bootstrap.handler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + ch.pipeline().addLast( + new HttpClientPipelineConfigurator(clientFactory, webSocket, p, sslCtx)); + } + }); + maps[p.ordinal()][toIndex(webSocket)] = bootstrap; + } + + private static int toIndex(boolean webSocket) { + return webSocket ? 1 : 0; + } + + private static int toIndex(SerializationFormat serializationFormat) { + return toIndex(serializationFormat == SerializationFormat.WS); } private SslContext determineSslContext(SessionProtocol desiredProtocol) { @@ -203,22 +215,23 @@ private void configureProxy(Channel ch, ProxyConfig proxyConfig, SessionProtocol /** * Returns an array whose index signifies {@link SessionProtocol#ordinal()}. Similar to {@link EnumMap}. */ - private static T[] newEnumMap(Class elementType, - Function factory, - SessionProtocol... allowedProtocols) { + private static Map[] newEnumMap(Set allowedProtocols) { @SuppressWarnings("unchecked") - final T[] maps = (T[]) Array.newInstance(elementType, SessionProtocol.values().length); + final Map[] maps = + (Map[]) Array.newInstance(Map.class, SessionProtocol.values().length); // Attempting to access the array with an unallowed protocol will trigger NPE, // which will help us find a bug. for (SessionProtocol p : allowedProtocols) { - maps[p.ordinal()] = factory.apply(p); + maps[p.ordinal()] = new HashMap<>(); } return maps; } - private Bootstrap getBootstrap(SessionProtocol desiredProtocol, SocketAddress remoteAddress) { + // TODO(minwoox): refactor this. https://github.com/line/armeria/issues/5129 + private Bootstrap getBootstrap(SessionProtocol desiredProtocol, SocketAddress remoteAddress, + SerializationFormat serializationFormat) { if (remoteAddress instanceof InetSocketAddress) { - return inetBootstraps[desiredProtocol.ordinal()]; + return inetBootstraps[desiredProtocol.ordinal()][toIndex(serializationFormat)]; } assert remoteAddress instanceof DomainSocketAddress : remoteAddress; @@ -228,7 +241,7 @@ private Bootstrap getBootstrap(SessionProtocol desiredProtocol, SocketAddress re eventLoop.getClass().getName()); } - return unixBootstraps[desiredProtocol.ordinal()]; + return unixBootstraps[desiredProtocol.ordinal()][toIndex(serializationFormat)]; } @Nullable @@ -258,32 +271,39 @@ private void removePendingAcquisition(SessionProtocol desiredProtocol, PoolKey k * Attempts to acquire a {@link Channel} which is matched by the specified condition immediately. * * @return {@code null} is there's no match left in the pool and thus a new connection has to be - * requested via {@link #acquireLater(SessionProtocol, PoolKey, ClientConnectionTimingsBuilder)}. + * requested via {@link #acquireLater(SessionProtocol, SerializationFormat, + * PoolKey, ClientConnectionTimingsBuilder)}. */ @Nullable - PooledChannel acquireNow(SessionProtocol desiredProtocol, PoolKey key) { + @SuppressWarnings("checkstyle:FallThrough") + PooledChannel acquireNow(SessionProtocol desiredProtocol, SerializationFormat serializationFormat, + PoolKey key) { PooledChannel ch; switch (desiredProtocol) { case HTTP: - ch = acquireNowExact(key, SessionProtocol.H2C); + ch = acquireNowExact(key, SessionProtocol.H2C, serializationFormat); if (ch == null) { - ch = acquireNowExact(key, SessionProtocol.H1C); + ch = acquireNowExact(key, SessionProtocol.H1C, serializationFormat); } break; case HTTPS: - ch = acquireNowExact(key, SessionProtocol.H2); + ch = acquireNowExact(key, SessionProtocol.H2, serializationFormat); if (ch == null) { - ch = acquireNowExact(key, SessionProtocol.H1); + ch = acquireNowExact(key, SessionProtocol.H1, serializationFormat); } break; default: - ch = acquireNowExact(key, desiredProtocol); + ch = acquireNowExact(key, desiredProtocol, serializationFormat); } return ch; } @Nullable - private PooledChannel acquireNowExact(PoolKey key, SessionProtocol protocol) { + private PooledChannel acquireNowExact(PoolKey key, SessionProtocol protocol, + SerializationFormat serializationFormat) { + if (serializationFormat.requiresNewConnection(protocol)) { + return null; + } final Deque queue = getPool(protocol, key); if (queue == null) { return null; @@ -336,11 +356,13 @@ private static SessionProtocol getProtocolIfHealthy(Channel ch) { * Acquires a new {@link Channel} which is matched by the specified condition by making a connection * attempt or waiting for the current connection attempt in progress. */ - CompletableFuture acquireLater(SessionProtocol desiredProtocol, PoolKey key, + CompletableFuture acquireLater(SessionProtocol desiredProtocol, + SerializationFormat serializationFormat, + PoolKey key, ClientConnectionTimingsBuilder timingsBuilder) { final ChannelAcquisitionFuture promise = new ChannelAcquisitionFuture(); - if (!usePendingAcquisition(desiredProtocol, key, promise, timingsBuilder)) { - connect(desiredProtocol, key, promise, timingsBuilder); + if (!usePendingAcquisition(desiredProtocol, serializationFormat, key, promise, timingsBuilder)) { + connect(desiredProtocol, serializationFormat, key, promise, timingsBuilder); } return promise; } @@ -350,11 +372,13 @@ CompletableFuture acquireLater(SessionProtocol desiredProtocol, P * * @return {@code true} if succeeded to reuse the pending connection. */ - private boolean usePendingAcquisition(SessionProtocol desiredProtocol, PoolKey key, + private boolean usePendingAcquisition(SessionProtocol desiredProtocol, + SerializationFormat serializationFormat, + PoolKey key, ChannelAcquisitionFuture promise, ClientConnectionTimingsBuilder timingsBuilder) { - if (desiredProtocol == SessionProtocol.H1 || desiredProtocol == SessionProtocol.H1C) { + if (desiredProtocol.isExplicitHttp1()) { // Can't use HTTP/1 connections because they will not be available in the pool until // the request is done. return false; @@ -366,11 +390,12 @@ private boolean usePendingAcquisition(SessionProtocol desiredProtocol, PoolKey k } timingsBuilder.pendingAcquisitionStart(); - pendingAcquisition.piggyback(desiredProtocol, key, promise, timingsBuilder); + pendingAcquisition.piggyback(desiredProtocol, serializationFormat, key, promise, timingsBuilder); return true; } - private void connect(SessionProtocol desiredProtocol, PoolKey key, ChannelAcquisitionFuture promise, + private void connect(SessionProtocol desiredProtocol, SerializationFormat serializationFormat, + PoolKey key, ChannelAcquisitionFuture promise, ClientConnectionTimingsBuilder timingsBuilder) { setPendingAcquisition(desiredProtocol, key, promise); timingsBuilder.socketConnectStart(); @@ -388,7 +413,7 @@ private void connect(SessionProtocol desiredProtocol, PoolKey key, ChannelAcquis // Create a new connection. final Promise sessionPromise = eventLoop.newPromise(); - connect(remoteAddress, desiredProtocol, key, sessionPromise); + connect(remoteAddress, desiredProtocol, serializationFormat, key, sessionPromise); if (sessionPromise.isDone()) { notifyConnect(desiredProtocol, key, sessionPromise, promise, timingsBuilder); @@ -402,17 +427,18 @@ private void connect(SessionProtocol desiredProtocol, PoolKey key, ChannelAcquis /** * A low-level operation that triggers a new connection attempt. Used only by: *

    - *
  • {@link #connect(SessionProtocol, PoolKey, ChannelAcquisitionFuture, - * ClientConnectionTimingsBuilder)} - The pool has been exhausted.
  • + *
  • {@link #connect(SessionProtocol, SerializationFormat, PoolKey, ChannelAcquisitionFuture, + * ClientConnectionTimingsBuilder)} - The pool has been exhausted.
  • *
  • {@link HttpSessionHandler} - HTTP/2 upgrade has failed.
  • *
*/ void connect(SocketAddress remoteAddress, SessionProtocol desiredProtocol, + SerializationFormat serializationFormat, PoolKey poolKey, Promise sessionPromise) { final Bootstrap bootstrap; try { - bootstrap = getBootstrap(desiredProtocol, remoteAddress); + bootstrap = getBootstrap(desiredProtocol, remoteAddress, serializationFormat); } catch (Exception e) { sessionPromise.tryFailure(e); return; @@ -433,7 +459,8 @@ void connect(SocketAddress remoteAddress, SessionProtocol desiredProtocol, channel.connect(remoteAddress).addListener((ChannelFuture connectFuture) -> { if (connectFuture.isSuccess()) { - initSession(desiredProtocol, poolKey, connectFuture, sessionPromise); + initSession(desiredProtocol, serializationFormat, + poolKey, connectFuture, sessionPromise); } else { maybeHandleProxyFailure(desiredProtocol, poolKey, connectFuture.cause()); sessionPromise.tryFailure(connectFuture.cause()); @@ -469,8 +496,8 @@ void maybeHandleProxyFailure(SessionProtocol protocol, PoolKey poolKey, Throwabl } } - private void initSession(SessionProtocol desiredProtocol, PoolKey poolKey, - ChannelFuture connectFuture, Promise sessionPromise) { + private void initSession(SessionProtocol desiredProtocol, SerializationFormat serializationFormat, + PoolKey poolKey, ChannelFuture connectFuture, Promise sessionPromise) { assert connectFuture.isSuccess(); final Channel ch = connectFuture.channel(); @@ -486,10 +513,11 @@ private void initSession(SessionProtocol desiredProtocol, PoolKey poolKey, ch.pipeline().addLast( new HttpSessionHandler(this, ch, sessionPromise, timeoutFuture, - desiredProtocol, poolKey, clientFactory)); + desiredProtocol, serializationFormat, poolKey, clientFactory)); } - private void notifyConnect(SessionProtocol desiredProtocol, PoolKey key, Future future, + private void notifyConnect(SessionProtocol desiredProtocol, + PoolKey key, Future future, ChannelAcquisitionFuture promise, ClientConnectionTimingsBuilder timingsBuilder) { assert future.isDone(); @@ -777,14 +805,15 @@ private final class ChannelAcquisitionFuture extends CompletableFuture handler = - pch -> handlePiggyback(desiredProtocol, key, childPromise, timingsBuilder, pch); + pch -> handlePiggyback(desiredProtocol, serializationFormat, key, + childPromise, timingsBuilder, pch); if (pendingPiggybackHandlers == null) { // The 1st handler @@ -813,11 +842,13 @@ void piggyback(SessionProtocol desiredProtocol, PoolKey key, } // Handle immediately if complete already. - handlePiggyback(desiredProtocol, key, childPromise, timingsBuilder, + handlePiggyback(desiredProtocol, serializationFormat, key, childPromise, timingsBuilder, isCompletedExceptionally() ? null : getNow(null)); } - private void handlePiggyback(SessionProtocol desiredProtocol, PoolKey key, + private void handlePiggyback(SessionProtocol desiredProtocol, + SerializationFormat serializationFormat, + PoolKey key, ChannelAcquisitionFuture childPromise, ClientConnectionTimingsBuilder timingsBuilder, @Nullable PooledChannel pch) { @@ -829,7 +860,8 @@ private void handlePiggyback(SessionProtocol desiredProtocol, PoolKey key, final HttpSession session = HttpSession.get(pch.get()); if (session.incrementNumUnfinishedResponses()) { result = PiggybackedChannelAcquisitionResult.SUCCESS; - } else if (usePendingAcquisition(actualProtocol, key, childPromise, timingsBuilder)) { + } else if (usePendingAcquisition(actualProtocol, serializationFormat, + key, childPromise, timingsBuilder)) { result = PiggybackedChannelAcquisitionResult.PIGGYBACKED_AGAIN; } else { result = PiggybackedChannelAcquisitionResult.NEW_CONNECTION; @@ -839,7 +871,7 @@ private void handlePiggyback(SessionProtocol desiredProtocol, PoolKey key, // We use the exact protocol (H1 or H1C) instead of 'desiredProtocol' so that // we do not waste our time looking for pending acquisitions for the host // that does not support HTTP/2. - final PooledChannel ch = acquireNow(actualProtocol, key); + final PooledChannel ch = acquireNow(actualProtocol, serializationFormat, key); if (ch != null) { pch = ch; result = PiggybackedChannelAcquisitionResult.SUCCESS; @@ -858,7 +890,7 @@ private void handlePiggyback(SessionProtocol desiredProtocol, PoolKey key, break; case NEW_CONNECTION: timingsBuilder.pendingAcquisitionEnd(); - connect(desiredProtocol, key, childPromise, timingsBuilder); + connect(desiredProtocol, serializationFormat, key, childPromise, timingsBuilder); break; case PIGGYBACKED_AGAIN: // There's nothing to do because usePendingAcquisition() was called successfully above. diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java index 884c7bc705fa..72f953d9d5d8 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java @@ -28,6 +28,7 @@ import com.linecorp.armeria.client.proxy.ProxyType; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.logging.ClientConnectionTimings; @@ -167,25 +168,27 @@ private void acquireConnectionAndExecute0(ClientRequestContext ctx, Endpoint end HttpRequest req, DecodedHttpResponse res, ClientConnectionTimingsBuilder timingsBuilder, ProxyConfig proxyConfig) { - final SessionProtocol protocol = ctx.sessionProtocol(); final PoolKey key = new PoolKey(endpoint, proxyConfig); final HttpChannelPool pool = factory.pool(ctx.eventLoop().withoutContext()); - final PooledChannel pooledChannel = pool.acquireNow(protocol, key); + final SessionProtocol protocol = ctx.sessionProtocol(); + final SerializationFormat serializationFormat = ctx.log().partial().serializationFormat(); + final PooledChannel pooledChannel = pool.acquireNow(protocol, serializationFormat, key); if (pooledChannel != null) { logSession(ctx, pooledChannel, null); doExecute(pooledChannel, ctx, req, res); } else { - pool.acquireLater(protocol, key, timingsBuilder).handle((newPooledChannel, cause) -> { - logSession(ctx, newPooledChannel, timingsBuilder.build()); - if (cause == null) { - doExecute(newPooledChannel, ctx, req, res); - } else { - final UnprocessedRequestException wrapped = UnprocessedRequestException.of(cause); - handleEarlyRequestException(ctx, req, wrapped); - res.close(wrapped); - } - return null; - }); + pool.acquireLater(protocol, serializationFormat, key, timingsBuilder) + .handle((newPooledChannel, cause) -> { + logSession(ctx, newPooledChannel, timingsBuilder.build()); + if (cause == null) { + doExecute(newPooledChannel, ctx, req, res); + } else { + final UnprocessedRequestException wrapped = UnprocessedRequestException.of(cause); + handleEarlyRequestException(ctx, req, wrapped); + res.close(wrapped); + } + return null; + }); } } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java index 8ae62a321c79..a05eb38e9d2b 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java @@ -29,6 +29,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,7 +79,8 @@ final class HttpClientFactory implements ClientFactory { private static final Set SUPPORTED_SCHEMES = Arrays.stream(SessionProtocol.values()) - .map(p -> Scheme.of(SerializationFormat.NONE, p)) + .flatMap(p -> Stream.of(Scheme.of(SerializationFormat.NONE, p), + Scheme.of(SerializationFormat.WS, p))) .collect(toImmutableSet()); private final EventLoopGroup workerGroup; diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java index 5e6b8f9c988a..5009687de826 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java @@ -124,6 +124,11 @@ final class HttpClientPipelineConfigurator extends ChannelDuplexHandler { */ private static final long UPGRADE_RESPONSE_MAX_LENGTH = 16384; + private static final RequestOptions REQUEST_OPTIONS_FOR_UPGRADE_REQUEST = + RequestOptions.builder() + .responseTimeoutMillis(0) + .maxResponseLength(UPGRADE_RESPONSE_MAX_LENGTH).build(); + private enum HttpPreference { HTTP1_REQUIRED, HTTP2_PREFERRED, @@ -131,6 +136,7 @@ private enum HttpPreference { } private final HttpClientFactory clientFactory; + private final boolean webSocket; @Nullable private final SslContext sslCtx; private final HttpPreference httpPreference; @@ -141,9 +147,10 @@ private enum HttpPreference { private final SessionProtocol http2; HttpClientPipelineConfigurator(HttpClientFactory clientFactory, - SessionProtocol sessionProtocol, + boolean webSocket, SessionProtocol sessionProtocol, @Nullable SslContext sslCtx) { this.clientFactory = clientFactory; + this.webSocket = webSocket; if (sessionProtocol == HTTP || sessionProtocol == HTTPS) { httpPreference = HttpPreference.HTTP2_PREFERRED; @@ -400,7 +407,10 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { void finishSuccessfully(ChannelPipeline pipeline, SessionProtocol protocol) { if (protocol == H1 || protocol == H1C) { - addBeforeSessionHandler(pipeline, new Http1ResponseDecoder(pipeline.channel())); + addBeforeSessionHandler( + pipeline, webSocket ? new WebSocketHttp1ClientChannelHandler(pipeline.channel()) + : new Http1ResponseDecoder(pipeline.channel(), + clientFactory, protocol)); } else if (protocol == H2 || protocol == H2C) { final int initialWindow = clientFactory.http2InitialConnectionWindowSize(); if (initialWindow > DEFAULT_WINDOW_SIZE) { @@ -420,7 +430,7 @@ private static void incrementLocalWindowSize(ChannelPipeline pipeline, int delta } } - private void addBeforeSessionHandler(ChannelPipeline pipeline, ChannelHandler handler) { + private static void addBeforeSessionHandler(ChannelPipeline pipeline, ChannelHandler handler) { final ChannelHandlerContext lastContext = pipeline.lastContext(); if (lastContext.handler().getClass() == HttpSessionHandler.class) { // Get the name of the HttpSessionHandler so that we can put our handlers before it. @@ -532,12 +542,11 @@ public void onComplete() {} com.linecorp.armeria.common.HttpMethod.OPTIONS, RequestTarget.forClient("*"), ClientOptions.of(), HttpRequest.of(com.linecorp.armeria.common.HttpMethod.OPTIONS, "*"), - null, RequestOptions.of(), noopResponseCancellationScheduler, + null, REQUEST_OPTIONS_FOR_UPGRADE_REQUEST, noopResponseCancellationScheduler, System.nanoTime(), SystemInfo.currentTimeMicros()); // NB: No need to set the response timeout because we have session creation timeout. - responseDecoder.addResponse(0, res, reqCtx, ctx.channel().eventLoop(), /* response timeout */ 0, - UPGRADE_RESPONSE_MAX_LENGTH); + responseDecoder.addResponse(0, res, reqCtx, ctx.channel().eventLoop()); ctx.fireChannelActive(); } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpRequestSubscriber.java b/core/src/main/java/com/linecorp/armeria/client/HttpRequestSubscriber.java index 89b16e540888..e4ddfb1b2845 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpRequestSubscriber.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpRequestSubscriber.java @@ -16,57 +16,22 @@ package com.linecorp.armeria.client; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; - import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpHeaders; import com.linecorp.armeria.common.HttpObject; import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.client.DecodedHttpResponse; import com.linecorp.armeria.unsafe.PooledObjects; import io.netty.channel.Channel; -final class HttpRequestSubscriber extends AbstractHttpRequestHandler implements Subscriber { - - private static final HttpData EMPTY_EOS = HttpData.empty().withEndOfStream(); - - private final HttpRequest request; - - // subscription, id and responseWrapper are assigned in onSubscribe() - @Nullable - private Subscription subscription; - private boolean isSubscriptionCompleted; +class HttpRequestSubscriber extends AbstractHttpRequestSubscriber { HttpRequestSubscriber(Channel ch, ClientHttpObjectEncoder encoder, HttpResponseDecoder responseDecoder, HttpRequest request, DecodedHttpResponse originalRes, ClientRequestContext ctx, long timeoutMillis) { - super(ch, encoder, responseDecoder, originalRes, ctx, timeoutMillis, request.isEmpty()); - this.request = request; - } - - @Override - public void onSubscribe(Subscription subscription) { - assert this.subscription == null; - this.subscription = subscription; - if (state() == State.DONE) { - cancel(); - return; - } - - if (!tryInitialize()) { - return; - } - - // NB: This must be invoked at the end of this method because otherwise the callback methods in this - // class can be called before the member fields (subscription, id, responseWrapper and - // timeoutFuture) are initialized. - // It is because the successful write of the first headers will trigger subscription.request(1). - writeHeaders(request.headers()); - channel().flush(); + super(ch, encoder, responseDecoder, request, originalRes, ctx, timeoutMillis, true, true); } @Override @@ -74,6 +39,7 @@ public void onNext(HttpObject o) { if (!(o instanceof HttpData) && !(o instanceof HttpHeaders)) { failAndReset(new IllegalArgumentException( "published an HttpObject that's neither Http2Headers nor Http2Data: " + o)); + PooledObjects.close(o); return; } @@ -100,38 +66,4 @@ public void onNext(HttpObject o) { break; } } - - @Override - public void onError(Throwable cause) { - isSubscriptionCompleted = true; - failRequest(cause); - } - - @Override - public void onComplete() { - isSubscriptionCompleted = true; - - if (state() != State.DONE) { - writeData(EMPTY_EOS); - channel().flush(); - } - } - - @Override - void onWriteSuccess() { - // Request more messages regardless whether the state is DONE. It makes the producer have - // a chance to produce the last call such as 'onComplete' and 'onError' when there are - // no more messages it can produce. - if (!isSubscriptionCompleted) { - assert subscription != null; - subscription.request(1); - } - } - - @Override - void cancel() { - isSubscriptionCompleted = true; - assert subscription != null; - subscription.cancel(); - } } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpResponseDecoder.java b/core/src/main/java/com/linecorp/armeria/client/HttpResponseDecoder.java index decffa52ebae..125340aa1152 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpResponseDecoder.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpResponseDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 LINE Corporation + * Copyright 2023 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 @@ -16,425 +16,43 @@ package com.linecorp.armeria.client; -import java.util.Iterator; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -import org.reactivestreams.Subscriber; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.linecorp.armeria.common.ContentTooLargeException; -import com.linecorp.armeria.common.ContentTooLargeExceptionBuilder; -import com.linecorp.armeria.common.HttpData; -import com.linecorp.armeria.common.HttpHeaders; -import com.linecorp.armeria.common.HttpObject; -import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.HttpStatusClass; -import com.linecorp.armeria.common.ResponseCompleteException; -import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.logging.RequestLogProperty; -import com.linecorp.armeria.common.stream.CancelledSubscriptionException; -import com.linecorp.armeria.common.stream.StreamWriter; -import com.linecorp.armeria.common.stream.SubscriptionOption; -import com.linecorp.armeria.common.util.Exceptions; -import com.linecorp.armeria.internal.client.ClientRequestContextExtension; import com.linecorp.armeria.internal.client.DecodedHttpResponse; import com.linecorp.armeria.internal.client.HttpSession; -import com.linecorp.armeria.internal.common.CancellationScheduler; -import com.linecorp.armeria.internal.common.CancellationScheduler.CancellationTask; import com.linecorp.armeria.internal.common.InboundTrafficController; import com.linecorp.armeria.internal.common.KeepAliveHandler; -import com.linecorp.armeria.unsafe.PooledObjects; import io.netty.channel.Channel; import io.netty.channel.EventLoop; -import io.netty.util.collection.IntObjectHashMap; -import io.netty.util.collection.IntObjectMap; -import io.netty.util.concurrent.EventExecutor; - -abstract class HttpResponseDecoder { - private static final Logger logger = LoggerFactory.getLogger(HttpResponseDecoder.class); +interface HttpResponseDecoder { - private final IntObjectMap responses = new IntObjectHashMap<>(); - private final Channel channel; - private final InboundTrafficController inboundTrafficController; - - @Nullable - private HttpSession httpSession; + Channel channel(); - private int unfinishedResponses; - private boolean closing; - - HttpResponseDecoder(Channel channel, InboundTrafficController inboundTrafficController) { - this.channel = channel; - this.inboundTrafficController = inboundTrafficController; - } - - final Channel channel() { - return channel; - } - - final InboundTrafficController inboundTrafficController() { - return inboundTrafficController; - } + InboundTrafficController inboundTrafficController(); HttpResponseWrapper addResponse( - int id, DecodedHttpResponse res, ClientRequestContext ctx, - EventLoop eventLoop, long responseTimeoutMillis, long maxContentLength) { - - final HttpResponseWrapper newRes = - new HttpResponseWrapper(res, ctx, responseTimeoutMillis, maxContentLength); - final HttpResponseWrapper oldRes = responses.put(id, newRes); - final KeepAliveHandler keepAliveHandler = keepAliveHandler(); - if (keepAliveHandler != null) { - keepAliveHandler.increaseNumRequests(); - } - - assert oldRes == null : "addResponse(" + id + ", " + res + ", " + responseTimeoutMillis + "): " + - oldRes; - onResponseAdded(id, eventLoop, newRes); - return newRes; - } - - abstract void onResponseAdded(int id, EventLoop eventLoop, HttpResponseWrapper responseWrapper); + int id, DecodedHttpResponse res, ClientRequestContext ctx, EventLoop eventLoop); @Nullable - final HttpResponseWrapper getResponse(int id) { - return responses.get(id); - } + HttpResponseWrapper getResponse(int id); @Nullable - final HttpResponseWrapper removeResponse(int id) { - if (closing) { - // `unfinishedResponses` will be removed by `failUnfinishedResponses()` - return null; - } + HttpResponseWrapper removeResponse(int id); - final HttpResponseWrapper removed = responses.remove(id); - if (removed != null) { - unfinishedResponses--; - assert unfinishedResponses >= 0 : unfinishedResponses; - } - return removed; - } + boolean hasUnfinishedResponses(); - final boolean hasUnfinishedResponses() { - return unfinishedResponses != 0; - } - - final boolean reserveUnfinishedResponse(int maxUnfinishedResponses) { - if (unfinishedResponses >= maxUnfinishedResponses) { - return false; - } - - unfinishedResponses++; - return true; - } + boolean reserveUnfinishedResponse(int maxUnfinishedResponses); - final void decrementUnfinishedResponses() { - unfinishedResponses--; - } - - final void failUnfinishedResponses(Throwable cause) { - if (closing) { - return; - } - closing = true; + void decrementUnfinishedResponses(); - for (final Iterator iterator = responses.values().iterator(); - iterator.hasNext();) { - final HttpResponseWrapper res = iterator.next(); - // To avoid calling removeResponse by res.close(cause), remove before closing. - iterator.remove(); - unfinishedResponses--; - res.close(cause); - } - } + void failUnfinishedResponses(Throwable cause); - HttpSession session() { - if (httpSession != null) { - return httpSession; - } - return httpSession = HttpSession.get(channel); - } + HttpSession session(); - @Nullable - abstract KeepAliveHandler keepAliveHandler(); + KeepAliveHandler keepAliveHandler(); - final boolean needsToDisconnectNow() { + default boolean needsToDisconnectNow() { return !session().isAcquirable() && !hasUnfinishedResponses(); } - - static final class HttpResponseWrapper implements StreamWriter { - - private final DecodedHttpResponse delegate; - private final ClientRequestContext ctx; - private final long maxContentLength; - private final long responseTimeoutMillis; - - private boolean responseStarted; - private long contentLengthHeaderValue = -1; - - private boolean done; - private boolean closed; - - HttpResponseWrapper(DecodedHttpResponse delegate, ClientRequestContext ctx, - long responseTimeoutMillis, long maxContentLength) { - this.delegate = delegate; - this.ctx = ctx; - this.maxContentLength = maxContentLength; - this.responseTimeoutMillis = responseTimeoutMillis; - } - - long maxContentLength() { - return maxContentLength; - } - - long writtenBytes() { - return delegate.writtenBytes(); - } - - long contentLengthHeaderValue() { - return contentLengthHeaderValue; - } - - @Override - public boolean isOpen() { - return delegate.isOpen(); - } - - @Override - public boolean isEmpty() { - throw new UnsupportedOperationException(); - } - - @Override - public long demand() { - throw new UnsupportedOperationException(); - } - - @Override - public CompletableFuture whenComplete() { - return delegate.whenComplete(); - } - - @Override - public void subscribe(Subscriber subscriber, EventExecutor executor, - SubscriptionOption... options) { - throw new UnsupportedOperationException(); - } - - @Override - public void abort() { - throw new UnsupportedOperationException(); - } - - @Override - public void abort(Throwable cause) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean tryWrite(HttpObject o) { - if (done) { - PooledObjects.close(o); - return false; - } - return delegate.tryWrite(o); - } - - void startResponse() { - if (responseStarted) { - return; - } - responseStarted = true; - ctx.logBuilder().startResponse(); - ctx.logBuilder().responseFirstBytesTransferred(); - initTimeout(); - } - - boolean tryWriteResponseHeaders(ResponseHeaders responseHeaders) { - assert responseHeaders.status().codeClass() != HttpStatusClass.INFORMATIONAL; - contentLengthHeaderValue = responseHeaders.contentLength(); - ctx.logBuilder().defer(RequestLogProperty.RESPONSE_HEADERS); - try { - return delegate.tryWrite(responseHeaders); - } finally { - ctx.logBuilder().responseHeaders(responseHeaders); - } - } - - boolean tryWriteData(HttpData data) { - if (done) { - PooledObjects.close(data); - return false; - } - data.touch(ctx); - ctx.logBuilder().increaseResponseLength(data); - return delegate.tryWrite(data); - } - - boolean tryWriteTrailers(HttpHeaders trailers) { - if (done) { - return false; - } - done = true; - ctx.logBuilder().defer(RequestLogProperty.RESPONSE_TRAILERS); - try { - return delegate.tryWrite(trailers); - } finally { - ctx.logBuilder().responseTrailers(trailers); - } - } - - @Override - public CompletableFuture whenConsumed() { - return delegate.whenConsumed(); - } - - void onSubscriptionCancelled(@Nullable Throwable cause) { - close(cause, true); - } - - @Override - public void close() { - close(null, false); - } - - @Override - public void close(Throwable cause) { - close(cause, false); - } - - private void close(@Nullable Throwable cause, boolean cancel) { - if (closed) { - return; - } - done = true; - closed = true; - cancelTimeoutOrLog(cause, cancel); - final HttpRequest request = ctx.request(); - assert request != null; - if (cause != null) { - request.abort(cause); - return; - } - final long requestAutoAbortDelayMillis = ctx.requestAutoAbortDelayMillis(); - if (requestAutoAbortDelayMillis == 0) { - request.abort(ResponseCompleteException.get()); - return; - } - if (requestAutoAbortDelayMillis > 0 && - requestAutoAbortDelayMillis < Long.MAX_VALUE) { - ctx.eventLoop().schedule(() -> request.abort(ResponseCompleteException.get()), - requestAutoAbortDelayMillis, TimeUnit.MILLISECONDS); - } - } - - private void closeAction(@Nullable Throwable cause) { - if (cause != null) { - delegate.close(cause); - ctx.logBuilder().endResponse(cause); - } else { - delegate.close(); - ctx.logBuilder().endResponse(); - } - } - - private void cancelAction(@Nullable Throwable cause) { - if (cause != null && !(cause instanceof CancelledSubscriptionException)) { - ctx.logBuilder().endResponse(cause); - } else { - ctx.logBuilder().endResponse(); - } - } - - private void cancelTimeoutOrLog(@Nullable Throwable cause, boolean cancel) { - CancellationScheduler responseCancellationScheduler = null; - final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class); - if (ctxExtension != null) { - responseCancellationScheduler = ctxExtension.responseCancellationScheduler(); - } - - if (responseCancellationScheduler == null || !responseCancellationScheduler.isFinished()) { - if (responseCancellationScheduler != null) { - responseCancellationScheduler.clearTimeout(false); - } - // There's no timeout or the response has not been timed out. - if (cancel) { - cancelAction(cause); - } else { - closeAction(cause); - } - return; - } - if (delegate.isOpen()) { - closeAction(cause); - } - - // Response has been timed out already. - // Log only when it's not a ResponseTimeoutException. - if (cause instanceof ResponseTimeoutException) { - return; - } - - if (cause == null || !logger.isWarnEnabled() || Exceptions.isExpected(cause)) { - return; - } - - final StringBuilder logMsg = new StringBuilder("Unexpected exception while closing a request"); - final String authority = ctx.request().authority(); - if (authority != null) { - logMsg.append(" to ").append(authority); - } - - logger.warn(logMsg.append(':').toString(), cause); - } - - void initTimeout() { - final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class); - if (ctxExtension != null) { - final CancellationScheduler responseCancellationScheduler = - ctxExtension.responseCancellationScheduler(); - responseCancellationScheduler.init( - ctx.eventLoop(), newCancellationTask(), - TimeUnit.MILLISECONDS.toNanos(responseTimeoutMillis), /* server */ false); - } - } - - private CancellationTask newCancellationTask() { - return new CancellationTask() { - @Override - public boolean canSchedule() { - return delegate.isOpen() && !done; - } - - @Override - public void run(Throwable cause) { - delegate.close(cause); - ctx.request().abort(cause); - ctx.logBuilder().endResponse(cause); - } - }; - } - - @Override - public String toString() { - return delegate.toString(); - } - } - - static Exception contentTooLargeException(HttpResponseWrapper res, long transferred) { - final ContentTooLargeExceptionBuilder builder = - ContentTooLargeException.builder() - .maxContentLength(res.maxContentLength()) - .transferred(transferred); - if (res.contentLengthHeaderValue() >= 0) { - builder.contentLength(res.contentLengthHeaderValue()); - } - return builder.build(); - } } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpResponseWrapper.java b/core/src/main/java/com/linecorp/armeria/client/HttpResponseWrapper.java new file mode 100644 index 000000000000..0795416df6d3 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/HttpResponseWrapper.java @@ -0,0 +1,328 @@ +/* + * Copyright 2023 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.client; + +import static com.google.common.base.MoreObjects.toStringHelper; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.reactivestreams.Subscriber; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpHeaders; +import com.linecorp.armeria.common.HttpObject; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.ResponseCompleteException; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.logging.RequestLogProperty; +import com.linecorp.armeria.common.stream.CancelledSubscriptionException; +import com.linecorp.armeria.common.stream.StreamWriter; +import com.linecorp.armeria.common.stream.SubscriptionOption; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.internal.client.ClientRequestContextExtension; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; +import com.linecorp.armeria.internal.common.CancellationScheduler; +import com.linecorp.armeria.internal.common.CancellationScheduler.CancellationTask; +import com.linecorp.armeria.unsafe.PooledObjects; + +import io.netty.channel.EventLoop; +import io.netty.util.concurrent.EventExecutor; + +class HttpResponseWrapper implements StreamWriter { + + private static final Logger logger = LoggerFactory.getLogger(HttpResponseWrapper.class); + + private final DecodedHttpResponse delegate; + private final EventLoop eventLoop; + private final ClientRequestContext ctx; + private final long maxContentLength; + private final long responseTimeoutMillis; + + private boolean responseStarted; + private long contentLengthHeaderValue = -1; + + private boolean done; + private boolean closed; + + HttpResponseWrapper(DecodedHttpResponse delegate, EventLoop eventLoop, ClientRequestContext ctx, + long responseTimeoutMillis, long maxContentLength) { + this.delegate = delegate; + this.eventLoop = eventLoop; + this.ctx = ctx; + this.maxContentLength = maxContentLength; + this.responseTimeoutMillis = responseTimeoutMillis; + } + + DecodedHttpResponse delegate() { + return delegate; + } + + EventLoop eventLoop() { + return eventLoop; + } + + long maxContentLength() { + return maxContentLength; + } + + long writtenBytes() { + return delegate.writtenBytes(); + } + + long contentLengthHeaderValue() { + return contentLengthHeaderValue; + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public boolean isEmpty() { + throw new UnsupportedOperationException(); + } + + @Override + public long demand() { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture whenComplete() { + return delegate.whenComplete(); + } + + @Override + public void subscribe(Subscriber subscriber, EventExecutor executor, + SubscriptionOption... options) { + throw new UnsupportedOperationException(); + } + + @Override + public void abort() { + throw new UnsupportedOperationException(); + } + + @Override + public void abort(Throwable cause) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean tryWrite(HttpObject o) { + if (done) { + PooledObjects.close(o); + return false; + } + return delegate.tryWrite(o); + } + + void startResponse() { + if (responseStarted) { + return; + } + responseStarted = true; + ctx.logBuilder().startResponse(); + ctx.logBuilder().responseFirstBytesTransferred(); + initTimeout(); + } + + boolean tryWriteResponseHeaders(ResponseHeaders responseHeaders) { + contentLengthHeaderValue = responseHeaders.contentLength(); + ctx.logBuilder().defer(RequestLogProperty.RESPONSE_HEADERS); + try { + return delegate.tryWrite(responseHeaders); + } finally { + ctx.logBuilder().responseHeaders(responseHeaders); + } + } + + boolean tryWriteData(HttpData data) { + if (done) { + PooledObjects.close(data); + return false; + } + data.touch(ctx); + ctx.logBuilder().increaseResponseLength(data); + return delegate.tryWrite(data); + } + + boolean tryWriteTrailers(HttpHeaders trailers) { + if (done) { + return false; + } + done = true; + ctx.logBuilder().defer(RequestLogProperty.RESPONSE_TRAILERS); + try { + return delegate.tryWrite(trailers); + } finally { + ctx.logBuilder().responseTrailers(trailers); + } + } + + @Override + public CompletableFuture whenConsumed() { + return delegate.whenConsumed(); + } + + /** + * This method is called when the delegate is completed. + */ + void onSubscriptionCancelled(@Nullable Throwable cause) { + close(cause, true); + } + + @Override + public void close() { + close(null, false); + } + + @Override + public void close(Throwable cause) { + close(cause, false); + } + + void close(@Nullable Throwable cause, boolean cancel) { + if (closed) { + return; + } + done = true; + closed = true; + cancelTimeoutOrLog(cause, cancel); + final HttpRequest request = ctx.request(); + assert request != null; + if (cause != null) { + request.abort(cause); + return; + } + final long requestAutoAbortDelayMillis = ctx.requestAutoAbortDelayMillis(); + if (requestAutoAbortDelayMillis < 0 || requestAutoAbortDelayMillis == Long.MAX_VALUE) { + return; + } + if (requestAutoAbortDelayMillis == 0) { + request.abort(ResponseCompleteException.get()); + return; + } + ctx.eventLoop().schedule(() -> request.abort(ResponseCompleteException.get()), + requestAutoAbortDelayMillis, TimeUnit.MILLISECONDS); + } + + private void closeAction(@Nullable Throwable cause) { + if (cause != null) { + delegate.close(cause); + ctx.logBuilder().endResponse(cause); + } else { + delegate.close(); + ctx.logBuilder().endResponse(); + } + } + + private void cancelAction(@Nullable Throwable cause) { + if (cause != null && !(cause instanceof CancelledSubscriptionException)) { + ctx.logBuilder().endResponse(cause); + } else { + ctx.logBuilder().endResponse(); + } + } + + private void cancelTimeoutOrLog(@Nullable Throwable cause, boolean cancel) { + CancellationScheduler responseCancellationScheduler = null; + final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class); + if (ctxExtension != null) { + responseCancellationScheduler = ctxExtension.responseCancellationScheduler(); + } + + if (responseCancellationScheduler == null || !responseCancellationScheduler.isFinished()) { + if (responseCancellationScheduler != null) { + responseCancellationScheduler.clearTimeout(false); + } + // There's no timeout or the response has not been timed out. + if (cancel) { + cancelAction(cause); + } else { + closeAction(cause); + } + return; + } + if (delegate.isOpen()) { + closeAction(cause); + } + + // Response has been timed out already. + // Log only when it's not a ResponseTimeoutException. + if (cause instanceof ResponseTimeoutException) { + return; + } + + if (cause == null || !logger.isWarnEnabled() || Exceptions.isExpected(cause)) { + return; + } + + final StringBuilder logMsg = new StringBuilder("Unexpected exception while closing a request"); + final String authority = ctx.request().authority(); + if (authority != null) { + logMsg.append(" to ").append(authority); + } + + logger.warn(logMsg.append(':').toString(), cause); + } + + void initTimeout() { + final ClientRequestContextExtension ctxExtension = ctx.as(ClientRequestContextExtension.class); + if (ctxExtension != null) { + final CancellationScheduler responseCancellationScheduler = + ctxExtension.responseCancellationScheduler(); + responseCancellationScheduler.init( + ctx.eventLoop(), newCancellationTask(), + TimeUnit.MILLISECONDS.toNanos(responseTimeoutMillis), /* server */ false); + } + } + + private CancellationTask newCancellationTask() { + return new CancellationTask() { + @Override + public boolean canSchedule() { + return delegate.isOpen() && !done; + } + + @Override + public void run(Throwable cause) { + delegate.close(cause); + ctx.request().abort(cause); + ctx.logBuilder().endResponse(cause); + } + }; + } + + @Override + public String toString() { + return toStringHelper(this).omitNullValues() + .add("ctx", ctx) + .add("eventLoop", eventLoop) + .add("responseStarted", responseStarted) + .add("maxContentLength", maxContentLength) + .add("responseTimeoutMillis", responseTimeoutMillis) + .add("contentLengthHeaderValue", contentLengthHeaderValue) + .add("delegate", delegate) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpSessionHandler.java b/core/src/main/java/com/linecorp/armeria/client/HttpSessionHandler.java index afed2bde1fe1..59a7ee6f81a2 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpSessionHandler.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpSessionHandler.java @@ -19,7 +19,6 @@ import static com.linecorp.armeria.common.SessionProtocol.H1C; import static com.linecorp.armeria.common.SessionProtocol.H2; import static com.linecorp.armeria.common.SessionProtocol.H2C; -import static com.linecorp.armeria.internal.common.KeepAliveHandlerUtil.needsKeepAliveHandler; import static java.util.Objects.requireNonNull; import java.io.IOException; @@ -30,16 +29,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.collect.ImmutableList; - import com.linecorp.armeria.client.HttpChannelPool.PoolKey; import com.linecorp.armeria.client.proxy.ProxyType; import com.linecorp.armeria.common.AggregationOptions; import com.linecorp.armeria.common.ClosedSessionException; import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.metric.MoreMeters; import com.linecorp.armeria.common.stream.CancelledSubscriptionException; import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.common.util.SafeCloseable; @@ -49,11 +46,8 @@ import com.linecorp.armeria.internal.common.Http2GoAwayHandler; import com.linecorp.armeria.internal.common.InboundTrafficController; import com.linecorp.armeria.internal.common.KeepAliveHandler; -import com.linecorp.armeria.internal.common.NoopKeepAliveHandler; import com.linecorp.armeria.internal.common.RequestContextUtil; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Timer; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; import io.netty.channel.Channel; @@ -82,6 +76,7 @@ final class HttpSessionHandler extends ChannelDuplexHandler implements HttpSessi private final Promise sessionPromise; private final ScheduledFuture sessionTimeoutFuture; private final SessionProtocol desiredProtocol; + private final SerializationFormat serializationFormat; private final PoolKey poolKey; private final HttpClientFactory clientFactory; @@ -124,18 +119,24 @@ final class HttpSessionHandler extends ChannelDuplexHandler implements HttpSessi HttpSessionHandler(HttpChannelPool channelPool, Channel channel, Promise sessionPromise, ScheduledFuture sessionTimeoutFuture, - SessionProtocol desiredProtocol, PoolKey poolKey, - HttpClientFactory clientFactory) { + SessionProtocol desiredProtocol, SerializationFormat serializationFormat, + PoolKey poolKey, HttpClientFactory clientFactory) { this.channelPool = requireNonNull(channelPool, "channelPool"); this.channel = requireNonNull(channel, "channel"); remoteAddress = channel.remoteAddress(); this.sessionPromise = requireNonNull(sessionPromise, "sessionPromise"); this.sessionTimeoutFuture = requireNonNull(sessionTimeoutFuture, "sessionTimeoutFuture"); this.desiredProtocol = desiredProtocol; + this.serializationFormat = serializationFormat; this.poolKey = poolKey; this.clientFactory = clientFactory; } + @Override + public SerializationFormat serializationFormat() { + return serializationFormat; + } + @Override public SessionProtocol protocol() { return protocol; @@ -195,8 +196,9 @@ public void invoke(PooledChannel pooledChannel, ClientRequestContext ctx, assert protocol != null; assert responseDecoder != null; assert requestEncoder != null; - if (!protocol.isMultiplex()) { - // When HTTP/1.1 is used: + if (!protocol.isMultiplex() && !serializationFormat.requiresNewConnection(protocol)) { + // When HTTP/1.1 is used and the serialization format does not require + // a new connection (w.g. WebSocket): // If pipelining is enabled, return as soon as the request is fully sent. // If pipelining is disabled, // return after the response is fully received and the request is fully sent. @@ -212,23 +214,26 @@ public void invoke(PooledChannel pooledChannel, ClientRequestContext ctx, }); } - if (ctx.exchangeType().isRequestStreaming()) { - final HttpRequestSubscriber reqSubscriber = new HttpRequestSubscriber( - channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); - // A StreamMessage of a request body uses RequestContext to get the default SubscriberExecutor. - try (SafeCloseable ignored = ctx.push()) { - req.subscribe(reqSubscriber, channel.eventLoop(), SubscriptionOption.WITH_POOLED_OBJECTS); - } - } else { - final AggregatedHttpRequestHandler reqHandler = new AggregatedHttpRequestHandler( - channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); - try (SafeCloseable ignored = ctx.push()) { + try (SafeCloseable ignored = ctx.push()) { + if (!ctx.exchangeType().isRequestStreaming()) { + final AggregatedHttpRequestHandler reqHandler = new AggregatedHttpRequestHandler( + channel, requestEncoder, responseDecoder, req, res, ctx, writeTimeoutMillis); req.aggregate(AggregationOptions.usePooledObjects(ctx.alloc(), channel.eventLoop())) .handle(reqHandler); + return; } + + final AbstractHttpRequestSubscriber subscriber = AbstractHttpRequestSubscriber.of( + channel, requestEncoder, responseDecoder, protocol, + ctx, req, res, writeTimeoutMillis, isWebSocket()); + req.subscribe(subscriber, channel.eventLoop(), SubscriptionOption.WITH_POOLED_OBJECTS); } } + private boolean isWebSocket() { + return serializationFormat == SerializationFormat.WS; + } + @Override public int incrementAndGetNumRequestsSent() { return ++numRequestsSent; @@ -345,40 +350,23 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc final SessionProtocol protocol = (SessionProtocol) evt; this.protocol = protocol; if (protocol == H1 || protocol == H1C) { - final Http1ResponseDecoder responseDecoder = ctx.pipeline().get(Http1ResponseDecoder.class); - - final long idleTimeoutMillis = clientFactory.idleTimeoutMillis(); - final long pingIntervalMillis = clientFactory.pingIntervalMillis(); - final long maxConnectionAgeMillis = clientFactory.maxConnectionAgeMillis(); - final int maxNumRequestsPerConnection = clientFactory.maxNumRequestsPerConnection(); - final boolean keepAliveOnPing = clientFactory.keepAliveOnPing(); - final boolean needsKeepAliveHandler = - needsKeepAliveHandler(idleTimeoutMillis, pingIntervalMillis, - maxConnectionAgeMillis, maxNumRequestsPerConnection); - - final KeepAliveHandler keepAliveHandler; - if (needsKeepAliveHandler) { - final Timer keepAliveTimer = - MoreMeters.newTimer(clientFactory.meterRegistry(), - "armeria.client.connections.lifespan", - ImmutableList.of(Tag.of("protocol", protocol.uriText()))); - keepAliveHandler = new Http1ClientKeepAliveHandler( - channel, responseDecoder, keepAliveTimer, idleTimeoutMillis, - pingIntervalMillis, maxConnectionAgeMillis, maxNumRequestsPerConnection, - keepAliveOnPing); + final HttpResponseDecoder responseDecoder; + if (isWebSocket()) { + responseDecoder = ctx.pipeline().get(WebSocketHttp1ClientChannelHandler.class); } else { - keepAliveHandler = new NoopKeepAliveHandler(); + responseDecoder = ctx.pipeline().get(Http1ResponseDecoder.class); } + final KeepAliveHandler keepAliveHandler = responseDecoder.keepAliveHandler(); + keepAliveHandler.initialize(ctx); final ClientHttp1ObjectEncoder requestEncoder = new ClientHttp1ObjectEncoder(channel, protocol, clientFactory.http1HeaderNaming(), - keepAliveHandler); + keepAliveHandler, + isWebSocket()); if (keepAliveHandler instanceof Http1ClientKeepAliveHandler) { ((Http1ClientKeepAliveHandler) keepAliveHandler).setEncoder(requestEncoder); } - responseDecoder.setKeepAliveHandler(ctx, keepAliveHandler); - this.requestEncoder = requestEncoder; this.responseDecoder = responseDecoder; } else if (protocol == H2 || protocol == H2C) { @@ -465,9 +453,10 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { assert responseDecoder == null || !responseDecoder.hasUnfinishedResponses(); sessionTimeoutFuture.cancel(false); if (proxyDestinationAddress != null) { - channelPool.connect(proxyDestinationAddress, retryProtocol, poolKey, sessionPromise); + channelPool.connect(proxyDestinationAddress, retryProtocol, serializationFormat, + poolKey, sessionPromise); } else { - channelPool.connect(remoteAddress, retryProtocol, poolKey, sessionPromise); + channelPool.connect(remoteAddress, retryProtocol, serializationFormat, poolKey, sessionPromise); } } else { // Fail all pending responses. diff --git a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java index 5109e1632e87..86aea236e12f 100644 --- a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java @@ -198,6 +198,12 @@ public RestClientPreparation content(MediaType contentType, HttpData content) { return this; } + @Override + public RestClientPreparation content(Publisher content) { + delegate.content(content); + return this; + } + @Override public RestClientPreparation content(MediaType contentType, Publisher content) { delegate.content(contentType, content); diff --git a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java index 74a88494ae27..852131f6a5d9 100644 --- a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java @@ -205,6 +205,12 @@ public TransformingRequestPreparation content(MediaType contentType, return this; } + @Override + public TransformingRequestPreparation content(Publisher content) { + delegate.content(content); + return this; + } + @Override public TransformingRequestPreparation content(MediaType contentType, Publisher content) { diff --git a/core/src/main/java/com/linecorp/armeria/client/WebClientRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/WebClientRequestPreparation.java index de7a06f2e902..15cf47cfae65 100644 --- a/core/src/main/java/com/linecorp/armeria/client/WebClientRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/WebClientRequestPreparation.java @@ -491,6 +491,11 @@ public WebClientRequestPreparation content(MediaType contentType, HttpData conte return (WebClientRequestPreparation) super.content(contentType, content); } + @Override + public WebClientRequestPreparation content(Publisher publisher) { + return (WebClientRequestPreparation) super.content(publisher); + } + @Override public WebClientRequestPreparation content(MediaType contentType, Publisher publisher) { return (WebClientRequestPreparation) super.content(contentType, publisher); diff --git a/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ClientChannelHandler.java b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ClientChannelHandler.java new file mode 100644 index 000000000000..3158fcd5dd3a --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ClientChannelHandler.java @@ -0,0 +1,263 @@ +/* + * Copyright 2023 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.client; + +import static com.linecorp.armeria.client.AbstractHttpResponseDecoder.contentTooLargeException; +import static io.netty.handler.codec.http.LastHttpContent.EMPTY_LAST_CONTENT; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.math.LongMath; + +import com.linecorp.armeria.common.ClosedSessionException; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.ProtocolViolationException; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; +import com.linecorp.armeria.internal.client.HttpSession; +import com.linecorp.armeria.internal.common.ArmeriaHttpUtil; +import com.linecorp.armeria.internal.common.InboundTrafficController; +import com.linecorp.armeria.internal.common.KeepAliveHandler; +import com.linecorp.armeria.internal.common.NoopKeepAliveHandler; +import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.ChannelPromise; +import io.netty.channel.EventLoop; +import io.netty.handler.codec.DecoderResult; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpObject; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpUtil; +import io.netty.util.ReferenceCountUtil; + +final class WebSocketHttp1ClientChannelHandler extends ChannelDuplexHandler implements HttpResponseDecoder { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketHttp1ClientChannelHandler.class); + + private enum State { + NEEDS_HANDSHAKE_RESPONSE, + NEEDS_HANDSHAKE_RESPONSE_END, + UPGRADE_COMPLETE + } + + private final Channel channel; + private final InboundTrafficController inboundTrafficController; + @Nullable + private HttpResponseWrapper res; + private final KeepAliveHandler keepAliveHandler; + + private State state = State.NEEDS_HANDSHAKE_RESPONSE; + @Nullable + private HttpSession httpSession; + + WebSocketHttp1ClientChannelHandler(Channel channel) { + this.channel = channel; + inboundTrafficController = InboundTrafficController.ofHttp1(channel); + + // Use NoopKeepAliveHandler because + // - hasRequestsInProgress is always true for WebSocket + // - a Ping frame is not sent by the keepAliveHandler but by the upper layer. + // TODO(minwoox): Provide a dedicated KeepAliveHandler to the upper layer (e.g. WebSocketClient) + // that handles Ping frames for WebSocket. + keepAliveHandler = new NoopKeepAliveHandler(); + } + + @Override + public Channel channel() { + return channel; + } + + @Override + public InboundTrafficController inboundTrafficController() { + return inboundTrafficController; + } + + @Override + public HttpResponseWrapper addResponse(int id, DecodedHttpResponse decodedHttpResponse, + ClientRequestContext ctx, EventLoop eventLoop) { + assert res == null; + res = new WebSocketHttp1ResponseWrapper(decodedHttpResponse, eventLoop, ctx, + ctx.responseTimeoutMillis(), ctx.maxResponseLength()); + return res; + } + + @Nullable + @Override + public HttpResponseWrapper getResponse(int unused) { + return res; + } + + @Nullable + @Override + public HttpResponseWrapper removeResponse(int unused) { + return res; + } + + @Override + public boolean hasUnfinishedResponses() { + return res != null; + } + + @Override + public boolean reserveUnfinishedResponse(int unused) { + return true; + } + + @Override + public void decrementUnfinishedResponses() {} + + @Override + public void failUnfinishedResponses(Throwable cause) { + if (res != null) { + res.close(cause); + } + } + + @Override + public HttpSession session() { + if (httpSession != null) { + return httpSession; + } + return httpSession = HttpSession.get(channel); + } + + @Override + public KeepAliveHandler keepAliveHandler() { + return keepAliveHandler; + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + keepAliveHandler.destroy(); + if (res != null) { + res.close(ClosedSessionException.get()); + } + ctx.fireChannelInactive(); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + try { + switch (state) { + case NEEDS_HANDSHAKE_RESPONSE: + if (!(msg instanceof HttpObject)) { + ctx.fireChannelRead(msg); + return; + } + if (!(msg instanceof HttpResponse)) { + failWithUnexpectedMessageType(ctx, msg, HttpResponse.class); + return; + } + + final HttpResponse nettyRes = (HttpResponse) msg; + final DecoderResult decoderResult = nettyRes.decoderResult(); + if (!decoderResult.isSuccess()) { + fail(ctx, new ProtocolViolationException(decoderResult.cause())); + return; + } + + if (!HttpUtil.isKeepAlive(nettyRes)) { + session().deactivate(); + } + + if (res == null && ArmeriaHttpUtil.isRequestTimeoutResponse(nettyRes)) { + ctx.close(); + return; + } + + res.startResponse(); + final ResponseHeaders responseHeaders = ArmeriaHttpUtil.toArmeria(nettyRes); + if (responseHeaders.status() == HttpStatus.SWITCHING_PROTOCOLS) { + final ChannelPipeline pipeline = ctx.pipeline(); + pipeline.remove(HttpClientCodec.class); + state = State.NEEDS_HANDSHAKE_RESPONSE_END; + } + if (!res.tryWriteResponseHeaders(responseHeaders)) { + fail(ctx, ClosedSessionException.get()); + } + break; + case NEEDS_HANDSHAKE_RESPONSE_END: + // HttpClientCodec produces this after creating the headers. We can just ignore it. + if (msg != EMPTY_LAST_CONTENT) { + failWithUnexpectedMessageType(ctx, msg, EMPTY_LAST_CONTENT.getClass()); + return; + } + state = State.UPGRADE_COMPLETE; + break; + case UPGRADE_COMPLETE: + assert msg instanceof ByteBuf; + final ByteBuf data = (ByteBuf) msg; + final int dataLength = data.readableBytes(); + if (dataLength > 0) { + final long maxContentLength = res.maxContentLength(); + final long writtenBytes = res.writtenBytes(); + if (maxContentLength > 0 && writtenBytes > maxContentLength - dataLength) { + final long transferred = LongMath.saturatedAdd(writtenBytes, dataLength); + res.close(contentTooLargeException(res, transferred)); + ctx.close(); + return; + } + if (!res.tryWriteData(HttpData.wrap(data.retain()))) { + ctx.close(); + } + } + break; + } + } finally { + ReferenceCountUtil.release(msg); + } + } + + private void failWithUnexpectedMessageType(ChannelHandlerContext ctx, Object msg, Class expected) { + final String message; + try (TemporaryThreadLocals tempThreadLocals = TemporaryThreadLocals.acquire()) { + final StringBuilder buf = tempThreadLocals.stringBuilder(); + buf.append("unexpected message type: " + msg.getClass().getName() + + " (expected: " + expected.getName() + ", channel: " + ctx.channel() + ')'); + message = buf.toString(); + } + fail(ctx, new ProtocolViolationException(message)); + } + + private void fail(ChannelHandlerContext ctx, Throwable cause) { + if (res != null) { + res.close(cause); + } else { + logger.warn("Unexpected exception:", cause); + } + + ctx.close(); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (msg instanceof HttpContent) { + ctx.write(((HttpContent) msg).content(), promise); + return; + } + ctx.write(msg, promise); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1RequestSubscriber.java b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1RequestSubscriber.java new file mode 100644 index 000000000000..83b733a60ac0 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1RequestSubscriber.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 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.client; + +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpObject; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; +import com.linecorp.armeria.unsafe.PooledObjects; + +import io.netty.channel.Channel; + +final class WebSocketHttp1RequestSubscriber extends AbstractHttpRequestSubscriber { + + WebSocketHttp1RequestSubscriber(Channel ch, ClientHttpObjectEncoder encoder, + HttpResponseDecoder responseDecoder, + HttpRequest request, DecodedHttpResponse originalRes, + ClientRequestContext ctx, long timeoutMillis) { + super(ch, encoder, responseDecoder, request, originalRes, ctx, timeoutMillis, false, false); + } + + @Override + public void onNext(HttpObject o) { + if (!(o instanceof HttpData)) { + failAndReset(new IllegalArgumentException( + "published an HttpObject that's not HttpData: " + o)); + PooledObjects.close(o); + return; + } + + switch (state()) { + case NEEDS_DATA: { + writeData((HttpData) o); + channel().flush(); + break; + } + case DONE: + // Cancel the subscription if any message comes here after the state has been changed to DONE. + cancel(); + PooledObjects.close(o); + break; + } + } +} + diff --git a/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ResponseWrapper.java b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ResponseWrapper.java new file mode 100644 index 000000000000..735e640ae8c2 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp1ResponseWrapper.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 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.client; + +import com.linecorp.armeria.common.ClosedSessionException; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; +import com.linecorp.armeria.internal.client.websocket.WebSocketClientUtil; + +import io.netty.channel.EventLoop; + +final class WebSocketHttp1ResponseWrapper extends HttpResponseWrapper { + + WebSocketHttp1ResponseWrapper(DecodedHttpResponse delegate, + EventLoop eventLoop, ClientRequestContext ctx, + long responseTimeoutMillis, long maxContentLength) { + super(delegate, eventLoop, ctx, responseTimeoutMillis, maxContentLength); + WebSocketClientUtil.setClosingResponseTask(ctx, cause -> { + super.close(cause, false); + }); + } + + @Override + void close(@Nullable Throwable cause, boolean cancel) { + if (cancel || !(cause instanceof ClosedSessionException)) { + super.close(cause, cancel); + return; + } + // Close the delegate directly so that we can give a chance to WebSocketFrameDecoder to close the + // response normally if it receives a close frame before the ClosedSessionException is raised. + delegate().close(cause); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp2RequestSubscriber.java b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp2RequestSubscriber.java new file mode 100644 index 000000000000..21798b4825c3 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/WebSocketHttp2RequestSubscriber.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 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.client; + +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.internal.client.DecodedHttpResponse; + +import io.netty.channel.Channel; +import io.netty.handler.codec.http.HttpHeaderValues; + +final class WebSocketHttp2RequestSubscriber extends HttpRequestSubscriber { + + WebSocketHttp2RequestSubscriber(Channel ch, ClientHttpObjectEncoder encoder, + HttpResponseDecoder responseDecoder, + HttpRequest request, DecodedHttpResponse originalRes, + ClientRequestContext ctx, long timeoutMillis) { + super(ch, encoder, responseDecoder, request, originalRes, ctx, timeoutMillis); + } + + @Override + RequestHeaders mapHeaders(RequestHeaders headers) { + if (headers.method() == HttpMethod.CONNECT) { + return headers; + } + return headers.toBuilder() + .method(HttpMethod.CONNECT) + .removeAndThen(HttpHeaderNames.CONNECTION) + .removeAndThen(HttpHeaderNames.UPGRADE) + .removeAndThen(HttpHeaderNames.SEC_WEBSOCKET_KEY) + .set(HttpHeaderNames.PROTOCOL, HttpHeaderValues.WEBSOCKET.toString()) + .build(); + } +} + diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/DefaultWebSocketClient.java b/core/src/main/java/com/linecorp/armeria/client/websocket/DefaultWebSocketClient.java new file mode 100644 index 000000000000..e3f2eed09e6c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/DefaultWebSocketClient.java @@ -0,0 +1,261 @@ +/* + * Copyright 2023 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.client.websocket; + +import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; +import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.generateSecWebSocketAccept; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; + +import com.linecorp.armeria.client.ClientOptions; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.ClientRequestContextCaptor; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.SplitHttpResponse; +import com.linecorp.armeria.common.logging.RequestLogProperty; +import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.internal.common.DefaultSplitHttpResponse; +import com.linecorp.armeria.internal.common.websocket.WebSocketFrameEncoder; +import com.linecorp.armeria.internal.common.websocket.WebSocketWrapper; + +import io.netty.handler.codec.http.HttpHeaderValues; + +final class DefaultWebSocketClient implements WebSocketClient { + + static final WebSocketClient DEFAULT = WebSocketClient.of(UNDEFINED_URI); + + private static final WebSocketFrameEncoder encoder = WebSocketFrameEncoder.of(true); + + private final WebClient webClient; + private final int maxFramePayloadLength; + private final boolean allowMaskMismatch; + private final List subprotocols; + private final String joinedSubprotocols; + + DefaultWebSocketClient(WebClient webClient, int maxFramePayloadLength, boolean allowMaskMismatch, + List subprotocols) { + this.webClient = webClient; + this.maxFramePayloadLength = maxFramePayloadLength; + this.allowMaskMismatch = allowMaskMismatch; + this.subprotocols = subprotocols; + if (!subprotocols.isEmpty()) { + joinedSubprotocols = Joiner.on(", ").join(subprotocols); + } else { + joinedSubprotocols = ""; + } + } + + @Override + public CompletableFuture connect(String path) { + requireNonNull(path, "path"); + final RequestHeaders requestHeaders = webSocketHeaders(path); + + final CompletableFuture> outboundFuture = new CompletableFuture<>(); + final HttpRequest request = HttpRequest.of(requestHeaders, StreamMessage.of(outboundFuture)); + final HttpResponse response; + final ClientRequestContext ctx; + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + response = webClient.execute(request); + ctx = captor.get(); + } + final SplitHttpResponse split = + new DefaultSplitHttpResponse(response, ctx.eventLoop(), responseHeaders -> { + final SessionProtocol actualSessionProtocol = actualSessionProtocol(ctx); + if (actualSessionProtocol.isExplicitHttp1()) { + return true; + } + assert actualSessionProtocol.isExplicitHttp2(); + return !responseHeaders.status().isInformational(); + }); + + final CompletableFuture result = new CompletableFuture<>(); + split.headers().handle((responseHeaders, cause) -> { + if (cause != null) { + fail(outboundFuture, response, result, cause); + return null; + } + if (!validateResponseHeaders(ctx, requestHeaders, responseHeaders, outboundFuture, + response, result)) { + return null; + } + + final WebSocketClientFrameDecoder decoder = + new WebSocketClientFrameDecoder(ctx, maxFramePayloadLength, allowMaskMismatch); + final WebSocketWrapper inbound = new WebSocketWrapper(split.body().decode(decoder, ctx.alloc())); + + result.complete(new WebSocketSession(ctx, responseHeaders, inbound, outboundFuture, encoder)); + return null; + }); + return result; + } + + private RequestHeaders webSocketHeaders(String path) { + final RequestHeadersBuilder builder; + if (scheme().sessionProtocol().isExplicitHttp2()) { + builder = RequestHeaders.builder(HttpMethod.CONNECT, path) + .set(HttpHeaderNames.PROTOCOL, HttpHeaderValues.WEBSOCKET.toString()); + } else { + builder = RequestHeaders.builder(HttpMethod.GET, path) + .set(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE.toString()) + .set(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET.toString()); + final String secWebSocketKey = generateSecWebSocketKey(); + builder.set(HttpHeaderNames.SEC_WEBSOCKET_KEY, secWebSocketKey); + } + + builder.set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); + if (!subprotocols.isEmpty()) { + builder.set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, joinedSubprotocols); + } + + return builder.build(); + } + + private boolean validateResponseHeaders( + ClientRequestContext ctx, RequestHeaders requestHeaders, ResponseHeaders responseHeaders, + CompletableFuture> outboundFuture, HttpResponse response, + CompletableFuture result) { + if (actualSessionProtocol(ctx).isExplicitHttp2()) { + final HttpStatus status = responseHeaders.status(); + if (status != HttpStatus.OK) { + fail(outboundFuture, response, result, new WebSocketClientHandshakeException( + "invalid status: " + status + " (expected: " + HttpStatus.OK + ')', + responseHeaders)); + return false; + } + } else { + if (!isHttp1WebSocketResponse(responseHeaders)) { + fail(outboundFuture, response, result, new WebSocketClientHandshakeException( + "invalid response headers: " + responseHeaders, responseHeaders)); + return false; + } + final String secWebSocketKey = requestHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_KEY); + assert secWebSocketKey != null; + final String secWebSocketAccept = responseHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_ACCEPT); + if (secWebSocketAccept == null) { + fail(outboundFuture, response, result, new WebSocketClientHandshakeException( + HttpHeaderNames.SEC_WEBSOCKET_ACCEPT + " is null.", responseHeaders)); + return false; + } + if (!secWebSocketAccept.equals(generateSecWebSocketAccept(secWebSocketKey))) { + fail(outboundFuture, response, result, new WebSocketClientHandshakeException( + "invalid " + HttpHeaderNames.SEC_WEBSOCKET_ACCEPT + " header: " + + secWebSocketAccept, responseHeaders)); + return false; + } + } + + if (!subprotocols.isEmpty()) { + final String responseSubprotocol = responseHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL); + // null is allowed if the server does not agree to any of the client's requested + // subprotocols. + // https://datatracker.ietf.org/doc/html/rfc6455#section-4.2.2 + + if (responseSubprotocol != null && !subprotocols.contains(responseSubprotocol)) { + fail(outboundFuture, response, result, new WebSocketClientHandshakeException( + "invalid " + HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL + " header: " + + responseSubprotocol + " (expected: one of " + subprotocols + ')', + responseHeaders)); + return false; + } + } + return true; + } + + private static SessionProtocol actualSessionProtocol(ClientRequestContext ctx) { + // This is always called after a ResponseHeaders is received which means + // RequestLogProperty.SESSION is already set. + return ctx.log().ensureAvailable(RequestLogProperty.SESSION).sessionProtocol(); + } + + private static void fail(CompletableFuture> outboundFuture, HttpResponse response, + CompletableFuture result, Throwable cause) { + outboundFuture.completeExceptionally(cause); + response.abort(cause); + result.completeExceptionally(cause); + } + + @VisibleForTesting + static String generateSecWebSocketKey() { + final byte[] bytes = new byte[16]; + ThreadLocalRandom.current().nextBytes(bytes); + return Base64.getEncoder().encodeToString(bytes); + } + + private static boolean isHttp1WebSocketResponse(ResponseHeaders responseHeaders) { + return responseHeaders.status() == HttpStatus.SWITCHING_PROTOCOLS && + HttpHeaderValues.WEBSOCKET.contentEqualsIgnoreCase( + responseHeaders.get(HttpHeaderNames.UPGRADE)) && + HttpHeaderValues.UPGRADE.contentEqualsIgnoreCase( + responseHeaders.get(HttpHeaderNames.CONNECTION)); + } + + @Override + public Scheme scheme() { + return webClient.scheme(); + } + + @Override + public EndpointGroup endpointGroup() { + return webClient.endpointGroup(); + } + + @Override + public String absolutePathRef() { + return webClient.absolutePathRef(); + } + + @Override + public URI uri() { + return webClient.uri(); + } + + @Override + public Class clientType() { + return webClient.clientType(); + } + + @Override + public ClientOptions options() { + return webClient.options(); + } + + @Override + public WebClient unwrap() { + return webClient; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClient.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClient.java new file mode 100644 index 000000000000..cc11f86ded96 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClient.java @@ -0,0 +1,222 @@ +/* + * Copyright 2023 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.client.websocket; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.client.ClientBuilderParams; +import com.linecorp.armeria.client.ClientOptions; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.Unwrappable; + +/** + * A WebSocket client. + * This client has a few different default values for {@link ClientOptions} from {@link WebClient} + * because of the nature of WebSocket. See {@link WebSocketClientBuilder} for more information. + * + *

WebSocket client example: + *

{@code
+ * WebSocketClient client = WebSocketClient.of("ws://www.example.com");
+ * client.connect("/chat").thenAccept(webSocketSession -> {
+ *     // Write messages to the server.
+ *     WebSocketWriter writer = WebSocket.streaming();
+ *     webSocketSessions.setOutbound(writer);
+ *     outbound.write("Hello ");
+ *     // You can also use backpressure using whenConsumed().
+ *     outbound.whenConsumed().thenRun(() -> outbound.write("world!"));
+ *
+ *     // Read messages from the server.
+ *     Subscriber myWebSocketSubscriber = new Subscriber() {
+ *         @Override
+ *         public void onSubscribe(Subscription s) {
+ *             s.request(Long.MAX_VALUE);
+ *         }
+ *         @Override
+ *         public void onNext(WebSocketFrame webSocketFrame) {
+ *             if (webSocketFrame.type() == WebSocketFrameType.TEXT) {
+ *                 System.out.println(webSocketFrame.text());
+ *             }
+ *             ...
+ *         }
+ *         ...
+ *     };
+ *     webSocketSessions.inbound().subscribe(myWebSocketSubscriber);
+ * });
+ * }
+ * + * @see The WebSocket Protocol + */ +@UnstableApi +public interface WebSocketClient extends ClientBuilderParams, Unwrappable { + + /** + * Returns a {@link WebSocketClient} without a base URI. + */ + static WebSocketClient of() { + return DefaultWebSocketClient.DEFAULT; + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@code uri} using the + * default options. + */ + static WebSocketClient of(String uri) { + return builder(uri).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link URI} using the + * default options. + */ + static WebSocketClient of(URI uri) { + return builder(uri).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@code scheme} using the default {@link ClientOptions}. + */ + static WebSocketClient of(String scheme, EndpointGroup endpointGroup) { + return builder(scheme, endpointGroup).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@link Scheme} using the default {@link ClientOptions}. + */ + static WebSocketClient of(Scheme scheme, EndpointGroup endpointGroup) { + return builder(scheme, endpointGroup).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@link SessionProtocol} using the default {@link ClientOptions}. + */ + static WebSocketClient of(SessionProtocol protocol, EndpointGroup endpointGroup) { + return builder(protocol, endpointGroup).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@code scheme} and {@code path} using the default {@link ClientOptions}. + */ + static WebSocketClient of(String scheme, EndpointGroup endpointGroup, String path) { + return builder(scheme, endpointGroup, path).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@link Scheme} and {@code path} using the default {@link ClientOptions}. + */ + static WebSocketClient of(Scheme scheme, EndpointGroup endpointGroup, String path) { + return builder(scheme, endpointGroup, path).build(); + } + + /** + * Returns a new {@link WebSocketClient} that connects to the specified {@link EndpointGroup} with + * the specified {@code scheme} and {@code path} using the default {@link ClientOptions}. + */ + static WebSocketClient of(SessionProtocol protocol, EndpointGroup endpointGroup, String path) { + return builder(protocol, endpointGroup, path).build(); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified base {@code uri}. + */ + static WebSocketClientBuilder builder(String uri) { + return builder(URI.create(requireNonNull(uri, "uri"))); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified base {@link URI}. + */ + static WebSocketClientBuilder builder(URI uri) { + return new WebSocketClientBuilder(requireNonNull(uri, "uri")); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@code scheme} + * and the {@link EndpointGroup}. + */ + static WebSocketClientBuilder builder(String scheme, EndpointGroup endpointGroup) { + requireNonNull(scheme, "scheme"); + return builder(Scheme.parse(scheme), endpointGroup); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@link Scheme} + * and the {@link EndpointGroup}. + */ + static WebSocketClientBuilder builder(Scheme scheme, EndpointGroup endpointGroup) { + requireNonNull(scheme, "scheme"); + requireNonNull(endpointGroup, "endpointGroup"); + return new WebSocketClientBuilder(scheme, endpointGroup, null); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@link SessionProtocol} + * and the {@link EndpointGroup}. + */ + static WebSocketClientBuilder builder(SessionProtocol protocol, EndpointGroup endpointGroup) { + requireNonNull(protocol, "protocol"); + return builder(Scheme.of(SerializationFormat.WS, protocol), endpointGroup); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@code scheme}, + * the {@link EndpointGroup}, and the {@code path}. + */ + static WebSocketClientBuilder builder(String scheme, EndpointGroup endpointGroup, String path) { + requireNonNull(scheme, "scheme"); + return builder(Scheme.parse(scheme), endpointGroup, path); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@link Scheme}, + * the {@link EndpointGroup}, and the {@code path}. + */ + static WebSocketClientBuilder builder(Scheme scheme, EndpointGroup endpointGroup, String path) { + requireNonNull(scheme, "scheme"); + requireNonNull(endpointGroup, "endpointGroup"); + return new WebSocketClientBuilder(scheme, endpointGroup, path); + } + + /** + * Returns a new {@link WebSocketClientBuilder} created with the specified {@link SessionProtocol}, + * the {@link EndpointGroup}, and the {@code path}. + */ + static WebSocketClientBuilder builder(SessionProtocol protocol, EndpointGroup endpointGroup, String path) { + requireNonNull(protocol, "protocol"); + return builder(Scheme.of(SerializationFormat.WS, protocol), endpointGroup, path); + } + + /** + * Connects to the specified {@code path}. + */ + CompletableFuture connect(String path); + + @Override + WebClient unwrap(); +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java new file mode 100644 index 000000000000..3a32e48c58c0 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java @@ -0,0 +1,374 @@ +/* + * Copyright 2023 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.client.websocket; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.linecorp.armeria.common.SessionProtocol.httpAndHttpsValues; +import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.DEFAULT_MAX_REQUEST_RESPONSE_LENGTH; +import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS; +import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map.Entry; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import com.linecorp.armeria.client.AbstractWebClientBuilder; +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientOption; +import com.linecorp.armeria.client.ClientOptionValue; +import com.linecorp.armeria.client.ClientOptions; +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.DecoratingHttpClientFunction; +import com.linecorp.armeria.client.DecoratingRpcClientFunction; +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.RpcClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.client.redirect.RedirectConfig; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestId; +import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.auth.AuthToken; +import com.linecorp.armeria.common.auth.BasicToken; +import com.linecorp.armeria.common.auth.OAuth1aToken; +import com.linecorp.armeria.common.auth.OAuth2Token; + +/** + * Builds a {@link WebSocketClient}. + * This client has the different default options from {@link WebClient}. Here are the differences: + *
    + *
  • {@link ClientOptions#RESPONSE_TIMEOUT_MILLIS} is {@code 0}.
  • + *
  • {@link ClientOptions#MAX_RESPONSE_LENGTH} is {@code 0}.
  • + *
  • {@link ClientOptions#REQUEST_AUTO_ABORT_DELAY_MILLIS} is {@code 5000}.
  • + *
  • {@link ClientOptions#AUTO_FILL_ORIGIN_HEADER} is {@code true}.
  • + *
+ */ +@UnstableApi +public final class WebSocketClientBuilder extends AbstractWebClientBuilder { + + static final int DEFAULT_MAX_FRAME_PAYLOAD_LENGTH = 65535; // 64 * 1024 -1 + + private int maxFramePayloadLength = DEFAULT_MAX_FRAME_PAYLOAD_LENGTH; + private boolean allowMaskMismatch; + private List subprotocols = ImmutableList.of(); + + WebSocketClientBuilder(URI uri) { + super(validateUri(requireNonNull(uri, "uri")), null, null, null); + setWebSocketDefaultOption(); + } + + WebSocketClientBuilder(Scheme scheme, EndpointGroup endpointGroup, @Nullable String path) { + super(null, validateScheme(requireNonNull(scheme, "scheme")), endpointGroup, path); + setWebSocketDefaultOption(); + } + + private static URI validateUri(URI uri) { + if (Clients.isUndefinedUri(uri)) { + return uri; + } + final String givenScheme = requireNonNull(uri, "uri").getScheme(); + final Scheme scheme = validateScheme(givenScheme); + if (scheme.uriText().equals(givenScheme)) { + // No need to replace the user-specified scheme because it's already in its normalized form. + return uri; + } + // Replace the user-specified scheme with the normalized one. + // e.g. http://foo.com/ -> ws+http://foo.com/ + return URI.create(scheme.uriText() + uri.toString().substring(givenScheme.length())); + } + + private static Scheme validateScheme(String scheme) { + final Scheme parsedScheme = Scheme.tryParse(scheme); + if (parsedScheme != null) { + return validateScheme(parsedScheme); + } + + throw invalidSchemeException(scheme); + } + + private static Scheme validateScheme(Scheme scheme) { + final SerializationFormat serializationFormat = scheme.serializationFormat(); + if ((serializationFormat == SerializationFormat.WS || + serializationFormat == SerializationFormat.NONE) && + httpAndHttpsValues().contains(scheme.sessionProtocol())) { + if (serializationFormat == SerializationFormat.WS) { + return scheme; + } + return Scheme.of(SerializationFormat.WS, scheme.sessionProtocol()); + } + throw invalidSchemeException(scheme.toString()); + } + + private static IllegalArgumentException invalidSchemeException(String scheme) { + return new IllegalArgumentException( + String.format("scheme: %s (expected serialization format: %s or %s," + + " expected session protocol: one of %s)", scheme, SerializationFormat.WS, + SerializationFormat.NONE, httpAndHttpsValues())); + } + + private void setWebSocketDefaultOption() { + responseTimeoutMillis(DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS); + maxResponseLength(DEFAULT_MAX_REQUEST_RESPONSE_LENGTH); + requestAutoAbortDelayMillis(DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS); + autoFillOriginHeader(true); + contextCustomizer(ctx -> ctx.logBuilder().serializationFormat(SerializationFormat.WS)); + } + + /** + * Sets the maximum length of a frame's payload. + * {@value DEFAULT_MAX_FRAME_PAYLOAD_LENGTH} is used by default. + */ + public WebSocketClientBuilder maxFramePayloadLength(int maxFramePayloadLength) { + checkArgument(maxFramePayloadLength > 0, + "maxFramePayloadLength: %s (expected: > 0)", maxFramePayloadLength); + this.maxFramePayloadLength = maxFramePayloadLength; + return this; + } + + /** + * Sets whether the decoder allows to loosen the masking requirement on received frames. + * It's not allowed by default. + */ + public WebSocketClientBuilder allowMaskMismatch(boolean allowMaskMismatch) { + this.allowMaskMismatch = allowMaskMismatch; + return this; + } + + /** + * Sets the subprotocols to use with the WebSocket Protocol. + * + * @see + * Subprotocols Using the WebSocket Protocol + */ + public WebSocketClientBuilder subprotocols(String... subprotocols) { + return subprotocols(ImmutableSet.copyOf(requireNonNull(subprotocols, "subprotocols"))); + } + + /** + * Sets the subprotocols to use with the WebSocket Protocol. + * + * @see + * Subprotocols Using the WebSocket Protocol + */ + public WebSocketClientBuilder subprotocols(Iterable subprotocols) { + this.subprotocols = ImmutableList.copyOf(requireNonNull(subprotocols, "subprotocols")); + return this; + } + + /** + * Sets whether to add an {@link HttpHeaderNames#ORIGIN} header automatically when sending + * an {@link HttpRequest} when the {@link HttpRequest#headers()} does not have it. + * It's {@code true} by default. + */ + public WebSocketClientBuilder autoFillOriginHeader(boolean autoFillOriginHeader) { + //TODO(minwoox): Promote this to AbstractClientOptionsBuilder. + option(ClientOptions.AUTO_FILL_ORIGIN_HEADER, autoFillOriginHeader); + return this; + } + + /** + * Returns a newly-created {@link WebSocketClient} based on the properties of this builder. + */ + public WebSocketClient build() { + final WebClient webClient = buildWebClient(); + return new DefaultWebSocketClient(webClient, maxFramePayloadLength, allowMaskMismatch, subprotocols); + } + + // Override the return type of the chaining methods in the superclass. + + @Deprecated + @Override + public WebSocketClientBuilder rpcDecorator(Function decorator) { + return (WebSocketClientBuilder) super.rpcDecorator(decorator); + } + + @Deprecated + @Override + public WebSocketClientBuilder rpcDecorator(DecoratingRpcClientFunction decorator) { + return (WebSocketClientBuilder) super.rpcDecorator(decorator); + } + + @Override + public WebSocketClientBuilder options(ClientOptions options) { + return (WebSocketClientBuilder) super.options(options); + } + + @Override + public WebSocketClientBuilder options(ClientOptionValue... options) { + return (WebSocketClientBuilder) super.options(options); + } + + @Override + public WebSocketClientBuilder options(Iterable> options) { + return (WebSocketClientBuilder) super.options(options); + } + + @Override + public WebSocketClientBuilder option(ClientOption option, T value) { + return (WebSocketClientBuilder) super.option(option, value); + } + + @Override + public WebSocketClientBuilder option(ClientOptionValue optionValue) { + return (WebSocketClientBuilder) super.option(optionValue); + } + + @Override + public WebSocketClientBuilder factory(ClientFactory factory) { + return (WebSocketClientBuilder) super.factory(factory); + } + + @Override + public WebSocketClientBuilder writeTimeout(Duration writeTimeout) { + return (WebSocketClientBuilder) super.writeTimeout(writeTimeout); + } + + @Override + public WebSocketClientBuilder writeTimeoutMillis(long writeTimeoutMillis) { + return (WebSocketClientBuilder) super.writeTimeoutMillis(writeTimeoutMillis); + } + + @Override + public WebSocketClientBuilder responseTimeout(Duration responseTimeout) { + return (WebSocketClientBuilder) super.responseTimeout(responseTimeout); + } + + @Override + public WebSocketClientBuilder responseTimeoutMillis(long responseTimeoutMillis) { + return (WebSocketClientBuilder) super.responseTimeoutMillis(responseTimeoutMillis); + } + + @Override + public WebSocketClientBuilder maxResponseLength(long maxResponseLength) { + return (WebSocketClientBuilder) super.maxResponseLength(maxResponseLength); + } + + @Override + public WebSocketClientBuilder requestAutoAbortDelay(Duration delay) { + return (WebSocketClientBuilder) super.requestAutoAbortDelay(delay); + } + + @Override + public WebSocketClientBuilder requestAutoAbortDelayMillis(long delayMillis) { + return (WebSocketClientBuilder) super.requestAutoAbortDelayMillis(delayMillis); + } + + @Override + public WebSocketClientBuilder requestIdGenerator(Supplier requestIdGenerator) { + return (WebSocketClientBuilder) super.requestIdGenerator(requestIdGenerator); + } + + @Override + public WebSocketClientBuilder successFunction(SuccessFunction successFunction) { + return (WebSocketClientBuilder) super.successFunction(successFunction); + } + + @Override + public WebSocketClientBuilder endpointRemapper( + Function endpointRemapper) { + return (WebSocketClientBuilder) super.endpointRemapper(endpointRemapper); + } + + @Override + public WebSocketClientBuilder decorator( + Function decorator) { + return (WebSocketClientBuilder) super.decorator(decorator); + } + + @Override + public WebSocketClientBuilder decorator(DecoratingHttpClientFunction decorator) { + return (WebSocketClientBuilder) super.decorator(decorator); + } + + @Override + public WebSocketClientBuilder clearDecorators() { + return (WebSocketClientBuilder) super.clearDecorators(); + } + + @Override + public WebSocketClientBuilder addHeader(CharSequence name, Object value) { + return (WebSocketClientBuilder) super.addHeader(name, value); + } + + @Override + public WebSocketClientBuilder addHeaders( + Iterable> headers) { + return (WebSocketClientBuilder) super.addHeaders(headers); + } + + @Override + public WebSocketClientBuilder setHeader(CharSequence name, Object value) { + return (WebSocketClientBuilder) super.setHeader(name, value); + } + + @Override + public WebSocketClientBuilder setHeaders( + Iterable> headers) { + return (WebSocketClientBuilder) super.setHeaders(headers); + } + + @Override + public WebSocketClientBuilder auth(BasicToken token) { + return (WebSocketClientBuilder) super.auth(token); + } + + @Override + public WebSocketClientBuilder auth(OAuth1aToken token) { + return (WebSocketClientBuilder) super.auth(token); + } + + @Override + public WebSocketClientBuilder auth(OAuth2Token token) { + return (WebSocketClientBuilder) super.auth(token); + } + + @Override + public WebSocketClientBuilder auth(AuthToken token) { + return (WebSocketClientBuilder) super.auth(token); + } + + @Override + public WebSocketClientBuilder followRedirects() { + return (WebSocketClientBuilder) super.followRedirects(); + } + + @Override + public WebSocketClientBuilder followRedirects(RedirectConfig redirectConfig) { + return (WebSocketClientBuilder) super.followRedirects(redirectConfig); + } + + @Override + public WebSocketClientBuilder contextCustomizer( + Consumer contextCustomizer) { + return (WebSocketClientBuilder) super.contextCustomizer(contextCustomizer); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientFrameDecoder.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientFrameDecoder.java new file mode 100644 index 000000000000..7390953ac5f1 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientFrameDecoder.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.client.websocket; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.internal.client.websocket.WebSocketClientUtil; +import com.linecorp.armeria.internal.common.websocket.WebSocketFrameDecoder; + +final class WebSocketClientFrameDecoder extends WebSocketFrameDecoder { + + private final ClientRequestContext ctx; + + WebSocketClientFrameDecoder(ClientRequestContext ctx, int maxFramePayloadLength, + boolean allowMaskMismatch) { + super(ctx, maxFramePayloadLength, allowMaskMismatch); + this.ctx = ctx; + } + + @Override + protected boolean expectMaskedFrames() { + return false; + } + + @Override + protected void onCloseFrameRead() { + // Need to close the response when HTTP/1.1 is used. + WebSocketClientUtil.closingResponse(ctx, null); + } + + @Override + protected void onProcessOnError(Throwable cause) { + // Need to close the response when HTTP/1.1 is used. + WebSocketClientUtil.closingResponse(ctx, cause); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeException.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeException.java new file mode 100644 index 000000000000..cba714a64260 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.client.websocket; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.client.InvalidResponseException; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * An {@link InvalidResponseException} raised when a client received a response with invalid headers. + */ +@UnstableApi +public final class WebSocketClientHandshakeException extends InvalidResponseException { + + private static final long serialVersionUID = -8521952766254225005L; + + private final ResponseHeaders headers; + + /** + * Creates a new instance. + */ + public WebSocketClientHandshakeException(String message, ResponseHeaders headers) { + super(message); + this.headers = requireNonNull(headers, "headers"); + } + + /** + * Returns the {@link ResponseHeaders} of the handshake response. + */ + public ResponseHeaders headers() { + return headers; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketSession.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketSession.java new file mode 100644 index 000000000000..db2e7da5a5a5 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketSession.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023 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.client.websocket; + +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; + +import org.reactivestreams.Publisher; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; +import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.websocket.WebSocket; +import com.linecorp.armeria.common.websocket.WebSocketFrame; +import com.linecorp.armeria.common.websocket.WebSocketWriter; +import com.linecorp.armeria.internal.common.websocket.WebSocketFrameEncoder; + +/** + * A WebSocket session that is created after {@link WebSocketClient#connect(String)} succeeds. + * You can start sending {@link WebSocketFrame}s via {@link #setOutbound(Publisher)}. You can also subscribe to + * {@link #inbound()} to receive {@link WebSocketFrame}s from the server. + */ +@UnstableApi +public final class WebSocketSession { + + private final ClientRequestContext ctx; + private final ResponseHeaders responseHeaders; + @Nullable + private final String subprotocol; + private final WebSocket inbound; + private final CompletableFuture> outboundFuture; + private final WebSocketFrameEncoder encoder; + + WebSocketSession(ClientRequestContext ctx, ResponseHeaders responseHeaders, WebSocket inbound, + CompletableFuture> outboundFuture, + WebSocketFrameEncoder encoder) { + this.ctx = ctx; + this.responseHeaders = responseHeaders; + subprotocol = responseHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL); + this.inbound = inbound; + this.outboundFuture = outboundFuture; + this.encoder = encoder; + } + + /** + * Returns the {@link ClientRequestContext}. + */ + public ClientRequestContext context() { + return ctx; + } + + /** + * Returns the {@link ResponseHeaders}. + */ + public ResponseHeaders responseHeaders() { + return responseHeaders; + } + + /** + * Returns the subprotocol negotiated between the client and the server. + */ + @Nullable + public String subprotocol() { + return subprotocol; + } + + /** + * Returns the {@link WebSocket} that is used to receive WebSocket frames from the server. + */ + public WebSocket inbound() { + return inbound; + } + + /** + * Returns the {@link WebSocketWriter} that is used to send WebSocket frames to the server. + * + * @throws IllegalStateException if this method or {@link #setOutbound(Publisher)} has been called already. + */ + public WebSocketWriter outbound() { + final WebSocketWriter writer = WebSocket.streaming(); + setOutbound(writer); + return writer; + } + + /** + * Sets the {@link WebSocket} that is used to send WebSocket frames to the server. + * + * @throws IllegalStateException if this method or {@link #outbound()} has been called already. + */ + public void setOutbound(Publisher outbound) { + requireNonNull(outbound, "outbound"); + if (outboundFuture.isDone()) { + if (outbound instanceof StreamMessage) { + ((StreamMessage) outbound).abort(); + } + throw new IllegalStateException("outbound() or setOutbound() has been already called."); + } + final StreamMessage streamMessage; + if (outbound instanceof StreamMessage) { + streamMessage = (StreamMessage) outbound; + } else { + streamMessage = new PublisherBasedStreamMessage<>(outbound); + } + + if (!outboundFuture.complete( + streamMessage.map(webSocketFrame -> HttpData.wrap(encoder.encode(ctx, webSocketFrame))))) { + streamMessage.abort(); + throw new IllegalStateException("outbound() or setOutbound() has been already called."); + } + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("ctx", ctx) + .add("responseHeaders", responseHeaders) + .add("subprotocol", subprotocol) + .add("inbound", inbound) + .add("outboundFuture", outboundFuture) + .add("encoder", encoder) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/package-info.java b/core/src/main/java/com/linecorp/armeria/client/websocket/package-info.java new file mode 100644 index 000000000000..848479aec637 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/websocket/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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. + */ + +/** + * Client-side classes for the WebSocket Protocol. + */ +@NonNullByDefault +package com.linecorp.armeria.client.websocket; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractHttpMessageBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractHttpMessageBuilder.java index 0e7f8f0efcc7..91e6e49e3584 100644 --- a/core/src/main/java/com/linecorp/armeria/common/AbstractHttpMessageBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractHttpMessageBuilder.java @@ -143,6 +143,14 @@ public AbstractHttpMessageBuilder content(MediaType contentType, HttpData conten return this; } + @Override + public AbstractHttpMessageBuilder content(Publisher publisher) { + requireNonNull(publisher, "publisher"); + checkState(content == null, "content has been set already"); + this.publisher = publisher; + return this; + } + @Override public AbstractHttpMessageBuilder content(MediaType contentType, Publisher publisher) { requireNonNull(contentType, "contentType"); diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractHttpRequestBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractHttpRequestBuilder.java index 2fd02b9a36f0..5dee216529f2 100644 --- a/core/src/main/java/com/linecorp/armeria/common/AbstractHttpRequestBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractHttpRequestBuilder.java @@ -141,6 +141,11 @@ public AbstractHttpRequestBuilder content(MediaType contentType, HttpData conten return (AbstractHttpRequestBuilder) super.content(contentType, content); } + @Override + public AbstractHttpRequestBuilder content(Publisher content) { + return (AbstractHttpRequestBuilder) super.content(content); + } + @Override public AbstractHttpRequestBuilder content(MediaType contentType, Publisher content) { return (AbstractHttpRequestBuilder) super.content(contentType, content); @@ -288,7 +293,9 @@ private RequestHeaders requestHeaders() { } private String buildPath() { - checkState(path != null, "path must be set."); + final String headerPath = requestHeadersBuilder.get(HttpHeaderNames.PATH); + checkState(path != null || headerPath != null, "path must be set."); + final String path = firstNonNull(this.path, headerPath); if (!disablePathParams) { // Path parameter substitution is enabled. Look for : or { first. diff --git a/core/src/main/java/com/linecorp/armeria/common/DeferredHttpResponse.java b/core/src/main/java/com/linecorp/armeria/common/DeferredHttpResponse.java index 4c3b7dbbfbcd..72b8d455036d 100644 --- a/core/src/main/java/com/linecorp/armeria/common/DeferredHttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/common/DeferredHttpResponse.java @@ -40,7 +40,7 @@ void delegate(HttpResponse delegate) { } void delegateWhenComplete(CompletionStage stage) { - delegateWhenCompleteStage(stage); + delegateOnCompletion(stage); } @SuppressWarnings("unchecked") diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpMessageSetters.java b/core/src/main/java/com/linecorp/armeria/common/HttpMessageSetters.java index 55a0a1af8ef4..5374f40c518f 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpMessageSetters.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpMessageSetters.java @@ -74,6 +74,11 @@ HttpMessageSetters content(MediaType contentType, @FormatString String format, */ HttpMessageSetters content(MediaType contentType, HttpData content); + /** + * Sets the {@link Publisher} for this message. + */ + HttpMessageSetters content(Publisher content); + /** * Sets the {@link Publisher} for this message. */ diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpRequestBuilder.java b/core/src/main/java/com/linecorp/armeria/common/HttpRequestBuilder.java index 5e06d71b17dd..0c42271bf8ec 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpRequestBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpRequestBuilder.java @@ -127,6 +127,11 @@ public HttpRequestBuilder content(MediaType contentType, HttpData content) { return (HttpRequestBuilder) super.content(contentType, content); } + @Override + public HttpRequestBuilder content(Publisher publisher) { + return (HttpRequestBuilder) super.content(publisher); + } + @Override public HttpRequestBuilder content(MediaType contentType, Publisher publisher) { return (HttpRequestBuilder) super.content(contentType, publisher); diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpRequestSetters.java b/core/src/main/java/com/linecorp/armeria/common/HttpRequestSetters.java index 487a95bef50d..fa438cab2ffb 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpRequestSetters.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpRequestSetters.java @@ -82,6 +82,12 @@ HttpRequestSetters content(MediaType contentType, @FormatString String format, @Override HttpRequestSetters content(MediaType contentType, HttpData content); + /** + * Sets the {@link Publisher} for this request. + */ + @Override + HttpRequestSetters content(Publisher content); + /** * Sets the {@link Publisher} for this request. */ diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java b/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java index 98d918906aae..4fb9982f13d6 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpResponseBuilder.java @@ -181,6 +181,14 @@ public HttpResponseBuilder content(MediaType contentType, HttpData content) { return (HttpResponseBuilder) super.content(contentType, content); } + /** + * Sets the {@link Publisher} for this response. + */ + @Override + public HttpResponseBuilder content(Publisher content) { + return (HttpResponseBuilder) super.content(content); + } + /** * Sets the {@link Publisher} for this response. */ diff --git a/core/src/main/java/com/linecorp/armeria/common/Scheme.java b/core/src/main/java/com/linecorp/armeria/common/Scheme.java index 4c4a72c42370..2767fe09283e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/Scheme.java +++ b/core/src/main/java/com/linecorp/armeria/common/Scheme.java @@ -60,6 +60,13 @@ public final class Scheme implements Comparable { final Scheme scheme = new Scheme(f, p); schemes.put(ftxt + '+' + ptxt, scheme); schemes.put(ptxt + '+' + ftxt, scheme); + if (SerializationFormat.WS == f) { + if (SessionProtocol.HTTP == p) { + schemes.put("ws", scheme); + } else if (SessionProtocol.HTTPS == p) { + schemes.put("wss", scheme); + } + } } } diff --git a/core/src/main/java/com/linecorp/armeria/common/SerializationFormat.java b/core/src/main/java/com/linecorp/armeria/common/SerializationFormat.java index 05cf0f1da86e..98e29570e22a 100644 --- a/core/src/main/java/com/linecorp/armeria/common/SerializationFormat.java +++ b/core/src/main/java/com/linecorp/armeria/common/SerializationFormat.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; +import static com.linecorp.armeria.common.MediaType.OCTET_STREAM; import static com.linecorp.armeria.common.MediaType.create; import static java.util.Objects.requireNonNull; @@ -39,6 +40,7 @@ import com.google.common.collect.Multimap; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; /** * Serialization format of a remote procedure call and its reply. @@ -55,6 +57,12 @@ public final class SerializationFormat implements Comparable HTTPS_VALUES = Sets.immutableEnumSet(HTTPS, H1, H2); + private static final Set HTTP_AND_HTTPS_VALUES = + Sets.immutableEnumSet(HTTPS, HTTP, H1, H1C, H2, H2C); + private static final Map uriTextToProtocols; static { @@ -117,6 +121,14 @@ public static Set httpsValues() { return HTTPS_VALUES; } + /** + * Returns an immutable {@link Set} that contains {@link #httpValues()} and {@link #httpsValues()}. + */ + @UnstableApi + public static Set httpAndHttpsValues() { + return HTTP_AND_HTTPS_VALUES; + } + private final String uriText; private final boolean useTls; private final boolean isMultiplex; diff --git a/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java b/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java index 460f2aeb46b2..67c7d672d70c 100644 --- a/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java +++ b/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java @@ -731,9 +731,21 @@ private void session0(@Nullable Channel channel, SessionProtocol sessionProtocol this.sslSession = sslSession; this.sessionProtocol = sessionProtocol; this.connectionTimings = connectionTimings; + maybeSetScheme(); updateFlags(RequestLogProperty.SESSION); } + private void maybeSetScheme() { + if (isAvailable(RequestLogProperty.SCHEME) || + serializationFormat == SerializationFormat.NONE) { + return; + } + + assert sessionProtocol != null; + scheme = Scheme.of(serializationFormat, sessionProtocol); + updateFlags(RequestLogProperty.SCHEME); + } + @Override public Channel channel() { ensureAvailable(RequestLogProperty.SESSION); diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/DeferredStreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/DeferredStreamMessage.java index bc94735cc8ef..723539500dd5 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/DeferredStreamMessage.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/DeferredStreamMessage.java @@ -137,7 +137,7 @@ public EventExecutor defaultSubscriberExecutor() { /** * Delegates when the specified {@link CompletionStage} is complete. */ - protected final void delegateWhenCompleteStage(CompletionStage> stage) { + protected final void delegateOnCompletion(CompletionStage> stage) { requireNonNull(stage, "stage"); stage.handle((upstream, thrown) -> { if (thrown != null) { diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java index 9f1280a84495..4a5bfe1526ee 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java @@ -202,7 +202,7 @@ static StreamMessage of(CompletionStage> } else { final DeferredStreamMessage deferred = new DeferredStreamMessage<>(); //noinspection unchecked - deferred.delegateWhenCompleteStage((CompletionStage>) stage); + deferred.delegateOnCompletion((CompletionStage>) stage); return deferred; } } @@ -224,7 +224,7 @@ static StreamMessage of(CompletionStage deferred = new DeferredStreamMessage<>(subscriberExecutor); //noinspection unchecked - deferred.delegateWhenCompleteStage((CompletionStage>) stage); + deferred.delegateOnCompletion((CompletionStage>) stage); return deferred; } diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessageUtil.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessageUtil.java index bee8a4611869..539f9ce2a5f6 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessageUtil.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessageUtil.java @@ -57,7 +57,7 @@ static StreamMessage createStreamMessageFrom( final DeferredStreamMessage deferred = new DeferredStreamMessage<>(); //noinspection unchecked - deferred.delegateWhenCompleteStage((CompletionStage>) future); + deferred.delegateOnCompletion((CompletionStage>) future); return deferred; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java b/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java index 661fd471326d..1c4ff86b472a 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java @@ -18,6 +18,7 @@ import static com.google.common.base.MoreObjects.firstNonNull; import static java.util.Objects.requireNonNull; +import java.net.URI; import java.util.concurrent.CompletableFuture; import java.util.function.BiFunction; import java.util.function.Function; @@ -26,6 +27,7 @@ import com.linecorp.armeria.client.ClientRequestContext; import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.client.UnprocessedRequestException; +import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.Request; @@ -43,6 +45,11 @@ public final class ClientUtil { + /** + * An undefined {@link URI} to create {@link WebClient} without specifying {@link URI}. + */ + public static final URI UNDEFINED_URI = URI.create("http://undefined"); + public static > O initContextAndExecuteWithFallback( U delegate, diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/DefaultClientRequestContext.java b/core/src/main/java/com/linecorp/armeria/internal/client/DefaultClientRequestContext.java index 8c9d406c36fd..eef72c48c05a 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/DefaultClientRequestContext.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/DefaultClientRequestContext.java @@ -404,7 +404,7 @@ public void finishInitialization(boolean success) { private void updateEndpoint(@Nullable Endpoint endpoint) { this.endpoint = endpoint; - autoFillSchemeAndAuthority(); + autoFillSchemeAuthorityAndOrigin(); } private void acquireEventLoop(EndpointGroup endpointGroup) { @@ -428,7 +428,7 @@ private void failEarly(Throwable cause) { final UnprocessedRequestException wrapped = UnprocessedRequestException.of(cause); final HttpRequest req = request(); if (req != null) { - autoFillSchemeAndAuthority(); + autoFillSchemeAuthorityAndOrigin(); req.abort(wrapped); } @@ -438,7 +438,7 @@ private void failEarly(Throwable cause) { } // TODO(ikhoon): Consider moving the logic for filling authority to `HttpClientDelegate.exceute()`. - private void autoFillSchemeAndAuthority() { + private void autoFillSchemeAuthorityAndOrigin() { final String authority = authority(); if (authority != null && endpoint != null && endpoint.isIpAddrOnly()) { // The connection will be established with the IP address but `host` set to the `Endpoint` @@ -453,7 +453,16 @@ private void autoFillSchemeAndAuthority() { final HttpHeadersBuilder headersBuilder = internalRequestHeaders.toBuilder(); headersBuilder.set(HttpHeaderNames.SCHEME, getScheme(sessionProtocol())); if (endpoint != null) { - headersBuilder.set(HttpHeaderNames.AUTHORITY, endpoint.authority()); + final String endpointAuthority = endpoint.authority(); + headersBuilder.set(HttpHeaderNames.AUTHORITY, endpointAuthority); + final String origin = origin(); + if (origin != null) { + headersBuilder.set(HttpHeaderNames.ORIGIN, origin); + } else if (options().autoFillOriginHeader()) { + final String uriText = sessionProtocol().isTls() ? SessionProtocol.HTTPS.uriText() + : SessionProtocol.HTTP.uriText(); + headersBuilder.set(HttpHeaderNames.ORIGIN, uriText + "://" + endpointAuthority); + } } internalRequestHeaders = headersBuilder.build(); } @@ -576,7 +585,6 @@ public ClientRequestContext newDerivedContext(RequestId id, protocol, newHeaders.method(), reqTarget); } } - return new DefaultClientRequestContext(this, id, req, rpcReq, endpoint, endpointGroup(), sessionProtocol(), method(), requestTarget()); } @@ -698,6 +706,23 @@ public String authority() { return authority; } + @Nullable + private String origin() { + final HttpHeaders additionalRequestHeaders = this.additionalRequestHeaders; + String origin = additionalRequestHeaders.get(HttpHeaderNames.ORIGIN); + final HttpRequest request = request(); + if (origin == null && request != null) { + origin = request.headers().get(HttpHeaderNames.ORIGIN); + } + if (origin == null) { + origin = defaultRequestHeaders.get(HttpHeaderNames.ORIGIN); + } + if (origin == null) { + origin = internalRequestHeaders.get(HttpHeaderNames.ORIGIN); + } + return origin; + } + @Override public URI uri() { final String scheme = getScheme(sessionProtocol()); diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/HttpSession.java b/core/src/main/java/com/linecorp/armeria/internal/client/HttpSession.java index 768fcdf534f8..29ff3f029321 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/HttpSession.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/HttpSession.java @@ -19,6 +19,7 @@ import com.linecorp.armeria.client.ClientRequestContext; import com.linecorp.armeria.common.ClosedSessionException; import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.common.InboundTrafficController; @@ -34,6 +35,12 @@ public interface HttpSession { int MAX_NUM_REQUESTS_SENT = 536870912; HttpSession INACTIVE = new HttpSession() { + + @Override + public SerializationFormat serializationFormat() { + return SerializationFormat.UNKNOWN; + } + @Nullable @Override public SessionProtocol protocol() { @@ -93,6 +100,13 @@ static HttpSession get(Channel ch) { return INACTIVE; } + SerializationFormat serializationFormat(); + + /** + * Returns the explicit {@link SessionProtocol} of this {@link HttpSession}. + * This is one of {@link SessionProtocol#H1}, {@link SessionProtocol#H1C}, {@link SessionProtocol#H2} and + * {@link SessionProtocol#H2C}. + */ @Nullable SessionProtocol protocol(); diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/websocket/WebSocketClientUtil.java b/core/src/main/java/com/linecorp/armeria/internal/client/websocket/WebSocketClientUtil.java new file mode 100644 index 000000000000..623b8ce5560c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/client/websocket/WebSocketClientUtil.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.internal.client.websocket; + +import static java.util.Objects.requireNonNull; + +import java.util.function.Consumer; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.annotation.Nullable; + +import io.netty.util.AttributeKey; + +public final class WebSocketClientUtil { + + private static final AttributeKey> CLOSING_RESPONSE_TASK = + AttributeKey.valueOf(WebSocketClientUtil.class, "CLOSING_RESPONSE_TASK"); + + public static void setClosingResponseTask(ClientRequestContext ctx, Consumer task) { + requireNonNull(ctx, "ctx"); + requireNonNull(task, "task"); + ctx.setAttr(CLOSING_RESPONSE_TASK, task); + } + + public static void closingResponse(ClientRequestContext ctx, @Nullable Throwable cause) { + requireNonNull(ctx, "ctx"); + final Consumer task = ctx.attr(CLOSING_RESPONSE_TASK); + if (task != null) { + task.accept(cause); + } + } + + private WebSocketClientUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/websocket/package-info.java b/core/src/main/java/com/linecorp/armeria/internal/client/websocket/package-info.java new file mode 100644 index 000000000000..c0062c76749b --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/client/websocket/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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. + */ + +/** + * Internal client classes for + * the WebSocket Protocol. + */ +@NonNullByDefault +package com.linecorp.armeria.internal.client.websocket; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultSplitHttpResponse.java b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultSplitHttpResponse.java index 431f82971928..62316790cfcb 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultSplitHttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultSplitHttpResponse.java @@ -17,6 +17,7 @@ package com.linecorp.armeria.internal.common; import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; import org.reactivestreams.Subscription; @@ -38,7 +39,21 @@ public class DefaultSplitHttpResponse extends AbstractSplitHttpMessage implement private final SplitHttpResponseBodySubscriber bodySubscriber; public DefaultSplitHttpResponse(HttpResponse response, EventExecutor upstreamExecutor) { - this(response, upstreamExecutor, new SplitHttpResponseBodySubscriber(response, upstreamExecutor)); + this(response, upstreamExecutor, headers -> !headers.status().isInformational()); + } + + /** + * Creates a new {@link DefaultSplitHttpResponse} from the specified {@link HttpResponse}. + * The specified {@link Predicate} is used to determine if the {@link ResponseHeaders} is the final one. + * For example, if there are multiple informational {@link ResponseHeaders} and the {@link Predicate} + * will be {@code headers -> !headers.status().isInformational()}. + * However, if the {@link ResponseHeaders} is only one, and it can be an informational one such as a + * WebSocket response, {@link Predicate} will be {@code headers -> true}. + */ + public DefaultSplitHttpResponse(HttpResponse response, EventExecutor upstreamExecutor, + Predicate finalResponseHeadersPredicate) { + this(response, upstreamExecutor, + new SplitHttpResponseBodySubscriber(response, upstreamExecutor, finalResponseHeadersPredicate)); } private DefaultSplitHttpResponse(HttpResponse response, EventExecutor upstreamExecutor, @@ -55,9 +70,12 @@ public final CompletableFuture headers() { private static final class SplitHttpResponseBodySubscriber extends SplitHttpMessageSubscriber { private final HeadersFuture headersFuture = new HeadersFuture<>(); + private final Predicate finalResponseHeadersPredicate; - SplitHttpResponseBodySubscriber(HttpResponse response, EventExecutor upstreamExecutor) { + SplitHttpResponseBodySubscriber(HttpResponse response, EventExecutor upstreamExecutor, + Predicate finalResponseHeadersPredicate) { super(1, response, upstreamExecutor); + this.finalResponseHeadersPredicate = finalResponseHeadersPredicate; } CompletableFuture headersFuture() { @@ -68,14 +86,12 @@ CompletableFuture headersFuture() { public void onNext(HttpObject httpObject) { if (httpObject instanceof ResponseHeaders) { final ResponseHeaders headers = (ResponseHeaders) httpObject; - final HttpStatus status = headers.status(); - if (status.isInformational()) { - // Ignore informational headers + if (finalResponseHeadersPredicate.test(headers)) { + headersFuture.doComplete(headers); + } else { final Subscription upstream = upstream(); assert upstream != null; upstream.request(1); - } else { - headersFuture.doComplete(headers); } return; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/HttpHeadersUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/HttpHeadersUtil.java index 33d989f02cfe..8fade4ac277b 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/HttpHeadersUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/HttpHeadersUtil.java @@ -92,6 +92,19 @@ public static RequestHeaders mergeRequestHeaders(RequestHeaders headers, headers.contains(HttpHeaderNames.USER_AGENT)) { return headers; } + if (defaultHeaders.isEmpty() && additionalHeaders.isEmpty()) { + boolean containAllInternalHeaders = true; + for (AsciiString name : internalHeaders.names()) { + if (!headers.contains(name)) { + containAllInternalHeaders = false; + break; + } + } + + if (containAllInternalHeaders) { + return headers; + } + } final RequestHeadersBuilder builder = headers.toBuilder(); diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameDecoder.java b/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameDecoder.java index d8c2f21b6e2a..ad31efcb993b 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameDecoder.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameDecoder.java @@ -34,8 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.linecorp.armeria.common.HttpRequestWriter; -import com.linecorp.armeria.common.Request; +import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.stream.HttpDecoder; import com.linecorp.armeria.common.stream.StreamDecoderInput; @@ -45,14 +44,12 @@ import com.linecorp.armeria.common.websocket.WebSocketCloseStatus; import com.linecorp.armeria.common.websocket.WebSocketFrame; import com.linecorp.armeria.common.websocket.WebSocketFrameType; -import com.linecorp.armeria.internal.common.RequestContextExtension; -import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.websocket.WebSocketProtocolViolationException; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; -public final class WebSocketFrameDecoder implements HttpDecoder { +public abstract class WebSocketFrameDecoder implements HttpDecoder { // Forked from Netty 4.1.92 https://github.com/netty/netty/blob/e8df52e442629214e0355528c00e873e213f0139/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocket08FrameDecoder.java @@ -67,10 +64,9 @@ enum State { CORRUPT } - private final ServiceRequestContext ctx; + private final RequestContext ctx; private final int maxFramePayloadLength; private final boolean allowMaskMismatch; - private final boolean expectMaskedFrames; @Nullable private WebSocket outboundFrames; @@ -85,12 +81,10 @@ enum State { private boolean receivedClosingHandshake; private State state = State.READING_FIRST; - public WebSocketFrameDecoder(ServiceRequestContext ctx, int maxFramePayloadLength, - boolean allowMaskMismatch, boolean expectMaskedFrames) { + protected WebSocketFrameDecoder(RequestContext ctx, int maxFramePayloadLength, boolean allowMaskMismatch) { this.ctx = ctx; this.maxFramePayloadLength = maxFramePayloadLength; this.allowMaskMismatch = allowMaskMismatch; - this.expectMaskedFrames = expectMaskedFrames; } public void setOutboundWebSocket(WebSocket outboundFrames) { @@ -136,7 +130,7 @@ public void process(StreamDecoderInput in, StreamDecoderOutput o throw protocolViolation("RSV != 0 and no extension negotiated, RSV:" + frameRsv); } - if (!allowMaskMismatch && expectMaskedFrames != frameMasked) { + if (!allowMaskMismatch && expectMaskedFrames() != frameMasked) { throw protocolViolation("received a frame that is not masked as expected"); } @@ -273,7 +267,7 @@ public void process(StreamDecoderInput in, StreamDecoderOutput o final CloseWebSocketFrame decodedFrame = WebSocketFrame.ofPooledClose(payloadBuffer); out.add(decodedFrame); logger.trace("{} is decoded.", decodedFrame); - closeRequest(); + onCloseFrameRead(); continue; // to while loop } @@ -304,6 +298,10 @@ public void process(StreamDecoderInput in, StreamDecoderOutput o } } + protected abstract boolean expectMaskedFrames(); + + protected abstract void onCloseFrameRead(); + private void unmask(ByteBuf frame) { long longMask = mask & 0xFFFFFFFFL; longMask |= longMask << 32; @@ -366,19 +364,17 @@ private void validateCloseFrame(ByteBuf buffer) { } } - private void closeRequest() { - final RequestContextExtension ctxExtension = ctx.as(RequestContextExtension.class); - assert ctxExtension != null; - final Request request = ctxExtension.originalRequest(); - assert request instanceof HttpRequestWriter : request; - //noinspection OverlyStrongTypeCast - ((HttpRequestWriter) request).close(); - } - @Override public void processOnError(Throwable cause) { - if (outboundFrames != null) { - outboundFrames.abort(cause); + // If an exception from the inbound stream is raised after receiving a close frame, + // we should not abort the outbound stream. + if (!receivedClosingHandshake) { + if (outboundFrames != null) { + outboundFrames.abort(cause); + } } + onProcessOnError(cause); } + + protected void onProcessOnError(Throwable cause) {} } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketUtil.java index dd18293c5fa6..544208f7bb2a 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/websocket/WebSocketUtil.java @@ -17,6 +17,11 @@ import static java.util.Objects.requireNonNull; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import com.google.common.hash.Hashing; + import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.RequestHeaders; @@ -29,8 +34,9 @@ public final class WebSocketUtil { - public static final long DEFAULT_REQUEST_TIMEOUT_MILLIS = 0; - public static final long DEFAULT_MAX_REQUEST_LENGTH = 0; + private static final String MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + public static final long DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS = 0; + public static final long DEFAULT_MAX_REQUEST_RESPONSE_LENGTH = 0; public static final long DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS = 5000; public static boolean isHttp1WebSocketUpgradeRequest(RequestHeaders headers) { @@ -54,6 +60,17 @@ public static boolean isHttp2WebSocketUpgradeRequest(RequestHeaders headers) { HttpHeaderValues.WEBSOCKET.contentEqualsIgnoreCase(headers.get(HttpHeaderNames.PROTOCOL)); } + /** + * Generates Sec-WebSocket-Accept using Sec-WebSocket-Key. + * + * @see Opening Handshake + */ + public static String generateSecWebSocketAccept(String webSocketKey) { + final String acceptSeed = webSocketKey + MAGIC_GUID; + final byte[] sha1 = Hashing.sha1().hashBytes(acceptSeed.getBytes(StandardCharsets.US_ASCII)).asBytes(); + return Base64.getEncoder().encodeToString(sha1); + } + static int byteAtIndex(int mask, int index) { return (mask >> 8 * (3 - index)) & 0xFF; } diff --git a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java index 344789073c22..4a8ad179f351 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java +++ b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java @@ -111,6 +111,7 @@ public void onNext(HttpObject o) { req.abortResponse(new IllegalArgumentException( "published an HttpObject that's neither HttpHeaders nor HttpData: " + o + " (service: " + service() + ')'), true); + PooledObjects.close(o); return; } diff --git a/core/src/main/java/com/linecorp/armeria/server/Http1RequestDecoder.java b/core/src/main/java/com/linecorp/armeria/server/Http1RequestDecoder.java index f564480c32ad..f5de6f98b25a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/Http1RequestDecoder.java +++ b/core/src/main/java/com/linecorp/armeria/server/Http1RequestDecoder.java @@ -248,7 +248,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception assert encoder instanceof ServerHttp1ObjectEncoder; ((ServerHttp1ObjectEncoder) encoder).webSocketUpgrading(); final ChannelPipeline pipeline = ctx.pipeline(); - pipeline.replace(this, null, new WebSocketSessionChannelHandler( + pipeline.replace(this, null, new WebSocketServiceChannelHandler( webSocketRequest, encoder, serviceConfig)); if (pipeline.get(HttpServerUpgradeHandler.class) != null) { pipeline.remove(HttpServerUpgradeHandler.class); diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java index bfb8c8291b6e..6e6b4ff3fd91 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java @@ -312,7 +312,7 @@ ServiceConfig build(ServiceNaming defaultServiceNaming, } else if (!webSocket || defaultRequestTimeoutMillis != Flags.defaultRequestTimeoutMillis()) { requestTimeoutMillis = defaultRequestTimeoutMillis; } else { - requestTimeoutMillis = WebSocketUtil.DEFAULT_REQUEST_TIMEOUT_MILLIS; + requestTimeoutMillis = WebSocketUtil.DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS; } final long maxRequestLength; @@ -321,7 +321,7 @@ ServiceConfig build(ServiceNaming defaultServiceNaming, } else if (!webSocket || defaultMaxRequestLength != Flags.defaultMaxRequestLength()) { maxRequestLength = defaultMaxRequestLength; } else { - maxRequestLength = WebSocketUtil.DEFAULT_MAX_REQUEST_LENGTH; + maxRequestLength = WebSocketUtil.DEFAULT_MAX_REQUEST_RESPONSE_LENGTH; } final long requestAutoAbortDelayMillis; diff --git a/core/src/main/java/com/linecorp/armeria/server/WebSocketSessionChannelHandler.java b/core/src/main/java/com/linecorp/armeria/server/WebSocketServiceChannelHandler.java similarity index 94% rename from core/src/main/java/com/linecorp/armeria/server/WebSocketSessionChannelHandler.java rename to core/src/main/java/com/linecorp/armeria/server/WebSocketServiceChannelHandler.java index e348d1c1b067..79bee0f88fe6 100644 --- a/core/src/main/java/com/linecorp/armeria/server/WebSocketSessionChannelHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/WebSocketServiceChannelHandler.java @@ -38,15 +38,15 @@ import io.netty.handler.codec.http2.Http2Error; import io.netty.util.ReferenceCountUtil; -final class WebSocketSessionChannelHandler extends ChannelDuplexHandler { +final class WebSocketServiceChannelHandler extends ChannelDuplexHandler { - private static final Logger logger = LoggerFactory.getLogger(WebSocketSessionChannelHandler.class); + private static final Logger logger = LoggerFactory.getLogger(WebSocketServiceChannelHandler.class); private final StreamingDecodedHttpRequest req; private final ServerHttpObjectEncoder encoder; private final ServiceConfig serviceConfig; - WebSocketSessionChannelHandler(StreamingDecodedHttpRequest req, ServerHttpObjectEncoder encoder, + WebSocketServiceChannelHandler(StreamingDecodedHttpRequest req, ServerHttpObjectEncoder encoder, ServiceConfig serviceConfig) { this.req = req; this.encoder = encoder; @@ -82,6 +82,7 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) { logger.warn("{} Unexpected msg: {}", ctx.channel(), msg); return; } + encoder.keepAliveHandler().onReadOrWrite(); try { final ByteBuf data = (ByteBuf) msg; final int dataLength = data.readableBytes(); @@ -123,7 +124,7 @@ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) final HttpResponse response = (HttpResponse) msg; final HttpResponseStatus status = response.status(); ctx.write(msg, promise); - if (status == HttpResponseStatus.SWITCHING_PROTOCOLS) { + if (status.code() == HttpResponseStatus.SWITCHING_PROTOCOLS.code()) { ctx.pipeline().remove(HttpServerCodec.class); } return; diff --git a/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketService.java b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketService.java index fef607e19338..9c53cf2e553e 100644 --- a/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketService.java +++ b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketService.java @@ -15,19 +15,17 @@ */ package com.linecorp.armeria.server.websocket; +import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.generateSecWebSocketAccept; import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.isHttp1WebSocketUpgradeRequest; import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.isHttp2WebSocketUpgradeRequest; import static com.linecorp.armeria.internal.common.websocket.WebSocketUtil.newCloseWebSocketFrame; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Splitter; -import com.google.common.hash.Hashing; import com.google.common.net.HostAndPort; import com.linecorp.armeria.common.HttpData; @@ -46,7 +44,6 @@ import com.linecorp.armeria.common.stream.StreamMessage; import com.linecorp.armeria.common.websocket.WebSocket; import com.linecorp.armeria.common.websocket.WebSocketFrame; -import com.linecorp.armeria.internal.common.websocket.WebSocketFrameDecoder; import com.linecorp.armeria.internal.common.websocket.WebSocketFrameEncoder; import com.linecorp.armeria.internal.common.websocket.WebSocketWrapper; import com.linecorp.armeria.server.AbstractHttpService; @@ -68,8 +65,6 @@ public final class WebSocketService extends AbstractHttpService { private static final Logger logger = LoggerFactory.getLogger(WebSocketService.class); - private static final String WEBSOCKET_13_ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - private static final String SUB_PROTOCOL_WILDCARD = "*"; private static final ResponseHeaders UNSUPPORTED_WEB_SOCKET_VERSION = @@ -192,19 +187,10 @@ private void maybeAddSubprotocol(RequestHeaders headers, HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, selectedSubprotocol)); } - // Generate Sec-WebSocket-Accept using Sec-WebSocket-Key. - // See https://datatracker.ietf.org/doc/html/rfc6455#section-11.3.3 - private static String generateSecWebSocketAccept(String webSocketKey) { - final String acceptSeed = webSocketKey + WEBSOCKET_13_ACCEPT_GUID; - final byte[] sha1 = Hashing.sha1().hashBytes(acceptSeed.getBytes(StandardCharsets.US_ASCII)).asBytes(); - return Base64.getEncoder().encodeToString(sha1); - } - private HttpResponse handleUpgradeRequest(ServiceRequestContext ctx, HttpRequest req, ResponseHeaders responseHeaders) { - final WebSocketFrameDecoder decoder = - new WebSocketFrameDecoder(ctx, maxFramePayloadLength, allowMaskMismatch, - true); // client sends masked frames. + final WebSocketServiceFrameDecoder decoder = + new WebSocketServiceFrameDecoder(ctx, maxFramePayloadLength, allowMaskMismatch); final StreamMessage inboundFrames = req.decode(decoder, ctx.alloc()); final WebSocket outboundFrames = handler.handle(ctx, new WebSocketWrapper(inboundFrames)); decoder.setOutboundWebSocket(outboundFrames); diff --git a/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceBuilder.java b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceBuilder.java index 3256706caf67..4003a9342572 100644 --- a/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceBuilder.java @@ -37,9 +37,9 @@ * This service has the different default configs from a normal {@link HttpService}. Here are the differences: *
    *
  • {@link ServiceConfig#requestTimeoutMillis()} is - * {@value WebSocketUtil#DEFAULT_REQUEST_TIMEOUT_MILLIS}.
  • + * {@value WebSocketUtil#DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS}. *
  • {@link ServiceConfig#maxRequestLength()} is - * {@value WebSocketUtil#DEFAULT_MAX_REQUEST_LENGTH}.
  • + * {@value WebSocketUtil#DEFAULT_MAX_REQUEST_RESPONSE_LENGTH}. *
  • {@link ServiceConfig#requestAutoAbortDelayMillis()} is * {@value WebSocketUtil#DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS}.
  • *
diff --git a/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceFrameDecoder.java b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceFrameDecoder.java new file mode 100644 index 000000000000..28bbd71aa2c9 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/websocket/WebSocketServiceFrameDecoder.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.server.websocket; + +import com.linecorp.armeria.common.HttpRequestWriter; +import com.linecorp.armeria.common.Request; +import com.linecorp.armeria.internal.common.RequestContextExtension; +import com.linecorp.armeria.internal.common.websocket.WebSocketFrameDecoder; +import com.linecorp.armeria.server.ServiceRequestContext; + +final class WebSocketServiceFrameDecoder extends WebSocketFrameDecoder { + + private final ServiceRequestContext ctx; + + WebSocketServiceFrameDecoder(ServiceRequestContext ctx, int maxFramePayloadLength, + boolean allowMaskMismatch) { + super(ctx, maxFramePayloadLength, allowMaskMismatch); + this.ctx = ctx; + } + + @Override + protected boolean expectMaskedFrames() { + return true; + } + + @Override + protected void onCloseFrameRead() { + final RequestContextExtension ctxExtension = ctx.as(RequestContextExtension.class); + assert ctxExtension != null; + final Request request = ctxExtension.originalRequest(); + assert request instanceof HttpRequestWriter : request; + //noinspection OverlyStrongTypeCast + ((HttpRequestWriter) request).close(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java b/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java index fb6d397f0df8..013b0dfe196f 100644 --- a/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java @@ -15,6 +15,7 @@ */ package com.linecorp.armeria.client; +import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -43,7 +44,7 @@ void testConcatenateRequestPath() { @Test void testRequestParamsUndefinedEndPoint() { final String path = "http://127.0.0.1/helloWorld/test?q1=foo"; - final WebClient client = WebClient.of(AbstractWebClientBuilder.UNDEFINED_URI); + final WebClient client = WebClient.of(UNDEFINED_URI); try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { client.execute(HttpRequest.of(RequestHeaders.of(HttpMethod.GET, path))).aggregate(); @@ -54,7 +55,7 @@ void testRequestParamsUndefinedEndPoint() { @Test void testWithoutRequestParamsUndefinedEndPoint() { final String path = "http://127.0.0.1/helloWorld/test"; - final WebClient client = WebClient.of(AbstractWebClientBuilder.UNDEFINED_URI); + final WebClient client = WebClient.of(UNDEFINED_URI); try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { client.execute(HttpRequest.of(RequestHeaders.of(HttpMethod.GET, path))).aggregate(); @@ -118,7 +119,7 @@ void testWithQueryParams() { final QueryParams queryParams = QueryParams.builder() .add("q1", "foo") .build(); - final WebClient client = WebClient.of(AbstractWebClientBuilder.UNDEFINED_URI); + final WebClient client = WebClient.of(UNDEFINED_URI); try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { client.get(path, queryParams).aggregate(); assertThat(captor.get().request().path()).isEqualTo("/helloWorld/test?q1=foo"); diff --git a/core/src/test/java/com/linecorp/armeria/client/Http1ResponseDecoderTest.java b/core/src/test/java/com/linecorp/armeria/client/Http1ResponseDecoderTest.java index 360a9083273f..c600209cc175 100644 --- a/core/src/test/java/com/linecorp/armeria/client/Http1ResponseDecoderTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/Http1ResponseDecoderTest.java @@ -20,6 +20,8 @@ import org.junit.jupiter.api.Test; +import com.linecorp.armeria.common.SessionProtocol; + import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.DefaultHttpResponse; @@ -33,8 +35,9 @@ class Http1ResponseDecoderTest { @Test void testRequestTimeoutClosesImmediately() throws Exception { final EmbeddedChannel channel = new EmbeddedChannel(); - try { - final Http1ResponseDecoder decoder = new Http1ResponseDecoder(channel); + try (HttpClientFactory httpClientFactory = new HttpClientFactory(ClientFactoryOptions.of())) { + final Http1ResponseDecoder decoder = new Http1ResponseDecoder( + channel, httpClientFactory, SessionProtocol.H1); channel.pipeline().addLast(decoder); final HttpHeaders httpHeaders = new DefaultHttpHeaders(); diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpResponseDecoderTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpResponseDecoderTest.java index b7f5e99b7608..ebc99f9be09d 100644 --- a/core/src/test/java/com/linecorp/armeria/client/HttpResponseDecoderTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/HttpResponseDecoderTest.java @@ -29,7 +29,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.linecorp.armeria.client.HttpResponseDecoder.HttpResponseWrapper; import com.linecorp.armeria.client.retry.Backoff; import com.linecorp.armeria.client.retry.RetryDecision; import com.linecorp.armeria.client.retry.RetryRule; @@ -59,8 +58,8 @@ protected void configure(ServerBuilder sb) { }; /** - * This test would be passed because the {@code cancelAction} method of the {@link HttpResponseWrapper} is - * invoked in the event loop of the {@link Channel}. + * This test would be passed because the {@code cancelAction} method of the + * {@link HttpResponseWrapper} is invoked in the event loop of the {@link Channel}. */ @ParameterizedTest @EnumSource(value = SessionProtocol.class, names = {"H1C", "H2C"}) diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpResponseWrapperTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpResponseWrapperTest.java index 98de15c9a095..092094e43fe9 100644 --- a/core/src/test/java/com/linecorp/armeria/client/HttpResponseWrapperTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/HttpResponseWrapperTest.java @@ -20,7 +20,6 @@ import org.junit.jupiter.api.Test; -import com.linecorp.armeria.client.HttpResponseDecoder.HttpResponseWrapper; import com.linecorp.armeria.common.CommonPools; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; @@ -161,11 +160,10 @@ private static HttpResponseWrapper httpResponseWrapper(DecodedHttpResponse res) final TestHttpResponseDecoder decoder = new TestHttpResponseDecoder(channel, controller); res.init(controller); - return decoder.addResponse(1, res, cctx, cctx.eventLoop(), cctx.responseTimeoutMillis(), - cctx.maxResponseLength()); + return decoder.addResponse(1, res, cctx, cctx.eventLoop()); } - private static class TestHttpResponseDecoder extends HttpResponseDecoder { + private static class TestHttpResponseDecoder extends AbstractHttpResponseDecoder { private final KeepAliveHandler keepAliveHandler = new NoopKeepAliveHandler(); TestHttpResponseDecoder(Channel channel, InboundTrafficController inboundTrafficController) { @@ -176,7 +174,7 @@ private static class TestHttpResponseDecoder extends HttpResponseDecoder { void onResponseAdded(int id, EventLoop eventLoop, HttpResponseWrapper responseWrapper) {} @Override - KeepAliveHandler keepAliveHandler() { + public KeepAliveHandler keepAliveHandler() { return keepAliveHandler; } } diff --git a/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilderTest.java new file mode 100644 index 000000000000..df22c6d51c3e --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilderTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2023 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.client.websocket; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.linecorp.armeria.client.ClientOptions; +import com.linecorp.armeria.client.Endpoint; + +class WebSocketClientBuilderTest { + + @CsvSource({ + "http, ws+http", + "https, ws+https", + "h1, ws+h1", + "h1c, ws+h1c", + "h2, ws+h2", + "h2c, ws+h2c", + "http, ws+http", + "https, ws+https", + "ws, ws+http", + "wss, ws+https", + "ws+h1, ws+h1", + "ws+h1c, ws+h1c", + "ws+h2, ws+h2", + "ws+h2c, ws+h2c", + "ws+http, ws+http", + "ws+https, ws+https", + }) + @ParameterizedTest + void uriWithWsPlusProtocol(String scheme, String convertedScheme) { + final WebSocketClient client = WebSocketClient.builder(scheme + "://google.com/").build(); + assertThat(client.uri().toString()).isEqualTo(convertedScheme + "://google.com/"); + } + + @CsvSource({ + "http, ws+http", + "https, ws+https", + "h1, ws+h1", + "h1c, ws+h1c", + "h2, ws+h2", + "h2c, ws+h2c", + "http, ws+http", + "https, ws+https", + "ws, ws+http", + "wss, ws+https", + "ws+h1, ws+h1", + "ws+h1c, ws+h1c", + "ws+h2, ws+h2", + "ws+h2c, ws+h2c", + "ws+http, ws+http", + "ws+https, ws+https", + }) + @ParameterizedTest + void endpointWithoutPath(String scheme, String convertedScheme) { + final WebSocketClient client = WebSocketClient.builder(scheme, Endpoint.of("127.0.0.1")).build(); + assertThat(client.uri().toString()).isEqualTo(convertedScheme + "://127.0.0.1/"); + } + + @CsvSource({ + "http, ws+http", + "https, ws+https", + "h1, ws+h1", + "h1c, ws+h1c", + "h2, ws+h2", + "h2c, ws+h2c", + "http, ws+http", + "https, ws+https", + "ws, ws+http", + "wss, ws+https", + "ws+h1, ws+h1", + "ws+h1c, ws+h1c", + "ws+h2, ws+h2", + "ws+h2c, ws+h2c", + "ws+http, ws+http", + "ws+https, ws+https", + }) + @ParameterizedTest + void endpointWithPath(String scheme, String convertedScheme) { + final WebSocketClient client = WebSocketClient.builder(scheme, Endpoint.of("127.0.0.1"), "/foo") + .build(); + assertThat(client.uri().toString()).isEqualTo(convertedScheme + "://127.0.0.1/foo"); + } + + @Test + void webSocketClientDefaultOptions() { + final WebSocketClient client = WebSocketClient.builder("wss://google.com/").build(); + assertThat(client.options().get(ClientOptions.RESPONSE_TIMEOUT_MILLIS)).isEqualTo(0); + assertThat(client.options().get(ClientOptions.MAX_RESPONSE_LENGTH)).isEqualTo(0); + assertThat(client.options().get(ClientOptions.REQUEST_AUTO_ABORT_DELAY_MILLIS)).isEqualTo(5000); + assertThat(client.options().get(ClientOptions.AUTO_FILL_ORIGIN_HEADER)).isTrue(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeTest.java b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeTest.java new file mode 100644 index 000000000000..b45f1c23de23 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientHandshakeTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023 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.client.websocket; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.linecorp.armeria.client.websocket.WebSocketClientTest.WebSocketServiceEchoHandler; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.websocket.WebSocket; +import com.linecorp.armeria.common.websocket.WebSocketWriter; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.websocket.WebSocketService; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class WebSocketClientHandshakeTest { + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.service("/chat", WebSocketService.builder(new WebSocketServiceEchoHandler()) + .subprotocols("foo", "foo1", "foo2") + .build()); + } + }; + + @CsvSource({ + "H1C, foo2, foo1, foo2", + "H1C, bar1, bar2, ", + "H2C, foo2, foo1, foo2", + "H2C, bar1, bar2, " + }) + @ParameterizedTest + void subprotocol(SessionProtocol sessionProtocol, + String subprotocol1, String subprotocol2, @Nullable String selected) { + final WebSocketClient client = + WebSocketClient.builder(server.uri(sessionProtocol, SerializationFormat.WS)) + .subprotocols(subprotocol1, subprotocol2) + .build(); + final WebSocketSession session = client.connect("/chat").join(); + if (selected == null) { + assertThat(session.responseHeaders().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL)).isNull(); + } else { + assertThat(session.responseHeaders().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL)) + .isEqualTo(selected); + } + // Abort the session to close the connection. + final WebSocketWriter outbound = WebSocket.streaming(); + outbound.abort(); + session.setOutbound(outbound); + session.inbound().abort(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientTest.java b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientTest.java new file mode 100644 index 000000000000..c98417de6a5d --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/websocket/WebSocketClientTest.java @@ -0,0 +1,196 @@ +/* + * Copyright 2023 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.client.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.common.ClosedSessionException; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.logging.RequestLogProperty; +import com.linecorp.armeria.common.websocket.WebSocket; +import com.linecorp.armeria.common.websocket.WebSocketCloseStatus; +import com.linecorp.armeria.common.websocket.WebSocketFrame; +import com.linecorp.armeria.common.websocket.WebSocketFrameType; +import com.linecorp.armeria.common.websocket.WebSocketWriter; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.websocket.WebSocketService; +import com.linecorp.armeria.server.websocket.WebSocketServiceHandler; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class WebSocketClientTest { + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.http(0) + .https(0) + .tlsSelfSigned(); + sb.route() + .get("/chat") + .connect("/chat") + .requestAutoAbortDelayMillis(5000) + .build(WebSocketService.of(new WebSocketServiceEchoHandler())); + } + }; + + @CsvSource({ + "H1, false", + "H1C, true", + "H1C, false", + "H2, false", + "H2C, true", + "H2C, false", + "HTTP, true", + "HTTP, false", + "HTTPS, false" + }) + @ParameterizedTest + void webSocketClient(SessionProtocol protocol, boolean defaultClient) throws InterruptedException { + // TODO(minwoox): Add server.webSocketClient(); + final CompletableFuture future; + if (defaultClient) { + future = WebSocketClient.of().connect(server.uri(protocol, SerializationFormat.WS) + "/chat"); + } else { + final WebSocketClient webSocketClient = + WebSocketClient.builder(server.uri(protocol, SerializationFormat.WS)) + .factory(ClientFactory.insecure()) + .build(); + future = webSocketClient.connect("/chat"); + } + final WebSocketSession webSocketSession = future.join(); + final ServiceRequestContext sctx = server.requestContextCaptor().take(); + final RequestHeaders headers = sctx.log().ensureAvailable(RequestLogProperty.REQUEST_HEADERS) + .requestHeaders(); + assertThat(headers.get(HttpHeaderNames.ORIGIN)).isEqualTo( + protocol.isHttps() ? server.httpsUri().toString() : server.httpUri().toString()); + + final WebSocketWriter outbound = webSocketSession.outbound(); + outbound.write(WebSocketFrame.ofText("hello")); + + final WebSocketInboundHandler inboundHandler = new WebSocketInboundHandler( + webSocketSession.inbound(), protocol); + + WebSocketFrame frame = inboundHandler.inboundQueue().take(); + assertThat(frame).isEqualTo(WebSocketFrame.ofText("hello")); + + frame = inboundHandler.inboundQueue().poll(1, TimeUnit.SECONDS); + assertThat(frame).isNull(); + + outbound.write(WebSocketFrame.ofText("armeria")); + frame = inboundHandler.inboundQueue().take(); + assertThat(frame).isEqualTo(WebSocketFrame.ofText("armeria")); + + outbound.close(WebSocketCloseStatus.NORMAL_CLOSURE); + frame = inboundHandler.inboundQueue().take(); + assertThat(frame).isEqualTo(WebSocketFrame.ofClose(WebSocketCloseStatus.NORMAL_CLOSURE)); + inboundHandler.completionFuture().join(); + await().until(outbound::isComplete); + } + + static final class WebSocketInboundHandler { + + private final ArrayBlockingQueue inboundQueue = new ArrayBlockingQueue<>(4); + private final CompletableFuture completionFuture = new CompletableFuture<>(); + + WebSocketInboundHandler(WebSocket inbound, SessionProtocol protocol) { + inbound.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(WebSocketFrame webSocketFrame) { + inboundQueue.add(webSocketFrame); + } + + @Override + public void onError(Throwable t) { + if (protocol.isExplicitHttp1()) { + // After receiving a close frame, ClosedSessionException can be raised for HTTP/1.1 + // before onComplete is called. + assertThat(t).isExactlyInstanceOf(ClosedSessionException.class); + } + completionFuture.complete(null); + } + + @Override + public void onComplete() { + completionFuture.complete(null); + } + }); + } + + ArrayBlockingQueue inboundQueue() { + return inboundQueue; + } + + CompletableFuture completionFuture() { + return completionFuture; + } + } + + static final class WebSocketServiceEchoHandler implements WebSocketServiceHandler { + + @Override + public WebSocket handle(ServiceRequestContext ctx, WebSocket in) { + final WebSocketWriter writer = WebSocket.streaming(); + in.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(WebSocketFrame webSocketFrame) { + if (webSocketFrame.type() != WebSocketFrameType.PING && + webSocketFrame.type() != WebSocketFrameType.PONG) { + writer.write(webSocketFrame); + } + } + + @Override + public void onError(Throwable t) { + writer.close(t); + } + + @Override + public void onComplete() { + writer.close(); + } + }); + return writer; + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameEncoderAndDecoderTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameEncoderAndDecoderTest.java index aecc8ea7278e..409e0ebe5839 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameEncoderAndDecoderTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/common/websocket/WebSocketFrameEncoderAndDecoderTest.java @@ -52,6 +52,7 @@ import com.linecorp.armeria.common.HttpRequestWriter; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpResponseWriter; +import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.common.RequestHeaders; import com.linecorp.armeria.common.websocket.WebSocketCloseStatus; import com.linecorp.armeria.common.websocket.WebSocketFrame; @@ -114,7 +115,7 @@ public void testWebSocketProtocolViolation() throws InterruptedException { final WebSocketFrameEncoder encoder = WebSocketFrameEncoder.of(true); final HttpRequestWriter requestWriter = HttpRequest.streaming(RequestHeaders.of(HttpMethod.GET, "/")); final WebSocketFrameDecoder decoder = - new WebSocketFrameDecoder(ctx, maxPayloadLength, false, true); + new TestWebSocketFrameDecoder(ctx, maxPayloadLength, false, true); final CompletableFuture whenComplete = new CompletableFuture<>(); requestWriter.decode(decoder, ctx.alloc()).subscribe(subscriber(whenComplete)); @@ -140,8 +141,8 @@ public void testWebSocketEncodingAndDecoding(boolean maskPayload, boolean allowM final HttpResponseWriter httpResponseWriter = HttpResponse.streaming(); final WebSocketFrameEncoder encoder = WebSocketFrameEncoder.of(maskPayload); final HttpRequestWriter requestWriter = HttpRequest.streaming(RequestHeaders.of(HttpMethod.GET, "/")); - final WebSocketFrameDecoder decoder = new WebSocketFrameDecoder(ctx, 1024 * 1024, allowMaskMismatch, - maskPayload); + final WebSocketFrameDecoder decoder = new TestWebSocketFrameDecoder( + ctx, 1024 * 1024, allowMaskMismatch, maskPayload); requestWriter.decode(decoder, ctx.alloc()).subscribe(subscriber(new CompletableFuture<>())); executeTests(encoder, requestWriter); httpResponseWriter.abort(); @@ -229,4 +230,23 @@ public void onComplete() { } }; } + + private static class TestWebSocketFrameDecoder extends WebSocketFrameDecoder { + + private final boolean expectMaskedFrames; + + TestWebSocketFrameDecoder(RequestContext ctx, int maxFramePayloadLength, + boolean allowMaskMismatch, boolean expectMaskedFrames) { + super(ctx, maxFramePayloadLength, allowMaskMismatch); + this.expectMaskedFrames = expectMaskedFrames; + } + + @Override + protected boolean expectMaskedFrames() { + return expectMaskedFrames; + } + + @Override + protected void onCloseFrameRead() {} + } } diff --git a/core/src/test/java/com/linecorp/armeria/server/websocket/WebSocketServiceConfigTest.java b/core/src/test/java/com/linecorp/armeria/server/websocket/WebSocketServiceConfigTest.java index 7303cfcc7b32..31862ce50c4d 100644 --- a/core/src/test/java/com/linecorp/armeria/server/websocket/WebSocketServiceConfigTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/websocket/WebSocketServiceConfigTest.java @@ -34,8 +34,9 @@ void webSocketServiceDefaultConfigValues() { assertThat(server.config().serviceConfigs()).hasSize(1); ServiceConfig serviceConfig = server.config().serviceConfigs().get(0); assertThat(serviceConfig.requestTimeoutMillis()).isEqualTo( - WebSocketUtil.DEFAULT_REQUEST_TIMEOUT_MILLIS); - assertThat(serviceConfig.maxRequestLength()).isEqualTo(WebSocketUtil.DEFAULT_MAX_REQUEST_LENGTH); + WebSocketUtil.DEFAULT_REQUEST_RESPONSE_TIMEOUT_MILLIS); + assertThat(serviceConfig.maxRequestLength()).isEqualTo( + WebSocketUtil.DEFAULT_MAX_REQUEST_RESPONSE_LENGTH); assertThat(serviceConfig.requestAutoAbortDelayMillis()).isEqualTo( WebSocketUtil.DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS); @@ -48,7 +49,8 @@ void webSocketServiceDefaultConfigValues() { assertThat(server.config().serviceConfigs()).hasSize(1); serviceConfig = server.config().serviceConfigs().get(0); assertThat(serviceConfig.requestTimeoutMillis()).isEqualTo(2000); - assertThat(serviceConfig.maxRequestLength()).isEqualTo(WebSocketUtil.DEFAULT_MAX_REQUEST_LENGTH); + assertThat(serviceConfig.maxRequestLength()).isEqualTo( + WebSocketUtil.DEFAULT_MAX_REQUEST_RESPONSE_LENGTH); assertThat(serviceConfig.requestAutoAbortDelayMillis()).isEqualTo(1000); } } diff --git a/dependencies.toml b/dependencies.toml index cd44cd974b92..fbf0321d9ec1 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -610,6 +610,9 @@ version.ref = "jetty11" [libraries.jetty11-apache-jstl] module = "org.eclipse.jetty:apache-jstl" version.ref = "jetty11-jstl" +[libraries.jetty11-http2-server] +module = "org.eclipse.jetty.http2:http2-server" +version.ref = "jetty11" [libraries.jetty11-server] module = "org.eclipse.jetty:jetty-server" version.ref = "jetty11" @@ -617,6 +620,10 @@ version.ref = "jetty11" [libraries.jetty11-webapp] module = "org.eclipse.jetty:jetty-webapp" version.ref = "jetty11" +# jetty-websocket for testing WebSocket interoperability. +[libraries.jetty11-websocket] +module = "org.eclipse.jetty.websocket:websocket-jakarta-server" +version.ref = "jetty11" [libraries.jetty93-annotations] module = "org.eclipse.jetty:jetty-annotations" diff --git a/it/websocket/build.gradle b/it/websocket/build.gradle index b3c1d0ccb1a6..21c8e4ab41e6 100644 --- a/it/websocket/build.gradle +++ b/it/websocket/build.gradle @@ -1,3 +1,7 @@ dependencies { + testImplementation libs.jakarta.websocket + testImplementation libs.jetty11.http2.server + testImplementation libs.jetty11.websocket testImplementation libs.java.websocket + testImplementation libs.logback14 } diff --git a/it/websocket/src/test/java/com/linecorp/armeria/it/websocket/WebSocketClientItTest.java b/it/websocket/src/test/java/com/linecorp/armeria/it/websocket/WebSocketClientItTest.java new file mode 100644 index 000000000000..7cb329be1fe6 --- /dev/null +++ b/it/websocket/src/test/java/com/linecorp/armeria/it/websocket/WebSocketClientItTest.java @@ -0,0 +1,202 @@ +/* + * Copyright 2023 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.it.websocket; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Queue; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; + +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.websocket.WebSocketClient; +import com.linecorp.armeria.client.websocket.WebSocketSession; +import com.linecorp.armeria.common.ClosedSessionException; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.websocket.CloseWebSocketFrame; +import com.linecorp.armeria.common.websocket.WebSocket; +import com.linecorp.armeria.common.websocket.WebSocketCloseStatus; +import com.linecorp.armeria.common.websocket.WebSocketFrame; +import com.linecorp.armeria.common.websocket.WebSocketWriter; + +import jakarta.websocket.CloseReason; +import jakarta.websocket.OnClose; +import jakarta.websocket.OnError; +import jakarta.websocket.OnMessage; +import jakarta.websocket.OnOpen; +import jakarta.websocket.Session; +import jakarta.websocket.server.ServerEndpoint; +import jakarta.websocket.server.ServerEndpointConfig; + +class WebSocketClientItTest { + + private static final Queue sentMessages = new ArrayBlockingQueue<>(2); + + @BeforeEach + void setUp() { + sentMessages.clear(); + } + + @CsvSource({ "h1c", "h2c" }) + @ParameterizedTest + void webSocketClientIt(String protocol) throws Exception { + final Server server = new Server(); + final ServerConnector connector = createConnector(protocol, server); + server.addConnector(connector); + setupJettyWebSocket(server); + server.start(); + + final WebSocketClient client = WebSocketClient.of( + protocol + "://127.0.0.1:" + connector.getLocalPort()); + final WebSocketSession webSocketSession = client.connect("/chat").join(); + + final WebSocketWriter writer = WebSocket.streaming(); + webSocketSession.setOutbound(writer); + writer.write("Hello, world!"); + writer.write("bye"); + writer.close(); + + final CountDownLatch latch = new CountDownLatch(1); + final List frames = new ArrayList<>(); + webSocketSession.inbound().subscribe( + new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(WebSocketFrame webSocketFrame) { + frames.add(webSocketFrame); + } + + @Override + public void onError(Throwable t) { + // The connection is closed by the server if HTTP/1.1 + assertThat(t).isExactlyInstanceOf(ClosedSessionException.class); + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + latch.await(); + assertThat(frames.size()).isOne(); + final WebSocketFrame frame = frames.get(0); + assertThat(frame).isInstanceOf(CloseWebSocketFrame.class); + assertThat(((CloseWebSocketFrame) frame).status()).isSameAs(WebSocketCloseStatus.NORMAL_CLOSURE); + + assertThat(sentMessages).containsExactly("Hello, world!", "bye"); + server.stop(); + } + + @CsvSource({ + "h1c, foo2, foo1, foo2", + "h1c, bar1, bar2, ", + "h2c, foo2, foo1, foo2", + "h2c, bar1, bar2, " + }) + @ParameterizedTest + void subprotocol(String sessionProtocol, + String subprotocol1, String subprotocol2, @Nullable String selected) throws Exception { + final Server server = new Server(); + final ServerConnector connector = createConnector(sessionProtocol, server); + server.addConnector(connector); + setupJettyWebSocket(server); + server.start(); + + final WebSocketClient client = + WebSocketClient.builder(sessionProtocol + "://127.0.0.1:" + connector.getLocalPort()) + .subprotocols(subprotocol1, subprotocol2) + .build(); + final WebSocketSession session = client.connect("/chat").join(); + if (selected == null) { + assertThat(session.responseHeaders().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL)).isNull(); + } else { + assertThat(session.responseHeaders().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL)) + .isEqualTo(selected); + } + // Abort the session to close the connection. + final WebSocketWriter outbound = WebSocket.streaming(); + outbound.abort(); + session.setOutbound(outbound); + session.inbound().abort(); + + server.stop(); + } + + private static void setupJettyWebSocket(Server server) { + final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + server.setHandler(context); + + JakartaWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { + // Add WebSocket endpoint + wsContainer.addEndpoint( + ServerEndpointConfig.Builder.create(EventSocket.class, "/chat") + .subprotocols(ImmutableList.of("foo", "foo1", "foo2")) + .build()); + }); + } + + private static ServerConnector createConnector(String protocol, Server server) { + if ("h1c".equals(protocol)) { + return new ServerConnector(server); + } + return new ServerConnector(server, new HTTP2ServerConnectionFactory(new HttpConfiguration())); + } + + @ServerEndpoint("/") + public static class EventSocket { + @OnOpen + public void onWebSocketConnect(Session sess) {} + + @OnMessage + public void onWebSocketText(Session sess, String message) throws IOException { + sentMessages.add(message); + if (message.toLowerCase(Locale.US).contains("bye")) { + sess.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Thanks")); + } + } + + @OnClose + public void onWebSocketClose(CloseReason reason) {} + + @OnError + public void onWebSocketError(Throwable cause) {} + } +} diff --git a/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientPreparation.scala b/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientPreparation.scala index a40c44dd8f26..4f068d7e93aa 100644 --- a/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientPreparation.scala +++ b/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientPreparation.scala @@ -190,6 +190,11 @@ final class ScalaRestClientPreparation private[scala] (delegate: RestClientPrepa this } + override def content(content: Publisher[_ <: HttpData]): ScalaRestClientPreparation = { + delegate.content(content) + this + } + override def content( contentType: MediaType, content: Publisher[_ <: HttpData]): ScalaRestClientPreparation = { diff --git a/settings.gradle b/settings.gradle index be2da1855b7b..00d66d1bc91f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -198,7 +198,7 @@ includeWithFlags ':it:spring:webflux-security', 'java17', 'reloca includeWithFlags ':it:thrift-fullcamel', 'java', 'relocate' includeWithFlags ':it:thrift0.9.1', 'java', 'relocate' includeWithFlags ':it:trace-context-leak', 'java', 'relocate' -includeWithFlags ':it:websocket', 'java', 'relocate' +includeWithFlags ':it:websocket', 'java11', 'relocate' includeWithFlags ':jetty9.3', 'java', 'relocate' project(':jetty9.3').projectDir = file('jetty/jetty9.3') includeWithFlags ':testing-internal', 'java', 'relocate' From e0b1f0f25ec9ffbcaa46e6b930db40dbd643976d Mon Sep 17 00:00:00 2001 From: Trustin Lee Date: Tue, 22 Aug 2023 14:32:25 +0900 Subject: [PATCH 07/10] Do not expose Guava's `ToStringHelper` from the public API (#5132) Motivation: It's a bad idea to expose Guava classes from our public API because all Guava classes are shaded into `com.linecorp.armeria.internal.shaded.guava`, exposing the internal classes in the public API. Modifications: - Fixed the broken test in `javadoc/build.gradle`. - Replaced `DynamicEndpointGroup.toStringHelper()` with `toString(Consumer)`. Result: - We do not leak internal classes in our public API anymore. --------- Co-authored-by: Hannam Rhee --- .../client/consul/ConsulEndpointGroup.java | 14 ++++--- .../client/endpoint/DynamicEndpointGroup.java | 39 +++++++++++++------ .../endpoint/PropertiesEndpointGroup.java | 4 +- .../client/endpoint/dns/DnsEndpointGroup.java | 10 ++--- .../client/eureka/EurekaEndpointGroup.java | 4 +- javadoc/build.gradle | 9 ++++- 6 files changed, 50 insertions(+), 30 deletions(-) diff --git a/consul/src/main/java/com/linecorp/armeria/client/consul/ConsulEndpointGroup.java b/consul/src/main/java/com/linecorp/armeria/client/consul/ConsulEndpointGroup.java index 76632bec3b39..75685681d382 100644 --- a/consul/src/main/java/com/linecorp/armeria/client/consul/ConsulEndpointGroup.java +++ b/consul/src/main/java/com/linecorp/armeria/client/consul/ConsulEndpointGroup.java @@ -142,10 +142,14 @@ protected void doCloseAsync(CompletableFuture future) { @Override public String toString() { - return toStringHelper() - .add("serviceName", serviceName) - .add("datacenter", datacenter) - .add("filter", filter) - .toString(); + return toString(buf -> { + buf.append(", serviceName=").append(serviceName); + if (datacenter != null) { + buf.append(", datacenter=").append(datacenter); + } + if (filter != null) { + buf.append(", filter=").append(filter); + } + }); } } diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java index 91765b810f85..e4a89d0e1aba 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java @@ -30,9 +30,8 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; +import java.util.function.Consumer; -import com.google.common.base.MoreObjects; -import com.google.common.base.MoreObjects.ToStringHelper; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; @@ -340,20 +339,36 @@ public final void close() { @Override public String toString() { - return toStringHelper().toString(); + return toString(unused -> {}); } /** - * Returns {@link ToStringHelper} that contains fields information. + * Returns the string representation of this {@link DynamicEndpointGroup}. Specify a {@link Consumer} + * to add more fields to the returned string, e.g. + *
{@code
+     * > @Override
+     * > public String toString() {
+     * >     return toString(buf -> {
+     * >         buf.append(", foo=").append(foo);
+     * >         buf.append(", bar=").append(bar);
+     * >     });
+     * > }
+     * }
+ * + * @param builderMutator the {@link Consumer} that appends the additional fields into the given + * {@link StringBuilder}. */ - protected ToStringHelper toStringHelper() { - return MoreObjects.toStringHelper(this) - .omitNullValues() - .add("selectionStrategy", selectionStrategy.getClass()) - .add("allowsEmptyEndpoints", allowEmptyEndpoints) - .add("endpoints", truncate(endpoints, 10)) - .add("numEndpoints", endpoints.size()) - .add("initialized", initialEndpointsFuture.isDone()); + @UnstableApi + protected final String toString(Consumer builderMutator) { + final StringBuilder buf = new StringBuilder(); + buf.append(getClass().getSimpleName()); + buf.append("{selectionStrategy=").append(selectionStrategy.getClass()); + buf.append(", allowsEmptyEndpoints=").append(allowEmptyEndpoints); + buf.append(", initialized=").append(initialEndpointsFuture.isDone()); + buf.append(", numEndpoints=").append(endpoints.size()); + buf.append(", endpoints=").append(truncate(endpoints, 10)); + builderMutator.accept(buf); + return buf.append('}').toString(); } private class InitialEndpointsFuture extends EventLoopCheckingFuture> { diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/PropertiesEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/PropertiesEndpointGroup.java index 0bfa926ef797..40539aa67db7 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/PropertiesEndpointGroup.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/PropertiesEndpointGroup.java @@ -233,8 +233,6 @@ protected void doCloseAsync(CompletableFuture future) { @Override public String toString() { - return toStringHelper() - .add("watchRegisterKey", watchRegisterKey) - .toString(); + return toString(buf -> buf.append(", watchRegisterKey=").append(watchRegisterKey)); } } diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/dns/DnsEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/dns/DnsEndpointGroup.java index 93b271cb2bb1..aeee34feaf76 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/dns/DnsEndpointGroup.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/dns/DnsEndpointGroup.java @@ -242,10 +242,10 @@ final void logDnsResolutionResult(Collection endpoints, int ttl) { @Override public String toString() { - return toStringHelper() - .add("questions", questions) - .add("logPrefix", logPrefix) - .add("attemptsSoFar", attemptsSoFar) - .toString(); + return toString(buf -> { + buf.append(", questions=").append(questions); + buf.append(", logPrefix=").append(logPrefix); + buf.append(", attemptsSoFar=").append(attemptsSoFar); + }); } } diff --git a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java index 216f8ce291d4..d615d758da18 100644 --- a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java +++ b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java @@ -419,8 +419,6 @@ private static Endpoint endpoint(InstanceInfo instanceInfo, boolean secureVip) { @Override public String toString() { - return toStringHelper() - .add("requestHeaders", requestHeaders) - .toString(); + return toString(buf -> buf.append(", requestHeaders=").append(requestHeaders)); } } diff --git a/javadoc/build.gradle b/javadoc/build.gradle index 2cf1394b86ce..ff579f48f28e 100644 --- a/javadoc/build.gradle +++ b/javadoc/build.gradle @@ -31,8 +31,13 @@ task checkJavadoc( 'nested.classes.inherited.from.class.' ] def allowedListPrefixes = ['java.', 'javax.'] - def disallowedListPrefixes = [ 'com.linecorp.armeria.internal.' ] + - rootProject.ext.relocations.collect { it[1] + '.' } + def disallowedListPrefixes = ['com.linecorp.armeria.internal.'] + disallowedListPrefixes.addAll(rootProject.ext.relocations.collect { + def packageName = it["from"] + assert packageName != null + packageName + '.' + }) + def errors = [] reportFile.parentFile.mkdirs() From a50ddd30f85d5d4eae019a5ba4ef04c24591c4c6 Mon Sep 17 00:00:00 2001 From: Trustin Lee Date: Tue, 22 Aug 2023 15:37:49 +0900 Subject: [PATCH 08/10] Add more factory methods to `PrometheusExpositionService` (#5134) Motivation: It is very common for users to specify `CollectorRegistry.defaultRegistry` when creating a `PrometheusExpositionService` or its builder. Modifications: - Added two shortcut methods: - `of()` -> `of(CollectorRegistry.defaultRegistry)` - `builder()` -> `builder(CollectorRegistry.defaultRegistry)` Result: - Much more convenient and less verbose when creating a `PrometheusExpositionService` --- .../metric/PrometheusExpositionService.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/linecorp/armeria/server/metric/PrometheusExpositionService.java b/core/src/main/java/com/linecorp/armeria/server/metric/PrometheusExpositionService.java index e6afdc782ca0..311acd838412 100644 --- a/core/src/main/java/com/linecorp/armeria/server/metric/PrometheusExpositionService.java +++ b/core/src/main/java/com/linecorp/armeria/server/metric/PrometheusExpositionService.java @@ -13,7 +13,6 @@ * License for the specific language governing permissions and limitations * under the License. */ - package com.linecorp.armeria.server.metric; import static java.util.Objects.requireNonNull; @@ -46,6 +45,14 @@ */ public final class PrometheusExpositionService extends AbstractHttpService implements TransientHttpService { + /** + * Returns a new {@link PrometheusExpositionService} that exposes Prometheus metrics from + * {@link CollectorRegistry#defaultRegistry}. + */ + public static PrometheusExpositionService of() { + return of(CollectorRegistry.defaultRegistry); + } + /** * Returns a new {@link PrometheusExpositionService} that exposes Prometheus metrics from the specified * {@link CollectorRegistry}. @@ -54,6 +61,14 @@ public static PrometheusExpositionService of(CollectorRegistry collectorRegistry return new PrometheusExpositionService(collectorRegistry, Flags.transientServiceOptions()); } + /** + * Returns a new {@link PrometheusExpositionServiceBuilder} created with + * {@link CollectorRegistry#defaultRegistry}. + */ + public static PrometheusExpositionServiceBuilder builder() { + return builder(CollectorRegistry.defaultRegistry); + } + /** * Returns a new {@link PrometheusExpositionServiceBuilder} created with the specified * {@link CollectorRegistry}. From cdcb18e1595b57b3727174d5467340fc3e4ddc90 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Tue, 22 Aug 2023 15:45:52 +0900 Subject: [PATCH 09/10] Add release notes for 1.25.0 (#5114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ![FireShot Capture 010 - 1 25 0 release notes — Armeria release notes - localhost](https://github.com/line/armeria/assets/8510579/ce93f759-0f8d-4702-a959-34d1ecd9add9) --------- Co-authored-by: minux Co-authored-by: Trustin Lee Co-authored-by: Trustin Lee --- site/src/pages/release-notes/1.25.0.mdx | 162 ++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 site/src/pages/release-notes/1.25.0.mdx diff --git a/site/src/pages/release-notes/1.25.0.mdx b/site/src/pages/release-notes/1.25.0.mdx new file mode 100644 index 000000000000..f82dd1217c23 --- /dev/null +++ b/site/src/pages/release-notes/1.25.0.mdx @@ -0,0 +1,162 @@ +--- +date: 2023-08-22 +--- + +## 🌟 New features + +- **GraalVM Support**: Armeria now provides [GraalVM](https://www.graalvm.org/) + [reachability metadata](https://www.graalvm.org/latest/reference-manual/native-image/metadata/) to easily build + [GraalVM](https://www.graalvm.org/) native images. #5005 +- **Micrometer Observation Support**: Support for [Micrometer Observation](https://micrometer.io/docs/observation) is added. + Refer to or for details on how to integrate with Armeria. #4659 #4980 + ```java + ObservationRegistry observationRegistry = ... + WebClient.builder() + .decorator(ObservationClient.newDecorator(observationRegistry)) + ... + Server.builder() + .decorator(ObservationService.newDecorator(observationRegistry)) + ... + ``` +- **WebSocket Client Support**: You can now send and receive data over [WebSocket](https://en.wikipedia.org/wiki/WebSocket) + using . #4972 + ```java + WebSocketClient client = WebSocketClient.of("ws://..."); + client.connect("/").thenAccept(webSocketSession -> { + WebSocketWriter writer = WebSocket.streaming(); + webSocketSessions.setOutbound(writer); + outbound.write("Hello!"); + + Subscriber subscriber = new Subscriber() { + ... + } + webSocketSessions.inbound().subscribe(subscriber); + }); + ``` +- **Implement gRPC Richer Error Model More Easily**: You can now easily use gRPC + [Richer Error Model](https://grpc.io/docs/guides/error/#richer-error-model) via . #4614 #4986 + ```java + GoogleGrpcStatusFunction statusFunction = (ctx, throwable, metadata) -> { + if (throwable instanceof MyException) { + return com.google.rpc.Status.newBuilder() + .setCode(Code.UNAUTHENTICATED.getNumber()) + .addDetails(detail(throwable)) + .build(); + } + ... + }; + Server.builder().service( + GrpcService.builder() + .exceptionMapping(statusFunction)) + ``` +- **Set HTTP Trailers Easily** You can now easily set trailers to be sent after the data stream using + or + . #3959 #4727 +- **New API for Multipart Headers**: You can now retrieve headers from a multipart request in an annotated service + using . #5106 +- **Access RequestLogProperty Values More Easily**: + has been introduced, which allows users to access a immediately if available. #4956 #4966 +- **Keep an Idle Connection Alive on PING**: The `keepAliveOnPing` option has been introduced. Enabling this option will keep + an idle connection alive when an HTTP/2 PING frame or `OPTIONS * HTTP/1.1` is received. The option can be configured + by or . #4794 #4806 +- **Create a StreamMessage from Future**: You can now easily create a from a `CompletionStage` + using )>. #4995 +- **More Shortcuts for PrometheusExpositionService**: You can now create a without + specifying the default `CollectorRegistry` explicitly. #5134 + +## 📈 Improvements + +- The number of event loops is equal to the number of cores by default when `io_uring` is used as the transport type. #5089 +- You can now customize error responses when a service for a request is not found + using . #4996 +- Redirection for a trailing slash is done correctly even if a reverse proxy rewrites the path. #4994 +- now tries to guess the correct route behind a reverse proxy. #4987 +- The `RetentionPolicy` of annotation is now `CLASS` so that + bytecode analysis tools can detect the declaration and usage of unstable APIs. #5131 + +## 🛠️ Bug fixes + +- now returns an `INTERNAL` error code if an error occurs while serializing gRPC metadata. #4625 #4686 +- now allows zero TTL for resolved DNS records. #5119 +- Armeria's DNS resolver doesn't cache a DNS whose query was timed out. #5117 +- Fixed a bug where headers could be written twice if `Content-Length` was exceeded during HTTP/2 cleartext upgrade. #5113 +- and now return + correct values when using domain sockets in abstract namespace. #5096 +- `armeria-logback12`, `armeria-logback13`, and `armeria-logback14` have been introduced for better + compatibility with [Logback](https://logback.qos.ch/). #5045 #5079 #5078 #5077 +- You can now use either an inline debug form or a modal debug form when using . #5072 +- When using Spring integrations, even if `internal-services.port` and `management.server.port` + are set to the same value internal services are bound to the port only once. #4796 #5022 +- Exceptions that occurred during a TLS handshake are properly propagated to users. #4950 +- now respects the `charset` attribute in the + `Content-Type` header if available. #4931 #4948 +- Routes with dynamic predicates are not incorrectly cached anymore. #4927 #4934 + +## 📃 Documentation + +- A new page has been added which describes how to integrate Armeria with Spring Boot. #4670 #4957 +- Documentation on how work in Armeria has been added. #4870 +- A new example on how to use [krotoDC](https://github.com/mscheong01/krotoDC) with Armeria has been added. #5092 + +## ☢️ Breaking changes + +- The `toStringHelper()` method in has been replaced + with `toString(Consumer)` to avoid exposing an internal API in the public API. #5132 + +## 🏚️ Deprecations + +- and its variants methods are deprecated. #5075 + - Use and its variants instead. + +## ⛓ Dependencies + +- gRPC-Java 1.56.0 → 1.57.2 +- GraphQL Kotlin 6.5.2 → 6.5.3 +- Guava 32.0.1-jre → 32.1.2-jre +- Jakarta Websocket 2.1.0 → 2.1.1 +- Kafka client 3.4.0 → 3.4.1 +- Kotlin 1.8.22 → 1.9.0 +- Kotlin Coroutine 1.7.1 → 1.7.3 +- Logback 1.4.7 → 1.4.11 +- Micrometer 1.11.1 → 1.11.3 +- Netty 4.1.94.Final → 4.1.96.Final +- Protobuf 3.22.3 → 3.24.0 +- Reactor 3.5.7 → 3.5.8 +- Resilience4j 2.0.2 → 2.1.0 +- Resteasy 5.0.5.Final → 5.0.7.Final +- Sangria 4.0.0 → 4.0.1 +- scala-collection-compat 2.10.0 → 2.11.0 +- Spring 6.0.9 → 6.0.11 +- Spring Boot 2.7.12 → 2.7.14, 3.1.0 → 3.1.1 +- Tomcat 10.1.10 → 10.1.12 + +## 🙇 Thank you + + From 12df7bdfba26a6827e479ac90a8f9a10e6093382 Mon Sep 17 00:00:00 2001 From: Hannam Rhee Date: Tue, 22 Aug 2023 17:39:52 +0900 Subject: [PATCH 10/10] Update the project version to 1.26.0-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 73cf970bceb8..30ff18fe70c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=com.linecorp.armeria -version=1.24.3-SNAPSHOT +version=1.26.0-SNAPSHOT projectName=Armeria projectUrl=https://armeria.dev/ projectDescription=Asynchronous HTTP/2 RPC/REST client/server library built on top of Java 8, Netty, Thrift and gRPC