io.github.openfeign
feign-httpclient
diff --git a/src/main/java/io/ipdata/client/Ipdata.java b/src/main/java/io/ipdata/client/Ipdata.java
index cc4c4c7..173cd91 100644
--- a/src/main/java/io/ipdata/client/Ipdata.java
+++ b/src/main/java/io/ipdata/client/Ipdata.java
@@ -1,6 +1,7 @@
package io.ipdata.client;
import feign.Client;
+import feign.Request;
import io.ipdata.client.service.CacheConfig;
import io.ipdata.client.service.IpdataService;
import io.ipdata.client.service.IpdataServiceBuilder;
@@ -47,10 +48,27 @@ public static class Builder {
@Setter
private Logger logger;
/**
- * (Optional) Configures a custom Feign client (If you wanna use this with Ribbon or Hystrix for example).
+ * (Optional) Configures a custom Feign client, for example Apache HttpClient ({@code feign-httpclient}),
+ * OkHttp ({@code feign-okhttp}) or a Ribbon/Hystrix wrapper.
+ *
+ * The default is the JDK HTTP/2 client ({@code feign.http2client.Http2Client} from {@code feign-java11}),
+ * which keeps one persistent connection pool for the lifetime of the service and multiplexes requests over
+ * HTTP/2 when the server negotiates it, falling back to HTTP/1.1 keep-alive otherwise. An injected client
+ * is shared by all calls of the built service (full model, single field and bulk), so configure its
+ * connection pooling there; the {@code options(Request.Options)} are passed to it on every request.
*/
@Setter
private Client feignClient;
+ /**
+ * (Optional) Connect and read timeouts and the redirect policy of the HTTP transport, shared by all calls
+ * of the built service. Defaults to {@code new Request.Options()}: 10 seconds to connect, 60 seconds to
+ * read, follow redirects. With the default transport both timeouts must be positive.
+ *
+ * @see #connectTimeout(long, TimeUnit)
+ * @see #readTimeout(long, TimeUnit)
+ */
+ @Setter
+ private Request.Options options;
/**
* Overrides current cache config with null (no caching).
@@ -86,6 +104,52 @@ public CacheConfigBuilder withCache() {
return new CacheConfigBuilder(this);
}
+ /**
+ * Sets the connect timeout of the HTTP transport (default: 10 seconds), keeping the other request options.
+ * Shorthand for {@code options(Request.Options)} with only the connect timeout changed.
+ *
+ * @param timeout the connect timeout, greater than 0
+ * @param unit the unit of the timeout
+ * @return this builder
+ * @see Request.Options
+ */
+ public Builder connectTimeout(long timeout, TimeUnit unit) {
+ Request.Options current = currentOptions();
+ this.options = new Request.Options(checkTimeout(timeout, unit), unit,
+ current.readTimeout(), current.readTimeoutUnit(), current.isFollowRedirects());
+ return this;
+ }
+
+ /**
+ * Sets the read timeout of the HTTP transport (default: 60 seconds), keeping the other request options.
+ * Shorthand for {@code options(Request.Options)} with only the read timeout changed.
+ *
+ * @param timeout the read timeout, greater than 0
+ * @param unit the unit of the timeout
+ * @return this builder
+ * @see Request.Options
+ */
+ public Builder readTimeout(long timeout, TimeUnit unit) {
+ Request.Options current = currentOptions();
+ this.options = new Request.Options(current.connectTimeout(), current.connectTimeoutUnit(),
+ checkTimeout(timeout, unit), unit, current.isFollowRedirects());
+ return this;
+ }
+
+ private Request.Options currentOptions() {
+ return options == null ? new Request.Options() : options;
+ }
+
+ private static long checkTimeout(long timeout, TimeUnit unit) {
+ if (timeout <= 0) {
+ throw new IllegalArgumentException("timeout must be greater than 0");
+ }
+ if (unit == null) {
+ throw new IllegalArgumentException("timeout unit should not be null");
+ }
+ return timeout;
+ }
+
/**
* @return An Ipdata api facade
* @see io.ipdata.client.service.IpdataService
@@ -97,7 +161,7 @@ public IpdataService get() {
if (url == null) {
throw new IllegalArgumentException("Target URL can't be null");
}
- return IpdataServiceBuilder.of(url, key, cacheConfig, logger, feignClient).build();
+ return IpdataServiceBuilder.of(url, key, cacheConfig, logger, feignClient, options).build();
}
}
diff --git a/src/main/java/io/ipdata/client/model/Blocklist.java b/src/main/java/io/ipdata/client/model/Blocklist.java
index 5ca4201..f6d89f7 100644
--- a/src/main/java/io/ipdata/client/model/Blocklist.java
+++ b/src/main/java/io/ipdata/client/model/Blocklist.java
@@ -1,5 +1,6 @@
package io.ipdata.client.model;
+import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.ToString;
@@ -8,4 +9,21 @@ public class Blocklist {
private String name;
private String site;
private String type;
+ /**
+ * First day the address was seen on this blocklist, as a {@code YYYY-MM-DD} (UTC) string, or {@code null}
+ * when the feed does not report it.
+ */
+ @JsonProperty("first_seen")
+ private String firstSeen;
+ /**
+ * Most recent day the address was seen on this blocklist, as a {@code YYYY-MM-DD} (UTC) string, or
+ * {@code null} when the feed does not report it.
+ */
+ @JsonProperty("last_seen")
+ private String lastSeen;
+ /**
+ * Number of days the address was seen on this blocklist, or {@code null} when the feed does not report it.
+ */
+ @JsonProperty("days_seen")
+ private Integer daysSeen;
}
diff --git a/src/main/java/io/ipdata/client/model/IpdataModel.java b/src/main/java/io/ipdata/client/model/IpdataModel.java
index 7d8cf62..ff82d8e 100644
--- a/src/main/java/io/ipdata/client/model/IpdataModel.java
+++ b/src/main/java/io/ipdata/client/model/IpdataModel.java
@@ -14,6 +14,18 @@ public class IpdataModel {
private String ip;
@JsonProperty("is_eu")
private boolean eu;
+ /**
+ * The address belongs to a satellite ISP. The API sends the key only when it is true, so the field reads
+ * {@code false} when it is absent.
+ */
+ @JsonProperty("is_satellite")
+ private boolean satellite;
+ /**
+ * The address is an anycast address. The API sends the key only when it is true, so the field reads
+ * {@code false} when it is absent.
+ */
+ @JsonProperty("is_anycast")
+ private boolean anycast;
private String city;
private String organisation;
@@ -44,4 +56,12 @@ public class IpdataModel {
public boolean isEu() {
return eu;
}
+
+ public boolean isSatellite() {
+ return satellite;
+ }
+
+ public boolean isAnycast() {
+ return anycast;
+ }
}
diff --git a/src/main/java/io/ipdata/client/service/IpdataField.java b/src/main/java/io/ipdata/client/service/IpdataField.java
index 1b960da..5c157c4 100644
--- a/src/main/java/io/ipdata/client/service/IpdataField.java
+++ b/src/main/java/io/ipdata/client/service/IpdataField.java
@@ -14,6 +14,8 @@
public class IpdataField {
public static final IpdataField IP = new IpdataField("ip", String.class);
public static final IpdataField IS_EU = new IpdataField("is_eu", Boolean.class);
+ public static final IpdataField IS_SATELLITE = new IpdataField("is_satellite", Boolean.class);
+ public static final IpdataField IS_ANYCAST = new IpdataField("is_anycast", Boolean.class);
public static final IpdataField CITY = new IpdataField("city", String.class);
public static final IpdataField REGION = new IpdataField("region", String.class);
public static final IpdataField REGION_CODE = new IpdataField("region_code", String.class);
diff --git a/src/main/java/io/ipdata/client/service/IpdataInternalSingleFieldClient.java b/src/main/java/io/ipdata/client/service/IpdataInternalSingleFieldClient.java
index 5f55f7a..a98f7d7 100644
--- a/src/main/java/io/ipdata/client/service/IpdataInternalSingleFieldClient.java
+++ b/src/main/java/io/ipdata/client/service/IpdataInternalSingleFieldClient.java
@@ -16,6 +16,12 @@ interface IpdataInternalSingleFieldClient {
@RequestLine("GET /{ip}/is_eu")
boolean isEu(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
+ @RequestLine("GET /{ip}/is_satellite")
+ boolean isSatellite(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
+
+ @RequestLine("GET /{ip}/is_anycast")
+ boolean isAnycast(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
+
@RequestLine("GET /{ip}/city")
String getCity(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
diff --git a/src/main/java/io/ipdata/client/service/IpdataService.java b/src/main/java/io/ipdata/client/service/IpdataService.java
index d83d522..a196cd3 100644
--- a/src/main/java/io/ipdata/client/service/IpdataService.java
+++ b/src/main/java/io/ipdata/client/service/IpdataService.java
@@ -14,6 +14,11 @@
*
* Also exposes single-field accessors (e.g. {@code getCountryName}, {@code getCity})
* inherited from {@link IpdataInternalSingleFieldClient}.
+ *
+ * A service owns one persistent HTTP connection pool, shared by all of its methods: requests are multiplexed
+ * over HTTP/2 when the server negotiates it and use HTTP/1.1 keep-alive otherwise. Create one instance per
+ * API key or endpoint, keep it for the lifetime of the application and share it between threads (it is
+ * thread-safe); every new instance pays a fresh TLS handshake. There is nothing to close.
*
* @see io.ipdata.client.Ipdata#builder()
*/
diff --git a/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java b/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
index f294cc2..b22ef6d 100644
--- a/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
+++ b/src/main/java/io/ipdata/client/service/IpdataServiceBuilder.java
@@ -9,6 +9,8 @@
import com.google.common.cache.CacheLoader;
import feign.Client;
import feign.Feign;
+import feign.Request;
+import feign.http2client.Http2Client;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import io.ipdata.client.model.*;
@@ -20,6 +22,15 @@
import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE;
+/**
+ * Assembles an {@link IpdataService}.
+ *
+ * The full-model proxy ({@code ipdata}, {@code bulk}, {@code getFields}) and the single-field proxy
+ * ({@code asn}, {@code threat}, ...) share one HTTP transport, so every call of the resulting service goes
+ * through the same persistent connection pool. The default transport is the JDK HTTP/2 client
+ * ({@link Http2Client} from {@code feign-java11}): HTTP/2 multiplexing when the server negotiates it,
+ * HTTP/1.1 keep-alive otherwise.
+ */
@RequiredArgsConstructor(staticName = "of")
public class IpdataServiceBuilder {
@@ -28,6 +39,27 @@ public class IpdataServiceBuilder {
private final CacheConfig cacheConfig;
private final Logger logger;
private final Client httpClient;
+ /**
+ * Connect and read timeouts and the redirect policy, applied to both Feign proxies and used to build the
+ * default transport. {@code null} means {@code new Request.Options()}: 10 seconds connect, 60 seconds read,
+ * follow redirects.
+ */
+ private final Request.Options options;
+
+ /**
+ * Same as the generated {@code of(url, key, cacheConfig, logger, httpClient, options)} factory with default
+ * request options.
+ *
+ * @param url the API endpoint
+ * @param key the API key
+ * @param cacheConfig the cache configuration, or {@code null} for no caching
+ * @param logger the logger to use, or {@code null} for the default one
+ * @param httpClient a custom Feign client shared by all calls, or {@code null} for the JDK HTTP/2 client
+ * @return a builder
+ */
+ public static IpdataServiceBuilder of(URL url, String key, CacheConfig cacheConfig, Logger logger, Client httpClient) {
+ return of(url, key, cacheConfig, logger, httpClient, null);
+ }
public IpdataService build() {
final ObjectMapper mapper = new ObjectMapper();
@@ -40,8 +72,16 @@ public IpdataService build() {
final Logger customLogger = this.logger == null ? LoggerFactory.getLogger(IpdataService.class) : this.logger;
final ApiErrorDecoder apiErrorDecoder = new ApiErrorDecoder(mapper, customLogger);
+ final Request.Options requestOptions = this.options == null ? new Request.Options() : this.options;
+ // One transport for both proxies: the java.net.http client behind Http2Client owns a single connection
+ // pool that lives as long as the service does. The very same options instance must be handed to
+ // Http2Client and to Feign (.options below): Http2Client silently builds a new HttpClient, and thus a new
+ // connection, for every request whose options differ from the ones it was constructed with.
+ final Client transport = new Ipv6SafeClient(httpClient != null ? httpClient : new Http2Client(requestOptions));
+
final IpdataInternalClient client = Feign.builder()
- .client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
+ .client(transport)
+ .options(requestOptions)
.decoder(new JacksonDecoder(mapper))
.encoder(new JacksonEncoder(mapper))
.requestInterceptor(keyRequestInterceptor)
@@ -49,7 +89,8 @@ public IpdataService build() {
.target(IpdataInternalClient.class, url.toString());
final IpdataInternalSingleFieldClient singleFieldClient = Feign.builder()
- .client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
+ .client(transport)
+ .options(requestOptions)
.decoder(new FieldDecoder(mapper))
.encoder(new JacksonEncoder(mapper))
.requestInterceptor(keyRequestInterceptor)
diff --git a/src/test/java/io/ipdata/client/ConnectionReuseTest.java b/src/test/java/io/ipdata/client/ConnectionReuseTest.java
new file mode 100644
index 0000000..651d1a5
--- /dev/null
+++ b/src/test/java/io/ipdata/client/ConnectionReuseTest.java
@@ -0,0 +1,269 @@
+package io.ipdata.client;
+
+import feign.Client;
+import feign.Request;
+import feign.Response;
+import io.ipdata.client.model.IpdataModel;
+import io.ipdata.client.service.IpdataService;
+import lombok.SneakyThrows;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static io.ipdata.client.service.IpdataField.ASN;
+import static io.ipdata.client.service.IpdataField.CURRENCY;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * One {@link IpdataService} instance drives every call (full model, single field, selected fields, bulk)
+ * through one transport, and therefore one connection pool: consecutive calls arrive at the server on the
+ * same client socket. The mock server speaks plain HTTP/1.1, so the default JDK HTTP/2 transport attempts an
+ * h2c upgrade and falls back to HTTP/1.1 keep-alive.
+ */
+public class ConnectionReuseTest {
+
+ private static final MockIpdataServer MOCK = MockIpdataServer.getInstance();
+ private static final String IP = "8.8.8.8";
+
+ @SneakyThrows
+ private static Ipdata.Builder builder() {
+ return Ipdata.builder().url(new URL(MOCK.getUrl())).key(MockIpdataServer.API_KEY);
+ }
+
+ private static void assertSameSocket(InetSocketAddress expected, String call) {
+ InetSocketAddress actual = MOCK.getLastRemoteAddress();
+ assertNotNull(actual);
+ assertEquals(call + " must arrive on the connection already used by the first call", expected, actual);
+ }
+
+ @Test
+ @SneakyThrows
+ public void sequentialCallsThroughBothProxiesReuseOneConnection() {
+ IpdataService service = builder().noCache().get();
+
+ service.ipdata(IP);
+ InetSocketAddress first = MOCK.getLastRemoteAddress();
+ assertNotNull(first);
+ assertTrue(first.getPort() > 0);
+
+ service.asn(IP);
+ assertSameSocket(first, "a single-field call");
+ service.getFields(IP, ASN, CURRENCY);
+ assertSameSocket(first, "a selected-fields call");
+ service.threat(IP);
+ assertSameSocket(first, "a second single-field call");
+ service.bulk(Arrays.asList(IP, "1.1.1.1"));
+ assertSameSocket(first, "a bulk (POST) call");
+ service.ipdata("2001:4860:4860::8888");
+ assertSameSocket(first, "a full-model call for another address");
+ }
+
+ @Test
+ @SneakyThrows
+ public void cachingServiceReusesOneConnectionForCacheMisses() {
+ IpdataService service = builder().withDefaultCache().get();
+
+ service.ipdata(IP);
+ InetSocketAddress first = MOCK.getLastRemoteAddress();
+ service.asn("1.1.1.1");
+ assertSameSocket(first, "a single-field cache miss");
+ service.timeZone("41.128.21.123");
+ assertSameSocket(first, "another single-field cache miss");
+ service.getFields("1.1.1.1", ASN);
+ assertSameSocket(first, "a selected-fields cache miss");
+ }
+
+ /**
+ * Http2Client rebuilds its HttpClient (and so opens a new connection) for every request whose options
+ * differ from the ones it was constructed with: custom timeouts must reach both the transport and Feign.
+ */
+ @Test
+ @SneakyThrows
+ public void customTimeoutsKeepOneConnection() {
+ IpdataService service = builder().noCache()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .get();
+
+ service.ipdata(IP);
+ InetSocketAddress first = MOCK.getLastRemoteAddress();
+ service.currency(IP);
+ assertSameSocket(first, "a single-field call with custom timeouts");
+ service.ipdata("1.1.1.1");
+ assertSameSocket(first, "a full-model call with custom timeouts");
+ }
+
+ @Test
+ @SneakyThrows
+ public void customRequestOptionsKeepOneConnection() {
+ IpdataService service = builder().noCache()
+ .options(new Request.Options(3, TimeUnit.SECONDS, 20, TimeUnit.SECONDS, false))
+ .get();
+
+ service.ipdata(IP);
+ InetSocketAddress first = MOCK.getLastRemoteAddress();
+ service.asn(IP);
+ assertSameSocket(first, "a single-field call with custom request options");
+ service.bulk(Arrays.asList(IP));
+ assertSameSocket(first, "a bulk call with custom request options");
+ }
+
+ @Test
+ @SneakyThrows
+ public void defaultTransportFallsBackToHttp11AgainstPlainHttpServer() {
+ IpdataService service = builder().noCache().get();
+
+ IpdataModel model = service.ipdata(IP);
+
+ assertEquals(IP, model.ip());
+ assertEquals("Google LLC", model.asn().name());
+ assertEquals("HTTP/1.1", MOCK.getLastProtocol());
+ }
+
+ @Test
+ @SneakyThrows
+ public void injectedFeignClientServesBothProxies() {
+ CountingClient counting = new CountingClient(new Client.Default(null, null));
+ IpdataService service = builder().noCache().feignClient(counting).get();
+
+ service.ipdata(IP);
+ service.asn(IP);
+ service.getFields(IP, ASN, CURRENCY);
+ service.threat(IP);
+ service.bulk(Arrays.asList(IP, "1.1.1.1"));
+
+ assertEquals("every call must go through the injected client", 5, counting.calls.get());
+ }
+
+ @Test
+ @SneakyThrows
+ public void injectedFeignClientServesBothProxiesOfACachingService() {
+ CountingClient counting = new CountingClient(new Client.Default(null, null));
+ IpdataService service = builder().withDefaultCache().feignClient(counting).get();
+
+ service.ipdata(IP);
+ service.ipdata(IP); // cache hit, no request
+ service.currency(IP);
+ service.currency(IP); // cache hit, no request
+
+ assertEquals(2, counting.calls.get());
+ }
+
+ @Test
+ @SneakyThrows
+ public void builderTimeoutsReachTheTransportOnEveryCall() {
+ CountingClient capturing = new CountingClient(new Client.Default(null, null));
+ IpdataService service = builder().noCache().feignClient(capturing)
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .get();
+
+ service.ipdata(IP);
+ assertEquals(5_000, capturing.lastOptions.connectTimeoutMillis());
+ assertEquals(30_000, capturing.lastOptions.readTimeoutMillis());
+ assertTrue(capturing.lastOptions.isFollowRedirects());
+ Request.Options fullModelOptions = capturing.lastOptions;
+
+ service.asn(IP);
+ assertSame("both proxies share the same options instance", fullModelOptions, capturing.lastOptions);
+ service.bulk(Arrays.asList(IP));
+ assertSame(fullModelOptions, capturing.lastOptions);
+ }
+
+ @Test
+ @SneakyThrows
+ public void explicitRequestOptionsReachTheTransport() {
+ CountingClient capturing = new CountingClient(new Client.Default(null, null));
+ Request.Options options = new Request.Options(2, TimeUnit.SECONDS, 20, TimeUnit.SECONDS, true);
+ IpdataService service = builder().noCache().feignClient(capturing).options(options).get();
+
+ service.ipdata(IP);
+ assertSame(options, capturing.lastOptions);
+ service.threat(IP);
+ assertSame(options, capturing.lastOptions);
+ }
+
+ @Test
+ @SneakyThrows
+ public void defaultOptionsAreTenSecondsConnectAndSixtySecondsRead() {
+ CountingClient capturing = new CountingClient(new Client.Default(null, null));
+ IpdataService service = builder().noCache().feignClient(capturing).get();
+
+ service.ipdata(IP);
+
+ assertEquals(10_000, capturing.lastOptions.connectTimeoutMillis());
+ assertEquals(60_000, capturing.lastOptions.readTimeoutMillis());
+ assertTrue(capturing.lastOptions.isFollowRedirects());
+ }
+
+ @Test
+ public void timeoutSettersKeepTheOtherOptions() {
+ List seen = new ArrayList<>();
+ CountingClient capturing = new CountingClient(new Client.Default(null, null)) {
+ @Override
+ public Response execute(Request request, Request.Options options) throws IOException {
+ seen.add(options);
+ return super.execute(request, options);
+ }
+ };
+ try {
+ builder().noCache().feignClient(capturing)
+ .options(new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false))
+ .readTimeout(45, TimeUnit.SECONDS)
+ .connectTimeout(1500, TimeUnit.MILLISECONDS)
+ .get()
+ .ipdata(IP);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ assertEquals(1, seen.size());
+ assertEquals(1_500, seen.get(0).connectTimeoutMillis());
+ assertEquals(45_000, seen.get(0).readTimeoutMillis());
+ assertEquals(false, seen.get(0).isFollowRedirects());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void connectTimeoutMustBePositive() {
+ builder().connectTimeout(0, TimeUnit.SECONDS);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void readTimeoutMustBePositive() {
+ builder().readTimeout(-1, TimeUnit.SECONDS);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void timeoutUnitMustNotBeNull() {
+ builder().readTimeout(1, null);
+ }
+
+ /**
+ * A Feign client that counts the requests it serves and remembers the options Feign handed over.
+ */
+ static class CountingClient implements Client {
+ final AtomicInteger calls = new AtomicInteger();
+ volatile Request.Options lastOptions;
+ private final Client delegate;
+
+ CountingClient(Client delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Response execute(Request request, Request.Options options) throws IOException {
+ calls.incrementAndGet();
+ lastOptions = options;
+ return delegate.execute(request, options);
+ }
+ }
+}
diff --git a/src/test/java/io/ipdata/client/MockIpdataServer.java b/src/test/java/io/ipdata/client/MockIpdataServer.java
index 527f7c1..e8c87e9 100644
--- a/src/test/java/io/ipdata/client/MockIpdataServer.java
+++ b/src/test/java/io/ipdata/client/MockIpdataServer.java
@@ -15,9 +15,11 @@
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
public class MockIpdataServer {
@@ -27,9 +29,13 @@ public class MockIpdataServer {
private final HttpServer server;
private final String url;
- private final Map fixtures = new HashMap<>();
+ private final Map fixtures = new ConcurrentHashMap<>();
private final ObjectMapper mapper = new ObjectMapper();
private volatile String lastRawPath;
+ private volatile String lastRawQuery;
+ private volatile String lastProtocol;
+ /** The client socket address (IP and ephemeral port) of every request received, in order. */
+ private final List remoteAddresses = Collections.synchronizedList(new ArrayList<>());
private MockIpdataServer() {
try {
@@ -59,8 +65,48 @@ public String getLastRawPath() {
return lastRawPath;
}
+ /** @return the raw (still percent-encoded) query string of the last request received, or {@code null} */
+ public String getLastRawQuery() {
+ return lastRawQuery;
+ }
+
+ /**
+ * Registers (or replaces) the response served for an IP address, for tests that need a shape the fixture
+ * files do not have.
+ *
+ * @param ip the address, as it appears in request paths
+ * @param json the full response body
+ * @throws IOException if {@code json} is not valid JSON
+ */
+ public void registerFixture(String ip, String json) throws IOException {
+ fixtures.put(ip, mapper.readTree(json));
+ }
+
+ /** @return the protocol of the last request received, for example {@code HTTP/1.1} */
+ public String getLastProtocol() {
+ return lastProtocol;
+ }
+
+ /**
+ * @return the client socket address of the last request received; two requests carrying the same address
+ * (same ephemeral port) arrived on the same TCP connection
+ */
+ public InetSocketAddress getLastRemoteAddress() {
+ synchronized (remoteAddresses) {
+ return remoteAddresses.isEmpty() ? null : remoteAddresses.get(remoteAddresses.size() - 1);
+ }
+ }
+
+ /** @return a snapshot of the client socket addresses of all requests received so far, in order */
+ public List getRemoteAddresses() {
+ synchronized (remoteAddresses) {
+ return new ArrayList<>(remoteAddresses);
+ }
+ }
+
private void loadFixtures() {
- String[] ips = {"8.8.8.8", "2001:4860:4860::8888", "1.1.1.1", "2001:4860:4860::8844", "41.128.21.123"};
+ String[] ips = {"8.8.8.8", "2001:4860:4860::8888", "1.1.1.1", "2001:4860:4860::8844", "41.128.21.123",
+ "98.97.34.0", "187.13.250.1"};
for (String ip : ips) {
String resourceName = "fixtures/" + ip.replace(":", "-") + ".json";
try (InputStream is = getClass().getResourceAsStream(resourceName)) {
@@ -75,10 +121,13 @@ private void loadFixtures() {
private void handleRequest(HttpExchange exchange) throws IOException {
try {
+ remoteAddresses.add(exchange.getRemoteAddress());
+ lastProtocol = exchange.getProtocol();
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
lastRawPath = exchange.getRequestURI().getRawPath();
String rawQuery = exchange.getRequestURI().getRawQuery();
+ lastRawQuery = rawQuery;
Map params = parseQuery(rawQuery);
String apiKey = params.get("api-key");
diff --git a/src/test/java/io/ipdata/client/NewResponseFieldsTest.java b/src/test/java/io/ipdata/client/NewResponseFieldsTest.java
new file mode 100644
index 0000000..a62d115
--- /dev/null
+++ b/src/test/java/io/ipdata/client/NewResponseFieldsTest.java
@@ -0,0 +1,232 @@
+package io.ipdata.client;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.ipdata.client.model.Blocklist;
+import io.ipdata.client.model.IpdataModel;
+import io.ipdata.client.model.ThreatModel;
+import io.ipdata.client.service.IpdataService;
+import lombok.SneakyThrows;
+import org.junit.Test;
+
+import java.net.URLDecoder;
+import java.util.Arrays;
+import java.util.List;
+
+import static io.ipdata.client.service.IpdataField.IS_ANYCAST;
+import static io.ipdata.client.service.IpdataField.IS_EU;
+import static io.ipdata.client.service.IpdataField.IS_SATELLITE;
+import static io.ipdata.client.service.IpdataField.THREAT;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The {@code is_satellite} and {@code is_anycast} flags (sent by the API only when true) and the optional
+ * {@code first_seen}, {@code last_seen} and {@code days_seen} attributes of blocklist entries.
+ */
+public class NewResponseFieldsTest {
+
+ private static final MockIpdataServer MOCK = MockIpdataServer.getInstance();
+ private static final TestContext TEST_CONTEXT = new TestContext(MockIpdataServer.API_KEY, MOCK.getUrl());
+
+ /** Sample A: satellite ISP, empty blocklists. */
+ private static final String SATELLITE_IP = "98.97.34.0";
+ /** Sample B: anycast address, two dated blocklist entries. */
+ private static final String ANYCAST_IP = "8.8.8.8";
+ /** Sample C: VPN exit, neither flag present, third blocklist entry without dates. */
+ private static final String VPN_IP = "187.13.250.1";
+
+ private static List services() {
+ return Arrays.asList(TEST_CONTEXT.ipdataService(), TEST_CONTEXT.cachingIpdataService());
+ }
+
+ private static void assertDated(Blocklist entry, String name, String type, int daysSeen, String firstSeen, String lastSeen) {
+ assertEquals(name, entry.getName());
+ assertEquals(type, entry.getType());
+ assertEquals(Integer.valueOf(daysSeen), entry.getDaysSeen());
+ assertEquals(firstSeen, entry.getFirstSeen());
+ assertEquals(lastSeen, entry.getLastSeen());
+ }
+
+ @Test
+ @SneakyThrows
+ public void satelliteFlagIsMapped() {
+ for (IpdataService service : services()) {
+ IpdataModel model = service.ipdata(SATELLITE_IP);
+ assertTrue(model.isSatellite());
+ assertTrue(model.satellite());
+ assertFalse(model.isAnycast());
+ assertFalse(model.isEu());
+ assertEquals("AS14593", model.asn().asn());
+ assertTrue(model.threat().getBlocklists().isEmpty());
+ assertEquals(97, model.threat().getScores().getTrustScore());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void anycastFlagAndDatedBlocklistEntriesAreMapped() {
+ for (IpdataService service : services()) {
+ IpdataModel model = service.ipdata(ANYCAST_IP);
+ assertTrue(model.isAnycast());
+ assertTrue(model.anycast());
+ assertFalse(model.isSatellite());
+ ThreatModel threat = model.threat();
+ assertTrue(threat.isDatacenter());
+ assertEquals(2, threat.getBlocklists().size());
+ assertDated(threat.getBlocklists().get(0), "ipdata", "cloud", 1, "2026-11-02", "2026-11-02");
+ assertDated(threat.getBlocklists().get(1), "ipdata", "open_resolvers", 1, "2026-11-02", "2026-11-02");
+ assertEquals("https://ipdata.co", threat.getBlocklists().get(0).getSite());
+ assertEquals(2, threat.getScores().getTrustScore());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void flagsReadFalseWhenAbsentAndUndatedBlocklistEntriesHaveNullDates() {
+ for (IpdataService service : services()) {
+ IpdataModel model = service.ipdata(VPN_IP);
+ assertFalse(model.isSatellite());
+ assertFalse(model.isAnycast());
+ assertFalse(model.isEu());
+ ThreatModel threat = model.threat();
+ assertTrue(threat.isVpn());
+ assertTrue(threat.isThreat());
+ List blocklists = threat.getBlocklists();
+ assertEquals(6, blocklists.size());
+ assertDated(blocklists.get(0), "Gluetun", "vpn", 2, "2026-10-31", "2026-11-02");
+ assertDated(blocklists.get(1), "ipdata", "cloud", 1, "2026-11-02", "2026-11-02");
+ Blocklist undated = blocklists.get(2);
+ assertEquals("ipdata", undated.getName());
+ assertEquals("https://ipdata.co", undated.getSite());
+ assertEquals("vpn", undated.getType());
+ assertNull(undated.getFirstSeen());
+ assertNull(undated.getLastSeen());
+ assertNull(undated.getDaysSeen());
+ assertDated(blocklists.get(3), "NordVPN", "vpn", 2, "2026-10-31", "2026-11-02");
+ assertDated(blocklists.get(4), "Wikimedia Global Blocklist", "general", 2, "2026-10-31", "2026-11-02");
+ assertDated(blocklists.get(5), "X4BNet", "vpn", 2, "2026-10-31", "2026-11-02");
+ assertEquals(0, threat.getScores().getTrustScore());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void threatEndpointCarriesTheBlocklistDates() {
+ for (IpdataService service : services()) {
+ ThreatModel anycast = service.threat(ANYCAST_IP);
+ assertDated(anycast.getBlocklists().get(1), "ipdata", "open_resolvers", 1, "2026-11-02", "2026-11-02");
+ ThreatModel vpn = service.threat(VPN_IP);
+ assertEquals(6, vpn.getBlocklists().size());
+ assertNull(vpn.getBlocklists().get(2).getDaysSeen());
+ assertEquals("2026-10-31", vpn.getBlocklists().get(5).getFirstSeen());
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void singleFieldEndpointsReturnTheFlags() {
+ for (IpdataService service : services()) {
+ assertTrue(service.isSatellite(SATELLITE_IP));
+ assertTrue(service.isAnycast(ANYCAST_IP));
+ assertFalse(service.isEu(ANYCAST_IP));
+ }
+ IpdataService service = TEST_CONTEXT.ipdataService();
+ service.isSatellite(SATELLITE_IP);
+ assertEquals("/" + SATELLITE_IP + "/is_satellite", MOCK.getLastRawPath());
+ service.isAnycast(ANYCAST_IP);
+ assertEquals("/" + ANYCAST_IP + "/is_anycast", MOCK.getLastRawPath());
+ }
+
+ @Test
+ @SneakyThrows
+ public void fieldSelectionAcceptsTheNewFieldNames() {
+ assertEquals("is_satellite", IS_SATELLITE.name());
+ assertEquals("is_anycast", IS_ANYCAST.name());
+ assertEquals("is_satellite", IS_SATELLITE.toString());
+ assertEquals(Boolean.class, IS_SATELLITE.type());
+ assertEquals(Boolean.class, IS_ANYCAST.type());
+
+ for (IpdataService service : services()) {
+ IpdataModel model = service.getFields(SATELLITE_IP, IS_SATELLITE, IS_ANYCAST, IS_EU);
+ assertTrue(model.isSatellite());
+ assertFalse(model.isAnycast()); // the mock, like the API, sends the key only when it is true
+ assertFalse(model.isEu());
+ assertNull(model.city());
+
+ model = service.getFields(ANYCAST_IP, IS_ANYCAST, THREAT);
+ assertTrue(model.isAnycast());
+ assertFalse(model.isSatellite());
+ assertEquals(Integer.valueOf(1), model.threat().getBlocklists().get(0).getDaysSeen());
+ }
+
+ TEST_CONTEXT.ipdataService().getFields(VPN_IP, IS_SATELLITE, IS_ANYCAST);
+ String query = URLDecoder.decode(MOCK.getLastRawQuery(), "UTF-8");
+ assertTrue(query, query.contains("fields=is_anycast,is_satellite"));
+ }
+
+ @Test
+ @SneakyThrows
+ public void bulkResponsesCarryTheNewFieldsPerItem() {
+ for (IpdataService service : services()) {
+ List models = service.bulk(Arrays.asList(SATELLITE_IP, ANYCAST_IP, VPN_IP));
+ assertEquals(3, models.size());
+
+ assertEquals(SATELLITE_IP, models.get(0).ip());
+ assertTrue(models.get(0).isSatellite());
+ assertFalse(models.get(0).isAnycast());
+
+ assertEquals(ANYCAST_IP, models.get(1).ip());
+ assertTrue(models.get(1).isAnycast());
+ assertFalse(models.get(1).isSatellite());
+ assertDated(models.get(1).threat().getBlocklists().get(1), "ipdata", "open_resolvers", 1, "2026-11-02", "2026-11-02");
+
+ assertEquals(VPN_IP, models.get(2).ip());
+ assertFalse(models.get(2).isSatellite());
+ assertFalse(models.get(2).isAnycast());
+ assertNull(models.get(2).threat().getBlocklists().get(2).getFirstSeen());
+ assertDated(models.get(2).threat().getBlocklists().get(3), "NordVPN", "vpn", 2, "2026-10-31", "2026-11-02");
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void modelRoundTripsTheNewFields() {
+ for (String ip : Arrays.asList(SATELLITE_IP, ANYCAST_IP, VPN_IP)) {
+ IpdataModel model = TEST_CONTEXT.ipdataService().ipdata(ip);
+ // the strict test mapper (fails on unknown properties) must know every key the fixtures carry
+ String expected = TEST_CONTEXT.get("/" + ip, null);
+ TEST_CONTEXT.mapper().readValue(expected, IpdataModel.class);
+ String actual = TEST_CONTEXT.mapper().writeValueAsString(model);
+ TEST_CONTEXT.assertEqualJson(actual, expected, TEST_CONTEXT.configuration().whenIgnoringPaths("time_zone.current_time"));
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ public void decoderIgnoresUnknownKeys() {
+ String ip = "203.0.113.7";
+ ObjectNode response = (ObjectNode) TEST_CONTEXT.mapper().readTree(TEST_CONTEXT.get("/" + VPN_IP, null));
+ response.put("ip", ip);
+ response.put("is_some_future_flag", true);
+ response.putObject("some_future_object").put("level", 3);
+ ((ObjectNode) response.get("threat")).put("is_some_future_threat", true);
+ ((ObjectNode) response.get("threat").get("blocklists").get(0)).put("confidence", 0.9);
+ ((ObjectNode) response.get("asn")).put("some_future_attribute", "x");
+ MOCK.registerFixture(ip, response.toString());
+
+ for (IpdataService service : services()) {
+ IpdataModel model = service.ipdata(ip);
+ assertEquals(ip, model.ip());
+ assertTrue(model.threat().isVpn());
+ assertFalse(model.isAnycast());
+ assertDated(model.threat().getBlocklists().get(0), "Gluetun", "vpn", 2, "2026-10-31", "2026-11-02");
+ assertEquals("AS28573", model.asn().asn());
+
+ ThreatModel threat = service.threat(ip);
+ assertEquals(6, threat.getBlocklists().size());
+ assertNull(threat.getBlocklists().get(2).getLastSeen());
+ }
+ }
+}
diff --git a/src/test/resources/io/ipdata/client/fixture.json b/src/test/resources/io/ipdata/client/fixture.json
index 3275fb2..f5ecb49 100644
--- a/src/test/resources/io/ipdata/client/fixture.json
+++ b/src/test/resources/io/ipdata/client/fixture.json
@@ -1,6 +1,8 @@
{
"ip": "69.78.70.144",
"is_eu": false,
+ "is_satellite": false,
+ "is_anycast": false,
"city": "test-city",
"region": "Illinois",
"region_code": "IL",
diff --git a/src/test/resources/io/ipdata/client/fixtures/187.13.250.1.json b/src/test/resources/io/ipdata/client/fixtures/187.13.250.1.json
new file mode 100644
index 0000000..3e179dc
--- /dev/null
+++ b/src/test/resources/io/ipdata/client/fixtures/187.13.250.1.json
@@ -0,0 +1,118 @@
+{
+ "ip": "187.13.250.1",
+ "is_eu": false,
+ "city": "Rio de Janeiro",
+ "region": "Rio de Janeiro",
+ "region_code": "RJ",
+ "region_type": "state",
+ "country_name": "Brazil",
+ "country_code": "BR",
+ "continent_name": "South America",
+ "continent_code": "SA",
+ "latitude": -22.9068,
+ "longitude": -43.1729,
+ "postal": "20000",
+ "calling_code": "55",
+ "flag": "https://ipdata.co/flags/br.png",
+ "emoji_flag": "🇧🇷",
+ "emoji_unicode": "U+1F1E7 U+1F1F7",
+ "asn": {
+ "asn": "AS28573",
+ "name": "Claro NXT Telecomunicacoes Ltda",
+ "domain": "claro.com.br",
+ "route": "187.13.0.0/16",
+ "type": "isp"
+ },
+ "company": {
+ "name": "Claro NXT Telecomunicacoes Ltda",
+ "domain": "claro.com.br",
+ "network": "187.13.0.0/16",
+ "type": "isp"
+ },
+ "languages": [
+ {
+ "name": "Portuguese",
+ "native": "Português"
+ }
+ ],
+ "currency": {
+ "name": "Brazilian Real",
+ "code": "BRL",
+ "symbol": "R$",
+ "native": "R$",
+ "plural": "Brazilian reals"
+ },
+ "time_zone": {
+ "name": "America/Sao_Paulo",
+ "abbr": "-03",
+ "offset": "-0300",
+ "is_dst": false,
+ "current_time": "2026-11-02T13:00:00.000000-03:00"
+ },
+ "threat": {
+ "is_tor": false,
+ "is_vpn": true,
+ "is_icloud_relay": false,
+ "is_proxy": false,
+ "is_datacenter": true,
+ "is_anonymous": true,
+ "is_known_attacker": true,
+ "is_known_abuser": true,
+ "is_threat": true,
+ "is_bogon": false,
+ "blocklists": [
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "Gluetun",
+ "site": "https://github.com/qdm12/gluetun",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "cloud"
+ },
+ {
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "NordVPN",
+ "site": "https://nordvpn.com",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "Wikimedia Global Blocklist",
+ "site": "https://wikimedia.org",
+ "type": "general"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "X4BNet",
+ "site": "https://github.com/X4BNet/lists_vpn",
+ "type": "vpn"
+ }
+ ],
+ "scores": {
+ "vpn_score": 100,
+ "proxy_score": 99,
+ "threat_score": 100,
+ "trust_score": 0
+ }
+ },
+ "count": "0"
+}
diff --git a/src/test/resources/io/ipdata/client/fixtures/8.8.8.8.json b/src/test/resources/io/ipdata/client/fixtures/8.8.8.8.json
index 8ad870f..4a7013d 100644
--- a/src/test/resources/io/ipdata/client/fixtures/8.8.8.8.json
+++ b/src/test/resources/io/ipdata/client/fixtures/8.8.8.8.json
@@ -1,5 +1,6 @@
{
"ip": "8.8.8.8",
+ "is_anycast": true,
"is_eu": false,
"city": "Mountain View",
"region": "California",
@@ -57,20 +58,37 @@
"threat": {
"is_tor": false,
"is_vpn": false,
+ "is_icloud_relay": false,
"is_proxy": false,
+ "is_datacenter": true,
"is_anonymous": false,
"is_known_attacker": false,
"is_known_abuser": false,
"is_threat": false,
"is_bogon": false,
- "is_icloud_relay": false,
- "is_datacenter": false,
- "blocklists": [],
+ "blocklists": [
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "cloud"
+ },
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "open_resolvers"
+ }
+ ],
"scores": {
- "vpn_score": 0,
- "proxy_score": 0,
- "threat_score": 0,
- "trust_score": 100
+ "vpn_score": 100,
+ "proxy_score": 93,
+ "threat_score": 100,
+ "trust_score": 2
}
},
"count": "1500"
diff --git a/src/test/resources/io/ipdata/client/fixtures/98.97.34.0.json b/src/test/resources/io/ipdata/client/fixtures/98.97.34.0.json
new file mode 100644
index 0000000..2f780b5
--- /dev/null
+++ b/src/test/resources/io/ipdata/client/fixtures/98.97.34.0.json
@@ -0,0 +1,73 @@
+{
+ "ip": "98.97.34.0",
+ "is_satellite": true,
+ "is_eu": false,
+ "city": "Seattle",
+ "region": "Washington",
+ "region_code": "WA",
+ "region_type": "state",
+ "country_name": "United States",
+ "country_code": "US",
+ "continent_name": "North America",
+ "continent_code": "NA",
+ "latitude": 47.6062,
+ "longitude": -122.3321,
+ "postal": "98101",
+ "calling_code": "1",
+ "flag": "https://ipdata.co/flags/us.png",
+ "emoji_flag": "🇺🇸",
+ "emoji_unicode": "U+1F1FA U+1F1F8",
+ "asn": {
+ "asn": "AS14593",
+ "name": "Space Exploration Technologies Corporation",
+ "domain": "spacex.com",
+ "route": "98.97.32.0/21",
+ "type": "business"
+ },
+ "company": {
+ "name": "Space Exploration Technologies Corporation",
+ "domain": "spacex.com",
+ "network": "98.97.32.0/21",
+ "type": "business"
+ },
+ "languages": [
+ {
+ "name": "English",
+ "native": "English"
+ }
+ ],
+ "currency": {
+ "name": "US Dollar",
+ "code": "USD",
+ "symbol": "$",
+ "native": "$",
+ "plural": "US dollars"
+ },
+ "time_zone": {
+ "name": "America/Los_Angeles",
+ "abbr": "PST",
+ "offset": "-0800",
+ "is_dst": false,
+ "current_time": "2026-11-02T08:00:00.000000-08:00"
+ },
+ "threat": {
+ "is_tor": false,
+ "is_vpn": false,
+ "is_icloud_relay": false,
+ "is_proxy": false,
+ "is_datacenter": false,
+ "is_anonymous": false,
+ "is_known_attacker": false,
+ "is_known_abuser": false,
+ "is_threat": false,
+ "is_bogon": false,
+ "blocklists": [],
+ "scores": {
+ "vpn_score": 0,
+ "proxy_score": 1,
+ "threat_score": 8,
+ "trust_score": 97
+ }
+ },
+ "count": "0"
+}