Skip to content

Commit e574984

Browse files
Merge pull request #19 from ipdata/http-reuse-and-new-fields
Reuse HTTPS connections, negotiate HTTP/2, add new response fields
2 parents cda239d + 6d1d95c commit e574984

16 files changed

Lines changed: 977 additions & 16 deletions

‎README.md‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ An 100% compliant [ipdata.co](https://ipdata.co) API java client.
1111
- [Configuration](#configuration)
1212
- [API](#create-an-instance)
1313
- [Basic Usage](#basic-usage)
14+
- [Connection reuse and HTTP/2](#connection-reuse-and-http2)
1415
- [Single Field Selection](#single-field-selection)
1516
- [Multiple Field Selection](#multiple-field-selection)
1617
- [Bulk data](#bulk-data)
@@ -22,7 +23,7 @@ You can have the library from Maven Central.
2223
<dependency>
2324
<groupId>co.ipdata.client</groupId>
2425
<artifactId>ipdata-java-client</artifactId>
25-
<version>0.2.0</version>
26+
<version>0.3.0</version>
2627
</dependency>
2728
```
2829

@@ -143,12 +144,49 @@ Output:
143144
}
144145
```
145146

147+
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)):
148+
149+
- `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.
150+
- `is_anycast` – `model.isAnycast()`: the address is an anycast address (`8.8.8.8` for example). Same present-only-when-true behaviour.
151+
- `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.
152+
- `threat.blocklists[].last_seen` – `Blocklist.getLastSeen()`: the most recent such day, same format, or `null`.
153+
- `threat.blocklists[].days_seen` – `Blocklist.getDaysSeen()`: the number of days the address was seen on that blocklist, as an `Integer`, or `null`.
154+
155+
#### Connection reuse and HTTP/2
156+
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.
157+
158+
`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.
159+
160+
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`:
161+
```java
162+
IpdataService ipdataService = Ipdata.builder().url(url)
163+
.key("MY_KEY")
164+
.connectTimeout(5, TimeUnit.SECONDS)
165+
.readTimeout(30, TimeUnit.SECONDS)
166+
.get();
167+
168+
IpdataService ipdataService = Ipdata.builder().url(url)
169+
.key("MY_KEY")
170+
.options(new Request.Options(5, TimeUnit.SECONDS, 30, TimeUnit.SECONDS, true))
171+
.get();
172+
```
173+
174+
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:
175+
```java
176+
IpdataService ipdataService = Ipdata.builder().url(url)
177+
.key("MY_KEY")
178+
.feignClient(new ApacheHttpClient(HttpClientBuilder.create().build()))
179+
.get();
180+
```
181+
146182
#### Single Field Selection
147183
If you're interested in only one field from the model capturing an IP address information, The service interface
148184
exposes a method on each available field:
149185

150186
```java
151187
boolean isEu = ipdataService.isEu("1.1.1.1");
188+
boolean isSatellite = ipdataService.isSatellite("1.1.1.1");
189+
boolean isAnycast = ipdataService.isAnycast("1.1.1.1");
152190
AsnModel asn = ipdataService.asn("1.1.1.1");
153191
TimeZone tz = ipdataService.timeZone("1.1.1.1");
154192
ThreatModel threat = ipdataService.threat("1.1.1.1");
@@ -164,6 +202,7 @@ import io.ipdata.client.service.IpdataService;
164202

165203
/* The model will be hydrated by the selected fields only */
166204
IpdataModel model = ipdataService.getFields("1.1.1.1", IpdataField.ASN, IpdataField.CURRENCY);
205+
IpdataModel flags = ipdataService.getFields("1.1.1.1", IpdataField.IS_SATELLITE, IpdataField.IS_ANYCAST);
167206

168207
```
169208

‎pom.xml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<modelVersion>4.0.0</modelVersion>
44
<groupId>co.ipdata.client</groupId>
55
<artifactId>ipdata-java-client</artifactId>
6-
<version>0.2.1</version>
6+
<version>0.3.0</version>
77
<description>A java client for ipdata.co</description>
88
<name>Ipdata java client</name>
99
<url>https://github.com/ipdata/java</url>
@@ -23,7 +23,7 @@
2323
<connection>scm:git:git@github.com:ipdata/java.git</connection>
2424
<developerConnection>scm:git:git@github.com:ipdata/java.git</developerConnection>
2525
<url>https://github.com/ipdata/java</url>
26-
<tag>0.2.1</tag>
26+
<tag>0.3.0</tag>
2727
</scm>
2828
<distributionManagement>
2929
<repository>
@@ -52,6 +52,11 @@
5252
<artifactId>feign-jackson</artifactId>
5353
<version>${version.client.feign}</version>
5454
</dependency>
55+
<dependency>
56+
<groupId>io.github.openfeign</groupId>
57+
<artifactId>feign-java11</artifactId>
58+
<version>${version.client.feign}</version>
59+
</dependency>
5560
<dependency>
5661
<groupId>io.github.openfeign</groupId>
5762
<artifactId>feign-httpclient</artifactId>

‎src/main/java/io/ipdata/client/Ipdata.java‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.ipdata.client;
22

33
import feign.Client;
4+
import feign.Request;
45
import io.ipdata.client.service.CacheConfig;
56
import io.ipdata.client.service.IpdataService;
67
import io.ipdata.client.service.IpdataServiceBuilder;
@@ -47,10 +48,27 @@ public static class Builder {
4748
@Setter
4849
private Logger logger;
4950
/**
50-
* (Optional) Configures a custom Feign client (If you wanna use this with Ribbon or Hystrix for example).
51+
* (Optional) Configures a custom Feign client, for example Apache HttpClient ({@code feign-httpclient}),
52+
* OkHttp ({@code feign-okhttp}) or a Ribbon/Hystrix wrapper.
53+
* <p>
54+
* The default is the JDK HTTP/2 client ({@code feign.http2client.Http2Client} from {@code feign-java11}),
55+
* which keeps one persistent connection pool for the lifetime of the service and multiplexes requests over
56+
* HTTP/2 when the server negotiates it, falling back to HTTP/1.1 keep-alive otherwise. An injected client
57+
* is shared by all calls of the built service (full model, single field and bulk), so configure its
58+
* connection pooling there; the {@code options(Request.Options)} are passed to it on every request.
5159
*/
5260
@Setter
5361
private Client feignClient;
62+
/**
63+
* (Optional) Connect and read timeouts and the redirect policy of the HTTP transport, shared by all calls
64+
* of the built service. Defaults to {@code new Request.Options()}: 10 seconds to connect, 60 seconds to
65+
* read, follow redirects. With the default transport both timeouts must be positive.
66+
*
67+
* @see #connectTimeout(long, TimeUnit)
68+
* @see #readTimeout(long, TimeUnit)
69+
*/
70+
@Setter
71+
private Request.Options options;
5472

5573
/**
5674
* Overrides current cache config with null (no caching).
@@ -86,6 +104,52 @@ public CacheConfigBuilder withCache() {
86104
return new CacheConfigBuilder(this);
87105
}
88106

107+
/**
108+
* Sets the connect timeout of the HTTP transport (default: 10 seconds), keeping the other request options.
109+
* Shorthand for {@code options(Request.Options)} with only the connect timeout changed.
110+
*
111+
* @param timeout the connect timeout, greater than 0
112+
* @param unit the unit of the timeout
113+
* @return this builder
114+
* @see Request.Options
115+
*/
116+
public Builder connectTimeout(long timeout, TimeUnit unit) {
117+
Request.Options current = currentOptions();
118+
this.options = new Request.Options(checkTimeout(timeout, unit), unit,
119+
current.readTimeout(), current.readTimeoutUnit(), current.isFollowRedirects());
120+
return this;
121+
}
122+
123+
/**
124+
* Sets the read timeout of the HTTP transport (default: 60 seconds), keeping the other request options.
125+
* Shorthand for {@code options(Request.Options)} with only the read timeout changed.
126+
*
127+
* @param timeout the read timeout, greater than 0
128+
* @param unit the unit of the timeout
129+
* @return this builder
130+
* @see Request.Options
131+
*/
132+
public Builder readTimeout(long timeout, TimeUnit unit) {
133+
Request.Options current = currentOptions();
134+
this.options = new Request.Options(current.connectTimeout(), current.connectTimeoutUnit(),
135+
checkTimeout(timeout, unit), unit, current.isFollowRedirects());
136+
return this;
137+
}
138+
139+
private Request.Options currentOptions() {
140+
return options == null ? new Request.Options() : options;
141+
}
142+
143+
private static long checkTimeout(long timeout, TimeUnit unit) {
144+
if (timeout <= 0) {
145+
throw new IllegalArgumentException("timeout must be greater than 0");
146+
}
147+
if (unit == null) {
148+
throw new IllegalArgumentException("timeout unit should not be null");
149+
}
150+
return timeout;
151+
}
152+
89153
/**
90154
* @return An Ipdata api facade
91155
* @see io.ipdata.client.service.IpdataService
@@ -97,7 +161,7 @@ public IpdataService get() {
97161
if (url == null) {
98162
throw new IllegalArgumentException("Target URL can't be null");
99163
}
100-
return IpdataServiceBuilder.of(url, key, cacheConfig, logger, feignClient).build();
164+
return IpdataServiceBuilder.of(url, key, cacheConfig, logger, feignClient, options).build();
101165
}
102166

103167
}

‎src/main/java/io/ipdata/client/model/Blocklist.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.ipdata.client.model;
22

3+
import com.fasterxml.jackson.annotation.JsonProperty;
34
import lombok.Getter;
45
import lombok.ToString;
56

@@ -8,4 +9,21 @@ public class Blocklist {
89
private String name;
910
private String site;
1011
private String type;
12+
/**
13+
* First day the address was seen on this blocklist, as a {@code YYYY-MM-DD} (UTC) string, or {@code null}
14+
* when the feed does not report it.
15+
*/
16+
@JsonProperty("first_seen")
17+
private String firstSeen;
18+
/**
19+
* Most recent day the address was seen on this blocklist, as a {@code YYYY-MM-DD} (UTC) string, or
20+
* {@code null} when the feed does not report it.
21+
*/
22+
@JsonProperty("last_seen")
23+
private String lastSeen;
24+
/**
25+
* Number of days the address was seen on this blocklist, or {@code null} when the feed does not report it.
26+
*/
27+
@JsonProperty("days_seen")
28+
private Integer daysSeen;
1129
}

‎src/main/java/io/ipdata/client/model/IpdataModel.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ public class IpdataModel {
1414
private String ip;
1515
@JsonProperty("is_eu")
1616
private boolean eu;
17+
/**
18+
* The address belongs to a satellite ISP. The API sends the key only when it is true, so the field reads
19+
* {@code false} when it is absent.
20+
*/
21+
@JsonProperty("is_satellite")
22+
private boolean satellite;
23+
/**
24+
* The address is an anycast address. The API sends the key only when it is true, so the field reads
25+
* {@code false} when it is absent.
26+
*/
27+
@JsonProperty("is_anycast")
28+
private boolean anycast;
1729
private String city;
1830
private String organisation;
1931

@@ -44,4 +56,12 @@ public class IpdataModel {
4456
public boolean isEu() {
4557
return eu;
4658
}
59+
60+
public boolean isSatellite() {
61+
return satellite;
62+
}
63+
64+
public boolean isAnycast() {
65+
return anycast;
66+
}
4767
}

‎src/main/java/io/ipdata/client/service/IpdataField.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
public class IpdataField<T> {
1515
public static final IpdataField<String> IP = new IpdataField<String>("ip", String.class);
1616
public static final IpdataField<Boolean> IS_EU = new IpdataField<Boolean>("is_eu", Boolean.class);
17+
public static final IpdataField<Boolean> IS_SATELLITE = new IpdataField<Boolean>("is_satellite", Boolean.class);
18+
public static final IpdataField<Boolean> IS_ANYCAST = new IpdataField<Boolean>("is_anycast", Boolean.class);
1719
public static final IpdataField<String> CITY = new IpdataField<String>("city", String.class);
1820
public static final IpdataField<String> REGION = new IpdataField<String>("region", String.class);
1921
public static final IpdataField<String> REGION_CODE = new IpdataField<String>("region_code", String.class);

‎src/main/java/io/ipdata/client/service/IpdataInternalSingleFieldClient.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ interface IpdataInternalSingleFieldClient {
1616
@RequestLine("GET /{ip}/is_eu")
1717
boolean isEu(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
1818

19+
@RequestLine("GET /{ip}/is_satellite")
20+
boolean isSatellite(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
21+
22+
@RequestLine("GET /{ip}/is_anycast")
23+
boolean isAnycast(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
24+
1925
@RequestLine("GET /{ip}/city")
2026
String getCity(@Param(value = "ip", encoded = true) String ip) throws IpdataException;
2127

‎src/main/java/io/ipdata/client/service/IpdataService.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
* <p>
1515
* Also exposes single-field accessors (e.g. {@code getCountryName}, {@code getCity})
1616
* inherited from {@link IpdataInternalSingleFieldClient}.
17+
* <p>
18+
* A service owns one persistent HTTP connection pool, shared by all of its methods: requests are multiplexed
19+
* over HTTP/2 when the server negotiates it and use HTTP/1.1 keep-alive otherwise. Create one instance per
20+
* API key or endpoint, keep it for the lifetime of the application and share it between threads (it is
21+
* thread-safe); every new instance pays a fresh TLS handshake. There is nothing to close.
1722
*
1823
* @see io.ipdata.client.Ipdata#builder()
1924
*/

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

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.google.common.cache.CacheLoader;
1010
import feign.Client;
1111
import feign.Feign;
12+
import feign.Request;
13+
import feign.http2client.Http2Client;
1214
import feign.jackson.JacksonDecoder;
1315
import feign.jackson.JacksonEncoder;
1416
import io.ipdata.client.model.*;
@@ -20,6 +22,15 @@
2022

2123
import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE;
2224

25+
/**
26+
* Assembles an {@link IpdataService}.
27+
* <p>
28+
* The full-model proxy ({@code ipdata}, {@code bulk}, {@code getFields}) and the single-field proxy
29+
* ({@code asn}, {@code threat}, ...) share one HTTP transport, so every call of the resulting service goes
30+
* through the same persistent connection pool. The default transport is the JDK HTTP/2 client
31+
* ({@link Http2Client} from {@code feign-java11}): HTTP/2 multiplexing when the server negotiates it,
32+
* HTTP/1.1 keep-alive otherwise.
33+
*/
2334
@RequiredArgsConstructor(staticName = "of")
2435
public class IpdataServiceBuilder {
2536

@@ -28,6 +39,27 @@ public class IpdataServiceBuilder {
2839
private final CacheConfig cacheConfig;
2940
private final Logger logger;
3041
private final Client httpClient;
42+
/**
43+
* Connect and read timeouts and the redirect policy, applied to both Feign proxies and used to build the
44+
* default transport. {@code null} means {@code new Request.Options()}: 10 seconds connect, 60 seconds read,
45+
* follow redirects.
46+
*/
47+
private final Request.Options options;
48+
49+
/**
50+
* Same as the generated {@code of(url, key, cacheConfig, logger, httpClient, options)} factory with default
51+
* request options.
52+
*
53+
* @param url the API endpoint
54+
* @param key the API key
55+
* @param cacheConfig the cache configuration, or {@code null} for no caching
56+
* @param logger the logger to use, or {@code null} for the default one
57+
* @param httpClient a custom Feign client shared by all calls, or {@code null} for the JDK HTTP/2 client
58+
* @return a builder
59+
*/
60+
public static IpdataServiceBuilder of(URL url, String key, CacheConfig cacheConfig, Logger logger, Client httpClient) {
61+
return of(url, key, cacheConfig, logger, httpClient, null);
62+
}
3163

3264
public IpdataService build() {
3365
final ObjectMapper mapper = new ObjectMapper();
@@ -40,16 +72,25 @@ public IpdataService build() {
4072
final Logger customLogger = this.logger == null ? LoggerFactory.getLogger(IpdataService.class) : this.logger;
4173
final ApiErrorDecoder apiErrorDecoder = new ApiErrorDecoder(mapper, customLogger);
4274

75+
final Request.Options requestOptions = this.options == null ? new Request.Options() : this.options;
76+
// One transport for both proxies: the java.net.http client behind Http2Client owns a single connection
77+
// pool that lives as long as the service does. The very same options instance must be handed to
78+
// Http2Client and to Feign (.options below): Http2Client silently builds a new HttpClient, and thus a new
79+
// connection, for every request whose options differ from the ones it was constructed with.
80+
final Client transport = new Ipv6SafeClient(httpClient != null ? httpClient : new Http2Client(requestOptions));
81+
4382
final IpdataInternalClient client = Feign.builder()
44-
.client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
83+
.client(transport)
84+
.options(requestOptions)
4585
.decoder(new JacksonDecoder(mapper))
4686
.encoder(new JacksonEncoder(mapper))
4787
.requestInterceptor(keyRequestInterceptor)
4888
.errorDecoder(apiErrorDecoder)
4989
.target(IpdataInternalClient.class, url.toString());
5090

5191
final IpdataInternalSingleFieldClient singleFieldClient = Feign.builder()
52-
.client(new Ipv6SafeClient(httpClient == null ? new Client.Default(null, null) : httpClient))
92+
.client(transport)
93+
.options(requestOptions)
5394
.decoder(new FieldDecoder(mapper))
5495
.encoder(new JacksonEncoder(mapper))
5596
.requestInterceptor(keyRequestInterceptor)

0 commit comments

Comments
 (0)