Polishing external contribution

- Removed duplicate Client in types names.
- Removed buffering in favor of OutputStream to
  Flow.Publisher<ByteBuffer> bridge.
- Made request and types package private.
- Various other small improvements.

Closes gh-30478
This commit is contained in:
Arjen Poutsma 2023-06-28 12:22:36 +02:00
parent 2ca8dd2faa
commit 0033eb4ed6
9 changed files with 851 additions and 189 deletions

View File

@ -1,92 +0,0 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
/**
* {@link ClientHttpRequest} implementation based on the Java {@code HttpClient}.
*
* @author Marten Deinum
* @since 6.1
*/
public class JdkClientClientHttpRequest extends AbstractBufferingClientHttpRequest {
/*
* The JDK HttpRequest doesn't allow all headers to be set. The named headers are taken from the default
* implementation for HttpRequest.
*/
private static final List<String> DISALLOWED_HEADERS =
List.of("connection", "content-length", "expect", "host", "upgrade");
private final HttpClient client;
private final URI uri;
private final HttpMethod method;
public JdkClientClientHttpRequest(HttpClient client, URI uri, HttpMethod method) {
this.client = client;
this.uri = uri;
this.method = method;
}
@Override
public HttpMethod getMethod() {
return this.method;
}
@Override
public URI getURI() {
return this.uri;
}
@Override
protected ClientHttpResponse executeInternal(HttpHeaders headers, byte[] content) throws IOException {
HttpRequest.Builder builder = HttpRequest.newBuilder(this.uri)
.method(getMethod().name(), HttpRequest.BodyPublishers.ofByteArray(content));
addHeaders(headers, builder);
HttpRequest request = builder.build();
HttpResponse<InputStream> response;
try {
response = this.client.send(request, HttpResponse.BodyHandlers.ofInputStream());
} catch (InterruptedException ex)
{
Thread.currentThread().interrupt();
throw new IllegalStateException("Request interupted.", ex);
}
return new JdkClientClientHttpResponse(response);
}
private static void addHeaders(HttpHeaders headers, HttpRequest.Builder builder) {
headers.forEach((headerName, headerValues) -> {
if (!DISALLOWED_HEADERS.contains(headerName.toLowerCase())) {
for (String headerValue : headerValues) {
builder.header(headerName, headerValue);
}
}
});
}
}

View File

@ -1,55 +0,0 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import org.springframework.http.HttpMethod;
/**
* {@link ClientHttpRequestFactory} implementation that uses a
* <a href="https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpClient.html">HttpClient</a> to create requests.
*
* @author Marten Deinum
* @since 6.1
*/
public class JdkClientClientHttpRequestFactory implements ClientHttpRequestFactory {
private HttpClient client;
private final boolean defaultClient;
public JdkClientClientHttpRequestFactory() {
this.client = HttpClient.newHttpClient();
this.defaultClient = true;
}
public JdkClientClientHttpRequestFactory(HttpClient client) {
this.client = client;
this.defaultClient = false;
}
@Override
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
return new JdkClientClientHttpRequest(this.client, uri, httpMethod);
}
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.Flow;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.lang.Nullable;
import org.springframework.util.StreamUtils;
/**
* {@link ClientHttpRequest} implementation based the Java {@link HttpClient}.
* Created via the {@link JdkClientHttpRequestFactory}.
*
* @author Marten Deinum
* @author Arjen Poutsma
* @since 6.1
*/
class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest {
/*
* The JDK HttpRequest doesn't allow all headers to be set. The named headers are taken from the default
* implementation for HttpRequest.
*/
private static final List<String> DISALLOWED_HEADERS =
List.of("connection", "content-length", "expect", "host", "upgrade");
private final HttpClient httpClient;
private final HttpMethod method;
private final URI uri;
private final Executor executor;
public JdkClientHttpRequest(HttpClient httpClient, URI uri, HttpMethod method, Executor executor) {
this.httpClient = httpClient;
this.uri = uri;
this.method = method;
this.executor = executor;
}
@Override
public HttpMethod getMethod() {
return this.method;
}
@Override
public URI getURI() {
return this.uri;
}
@Override
protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException {
try {
HttpRequest request = buildRequest(headers, body);
HttpResponse<InputStream> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
return new JdkClientHttpResponse(response);
}
catch (UncheckedIOException ex) {
throw ex.getCause();
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("Could not send request: " + ex.getMessage(), ex);
}
}
private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(this.uri);
headers.forEach((headerName, headerValues) -> {
if (!headerName.equalsIgnoreCase(HttpHeaders.CONTENT_LENGTH)) {
if (!DISALLOWED_HEADERS.contains(headerName.toLowerCase())) {
for (String headerValue : headerValues) {
builder.header(headerName, headerValue);
}
}
}
});
builder.method(this.method.name(), bodyPublisher(headers, body));
return builder.build();
}
private HttpRequest.BodyPublisher bodyPublisher(HttpHeaders headers, @Nullable Body body) {
if (body != null) {
Flow.Publisher<ByteBuffer> outputStreamPublisher = OutputStreamPublisher.create(
outputStream -> body.writeTo(StreamUtils.nonClosing(outputStream)),
this.executor);
long contentLength = headers.getContentLength();
if (contentLength != -1) {
return HttpRequest.BodyPublishers.fromPublisher(outputStreamPublisher, contentLength);
}
else {
return HttpRequest.BodyPublishers.fromPublisher(outputStreamPublisher);
}
}
else {
return HttpRequest.BodyPublishers.noBody();
}
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.util.concurrent.Executor;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.HttpMethod;
import org.springframework.util.Assert;
/**
* {@link ClientHttpRequestFactory} implementation based on the Java
* {@link HttpClient}.
*
* @author Marten Deinum
* @author Arjen Poutsma
* @since 6.1
*/
public class JdkClientHttpRequestFactory implements ClientHttpRequestFactory {
private final HttpClient httpClient;
private final Executor executor;
/**
* Create a new instance of the {@code JdkClientHttpRequestFactory}
* with a default {@link HttpClient}.
*/
public JdkClientHttpRequestFactory() {
this(HttpClient.newHttpClient());
}
/**
* Create a new instance of the {@code JdkClientHttpRequestFactory} based on
* the given {@link HttpClient}.
* @param httpClient the client to base on
*/
public JdkClientHttpRequestFactory(HttpClient httpClient) {
Assert.notNull(httpClient, "HttpClient is required");
this.httpClient = httpClient;
this.executor = httpClient.executor().orElseGet(SimpleAsyncTaskExecutor::new);
}
/**
* Create a new instance of the {@code JdkClientHttpRequestFactory} based on
* the given {@link HttpClient} and {@link Executor}.
* @param httpClient the client to base on
* @param executor the executor to use for blocking write operations
*/
public JdkClientHttpRequestFactory(HttpClient httpClient, Executor executor) {
Assert.notNull(httpClient, "HttpClient is required");
Assert.notNull(executor, "Executor must not be null");
this.httpClient = httpClient;
this.executor = executor;
}
@Override
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor);
}
}

View File

@ -18,81 +18,89 @@ package org.springframework.http.client;
import java.io.IOException;
import java.io.InputStream;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
/**
* {@link ClientHttpResponse} implementation based on the Java {@code HttpClient}.
* {@link ClientHttpResponse} implementation based on the Java {@link HttpClient}.
*
* @author Marten Deinum
* @author Arjen Poutsma
* @since 6.1
*/
public class JdkClientClientHttpResponse implements ClientHttpResponse {
class JdkClientHttpResponse implements ClientHttpResponse {
private final HttpResponse<InputStream> response;
@Nullable
private volatile HttpHeaders headers;
public JdkClientClientHttpResponse(HttpResponse<InputStream> response) {
private final HttpHeaders headers;
private final InputStream body;
public JdkClientHttpResponse(HttpResponse<InputStream> response) {
this.response = response;
this.headers = adaptHeaders(response);
InputStream inputStream = response.body();
this.body = (inputStream != null) ? inputStream : InputStream.nullInputStream();
}
private static HttpHeaders adaptHeaders(HttpResponse<?> response) {
Map<String, List<String>> rawHeaders = response.headers().map();
Map<String, List<String>> map = new LinkedCaseInsensitiveMap<>(rawHeaders.size(), Locale.ENGLISH);
MultiValueMap<String, String> multiValueMap = CollectionUtils.toMultiValueMap(map);
multiValueMap.putAll(rawHeaders);
return HttpHeaders.readOnlyHttpHeaders(multiValueMap);
}
@Override
public HttpStatusCode getStatusCode() throws IOException {
public HttpStatusCode getStatusCode() {
return HttpStatusCode.valueOf(this.response.statusCode());
}
@Override
@Deprecated
public int getRawStatusCode() {
return this.response.statusCode();
}
@Override
public String getStatusText() {
HttpStatus status = HttpStatus.resolve(this.response.statusCode());
return (status != null) ? status.getReasonPhrase() : "";
}
@Override
public InputStream getBody() throws IOException {
InputStream body = this.response.body();
return (body != null ? body : InputStream.nullInputStream());
// HttpResponse does not expose status text
if (getStatusCode() instanceof HttpStatus status) {
return status.getReasonPhrase();
}
else {
return "";
}
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = this.headers;
if (headers == null) {
headers = new HttpHeaders();
for (String headerName : this.response.headers().map().keySet()) {
for (String headerValue : this.response.headers().allValues(headerName)) {
headers.add(headerName, headerValue);
}
}
this.headers = headers;
}
return headers;
return this.headers;
}
@Override
public InputStream getBody() throws IOException {
return this.body;
}
@Override
public void close() {
InputStream body = this.response.body();
try {
try {
StreamUtils.drain(body);
StreamUtils.drain(this.body);
}
finally {
body.close();
this.body.close();
}
}
catch (IOException ex) {
// Ignore exception on close...
catch (IOException ignored) {
}
}
}

View File

@ -0,0 +1,400 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Bridges between {@link OutputStream} and
* {@link Flow.Publisher Flow.Publisher&lt;ByteBuffer&gt;}.
*
* @author Oleh Dokuka
* @author Arjen Poutsma
* @since 6.1
* @see #create(OutputStreamHandler, Executor)
*/
final class OutputStreamPublisher implements Flow.Publisher<ByteBuffer> {
private final OutputStreamHandler outputStreamHandler;
private final Executor executor;
private OutputStreamPublisher(OutputStreamHandler outputStreamHandler, Executor executor) {
this.outputStreamHandler = outputStreamHandler;
this.executor = executor;
}
/**
* Creates a new {@code Publisher<ByteBuffer>} based on bytes written to a
* {@code OutputStream}.
* <ul>
* <li>The parameter {@code outputStreamHandler} is invoked once per
* subscription of the returned {@code Publisher}, when the first
* {@code ByteBuffer} is
* {@linkplain Flow.Subscription#request(long) requested}.</li>
* <li>Each {@link OutputStream#write(byte[], int, int) OutputStream.write()}
* invocation that {@code outputStreamHandler} makes will result in a
* {@linkplain Flow.Subscriber#onNext(Object) published} {@code ByteBuffer}
* if there is {@linkplain Flow.Subscription#request(long) demand}.</li>
* <li>If there is <em>no demand</em>, {@code OutputStream.write()} will block
* until there is.</li>
* <li>If the subscription is {@linkplain Flow.Subscription#cancel() cancelled},
* {@code OutputStream.write()} will throw a {@code IOException}.</li>
* <li>{@linkplain OutputStream#close() Closing} the {@code OutputStream}
* will result in a {@linkplain Flow.Subscriber#onComplete() complete} signal.</li>
* <li>Any {@code IOException}s thrown from {@code outputStreamHandler} will
* be dispatched to the {@linkplain Flow.Subscriber#onError(Throwable) Subscriber}.
* </ul>
* @param outputStreamHandler invoked when the first buffer is requested
* @param executor used to invoke the {@code outputStreamHandler}
* @return a {@code Publisher<ByteBuffer>} based on bytes written by
* {@code outputStreamHandler}
*/
public static Flow.Publisher<ByteBuffer> create(OutputStreamHandler outputStreamHandler, Executor executor) {
Assert.notNull(outputStreamHandler, "OutputStreamHandler must not be null");
Assert.notNull(executor, "Executor must not be null");
return new OutputStreamPublisher(outputStreamHandler, executor);
}
@Override
public void subscribe(Flow.Subscriber<? super ByteBuffer> subscriber) {
Objects.requireNonNull(subscriber, "Subscriber must not be null");
OutputStreamSubscription subscription = new OutputStreamSubscription(subscriber, this.outputStreamHandler);
subscriber.onSubscribe(subscription);
this.executor.execute(subscription::invokeHandler);
}
/**
* Defines the contract for handling the {@code OutputStream} provided by
* the {@code OutputStreamPublisher}.
*/
@FunctionalInterface
public interface OutputStreamHandler {
/**
* Use the given stream for writing.
* <ul>
* <li>If the linked subscription has
* {@linkplain Flow.Subscription#request(long) demand}, any
* {@linkplain OutputStream#write(byte[], int, int) written} bytes
* will be {@linkplain Flow.Subscriber#onNext(Object) published} to the
* {@link Flow.Subscriber Subscriber}.</li>
* <li>If there is no demand, any
* {@link OutputStream#write(byte[], int, int) write()} invocations will
* block until there is demand.</li>
* <li>If the linked subscription is
* {@linkplain Flow.Subscription#cancel() cancelled},
* {@link OutputStream#write(byte[], int, int) write()} invocations will
* result in a {@code IOException}.</li>
* </ul>
* @param outputStream the stream to write to
* @throws IOException any thrown I/O errors will be dispatched to the
* {@linkplain Flow.Subscriber#onError(Throwable) Subscriber}
*/
void handle(OutputStream outputStream) throws IOException;
}
private static final class OutputStreamSubscription extends OutputStream implements Flow.Subscription {
static final Object READY = new Object();
private final Flow.Subscriber<? super ByteBuffer> actual;
private final OutputStreamHandler outputStreamHandler;
private final AtomicLong requested = new AtomicLong();
private final AtomicReference<Object> parkedThreadAtomic = new AtomicReference<>();
@Nullable
private volatile Throwable error;
private long produced;
public OutputStreamSubscription(Flow.Subscriber<? super ByteBuffer> actual,
OutputStreamHandler outputStreamHandler) {
this.actual = actual;
this.outputStreamHandler = outputStreamHandler;
}
@Override
public void write(int b) throws IOException {
checkDemandAndAwaitIfNeeded();
ByteBuffer byteBuffer = ByteBuffer.allocate(1);
byteBuffer.put((byte) b);
byteBuffer.flip();
this.actual.onNext(byteBuffer);
this.produced++;
}
@Override
public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
checkDemandAndAwaitIfNeeded();
ByteBuffer byteBuffer = ByteBuffer.allocate(len);
byteBuffer.put(b, off, len);
byteBuffer.flip();
this.actual.onNext(byteBuffer);
this.produced++;
}
private void checkDemandAndAwaitIfNeeded() throws IOException {
long r = this.requested.get();
if (isTerminated(r) || isCancelled(r)) {
throw new IOException("Subscription has been terminated");
}
long p = this.produced;
if (p == r) {
if (p > 0) {
r = tryProduce(p);
this.produced = 0;
}
while (true) {
if (isTerminated(r) || isCancelled(r)) {
throw new IOException("Subscription has been terminated");
}
if (r != 0) {
return;
}
await();
r = this.requested.get();
}
}
}
private void invokeHandler() {
// assume sync write within try-with-resource block
// use BufferedOutputStream, so that written bytes are buffered
// before publishing as byte buffer
try (OutputStream outputStream = new BufferedOutputStream(this)) {
this.outputStreamHandler.handle(outputStream);
}
catch (IOException ex) {
long previousState = tryTerminate();
if (isCancelled(previousState)) {
return;
}
if (isTerminated(previousState)) {
// failure due to illegal requestN
this.actual.onError(this.error);
return;
}
this.actual.onError(ex);
return;
}
long previousState = tryTerminate();
if (isCancelled(previousState)) {
return;
}
if (isTerminated(previousState)) {
// failure due to illegal requestN
this.actual.onError(this.error);
return;
}
this.actual.onComplete();
}
@Override
public void request(long n) {
if (n <= 0) {
this.error = new IllegalArgumentException("request should be a positive number");
long previousState = tryTerminate();
if (isTerminated(previousState) || isCancelled(previousState)) {
return;
}
if (previousState > 0) {
// error should eventually be observed and propagated
return;
}
// resume parked thread, so it can observe error and propagate it
resume();
return;
}
if (addCap(n) == 0) {
// resume parked thread so it can continue the work
resume();
}
}
@Override
public void cancel() {
long previousState = tryCancel();
if (isCancelled(previousState) || previousState > 0) {
return;
}
// resume parked thread, so it can be unblocked and close all the resources
resume();
}
private void await() {
Thread toUnpark = Thread.currentThread();
while (true) {
Object current = this.parkedThreadAtomic.get();
if (current == READY) {
break;
}
if (current != null && current != toUnpark) {
throw new IllegalStateException("Only one (Virtual)Thread can await!");
}
if (this.parkedThreadAtomic.compareAndSet(null, toUnpark)) {
LockSupport.park();
// we don't just break here because park() can wake up spuriously
// if we got a proper resume, get() == READY and the loop will quit above
}
}
// clear the resume indicator so that the next await call will park without a resume()
this.parkedThreadAtomic.lazySet(null);
}
private void resume() {
if (this.parkedThreadAtomic.get() != READY) {
Object old = this.parkedThreadAtomic.getAndSet(READY);
if (old != READY) {
LockSupport.unpark((Thread)old);
}
}
}
private long tryCancel() {
while (true) {
long r = this.requested.get();
if (isCancelled(r)) {
return r;
}
if (this.requested.compareAndSet(r, Long.MIN_VALUE)) {
return r;
}
}
}
private long tryTerminate() {
while (true) {
long r = this.requested.get();
if (isCancelled(r) || isTerminated(r)) {
return r;
}
if (this.requested.compareAndSet(r, Long.MIN_VALUE | Long.MAX_VALUE)) {
return r;
}
}
}
private long tryProduce(long n) {
while (true) {
long current = this.requested.get();
if (isTerminated(current) || isCancelled(current)) {
return current;
}
if (current == Long.MAX_VALUE) {
return Long.MAX_VALUE;
}
long update = current - n;
if (update < 0L) {
update = 0L;
}
if (this.requested.compareAndSet(current, update)) {
return update;
}
}
}
private long addCap(long n) {
while (true) {
long r = this.requested.get();
if (isTerminated(r) || isCancelled(r) || r == Long.MAX_VALUE) {
return r;
}
long u = addCap(r, n);
if (this.requested.compareAndSet(r, u)) {
return r;
}
}
}
private static boolean isTerminated(long state) {
return state == (Long.MIN_VALUE | Long.MAX_VALUE);
}
private static boolean isCancelled(long state) {
return state == Long.MIN_VALUE;
}
private static long addCap(long a, long b) {
long res = a + b;
if (res < 0L) {
return Long.MAX_VALUE;
}
return res;
}
}
}

View File

@ -23,11 +23,11 @@ import org.springframework.http.HttpMethod;
/**
* @author Marten Deinum
*/
public class JdkClientClientHttpRequestFactoryTests extends AbstractHttpRequestFactoryTests {
public class JdkClientHttpRequestFactoryTests extends AbstractHttpRequestFactoryTests {
@Override
protected ClientHttpRequestFactory createRequestFactory() {
return new JdkClientClientHttpRequestFactory();
return new JdkClientHttpRequestFactory();
}
@Override

View File

@ -0,0 +1,182 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http.client;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.Flow;
import org.junit.jupiter.api.Test;
import org.reactivestreams.FlowAdapters;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
/**
* @author Arjen Poutsma
* @author Oleh Dokuka
*/
class OutputStreamPublisherTests {
private final Executor executor = Executors.newSingleThreadExecutor();
@Test
void basic() {
Flow.Publisher<ByteBuffer> flowPublisher = OutputStreamPublisher.create(outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
writer.write("foo");
writer.write("bar");
writer.write("baz");
}
}, this.executor);
Flux<String> flux = toString(flowPublisher);
StepVerifier.create(flux)
.assertNext(s -> assertThat(s).isEqualTo("foobarbaz"))
.verifyComplete();
}
@Test
void flush() {
Flow.Publisher<ByteBuffer> flowPublisher = OutputStreamPublisher.create(outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
writer.write("foo");
writer.flush();
writer.write("bar");
writer.flush();
writer.write("baz");
writer.flush();
}
}, this.executor);
Flux<String> flux = toString(flowPublisher);
StepVerifier.create(flux)
.assertNext(s -> assertThat(s).isEqualTo("foo"))
.assertNext(s -> assertThat(s).isEqualTo("bar"))
.assertNext(s -> assertThat(s).isEqualTo("baz"))
.verifyComplete();
}
@Test
void cancel() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
Flow.Publisher<ByteBuffer> flowPublisher = OutputStreamPublisher.create(outputStream -> {
try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
assertThatIOException()
.isThrownBy(() -> {
writer.write("foo");
writer.flush();
writer.write("bar");
writer.flush();
})
.withMessage("Subscription has been terminated");
latch.countDown();
}
}, this.executor);
Flux<String> flux = toString(flowPublisher);
StepVerifier.create(flux, 1)
.assertNext(s -> assertThat(s).isEqualTo("foo"))
.thenCancel()
.verify();
latch.await();
}
@Test
void closed() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
Flow.Publisher<ByteBuffer> flowPublisher = OutputStreamPublisher.create(outputStream -> {
Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
writer.write("foo");
writer.close();
assertThatIOException().isThrownBy(() -> writer.write("bar"))
.withMessage("Stream closed");
latch.countDown();
}, this.executor);
Flux<String> flux = toString(flowPublisher);
StepVerifier.create(flux)
.assertNext(s -> assertThat(s).isEqualTo("foo"))
.verifyComplete();
latch.await();
}
@Test
void negativeRequestN() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
Flow.Publisher<ByteBuffer> flowPublisher = OutputStreamPublisher.create(outputStream -> {
try(Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
writer.write("foo");
writer.flush();
writer.write("foo");
writer.flush();
}
finally {
latch.countDown();
}
}, this.executor);
Flow.Subscription[] subscriptions = new Flow.Subscription[1];
Flux<String> flux = toString(a-> flowPublisher.subscribe(new Flow.Subscriber<>() {
@Override
public void onSubscribe(Flow.Subscription subscription) {
subscriptions[0] = subscription;
a.onSubscribe(subscription);
}
@Override
public void onNext(ByteBuffer item) {
a.onNext(item);
}
@Override
public void onError(Throwable throwable) {
a.onError(throwable);
}
@Override
public void onComplete() {
a.onComplete();
}
}));
StepVerifier.create(flux, 1)
.assertNext(s -> assertThat(s).isEqualTo("foo"))
.then(() -> subscriptions[0].request(-1))
.expectErrorMessage("request should be a positive number")
.verify();
latch.await();
}
private static Flux<String> toString(Flow.Publisher<ByteBuffer> flowPublisher) {
return Flux.from(FlowAdapters.toPublisher(flowPublisher))
.map(bb -> StandardCharsets.UTF_8.decode(bb).toString());
}
}

View File

@ -48,6 +48,7 @@ import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@ -91,10 +92,11 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
static Stream<Named<ClientHttpRequestFactory>> clientHttpRequestFactories() {
return Stream.of(
named("JDK", new SimpleClientHttpRequestFactory()),
named("JDK HttpURLConnection", new SimpleClientHttpRequestFactory()),
named("HttpComponents", new HttpComponentsClientHttpRequestFactory()),
named("OkHttp", new OkHttp3ClientHttpRequestFactory()),
named("Jetty", new JettyClientHttpRequestFactory())
named("Jetty", new JettyClientHttpRequestFactory()),
named("JDK HttpClient", new JdkClientHttpRequestFactory())
);
}
@ -225,7 +227,7 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
@ParameterizedRestTemplateTest
void patchForObject(ClientHttpRequestFactory clientHttpRequestFactory) throws Exception {
assumeFalse(clientHttpRequestFactory instanceof SimpleClientHttpRequestFactory,
"JDK client does not support the PATCH method");
"HttpURLConnection does not support the PATCH method");
setUpClient(clientHttpRequestFactory);
@ -254,6 +256,7 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
template.execute(baseUrl + "/status/badrequest", HttpMethod.GET, null, null))
.satisfies(ex -> {
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assumeFalse(clientHttpRequestFactory instanceof JdkClientHttpRequestFactory, "JDK HttpClient does not expose status text");
assertThat(ex.getMessage()).isEqualTo("400 Client Error: [no body]");
});
}