Skip to content

Commit 48c5b6b

Browse files
authored
WebClient optional reason phrase #9646 (#9647)
* WebClient optional reason phrase #9646 Signed-off-by: Daniel Kec <daniel.kec@oracle.com>
1 parent eb7bc8f commit 48c5b6b

File tree

3 files changed

+182
-3
lines changed

3 files changed

+182
-3
lines changed

webclient/http1/src/main/java/io/helidon/webclient/http1/Http1CallChainBase.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2023, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -277,7 +277,7 @@ private static boolean mayHaveEntity(Status responseStatus, ClientResponseHeader
277277
return false;
278278
}
279279
// Why is NOT_MODIFIED_304 not added here too?
280-
if (responseStatus == Status.NO_CONTENT_204) {
280+
if (responseStatus.code() == Status.NO_CONTENT_204.code()) {
281281
return false;
282282
}
283283
if ((

webclient/tests/webclient/pom.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<!--
3-
Copyright (c) 2020, 2024 Oracle and/or its affiliates.
3+
Copyright (c) 2020, 2025 Oracle and/or its affiliates.
44
55
Licensed under the Apache License, Version 2.0 (the "License");
66
you may not use this file except in compliance with the License.
@@ -109,6 +109,11 @@
109109
<artifactId>junit-jupiter-api</artifactId>
110110
<scope>test</scope>
111111
</dependency>
112+
<dependency>
113+
<groupId>org.junit.jupiter</groupId>
114+
<artifactId>junit-jupiter-params</artifactId>
115+
<scope>test</scope>
116+
</dependency>
112117
<dependency>
113118
<groupId>org.hamcrest</groupId>
114119
<artifactId>hamcrest-all</artifactId>
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright (c) 2025 Oracle and/or its affiliates.
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+
* http://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 io.helidon.webclient.tests;
18+
19+
import java.io.ByteArrayOutputStream;
20+
import java.io.IOException;
21+
import java.io.OutputStream;
22+
import java.net.InetSocketAddress;
23+
import java.net.ServerSocket;
24+
import java.nio.ByteBuffer;
25+
import java.nio.charset.StandardCharsets;
26+
import java.time.Duration;
27+
import java.util.Arrays;
28+
import java.util.concurrent.ExecutorService;
29+
import java.util.concurrent.Executors;
30+
import java.util.concurrent.TimeUnit;
31+
import java.util.regex.Matcher;
32+
import java.util.regex.Pattern;
33+
34+
import io.helidon.http.HeaderNames;
35+
import io.helidon.logging.common.LogConfig;
36+
import io.helidon.webclient.api.HttpClientResponse;
37+
import io.helidon.webclient.api.WebClient;
38+
39+
import org.hamcrest.MatcherAssert;
40+
import org.junit.jupiter.api.AfterAll;
41+
import org.junit.jupiter.api.BeforeAll;
42+
import org.junit.jupiter.params.ParameterizedTest;
43+
import org.junit.jupiter.params.provider.ValueSource;
44+
45+
import static java.lang.System.Logger.Level.DEBUG;
46+
import static org.hamcrest.Matchers.is;
47+
import static org.hamcrest.Matchers.startsWith;
48+
import static org.junit.jupiter.api.Assertions.assertFalse;
49+
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
50+
51+
/**
52+
* Reason phrase is optional, but space behind status is mandatory.
53+
* <pre>{@code
54+
* Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF
55+
* Reason-Phrase = *<TEXT, excluding CR, LF>
56+
* CR = <US-ASCII CR, carriage return (13)>
57+
* LF = <US-ASCII LF, linefeed (10)>
58+
* SP = <US-ASCII SP, space (32)>
59+
* }</pre>
60+
*/
61+
class ReasonPhraseTest {
62+
63+
private static final System.Logger LOGGER = System.getLogger(ReasonPhraseTest.class.getName());
64+
private static final String CUSTOM_STATUS_LINE = "Custom-Status-Line";
65+
private static final Pattern CUSTOM_STATUS_LINE_HEADER_PATTERN = Pattern.compile("Custom-Status-Line: ([^\r]*)");
66+
private static final String CRLF = "\r\n";
67+
private static ServerSocket socket;
68+
private static WebClient client;
69+
private static ExecutorService executor;
70+
71+
@BeforeAll
72+
static void beforeAll() throws IOException {
73+
LogConfig.configureRuntime();
74+
socket = new ServerSocket();
75+
socket.bind(new InetSocketAddress("localhost", 0));
76+
77+
client = WebClient.builder()
78+
.keepAlive(true)
79+
.readTimeout(Duration.ofSeconds(1))
80+
.baseUri("http://localhost:" + socket.getLocalPort())
81+
.build();
82+
83+
executor = Executors.newVirtualThreadPerTaskExecutor();
84+
executor.submit(ReasonPhraseTest::startMockServer);
85+
}
86+
87+
@AfterAll
88+
static void afterAll() throws InterruptedException {
89+
try {
90+
socket.close();
91+
} catch (IOException e) {
92+
throw new RuntimeException(e);
93+
}
94+
executor.shutdownNow();
95+
executor.awaitTermination(10, TimeUnit.SECONDS);
96+
}
97+
98+
@ParameterizedTest
99+
@ValueSource(strings = {
100+
"HTTP/1.1 204 No content",
101+
"HTTP/1.1 204 Custom reason",
102+
"HTTP/1.1 204 "
103+
})
104+
void allowedStatusLines(String statusLine) {
105+
HttpClientResponse res = client
106+
.delete("/test")
107+
.header(HeaderNames.create(CUSTOM_STATUS_LINE), statusLine)
108+
.request();
109+
110+
assertFalse(res.entity().hasEntity());
111+
IllegalStateException e = assertThrowsExactly(IllegalStateException.class,
112+
() -> res.as(String.class));
113+
MatcherAssert.assertThat(e.getMessage(), is("No entity"));
114+
}
115+
116+
@ParameterizedTest
117+
@ValueSource(strings = {
118+
"HTTP/1.1 204"
119+
})
120+
void badStatusLines(String statusLine) {
121+
var e = assertThrowsExactly(IllegalStateException.class,
122+
() -> client
123+
.delete("/test")
124+
.header(HeaderNames.create(CUSTOM_STATUS_LINE), statusLine)
125+
.request());
126+
127+
MatcherAssert.assertThat(e.getMessage(),
128+
startsWith("HTTP Response did not contain HTTP status line. Line: HTTP/1.0 or HTTP/1.1"));
129+
}
130+
131+
private static void startMockServer() {
132+
while (!socket.isClosed()) {
133+
try (var s = socket.accept();
134+
var os = s.getOutputStream();
135+
var is = s.getInputStream()) {
136+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
137+
ByteBuffer bb = ByteBuffer.allocate(1024);
138+
int bytesRecevied;
139+
while ((bytesRecevied = is.read(bb.array())) != -1) {
140+
baos.write(bb.array(), 0, bytesRecevied);
141+
if (is.available() == 0) {
142+
break;
143+
}
144+
}
145+
146+
String requestContent = baos.toString();
147+
148+
// parse out custom status header value
149+
String customStatusLine = Arrays.stream(requestContent.split(CRLF))
150+
.map(CUSTOM_STATUS_LINE_HEADER_PATTERN::matcher)
151+
.filter(Matcher::matches)
152+
.findFirst()
153+
.map(m -> m.group(1))
154+
.orElseThrow();
155+
156+
writeLine(customStatusLine, os);
157+
writeLine("Content-Type: text/plain;charset=UTF-8", os);
158+
writeLine("Keep-Alive: timeout=20", os);
159+
writeLine("Connection: keep-alive", os);
160+
writeLine("", os);
161+
os.flush();
162+
} catch (Exception e) {
163+
LOGGER.log(DEBUG, "Error in mock server.", e);
164+
break;
165+
}
166+
}
167+
}
168+
169+
private static void writeLine(String line, OutputStream os) throws IOException {
170+
os.write(line.getBytes(StandardCharsets.UTF_8));
171+
os.write(CRLF.getBytes(StandardCharsets.UTF_8));
172+
}
173+
174+
}

0 commit comments

Comments
 (0)