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