diff --git a/pom.xml b/pom.xml index 6ee5ec391..4edcc67f0 100644 --- a/pom.xml +++ b/pom.xml @@ -272,13 +272,13 @@ jackson-databind - tools.jackson.core - ${tools.jackson.core.databind.version} + com.fasterxml.jackson.core + ${com.fasterxml.jackson.version} jackson-datatype-jsr310 com.fasterxml.jackson.datatype - ${com.fasterxml.jackson.datatype.version} + ${com.fasterxml.jackson.version} org.apache.httpcomponents.client5 @@ -390,8 +390,7 @@ UTF-8 - 3.1.1 - 2.21.2 + 2.21.2 4.12.0 4.7.7 3.6.1 diff --git a/src/main/java/com/mindee/input/URLInputSource.java b/src/main/java/com/mindee/input/URLInputSource.java index 479f2ba5c..352bc2834 100644 --- a/src/main/java/com/mindee/input/URLInputSource.java +++ b/src/main/java/com/mindee/input/URLInputSource.java @@ -7,13 +7,18 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL; +import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Base64; +import java.util.Locale; import lombok.Getter; /** @@ -54,12 +59,81 @@ public static Builder builder(URL url) { } /** - * Ensures the URL can be sent to the Mindee server. + * Ensures the URL can be safely sent to the Mindee server. + * + *

+ * Rejects any URL that could be used for Server-Side Request Forgery (SSRF): + *

*/ public void validateSecure() { if (!"https".equalsIgnoreCase(this.url.getProtocol())) { throw new MindeeException("Only HTTPS source URLs are allowed"); } + String userInfo = this.url.getUserInfo(); + if (userInfo != null && !userInfo.isEmpty()) { + throw new MindeeException("Source URLs must not embed user credentials"); + } + String host = this.url.getHost(); + if (host == null || host.isEmpty()) { + throw new MindeeException("Source URL is missing a host"); + } + String lowerHost = host.toLowerCase(Locale.ROOT); + if ( + "localhost".equals(lowerHost) + || lowerHost.endsWith(".localhost") + || "ip6-localhost".equals(lowerHost) + || "ip6-loopback".equals(lowerHost) + ) { + throw new MindeeException("Loopback hostnames are not allowed: " + host); + } + + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new MindeeException("Unable to resolve source URL host: " + host, e); + } + for (InetAddress addr : addresses) { + if (isDisallowedAddress(addr)) { + throw new MindeeException( + "Source URL host resolves to a disallowed address: " + addr.getHostAddress() + ); + } + } + } + + private static boolean isDisallowedAddress(InetAddress addr) { + return addr.isLoopbackAddress() + || addr.isLinkLocalAddress() + || addr.isSiteLocalAddress() + || addr.isAnyLocalAddress() + || addr.isMulticastAddress() + || isUniqueLocalIpv6(addr) + || isCarrierGradeNat(addr); + } + + private static boolean isUniqueLocalIpv6(InetAddress addr) { + if (!(addr instanceof Inet6Address)) { + return false; + } + byte[] raw = addr.getAddress(); + return (raw[0] & 0xFE) == 0xFC; + } + + private static boolean isCarrierGradeNat(InetAddress addr) { + if (!(addr instanceof Inet4Address)) { + return false; + } + byte[] raw = addr.getAddress(); + return (raw[0] & 0xFF) == 100 && (raw[1] & 0xC0) == 0x40; } /** diff --git a/src/main/java/com/mindee/v2/MindeeClient.java b/src/main/java/com/mindee/v2/MindeeClient.java index 85a3661c0..10f1972aa 100644 --- a/src/main/java/com/mindee/v2/MindeeClient.java +++ b/src/main/java/com/mindee/v2/MindeeClient.java @@ -9,10 +9,12 @@ import com.mindee.v2.http.MindeeHttpExceptionV2; import com.mindee.v2.parsing.CommonResponse; import com.mindee.v2.parsing.JobResponse; +import com.mindee.v2.parsing.JobStatus; import com.mindee.v2.parsing.error.ErrorResponse; import com.mindee.v2.parsing.search.SearchResponse; import com.mindee.v2.product.extraction.ExtractionResponse; import java.io.IOException; +import java.util.concurrent.CancellationException; /** * Entry point for the Mindee **V2** API features. @@ -84,6 +86,20 @@ public TResponse getResult( return mindeeApi.reqGetResult(responseClass, inferenceId); } + /** + * Get the result of an inference from a given URL. + * The inference will only be available after it has finished processing. + */ + public TResponse getResultFromUrl( + Class responseClass, + String inferenceUrl + ) { + if (inferenceUrl == null || inferenceUrl.trim().isEmpty()) { + throw new IllegalArgumentException("inferenceUrl must not be null or blank."); + } + return mindeeApi.reqGetResultFromUrl(responseClass, inferenceUrl); + } + /** * Send a local file to an async queue, poll, and parse when complete. * Use default polling options. @@ -215,22 +231,26 @@ private TResponse pollAndFetch( JobResponse initialJob, PollingOptions pollingOptions ) throws InterruptedException { - Thread.sleep((long) (pollingOptions.getInitialDelaySec() * 1000)); + interruptibleSleep((long) (pollingOptions.getInitialDelaySec() * 1000), pollingOptions); JobResponse resp = initialJob; int attempts = 0; int max = pollingOptions.getMaxRetries(); + double currentIntervalSec = pollingOptions.getIntervalSec(); + double maxIntervalSec = pollingOptions.getMaxIntervalSec(); + double backoffMultiplier = pollingOptions.getBackoffMultiplier(); while (attempts < max) { - Thread.sleep((long) (pollingOptions.getIntervalSec() * 1000)); + interruptibleSleep((long) (currentIntervalSec * 1000), pollingOptions); resp = getJob(initialJob.getJob().getId()); - if (resp.getJob().getStatus().equals("Failed")) { + if (resp.getJob().getStatusEnum() == JobStatus.Failed) { attempts = max; } - if (resp.getJob().getStatus().equals("Processed")) { + if (resp.getJob().getStatusEnum() == JobStatus.Processed) { return getResult(responseClass, resp.getJob().getId()); } + currentIntervalSec = Math.min(currentIntervalSec * backoffMultiplier, maxIntervalSec); attempts++; } @@ -241,6 +261,29 @@ private TResponse pollAndFetch( throw new RuntimeException("Max retries exceeded (" + max + ")."); } + /** + * Sleeps for the requested duration, honouring both thread interruption and the + * caller-supplied cancellation token. The cancel token is checked before sleeping + * and after each 100 ms tick so long waits stay responsive. + */ + private static void interruptibleSleep( + long millis, + PollingOptions options + ) throws InterruptedException { + if (options.getCancelToken().getAsBoolean()) { + throw new CancellationException("Polling cancelled"); + } + long remaining = millis; + while (remaining > 0) { + long chunk = Math.min(remaining, 100L); + Thread.sleep(chunk); + remaining -= chunk; + if (options.getCancelToken().getAsBoolean()) { + throw new CancellationException("Polling cancelled"); + } + } + } + private static MindeeApiV2 createDefaultApiV2(String apiKey) { MindeeSettings settings = apiKey == null || apiKey.trim().isEmpty() ? new MindeeSettings() diff --git a/src/main/java/com/mindee/v2/clientoptions/PollingOptions.java b/src/main/java/com/mindee/v2/clientoptions/PollingOptions.java index e35b9c517..20003cb08 100644 --- a/src/main/java/com/mindee/v2/clientoptions/PollingOptions.java +++ b/src/main/java/com/mindee/v2/clientoptions/PollingOptions.java @@ -1,11 +1,54 @@ package com.mindee.v2.clientoptions; import com.mindee.clientoptions.BasePollingOptions; +import java.util.function.BooleanSupplier; import lombok.Builder; +import lombok.Getter; public class PollingOptions extends BasePollingOptions { + + /** + * Multiplier applied to {@code intervalSec} after each poll attempt to implement + * exponential backoff. Must be ≥ 1.0. A value of 1.0 disables backoff. + */ + @Getter + private final Double backoffMultiplier; + + /** + * Upper bound (in seconds) for the polling interval after backoff is applied. + * Must be ≥ {@code intervalSec}. + */ + @Getter + private final Double maxIntervalSec; + + /** + * Optional cancellation signal. When it evaluates to {@code true}, polling is + * aborted with a {@link java.util.concurrent.CancellationException}. Also, + * interrupting the polling thread cancels the operation. + */ + @Getter + private final BooleanSupplier cancelToken; + @Builder - public PollingOptions(Double initialDelaySec, Double intervalSec, Integer maxRetries) { + public PollingOptions( + Double initialDelaySec, + Double intervalSec, + Integer maxRetries, + Double backoffMultiplier, + Double maxIntervalSec, + BooleanSupplier cancelToken + ) { super(initialDelaySec, intervalSec, maxRetries, 3.0, 1.5, 100, 1.0, 1.0, 2); + this.backoffMultiplier = backoffMultiplier == null ? 1.5 : backoffMultiplier; + if (this.backoffMultiplier < 1.0) { + throw new IllegalArgumentException("Backoff multiplier must be ≥ 1.0"); + } + this.maxIntervalSec = maxIntervalSec == null ? 60.0 : maxIntervalSec; + if (this.maxIntervalSec < this.getIntervalSec()) { + throw new IllegalArgumentException( + "Max interval must be ≥ interval (" + this.getIntervalSec() + ")" + ); + } + this.cancelToken = cancelToken == null ? () -> false : cancelToken; } } diff --git a/src/main/java/com/mindee/v2/http/MindeeApiV2.java b/src/main/java/com/mindee/v2/http/MindeeApiV2.java index 7cd560aeb..ab5db757f 100644 --- a/src/main/java/com/mindee/v2/http/MindeeApiV2.java +++ b/src/main/java/com/mindee/v2/http/MindeeApiV2.java @@ -54,6 +54,15 @@ public abstract TResponse reqGetResult( String inferenceId ); + /** + * Retrieves the inference from a given URL. + * The inference will only be available after it has finished processing. + */ + public abstract TResponse reqGetResultFromUrl( + Class responseClass, + String inferenceUrl + ); + /** * Retrieves a list of models. * diff --git a/src/main/java/com/mindee/v2/http/MindeeHttpApiV2.java b/src/main/java/com/mindee/v2/http/MindeeHttpApiV2.java index 890ca2c5c..0db80251b 100644 --- a/src/main/java/com/mindee/v2/http/MindeeHttpApiV2.java +++ b/src/main/java/com/mindee/v2/http/MindeeHttpApiV2.java @@ -137,6 +137,91 @@ public TResponse reqGetResult( return executeAPIRequest(get, responseClass); } + @Override + public TResponse reqGetResultFromUrl( + Class responseClass, + String inferenceUrl + ) { + if (inferenceUrl == null || inferenceUrl.trim().isEmpty()) { + throw new IllegalArgumentException("inferenceUrl must not be null or blank."); + } + validateInferenceUrl(inferenceUrl); + var get = new HttpGet(inferenceUrl); + return executeAPIRequest(get, responseClass); + } + + /** + * Ensures that a caller-supplied inference URL targets the configured Mindee + * base URL so the {@code Authorization} header attached by + * {@link #executeAPIRequest} cannot leak to third-party hosts. + * + *

+ * The URL must be an absolute HTTPS URL whose host + port match the + * configured base URL, whose path is under the base URL's path, and which + * carries no embedded userinfo. + */ + void validateInferenceUrl(String inferenceUrl) { + java.net.URI target; + java.net.URI base; + try { + target = new java.net.URI(inferenceUrl); + base = new java.net.URI(this.mindeeSettings.getBaseUrl()); + } catch (java.net.URISyntaxException e) { + throw new MindeeException("inferenceUrl is not a valid URI: " + inferenceUrl, e); + } + if (!target.isAbsolute()) { + throw new MindeeException("inferenceUrl must be an absolute URL: " + inferenceUrl); + } + if (!"https".equalsIgnoreCase(target.getScheme())) { + throw new MindeeException("inferenceUrl must use https: " + inferenceUrl); + } + if (target.getUserInfo() != null && !target.getUserInfo().isEmpty()) { + throw new MindeeException("inferenceUrl must not contain userinfo: " + inferenceUrl); + } + String targetHost = target.getHost(); + String baseHost = base.getHost(); + if (targetHost == null || baseHost == null || !targetHost.equalsIgnoreCase(baseHost)) { + throw new MindeeException( + "inferenceUrl host '" + + targetHost + + "' does not match Mindee base URL host '" + + baseHost + + "'" + ); + } + int targetPort = target.getPort() == -1 ? defaultPort(target.getScheme()) : target.getPort(); + int basePort = base.getPort() == -1 ? defaultPort(base.getScheme()) : base.getPort(); + if (targetPort != basePort) { + throw new MindeeException( + "inferenceUrl port " + targetPort + " does not match Mindee base URL port " + basePort + ); + } + String basePath = base.getPath() == null ? "" : base.getPath(); + String targetPath = target.getPath() == null ? "" : target.getPath(); + if (!basePath.isEmpty() && !basePath.equals("/")) { + String normalizedBase = basePath.endsWith("/") ? basePath : basePath + "/"; + if (!targetPath.equals(basePath) && !targetPath.startsWith(normalizedBase)) { + throw new MindeeException( + "inferenceUrl path '" + + targetPath + + "' is not under Mindee base URL path '" + + basePath + + "'" + ); + } + } + } + + private static int defaultPort(String scheme) { + if ("https".equalsIgnoreCase(scheme)) { + return 443; + } + if ("http".equalsIgnoreCase(scheme)) { + return 80; + } + return -1; + } + @Override public SearchResponse reqGetSearchModels(String modelName, String modelType) { URIBuilder url; diff --git a/src/main/java/com/mindee/v2/parsing/Job.java b/src/main/java/com/mindee/v2/parsing/Job.java index 62414bcb5..56cb0bf20 100644 --- a/src/main/java/com/mindee/v2/parsing/Job.java +++ b/src/main/java/com/mindee/v2/parsing/Job.java @@ -43,11 +43,19 @@ public final class Job { private String id; /** - * Status of the job. + * Status of the job as returned by the API (raw string). */ @JsonProperty("status") private String status; + /** + * Typed view of {@link #status}. Returns {@link JobStatus#Unknown} for values + * the SDK doesn't recognise, or {@code null} if the raw status is {@code null}. + */ + public JobStatus getStatusEnum() { + return JobStatus.fromValue(status); + } + /** * Status of the job. */ diff --git a/src/main/java/com/mindee/v2/parsing/JobStatus.java b/src/main/java/com/mindee/v2/parsing/JobStatus.java new file mode 100644 index 000000000..b90897df7 --- /dev/null +++ b/src/main/java/com/mindee/v2/parsing/JobStatus.java @@ -0,0 +1,42 @@ +package com.mindee.v2.parsing; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Possible statuses returned by the API for an asynchronous {@link Job}. + */ +public enum JobStatus { + Processing("Processing"), + Processed("Processed"), + Failed("Failed"), + Unknown("Unknown"); + + private final String value; + + JobStatus(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + /** + * Deserializes a status string coming from the API. Unknown values fall back to + * {@link #Unknown} rather than failing so newly added statuses don't break clients. + */ + @JsonCreator + public static JobStatus fromValue(String value) { + if (value == null) { + return null; + } + for (JobStatus status : values()) { + if (status.value.equalsIgnoreCase(value)) { + return status; + } + } + return Unknown; + } +} diff --git a/src/main/java/com/mindee/v2/parsing/inference/field/SimpleField.java b/src/main/java/com/mindee/v2/parsing/inference/field/SimpleField.java index 1f7d36847..058cac066 100644 --- a/src/main/java/com/mindee/v2/parsing/inference/field/SimpleField.java +++ b/src/main/java/com/mindee/v2/parsing/inference/field/SimpleField.java @@ -43,24 +43,45 @@ public String getStringValue() throws ClassCastException { /** * Retrieves the value of the field as a {@link Double}. * - * @return the field value as a Double - * @throws ClassCastException if the value cannot be cast to a Double + *

+ * Accepts any {@link Number} — the wire deserializer stores numeric values as + * {@link BigDecimal} to preserve precision, but programmatically-constructed + * fields may hold a {@code Double}, {@code Integer}, {@code Long}, etc. Prefer + * {@link #getBigDecimalValue()} when precision matters. + * + * @return the field value as a Double (may lose precision) + * @throws ClassCastException if the value is not a {@link Number} */ public Double getDoubleValue() throws ClassCastException { - return (Double) value; + if (value == null) { + return null; + } + return ((Number) value).doubleValue(); } /** * Retrieves the value of the field as a {@link BigDecimal}. * + *

+ * Accepts any {@link Number}. Non-{@code BigDecimal} numbers are converted + * via their string representation to preserve as many digits as possible. + * * @return the field value as a BigDecimal - * @throws ClassCastException if the value cannot be cast to a BigDecimal + * @throws ClassCastException if the value is not a {@link Number} */ public BigDecimal getBigDecimalValue() throws ClassCastException { if (value == null) { return null; } - return BigDecimal.valueOf(getDoubleValue()); + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return new BigDecimal(value.toString()); + } + throw new ClassCastException( + "Value of type " + value.getClass().getName() + " cannot be cast to BigDecimal" + ); } /** @@ -80,6 +101,9 @@ public String toString() { if (value.getClass().equals(Boolean.class)) { return formatForDisplay((Boolean) value, 5); } + if (value instanceof Number) { + return Double.toString(((Number) value).doubleValue()); + } return value.toString(); } } diff --git a/src/main/java/com/mindee/v2/parsing/inference/field/SimpleFieldDeserializer.java b/src/main/java/com/mindee/v2/parsing/inference/field/SimpleFieldDeserializer.java index 17bfdee62..45746a75b 100644 --- a/src/main/java/com/mindee/v2/parsing/inference/field/SimpleFieldDeserializer.java +++ b/src/main/java/com/mindee/v2/parsing/inference/field/SimpleFieldDeserializer.java @@ -28,7 +28,7 @@ public SimpleField deserialize(JsonParser jp, DeserializationContext ctxt) throw value = valueNode.booleanValue(); break; case NUMBER: - value = valueNode.doubleValue(); + value = valueNode.decimalValue(); break; case STRING: value = valueNode.textValue(); diff --git a/src/test/java/com/mindee/input/URLInputSourceTest.java b/src/test/java/com/mindee/input/URLInputSourceTest.java index 3f78505dd..5845ec613 100644 --- a/src/test/java/com/mindee/input/URLInputSourceTest.java +++ b/src/test/java/com/mindee/input/URLInputSourceTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; +import com.mindee.MindeeException; import java.io.File; import java.io.IOException; import java.net.HttpURLConnection; @@ -86,6 +87,93 @@ void toLocalInputSource_shouldCreateLocalInputSource() throws IOException { urlInputSource.cleanup(); } + @Nested + @DisplayName("validateSecure() – SSRF/loopback checks") + class ValidateSecure { + @Test + void httpsPublicHost_isAccepted() throws MalformedURLException { + URLInputSource.builder("https://example.com/file.pdf").build().validateSecure(); + } + + @Test + void httpScheme_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("http://example.com/file.pdf").build(); + MindeeException e = assertThrows(MindeeException.class, src::validateSecure); + assertTrue(e.getMessage().contains("HTTPS")); + } + + @Test + void userInfo_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://user:pass@example.com/file.pdf").build(); + MindeeException e = assertThrows(MindeeException.class, src::validateSecure); + assertTrue(e.getMessage().contains("credentials")); + } + + @Test + void loopbackHostname_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://localhost/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void subLocalhostHostname_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://foo.localhost/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void loopbackIpv4_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://127.0.0.1/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void loopbackIpv6_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://[::1]/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void anyLocalIpv4_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://0.0.0.0/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void privateRfc1918_isRejected() throws MalformedURLException { + for (String host : new String[] { "10.0.0.1", "172.16.0.1", "192.168.1.1" }) { + var src = URLInputSource.builder("https://" + host + "/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure, "expected rejection for " + host); + } + } + + @Test + void linkLocalIpv4_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://169.254.169.254/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void cgnat_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://100.64.0.1/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void uniqueLocalIpv6_isRejected() throws MalformedURLException { + var src = URLInputSource.builder("https://[fd00::1]/file.pdf").build(); + assertThrows(MindeeException.class, src::validateSecure); + } + + @Test + void unresolvableHost_isRejected() throws MalformedURLException { + var src = URLInputSource + .builder("https://this-host-should-not-exist.invalid/file.pdf") + .build(); + assertThrows(MindeeException.class, src::validateSecure); + } + } + static class TestableURLInputSource extends URLInputSource { @Setter diff --git a/src/test/java/com/mindee/v1/MindeeClientTest.java b/src/test/java/com/mindee/v1/MindeeClientTest.java index 0a1693123..caf2c0a8c 100644 --- a/src/test/java/com/mindee/v1/MindeeClientTest.java +++ b/src/test/java/com/mindee/v1/MindeeClientTest.java @@ -99,7 +99,7 @@ void givenAClientForInvoiceAndPageOptions_parse_thenShouldOperateCutOnPagesAndCa @Test void givenADocumentUrl_whenParsed_shouldCallApiWithCorrectParams() throws IOException { - URL docUrl = new URL("https://this.document.does.not.exist"); + URL docUrl = new URL("https://example.com/this-document.pdf"); var predictResponse = new PredictResponse(); predictResponse.setDocument(new Document<>()); predictResponse.setApiRequest(null); @@ -145,7 +145,10 @@ void givenAnAsyncUrl_whenEnqueued_shouldInvokeApiCorrectly() throws IOException var mindeeClient = new MindeeClient(new FakeMindeeApiV1<>(predictResponse)); - var jobId = mindeeClient.enqueue(InvoiceV4.class, new URL("https://fake.pdf")).getJob().getId(); + var jobId = mindeeClient + .enqueue(InvoiceV4.class, new URL("https://example.com/fake.pdf")) + .getJob() + .getId(); Assertions.assertEquals("someid", jobId); } diff --git a/src/test/java/com/mindee/v1/http/MindeeHttpApiV1Test.java b/src/test/java/com/mindee/v1/http/MindeeHttpApiV1Test.java index 5ba26adf1..84fbf6e29 100644 --- a/src/test/java/com/mindee/v1/http/MindeeHttpApiV1Test.java +++ b/src/test/java/com/mindee/v1/http/MindeeHttpApiV1Test.java @@ -189,7 +189,9 @@ void givenParseParametersWithFileUrl_whenParsed_shouldBuildRequestCorrectly() th .builder() .file(null) .fileName(null) - .urlInputSource(new URLInputSource.Builder("https://thisfile.does.not.exist").build()) + .urlInputSource( + new URLInputSource.Builder("https://example.com/thisfile.does.not.exist").build() + ) .build() ) .getDocument(); @@ -211,7 +213,8 @@ void givenParseParametersWithFileUrl_whenParsed_shouldBuildRequestCorrectly() th Map requestMap = objectMapper .readValue(recordedRequest.getBody().readUtf8(), new TypeReference>() { }); - Assertions.assertEquals("https://thisfile.does.not.exist", requestMap.get("document")); + Assertions + .assertEquals("https://example.com/thisfile.does.not.exist", requestMap.get("document")); } @Test diff --git a/src/test/java/com/mindee/v2/MindeeClientIT.java b/src/test/java/com/mindee/v2/MindeeClientIT.java index dfc243d89..c0e069ed2 100644 --- a/src/test/java/com/mindee/v2/MindeeClientIT.java +++ b/src/test/java/com/mindee/v2/MindeeClientIT.java @@ -233,4 +233,33 @@ void searchModelsByName_mustSucceed() { assertNotNull(response); assertFalse(response.getModels().isEmpty()); } + + @Test + @DisplayName("getResultFromUrl - fetching an inference by its full URL must succeed") + void getResultFromUrl_mustSucceed() throws IOException, InterruptedException { + var source = new LocalInputSource(getResourcePath("file_types/pdf/blank_1.pdf")); + var params = ExtractionParameters + .builder(modelId) + .alias("java-integration-test_get-result-from-url") + .build(); + + var enqueueResp = mindeeClient.enqueue(source, params); + assertNotNull(enqueueResp); + var jobId = enqueueResp.getJob().getId(); + + String resultUrl = null; + for (int i = 0; i < 80 && resultUrl == null; i++) { + Thread.sleep(1500); + var poll = mindeeClient.getJob(jobId); + if (poll.getJob().getStatusEnum() == com.mindee.v2.parsing.JobStatus.Processed) { + resultUrl = poll.getJob().getResultUrl(); + } + } + assertNotNull(resultUrl, "Job must expose a result_url once processed"); + + var response = mindeeClient.getResultFromUrl(ExtractionResponse.class, resultUrl); + assertNotNull(response); + assertNotNull(response.getInference()); + assertNotNull(response.getInference().getId()); + } } diff --git a/src/test/java/com/mindee/v2/MindeeClientTest.java b/src/test/java/com/mindee/v2/MindeeClientTest.java index 5e4738568..139273f00 100644 --- a/src/test/java/com/mindee/v2/MindeeClientTest.java +++ b/src/test/java/com/mindee/v2/MindeeClientTest.java @@ -3,12 +3,15 @@ import static com.mindee.TestingUtilities.getResourcePath; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.mindee.input.LocalInputSource; import com.mindee.input.URLInputSource; import com.mindee.v2.clientoptions.BaseParameters; +import com.mindee.v2.clientoptions.PollingOptions; import com.mindee.v2.http.MindeeApiV2; import com.mindee.v2.parsing.CommonResponse; import com.mindee.v2.parsing.JobResponse; @@ -17,6 +20,10 @@ import com.mindee.v2.product.extraction.params.ExtractionParameters; import java.io.IOException; import java.nio.file.Files; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -60,6 +67,14 @@ public TResponse reqGetResult( ) { return (TResponse) resultResponse; } + + @Override + public TResponse reqGetResultFromUrl( + Class tResponseClass, + String inferenceUrl + ) { + return (TResponse) resultResponse; + } } @Nested @@ -135,4 +150,140 @@ void document_getResult_async() throws IOException { ); } } + + @Nested + @DisplayName("getResultFromUrl()") + class GetResultFromUrl { + @Test + @DisplayName("hits the HTTP endpoint once and returns a non-null response") + void document_getResultFromUrl_async() throws IOException { + String json = Files + .readString(getResourcePath("v2/products/extraction/financial_document/complete.json")); + + var mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + + ExtractionResponse processed = mapper.readValue(json, ExtractionResponse.class); + + AtomicReference capturedUrl = new AtomicReference<>(); + var api = new FakeMindeeApiV2(null, processed) { + @Override + public TResponse reqGetResultFromUrl( + Class tResponseClass, + String inferenceUrl + ) { + capturedUrl.set(inferenceUrl); + return (TResponse) processed; + } + }; + var mindeeClient = new MindeeClient(api); + + String url = "https://api.mindee.net/v2/inferences/12345678-1234-1234-1234-123456789abc"; + ExtractionResponse response = mindeeClient.getResultFromUrl(ExtractionResponse.class, url); + + assertNotNull(response, "getResultFromUrl() must return a response"); + assertEquals(url, capturedUrl.get(), "URL must be forwarded verbatim to the API"); + assertEquals(21, response.getInference().getResult().getFields().size()); + assertEquals( + "John Smith", + response + .getInference() + .getResult() + .getFields() + .get("supplier_name") + .getSimpleField() + .getValue() + ); + } + + @Test + @DisplayName("rejects a null URL") + void nullUrl_throws() { + var client = new MindeeClient(new FakeMindeeApiV2(null, null)); + assertThrows( + IllegalArgumentException.class, + () -> client.getResultFromUrl(ExtractionResponse.class, null) + ); + } + + @Test + @DisplayName("rejects a blank URL") + void blankUrl_throws() { + var client = new MindeeClient(new FakeMindeeApiV2(null, null)); + assertThrows( + IllegalArgumentException.class, + () -> client.getResultFromUrl(ExtractionResponse.class, " ") + ); + } + } + + @Nested + @DisplayName("polling with cancellation and backoff") + class Polling { + private JobResponse processing() throws JsonProcessingException { + String json = "{\"job\": {\"id\": \"dummy-id\", \"status\": \"Processing\"}}"; + var mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + return mapper.readValue(json, JobResponse.class); + } + + @Test + @DisplayName("cancelToken aborts polling with CancellationException") + void polling_cancelToken_aborts() throws IOException { + JobResponse processing = processing(); + AtomicInteger jobCalls = new AtomicInteger(); + AtomicBoolean cancel = new AtomicBoolean(false); + + var api = new FakeMindeeApiV2(processing, null) { + @Override + public JobResponse reqGetJob(String jobId) { + jobCalls.incrementAndGet(); + cancel.set(true); + return processing; + } + }; + var client = new MindeeClient(api); + + var options = PollingOptions + .builder() + .initialDelaySec(1.0) + .intervalSec(1.0) + .maxRetries(10) + .cancelToken(cancel::get) + .build(); + + var input = new LocalInputSource(getResourcePath("file_types/pdf/blank_1.pdf")); + assertThrows(CancellationException.class, () -> { + try { + client + .enqueueAndGetResult( + ExtractionResponse.class, + input, + ExtractionParameters.builder("dummy-model-id").build(), + options + ); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + }); + assertTrue(jobCalls.get() >= 1, "at least one poll should occur before cancellation"); + } + + @Test + @DisplayName("interval grows with backoff up to maxIntervalSec") + void polling_backoff_caps() { + var options = PollingOptions + .builder() + .intervalSec(1.0) + .backoffMultiplier(2.0) + .maxIntervalSec(5.0) + .build(); + + double interval = options.getIntervalSec(); + for (int i = 0; i < 10; i++) { + interval = Math.min(interval * options.getBackoffMultiplier(), options.getMaxIntervalSec()); + } + assertEquals(5.0, interval); + } + } } diff --git a/src/test/java/com/mindee/v2/clientoptions/PollingOptionsTest.java b/src/test/java/com/mindee/v2/clientoptions/PollingOptionsTest.java index d270d51c5..18a95f2bf 100644 --- a/src/test/java/com/mindee/v2/clientoptions/PollingOptionsTest.java +++ b/src/test/java/com/mindee/v2/clientoptions/PollingOptionsTest.java @@ -1,5 +1,6 @@ package com.mindee.v2.clientoptions; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -12,20 +13,34 @@ void shouldSetDefaultValues() { Assertions.assertEquals(3.0, pollingOptions.getInitialDelaySec()); Assertions.assertEquals(1.5, pollingOptions.getIntervalSec()); Assertions.assertEquals(100, pollingOptions.getMaxRetries()); + Assertions.assertEquals(1.5, pollingOptions.getBackoffMultiplier()); + Assertions.assertEquals(60.0, pollingOptions.getMaxIntervalSec()); + Assertions.assertNotNull(pollingOptions.getCancelToken()); + Assertions.assertFalse(pollingOptions.getCancelToken().getAsBoolean()); } @Test void shouldSetCustomValues() { + AtomicBoolean cancelled = new AtomicBoolean(false); PollingOptions pollingOptions = PollingOptions .builder() .initialDelaySec(4.0) .intervalSec(2.5) .maxRetries(50) + .backoffMultiplier(2.0) + .maxIntervalSec(30.0) + .cancelToken(cancelled::get) .build(); Assertions.assertEquals(4.0, pollingOptions.getInitialDelaySec()); Assertions.assertEquals(2.5, pollingOptions.getIntervalSec()); Assertions.assertEquals(50, pollingOptions.getMaxRetries()); + Assertions.assertEquals(2.0, pollingOptions.getBackoffMultiplier()); + Assertions.assertEquals(30.0, pollingOptions.getMaxIntervalSec()); + + Assertions.assertFalse(pollingOptions.getCancelToken().getAsBoolean()); + cancelled.set(true); + Assertions.assertTrue(pollingOptions.getCancelToken().getAsBoolean()); } @Test @@ -37,4 +52,24 @@ void shouldThrowWhenInitialDelayIsTooLow() { ); Assertions.assertEquals("Initial delay must be ≥ 1.0", exception.getMessage()); } + + @Test + void shouldThrowWhenBackoffMultiplierIsTooLow() { + IllegalArgumentException exception = Assertions + .assertThrows( + IllegalArgumentException.class, + () -> PollingOptions.builder().backoffMultiplier(0.9).build() + ); + Assertions.assertEquals("Backoff multiplier must be ≥ 1.0", exception.getMessage()); + } + + @Test + void shouldThrowWhenMaxIntervalIsBelowInterval() { + IllegalArgumentException exception = Assertions + .assertThrows( + IllegalArgumentException.class, + () -> PollingOptions.builder().intervalSec(5.0).maxIntervalSec(2.0).build() + ); + Assertions.assertTrue(exception.getMessage().startsWith("Max interval must be ≥ interval")); + } } diff --git a/src/test/java/com/mindee/v2/http/MindeeHttpApiV2Test.java b/src/test/java/com/mindee/v2/http/MindeeHttpApiV2Test.java new file mode 100644 index 000000000..2a72d28e0 --- /dev/null +++ b/src/test/java/com/mindee/v2/http/MindeeHttpApiV2Test.java @@ -0,0 +1,109 @@ +package com.mindee.v2.http; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.mindee.MindeeException; +import com.mindee.v2.MindeeSettings; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("MindeeV2 - HTTP API URL validation") +class MindeeHttpApiV2Test { + + private static MindeeHttpApiV2 apiWithBase(String baseUrl) { + return MindeeHttpApiV2 + .builder() + .mindeeSettings(new MindeeSettings("dummy-key", baseUrl)) + .build(); + } + + @Nested + @DisplayName("validateInferenceUrl() – prevents Authorization leak") + class ValidateInferenceUrl { + private final MindeeHttpApiV2 api = apiWithBase("https://api-v2.mindee.net/v2"); + + @Test + void sameHostAndPath_isAccepted() { + assertDoesNotThrow( + () -> api.validateInferenceUrl("https://api-v2.mindee.net/v2/inferences/abc-123") + ); + } + + @Test + void differentHost_isRejected() { + MindeeException e = assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("https://evil.example.com/v2/inferences/abc-123") + ); + assertTrue(e.getMessage().contains("does not match")); + } + + @Test + void differentPort_isRejected() { + assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("https://api-v2.mindee.net:8443/v2/inferences/abc-123") + ); + } + + @Test + void httpScheme_isRejected() { + assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("http://api-v2.mindee.net/v2/inferences/abc-123") + ); + } + + @Test + void pathOutsideBase_isRejected() { + MindeeException e = assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("https://api-v2.mindee.net/other/inferences/abc-123") + ); + assertTrue(e.getMessage().contains("not under")); + } + + @Test + void embeddedUserInfo_isRejected() { + assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("https://leak:leak@api-v2.mindee.net/v2/inferences/abc-123") + ); + } + + @Test + void relativeUrl_isRejected() { + assertThrows(MindeeException.class, () -> api.validateInferenceUrl("/v2/inferences/abc-123")); + } + + @Test + void invalidUri_isRejected() { + assertThrows( + MindeeException.class, + () -> api.validateInferenceUrl("https://api-v2.mindee.net/v2/inferences/{bad") + ); + } + + @Test + void hostCaseInsensitive_isAccepted() { + assertDoesNotThrow( + () -> api.validateInferenceUrl("https://API-V2.MINDEE.NET/v2/inferences/abc-123") + ); + } + + @Test + void customBaseUrl_isEnforced() { + var customApi = apiWithBase("https://custom.mindee.internal/api"); + assertDoesNotThrow( + () -> customApi.validateInferenceUrl("https://custom.mindee.internal/api/inferences/abc") + ); + assertThrows( + MindeeException.class, + () -> customApi.validateInferenceUrl("https://api-v2.mindee.net/v2/inferences/abc") + ); + } + } +} diff --git a/src/test/java/com/mindee/v2/parsing/JobTest.java b/src/test/java/com/mindee/v2/parsing/JobTest.java index 30996ee1a..6a3f3f880 100644 --- a/src/test/java/com/mindee/v2/parsing/JobTest.java +++ b/src/test/java/com/mindee/v2/parsing/JobTest.java @@ -28,6 +28,7 @@ void whenProcessing_mustHaveValidProperties() throws IOException { var job = response.getJob(); assertNotNull(job); assertEquals("Processing", job.getStatus()); + assertEquals(JobStatus.Processing, job.getStatusEnum()); assertNotNull(job.getCreatedAt()); assertNull(job.getCompletedAt()); assertNull(job.getResultUrl()); @@ -45,6 +46,7 @@ void whenProcessing_mustHaveValidProperties() throws IOException { var job = response.getJob(); assertNotNull(job); assertEquals("Processed", job.getStatus()); + assertEquals(JobStatus.Processed, job.getStatusEnum()); assertNotNull(job.getCreatedAt()); assertNotNull(job.getCompletedAt()); assertNotNull(job.getResultUrl()); diff --git a/src/test/java/com/mindee/v2/parsing/inference/field/SimpleFieldTest.java b/src/test/java/com/mindee/v2/parsing/inference/field/SimpleFieldTest.java new file mode 100644 index 000000000..ce02a1010 --- /dev/null +++ b/src/test/java/com/mindee/v2/parsing/inference/field/SimpleFieldTest.java @@ -0,0 +1,85 @@ +package com.mindee.v2.parsing.inference.field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("MindeeV2 - SimpleField numeric accessors") +class SimpleFieldTest { + + private static SimpleField of(Object value) { + return new SimpleField(value, FieldConfidence.Certain, null); + } + + @Test + @DisplayName("getDoubleValue accepts BigDecimal (wire path)") + void getDoubleValue_fromBigDecimal() { + assertEquals(1.5, of(new BigDecimal("1.5")).getDoubleValue()); + } + + @Test + @DisplayName("getDoubleValue accepts Double (programmatic path)") + void getDoubleValue_fromDouble() { + assertEquals(2.25, of(2.25d).getDoubleValue()); + } + + @Test + @DisplayName("getDoubleValue accepts Integer / Long / Float") + void getDoubleValue_fromOtherNumbers() { + assertEquals(42.0, of(42).getDoubleValue()); + assertEquals(9_000_000_000d, of(9_000_000_000L).getDoubleValue()); + assertEquals(0.5, of(0.5f).getDoubleValue(), 1e-6); + } + + @Test + @DisplayName("getDoubleValue on null value returns null") + void getDoubleValue_null() { + assertNull(of(null).getDoubleValue()); + } + + @Test + @DisplayName("getDoubleValue on non-numeric throws ClassCastException") + void getDoubleValue_nonNumeric_throws() { + assertThrows(ClassCastException.class, () -> of("nope").getDoubleValue()); + assertThrows(ClassCastException.class, () -> of(Boolean.TRUE).getDoubleValue()); + } + + @Test + @DisplayName("getBigDecimalValue accepts BigDecimal (wire path)") + void getBigDecimalValue_fromBigDecimal() { + assertEquals(new BigDecimal("1.5"), of(new BigDecimal("1.5")).getBigDecimalValue()); + } + + @Test + @DisplayName("getBigDecimalValue converts other Number types via toString to preserve digits") + void getBigDecimalValue_fromOtherNumbers() { + assertEquals(new BigDecimal("2.25"), of(2.25d).getBigDecimalValue()); + assertEquals(new BigDecimal("42"), of(42).getBigDecimalValue()); + assertEquals(new BigDecimal("9000000000"), of(9_000_000_000L).getBigDecimalValue()); + } + + @Test + @DisplayName("getBigDecimalValue on null value returns null") + void getBigDecimalValue_null() { + assertNull(of(null).getBigDecimalValue()); + } + + @Test + @DisplayName("getBigDecimalValue on non-numeric throws ClassCastException") + void getBigDecimalValue_nonNumeric_throws() { + assertThrows(ClassCastException.class, () -> of("nope").getBigDecimalValue()); + assertThrows(ClassCastException.class, () -> of(Boolean.TRUE).getBigDecimalValue()); + } + + @Test + @DisplayName("toString renders any Number in double-style format") + void toString_number() { + assertEquals("12.0", of(12).toString()); + assertEquals("1.5", of(new BigDecimal("1.5")).toString()); + assertEquals("2.25", of(2.25d).toString()); + } +} diff --git a/src/test/java/com/mindee/v2/product/ExtractionTest.java b/src/test/java/com/mindee/v2/product/ExtractionTest.java index 724e37639..ef87f438c 100644 --- a/src/test/java/com/mindee/v2/product/ExtractionTest.java +++ b/src/test/java/com/mindee/v2/product/ExtractionTest.java @@ -142,9 +142,9 @@ void asyncPredict_whenComplete_mustExposeAllProperties() throws IOException { assertNotNull(taxItemObj); assertEquals(3, taxItemObj.getFields().size()); SimpleField baseTax = taxItemObj.getFields().get("base").getSimpleField(); - assertEquals(31.5, baseTax.getValue()); + assertEquals(new BigDecimal("31.5"), baseTax.getValue()); assertEquals(31.5, baseTax.getDoubleValue()); - assertEquals(BigDecimal.valueOf(31.5), baseTax.getBigDecimalValue()); + assertEquals(new BigDecimal("31.5"), baseTax.getBigDecimalValue()); assertNotNull(taxes.toString()); // single object @@ -261,25 +261,27 @@ void standardFieldTypes_mustExposeSimpleFieldValues() throws IOException { var fieldSimpleFloat = fields.get("field_simple_float").getSimpleField(); assertNotNull(fieldSimpleFloat); - assertInstanceOf(Double.class, fieldSimpleFloat.getValue()); - assertEquals(fieldSimpleFloat.getValue(), fieldSimpleFloat.getDoubleValue()); + assertInstanceOf(BigDecimal.class, fieldSimpleFloat.getValue()); + assertEquals(fieldSimpleFloat.getValue(), fieldSimpleFloat.getBigDecimalValue()); + assertEquals(1.1, fieldSimpleFloat.getDoubleValue()); assertThrows(ClassCastException.class, fieldSimpleFloat::getStringValue); assertThrows(ClassCastException.class, fieldSimpleFloat::getBooleanValue); assertEquals(FieldConfidence.High, fieldSimpleFloat.getConfidence()); var fieldSimpleInt = fields.get("field_simple_int").getSimpleField(); assertNotNull(fieldSimpleInt); - assertInstanceOf(Double.class, fieldSimpleInt.getValue()); - assertEquals(fieldSimpleInt.getValue(), fieldSimpleInt.getDoubleValue()); + assertInstanceOf(BigDecimal.class, fieldSimpleInt.getValue()); + assertEquals(fieldSimpleInt.getValue(), fieldSimpleInt.getBigDecimalValue()); + assertEquals(12.0, fieldSimpleInt.getDoubleValue()); assertEquals(FieldConfidence.Medium, fieldSimpleInt.getConfidence()); assertThrows(ClassCastException.class, fieldSimpleInt::getStringValue); var fieldSimpleZero = fields.get("field_simple_zero").getSimpleField(); assertNotNull(fieldSimpleZero); assertEquals(FieldConfidence.Low, fieldSimpleZero.getConfidence()); - assertInstanceOf(Double.class, fieldSimpleZero.getValue()); + assertInstanceOf(BigDecimal.class, fieldSimpleZero.getValue()); assertEquals(0.0, fieldSimpleZero.getDoubleValue()); - assertEquals(BigDecimal.valueOf(0.0), fieldSimpleZero.getBigDecimalValue()); + assertEquals(BigDecimal.ZERO, fieldSimpleZero.getBigDecimalValue()); assertThrows(ClassCastException.class, fieldSimpleZero::getStringValue); assertThrows(ClassCastException.class, fieldSimpleZero::getBooleanValue);