diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8d070bd
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: Java checks
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java: ['8', '21']
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ - run: mvn --batch-mode --no-transfer-progress clean verify
+ env:
+ IPGEO_RUN_LIVE_TESTS: 'false'
+ IPGEO_RUN_LIVE_HARDENING: 'false'
diff --git a/README.md b/README.md
index 11c615c..994aaec 100644
--- a/README.md
+++ b/README.md
@@ -320,6 +320,7 @@ Language values supported by `.lang(...)`:
- `Language.KO` for Korean
- `Language.FA` for Persian
- `Language.PT` for Portuguese
+- `Language.AR` for Arabic
Dot-separated field paths can be passed to `.fields(...)` and `.excludes(...)` to control which parts of the response are returned.
@@ -690,7 +691,7 @@ String full = JsonOutput.toPrettyJson(response.data(), JsonOutputMode.FULL);
## Error Handling
-All SDK exceptions extend `IpGeolocationException`.
+All SDK exceptions extend `IpGeolocationException`. If an error includes your API key, the SDK replaces it with `[REDACTED]`. The error type and HTTP status stay the same. Some technical error details may be shortened to keep the key hidden.
| HTTP Status | Exception |
|-------------|-----------|
diff --git a/pom.xml b/pom.xml
index 6a6a3c9..4fb7394 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,15 +38,15 @@
8
UTF-8
- 2.21.7
+ 2.21.8
5.11.4
3.27.7
- 0.8.12
+ 0.8.16
0.90
0.90
0.90
- 3.3.1
- 3.8.0
+ 3.4.0
+ 3.12.0
3.2.7
0.10.0
@@ -99,10 +99,36 @@
+
+ org.apache.maven.plugins
+ maven-clean-plugin
+ 3.5.0
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 4.0.3
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.5.0
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 3.6.1
+
+
+
+
org.apache.maven.plugins
maven-jar-plugin
- 3.4.2
+ 3.5.1
@@ -115,22 +141,48 @@
org.apache.maven.plugins
maven-compiler-plugin
- 3.13.0
+ 3.16.0
${maven.compiler.release}
+
+ org.apache.maven.plugins
+ maven-install-plugin
+ 3.2.0
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+ 3.2.0
+
+
org.apache.maven.plugins
maven-surefire-plugin
- 3.5.2
+ 3.5.6
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 3.6.1
+
+
org.jacoco
jacoco-maven-plugin
${jacoco.version}
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 3.6.1
+
+
prepare-agent
@@ -184,7 +236,7 @@
org.apache.maven.plugins
maven-enforcer-plugin
- 3.5.0
+ 3.7.0
enforce-java
@@ -218,6 +270,18 @@
org.apache.maven.plugins
maven-source-plugin
${maven.source.plugin.version}
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 4.0.3
+
+
+ io.airlift
+ aircompressor
+ 2.0.3
+
+
attach-sources
@@ -232,6 +296,23 @@
org.apache.maven.plugins
maven-javadoc-plugin
${maven.javadoc.plugin.version}
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 4.0.3
+
+
+ io.airlift
+ aircompressor
+ 2.0.3
+
+
+ commons-beanutils
+ commons-beanutils
+ 1.11.0
+
+
${maven.compiler.release}
all,-missing
@@ -252,6 +333,28 @@
org.apache.maven.plugins
maven-gpg-plugin
${maven.gpg.plugin.version}
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 4.0.3
+
+
+ org.bouncycastle
+ bcpg-jdk18on
+ 1.85
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.85
+
+
+ org.bouncycastle
+ bcutil-jdk18on
+ 1.85
+
+
sign-artifacts
@@ -268,6 +371,28 @@
central-publishing-maven-plugin
${central.publishing.plugin.version}
true
+
+
+ org.codehaus.plexus
+ plexus-utils
+ 3.6.1
+
+
+ com.google.guava
+ guava
+ 33.7.2-jre
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ 5.6.3
+
+
central
true
diff --git a/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java b/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java
index 10abe65..5d0e032 100644
--- a/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java
+++ b/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java
@@ -8,6 +8,7 @@
import io.ipgeolocation.sdk.exceptions.TransportException;
import io.ipgeolocation.sdk.exceptions.ValidationException;
import io.ipgeolocation.sdk.internal.HttpExecutor;
+import io.ipgeolocation.sdk.internal.ErrorSanitizer;
import io.ipgeolocation.sdk.internal.HttpRequestData;
import io.ipgeolocation.sdk.internal.HttpResponseData;
import io.ipgeolocation.sdk.internal.JavaNetHttpExecutor;
@@ -46,6 +47,7 @@ public final class IpGeolocationClient implements AutoCloseable {
private final ObjectMapper objectMapper;
private final HttpExecutor executor;
private final ResponseMapper responseMapper;
+ private final ErrorSanitizer errors;
/**
* Creates a client that uses the provided SDK configuration.
@@ -63,7 +65,8 @@ public IpGeolocationClient(IpGeolocationClientConfig config) {
this.config = Objects.requireNonNull(config, "config must not be null");
this.executor = Objects.requireNonNull(executor, "executor must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
- this.responseMapper = new ResponseMapper(this.objectMapper);
+ this.responseMapper = new ResponseMapper(this.objectMapper, config.apiKey());
+ this.errors = new ErrorSanitizer(config.apiKey());
}
/**
* Executes a single-IP lookup request and returns typed data plus response metadata.
@@ -249,7 +252,7 @@ private HttpRequestData buildBulkHttpRequest(
try {
payload = objectMapper.writeValueAsString(new BulkGeolocationRequestBody(request.ips()));
} catch (JsonProcessingException e) {
- throw new SerializationException("Failed to serialize bulk lookup request body", e);
+ throw new SerializationException("Failed to serialize bulk lookup request body", errors.cause(e));
}
Map> headers = new LinkedHashMap>();
@@ -318,12 +321,12 @@ private HttpResponseData executeOnce(HttpRequestData request, Duration timeout)
try {
return executor.send(request);
} catch (SocketTimeoutException e) {
- throw new RequestTimeoutException("HTTP request timed out", e);
+ throw new RequestTimeoutException("HTTP request timed out", errors.cause(e));
} catch (IOException e) {
- throw new TransportException("HTTP transport error", e);
+ throw new TransportException("HTTP transport error", errors.cause(e));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
- throw new TransportException("Request execution was interrupted", e);
+ throw new TransportException("Request execution was interrupted", errors.cause(e));
}
}
diff --git a/src/main/java/io/ipgeolocation/sdk/Language.java b/src/main/java/io/ipgeolocation/sdk/Language.java
index 375d50f..521affd 100644
--- a/src/main/java/io/ipgeolocation/sdk/Language.java
+++ b/src/main/java/io/ipgeolocation/sdk/Language.java
@@ -17,7 +17,8 @@ public enum Language {
IT("it"),
KO("ko"),
FA("fa"),
- PT("pt");
+ PT("pt"),
+ AR("ar");
private final String code;
diff --git a/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java b/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java
new file mode 100644
index 0000000..15946ff
--- /dev/null
+++ b/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java
@@ -0,0 +1,29 @@
+package io.ipgeolocation.sdk.internal;
+
+import java.io.IOException;
+
+public final class ErrorSanitizer {
+ private final String apiKey;
+ private final String encodedApiKey;
+
+ public ErrorSanitizer(String apiKey) {
+ this.apiKey = apiKey;
+ this.encodedApiKey = Compat.isBlank(apiKey) ? null : UriBuilder.encode(apiKey, "UTF-8");
+ }
+
+ public String message(String value) {
+ if (value == null || encodedApiKey == null) {
+ return value;
+ }
+ return value.replace(encodedApiKey, "[REDACTED]").replace(apiKey, "[REDACTED]");
+ }
+
+ public Throwable cause(Throwable error) {
+ if (encodedApiKey == null) {
+ return error;
+ }
+ IOException sanitized = new IOException(error.getClass().getName());
+ sanitized.setStackTrace(error.getStackTrace());
+ return sanitized;
+ }
+}
diff --git a/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java b/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java
index 05eeb6f..5d983c7 100644
--- a/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java
+++ b/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java
@@ -122,11 +122,13 @@ private String readBody(HttpURLConnection connection, int statusCode) throws IOE
}
private static int toMillis(Duration duration) {
- long millis = duration.toMillis();
- if (millis > Integer.MAX_VALUE) {
+ if (duration.isNegative() || duration.isZero()) {
+ throw new IllegalArgumentException("timeout must be greater than zero");
+ }
+ if (duration.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) >= 0) {
return Integer.MAX_VALUE;
}
- return (int) millis;
+ return (int) Math.max(1L, duration.toMillis());
}
interface ConnectionFactory {
diff --git a/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java b/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java
index 4bb90db..785d649 100644
--- a/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java
+++ b/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java
@@ -28,23 +28,33 @@
public final class ResponseMapper {
private final ObjectMapper objectMapper;
+ private final ErrorSanitizer errors;
public ResponseMapper(ObjectMapper objectMapper) {
+ this(objectMapper, null);
+ }
+
+ public ResponseMapper(ObjectMapper objectMapper, String apiKey) {
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
+ this.errors = new ErrorSanitizer(apiKey);
}
public T readBody(String body, Class type) {
try {
- return objectMapper.readValue(body, type);
+ T result = objectMapper.readValue(body, type);
+ if (result == null) {
+ throw new IOException("Expected a JSON object");
+ }
+ return result;
} catch (IOException e) {
- throw new SerializationException("Failed to deserialize API response", e);
+ throw new SerializationException("Failed to deserialize API response", errors.cause(e));
}
}
public List parseBulkResponse(String body) {
try {
JsonNode root = objectMapper.readTree(body);
- if (!root.isArray()) {
+ if (root == null || !root.isArray()) {
throw new SerializationException(
"Failed to deserialize bulk response: expected an array payload",
new IllegalArgumentException("Unexpected JSON token"));
@@ -52,6 +62,9 @@ public List parseBulkResponse(String body) {
List result = new ArrayList<>(root.size());
for (JsonNode node : root) {
+ if (!node.isObject()) {
+ throw new IOException("Expected a JSON object for each bulk result");
+ }
if (node.has("message") && !node.has("ip")) {
ErrorResponse error = objectMapper.treeToValue(node, ErrorResponse.class);
result.add(new BulkLookupError(error.message()));
@@ -62,7 +75,7 @@ public List parseBulkResponse(String body) {
}
return result;
} catch (IOException e) {
- throw new SerializationException("Failed to deserialize bulk lookup response", e);
+ throw new SerializationException("Failed to deserialize bulk lookup response", errors.cause(e));
}
}
@@ -145,9 +158,10 @@ private String extractApiMessage(String body) {
}
try {
ErrorResponse err = objectMapper.readValue(body, ErrorResponse.class);
- return err.message();
+ return err == null ? null : errors.message(err.message());
} catch (IOException ignored) {
- return body.length() > 512 ? body.substring(0, 512) : body;
+ String message = errors.message(body);
+ return message.length() > 512 ? message.substring(0, 512) : message;
}
}
}
diff --git a/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java b/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java
index aeeea42..b2f79f2 100644
--- a/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java
+++ b/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java
@@ -36,7 +36,11 @@ public URI build() {
queryParams.forEach((k, v) -> joiner.add(encode(k) + "=" + encode(v)));
full.append('?').append(joiner);
}
- return URI.create(full.toString());
+ try {
+ return URI.create(full.toString());
+ } catch (IllegalArgumentException invalidUri) {
+ throw new IllegalArgumentException("Invalid API request URI");
+ }
}
private static String encode(String value) {
diff --git a/src/main/java/io/ipgeolocation/sdk/model/Network.java b/src/main/java/io/ipgeolocation/sdk/model/Network.java
index e125b06..3047642 100644
--- a/src/main/java/io/ipgeolocation/sdk/model/Network.java
+++ b/src/main/java/io/ipgeolocation/sdk/model/Network.java
@@ -17,20 +17,33 @@ public final class Network {
private final String connectionType;
private final String route;
private final Boolean isAnycast;
+ private final Boolean isCdn;
+ private final String cdnProviderName;
+
+ public Network(
+ String connectionType, String route, Boolean isAnycast) {
+ this(connectionType, route, isAnycast, null, null);
+ }
@JsonCreator
public Network(
@JsonProperty("connection_type") String connectionType,
@JsonProperty("route") String route,
- @JsonProperty("is_anycast") Boolean isAnycast) {
+ @JsonProperty("is_anycast") Boolean isAnycast,
+ @JsonProperty("is_cdn") Boolean isCdn,
+ @JsonProperty("cdn_provider_name") String cdnProviderName) {
this.connectionType = connectionType;
this.route = route;
this.isAnycast = isAnycast;
+ this.isCdn = isCdn;
+ this.cdnProviderName = cdnProviderName;
}
public String connectionType() { return connectionType; }
public String route() { return route; }
public Boolean isAnycast() { return isAnycast; }
+ public Boolean isCdn() { return isCdn; }
+ public String cdnProviderName() { return cdnProviderName; }
@Override
public boolean equals(Object other) {
@@ -43,12 +56,14 @@ public boolean equals(Object other) {
Network that = (Network) other;
return Objects.equals(connectionType, that.connectionType)
&& Objects.equals(route, that.route)
- && Objects.equals(isAnycast, that.isAnycast);
+ && Objects.equals(isAnycast, that.isAnycast)
+ && Objects.equals(isCdn, that.isCdn)
+ && Objects.equals(cdnProviderName, that.cdnProviderName);
}
@Override
public int hashCode() {
- return Objects.hash(connectionType, route, isAnycast);
+ return Objects.hash(connectionType, route, isAnycast, isCdn, cdnProviderName);
}
@Override
@@ -59,6 +74,10 @@ public String toString() {
+ route
+ "', isAnycast="
+ isAnycast
- + '}';
+ + ", isCdn="
+ + isCdn
+ + ", cdnProviderName='"
+ + cdnProviderName
+ + "'}";
}
}
diff --git a/src/main/java/io/ipgeolocation/sdk/model/Security.java b/src/main/java/io/ipgeolocation/sdk/model/Security.java
index 3bddab3..0f12731 100644
--- a/src/main/java/io/ipgeolocation/sdk/model/Security.java
+++ b/src/main/java/io/ipgeolocation/sdk/model/Security.java
@@ -40,6 +40,47 @@ public final class Security {
private final Boolean isCloudProvider;
private final String cloudProviderName;
+ @JsonProperty("proxy_days_seen_30d")
+ private final Integer proxyDaysSeen30d;
+ @JsonProperty("proxy_days_seen_7d")
+ private final Integer proxyDaysSeen7d;
+ @JsonProperty("proxy_provider_count_30d")
+ private final Integer proxyProviderCount30d;
+ private final Double botConfidenceScore;
+ private final String botOperatorName;
+ private final String botType;
+ private final Boolean isKnownGoodBot;
+ private final String botLastSeen;
+ private final Boolean isCorporateGateway;
+ private final String corporateGatewayType;
+ private final String corporateGatewayProviderName;
+
+ public Security(
+ Double threatScore,
+ Boolean isTor,
+ Boolean isProxy,
+ List proxyProviderNames,
+ Double proxyConfidenceScore,
+ String proxyLastSeen,
+ Boolean isResidentialProxy,
+ Boolean isVpn,
+ List vpnProviderNames,
+ Double vpnConfidenceScore,
+ String vpnLastSeen,
+ Boolean isRelay,
+ String relayProviderName,
+ Boolean isAnonymous,
+ Boolean isKnownAttacker,
+ Boolean isBot,
+ Boolean isSpam,
+ Boolean isCloudProvider,
+ String cloudProviderName) {
+ this(threatScore, isTor, isProxy, proxyProviderNames, proxyConfidenceScore, proxyLastSeen,
+ isResidentialProxy, isVpn, vpnProviderNames, vpnConfidenceScore, vpnLastSeen, isRelay,
+ relayProviderName, isAnonymous, isKnownAttacker, isBot, isSpam, isCloudProvider, cloudProviderName,
+ null, null, null, null, null, null, null, null, null, null, null);
+ }
+
@JsonCreator
public Security(
@JsonProperty("threat_score") Double threatScore,
@@ -60,7 +101,18 @@ public Security(
@JsonProperty("is_bot") Boolean isBot,
@JsonProperty("is_spam") Boolean isSpam,
@JsonProperty("is_cloud_provider") Boolean isCloudProvider,
- @JsonProperty("cloud_provider_name") String cloudProviderName) {
+ @JsonProperty("cloud_provider_name") String cloudProviderName,
+ @JsonProperty("proxy_days_seen_30d") Integer proxyDaysSeen30d,
+ @JsonProperty("proxy_days_seen_7d") Integer proxyDaysSeen7d,
+ @JsonProperty("proxy_provider_count_30d") Integer proxyProviderCount30d,
+ @JsonProperty("bot_confidence_score") Double botConfidenceScore,
+ @JsonProperty("bot_operator_name") String botOperatorName,
+ @JsonProperty("bot_type") String botType,
+ @JsonProperty("is_known_good_bot") Boolean isKnownGoodBot,
+ @JsonProperty("bot_last_seen") String botLastSeen,
+ @JsonProperty("is_corporate_gateway") Boolean isCorporateGateway,
+ @JsonProperty("corporate_gateway_type") String corporateGatewayType,
+ @JsonProperty("corporate_gateway_provider_name") String corporateGatewayProviderName) {
this.threatScore = threatScore;
this.isTor = isTor;
this.isProxy = isProxy;
@@ -80,6 +132,17 @@ public Security(
this.isSpam = isSpam;
this.isCloudProvider = isCloudProvider;
this.cloudProviderName = cloudProviderName;
+ this.proxyDaysSeen30d = proxyDaysSeen30d;
+ this.proxyDaysSeen7d = proxyDaysSeen7d;
+ this.proxyProviderCount30d = proxyProviderCount30d;
+ this.botConfidenceScore = botConfidenceScore;
+ this.botOperatorName = botOperatorName;
+ this.botType = botType;
+ this.isKnownGoodBot = isKnownGoodBot;
+ this.botLastSeen = botLastSeen;
+ this.isCorporateGateway = isCorporateGateway;
+ this.corporateGatewayType = corporateGatewayType;
+ this.corporateGatewayProviderName = corporateGatewayProviderName;
}
public Double threatScore() { return threatScore; }
@@ -108,6 +171,18 @@ public Security(
public Boolean isCloudProvider() { return isCloudProvider; }
public String cloudProviderName() { return cloudProviderName; }
+ public Integer proxyDaysSeen30d() { return proxyDaysSeen30d; }
+ public Integer proxyDaysSeen7d() { return proxyDaysSeen7d; }
+ public Integer proxyProviderCount30d() { return proxyProviderCount30d; }
+ public Double botConfidenceScore() { return botConfidenceScore; }
+ public String botOperatorName() { return botOperatorName; }
+ public String botType() { return botType; }
+ public Boolean isKnownGoodBot() { return isKnownGoodBot; }
+ public String botLastSeen() { return botLastSeen; }
+ public Boolean isCorporateGateway() { return isCorporateGateway; }
+ public String corporateGatewayType() { return corporateGatewayType; }
+ public String corporateGatewayProviderName() { return corporateGatewayProviderName; }
+
@Override
public boolean equals(Object other) {
if (this == other) {
@@ -135,7 +210,18 @@ public boolean equals(Object other) {
&& Objects.equals(isBot, that.isBot)
&& Objects.equals(isSpam, that.isSpam)
&& Objects.equals(isCloudProvider, that.isCloudProvider)
- && Objects.equals(cloudProviderName, that.cloudProviderName);
+ && Objects.equals(cloudProviderName, that.cloudProviderName)
+ && Objects.equals(proxyDaysSeen30d, that.proxyDaysSeen30d)
+ && Objects.equals(proxyDaysSeen7d, that.proxyDaysSeen7d)
+ && Objects.equals(proxyProviderCount30d, that.proxyProviderCount30d)
+ && Objects.equals(botConfidenceScore, that.botConfidenceScore)
+ && Objects.equals(botOperatorName, that.botOperatorName)
+ && Objects.equals(botType, that.botType)
+ && Objects.equals(isKnownGoodBot, that.isKnownGoodBot)
+ && Objects.equals(botLastSeen, that.botLastSeen)
+ && Objects.equals(isCorporateGateway, that.isCorporateGateway)
+ && Objects.equals(corporateGatewayType, that.corporateGatewayType)
+ && Objects.equals(corporateGatewayProviderName, that.corporateGatewayProviderName);
}
@Override
@@ -159,7 +245,18 @@ public int hashCode() {
isBot,
isSpam,
isCloudProvider,
- cloudProviderName);
+ cloudProviderName,
+ proxyDaysSeen30d,
+ proxyDaysSeen7d,
+ proxyProviderCount30d,
+ botConfidenceScore,
+ botOperatorName,
+ botType,
+ isKnownGoodBot,
+ botLastSeen,
+ isCorporateGateway,
+ corporateGatewayType,
+ corporateGatewayProviderName);
}
@Override
@@ -202,6 +299,28 @@ public String toString() {
+ isCloudProvider
+ ", cloudProviderName='"
+ cloudProviderName
- + "'}";
+ + "', proxyDaysSeen30d="
+ + proxyDaysSeen30d
+ + ", proxyDaysSeen7d="
+ + proxyDaysSeen7d
+ + ", proxyProviderCount30d="
+ + proxyProviderCount30d
+ + ", botConfidenceScore="
+ + botConfidenceScore
+ + ", botOperatorName="
+ + botOperatorName
+ + ", botType="
+ + botType
+ + ", isKnownGoodBot="
+ + isKnownGoodBot
+ + ", botLastSeen="
+ + botLastSeen
+ + ", isCorporateGateway="
+ + isCorporateGateway
+ + ", corporateGatewayType="
+ + corporateGatewayType
+ + ", corporateGatewayProviderName="
+ + corporateGatewayProviderName
+ + "}";
}
}
diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java
index 63418da..852f1ee 100644
--- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java
@@ -90,7 +90,7 @@ void preservesApiErrorDetails() {
IpGeolocationClient client =
new IpGeolocationClient(
- IpGeolocationClientConfig.builder("k")
+ IpGeolocationClientConfig.builder("test-key")
.build(),
executor,
ObjectMapperFactory.create());
diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java
index e27859e..d87d523 100644
--- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java
@@ -7,9 +7,40 @@
import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
class IpGeolocationClientRequestBuildingTest {
+ @ParameterizedTest
+ @EnumSource(Language.class)
+ void sendsEveryLanguageForTypedAndRawSingleAndBulkLookups(Language language) {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ String item = "{\"ip\":\"8.8.8.8\",\"location\":{\"country_name\":\"الولايات المتحدة\"}}";
+ executor.enqueueResponse(200, item);
+ executor.enqueueResponse(200, item);
+ executor.enqueueResponse(200, "[" + item + "]");
+ executor.enqueueResponse(200, "[" + item + "]");
+ IpGeolocationClient client = new IpGeolocationClient(
+ IpGeolocationClientConfig.builder("test-key").build(),
+ executor, ObjectMapperFactory.create());
+ LookupIpGeolocationRequest single = LookupIpGeolocationRequest.builder()
+ .ip("8.8.8.8").lang(language).build();
+ BulkLookupIpGeolocationRequest bulk = BulkLookupIpGeolocationRequest.builder()
+ .addIp("8.8.8.8").lang(language).build();
+
+ assertThat(client.lookupIpGeolocation(single).data().location().countryName())
+ .isEqualTo("الولايات المتحدة");
+ assertThat(client.lookupIpGeolocationRaw(single).data()).isEqualTo(item);
+ assertThat(client.bulkLookupIpGeolocation(bulk).data()).hasSize(1);
+ assertThat(client.bulkLookupIpGeolocationRaw(bulk).data()).isEqualTo("[" + item + "]");
+
+ assertThat(executor.capturedRequests()).hasSize(4);
+ for (HttpRequestData request : executor.capturedRequests()) {
+ assertThat(request.uri().getQuery()).contains("lang=" + language.code());
+ }
+ }
+
@Test
void lookupBuildsExpectedQueryAndHeaders() {
TestHttpExecutor executor = new TestHttpExecutor();
diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java
new file mode 100644
index 0000000..6088f50
--- /dev/null
+++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java
@@ -0,0 +1,164 @@
+package io.ipgeolocation.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import io.ipgeolocation.sdk.exceptions.ApiException;
+import io.ipgeolocation.sdk.exceptions.SerializationException;
+import io.ipgeolocation.sdk.exceptions.TransportException;
+import io.ipgeolocation.sdk.exceptions.UnauthorizedException;
+import io.ipgeolocation.sdk.internal.ObjectMapperFactory;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class IpGeolocationClientSecurityTest {
+ private static final String KEY = "dummy-test-key/+ with space";
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1, 2, 3})
+ void redactsApiErrorsForEveryLookupMethod(int operation) throws Exception {
+ String encoded = URLEncoder.encode(KEY, "UTF-8").replace("+", "%20");
+ TestHttpExecutor executor = new TestHttpExecutor();
+ executor.enqueueResponse(401, "{\"message\":\"Invalid " + KEY + " or " + encoded + "\"}");
+ Throwable error = catchThrowable(() -> execute(client(executor), operation));
+ assertThat(error).isInstanceOf(UnauthorizedException.class);
+ assertThat(((ApiException) error).statusCode()).isEqualTo(401);
+ assertThat(((ApiException) error).apiMessage()).isEqualTo("Invalid [REDACTED] or [REDACTED]");
+ assertNoKey(error);
+ }
+
+ @Test
+ void redactsBeforeTruncatingNonJsonErrors() throws Exception {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ String prefix = String.join("", Collections.nCopies(500, "x"));
+ executor.enqueueResponse(503, prefix + KEY);
+ Throwable error = catchThrowable(() -> execute(client(executor), 0));
+ assertThat(((ApiException) error).apiMessage()).isEqualTo(prefix + "[REDACTED]");
+ assertNoKey(error);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1, 2, 3})
+ void preservesStatusWhenErrorBodyIsJsonNull(int operation) {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ executor.enqueueResponse(401, "null");
+ Throwable error = catchThrowable(() -> execute(client(executor), operation));
+ assertThat(error).isInstanceOf(UnauthorizedException.class);
+ assertThat(((ApiException) error).statusCode()).isEqualTo(401);
+ assertThat(((ApiException) error).apiMessage()).isNull();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 2})
+ void detachesParsingErrorsThatRetainSensitiveValues(int operation) throws Exception {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ String body = "{\"ip\":\"8.8.8.8\",\"security\":{\"threat_score\":\"" + KEY + "\"}}";
+ executor.enqueueResponse(200, operation == 2 ? "[" + body + "]" : body);
+ Throwable error = catchThrowable(() -> execute(client(executor), operation));
+ assertThat(error).isInstanceOf(SerializationException.class);
+ assertThat(error.getCause()).isExactlyInstanceOf(IOException.class);
+ assertNoKey(error);
+ }
+
+ @Test
+ void removesNestedAndSuppressedTransportSecrets() throws Exception {
+ IOException original = new IOException("Unable to read " + KEY, new IOException(KEY));
+ original.addSuppressed(new IOException(KEY));
+ TestHttpExecutor executor = new TestHttpExecutor();
+ executor.enqueueIOException(original);
+ Throwable error = catchThrowable(() -> execute(client(executor), 0));
+ assertThat(error).isInstanceOf(TransportException.class);
+ assertThat(error.getCause()).isNotSameAs(original);
+ assertThat(error.getCause().getCause()).isNull();
+ assertThat(error.getCause().getSuppressed()).isEmpty();
+ assertNoKey(error);
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {467, 480, 499, 500, 1000})
+ void parsingErrorsCannotExposePartOfATruncatedKey(int prefixLength) throws Exception {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ String prefix = String.join("", Collections.nCopies(prefixLength, "x"));
+ executor.enqueueResponse(200,
+ "{\"security\":{\"threat_score\":\"" + prefix + KEY + prefix + "\"}}");
+ Throwable error = catchThrowable(() -> execute(client(executor), 0));
+ assertThat(error).isInstanceOf(SerializationException.class);
+ assertNoKey(error);
+ assertThat(error.getCause().getMessage()).isEqualTo(
+ "com.fasterxml.jackson.databind.exc.InvalidFormatException");
+ }
+
+ @Test
+ void invalidBaseUrlDoesNotExposeQueryValues() throws Exception {
+ IpGeolocationClient client = new IpGeolocationClient(
+ IpGeolocationClientConfig.builder(KEY).baseUrl("https://invalid host").build());
+ Throwable error = catchThrowable(() -> execute(client, 0));
+ assertThat(error).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Invalid API request URI");
+ assertNoKey(error);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"null", "", "[null]", "[true]", "[5]", "[\"text\"]"})
+ void malformedBulkResultsUseSerializationException(String body) {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ executor.enqueueResponse(200, body);
+ assertThatThrownBy(() -> execute(client(executor), 2)).isInstanceOf(SerializationException.class);
+ }
+
+ @Test
+ void nullSingleResultUsesSerializationException() {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ executor.enqueueResponse(200, "null");
+ assertThatThrownBy(() -> execute(client(executor), 0)).isInstanceOf(SerializationException.class);
+ }
+
+ @Test
+ void successfulResponsesAreNotRedacted() {
+ TestHttpExecutor executor = new TestHttpExecutor();
+ String body = "{\"ip\":\"" + KEY + "\"}";
+ executor.enqueueResponse(200, body);
+ executor.enqueueResponse(200, body);
+ IpGeolocationClient client = client(executor);
+ LookupIpGeolocationRequest request = LookupIpGeolocationRequest.builder().build();
+ assertThat(client.lookupIpGeolocation(request).data().ip()).isEqualTo(KEY);
+ assertThat(client.lookupIpGeolocationRaw(request).data()).isEqualTo(body);
+ }
+
+ private static IpGeolocationClient client(TestHttpExecutor executor) {
+ return new IpGeolocationClient(IpGeolocationClientConfig.builder(KEY).build(),
+ executor, ObjectMapperFactory.create());
+ }
+
+ private static Object execute(IpGeolocationClient client, int operation) {
+ LookupIpGeolocationRequest single = LookupIpGeolocationRequest.builder().ip("8.8.8.8").build();
+ BulkLookupIpGeolocationRequest bulk = BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").build();
+ switch (operation) {
+ case 0: return client.lookupIpGeolocation(single);
+ case 1: return client.lookupIpGeolocationRaw(single);
+ case 2: return client.bulkLookupIpGeolocation(bulk);
+ default: return client.bulkLookupIpGeolocationRaw(bulk);
+ }
+ }
+
+ private static void assertNoKey(Throwable error) throws Exception {
+ StringWriter text = new StringWriter();
+ error.printStackTrace(new PrintWriter(text));
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(error); }
+ String encoded = URLEncoder.encode(KEY, "UTF-8").replace("+", "%20");
+ assertThat(text.toString()).doesNotContain(KEY, encoded);
+ assertThat(new String(bytes.toByteArray(), StandardCharsets.ISO_8859_1))
+ .doesNotContain(KEY, encoded);
+ }
+}
diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java
index bcc0546..fb57ed7 100644
--- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java
@@ -30,6 +30,33 @@ static void beforeAll() {
Assumptions.assumeTrue(!TestSupport.isBlank(paidKey), "IPGEO_PAID_KEY is required");
}
+ @Test
+ void paidPlanArabicWorksForSingleAndBulk() {
+ try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(paidKey).build())) {
+ ApiResponse single =
+ client.lookupIpGeolocation(LookupIpGeolocationRequest.builder()
+ .ip("8.8.8.8").lang(Language.AR).build());
+ String countryName = single.data().location().countryName();
+ assertThat(countryName).containsPattern("[\\u0600-\\u06ff]");
+
+ ApiResponse> bulk = client.bulkLookupIpGeolocation(
+ BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").lang(Language.AR).build());
+ assertThat(bulk.data()).hasSize(1);
+ assertThat(bulk.data().get(0)).isInstanceOf(BulkLookupSuccess.class);
+ assertThat(((BulkLookupSuccess) bulk.data().get(0)).item().location().countryName())
+ .isEqualTo(countryName);
+ }
+ }
+
+ @Test
+ void freePlanArabicIsRejected() {
+ try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) {
+ assertThatThrownBy(() -> client.lookupIpGeolocation(LookupIpGeolocationRequest.builder()
+ .ip("8.8.8.8").lang(Language.AR).build()))
+ .isInstanceOf(UnauthorizedException.class);
+ }
+ }
+
@Test
void freePlanBaseLookupWorks() {
try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) {
@@ -104,17 +131,6 @@ void freePlanIncludeStarReturnsDefaultResponseWithoutAdditionalModules() {
}
}
- @Test
- void freePlanBulkIsRejected() {
- try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) {
- assertThatThrownBy(
- () ->
- client.bulkLookupIpGeolocation(
- BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").build()))
- .isInstanceOf(UnauthorizedException.class);
- }
- }
-
@Test
void paidPlanSecurityAndAbuseWorks() {
try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(paidKey).build())) {
diff --git a/src/test/java/io/ipgeolocation/sdk/LanguageTest.java b/src/test/java/io/ipgeolocation/sdk/LanguageTest.java
index 0f0d9c1..183215d 100644
--- a/src/test/java/io/ipgeolocation/sdk/LanguageTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/LanguageTest.java
@@ -3,7 +3,10 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.util.Locale;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
class LanguageTest {
@@ -11,6 +14,15 @@ class LanguageTest {
void fromCodeIsCaseInsensitive() {
assertThat(Language.fromCode("EN")).isEqualTo(Language.EN);
assertThat(Language.fromCode("pT")).isEqualTo(Language.PT);
+ assertThat(Language.fromCode("aR")).isEqualTo(Language.AR);
+ assertThat(Language.AR.code()).isEqualTo("ar");
+ }
+
+ @ParameterizedTest
+ @EnumSource(Language.class)
+ void everyLanguageRetainsItsWireCode(Language language) {
+ assertThat(language.code()).isEqualTo(language.name().toLowerCase(Locale.ROOT));
+ assertThat(Language.fromCode(language.code())).isEqualTo(language);
}
@Test
@@ -18,5 +30,7 @@ void fromCodeRejectsUnsupportedLanguage() {
assertThatThrownBy(() -> Language.fromCode("zz"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unsupported language code: zz");
+ assertThatThrownBy(() -> Language.fromCode(null)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> Language.fromCode("")).isInstanceOf(IllegalArgumentException.class);
}
}
diff --git a/src/test/java/io/ipgeolocation/sdk/NetworkTest.java b/src/test/java/io/ipgeolocation/sdk/NetworkTest.java
new file mode 100644
index 0000000..0b28aca
--- /dev/null
+++ b/src/test/java/io/ipgeolocation/sdk/NetworkTest.java
@@ -0,0 +1,32 @@
+package io.ipgeolocation.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.ipgeolocation.sdk.internal.ObjectMapperFactory;
+import io.ipgeolocation.sdk.model.Network;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class NetworkTest {
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void preservesCdnFlagInTypedResponses(boolean value) throws Exception {
+ Network network = ObjectMapperFactory.create().readValue(
+ "{\"is_cdn\":" + value + ",\"cdn_provider_name\":\"Example CDN\"}", Network.class);
+ assertThat(network.isCdn()).isEqualTo(value);
+ assertThat(network.cdnProviderName()).isEqualTo("Example CDN");
+ assertThat(ObjectMapperFactory.create().valueToTree(network).get("is_cdn").booleanValue())
+ .isEqualTo(value);
+ }
+
+ @Test
+ void retainsExistingConstructorAndMissingFieldBehavior() throws Exception {
+ Network oldConstructor = new Network("corporate", "8.8.8.0/24", true);
+ Network parsed = ObjectMapperFactory.create().readValue(
+ "{\"connection_type\":\"corporate\",\"route\":\"8.8.8.0/24\",\"is_anycast\":true}", Network.class);
+ assertThat(parsed).isEqualTo(oldConstructor).hasSameHashCodeAs(oldConstructor);
+ assertThat(parsed.isCdn()).isNull();
+ assertThat(parsed.cdnProviderName()).isNull();
+ }
+}
diff --git a/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java b/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java
new file mode 100644
index 0000000..4d16935
--- /dev/null
+++ b/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java
@@ -0,0 +1,49 @@
+package io.ipgeolocation.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.ipgeolocation.sdk.internal.ObjectMapperFactory;
+import io.ipgeolocation.sdk.model.Security;
+import java.util.Iterator;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class SecurityFieldsTest {
+ @Test
+ void preservesCurrentProxyBotAndGatewayFields() throws Exception {
+ String body = "{\"proxy_days_seen_30d\":30,\"proxy_days_seen_7d\":0,"
+ + "\"proxy_provider_count_30d\":2,\"bot_confidence_score\":99.5,"
+ + "\"bot_operator_name\":\"Example\",\"bot_type\":\"crawler\","
+ + "\"is_known_good_bot\":false,\"bot_last_seen\":\"2026-11-03\","
+ + "\"is_corporate_gateway\":true,\"corporate_gateway_type\":\"sase\","
+ + "\"corporate_gateway_provider_name\":\"Example Gateway\"}";
+ ObjectMapper mapper = ObjectMapperFactory.create();
+ Security security = mapper.readValue(body, Security.class);
+ JsonNode serialized = mapper.valueToTree(security);
+ Iterator> fields = mapper.readTree(body).fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+ assertThat(serialized.get(field.getKey())).as(field.getKey()).isEqualTo(field.getValue());
+ }
+ assertThat(security.proxyDaysSeen30d()).isEqualTo(30);
+ assertThat(security.proxyDaysSeen7d()).isZero();
+ assertThat(security.proxyProviderCount30d()).isEqualTo(2);
+ assertThat(security.botConfidenceScore()).isEqualTo(99.5);
+ assertThat(security.isKnownGoodBot()).isFalse();
+ assertThat(security.isCorporateGateway()).isTrue();
+ }
+
+ @Test
+ void retainsExistingConstructorAndMissingFieldBehavior() throws Exception {
+ Security original = new Security(null, null, null, null, null, null, null,
+ null, null, null, null, null, null, null, null, null, null, null, null);
+ Security parsed = ObjectMapperFactory.create().readValue("{}", Security.class);
+ assertThat(parsed).isEqualTo(original).hasSameHashCodeAs(original);
+ assertThat(parsed.proxyDaysSeen30d()).isNull();
+ assertThat(parsed.botConfidenceScore()).isNull();
+ assertThat(parsed.isKnownGoodBot()).isNull();
+ assertThat(parsed.isCorporateGateway()).isNull();
+ }
+}
diff --git a/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java b/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java
index b6a065d..8c834e9 100644
--- a/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java
@@ -142,6 +142,11 @@ private void invokeAccessors(Object value) throws Exception {
private Constructor> constructorFor(Class> type) {
Constructor>[] constructors = type.getConstructors();
+ for (Constructor> constructor : constructors) {
+ if (constructor.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonCreator.class)) {
+ return constructor;
+ }
+ }
if (constructors.length != 1) {
throw new IllegalStateException("Expected exactly one public constructor for " + type);
}
diff --git a/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java b/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java
index 73757e2..b9a4580 100644
--- a/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java
+++ b/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java
@@ -19,9 +19,59 @@
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
class JavaNetHttpExecutorTest {
+ @ParameterizedTest
+ @CsvSource({
+ "PT0.000000001S, 1",
+ "PT0.000999999S, 1",
+ "PT0.001S, 1",
+ "PT0.001999999S, 1",
+ "PT10S, 10000",
+ "PT2147483.646S, 2147483646",
+ "PT2147483.647S, 2147483647",
+ "PT2147483.648S, 2147483647",
+ "PT9223372036854775807S, 2147483647"
+ })
+ void appliesFiniteTimeoutsWithoutOverflow(String value, int expectedMillis) throws Exception {
+ Duration timeout = Duration.parse(value);
+ StubHttpURLConnection connection =
+ new StubHttpURLConnection(
+ url("https://api.ipgeolocation.io/v3/ipgeo"), 200, "{}",
+ Collections.>emptyMap());
+ JavaNetHttpExecutor executor =
+ new JavaNetHttpExecutor(timeout, new StubConnectionFactory(connection));
+
+ executor.send(new HttpRequestData(
+ connection.getURL().toURI(), "GET",
+ Collections.>emptyMap(), null, timeout));
+
+ assertThat(connection.getConnectTimeout()).isEqualTo(expectedMillis);
+ assertThat(connection.getReadTimeout()).isEqualTo(expectedMillis);
+ assertThat(connection.disconnected).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {0L, -1L, -1_000_000_000L})
+ void rejectsNonPositiveReadTimeouts(long nanos) {
+ StubHttpURLConnection connection =
+ new StubHttpURLConnection(
+ url("https://api.ipgeolocation.io/v3/ipgeo"), 200, "{}",
+ Collections.>emptyMap());
+ JavaNetHttpExecutor executor =
+ new JavaNetHttpExecutor(Duration.ofSeconds(1), new StubConnectionFactory(connection));
+
+ assertThatThrownBy(() -> executor.send(new HttpRequestData(
+ URI.create("https://api.ipgeolocation.io/v3/ipgeo"), "GET",
+ Collections.>emptyMap(), null, Duration.ofNanos(nanos))))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(connection.disconnected).isTrue();
+ }
+
@Test
void rejectsInvalidTimeoutsAndNullConnectionFactory() {
assertThatThrownBy(() -> new JavaNetHttpExecutor(Duration.ZERO))
@@ -252,6 +302,7 @@ private static final class StubHttpURLConnection extends HttpURLConnection {
private boolean nullInputStream;
private boolean nullErrorStream;
private boolean outputStreamRequested;
+ private boolean disconnected;
private int fixedLengthStreamingMode = -1;
private StubHttpURLConnection(
@@ -263,7 +314,9 @@ private StubHttpURLConnection(
}
@Override
- public void disconnect() {}
+ public void disconnect() {
+ disconnected = true;
+ }
@Override
public boolean usingProxy() {