Skip to content

Commit 03169ae

Browse files
committed
Fix IPv6 address encoding in HTTP requests (issue #10)
Feign's template engine percent-encodes colons in path parameters, converting IPv6 addresses like 2001:4860:4860::8888 to 2001%3A4860%3A4860%3A%3A8888. This breaks requests to the ipdata API. Add Ipv6SafeClient wrapper that decodes %3A back to literal colons before sending requests, since colons are valid in URI paths per RFC 3986 section 3.3. Also switch the default HTTP client from ApacheHttpClient (which further re-encodes via URIBuilder) to Feign's built-in Client.Default. https://claude.ai/code/session_01KxvyXRVVZaLrgTZshvsZY6
1 parent 9745051 commit 03169ae

5 files changed

Lines changed: 90 additions & 13 deletions

File tree

src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import com.google.common.cache.CacheLoader;
1010
import feign.Client;
1111
import feign.Feign;
12-
import feign.httpclient.ApacheHttpClient;
1312
import feign.jackson.JacksonDecoder;
1413
import feign.jackson.JacksonEncoder;
1514
import io.ipdata.client.model.*;
@@ -42,15 +41,15 @@ public IpdataService build() {
4241
final ApiErrorDecoder apiErrorDecoder = new ApiErrorDecoder(mapper, customLogger);
4342

4443
final IpdataInternalClient client = Feign.builder()
45-
.client(httpClient == null ? new ApacheHttpClient() : httpClient)
44+
.client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
4645
.decoder(new JacksonDecoder(mapper))
4746
.encoder(new JacksonEncoder(mapper))
4847
.requestInterceptor(keyRequestInterceptor)
4948
.errorDecoder(apiErrorDecoder)
5049
.target(IpdataInternalClient.class, url.toString());
5150

5251
final IpdataInternalSingleFieldClient singleFieldClient = Feign.builder()
53-
.client(httpClient == null ? new ApacheHttpClient() : httpClient)
52+
.client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
5453
.decoder(new FieldDecoder(mapper))
5554
.encoder(new JacksonEncoder(mapper))
5655
.requestInterceptor(keyRequestInterceptor)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.ipdata.client.service;
2+
3+
import feign.Client;
4+
import feign.Request;
5+
import feign.Response;
6+
7+
import java.io.IOException;
8+
9+
/**
10+
* A Feign Client wrapper that prevents percent-encoding of colons in the request path.
11+
* <p>
12+
* Feign's template engine may percent-encode colons in path parameters (e.g., IPv6 addresses),
13+
* converting {@code 2001:4860:4860::8888} to {@code 2001%3A4860%3A4860%3A%3A8888}.
14+
* Colons are valid in URI path segments per RFC 3986 section 3.3, so this wrapper
15+
* decodes them before forwarding to the underlying HTTP client.
16+
*
17+
* @see <a href="https://github.com/ipdata/java/issues/10">Issue #10</a>
18+
*/
19+
class Ipv6SafeClient implements Client {
20+
21+
private final Client delegate;
22+
23+
Ipv6SafeClient(Client delegate) {
24+
this.delegate = delegate;
25+
}
26+
27+
@Override
28+
public Response execute(Request request, Request.Options options) throws IOException {
29+
String url = request.url();
30+
int queryIndex = url.indexOf('?');
31+
String path = queryIndex >= 0 ? url.substring(0, queryIndex) : url;
32+
33+
if (path.contains("%3A") || path.contains("%3a")) {
34+
String fixedPath = path.replace("%3A", ":").replace("%3a", ":");
35+
String query = queryIndex >= 0 ? url.substring(queryIndex) : "";
36+
String fixedUrl = fixedPath + query;
37+
request = Request.create(
38+
request.httpMethod(), fixedUrl, request.headers(),
39+
request.body(), request.charset()
40+
);
41+
}
42+
return delegate.execute(request, options);
43+
}
44+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package io.ipdata.client;
2+
3+
import io.ipdata.client.model.IpdataModel;
4+
import io.ipdata.client.service.IpdataService;
5+
import lombok.SneakyThrows;
6+
import org.junit.Assert;
7+
import org.junit.Test;
8+
9+
/**
10+
* Regression test for https://github.com/ipdata/java/issues/10
11+
* Verifies that IPv6 colons are not percent-encoded (%3A) in HTTP requests.
12+
*/
13+
public class Ipv6EncodingTest {
14+
15+
private static final MockIpdataServer MOCK = MockIpdataServer.getInstance();
16+
private static final TestContext TEST_CONTEXT = new TestContext(MockIpdataServer.API_KEY, MOCK.getUrl());
17+
18+
@Test
19+
@SneakyThrows
20+
public void testIpv6ColonsAreNotEncoded() {
21+
String ipv6 = "2001:4860:4860::8888";
22+
IpdataService service = TEST_CONTEXT.ipdataService();
23+
IpdataModel model = service.ipdata(ipv6);
24+
Assert.assertNotNull(model);
25+
26+
String rawPath = MOCK.getLastRawPath();
27+
Assert.assertFalse(
28+
"IPv6 colons should not be percent-encoded in the request path, but got: " + rawPath,
29+
rawPath.contains("%3A")
30+
);
31+
Assert.assertTrue(
32+
"Request path should contain the IPv6 address with literal colons",
33+
rawPath.contains(ipv6)
34+
);
35+
}
36+
}

src/test/java/io/ipdata/client/MockIpdataServer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public class MockIpdataServer {
2929
private final String url;
3030
private final Map<String, JsonNode> fixtures = new HashMap<>();
3131
private final ObjectMapper mapper = new ObjectMapper();
32+
private volatile String lastRawPath;
3233

3334
private MockIpdataServer() {
3435
try {
@@ -54,6 +55,10 @@ public String getUrl() {
5455
return url;
5556
}
5657

58+
public String getLastRawPath() {
59+
return lastRawPath;
60+
}
61+
5762
private void loadFixtures() {
5863
String[] ips = {"8.8.8.8", "2001:4860:4860::8888", "1.1.1.1", "2001:4860:4860::8844", "41.128.21.123"};
5964
for (String ip : ips) {
@@ -72,6 +77,7 @@ private void handleRequest(HttpExchange exchange) throws IOException {
7277
try {
7378
String method = exchange.getRequestMethod();
7479
String path = exchange.getRequestURI().getPath();
80+
lastRawPath = exchange.getRequestURI().getRawPath();
7581
String rawQuery = exchange.getRequestURI().getRawQuery();
7682
Map<String, String> params = parseQuery(rawQuery);
7783

src/test/java/io/ipdata/client/TestContext.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import com.fasterxml.jackson.databind.DeserializationFeature;
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import com.google.common.io.CharStreams;
8-
import feign.httpclient.ApacheHttpClient;
98
import io.ipdata.client.service.IpdataService;
109
import lombok.Getter;
1110
import lombok.SneakyThrows;
@@ -17,7 +16,6 @@
1716
import org.apache.http.client.methods.HttpGet;
1817
import org.apache.http.client.methods.HttpPost;
1918
import org.apache.http.client.utils.URIBuilder;
20-
import org.apache.http.conn.ssl.NoopHostnameVerifier;
2119
import org.apache.http.entity.StringEntity;
2220
import org.apache.http.impl.client.HttpClientBuilder;
2321

@@ -56,17 +54,11 @@ public TestContext(String key, String url) {
5654
mapper.setPropertyNamingStrategy(CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES);
5755
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
5856
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
59-
httpClient = HttpClientBuilder.create().setSSLHostnameVerifier(new NoopHostnameVerifier()).build();
57+
httpClient = HttpClientBuilder.create().build();
6058
ipdataService = Ipdata.builder().url(this.url).key(this.key)
6159
.noCache()
62-
.feignClient(new ApacheHttpClient(HttpClientBuilder.create()
63-
.setSSLHostnameVerifier(new NoopHostnameVerifier())
64-
.build())
65-
).get();
60+
.get();
6661
cachingIpdataService = Ipdata.builder().url(this.url)
67-
.feignClient(new ApacheHttpClient(HttpClientBuilder.create()
68-
.setSSLHostnameVerifier(new NoopHostnameVerifier())
69-
.build()))
7062
.withDefaultCache().key(key).get();
7163
}
7264

0 commit comments

Comments
 (0)