diff --git a/README.md b/README.md index 619f88d..3a0d557 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,12 +144,49 @@ 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. + +`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: ```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"); @@ -164,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/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/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" +}