getAuthData() {
* @return signature generate based on the message passed and the transloadit secret.
*/
private String getSignature(String message) throws LocalOperationException {
+ if (transloadit.secret == null) {
+ throw new LocalOperationException("Cannot generate signature without a secret or signature provider.");
+ }
byte[] kSecret = transloadit.secret.getBytes(Charset.forName("UTF-8"));
byte[] rawHmac = hmacSHA384(kSecret, message);
byte[] hexBytes = new Hex().encode(rawHmac);
diff --git a/src/main/java/com/transloadit/sdk/SignatureProvider.java b/src/main/java/com/transloadit/sdk/SignatureProvider.java
new file mode 100644
index 00000000..cc596708
--- /dev/null
+++ b/src/main/java/com/transloadit/sdk/SignatureProvider.java
@@ -0,0 +1,58 @@
+package com.transloadit.sdk;
+
+/**
+ * Interface for providing external signatures for Transloadit requests.
+ * Implement this interface to generate signatures on your backend server
+ * instead of including the secret key in your application.
+ *
+ * This approach significantly improves security by keeping your secret key
+ * on your backend server, preventing it from being exposed in client applications.
+ *
+ * Example implementation:
+ *
+ * public final class RemoteSignatureProvider implements SignatureProvider {
+ * private final HttpClient httpClient;
+ *
+ * public RemoteSignatureProvider(HttpClient httpClient) {
+ * this.httpClient = httpClient;
+ * }
+ *
+ * {@literal @}Override
+ * public String generateSignature(String paramsJson) throws Exception {
+ * HttpResponse response = httpClient.post("/api/sign")
+ * .body(paramsJson)
+ * .execute();
+ *
+ * if (!response.isSuccessful()) {
+ * throw new Exception("Failed to generate signature: " + response.statusCode());
+ * }
+ * return response.body().getString("signature");
+ * }
+ * }
+ *
+ *
+ * For asynchronous implementations, consider using CompletableFuture or similar patterns
+ * to bridge async operations to this synchronous interface.
+ *
+ * @see Transloadit Authentication Documentation
+ * @since 2.1.0
+ */
+public interface SignatureProvider {
+
+ /**
+ * Generate a signature for the given parameters JSON string.
+ *
+ * The implementation should generate a signature for the provided JSON parameters
+ * according to Transloadit's authentication requirements, typically using HMAC-SHA384
+ * with your secret key.
+ *
+ * This method is called synchronously, so implementations should either be fast
+ * or use appropriate timeout mechanisms. For network-based implementations, consider
+ * caching signatures when appropriate.
+ *
+ * @param paramsJson The JSON string containing the request parameters to sign
+ * @return The generated signature string (should include the algorithm prefix, e.g., "sha384:...")
+ * @throws Exception if signature generation fails for any reason
+ */
+ String generateSignature(String paramsJson) throws Exception;
+}
diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java
index ce4f86b0..57b95f8d 100644
--- a/src/main/java/com/transloadit/sdk/Transloadit.java
+++ b/src/main/java/com/transloadit/sdk/Transloadit.java
@@ -24,6 +24,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Properties;
import java.util.SortedMap;
import java.util.TreeMap;
@@ -46,6 +47,7 @@ public class Transloadit {
protected ArrayList qualifiedErrorsForRetry;
protected int retryDelay = 0; // default value
protected String versionInfo;
+ private SignatureProvider signatureProvider;
/**
* A new instance to transloadit client.
@@ -97,19 +99,98 @@ public Transloadit(String key, String secret) {
this(key, secret, 5 * 60, DEFAULT_HOST_URL);
}
+ /**
+ * A new instance to transloadit client with external signature generation.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @param duration for how long (in seconds) the request should be valid.
+ * @param hostUrl the host url to the transloadit service.
+ * @since 2.1.0
+ */
+ public Transloadit(String key, SignatureProvider signatureProvider, long duration, String hostUrl) {
+ this(key, (String) null, duration, hostUrl); // Explicit cast to avoid ambiguity
+ setSignatureProvider(Objects.requireNonNull(signatureProvider, "signatureProvider must not be null"));
+ }
+
+ /**
+ * A new instance to transloadit client with external signature generation.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @param duration for how long (in seconds) the request should be valid.
+ * @since 2.1.0
+ */
+ public Transloadit(String key, SignatureProvider signatureProvider, long duration) {
+ this(key, signatureProvider, duration, DEFAULT_HOST_URL);
+ }
+
+ /**
+ * A new instance to transloadit client with external signature generation.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @param hostUrl the host url to the transloadit service.
+ * @since 2.1.0
+ */
+ public Transloadit(String key, SignatureProvider signatureProvider, String hostUrl) {
+ this(key, signatureProvider, 5 * 60, hostUrl);
+ }
+
+ /**
+ * A new instance to transloadit client with external signature generation.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @since 2.1.0
+ */
+ public Transloadit(String key, SignatureProvider signatureProvider) {
+ this(key, signatureProvider, 5 * 60, DEFAULT_HOST_URL);
+ }
+
/**
* Enable/Disable request signing.
* @param flag the boolean value to set it to.
* @throws LocalOperationException if something goes wrong while running non-http operations.
*/
public void setRequestSigning(boolean flag) throws LocalOperationException {
- if (flag && secret == null) {
- throw new LocalOperationException("Cannot enable request signing with null secret.");
+ if (flag && secret == null && signatureProvider == null) {
+ throw new LocalOperationException("Cannot enable request signing with null secret and no signature provider.");
} else {
shouldSignRequest = flag;
}
}
+ /**
+ * Gets the signature provider if one has been set.
+ *
+ * @return The signature provider, or null if using built-in signature generation
+ * @since 2.1.0
+ */
+ public SignatureProvider getSignatureProvider() {
+ return signatureProvider;
+ }
+
+ /**
+ * Sets a signature provider for external signature generation.
+ *
+ * When a signature provider is set, it will be used instead of the built-in
+ * signature generation. This allows you to generate signatures on your backend
+ * server for improved security.
+ *
+ * @param signatureProvider The signature provider to use, or null to use built-in generation
+ * (disabling signing entirely when no secret is configured)
+ * @since 2.1.0
+ */
+ public void setSignatureProvider(@Nullable SignatureProvider signatureProvider) {
+ this.signatureProvider = signatureProvider;
+ if (signatureProvider != null) {
+ this.shouldSignRequest = true;
+ } else {
+ this.shouldSignRequest = this.secret != null;
+ }
+ }
+
/**
* Loads the current version from the version.properties File and builds an Info String for the
* Transloadit-Client header.
@@ -139,6 +220,34 @@ String getVersionInfo() {
return this.versionInfo;
}
+ /**
+ * Exposes the configured API key to subclasses.
+ *
+ * @return Transloadit key associated with this client
+ */
+ protected String getKeyInternal() {
+ return key;
+ }
+
+ /**
+ * Exposes the configured API secret to subclasses.
+ *
+ * @return the secret or {@code null} if not set
+ */
+ @Nullable
+ protected String getSecretInternal() {
+ return secret;
+ }
+
+ /**
+ * Indicates whether request signing is currently enabled.
+ *
+ * @return {@code true} if signature generation is active
+ */
+ protected boolean isSigningEnabledInternal() {
+ return shouldSignRequest;
+ }
+
/**
* Adjusts number of retry attempts that should be taken if a "RATE_LIMIT_REACHED" error appears
@@ -426,6 +535,7 @@ public void setRetryDelay(int delay) throws LocalOperationException {
* @param input Input value that is provided as ${fields.input} in the template
* @param urlParams Additional parameters for the URL query string (optional)
* @return The signed Smart CDN URL
+ * @throws LocalOperationException if URL encoding fails or signing cannot be performed
*/
public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input,
@Nullable Map> urlParams) throws LocalOperationException {
@@ -444,17 +554,25 @@ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String te
* @param urlParams Additional parameters for the URL query string (optional)
* @param expiresAt Expiration timestamp of the signature in milliseconds since the UNIX epoch.
* @return The signed Smart CDN URL
+ * @throws LocalOperationException if URL encoding fails or signing cannot be performed
*/
public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input,
@Nullable Map> urlParams, long expiresAt) throws LocalOperationException {
try {
+ if (this.secret == null) {
+ throw new LocalOperationException("Cannot sign Smart CDN URLs without a secret");
+ }
+
String workspaceSlug = URLEncoder.encode(workspace, StandardCharsets.UTF_8.name());
String templateSlug = URLEncoder.encode(template, StandardCharsets.UTF_8.name());
String inputField = URLEncoder.encode(input, StandardCharsets.UTF_8.name());
// Use TreeMap to ensure keys in URL params are sorted.
- SortedMap> params = new TreeMap<>(urlParams);
+ SortedMap> params = new TreeMap<>();
+ if (urlParams != null) {
+ params.putAll(urlParams);
+ }
params.put("auth_key", Collections.singletonList(this.key));
params.put("exp", Collections.singletonList(String.valueOf(expiresAt)));
diff --git a/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java b/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java
index f6f1873b..f1cd37ef 100644
--- a/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java
+++ b/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java
@@ -19,4 +19,13 @@ public LocalOperationException(Exception e) {
public LocalOperationException(String msg) {
super(msg);
}
+
+ /**
+ * Constructs a new LocalOperationException with the specified message and cause.
+ * @param msg detail message
+ * @param cause root cause
+ */
+ public LocalOperationException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
}
diff --git a/src/main/resources/java-sdk-version/version.properties b/src/main/resources/java-sdk-version/version.properties
index 3dacf21d..ac60fcd3 100644
--- a/src/main/resources/java-sdk-version/version.properties
+++ b/src/main/resources/java-sdk-version/version.properties
@@ -1 +1 @@
-versionNumber='2.0.1'
+versionNumber='2.1.0'
diff --git a/src/test/java/com/transloadit/sdk/RequestTest.java b/src/test/java/com/transloadit/sdk/RequestTest.java
index 1be38482..5d39d8ae 100644
--- a/src/test/java/com/transloadit/sdk/RequestTest.java
+++ b/src/test/java/com/transloadit/sdk/RequestTest.java
@@ -2,27 +2,29 @@
import com.transloadit.sdk.exceptions.LocalOperationException;
import com.transloadit.sdk.exceptions.RequestException;
-
-
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockserver.client.MockServerClient;
-
+import org.json.JSONObject;
import org.mockserver.junit.jupiter.MockServerExtension;
import org.mockserver.junit.jupiter.MockServerSettings;
import org.mockserver.matchers.Times;
import org.mockserver.model.HttpRequest;
+import org.mockserver.model.HttpResponse;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
-//CHECKSTYLE:OFF
-import java.util.Map; // Suppress warning as the Map import is needed for the JavaDoc Comments
import static org.mockserver.model.HttpError.error;
-//CHECKSTYLE:ON
/**
* Unit test for {@link Request} class. Api-Responses are simulated by mocking the server's response.
@@ -49,6 +51,75 @@ public void setUp() throws Exception {
mockServerClient.reset();
}
+ private JSONObject runSmartSig(String paramsJson, String key, String secret) throws Exception {
+ ProcessBuilder builder = new ProcessBuilder("npx", "--yes", "transloadit@4.0.4", "smart_sig");
+ builder.environment().put("TRANSLOADIT_KEY", key);
+ builder.environment().put("TRANSLOADIT_SECRET", secret);
+
+ Process process;
+ try {
+ process = builder.start();
+ } catch (IOException e) {
+ Assumptions.assumeTrue(false, "npx not available: " + e.getMessage());
+ return new JSONObject();
+ }
+
+ try (OutputStream os = process.getOutputStream()) {
+ os.write(paramsJson.getBytes(StandardCharsets.UTF_8));
+ }
+
+ String stdout;
+ String stderr;
+ try (InputStream stdoutStream = process.getInputStream();
+ InputStream stderrStream = process.getErrorStream()) {
+ stdout = readStream(stdoutStream).trim();
+ stderr = readStream(stderrStream).trim();
+ }
+ int status = process.waitFor();
+ if (status != 0) {
+ Assertions.fail("smart_sig CLI failed: " + stderr);
+ }
+ return new JSONObject(stdout);
+ }
+
+ private String readStream(InputStream stream) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int read;
+ while ((read = stream.read(chunk)) != -1) {
+ buffer.write(chunk, 0, read);
+ }
+ return buffer.toString(StandardCharsets.UTF_8.name());
+ }
+
+ private String extractMultipartField(String body, String fieldName) {
+ String token = "name=\"" + fieldName + "\"";
+ int nameIndex = body.indexOf(token);
+ if (nameIndex == -1) {
+ return null;
+ }
+
+ int headerEnd = body.indexOf("\r\n\r\n", nameIndex);
+ int delimiterLength = 4;
+ if (headerEnd == -1) {
+ headerEnd = body.indexOf("\n\n", nameIndex);
+ delimiterLength = 2;
+ }
+ if (headerEnd == -1) {
+ return null;
+ }
+
+ int valueStart = headerEnd + delimiterLength;
+ int boundaryIndex = body.indexOf("\r\n--", valueStart);
+ if (boundaryIndex == -1) {
+ boundaryIndex = body.indexOf("\n--", valueStart);
+ }
+ if (boundaryIndex == -1) {
+ boundaryIndex = body.length();
+ }
+
+ return body.substring(valueStart, boundaryIndex).trim();
+ }
/**
* Checks the result of the {@link Request#get(String)} method by verifying the format of the GET request
@@ -59,11 +130,10 @@ public void setUp() throws Exception {
public void get() throws Exception {
request.get("/foo");
-
mockServerClient.verify(HttpRequest.request()
.withPath("/foo")
.withMethod("GET")
- .withHeader("Transloadit-Client", "java-sdk:2.0.1"));
+ .withHeader("Transloadit-Client", "java-sdk:2.1.0"));
}
@@ -80,7 +150,6 @@ public void post() throws Exception {
.withPath("/foo").withMethod("POST"));
}
-
/**
* Checks the result of the {@link Request#delete(String, Map)} )} method by verifying the format of the
* DELETE request the MockServer receives.
@@ -154,7 +223,6 @@ public void retryAfterSpecificErrors() throws LocalOperationException, RequestEx
//mockServerClient.verify(HttpRequest.request("/foo").withMethod("GET"));
-
// POST REQUESTS
testRequest = new Request(transloadit2);
mockServerClient.when(HttpRequest.request()
@@ -177,6 +245,99 @@ public void retryAfterSpecificErrors() throws LocalOperationException, RequestEx
testRequest.delete("/foo", new HashMap());
}
+ /**
+ * Verifies that Request routes params through the custom SignatureProvider.
+ */
+ @Test
+ public void postUsesSignatureProviderWhenPresent() throws Exception {
+ final boolean[] invoked = {false};
+ final String expectedSignature = "providedSignature";
+ SignatureProvider provider = params -> {
+ invoked[0] = true;
+ return expectedSignature;
+ };
+
+ Transloadit client = new Transloadit("KEY", provider, "http://localhost:" + PORT);
+ Request providerRequest = new Request(client);
+
+ mockServerClient.when(HttpRequest.request().withPath("/signature-test").withMethod("POST"))
+ .respond(HttpResponse.response().withStatusCode(200));
+
+ providerRequest.post("/signature-test", new HashMap<>());
+
+ HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request()
+ .withPath("/signature-test").withMethod("POST"));
+ String body = recorded[0].getBodyAsString();
+
+ Assertions.assertTrue(invoked[0], "Signature provider should be called");
+ Assertions.assertTrue(body.contains(expectedSignature), "Signature should come from provider");
+ }
+
+ /**
+ * Built-in signing should match the Node smart_sig CLI output.
+ */
+ @Test
+ public void payloadSignatureMatchesSmartSigCli() throws Exception {
+ String key = "cli_key";
+ String secret = "cli_secret";
+ Transloadit client = new Transloadit(key, secret, "http://localhost:" + PORT);
+ Request localRequest = new Request(client);
+
+ HashMap params = new HashMap();
+
+ mockServerClient.when(HttpRequest.request().withPath("/cli-sign").withMethod("POST"))
+ .respond(HttpResponse.response().withStatusCode(200));
+
+ localRequest.post("/cli-sign", params);
+
+ HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request()
+ .withPath("/cli-sign").withMethod("POST"));
+ String body = recorded[0].getBodyAsString();
+ String paramsJson = extractMultipartField(body, "params");
+ String signature = extractMultipartField(body, "signature");
+
+ Assertions.assertNotNull(paramsJson, "params payload missing: " + body);
+ Assertions.assertNotNull(signature, "signature missing: " + body);
+
+ JSONObject cliResult = runSmartSig(paramsJson, key, secret);
+ Assertions.assertEquals(paramsJson, cliResult.getString("params"), "CLI params mismatch: " + cliResult);
+ Assertions.assertEquals(signature, cliResult.getString("signature"), "CLI signature mismatch: " + cliResult + " javaParams=" + paramsJson);
+ }
+
+ /**
+ * When signing is disabled, no signature parameter should be added.
+ */
+ @Test
+ public void toPayloadOmitsSignatureWhenSigningDisabled() throws Exception {
+ Transloadit client = new Transloadit("KEY", "SECRET", "http://localhost:" + PORT);
+ client.setRequestSigning(false);
+ Request localRequest = new Request(client);
+
+ mockServerClient.when(HttpRequest.request().withPath("/no-sign").withMethod("POST"))
+ .respond(HttpResponse.response().withStatusCode(200));
+
+ localRequest.post("/no-sign", new HashMap<>());
+
+ HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request()
+ .withPath("/no-sign").withMethod("POST"));
+ Assertions.assertFalse(recorded[0].getBodyAsString().contains("signature"));
+ }
+
+ /**
+ * Ensures provider exceptions are surfaced as LocalOperationException.
+ */
+ @Test
+ public void signatureProviderExceptionIsWrapped() {
+ SignatureProvider provider = params -> {
+ throw new Exception("boom");
+ };
+ Transloadit client = new Transloadit("KEY", provider, "http://localhost:" + PORT);
+ Request providerRequest = new Request(client);
+
+ Assertions.assertThrows(LocalOperationException.class, () ->
+ providerRequest.post("/signature-error", new HashMap<>()));
+ }
+
/**
* Test secure nonce generation with.
*/
diff --git a/src/test/java/com/transloadit/sdk/SignatureProviderTest.java b/src/test/java/com/transloadit/sdk/SignatureProviderTest.java
new file mode 100644
index 00000000..01825b8d
--- /dev/null
+++ b/src/test/java/com/transloadit/sdk/SignatureProviderTest.java
@@ -0,0 +1,122 @@
+package com.transloadit.sdk;
+
+import com.transloadit.sdk.exceptions.LocalOperationException;
+import org.jetbrains.annotations.NotNull;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Tests for {@link SignatureProvider} integration with {@link Transloadit} and {@link Request}.
+ */
+public class SignatureProviderTest {
+ private static final String TEST_SIGNATURE = "sha384:external-signature";
+
+ @Test
+ void signatureProviderConstructorsEnableSigning() {
+ SignatureProvider provider = params -> TEST_SIGNATURE;
+
+ Transloadit withHost = new Transloadit("KEY", provider, 60, "http://example.com");
+ Assertions.assertSame(provider, withHost.getSignatureProvider());
+ Assertions.assertTrue(withHost.shouldSignRequest);
+ Assertions.assertNull(withHost.secret);
+ Assertions.assertEquals("http://example.com", withHost.getHostUrl());
+
+ Transloadit withDefaults = new Transloadit("KEY", provider);
+ Assertions.assertSame(provider, withDefaults.getSignatureProvider());
+ Assertions.assertTrue(withDefaults.shouldSignRequest);
+ Assertions.assertNull(withDefaults.secret);
+ Assertions.assertEquals(5 * 60, withDefaults.duration);
+ Assertions.assertEquals(Transloadit.DEFAULT_HOST_URL, withDefaults.getHostUrl());
+ }
+
+ @Test
+ void setSignatureProviderTogglesSigningBasedOnAvailability() throws LocalOperationException {
+ SignatureProvider provider = params -> TEST_SIGNATURE;
+ Transloadit transloadit = new Transloadit("KEY", "SECRET");
+
+ transloadit.setSignatureProvider(provider);
+ Assertions.assertSame(provider, transloadit.getSignatureProvider());
+ Assertions.assertTrue(transloadit.shouldSignRequest);
+
+ transloadit.setSignatureProvider(null);
+ Assertions.assertNull(transloadit.getSignatureProvider());
+ Assertions.assertTrue(transloadit.shouldSignRequest); // falls back to secret-based signing
+
+ Transloadit withoutSecret = new Transloadit("KEY", provider);
+ Assertions.assertTrue(withoutSecret.shouldSignRequest);
+ withoutSecret.setSignatureProvider(null);
+ Assertions.assertFalse(withoutSecret.shouldSignRequest);
+ }
+
+ @Test
+ void toPayloadUsesSignatureFromProvider() throws Exception {
+ AtomicReference capturedParams = new AtomicReference<>();
+ SignatureProvider provider = paramsJson -> {
+ capturedParams.set(paramsJson);
+ return TEST_SIGNATURE;
+ };
+
+ Transloadit transloadit = new Transloadit("KEY", provider);
+ Request request = new Request(transloadit);
+
+ Map data = new HashMap<>();
+ data.put("template_id", "123");
+ data.put("expires", Instant.now().toString());
+
+ Map payload = invokeToPayload(request, data);
+ Assertions.assertEquals(TEST_SIGNATURE, payload.get("signature"));
+
+ String paramsJson = payload.get("params");
+ Assertions.assertNotNull(paramsJson);
+ Assertions.assertEquals(paramsJson, capturedParams.get());
+
+ JSONObject params = new JSONObject(paramsJson);
+ Assertions.assertEquals("123", params.get("template_id"));
+ Assertions.assertTrue(params.has("auth"));
+ Assertions.assertTrue(params.has("nonce"));
+ }
+
+ @Test
+ void toPayloadFallsBackToBuiltInSignature() throws Exception {
+ Transloadit transloadit = new Transloadit("KEY", "SECRET");
+ Request request = new Request(transloadit);
+
+ Map data = new HashMap<>();
+ Map payload = invokeToPayload(request, data);
+ Assertions.assertTrue(payload.containsKey("signature"));
+ Assertions.assertTrue(payload.get("signature").startsWith("sha384:"));
+ }
+
+ @Test
+ void toPayloadWrapsProviderExceptions() throws Exception {
+ SignatureProvider provider = params -> {
+ throw new IllegalStateException("backend unavailable");
+ };
+ Transloadit transloadit = new Transloadit("KEY", provider);
+ Request request = new Request(transloadit);
+
+ Map data = new HashMap<>();
+ InvocationTargetException invocationTargetException = Assertions.assertThrows(InvocationTargetException.class,
+ () -> invokeToPayload(request, data));
+
+ Throwable cause = invocationTargetException.getCause();
+ Assertions.assertTrue(cause instanceof LocalOperationException);
+ Assertions.assertEquals("Failed to generate signature using provider.", cause.getMessage());
+ Assertions.assertEquals(IllegalStateException.class, cause.getCause().getClass());
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map invokeToPayload(@NotNull Request request, Map data) throws Exception {
+ Method method = Request.class.getDeclaredMethod("toPayload", Map.class);
+ method.setAccessible(true);
+ return (Map) method.invoke(request, data);
+ }
+}
diff --git a/src/test/java/com/transloadit/sdk/TransloaditTest.java b/src/test/java/com/transloadit/sdk/TransloaditTest.java
index 92e6f1bd..a66284fe 100644
--- a/src/test/java/com/transloadit/sdk/TransloaditTest.java
+++ b/src/test/java/com/transloadit/sdk/TransloaditTest.java
@@ -61,6 +61,58 @@ public void getHostUrl() {
* @throws RequestException if communication with the server goes wrong.
* @throws IOException if Test resource "assembly.json" is missing.
*/
+
+ /**
+ * Verifies constructor overload that accepts a SignatureProvider enables signing.
+ */
+ @Test
+ public void constructorWithSignatureProviderEnablesSigning() {
+ SignatureProvider provider = params -> "signature";
+ Transloadit urlClient = new Transloadit("KEY", provider, "http://localhost:" + PORT);
+ Transloadit defaultClient = new Transloadit("KEY", provider);
+
+ Assertions.assertSame(provider, urlClient.getSignatureProvider());
+ Assertions.assertSame(provider, defaultClient.getSignatureProvider());
+ Assertions.assertTrue(urlClient.shouldSignRequest);
+ Assertions.assertTrue(defaultClient.shouldSignRequest);
+ Assertions.assertNull(urlClient.secret);
+ Assertions.assertNull(defaultClient.secret);
+ }
+
+ /**
+ * Throws when enabling signing without secret or provider.
+ */
+ @Test
+ public void setRequestSigningWithoutCredentialsThrows() {
+ Transloadit client = new Transloadit("KEY", (String) null, 5 * 60, "http://localhost:" + PORT);
+ Assertions.assertThrows(LocalOperationException.class, () -> client.setRequestSigning(true));
+ }
+
+ /**
+ * Ensures setSignatureProvider flips signing state depending on secret availability.
+ */
+ @Test
+ public void setSignatureProviderTogglesSigningBasedOnSecret() {
+ Transloadit noSecret = new Transloadit("KEY", (String) null, 5 * 60, "http://localhost:" + PORT);
+ Assertions.assertFalse(noSecret.shouldSignRequest);
+
+ SignatureProvider provider = params -> "signature";
+ noSecret.setSignatureProvider(provider);
+ Assertions.assertTrue(noSecret.shouldSignRequest);
+
+ noSecret.setSignatureProvider(null);
+ Assertions.assertFalse(noSecret.shouldSignRequest);
+
+ Transloadit withSecret = new Transloadit("KEY", "SECRET", "http://localhost:" + PORT);
+ withSecret.setSignatureProvider(null);
+ Assertions.assertTrue(withSecret.shouldSignRequest);
+
+ Transloadit withSecretDefaultUrl = new Transloadit("KEY", "SECRET");
+ withSecretDefaultUrl.setSignatureProvider(provider);
+ Assertions.assertTrue(withSecretDefaultUrl.shouldSignRequest);
+ Assertions.assertSame(provider, withSecretDefaultUrl.getSignatureProvider());
+ }
+
@Test
public void getAssembly() throws LocalOperationException, RequestException, IOException {
mockServerClient.when(HttpRequest.request()
@@ -121,6 +173,22 @@ public void cancelAssembly() throws LocalOperationException, RequestException, I
* @throws RequestException if communication with the server goes wrong.
* @throws IOException if Test resource "assemblies.json" is missing.
*/
+ /**
+ * Checks listAssemblies parses the returned JSON into count and items correctly.
+ */
+ @Test
+ public void listAssembliesParsesItems() throws RequestException, LocalOperationException, IOException {
+ mockServerClient.when(HttpRequest.request()
+ .withPath("/assemblies").withMethod("GET"))
+ .respond(HttpResponse.response().withBody(getJson("assemblies_with_items.json")));
+
+ ListResponse assemblies = transloadit.listAssemblies();
+ Assertions.assertEquals(2, assemblies.size());
+ Assertions.assertEquals(2, assemblies.getItems().length());
+ Assertions.assertEquals("abcd1234", assemblies.getItems().getJSONObject(0).getString("assembly_id"));
+ Assertions.assertEquals("efgh5678", assemblies.getItems().getJSONObject(1).getString("assembly_id"));
+ }
+
@Test
public void listAssemblies() throws RequestException, LocalOperationException, IOException {
@@ -282,6 +350,32 @@ public void loadVersionInfo() {
Assertions.assertTrue(matcher.find());
}
+ /**
+ * Smart CDN signing should fail when no secret is configured.
+ */
+ @Test
+ public void getSignedSmartCDNUrlWithoutSecretThrows() {
+ Transloadit client = new Transloadit("foo_key", params -> "ignored");
+ Map> params = new HashMap<>();
+ params.put("foo", Collections.singletonList("bar"));
+
+ Assertions.assertThrows(LocalOperationException.class, () ->
+ client.getSignedSmartCDNUrl("workspace", "template", "input", params));
+ }
+
+ /**
+ * Smart CDN signing works when no optional parameters are provided.
+ */
+ @Test
+ @SuppressWarnings("checkstyle:linelength")
+ public void getSignedSmartCDNUrlHandlesNullParams() throws LocalOperationException {
+ Transloadit client = new Transloadit("foo_key", "foo_secret");
+ long expiresAt = Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli();
+ String url = client.getSignedSmartCDNUrl("foo_workspace", "foo_template", "foo/input", null, expiresAt);
+ Assertions.assertTrue(url.contains("auth_key=foo_key"));
+ Assertions.assertTrue(url.contains("sig=sha256"));
+ }
+
/**
* Test if the SDK can generate a correct signed Smart CDN URL.
*/
@@ -293,14 +387,16 @@ public void getSignedSmartCDNURL() throws LocalOperationException {
params.put("foo", Collections.singletonList("bar"));
params.put("aaa", Arrays.asList("42", "21")); // Must be sorted before `foo`
+ long expiresAt = Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli();
String url = client.getSignedSmartCDNUrl(
"foo_workspace",
"foo_template",
"foo/input",
params,
- Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli()
+ expiresAt
);
- Assertions.assertEquals("https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519", url);
+ String expectedUrl = "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519";
+ Assertions.assertEquals(expectedUrl, url);
}
}
diff --git a/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java b/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java
new file mode 100644
index 00000000..71a00ccc
--- /dev/null
+++ b/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java
@@ -0,0 +1,33 @@
+package com.transloadit.sdk.exceptions;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Basic coverage tests for exception constructors.
+ */
+public class ExceptionsTest {
+ @Test
+ void requestExceptionConstructors() {
+ Exception cause = new IllegalArgumentException("boom");
+ RequestException wrapped = new RequestException(cause);
+ Assertions.assertEquals(cause, wrapped.getCause());
+
+ RequestException messageOnly = new RequestException("message");
+ Assertions.assertEquals("message", messageOnly.getMessage());
+ }
+
+ @Test
+ void localOperationExceptionConstructors() {
+ Exception cause = new IllegalStateException("nope");
+ LocalOperationException wrapped = new LocalOperationException(cause);
+ Assertions.assertEquals(cause, wrapped.getCause());
+
+ LocalOperationException messageOnly = new LocalOperationException("message");
+ Assertions.assertEquals("message", messageOnly.getMessage());
+
+ LocalOperationException messageAndCause = new LocalOperationException("detail", cause);
+ Assertions.assertEquals("detail", messageAndCause.getMessage());
+ Assertions.assertEquals(cause, messageAndCause.getCause());
+ }
+}
diff --git a/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java b/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java
new file mode 100644
index 00000000..20be583a
--- /dev/null
+++ b/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java
@@ -0,0 +1,53 @@
+package com.transloadit.sdk.integration;
+
+import com.transloadit.sdk.Assembly;
+import com.transloadit.sdk.Transloadit;
+import com.transloadit.sdk.response.AssemblyResponse;
+import org.json.JSONArray;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+public class AssemblyIntegrationTest {
+
+ @Test
+ void createAssemblyAndWaitForCompletion() throws Exception {
+ String key = System.getenv("TRANSLOADIT_KEY");
+ String secret = System.getenv("TRANSLOADIT_SECRET");
+ Assumptions.assumeTrue(key != null && !key.trim().isEmpty(), "TRANSLOADIT_KEY env var required");
+ Assumptions.assumeTrue(secret != null && !secret.trim().isEmpty(), "TRANSLOADIT_SECRET env var required");
+
+ Transloadit client = new Transloadit(key, secret);
+ Assembly assembly = client.newAssembly();
+
+ Map importStep = new HashMap<>();
+ importStep.put("url", "https://demos.transloadit.com/inputs/chameleon.jpg");
+ assembly.addStep("import", "/http/import", importStep);
+
+ Map resizeStep = new HashMap<>();
+ resizeStep.put("use", "import");
+ resizeStep.put("width", 32);
+ resizeStep.put("height", 32);
+ assembly.addStep("resize", "/image/resize", resizeStep);
+
+ AssemblyResponse response = assembly.save(false);
+ String assemblyId = response.getId();
+
+ long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5);
+ while (!response.isFinished() && System.currentTimeMillis() < deadline) {
+ Thread.sleep(5000);
+ response = client.getAssembly(assemblyId);
+ }
+
+ Assertions.assertTrue(response.isFinished(), "Assembly did not finish in time");
+ Assertions.assertEquals("ASSEMBLY_COMPLETED", response.json().optString("ok"));
+
+ JSONArray stepResult = response.getStepResult("resize");
+ Assertions.assertNotNull(stepResult, "resize step result missing");
+ Assertions.assertTrue(stepResult.length() > 0, "resize step result empty");
+ }
+}
diff --git a/src/test/java/com/transloadit/sdk/integration/package-info.java b/src/test/java/com/transloadit/sdk/integration/package-info.java
new file mode 100644
index 00000000..caae4454
--- /dev/null
+++ b/src/test/java/com/transloadit/sdk/integration/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Integration tests that exercise live Transloadit API interactions.
+ */
+package com.transloadit.sdk.integration;
diff --git a/src/test/resources/__files/assemblies_with_items.json b/src/test/resources/__files/assemblies_with_items.json
new file mode 100644
index 00000000..b08564c1
--- /dev/null
+++ b/src/test/resources/__files/assemblies_with_items.json
@@ -0,0 +1 @@
+{"items":[{"assembly_id":"abcd1234","ok":"ASSEMBLY_COMPLETED"},{"assembly_id":"efgh5678","ok":"ASSEMBLY_UPLOADING"}],"count":2}