Skip to content

Commit f537b24

Browse files
committed
refactor: replace Apache HttpClient with JDK java.net.http.HttpClient
1 parent 901faa3 commit f537b24

4 files changed

Lines changed: 367 additions & 49 deletions

File tree

pom.xml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@
1313
<artifactId>xmlbeans</artifactId>
1414
<version>5.3.0</version>
1515
</dependency>
16-
<dependency>
17-
<groupId>org.apache.httpcomponents</groupId>
18-
<artifactId>httpclient</artifactId>
19-
<version>4.5.14</version>
20-
</dependency>
2116
<dependency>
2217
<groupId>org.bouncycastle</groupId>
2318
<artifactId>bcprov-jdk18on</artifactId>

src/main/java/org/kopi/ebics/client/HttpRequestSender.java

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,16 @@
1919
package org.kopi.ebics.client;
2020

2121
import java.io.IOException;
22-
import java.io.InputStream;
22+
import java.net.Authenticator;
23+
import java.net.InetSocketAddress;
24+
import java.net.PasswordAuthentication;
25+
import java.net.ProxySelector;
26+
import java.net.URI;
27+
import java.net.http.HttpClient;
28+
import java.net.http.HttpRequest;
29+
import java.net.http.HttpResponse;
30+
import java.time.Duration;
2331

24-
import org.apache.http.HttpEntity;
25-
import org.apache.http.HttpHeaders;
26-
import org.apache.http.HttpHost;
27-
import org.apache.http.auth.AuthScope;
28-
import org.apache.http.auth.UsernamePasswordCredentials;
29-
import org.apache.http.client.CredentialsProvider;
30-
import org.apache.http.client.config.RequestConfig;
31-
import org.apache.http.client.entity.EntityBuilder;
32-
import org.apache.http.client.methods.CloseableHttpResponse;
33-
import org.apache.http.client.methods.HttpPost;
34-
import org.apache.http.impl.client.BasicCredentialsProvider;
35-
import org.apache.http.impl.client.CloseableHttpClient;
36-
import org.apache.http.impl.client.HttpClientBuilder;
37-
import org.apache.http.impl.client.ProxyAuthenticationStrategy;
38-
import org.apache.http.util.EntityUtils;
3932
import org.kopi.ebics.interfaces.Configuration;
4033
import org.kopi.ebics.interfaces.ContentFactory;
4134
import org.kopi.ebics.io.ByteArrayContentFactory;
@@ -48,9 +41,12 @@
4841
*/
4942
public class HttpRequestSender {
5043

44+
private static final Duration TIMEOUT = Duration.ofSeconds(300);
45+
private static final String CONTENT_TYPE = "text/xml; charset=ISO-8859-1";
46+
5147
private final EbicsSession session;
5248
private ContentFactory response;
53-
private final CloseableHttpClient httpClient;
49+
private final HttpClient httpClient;
5450

5551
/**
5652
* Constructs a new <code>HttpRequestSender</code> with a given ebics
@@ -63,33 +59,32 @@ public HttpRequestSender(EbicsSession session) {
6359
this.httpClient = createClient();
6460
}
6561

66-
private CloseableHttpClient createClient() {
67-
RequestConfig.Builder configBuilder = RequestConfig.copy(RequestConfig.DEFAULT)
68-
.setSocketTimeout(300_000).setConnectTimeout(300_000);
62+
private HttpClient createClient() {
63+
HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(TIMEOUT);
6964
Configuration conf = session.getConfiguration();
7065
String proxyHost = conf.getProperty("http.proxy.host");
71-
CredentialsProvider credsProvider = null;
7266

7367
if (proxyHost != null && !proxyHost.isEmpty()) {
7468
int proxyPort = Integer.parseInt(conf.getProperty("http.proxy.port").trim());
75-
HttpHost proxy = new HttpHost(proxyHost.trim(), proxyPort);
76-
configBuilder.setProxy(proxy);
69+
builder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost.trim(), proxyPort)));
7770

7871
String user = conf.getProperty("http.proxy.user");
7972
if (user != null && !user.isEmpty()) {
80-
user = user.trim();
73+
String trimmedUser = user.trim();
8174
String pwd = conf.getProperty("http.proxy.password").trim();
82-
credsProvider = new BasicCredentialsProvider();
83-
credsProvider.setCredentials(new AuthScope(proxyHost, proxyPort),
84-
new UsernamePasswordCredentials(user, pwd));
75+
builder.authenticator(new Authenticator() {
76+
@Override
77+
protected PasswordAuthentication getPasswordAuthentication() {
78+
// Only answer proxy challenges — never leak proxy
79+
// credentials to a server-side 401.
80+
if (getRequestorType() != RequestorType.PROXY) {
81+
return null;
82+
}
83+
return new PasswordAuthentication(trimmedUser, pwd.toCharArray());
84+
}
85+
});
8586
}
8687
}
87-
HttpClientBuilder builder = HttpClientBuilder.create()
88-
.setDefaultRequestConfig(configBuilder.build());
89-
if (credsProvider != null) {
90-
builder.setDefaultCredentialsProvider(credsProvider);
91-
builder.setProxyAuthenticationStrategy(new ProxyAuthenticationStrategy());
92-
}
9388
return builder.build();
9489
}
9590

@@ -102,19 +97,28 @@ private CloseableHttpClient createClient() {
10297
* @return the HTTP return code
10398
*/
10499
public final int send(ContentFactory request) throws IOException {
105-
InputStream input = request.getContent();
106-
HttpPost method = new HttpPost(
107-
session.getUser().getPartner().getBank().getURL().toString());
108-
109-
HttpEntity requestEntity = EntityBuilder.create().setStream(input).build();
110-
method.setEntity(requestEntity);
111-
method.setHeader(HttpHeaders.CONTENT_TYPE, "text/xml; charset=ISO-8859-1");
100+
URI uri = URI.create(session.getUser().getPartner().getBank().getURL().toString());
101+
HttpRequest httpRequest = HttpRequest.newBuilder(uri)
102+
.timeout(TIMEOUT)
103+
.header("Content-Type", CONTENT_TYPE)
104+
.POST(HttpRequest.BodyPublishers.ofInputStream(() -> {
105+
try {
106+
return request.getContent();
107+
} catch (IOException e) {
108+
throw new RuntimeException(e);
109+
}
110+
}))
111+
.build();
112112

113-
try (CloseableHttpResponse response = httpClient.execute(method)) {
114-
this.response = new ByteArrayContentFactory(
115-
EntityUtils.toByteArray(response.getEntity()));
116-
return response.getStatusLine().getStatusCode();
113+
HttpResponse<byte[]> httpResponse;
114+
try {
115+
httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray());
116+
} catch (InterruptedException e) {
117+
Thread.currentThread().interrupt();
118+
throw new IOException("HTTP request interrupted", e);
117119
}
120+
this.response = new ByteArrayContentFactory(httpResponse.body());
121+
return httpResponse.statusCode();
118122
}
119123

120124
/**
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package org.kopi.ebics.client;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertNotNull;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
9+
import java.io.OutputStream;
10+
import java.net.InetSocketAddress;
11+
import java.net.URL;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.Base64;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.concurrent.atomic.AtomicReference;
17+
18+
import com.sun.net.httpserver.HttpServer;
19+
import org.junit.jupiter.api.AfterEach;
20+
import org.junit.jupiter.api.BeforeEach;
21+
import org.junit.jupiter.api.Test;
22+
import org.kopi.ebics.io.ByteArrayContentFactory;
23+
import org.kopi.ebics.session.EbicsSession;
24+
import org.mockito.Mockito;
25+
26+
/**
27+
* End-to-end test for {@link HttpRequestSender}: spins up an in-process
28+
* {@link HttpServer}, captures what the sender posts, and asserts the
29+
* sender returns the server's response body and status code unmodified.
30+
*
31+
* <p>Written before migrating from Apache HttpClient 4.x to
32+
* {@link java.net.http.HttpClient} so the contract is pinned independently
33+
* of the underlying client.
34+
*/
35+
class HttpRequestSenderTest {
36+
37+
private HttpServer server;
38+
private AtomicReference<String> capturedMethod;
39+
private AtomicReference<String> capturedContentType;
40+
private AtomicReference<byte[]> capturedBody;
41+
private volatile int responseStatus;
42+
private volatile byte[] responseBody;
43+
44+
@BeforeEach
45+
void startServer() throws Exception {
46+
capturedMethod = new AtomicReference<>();
47+
capturedContentType = new AtomicReference<>();
48+
capturedBody = new AtomicReference<>();
49+
responseStatus = 200;
50+
responseBody = "<response/>".getBytes(StandardCharsets.UTF_8);
51+
52+
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
53+
server.createContext("/", exchange -> {
54+
capturedMethod.set(exchange.getRequestMethod());
55+
capturedContentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
56+
capturedBody.set(exchange.getRequestBody().readAllBytes());
57+
exchange.sendResponseHeaders(responseStatus, responseBody.length);
58+
try (OutputStream os = exchange.getResponseBody()) {
59+
os.write(responseBody);
60+
}
61+
});
62+
server.start();
63+
}
64+
65+
@AfterEach
66+
void stopServer() {
67+
if (server != null) {
68+
server.stop(0);
69+
}
70+
}
71+
72+
@Test
73+
void postsRequestBodyAndReturnsResponse() throws Exception {
74+
byte[] requestBody = "<EbicsRequest>hello</EbicsRequest>".getBytes(StandardCharsets.UTF_8);
75+
76+
HttpRequestSender sender = new HttpRequestSender(session(serverUrl()));
77+
int status = sender.send(new ByteArrayContentFactory(requestBody));
78+
79+
assertEquals(200, status, "status code must be propagated from server");
80+
assertEquals("POST", capturedMethod.get(), "must use POST");
81+
assertEquals("text/xml; charset=ISO-8859-1", capturedContentType.get(),
82+
"Content-Type header must match EBICS expectation");
83+
assertArrayEquals(requestBody, capturedBody.get(),
84+
"request body bytes must reach the server unchanged");
85+
assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes(),
86+
"response body must be exposed via getResponseBody()");
87+
}
88+
89+
@Test
90+
void propagatesNonSuccessStatusCodes() throws Exception {
91+
responseStatus = 500;
92+
responseBody = "<error/>".getBytes(StandardCharsets.UTF_8);
93+
94+
HttpRequestSender sender = new HttpRequestSender(session(serverUrl()));
95+
int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8)));
96+
97+
assertEquals(500, status);
98+
assertArrayEquals(responseBody, sender.getResponseBody().getContent().readAllBytes());
99+
}
100+
101+
@Test
102+
void routesThroughConfiguredProxyWithoutAuth() throws Exception {
103+
try (StubProxy proxy = new StubProxy()) {
104+
proxy.enqueueResponse(200, Map.of(), "<ok/>".getBytes(StandardCharsets.UTF_8));
105+
106+
EbicsSession session = proxiedSession(proxy.port(), null, null);
107+
HttpRequestSender sender = new HttpRequestSender(session);
108+
int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8)));
109+
110+
assertEquals(200, status);
111+
List<StubProxy.RecordedRequest> reqs = proxy.recordedRequests();
112+
assertEquals(1, reqs.size(), "proxy must receive exactly one request");
113+
// Going through a proxy, the request line carries the absolute URI.
114+
assertTrue(reqs.get(0).requestLine().contains("http://bank.example/ebics"),
115+
"request line must contain the absolute target URI; got: " + reqs.get(0).requestLine());
116+
assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"),
117+
"no Proxy-Authorization header expected when no credentials configured");
118+
}
119+
}
120+
121+
@Test
122+
void retriesWithProxyAuthorizationAfter407Challenge() throws Exception {
123+
try (StubProxy proxy = new StubProxy()) {
124+
// First connection: challenge with 407 so the client invokes the Authenticator.
125+
proxy.enqueueResponse(407,
126+
Map.of("Proxy-Authenticate", "Basic realm=\"ebics\""),
127+
new byte[0]);
128+
// Second connection: the retry carrying Proxy-Authorization.
129+
proxy.enqueueResponse(200, Map.of(), "<ok/>".getBytes(StandardCharsets.UTF_8));
130+
131+
EbicsSession session = proxiedSession(proxy.port(), "alice", "s3cret");
132+
HttpRequestSender sender = new HttpRequestSender(session);
133+
int status = sender.send(new ByteArrayContentFactory("x".getBytes(StandardCharsets.UTF_8)));
134+
135+
assertEquals(200, status, "client must complete the request after auth retry");
136+
List<StubProxy.RecordedRequest> reqs = proxy.recordedRequests();
137+
assertEquals(2, reqs.size(), "proxy must see the original request plus the auth retry");
138+
assertFalse(reqs.get(0).headers().containsKey("proxy-authorization"),
139+
"first request must go without credentials (challenge-response flow)");
140+
141+
String expectedAuth = "Basic "
142+
+ Base64.getEncoder().encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8));
143+
assertEquals(expectedAuth, reqs.get(1).header("Proxy-Authorization"),
144+
"retry must carry Basic Proxy-Authorization derived from configured credentials");
145+
}
146+
}
147+
148+
private URL serverUrl() throws Exception {
149+
return new URL("http://127.0.0.1:" + server.getAddress().getPort() + "/");
150+
}
151+
152+
private static EbicsSession session(URL url) {
153+
EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS);
154+
Mockito.when(session.getConfiguration().getProperty(Mockito.anyString())).thenReturn(null);
155+
Mockito.when(session.getUser().getPartner().getBank().getURL()).thenReturn(url);
156+
return session;
157+
}
158+
159+
private static EbicsSession proxiedSession(int proxyPort, String user, String pass) throws Exception {
160+
EbicsSession session = Mockito.mock(EbicsSession.class, Mockito.RETURNS_DEEP_STUBS);
161+
var conf = session.getConfiguration();
162+
Mockito.when(conf.getProperty(Mockito.anyString())).thenReturn(null);
163+
Mockito.when(conf.getProperty("http.proxy.host")).thenReturn("127.0.0.1");
164+
Mockito.when(conf.getProperty("http.proxy.port")).thenReturn(String.valueOf(proxyPort));
165+
if (user != null) {
166+
Mockito.when(conf.getProperty("http.proxy.user")).thenReturn(user);
167+
Mockito.when(conf.getProperty("http.proxy.password")).thenReturn(pass);
168+
}
169+
// Target host is arbitrary — the stub proxy intercepts everything and never forwards.
170+
Mockito.when(session.getUser().getPartner().getBank().getURL())
171+
.thenReturn(new URL("http://bank.example/ebics"));
172+
return session;
173+
}
174+
}

0 commit comments

Comments
 (0)