+ * Provides methods for looking up geolocation, threat intelligence, and other + * metadata for IP addresses. Supports single IP lookups, bulk lookups, and + * selective field retrieval. + *
+ * Also exposes single-field accessors (e.g. {@code getCountryName}, {@code getCity})
+ * inherited from {@link IpdataInternalSingleFieldClient}.
+ *
+ * @see io.ipdata.client.Ipdata#builder()
+ */
public interface IpdataService extends IpdataInternalSingleFieldClient {
+ /**
+ * Retrieves the full IP data model for the given IP address.
+ *
+ * @param ip an IPv4 or IPv6 address
+ * @return the full geolocation and metadata for the IP
+ * @throws IpdataException if the API call fails
+ */
IpdataModel ipdata(String ip) throws IpdataException;
+ /**
+ * Retrieves IP data for multiple IP addresses in a single request.
+ *
+ * @param ips list of IPv4 or IPv6 addresses
+ * @return a list of IP data models, one per input address
+ * @throws IpdataException if the API call fails
+ */
List
+ * Fields are sorted before querying to maximize cache hit rates when caching is enabled.
+ *
+ * @param ip an IPv4 or IPv6 address
+ * @param fields one or more fields to retrieve (e.g. {@code IpdataField.ASN}, {@code IpdataField.CURRENCY})
+ * @return a partial IP data model containing only the requested fields
+ * @throws IpdataException if the API call fails
+ * @throws IllegalArgumentException if no fields are specified
+ */
IpdataModel getFields(String ip, IpdataField>... fields) throws IpdataException;
}
diff --git a/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java b/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
index af68fda..f294cc2 100644
--- a/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
+++ b/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
@@ -9,7 +9,6 @@
import com.google.common.cache.CacheLoader;
import feign.Client;
import feign.Feign;
-import feign.httpclient.ApacheHttpClient;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import io.ipdata.client.model.*;
@@ -19,7 +18,7 @@
import java.net.URL;
-import static com.fasterxml.jackson.databind.PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES;
+import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE;
@RequiredArgsConstructor(staticName = "of")
public class IpdataServiceBuilder {
@@ -32,7 +31,7 @@ public class IpdataServiceBuilder {
public IpdataService build() {
final ObjectMapper mapper = new ObjectMapper();
- mapper.setPropertyNamingStrategy(CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES);
+ mapper.setPropertyNamingStrategy(SNAKE_CASE);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
@@ -42,7 +41,7 @@ public IpdataService build() {
final ApiErrorDecoder apiErrorDecoder = new ApiErrorDecoder(mapper, customLogger);
final IpdataInternalClient client = Feign.builder()
- .client(httpClient == null ? new ApacheHttpClient() : httpClient)
+ .client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
.decoder(new JacksonDecoder(mapper))
.encoder(new JacksonEncoder(mapper))
.requestInterceptor(keyRequestInterceptor)
@@ -50,7 +49,7 @@ public IpdataService build() {
.target(IpdataInternalClient.class, url.toString());
final IpdataInternalSingleFieldClient singleFieldClient = Feign.builder()
- .client(httpClient == null ? new ApacheHttpClient() : httpClient)
+ .client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
.decoder(new FieldDecoder(mapper))
.encoder(new JacksonEncoder(mapper))
.requestInterceptor(keyRequestInterceptor)
diff --git a/src/main/java/io/ipdata/client/service/IpdataServiceSupport.java b/src/main/java/io/ipdata/client/service/IpdataServiceSupport.java
index 7f7324e..7531ba8 100644
--- a/src/main/java/io/ipdata/client/service/IpdataServiceSupport.java
+++ b/src/main/java/io/ipdata/client/service/IpdataServiceSupport.java
@@ -30,15 +30,10 @@ private IpdataInternalSingleFieldClient getApi() {
return singleFieldClient;
}
- @Override
- public IpdataModel[] bulkAsArray(List
+ * Feign's template engine may percent-encode colons in path parameters (e.g., IPv6 addresses),
+ * converting {@code 2001:4860:4860::8888} to {@code 2001%3A4860%3A4860%3A%3A8888}.
+ * Colons are valid in URI path segments per RFC 3986 section 3.3, so this wrapper
+ * decodes them before forwarding to the underlying HTTP client.
+ *
+ * @see Issue #10
+ */
+class Ipv6SafeClient implements Client {
+
+ private final Client delegate;
+
+ Ipv6SafeClient(Client delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Response execute(Request request, Request.Options options) throws IOException {
+ String url = request.url();
+ int queryIndex = url.indexOf('?');
+ String path = queryIndex >= 0 ? url.substring(0, queryIndex) : url;
+
+ if (path.contains("%3A") || path.contains("%3a")) {
+ String fixedPath = path.replace("%3A", ":").replace("%3a", ":");
+ String query = queryIndex >= 0 ? url.substring(queryIndex) : "";
+ String fixedUrl = fixedPath + query;
+ request = Request.create(
+ request.httpMethod(), fixedUrl, request.headers(),
+ request.body(), request.charset()
+ );
+ }
+ return delegate.execute(request, options);
+ }
+}
diff --git a/src/test/java/io/ipdata/client/EdgeCaseTest.java b/src/test/java/io/ipdata/client/EdgeCaseTest.java
new file mode 100644
index 0000000..1874198
--- /dev/null
+++ b/src/test/java/io/ipdata/client/EdgeCaseTest.java
@@ -0,0 +1,73 @@
+package io.ipdata.client;
+
+import io.ipdata.client.error.IpdataException;
+import io.ipdata.client.error.RemoteIpdataException;
+import io.ipdata.client.model.IpdataModel;
+import io.ipdata.client.service.IpdataService;
+import lombok.SneakyThrows;
+import org.junit.Assert;
+import org.junit.Test;
+
+import static io.ipdata.client.service.IpdataField.ASN;
+import static io.ipdata.client.service.IpdataField.CURRENCY;
+
+public class EdgeCaseTest {
+
+ private static final TestContext TEST_CONTEXT = new TestContext(MockIpdataServer.API_KEY, MockIpdataServer.getInstance().getUrl());
+
+ @Test(expected = IllegalArgumentException.class)
+ @SneakyThrows
+ public void testGetFieldsWithNoFieldsThrows() {
+ IpdataService service = TEST_CONTEXT.ipdataService();
+ service.getFields("8.8.8.8");
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ @SneakyThrows
+ public void testGetFieldsWithNoFieldsThrowsCaching() {
+ IpdataService service = TEST_CONTEXT.cachingIpdataService();
+ service.getFields("8.8.8.8");
+ }
+
+ @Test
+ @SneakyThrows
+ public void testInvalidKeyReturnsRemoteException() {
+ IpdataService service = Ipdata.builder()
+ .url(TEST_CONTEXT.url())
+ .key("INVALID_KEY")
+ .noCache()
+ .get();
+ try {
+ service.ipdata("8.8.8.8");
+ Assert.fail("Expected RemoteIpdataException");
+ } catch (RemoteIpdataException e) {
+ Assert.assertEquals(401, e.getStatus());
+ Assert.assertNotNull(e.getMessage());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void testCachedInvalidKeyUnwrapsException() {
+ IpdataService service = Ipdata.builder()
+ .url(TEST_CONTEXT.url())
+ .key("INVALID_KEY")
+ .withDefaultCache()
+ .get();
+ try {
+ service.ipdata("8.8.8.8");
+ Assert.fail("Expected RemoteIpdataException");
+ } catch (RemoteIpdataException e) {
+ Assert.assertEquals(401, e.getStatus());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void testGetFieldsReturnsSelectedFields() {
+ IpdataService service = TEST_CONTEXT.ipdataService();
+ IpdataModel model = service.getFields("8.8.8.8", ASN, CURRENCY);
+ Assert.assertNotNull(model.asn());
+ Assert.assertNotNull(model.currency());
+ }
+}
diff --git a/src/test/java/io/ipdata/client/FullModelTest.java b/src/test/java/io/ipdata/client/FullModelTest.java
index 99bfa72..55424e8 100644
--- a/src/test/java/io/ipdata/client/FullModelTest.java
+++ b/src/test/java/io/ipdata/client/FullModelTest.java
@@ -1,18 +1,15 @@
package io.ipdata.client;
-import feign.httpclient.ApacheHttpClient;
import io.ipdata.client.error.IpdataException;
+import io.ipdata.client.error.RemoteIpdataException;
import io.ipdata.client.model.IpdataModel;
import io.ipdata.client.service.IpdataService;
import lombok.SneakyThrows;
-import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
-import java.util.concurrent.TimeUnit;
-
@RunWith(Parameterized.class)
public class FullModelTest {
@@ -44,21 +41,24 @@ public void testFullResponse() {
public void testSingleFields() {
IpdataService ipdataService = fixture.service();
String field = ipdataService.getCountryName(fixture.target());
- String expected = TEST_CONTEXT.get("/8.8.8.8/country_name", null);
- Assert.assertEquals(field, expected);
+ String expected = TEST_CONTEXT.get("/" + fixture.target() + "/country_name", null);
+ Assert.assertEquals(expected, field);
}
@SneakyThrows
- @Test(expected = IpdataException.class)
+ @Test
public void testError() {
IpdataService serviceWithInvalidKey = Ipdata.builder().url(TEST_CONTEXT.url())
.key("THIS_IS_AN_INVALID_KEY")
- .withDefaultCache()
- .feignClient(new ApacheHttpClient(HttpClientBuilder.create()
- .setConnectionTimeToLive(10, TimeUnit.SECONDS)
- .build())).get();
- serviceWithInvalidKey.ipdata(fixture.target());
+ .noCache()
+ .get();
+ try {
+ serviceWithInvalidKey.ipdata(fixture.target());
+ Assert.fail("Expected RemoteIpdataException");
+ } catch (RemoteIpdataException e) {
+ Assert.assertEquals(401, e.getStatus());
+ }
}
@Parameterized.Parameters
diff --git a/src/test/java/io/ipdata/client/Ipv6EncodingTest.java b/src/test/java/io/ipdata/client/Ipv6EncodingTest.java
new file mode 100644
index 0000000..fddbac7
--- /dev/null
+++ b/src/test/java/io/ipdata/client/Ipv6EncodingTest.java
@@ -0,0 +1,36 @@
+package io.ipdata.client;
+
+import io.ipdata.client.model.IpdataModel;
+import io.ipdata.client.service.IpdataService;
+import lombok.SneakyThrows;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Regression test for https://github.com/ipdata/java/issues/10
+ * Verifies that IPv6 colons are not percent-encoded (%3A) in HTTP requests.
+ */
+public class Ipv6EncodingTest {
+
+ private static final MockIpdataServer MOCK = MockIpdataServer.getInstance();
+ private static final TestContext TEST_CONTEXT = new TestContext(MockIpdataServer.API_KEY, MOCK.getUrl());
+
+ @Test
+ @SneakyThrows
+ public void testIpv6ColonsAreNotEncoded() {
+ String ipv6 = "2001:4860:4860::8888";
+ IpdataService service = TEST_CONTEXT.ipdataService();
+ IpdataModel model = service.ipdata(ipv6);
+ Assert.assertNotNull(model);
+
+ String rawPath = MOCK.getLastRawPath();
+ Assert.assertFalse(
+ "IPv6 colons should not be percent-encoded in the request path, but got: " + rawPath,
+ rawPath.contains("%3A")
+ );
+ Assert.assertTrue(
+ "Request path should contain the IPv6 address with literal colons",
+ rawPath.contains(ipv6)
+ );
+ }
+}
diff --git a/src/test/java/io/ipdata/client/MockIpdataServer.java b/src/test/java/io/ipdata/client/MockIpdataServer.java
index 6f44dc3..527f7c1 100644
--- a/src/test/java/io/ipdata/client/MockIpdataServer.java
+++ b/src/test/java/io/ipdata/client/MockIpdataServer.java
@@ -29,6 +29,7 @@ public class MockIpdataServer {
private final String url;
private final Map