Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,13 @@
</dependency>
<dependency>
<artifactId>jackson-databind</artifactId>
<groupId>tools.jackson.core</groupId>
<version>${tools.jackson.core.databind.version}</version>
<groupId>com.fasterxml.jackson.core</groupId>
<version>${com.fasterxml.jackson.version}</version>
</dependency>
<dependency>
<artifactId>jackson-datatype-jsr310</artifactId>
<groupId>com.fasterxml.jackson.datatype</groupId>
<version>${com.fasterxml.jackson.datatype.version}</version>
<version>${com.fasterxml.jackson.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
Expand Down Expand Up @@ -390,8 +390,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<!-- Code dependencies -->
<tools.jackson.core.databind.version>3.1.1</tools.jackson.core.databind.version>
<com.fasterxml.jackson.datatype.version>2.21.2</com.fasterxml.jackson.datatype.version>
<com.fasterxml.jackson.version>2.21.2</com.fasterxml.jackson.version>
<com.squareup.okhttp3.version>4.12.0</com.squareup.okhttp3.version>
<info.picocli.version>4.7.7</info.picocli.version>
<org.apache.commons.math3.version>3.6.1</org.apache.commons.math3.version>
Expand Down
76 changes: 75 additions & 1 deletion src/main/java/com/mindee/input/URLInputSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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.
*
* <p>
* Rejects any URL that could be used for Server-Side Request Forgery (SSRF):
* <ul>
* <li>non-HTTPS schemes,</li>
* <li>embedded userinfo (e.g. {@code https://user:pass@host}),</li>
* <li>loopback hostnames ({@code localhost}, {@code *.localhost}),</li>
* <li>hosts that resolve to loopback, link-local, site-local (RFC 1918),
* any-local ({@code 0.0.0.0}), multicast, IPv6 unique-local
* ({@code fc00::/7}) or carrier-grade NAT ({@code 100.64.0.0/10})
* addresses.</li>
* </ul>
*/
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;
}

/**
Expand Down
51 changes: 47 additions & 4 deletions src/main/java/com/mindee/v2/MindeeClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -84,6 +86,20 @@ public <TResponse extends CommonResponse> 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 extends CommonResponse> TResponse getResultFromUrl(
Class<TResponse> 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.
Expand Down Expand Up @@ -215,22 +231,26 @@ private <TResponse extends CommonResponse> 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++;
}

Expand All @@ -241,6 +261,29 @@ private <TResponse extends CommonResponse> 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()
Expand Down
45 changes: 44 additions & 1 deletion src/main/java/com/mindee/v2/clientoptions/PollingOptions.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/mindee/v2/http/MindeeApiV2.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ public abstract <TResponse extends CommonResponse> 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 extends CommonResponse> TResponse reqGetResultFromUrl(
Class<TResponse> responseClass,
String inferenceUrl
);

/**
* Retrieves a list of models.
*
Expand Down
85 changes: 85 additions & 0 deletions src/main/java/com/mindee/v2/http/MindeeHttpApiV2.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,91 @@ public <TResponse extends CommonResponse> TResponse reqGetResult(
return executeAPIRequest(get, responseClass);
}

@Override
public <TResponse extends CommonResponse> TResponse reqGetResultFromUrl(
Class<TResponse> 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.
*
* <p>
* 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;
Expand Down
Loading
Loading