Skip to content

Commit 3d63cbf

Browse files
committed
Introduce JettyClientHttpRequestFactory
This commit introduces an implementation of ClientHttpRequestFactory based on Jetty's HttpClient. Closes gh-30564
1 parent 67f8848 commit 3d63cbf

9 files changed

+356
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http.client;
18+
19+
import java.io.IOException;
20+
import java.io.OutputStream;
21+
import java.net.URI;
22+
import java.time.Duration;
23+
import java.util.concurrent.ExecutionException;
24+
import java.util.concurrent.TimeUnit;
25+
import java.util.concurrent.TimeoutException;
26+
27+
import org.eclipse.jetty.client.api.Request;
28+
import org.eclipse.jetty.client.api.Response;
29+
import org.eclipse.jetty.client.util.InputStreamResponseListener;
30+
import org.eclipse.jetty.client.util.OutputStreamRequestContent;
31+
32+
import org.springframework.http.HttpHeaders;
33+
import org.springframework.http.HttpMethod;
34+
import org.springframework.lang.Nullable;
35+
import org.springframework.util.StreamUtils;
36+
37+
/**
38+
* {@link ClientHttpRequest} implementation based on Jetty's
39+
* {@link org.eclipse.jetty.client.HttpClient}.
40+
*
41+
* @author Arjen Poutsma
42+
* @since 6.1
43+
* @see JettyClientHttpRequestFactory
44+
*/
45+
class JettyClientHttpRequest extends AbstractStreamingClientHttpRequest {
46+
47+
private final Request request;
48+
49+
private final Duration timeOut;
50+
51+
52+
public JettyClientHttpRequest(Request request, Duration timeOut) {
53+
this.request = request;
54+
this.timeOut = timeOut;
55+
}
56+
57+
@Override
58+
public HttpMethod getMethod() {
59+
return HttpMethod.valueOf(this.request.getMethod());
60+
}
61+
62+
@Override
63+
public URI getURI() {
64+
return this.request.getURI();
65+
}
66+
67+
@Override
68+
protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException {
69+
if (!headers.isEmpty()) {
70+
this.request.headers(httpFields -> {
71+
headers.forEach((headerName, headerValues) -> {
72+
for (String headerValue : headerValues) {
73+
httpFields.add(headerName, headerValue);
74+
}
75+
});
76+
});
77+
}
78+
String contentType = null;
79+
if (headers.getContentType() != null) {
80+
contentType = headers.getContentType().toString();
81+
}
82+
try {
83+
InputStreamResponseListener responseListener = new InputStreamResponseListener();
84+
if (body != null) {
85+
OutputStreamRequestContent requestContent = new OutputStreamRequestContent(contentType);
86+
this.request.body(requestContent)
87+
.send(responseListener);
88+
try (OutputStream outputStream = requestContent.getOutputStream()) {
89+
body.writeTo(StreamUtils.nonClosing(outputStream));
90+
}
91+
}
92+
else {
93+
this.request.send(responseListener);
94+
}
95+
Response response = responseListener.get(TimeUnit.MILLISECONDS.convert(this.timeOut), TimeUnit.MILLISECONDS);
96+
return new JettyClientHttpResponse(response, responseListener.getInputStream());
97+
}
98+
catch (InterruptedException | TimeoutException | ExecutionException ex) {
99+
throw new IOException("Could not send request: " + ex.getMessage(), ex);
100+
}
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http.client;
18+
19+
import java.io.IOException;
20+
import java.net.URI;
21+
import java.time.Duration;
22+
23+
import org.eclipse.jetty.client.HttpClient;
24+
import org.eclipse.jetty.client.api.Request;
25+
26+
import org.springframework.beans.factory.DisposableBean;
27+
import org.springframework.beans.factory.InitializingBean;
28+
import org.springframework.http.HttpMethod;
29+
import org.springframework.util.Assert;
30+
31+
/**
32+
* {@link ClientHttpRequestFactory} implementation based on Jetty's {@link HttpClient}.
33+
*
34+
* @author Arjen Poutsma
35+
* @since 6.1
36+
* @see <a href="https://www.eclipse.org/jetty/documentation/jetty-11/programming-guide/index.html#pg-client-http">Jetty HttpClient</a>
37+
*/
38+
public class JettyClientHttpRequestFactory implements ClientHttpRequestFactory, InitializingBean, DisposableBean {
39+
40+
private final HttpClient httpClient;
41+
42+
private final boolean defaultClient;
43+
44+
private Duration timeOut = Duration.ofSeconds(1);
45+
46+
47+
/**
48+
* Default constructor that creates a new instance of {@link HttpClient}.
49+
*/
50+
public JettyClientHttpRequestFactory() {
51+
this(new HttpClient(), true);
52+
}
53+
54+
/**
55+
* Constructor that takes a customized {@code HttpClient} instance.
56+
* @param httpClient the
57+
*/
58+
public JettyClientHttpRequestFactory(HttpClient httpClient) {
59+
this(httpClient, false);
60+
}
61+
62+
private JettyClientHttpRequestFactory(HttpClient httpClient, boolean defaultClient) {
63+
this.httpClient = httpClient;
64+
this.defaultClient = defaultClient;
65+
}
66+
67+
68+
/**
69+
* Sets the maximum time to wait until all headers have been received.
70+
* The default value is 1 second.
71+
*/
72+
public void setTimeOut(Duration timeOut) {
73+
Assert.notNull(timeOut, "TimeOut must not be null");
74+
Assert.isTrue(!timeOut.isNegative(), "TimeOut must not be negative");
75+
this.timeOut = timeOut;
76+
}
77+
78+
@Override
79+
public void afterPropertiesSet() throws Exception {
80+
startHttpClient();
81+
}
82+
83+
private void startHttpClient() throws Exception {
84+
if (!this.httpClient.isStarted()) {
85+
this.httpClient.start();
86+
}
87+
}
88+
89+
@Override
90+
public void destroy() throws Exception {
91+
if (this.defaultClient) {
92+
if (!this.httpClient.isStopped()) {
93+
this.httpClient.stop();
94+
}
95+
}
96+
}
97+
98+
@Override
99+
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException {
100+
try {
101+
startHttpClient();
102+
}
103+
catch (Exception ex) {
104+
throw new IOException("Could not start HttpClient: " + ex.getMessage(), ex);
105+
}
106+
107+
Request request = this.httpClient.newRequest(uri).method(httpMethod.name());
108+
return new JettyClientHttpRequest(request, this.timeOut);
109+
}
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http.client;
18+
19+
import java.io.IOException;
20+
import java.io.InputStream;
21+
22+
import org.eclipse.jetty.client.api.Response;
23+
24+
import org.springframework.http.HttpHeaders;
25+
import org.springframework.http.HttpStatusCode;
26+
import org.springframework.util.MultiValueMap;
27+
28+
/**
29+
* {@link ClientHttpResponse} implementation based on based on Jetty's
30+
* {@link org.eclipse.jetty.client.HttpClient}.
31+
*
32+
* @author Arjen Poutsma
33+
* @since 6.1
34+
*/
35+
class JettyClientHttpResponse implements ClientHttpResponse {
36+
37+
private final Response response;
38+
39+
private final InputStream body;
40+
41+
private final HttpHeaders headers;
42+
43+
44+
public JettyClientHttpResponse(Response response, InputStream inputStream) {
45+
this.response = response;
46+
this.body = inputStream;
47+
48+
MultiValueMap<String, String> headers = new JettyHeadersAdapter(response.getHeaders());
49+
this.headers = HttpHeaders.readOnlyHttpHeaders(headers);
50+
}
51+
52+
53+
@Override
54+
public HttpStatusCode getStatusCode() throws IOException {
55+
return HttpStatusCode.valueOf(this.response.getStatus());
56+
}
57+
58+
@Override
59+
public String getStatusText() throws IOException {
60+
return this.response.getReason();
61+
}
62+
63+
@Override
64+
public HttpHeaders getHeaders() {
65+
return this.headers;
66+
}
67+
68+
@Override
69+
public InputStream getBody() throws IOException {
70+
return this.body;
71+
}
72+
73+
@Override
74+
public void close() {
75+
try {
76+
this.body.close();
77+
}
78+
catch (IOException ignored) {
79+
}
80+
}
81+
}

Diff for: spring-web/src/main/java/org/springframework/http/client/reactive/JettyHeadersAdapter.java renamed to spring-web/src/main/java/org/springframework/http/client/JettyHeadersAdapter.java

+11-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
package org.springframework.http.client.reactive;
17+
package org.springframework.http.client;
1818

1919
import java.util.AbstractSet;
2020
import java.util.Collection;
@@ -28,6 +28,7 @@
2828

2929
import org.springframework.http.HttpHeaders;
3030
import org.springframework.lang.Nullable;
31+
import org.springframework.util.Assert;
3132
import org.springframework.util.CollectionUtils;
3233
import org.springframework.util.MultiValueMap;
3334

@@ -41,13 +42,20 @@
4142
* @author Sam Brannen
4243
* @since 5.3
4344
*/
44-
class JettyHeadersAdapter implements MultiValueMap<String, String> {
45+
public final class JettyHeadersAdapter implements MultiValueMap<String, String> {
4546

4647
private final HttpFields headers;
4748

4849
private static final String IMMUTABLE_HEADER_ERROR = "Immutable headers";
4950

50-
JettyHeadersAdapter(HttpFields headers) {
51+
52+
/**
53+
* Creates a new {@code JettyHeadersAdapter} based on the given
54+
* {@code HttpFields} instance.
55+
* @param headers the {@code HttpFields} to base this adapter on
56+
*/
57+
public JettyHeadersAdapter(HttpFields headers) {
58+
Assert.notNull(headers, "Headers must not be null");
5159
this.headers = headers;
5260
}
5361

Diff for: spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpRequest.java

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.http.HttpHeaders;
3838
import org.springframework.http.HttpMethod;
3939
import org.springframework.http.MediaType;
40+
import org.springframework.http.client.JettyHeadersAdapter;
4041

4142
/**
4243
* {@link ClientHttpRequest} implementation for the Jetty ReactiveStreams HTTP client.

Diff for: spring-web/src/main/java/org/springframework/http/client/reactive/JettyClientHttpResponse.java

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.http.HttpHeaders;
3030
import org.springframework.http.HttpStatusCode;
3131
import org.springframework.http.ResponseCookie;
32+
import org.springframework.http.client.JettyHeadersAdapter;
3233
import org.springframework.lang.Nullable;
3334
import org.springframework.util.CollectionUtils;
3435
import org.springframework.util.LinkedMultiValueMap;

Diff for: spring-web/src/test/java/org/springframework/http/client/AbstractHttpRequestFactoryTests.java

+7-8
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,19 @@ void multipleWrites() throws Exception {
111111
ClientHttpRequest request = factory.createRequest(URI.create(baseUrl + "/echo"), HttpMethod.POST);
112112

113113
final byte[] body = "Hello World".getBytes(StandardCharsets.UTF_8);
114+
request.getHeaders().setContentLength(body.length);
114115
if (request instanceof StreamingHttpOutputMessage streamingRequest) {
115-
streamingRequest.setBody(outputStream -> {
116-
StreamUtils.copy(body, outputStream);
117-
outputStream.flush();
118-
outputStream.close();
119-
});
116+
streamingRequest.setBody(outputStream -> StreamUtils.copy(body, outputStream));
120117
}
121118
else {
122119
StreamUtils.copy(body, request.getBody());
123120
}
124121

125-
request.execute();
126-
assertThatIllegalStateException().isThrownBy(() ->
127-
FileCopyUtils.copy(body, request.getBody()));
122+
try (ClientHttpResponse response = request.execute()) {
123+
assertThatIllegalStateException().isThrownBy(() ->
124+
FileCopyUtils.copy(body, request.getBody()));
125+
assertThat(response.getStatusCode()).as("Invalid status code").isEqualTo(HttpStatus.OK);
126+
}
128127
}
129128

130129
@Test

0 commit comments

Comments
 (0)