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 72de35fef45..6aa86c2ee2b 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 extends HttpObject> 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 extends HttpData> 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 23cd7249311..ecc8fe74685 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 extends HttpObject>
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 extends HttpData> 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 dd7b795f33b..98d918906aa 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 aab05a2c4e8..d1fd409e0b7 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 extends HttpObject> publisher) {
- return new PublisherBasedHttpResponse(new PrependingPublisher<>(headers, publisher));
+ return new PublisherBasedHttpResponse(new SurroundingPublisher<>(headers, publisher, null));
+ }
+
+ static PublisherBasedHttpResponse from(ResponseHeaders headers,
+ Publisher extends HttpData> publisher,
+ HttpHeaders trailers) {
+ if (trailers.isEmpty()) {
+ return from(headers, publisher);
+ }
+ return new PublisherBasedHttpResponse(new SurroundingPublisher<>(headers, publisher, trailers));
}
PublisherBasedHttpResponse(Publisher extends HttpObject> 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 4abd85bfc0e..00000000000
--- 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 extends T> rest;
-
- public PrependingPublisher(T first, Publisher extends T> rest) {
- this.first = first;
- this.rest = rest;
- }
-
- @Override
- public void subscribe(Subscriber super T> 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 extends T> rest;
- private Subscriber super T> downstream;
- @Nullable
- private volatile Subscription upstream;
- private volatile long demand;
- private boolean firstSent;
- private boolean subscribed;
- private volatile boolean cancelled;
-
- RestSubscriber(T first, Publisher extends T> rest, Subscriber super T> 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 00000000000..8d003f69107
--- /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 extends T> 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 super T> 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 super T> 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 super T> 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 super T> 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 4c7eedfeea9..1bcd166db74 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 3f73de8e279..9269b986dfe 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