From 3685a510870928172630c9ada8c958fac5bf6dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:33:10 +0000 Subject: [PATCH 1/2] Reuse one HTTP/2 connection pool across all IpdataService calls Both Feign proxies behind an IpdataService (the full-model/bulk client and the single-field client) were built with their own feign.Client.Default, i.e. HttpURLConnection, so every lookup paid for its own connection setup and TLS handshake. They now share one transport: feign-java11's Http2Client over the JDK java.net.http client, which keeps a single connection pool for the lifetime of the service, multiplexes requests over HTTP/2 when the server negotiates it and falls back to HTTP/1.1 keep-alive otherwise. The same feign.Request.Options instance is handed to Http2Client and to both Feign builders: Http2Client silently builds a new HttpClient (and so a new connection) for every request whose options differ from the ones it was constructed with. Timeouts are configurable through the builder (connectTimeout/readTimeout or options(Request.Options)); the existing IpdataServiceBuilder.of(url, key, cacheConfig, logger, client) factory is kept and an injected feignClient keeps working, shared by all calls of the service. Tests: MockIpdataServer records the client socket address and protocol of every request; ConnectionReuseTest proves that sequential full-model, single-field, selected-fields and bulk calls arrive on one connection (with default and custom options), that an injected client serves both proxies, that the builder options reach the transport on every call and that the default transport works against the plain-HTTP mock (h2c upgrade attempt, HTTP/1.1 fallback). Version 0.3.0 (pom version, scm tag, README install snippet); the README gains a "Connection reuse and HTTP/2" section. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- README.md | 30 +- pom.xml | 9 +- src/main/java/io/ipdata/client/Ipdata.java | 68 ++++- .../ipdata/client/service/IpdataService.java | 5 + .../client/service/IpdataServiceBuilder.java | 45 ++- .../io/ipdata/client/ConnectionReuseTest.java | 269 ++++++++++++++++++ .../io/ipdata/client/MockIpdataServer.java | 28 ++ 7 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 src/test/java/io/ipdata/client/ConnectionReuseTest.java diff --git a/README.md b/README.md index 619f88d..da67312 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ An 100% compliant [ipdata.co](https://ipdata.co) API java client. - [Configuration](#configuration) - [API](#create-an-instance) - [Basic Usage](#basic-usage) + - [Connection reuse and HTTP/2](#connection-reuse-and-http2) - [Single Field Selection](#single-field-selection) - [Multiple Field Selection](#multiple-field-selection) - [Bulk data](#bulk-data) @@ -22,7 +23,7 @@ You can have the library from Maven Central. co.ipdata.client ipdata-java-client - 0.2.0 + 0.3.0 ``` @@ -143,6 +144,33 @@ Output: } ``` +#### Connection reuse and HTTP/2 +An `IpdataService` keeps a persistent HTTPS connection pool for as long as it lives. Create one service per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new service pays a fresh TLS handshake. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise. + +`IpdataService` is thread-safe: share one instance between threads. The full-model, single-field and bulk calls all go through the same pool. There is nothing to close: the default transport is the JDK's `java.net.http.HttpClient` (through `feign-java11`), which has no close method on Java 11 and drops idle connections on its own (it has no per-instance idle setting; the `jdk.httpclient.keepalive.timeout` system property, 1200 seconds by default, applies). A request whose transport call fails with an `IOException`, for example on a pooled connection the server had already closed, is re-sent by Feign's default `Retryer`, exactly as before; HTTP error statuses are never retried. + +Timeouts default to 10 seconds to connect and 60 seconds to read and are set on the builder, either one by one or as any `feign.Request.Options`: +```java +IpdataService ipdataService = Ipdata.builder().url(url) + .key("MY_KEY") + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .get(); + +IpdataService ipdataService = Ipdata.builder().url(url) + .key("MY_KEY") + .options(new Request.Options(5, TimeUnit.SECONDS, 30, TimeUnit.SECONDS, true)) + .get(); +``` + +To use another HTTP stack, pass a `feign.Client` to `feignClient(...)`, for example Apache HttpClient (`io.github.openfeign:feign-httpclient`) or OkHttp (`io.github.openfeign:feign-okhttp`). The injected client is shared by all calls of that service, so configure its connection pooling there; the timeouts above are handed to it on every request: +```java +IpdataService ipdataService = Ipdata.builder().url(url) + .key("MY_KEY") + .feignClient(new ApacheHttpClient(HttpClientBuilder.create().build())) + .get(); +``` + #### Single Field Selection If you're interested in only one field from the model capturing an IP address information, The service interface exposes a method on each available field: diff --git a/pom.xml b/pom.xml index 4b3a67b..b20011d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 co.ipdata.client ipdata-java-client - 0.2.1 + 0.3.0 A java client for ipdata.co Ipdata java client https://github.com/ipdata/java @@ -23,7 +23,7 @@ scm:git:git@github.com:ipdata/java.git scm:git:git@github.com:ipdata/java.git https://github.com/ipdata/java - 0.2.1 + 0.3.0 @@ -52,6 +52,11 @@ feign-jackson ${version.client.feign} + + io.github.openfeign + feign-java11 + ${version.client.feign} + 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/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..02c0f8e 100644 --- a/src/test/java/io/ipdata/client/MockIpdataServer.java +++ b/src/test/java/io/ipdata/client/MockIpdataServer.java @@ -15,6 +15,7 @@ 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; @@ -30,6 +31,9 @@ public class MockIpdataServer { private final Map fixtures = new HashMap<>(); private final ObjectMapper mapper = new ObjectMapper(); private volatile String lastRawPath; + 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,6 +63,28 @@ public String getLastRawPath() { return lastRawPath; } + /** @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"}; for (String ip : ips) { @@ -75,6 +101,8 @@ 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(); From 6d1d95ca22040f45122de770ad22c289cce4714c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:38:26 +0000 Subject: [PATCH 2/2] Add is_satellite, is_anycast and blocklist first/last/days seen fields The API now flags satellite ISP addresses (is_satellite) and anycast addresses (is_anycast) next to is_eu, sending each key only when it is true, and every threat.blocklists entry may carry first_seen and last_seen (YYYY-MM-DD, UTC) and days_seen; some feeds, such as ipdata's own vpn feed, omit all three. IpdataModel gains primitive booleans mapped like is_eu (isSatellite(), isAnycast()) that read false when the key is absent. Blocklist gains nullable String firstSeen/lastSeen and Integer daysSeen; the dates stay strings (no new dependency, no format coupling). IpdataField gains IS_SATELLITE and IS_ANYCAST for getFields(...), and the single-field service methods isSatellite(ip) and isAnycast(ip) follow the existing GET /{ip}/{field} pattern. The Jackson decoder already ignores unknown properties (FAIL_ON_UNKNOWN_PROPERTIES is false); a test now covers it. Tests: fixtures built from the API samples (98.97.34.0 satellite ISP, 8.8.8.8 extended with is_anycast and two dated blocklist entries, 187.13.250.1 VPN exit whose third blocklist entry has no dates); NewResponseFieldsTest asserts the typed values, the false-when-absent behaviour, the single-field endpoints, field selection with the new names, bulk responses and unknown-key tolerance, with and without the cache. The README documents the five fields and the new methods. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- README.md | 11 + .../io/ipdata/client/model/Blocklist.java | 18 ++ .../io/ipdata/client/model/IpdataModel.java | 20 ++ .../io/ipdata/client/service/IpdataField.java | 2 + .../IpdataInternalSingleFieldClient.java | 6 + .../io/ipdata/client/MockIpdataServer.java | 25 +- .../ipdata/client/NewResponseFieldsTest.java | 232 ++++++++++++++++++ .../resources/io/ipdata/client/fixture.json | 2 + .../ipdata/client/fixtures/187.13.250.1.json | 118 +++++++++ .../io/ipdata/client/fixtures/8.8.8.8.json | 32 ++- .../io/ipdata/client/fixtures/98.97.34.0.json | 73 ++++++ 11 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 src/test/java/io/ipdata/client/NewResponseFieldsTest.java create mode 100644 src/test/resources/io/ipdata/client/fixtures/187.13.250.1.json create mode 100644 src/test/resources/io/ipdata/client/fixtures/98.97.34.0.json diff --git a/README.md b/README.md index da67312..3a0d557 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,14 @@ Output: } ``` +The following fields were added to the API responses recently and are mapped by the model (every other field is described in the [API reference](https://docs.ipdata.co/api-reference/response-fields)): + +- `is_satellite` – `model.isSatellite()`: the address belongs to a satellite ISP. The API sends the key only when it is `true`; the model reads `false` when it is absent. +- `is_anycast` – `model.isAnycast()`: the address is an anycast address (`8.8.8.8` for example). Same present-only-when-true behaviour. +- `threat.blocklists[].first_seen` – `Blocklist.getFirstSeen()`: the first day the address was seen on that blocklist, as a `YYYY-MM-DD` (UTC) string, or `null` when the feed does not report it. +- `threat.blocklists[].last_seen` – `Blocklist.getLastSeen()`: the most recent such day, same format, or `null`. +- `threat.blocklists[].days_seen` – `Blocklist.getDaysSeen()`: the number of days the address was seen on that blocklist, as an `Integer`, or `null`. + #### Connection reuse and HTTP/2 An `IpdataService` keeps a persistent HTTPS connection pool for as long as it lives. Create one service per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new service pays a fresh TLS handshake. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise. @@ -177,6 +185,8 @@ exposes a method on each available field: ```java boolean isEu = ipdataService.isEu("1.1.1.1"); +boolean isSatellite = ipdataService.isSatellite("1.1.1.1"); +boolean isAnycast = ipdataService.isAnycast("1.1.1.1"); AsnModel asn = ipdataService.asn("1.1.1.1"); TimeZone tz = ipdataService.timeZone("1.1.1.1"); ThreatModel threat = ipdataService.threat("1.1.1.1"); @@ -192,6 +202,7 @@ import io.ipdata.client.service.IpdataService; /* The model will be hydrated by the selected fields only */ IpdataModel model = ipdataService.getFields("1.1.1.1", IpdataField.ASN, IpdataField.CURRENCY); +IpdataModel flags = ipdataService.getFields("1.1.1.1", IpdataField.IS_SATELLITE, IpdataField.IS_ANYCAST); ``` 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/test/java/io/ipdata/client/MockIpdataServer.java b/src/test/java/io/ipdata/client/MockIpdataServer.java index 02c0f8e..e8c87e9 100644 --- a/src/test/java/io/ipdata/client/MockIpdataServer.java +++ b/src/test/java/io/ipdata/client/MockIpdataServer.java @@ -19,6 +19,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class MockIpdataServer { @@ -28,9 +29,10 @@ 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<>()); @@ -63,6 +65,23 @@ 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; @@ -86,7 +105,8 @@ public List getRemoteAddresses() { } 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)) { @@ -107,6 +127,7 @@ private void handleRequest(HttpExchange exchange) throws IOException { 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" +}