From 163e235e372d37a4608ea06215dbef0d9826f5e0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 03:53:09 +0200 Subject: [PATCH 01/15] Mark generated API2 endpoint blocks --- .../java/com/transloadit/sdk/Transloadit.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 57b95f8..f48a433 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -327,11 +327,19 @@ public Assembly newAssembly() { * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public AssemblyResponse getAssembly(String id) throws RequestException, LocalOperationException { Request request = new Request(this); return new AssemblyResponse(request.get("/assemblies/" + id)); } + // + /** * Returns a single assembly. * @@ -354,12 +362,20 @@ public AssemblyResponse getAssemblyByUrl(String url) * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public AssemblyResponse cancelAssembly(String url) throws RequestException, LocalOperationException { Request request = new Request(this); return new AssemblyResponse(request.delete(url, new HashMap())); } + // + /** * Returns a list of all assemblies under the user account. * @@ -368,12 +384,20 @@ public AssemblyResponse cancelAssembly(String url) * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public ListResponse listAssemblies(Map options) throws RequestException, LocalOperationException { Request request = new Request(this); return new ListResponse(request.get("/assemblies", options)); } + // + /** * Returns a list of all assemblies under the user account. * @return {@link ListResponse} @@ -403,11 +427,19 @@ public Template newTemplate(String name) { * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public Response getTemplate(String id) throws RequestException, LocalOperationException { Request request = new Request(this); return new Response(request.get("/templates/" + id)); } + // + /** * Updates the template with the specified id. * @@ -418,12 +450,20 @@ public Response getTemplate(String id) throws RequestException, LocalOperationEx * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public Response updateTemplate(String id, Map options) throws RequestException, LocalOperationException { Request request = new Request(this); return new Response(request.put("/templates/" + id, options)); } + // + /** * Deletes a template. * @@ -433,12 +473,20 @@ public Response updateTemplate(String id, Map options) * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public Response deleteTemplate(String id) throws RequestException, LocalOperationException { Request request = new Request(this); return new Response(request.delete("/templates/" + id, new HashMap())); } + // + /** * Returns a list of all templates under the user account. * @@ -448,12 +496,20 @@ public Response deleteTemplate(String id) * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public ListResponse listTemplates(Map options) throws RequestException, LocalOperationException { Request request = new Request(this); return new ListResponse(request.get("/templates", options)); } + // + /** * Returns a list of all templates under the user account. * @@ -477,12 +533,20 @@ public ListResponse listTemplates() * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public Response getBill(int month, int year) throws RequestException, LocalOperationException { Request request = new Request(this); return new Response(request.get("/bill/" + year + String.format("-%02d", month))); } + // + /** * Returns Array List of String encoded Exceptions, which should be qualified for a retry attempt. * {@code "java.net.SocketTimeoutException" } is added by default From 06a79b7924bd10989f704cfa6b9d61220b5377ac Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 04:07:19 +0200 Subject: [PATCH 02/15] Add API2 template lifecycle example --- examples/build.gradle | 6 + .../Api2DevdockTemplateLifecycle.java | 177 ++++++++++++++++++ settings.gradle | 4 +- .../java/com/transloadit/sdk/Transloadit.java | 23 +++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 examples/src/main/java/com/transloadit/examples/Api2DevdockTemplateLifecycle.java diff --git a/examples/build.gradle b/examples/build.gradle index a75ea9e..3426c6d 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -10,6 +10,7 @@ sourceCompatibility = 1.8 targetCompatibility = 1.8 repositories { + mavenCentral() jcenter() } @@ -37,3 +38,8 @@ compileTestKotlin { jvmTarget.set(JvmTarget.JVM_1_8) } } + +tasks.register('api2DevdockTemplateLifecycle', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.transloadit.examples.Api2DevdockTemplateLifecycle' +} diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTemplateLifecycle.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTemplateLifecycle.java new file mode 100644 index 0000000..ee19673 --- /dev/null +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTemplateLifecycle.java @@ -0,0 +1,177 @@ +package com.transloadit.examples; + +import com.transloadit.sdk.Transloadit; +import com.transloadit.sdk.response.ListResponse; +import com.transloadit.sdk.response.Response; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Runs API2's contract-owned Template lifecycle scenario against devdock. + */ +public final class Api2DevdockTemplateLifecycle { + /** + * Runs the Template lifecycle scenario. + * + * @param args ignored + * @throws Exception if the scenario cannot be completed + */ + public static void main(String[] args) throws Exception { + JSONObject scenario = loadScenario(); + Transloadit transloadit = new Transloadit( + requiredEnv("TRANSLOADIT_KEY"), + requiredEnv("TRANSLOADIT_SECRET"), + requiredEnv("TRANSLOADIT_ENDPOINT")); + + JSONObject templateConfig = scenario.getJSONObject("template"); + JSONObject updateConfig = scenario.getJSONObject("update"); + String templateName = templateConfig.getString("namePrefix") + "-" + System.currentTimeMillis(); + + Response created = transloadit.createTemplate(templatePayload(templateName, templateConfig)); + String templateId = created.json().getString("id"); + boolean deleteTemplate = true; + + try { + Response fetched = transloadit.getTemplate(templateId); + + Map listOptions = new HashMap(); + listOptions.put("pagesize", scenario.getJSONObject("list").getInt("pageSize")); + ListResponse listed = transloadit.listTemplates(listOptions); + + String updatedTemplateName = templateName + updateConfig.getString("nameSuffix"); + transloadit.updateTemplate(templateId, templatePayload(updatedTemplateName, updateConfig)); + Response updated = transloadit.getTemplate(templateId); + + transloadit.deleteTemplate(templateId); + deleteTemplate = false; + + JSONObject result = new JSONObject(); + JSONObject deletedGet = deletedGetResult(transloadit, templateId); + for (Iterator keys = deletedGet.keys(); keys.hasNext();) { + String key = keys.next(); + result.put(key, deletedGet.get(key)); + } + result.put("fetched", templateResult(fetched.json())); + result.put("listCount", listed.size()); + result.put("templateId", templateId); + result.put("templateName", templateName); + result.put("updated", templateResult(updated.json())); + result.put("updatedTemplateName", updatedTemplateName); + writeResult(result); + } finally { + if (deleteTemplate) { + transloadit.deleteTemplate(templateId); + } + } + + System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") + + " passed for " + templateId); + } + + private static JSONObject deletedGetResult(Transloadit transloadit, String templateId) throws Exception { + Response response = transloadit.getTemplate(templateId); + JSONObject body = response.json(); + boolean succeeded = response.status() >= 200 && response.status() < 300 && !body.has("error"); + + JSONObject result = new JSONObject(); + result.put("deletedGetSucceeded", succeeded); + result.put("deletedErrorCode", body.optString("error", body.optString("ok", ""))); + return result; + } + + private static JSONObject loadScenario() throws Exception { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "examples/api2-devdock-template-lifecycle/api2-scenario.json"; + } + + byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException(name + " must be set"); + } + + return value; + } + + private static Map templatePayload(String name, JSONObject config) { + JSONObject content = config.getJSONObject("content"); + Map template = jsonObjectToMap(content.getJSONObject("additionalProperties")); + template.put("steps", jsonObjectToMap(content.getJSONObject("steps"))); + + Map payload = new HashMap(); + payload.put("name", name); + payload.put("require_signature_auth", config.getBoolean("requireSignatureAuth") ? 1 : 0); + payload.put("template", template); + return payload; + } + + private static JSONObject templateResult(JSONObject template) { + JSONObject result = new JSONObject(); + result.put("content", template.getJSONObject("content")); + result.put("id", template.getString("id")); + result.put("name", template.getString("name")); + result.put("requireSignatureAuth", template.getInt("require_signature_auth") != 0); + return result; + } + + private static Map jsonObjectToMap(JSONObject object) { + Map map = new HashMap(); + for (Iterator keys = object.keys(); keys.hasNext();) { + String key = keys.next(); + map.put(key, jsonValueToJava(object.get(key))); + } + + return map; + } + + private static Object jsonValueToJava(Object value) { + if (value == JSONObject.NULL) { + return null; + } + + if (value instanceof JSONObject) { + return jsonObjectToMap((JSONObject) value); + } + + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + List list = new ArrayList(); + for (int index = 0; index < array.length(); index += 1) { + list.add(jsonValueToJava(array.get(index))); + } + + return list; + } + + return value; + } + + private static void writeResult(JSONObject result) throws Exception { + String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private Api2DevdockTemplateLifecycle() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/settings.gradle b/settings.gradle index 6110b74..dc30395 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ // Uncomment following line if you want to use the local java-sdk // for the example instead of pulling the JARs from JCenter. // This is useful for debugging and testing new features. -// include ':examples' -rootProject.name = 'transloadit' \ No newline at end of file +include ':examples' +rootProject.name = 'transloadit' diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index f48a433..323fce1 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -418,6 +418,29 @@ public Template newTemplate(String name) { return new Template(this, name); } + /** + * Creates a template. + * + * @param options a Map of options to create. + * @return {@link Response} + * + * @throws RequestException if request to transloadit server fails. + * @throws LocalOperationException if something goes wrong while running non-http operations. + */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + public Response createTemplate(Map options) + throws RequestException, LocalOperationException { + Request request = new Request(this); + return new Response(request.post("/templates", options)); + } + + // + /** * Returns a single template. * From 13f4bb78a6110f357add896678a7a49cb1e53d1a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 04:18:32 +0200 Subject: [PATCH 03/15] Add API2 TUS assembly example --- examples/build.gradle | 6 + .../examples/Api2DevdockTusAssembly.java | 181 ++++++++++++++++++ .../java/com/transloadit/sdk/Transloadit.java | 23 +++ 3 files changed, 210 insertions(+) create mode 100644 examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java diff --git a/examples/build.gradle b/examples/build.gradle index 3426c6d..5c31b8f 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -16,6 +16,7 @@ repositories { dependencies { implementation rootProject + implementation 'io.tus.java.client:tus-java-client:0.5.1' implementation 'org.json:json:20231013' } buildscript { @@ -43,3 +44,8 @@ tasks.register('api2DevdockTemplateLifecycle', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'com.transloadit.examples.Api2DevdockTemplateLifecycle' } + +tasks.register('api2DevdockTusAssembly', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.transloadit.examples.Api2DevdockTusAssembly' +} diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java new file mode 100644 index 0000000..8fe55ef --- /dev/null +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -0,0 +1,181 @@ +package com.transloadit.examples; + +import com.transloadit.sdk.Transloadit; +import com.transloadit.sdk.response.AssemblyResponse; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Runs API2's contract-owned TUS Assembly scenario against devdock. + */ +public final class Api2DevdockTusAssembly { + /** + * Runs the TUS Assembly scenario. + * + * @param args ignored + * @throws Exception if the scenario cannot be completed + */ + public static void main(String[] args) throws Exception { + JSONObject scenario = loadScenario(); + Transloadit transloadit = new Transloadit( + requiredEnv("TRANSLOADIT_KEY"), + requiredEnv("TRANSLOADIT_SECRET"), + requiredEnv("TRANSLOADIT_ENDPOINT")); + + JSONObject createRequest = scenario.getJSONObject("createTusAssembly").getJSONObject("request"); + AssemblyResponse created = transloadit.createAssembly( + jsonObjectToMap(createRequest.getJSONObject("normalizedParams")), + jsonObjectToStringMap(createRequest.getJSONObject("formFields"))); + JSONObject createResponse = created.json(); + + String uploadUrl = uploadScenarioBytes(scenario, createResponse); + + JSONObject result = new JSONObject(); + result.put("createResponse", createResponse); + result.put("uploadUrl", uploadUrl); + writeResult(result); + + System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") + + " uploaded to " + uploadUrl); + } + + private static JSONObject loadScenario() throws Exception { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "examples/api2-devdock-tus-assembly/api2-scenario.json"; + } + + byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException(name + " must be set"); + } + + return value; + } + + private static String uploadScenarioBytes(JSONObject scenario, JSONObject createResponse) throws Exception { + JSONObject uploadConfig = scenario.getJSONObject("upload"); + JSONObject source = uploadConfig.getJSONObject("source"); + byte[] bytes = source.getString("value").getBytes(StandardCharsets.UTF_8); + + TusClient tusClient = new TusClient(); + tusClient.setUploadCreationURL(new URL(createResponse.getString("tus_url"))); + + TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(bytes)); + upload.setSize(bytes.length); + upload.setFingerprint("api2-devdock-java-sdk-" + createResponse.getString("assembly_id")); + upload.setMetadata(uploadMetadata(scenario, createResponse)); + + TusUploader uploader = tusClient.createUpload(upload); + uploader.setChunkSize(bytes.length); + while (uploader.uploadChunk() > -1) { + // Upload the single scenario-owned source until tus-java-client reports completion. + } + uploader.finish(false); + + return uploader.getUploadURL().toString(); + } + + private static Map uploadMetadata( + JSONObject scenario, + JSONObject createResponse) { + Map metadata = new HashMap(); + JSONArray fields = scenario.getJSONObject("upload").getJSONArray("metadata"); + for (int index = 0; index < fields.length(); index += 1) { + JSONObject field = fields.getJSONObject(index); + metadata.put(field.getString("name"), String.valueOf(resolveScenarioValue( + field.getJSONObject("value"), + scenario, + createResponse))); + } + + return metadata; + } + + private static Object resolveScenarioValue( + JSONObject value, + JSONObject scenario, + JSONObject createResponse) { + if (value.has("value")) { + return value.get("value"); + } + + JSONObject source = value.getJSONObject("source"); + Object current; + if ("scenario".equals(source.getString("root"))) { + current = scenario; + } else if ("createResponse".equals(source.getString("root"))) { + current = createResponse; + } else { + throw new IllegalStateException("Unsupported scenario value root: " + source.getString("root")); + } + + JSONArray path = source.getJSONArray("path"); + for (int index = 0; index < path.length(); index += 1) { + if (!(current instanceof JSONObject)) { + throw new IllegalStateException("Cannot resolve scenario path through non-object value"); + } + + current = ((JSONObject) current).get(path.getString(index)); + } + + return current; + } + + private static Map jsonObjectToMap(JSONObject object) { + Map map = new HashMap(); + for (Iterator keys = object.keys(); keys.hasNext();) { + String key = keys.next(); + Object value = object.get(key); + if (value instanceof JSONObject) { + value = jsonObjectToMap((JSONObject) value); + } + map.put(key, value); + } + + return map; + } + + private static Map jsonObjectToStringMap(JSONObject object) { + Map map = new HashMap(); + for (Iterator keys = object.keys(); keys.hasNext();) { + String key = keys.next(); + map.put(key, String.valueOf(object.get(key))); + } + + return map; + } + + private static void writeResult(JSONObject result) throws Exception { + String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private Api2DevdockTusAssembly() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 323fce1..1edfc00 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -319,6 +319,29 @@ public Assembly newAssembly() { return new Assembly(this); } + /** + * Creates an assembly. + * + * @param options a Map of options to create. + * @param extraData extra form data to create the assembly with. + * @return {@link AssemblyResponse} + * @throws RequestException if request to transloadit server fails. + * @throws LocalOperationException if something goes wrong while running non-http operations. + */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + public AssemblyResponse createAssembly(Map options, Map extraData) + throws RequestException, LocalOperationException { + Request request = new Request(this); + return new AssemblyResponse(request.post("/assemblies", options, extraData, null, null)); + } + + // + /** * Returns a single assembly. * From 449c229e2d0964621247784ea24cbc6a148322e0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 04:37:10 +0200 Subject: [PATCH 04/15] Generate createTusAssembly feature --- .../examples/Api2DevdockTusAssembly.java | 33 +++---------------- .../java/com/transloadit/sdk/Transloadit.java | 29 ++++++++++++++++ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 8fe55ef..049c8e1 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -14,7 +14,6 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; /** @@ -34,10 +33,10 @@ public static void main(String[] args) throws Exception { requiredEnv("TRANSLOADIT_SECRET"), requiredEnv("TRANSLOADIT_ENDPOINT")); - JSONObject createRequest = scenario.getJSONObject("createTusAssembly").getJSONObject("request"); - AssemblyResponse created = transloadit.createAssembly( - jsonObjectToMap(createRequest.getJSONObject("normalizedParams")), - jsonObjectToStringMap(createRequest.getJSONObject("formFields"))); + int fileCount = scenario.getJSONObject("createTusAssembly") + .getJSONObject("input") + .getInt("file_count"); + AssemblyResponse created = transloadit.createTusAssembly(fileCount); JSONObject createResponse = created.json(); String uploadUrl = uploadScenarioBytes(scenario, createResponse); @@ -140,30 +139,6 @@ private static Object resolveScenarioValue( return current; } - private static Map jsonObjectToMap(JSONObject object) { - Map map = new HashMap(); - for (Iterator keys = object.keys(); keys.hasNext();) { - String key = keys.next(); - Object value = object.get(key); - if (value instanceof JSONObject) { - value = jsonObjectToMap((JSONObject) value); - } - map.put(key, value); - } - - return map; - } - - private static Map jsonObjectToStringMap(JSONObject object) { - Map map = new HashMap(); - for (Iterator keys = object.keys(); keys.hasNext();) { - String key = keys.next(); - map.put(key, String.valueOf(object.get(key))); - } - - return map; - } - private static void writeResult(JSONObject result) throws Exception { String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); if (resultPath == null || resultPath.isEmpty()) { diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 1edfc00..6ae5e30 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -342,6 +342,35 @@ public AssemblyResponse createAssembly(Map options, Map + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Creates a TUS-ready Assembly that waits for the requested number of resumable uploads + * before execution continues. + */ + public AssemblyResponse createTusAssembly(int fileCount) + throws RequestException, LocalOperationException { + Map options = new HashMap(); + options.put("await", false); + Map optionsSteps = new HashMap(); + Map optionsStepsOriginal = new HashMap(); + optionsStepsOriginal.put("output_meta", true); + optionsStepsOriginal.put("result", "debug"); + optionsStepsOriginal.put("robot", "/upload/handle"); + optionsSteps.put(":original", optionsStepsOriginal); + options.put("steps", optionsSteps); + Map extraData = new HashMap(); + extraData.put("num_expected_upload_files", String.valueOf(fileCount)); + + return createAssembly(options, extraData); + } + + // + /** * Returns a single assembly. * From 00137321be12d59ea438c95aad46fc1f80493d54 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 04:58:59 +0200 Subject: [PATCH 05/15] Generate waitForAssembly feature --- .../examples/Api2DevdockTusAssembly.java | 6 ++- .../java/com/transloadit/sdk/Transloadit.java | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 049c8e1..02c8dae 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -40,14 +40,18 @@ public static void main(String[] args) throws Exception { JSONObject createResponse = created.json(); String uploadUrl = uploadScenarioBytes(scenario, createResponse); + AssemblyResponse completed = transloadit.waitForAssembly( + createResponse.getString("assembly_ssl_url")); + JSONObject status = completed.json(); JSONObject result = new JSONObject(); result.put("createResponse", createResponse); + result.put("status", status); result.put("uploadUrl", uploadUrl); writeResult(result); System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") - + " uploaded to " + uploadUrl); + + " uploaded to " + uploadUrl + " and finished with " + status.getString("ok")); } private static JSONObject loadScenario() throws Exception { diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 6ae5e30..24d9282 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -400,12 +400,59 @@ public AssemblyResponse getAssembly(String id) throws RequestException, LocalOpe * @throws RequestException if request to transloadit server fails. * @throws LocalOperationException if something goes wrong while running non-http operations. */ + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + public AssemblyResponse getAssemblyByUrl(String url) throws RequestException, LocalOperationException { Request request = new Request(this); return new AssemblyResponse(request.get(url)); } + // + + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Wait for an Assembly to finish uploading and executing. + * The assembly URL should be the assembly_ssl_url returned by createAssembly. + */ + public AssemblyResponse waitForAssembly(String assemblyUrl) + throws RequestException, LocalOperationException { + List responsePollValues = java.util.Arrays.asList( + "ASSEMBLY_UPLOADING", "ASSEMBLY_EXECUTING"); + while (true) { + AssemblyResponse response = getAssemblyByUrl(assemblyUrl); + org.json.JSONObject responseJson = response.json(); + + // Abort polling if the assembly has entered an error state + if (!responseJson.optString("error").isEmpty()) { + return response; + } + + // The polling is done if the assembly is not uploading or executing anymore. + if (!(responsePollValues.contains(responseJson.optString("ok")))) { + return response; + } + + try { + Thread.sleep(1000); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new LocalOperationException(error); + } + } + } + + // + /** * cancels a running assembly. * From 9dd6431b387025eab01486ac282430631c16753e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 05:37:05 +0200 Subject: [PATCH 06/15] Read TUS scenario preparations generically --- .../examples/Api2DevdockTusAssembly.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 02c8dae..0154a57 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -33,7 +33,11 @@ public static void main(String[] args) throws Exception { requiredEnv("TRANSLOADIT_SECRET"), requiredEnv("TRANSLOADIT_ENDPOINT")); - int fileCount = scenario.getJSONObject("createTusAssembly") + int fileCount = featureStep( + scenario, + "preparations", + "createTusAssembly", + "feature-call") .getJSONObject("input") .getInt("file_count"); AssemblyResponse created = transloadit.createTusAssembly(fileCount); @@ -64,6 +68,29 @@ private static JSONObject loadScenario() throws Exception { return new JSONObject(new String(contents, StandardCharsets.UTF_8)); } + private static JSONObject featureStep( + JSONObject scenario, + String collectionName, + String featureId, + String kind) { + JSONArray steps = scenario.getJSONArray(collectionName); + for (int index = 0; index < steps.length(); index += 1) { + JSONObject step = steps.getJSONObject(index); + if (!featureId.equals(step.getString("featureId"))) { + continue; + } + if (!kind.equals(step.getString("kind"))) { + throw new IllegalStateException(collectionName + "[" + index + + "] must have kind " + kind); + } + + return step; + } + + throw new IllegalStateException("Scenario has no " + collectionName + + " step for feature " + featureId); + } + private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isEmpty()) { From 381ef81e18ff7e400467f5370905ca6b24fad493 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 07:24:52 +0200 Subject: [PATCH 07/15] Generate TUS assembly upload helper --- .../examples/Api2DevdockTusAssembly.java | 102 ++++------------- .../java/com/transloadit/sdk/Transloadit.java | 108 ++++++++++++++++++ .../sdk/UploadTusAssemblyResult.java | 24 ++++ 3 files changed, 156 insertions(+), 78 deletions(-) create mode 100644 src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 0154a57..978b47b 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -1,19 +1,15 @@ package com.transloadit.examples; import com.transloadit.sdk.Transloadit; -import com.transloadit.sdk.response.AssemblyResponse; -import io.tus.java.client.TusClient; -import io.tus.java.client.TusUpload; -import io.tus.java.client.TusUploader; +import com.transloadit.sdk.UploadTusAssemblyResult; import org.json.JSONArray; import org.json.JSONObject; -import java.io.ByteArrayInputStream; -import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; /** @@ -40,22 +36,27 @@ public static void main(String[] args) throws Exception { "feature-call") .getJSONObject("input") .getInt("file_count"); - AssemblyResponse created = transloadit.createTusAssembly(fileCount); - JSONObject createResponse = created.json(); - String uploadUrl = uploadScenarioBytes(scenario, createResponse); - AssemblyResponse completed = transloadit.waitForAssembly( - createResponse.getString("assembly_ssl_url")); - JSONObject status = completed.json(); + JSONObject uploadConfig = scenario.getJSONObject("upload"); + JSONObject source = uploadConfig.getJSONObject("source"); + byte[] bytes = source.getString("value").getBytes(StandardCharsets.UTF_8); + UploadTusAssemblyResult uploadResult = transloadit.uploadTusAssembly( + fileCount, + bytes, + uploadConfig.getString("fieldName"), + uploadConfig.getString("fileName"), + uploadUserMeta(uploadConfig)); + JSONObject status = uploadResult.getAssembly().json(); JSONObject result = new JSONObject(); - result.put("createResponse", createResponse); + result.put("createResponse", status); result.put("status", status); - result.put("uploadUrl", uploadUrl); + result.put("uploadUrl", uploadResult.getUploadUrl()); writeResult(result); System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") - + " uploaded to " + uploadUrl + " and finished with " + status.getString("ok")); + + " uploaded to " + uploadResult.getUploadUrl() + + " and finished with " + status.getString("ok")); } private static JSONObject loadScenario() throws Exception { @@ -100,74 +101,19 @@ private static String requiredEnv(String name) { return value; } - private static String uploadScenarioBytes(JSONObject scenario, JSONObject createResponse) throws Exception { - JSONObject uploadConfig = scenario.getJSONObject("upload"); - JSONObject source = uploadConfig.getJSONObject("source"); - byte[] bytes = source.getString("value").getBytes(StandardCharsets.UTF_8); - - TusClient tusClient = new TusClient(); - tusClient.setUploadCreationURL(new URL(createResponse.getString("tus_url"))); - - TusUpload upload = new TusUpload(); - upload.setInputStream(new ByteArrayInputStream(bytes)); - upload.setSize(bytes.length); - upload.setFingerprint("api2-devdock-java-sdk-" + createResponse.getString("assembly_id")); - upload.setMetadata(uploadMetadata(scenario, createResponse)); - - TusUploader uploader = tusClient.createUpload(upload); - uploader.setChunkSize(bytes.length); - while (uploader.uploadChunk() > -1) { - // Upload the single scenario-owned source until tus-java-client reports completion. - } - uploader.finish(false); - - return uploader.getUploadURL().toString(); - } - - private static Map uploadMetadata( - JSONObject scenario, - JSONObject createResponse) { + private static Map uploadUserMeta(JSONObject uploadConfig) { Map metadata = new HashMap(); - JSONArray fields = scenario.getJSONObject("upload").getJSONArray("metadata"); - for (int index = 0; index < fields.length(); index += 1) { - JSONObject field = fields.getJSONObject(index); - metadata.put(field.getString("name"), String.valueOf(resolveScenarioValue( - field.getJSONObject("value"), - scenario, - createResponse))); - } - - return metadata; - } - - private static Object resolveScenarioValue( - JSONObject value, - JSONObject scenario, - JSONObject createResponse) { - if (value.has("value")) { - return value.get("value"); - } - - JSONObject source = value.getJSONObject("source"); - Object current; - if ("scenario".equals(source.getString("root"))) { - current = scenario; - } else if ("createResponse".equals(source.getString("root"))) { - current = createResponse; - } else { - throw new IllegalStateException("Unsupported scenario value root: " + source.getString("root")); + if (!uploadConfig.has("userMeta")) { + return metadata; } - JSONArray path = source.getJSONArray("path"); - for (int index = 0; index < path.length(); index += 1) { - if (!(current instanceof JSONObject)) { - throw new IllegalStateException("Cannot resolve scenario path through non-object value"); - } - - current = ((JSONObject) current).get(path.getString(index)); + JSONObject userMeta = uploadConfig.getJSONObject("userMeta"); + for (Iterator keys = userMeta.keys(); keys.hasNext();) { + String key = keys.next(); + metadata.put(key, String.valueOf(userMeta.get(key))); } - return current; + return metadata; } private static void writeResult(JSONObject result) throws Exception { diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 24d9282..89c84bf 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -371,6 +371,114 @@ public AssemblyResponse createTusAssembly(int fileCount) // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Create a TUS-ready Assembly, upload one file with the TUS protocol, and wait for the Assembly to finish. + */ + public UploadTusAssemblyResult uploadTusAssembly(int fileCount, byte[] content, String fieldname, String filename, Map userMeta) + throws RequestException, LocalOperationException { + AssemblyResponse createdAssembly = createTusAssembly(fileCount); + + java.net.URL endpointUrl; + try { + endpointUrl = new java.net.URL(createdAssembly.getTusUrl()); + } catch (java.net.MalformedURLException error) { + throw new LocalOperationException(error); + } + + Map metadataMap = new HashMap(); + if (userMeta != null) { + for (Map.Entry entry : userMeta.entrySet()) { + metadataMap.put(entry.getKey(), entry.getValue()); + } + } + metadataMap.put("assembly_url", String.valueOf(createdAssembly.getUrl())); + metadataMap.put("fieldname", String.valueOf(fieldname)); + metadataMap.put("filename", String.valueOf(filename)); + + okhttp3.OkHttpClient httpClient = new okhttp3.OkHttpClient(); + + String uploadUrlText; + okhttp3.Request.Builder createRequestBuilder = new okhttp3.Request.Builder() + .url(endpointUrl) + .method("POST", okhttp3.RequestBody.create(null, new byte[0])); + createRequestBuilder.addHeader("Tus-Resumable", "1.0.0"); + createRequestBuilder.addHeader("Upload-Length", String.valueOf(content.length)); + List createMetadataParts = new ArrayList(); + for (Map.Entry entry : metadataMap.entrySet()) { + createMetadataParts.add(entry.getKey() + " " + java.util.Base64.getEncoder().encodeToString(entry.getValue().getBytes(StandardCharsets.UTF_8))); + } + createRequestBuilder.addHeader("Upload-Metadata", String.join(",", createMetadataParts)); + okhttp3.Request createRequest = createRequestBuilder.build(); + + okhttp3.Response createResponse; + try { + createResponse = httpClient.newCall(createRequest).execute(); + } catch (java.io.IOException error) { + throw new RequestException(error); + } + try { + if (createResponse.code() != 201) { + throw new RequestException(String.format("TUS create returned HTTP %d, expected 201", createResponse.code())); + } + String uploadUrlLocation = createResponse.header("Location"); + if (uploadUrlLocation == null || uploadUrlLocation.isEmpty()) { + throw new RequestException("TUS create did not return a Location header"); + } + java.net.URL uploadUrl; + try { + uploadUrl = new java.net.URL(endpointUrl, uploadUrlLocation); + } catch (java.net.MalformedURLException error) { + throw new LocalOperationException(error); + } + uploadUrlText = uploadUrl.toString(); + } finally { + createResponse.close(); + } + + okhttp3.Request.Builder uploadRequestBuilder = new okhttp3.Request.Builder() + .url(uploadUrlText) + .method("PATCH", okhttp3.RequestBody.create(null, content)); + uploadRequestBuilder.addHeader("Tus-Resumable", "1.0.0"); + uploadRequestBuilder.addHeader("Upload-Offset", "0"); + uploadRequestBuilder.addHeader("Content-Type", "application/offset+octet-stream"); + okhttp3.Request uploadRequest = uploadRequestBuilder.build(); + + okhttp3.Response uploadResponse; + try { + uploadResponse = httpClient.newCall(uploadRequest).execute(); + } catch (java.io.IOException error) { + throw new RequestException(error); + } + try { + if (uploadResponse.code() != 204) { + throw new RequestException(String.format("TUS upload returned HTTP %d, expected 204", uploadResponse.code())); + } + int remoteOffset; + try { + remoteOffset = Integer.parseInt(uploadResponse.header("Upload-Offset")); + } catch (NumberFormatException error) { + throw new LocalOperationException(error); + } + if (remoteOffset != content.length) { + throw new RequestException(String.format("TUS upload offset %d, expected %d", remoteOffset, content.length)); + } + } finally { + uploadResponse.close(); + } + + AssemblyResponse completedAssembly = waitForAssembly(createdAssembly.getSslUrl()); + + return new UploadTusAssemblyResult(completedAssembly, uploadUrlText); + } + + // + /** * Returns a single assembly. * diff --git a/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java b/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java new file mode 100644 index 0000000..78d522b --- /dev/null +++ b/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java @@ -0,0 +1,24 @@ +package com.transloadit.sdk; + +import com.transloadit.sdk.response.AssemblyResponse; + +/** + * Result returned after uploading one file through a TUS Assembly. + */ +public class UploadTusAssemblyResult { + private final AssemblyResponse assembly; + private final String uploadUrl; + + public UploadTusAssemblyResult(AssemblyResponse assembly, String uploadUrl) { + this.assembly = assembly; + this.uploadUrl = uploadUrl; + } + + public AssemblyResponse getAssembly() { + return assembly; + } + + public String getUploadUrl() { + return uploadUrl; + } +} From 8df12dbd3694506efc7264f012acf03d9d8b9588 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 01:01:35 +0200 Subject: [PATCH 08/15] Use header-derived TUS offset variable --- src/main/java/com/transloadit/sdk/Transloadit.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 89c84bf..ecb6e8a 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -459,14 +459,14 @@ public UploadTusAssemblyResult uploadTusAssembly(int fileCount, byte[] content, if (uploadResponse.code() != 204) { throw new RequestException(String.format("TUS upload returned HTTP %d, expected 204", uploadResponse.code())); } - int remoteOffset; + int uploadOffset; try { - remoteOffset = Integer.parseInt(uploadResponse.header("Upload-Offset")); + uploadOffset = Integer.parseInt(uploadResponse.header("Upload-Offset")); } catch (NumberFormatException error) { throw new LocalOperationException(error); } - if (remoteOffset != content.length) { - throw new RequestException(String.format("TUS upload offset %d, expected %d", remoteOffset, content.length)); + if (uploadOffset != content.length) { + throw new RequestException(String.format("TUS upload offset %d, expected %d", uploadOffset, content.length)); } } finally { uploadResponse.close(); From df3c8e38b4c49ff27b03548a5a3f352322c7d1f3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 05:05:48 +0200 Subject: [PATCH 09/15] Read TUS example input from SDK feature call --- .../examples/Api2DevdockTusAssembly.java | 49 +++++++------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 978b47b..189a907 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -29,22 +29,16 @@ public static void main(String[] args) throws Exception { requiredEnv("TRANSLOADIT_SECRET"), requiredEnv("TRANSLOADIT_ENDPOINT")); - int fileCount = featureStep( - scenario, - "preparations", - "createTusAssembly", - "feature-call") - .getJSONObject("input") - .getInt("file_count"); - - JSONObject uploadConfig = scenario.getJSONObject("upload"); - JSONObject source = uploadConfig.getJSONObject("source"); - byte[] bytes = source.getString("value").getBytes(StandardCharsets.UTF_8); + JSONObject input = sdkFeatureCall(scenario, "uploadTusAssembly").getJSONObject("input"); + int fileCount = input.getInt("file_count"); + + JSONObject uploadConfig = input.getJSONObject("upload"); + byte[] bytes = uploadConfig.getString("content").getBytes(StandardCharsets.UTF_8); UploadTusAssemblyResult uploadResult = transloadit.uploadTusAssembly( fileCount, bytes, - uploadConfig.getString("fieldName"), - uploadConfig.getString("fileName"), + uploadConfig.getString("fieldname"), + uploadConfig.getString("filename"), uploadUserMeta(uploadConfig)); JSONObject status = uploadResult.getAssembly().json(); @@ -69,27 +63,22 @@ private static JSONObject loadScenario() throws Exception { return new JSONObject(new String(contents, StandardCharsets.UTF_8)); } - private static JSONObject featureStep( - JSONObject scenario, - String collectionName, - String featureId, - String kind) { - JSONArray steps = scenario.getJSONArray(collectionName); - for (int index = 0; index < steps.length(); index += 1) { - JSONObject step = steps.getJSONObject(index); - if (!featureId.equals(step.getString("featureId"))) { + private static JSONObject sdkFeatureCall(JSONObject scenario, String featureId) { + JSONArray featureCalls = scenario.getJSONArray("sdkFeatureCalls"); + for (int index = 0; index < featureCalls.length(); index += 1) { + JSONObject featureCall = featureCalls.getJSONObject(index); + if (!featureId.equals(featureCall.getString("featureId"))) { continue; } - if (!kind.equals(step.getString("kind"))) { - throw new IllegalStateException(collectionName + "[" + index - + "] must have kind " + kind); + if (!"sdk-feature-call".equals(featureCall.getString("kind"))) { + throw new IllegalStateException("sdkFeatureCalls[" + index + + "] must have kind sdk-feature-call"); } - return step; + return featureCall; } - throw new IllegalStateException("Scenario has no " + collectionName - + " step for feature " + featureId); + throw new IllegalStateException("Scenario has no SDK feature call for feature " + featureId); } private static String requiredEnv(String name) { @@ -103,11 +92,11 @@ private static String requiredEnv(String name) { private static Map uploadUserMeta(JSONObject uploadConfig) { Map metadata = new HashMap(); - if (!uploadConfig.has("userMeta")) { + if (!uploadConfig.has("user_meta")) { return metadata; } - JSONObject userMeta = uploadConfig.getJSONObject("userMeta"); + JSONObject userMeta = uploadConfig.getJSONObject("user_meta"); for (Iterator keys = userMeta.keys(); keys.hasNext();) { String key = keys.next(); metadata.put(key, String.valueOf(userMeta.get(key))); From 174fcacdf33501c8638c0d7f61974c39a454b12c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 21:01:01 +0200 Subject: [PATCH 10/15] Read SDK example input projection --- .../examples/Api2DevdockTusAssembly.java | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java index 189a907..53ba376 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusAssembly.java @@ -2,7 +2,6 @@ import com.transloadit.sdk.Transloadit; import com.transloadit.sdk.UploadTusAssemblyResult; -import org.json.JSONArray; import org.json.JSONObject; import java.nio.charset.StandardCharsets; @@ -29,7 +28,10 @@ public static void main(String[] args) throws Exception { requiredEnv("TRANSLOADIT_SECRET"), requiredEnv("TRANSLOADIT_ENDPOINT")); - JSONObject input = sdkFeatureCall(scenario, "uploadTusAssembly").getJSONObject("input"); + JSONObject exampleInput = scenario.getJSONObject("exampleInput"); + JSONObject input = exampleInput + .getJSONObject("sdkFeatureInputs") + .getJSONObject("uploadTusAssembly"); int fileCount = input.getInt("file_count"); JSONObject uploadConfig = input.getJSONObject("upload"); @@ -48,7 +50,7 @@ public static void main(String[] args) throws Exception { result.put("uploadUrl", uploadResult.getUploadUrl()); writeResult(result); - System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") + System.out.println("Java SDK devdock scenario " + exampleInput.getString("scenarioId") + " uploaded to " + uploadResult.getUploadUrl() + " and finished with " + status.getString("ok")); } @@ -63,24 +65,6 @@ private static JSONObject loadScenario() throws Exception { return new JSONObject(new String(contents, StandardCharsets.UTF_8)); } - private static JSONObject sdkFeatureCall(JSONObject scenario, String featureId) { - JSONArray featureCalls = scenario.getJSONArray("sdkFeatureCalls"); - for (int index = 0; index < featureCalls.length(); index += 1) { - JSONObject featureCall = featureCalls.getJSONObject(index); - if (!featureId.equals(featureCall.getString("featureId"))) { - continue; - } - if (!"sdk-feature-call".equals(featureCall.getString("kind"))) { - throw new IllegalStateException("sdkFeatureCalls[" + index - + "] must have kind sdk-feature-call"); - } - - return featureCall; - } - - throw new IllegalStateException("Scenario has no SDK feature call for feature " + featureId); - } - private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isEmpty()) { From 355533fc3dff93c693b87e90f1c5d0757cbf60db Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 17:07:48 +0200 Subject: [PATCH 11/15] Regenerate contract-owned TUS Assembly surfaces --- src/main/java/com/transloadit/sdk/Transloadit.java | 9 ++++----- .../com/transloadit/sdk/UploadTusAssemblyResult.java | 4 ++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index ecb6e8a..87793b1 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -349,8 +349,7 @@ public AssemblyResponse createAssembly(Map options, Map userMeta) throws RequestException, LocalOperationException { @@ -529,8 +528,8 @@ public AssemblyResponse getAssemblyByUrl(String url) // belongs in the contract generator so all SDKs stay in sync. /** - * Wait for an Assembly to finish uploading and executing. - * The assembly URL should be the assembly_ssl_url returned by createAssembly. + * Waits for an Assembly to finish uploading and executing. + * Use the returned assembly_ssl_url as the assembly URL. */ public AssemblyResponse waitForAssembly(String assemblyUrl) throws RequestException, LocalOperationException { diff --git a/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java b/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java index 78d522b..36e8781 100644 --- a/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java +++ b/src/main/java/com/transloadit/sdk/UploadTusAssemblyResult.java @@ -2,6 +2,10 @@ import com.transloadit.sdk.response.AssemblyResponse; +// This file is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + /** * Result returned after uploading one file through a TUS Assembly. */ From 559231c88e63f45a15ea3744f82d987abd6d8361 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 18:29:58 +0200 Subject: [PATCH 12/15] Regenerate required feature value guards --- src/main/java/com/transloadit/sdk/Transloadit.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 87793b1..a380196 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -471,7 +471,11 @@ public UploadTusAssemblyResult uploadTusAssembly(int fileCount, byte[] content, uploadResponse.close(); } - AssemblyResponse completedAssembly = waitForAssembly(createdAssembly.getSslUrl()); + String createdAssemblyAssemblySslUrl = createdAssembly.getSslUrl(); + if (createdAssemblyAssemblySslUrl == null || createdAssemblyAssemblySslUrl.isEmpty()) { + throw new LocalOperationException("uploadTusAssembly needs createdAssembly.assembly_ssl_url"); + } + AssemblyResponse completedAssembly = waitForAssembly(createdAssemblyAssemblySslUrl); return new UploadTusAssemblyResult(completedAssembly, uploadUrlText); } From 60989a96c84eff996506556dd8864e4626a7185a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 04:31:56 +0200 Subject: [PATCH 13/15] Add assembly lifecycle devdock example --- examples/build.gradle | 5 + .../Api2DevdockAssemblyLifecycle.java | 117 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java diff --git a/examples/build.gradle b/examples/build.gradle index 5c31b8f..6fc62cd 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -45,6 +45,11 @@ tasks.register('api2DevdockTemplateLifecycle', JavaExec) { mainClass = 'com.transloadit.examples.Api2DevdockTemplateLifecycle' } +tasks.register('api2DevdockAssemblyLifecycle', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.transloadit.examples.Api2DevdockAssemblyLifecycle' +} + tasks.register('api2DevdockTusAssembly', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'com.transloadit.examples.Api2DevdockTusAssembly' diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java new file mode 100644 index 0000000..68efc2e --- /dev/null +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java @@ -0,0 +1,117 @@ +package com.transloadit.examples; + +import com.transloadit.sdk.Transloadit; +import com.transloadit.sdk.response.AssemblyResponse; +import com.transloadit.sdk.response.ListResponse; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * Runs API2's contract-owned Assembly lifecycle scenario against devdock. + */ +public final class Api2DevdockAssemblyLifecycle { + /** + * Runs the Assembly lifecycle scenario. + * + * @param args ignored + * @throws Exception if the scenario cannot be completed + */ + public static void main(String[] args) throws Exception { + JSONObject scenario = loadScenario(); + Transloadit transloadit = new Transloadit( + requiredEnv("TRANSLOADIT_KEY"), + requiredEnv("TRANSLOADIT_SECRET"), + requiredEnv("TRANSLOADIT_ENDPOINT")); + + AssemblyResponse created = transloadit.createTusAssembly( + scenario.getJSONObject("assembly").getInt("fileCount")); + String createdAssemblySslUrl = created.getSslUrl(); + boolean cancelAssembly = true; + + try { + AssemblyResponse fetched = transloadit.getAssemblyByUrl(createdAssemblySslUrl); + + Map listOptions = new HashMap(); + listOptions.put("assembly_id", created.getId()); + listOptions.put("pagesize", scenario.getJSONObject("list").getInt("pageSize")); + ListResponse listed = transloadit.listAssemblies(listOptions); + + AssemblyResponse cancelled = transloadit.cancelAssembly(createdAssemblySslUrl); + cancelAssembly = false; + + JSONObject result = new JSONObject(); + result.put("cancelled", assemblyResult(cancelled)); + result.put("created", assemblyResult(created)); + result.put("fetched", assemblyResult(fetched)); + result.put("listContainsCreated", listContainsAssembly(listed.getItems(), created.getId())); + result.put("listCount", listed.size()); + writeResult(result); + } finally { + if (cancelAssembly) { + transloadit.cancelAssembly(createdAssemblySslUrl); + } + } + + System.out.println("Java SDK devdock scenario " + scenario.getString("scenarioId") + + " canceled Assembly " + created.getId()); + } + + private static JSONObject assemblyResult(AssemblyResponse response) { + JSONObject result = new JSONObject(); + result.put("assemblyId", response.getId()); + result.put("assemblySslUrl", response.getSslUrl()); + result.put("assemblyUrl", response.getUrl()); + result.put("ok", response.json().optString("ok", "")); + return result; + } + + private static boolean listContainsAssembly(JSONArray items, String assemblyId) { + for (int index = 0; index < items.length(); index += 1) { + if (assemblyId.equals(items.getJSONObject(index).optString("id"))) { + return true; + } + } + + return false; + } + + private static JSONObject loadScenario() throws Exception { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "examples/api2-devdock-assembly-lifecycle/api2-scenario.json"; + } + + byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException(name + " must be set"); + } + + return value; + } + + private static void writeResult(JSONObject result) throws Exception { + String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private Api2DevdockAssemblyLifecycle() { + throw new IllegalStateException("Utility class"); + } +} From 7ac30736575c47bef4a3ff9e927550616f60207e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:47:55 +0200 Subject: [PATCH 14/15] Use SSL Assembly URL for TUS metadata Co-Authored-By: Claude Fable 5 --- src/main/java/com/transloadit/sdk/Transloadit.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index a380196..0bf0a79 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -396,7 +396,7 @@ public UploadTusAssemblyResult uploadTusAssembly(int fileCount, byte[] content, metadataMap.put(entry.getKey(), entry.getValue()); } } - metadataMap.put("assembly_url", String.valueOf(createdAssembly.getUrl())); + metadataMap.put("assembly_url", String.valueOf(createdAssembly.getSslUrl())); metadataMap.put("fieldname", String.valueOf(fieldname)); metadataMap.put("filename", String.valueOf(filename)); From 701595334954d8a9e583a23449607c8c12033e65 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 09:27:39 +0200 Subject: [PATCH 15/15] Prove TUS resume upload via generated SDK method resumeTusUpload() is generated from the API2 resumeUpload TUS protocol contract: it discovers the server offset with a HEAD request, PATCHes the remaining bytes from that offset, asserts the final offset matches the content length, and waits for the Assembly to finish. The new api2-devdock-tus-resume-upload example interrupts an upload after the first chunk like a dropped connection would, then resumes it through the public SDK method. The lifecycle example now polls the Assembly list briefly because the API acknowledges creation before the list storage row lands. Co-Authored-By: Claude Fable 5 --- examples/build.gradle | 5 + .../Api2DevdockAssemblyLifecycle.java | 9 + .../examples/Api2DevdockTusResumeUpload.java | 234 ++++++++++++++++++ .../java/com/transloadit/sdk/Transloadit.java | 88 +++++++ 4 files changed, 336 insertions(+) create mode 100644 examples/src/main/java/com/transloadit/examples/Api2DevdockTusResumeUpload.java diff --git a/examples/build.gradle b/examples/build.gradle index 6fc62cd..6435459 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -54,3 +54,8 @@ tasks.register('api2DevdockTusAssembly', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = 'com.transloadit.examples.Api2DevdockTusAssembly' } + +tasks.register('api2DevdockTusResumeUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.transloadit.examples.Api2DevdockTusResumeUpload' +} diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java index 68efc2e..0827f7f 100644 --- a/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java @@ -40,7 +40,16 @@ public static void main(String[] args) throws Exception { Map listOptions = new HashMap(); listOptions.put("assembly_id", created.getId()); listOptions.put("pagesize", scenario.getJSONObject("list").getInt("pageSize")); + // The Assembly list is eventually consistent: the API acknowledges creation before + // the list storage row lands, so poll briefly until the created Assembly shows up. ListResponse listed = transloadit.listAssemblies(listOptions); + for (int attempt = 0; attempt < 20; attempt += 1) { + if (listContainsAssembly(listed.getItems(), created.getId())) { + break; + } + Thread.sleep(500); + listed = transloadit.listAssemblies(listOptions); + } AssemblyResponse cancelled = transloadit.cancelAssembly(createdAssemblySslUrl); cancelAssembly = false; diff --git a/examples/src/main/java/com/transloadit/examples/Api2DevdockTusResumeUpload.java b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusResumeUpload.java new file mode 100644 index 0000000..5a46643 --- /dev/null +++ b/examples/src/main/java/com/transloadit/examples/Api2DevdockTusResumeUpload.java @@ -0,0 +1,234 @@ +package com.transloadit.examples; + +import com.transloadit.sdk.Transloadit; +import com.transloadit.sdk.response.AssemblyResponse; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Runs API2's contract-owned TUS resume scenario against devdock. + * + *

This example is intentionally checked into the SDK repository: it reads the + * API/TUS facts from API2's injected scenario JSON, interrupts an upload like an + * unlucky user would, and resumes it through the public SDK method.

+ */ +public final class Api2DevdockTusResumeUpload { + /** + * Runs the TUS resume scenario. + * + * @param args ignored + * @throws Exception if the scenario cannot be completed + */ + public static void main(String[] args) throws Exception { + JSONObject scenario = loadScenario(); + Transloadit transloadit = new Transloadit( + requiredEnv("TRANSLOADIT_KEY"), + requiredEnv("TRANSLOADIT_SECRET"), + requiredEnv("TRANSLOADIT_ENDPOINT")); + + JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse"); + JSONObject upload = scenario.getJSONObject("upload"); + JSONObject resume = upload.getJSONObject("resume"); + + Map context = new HashMap(); + context.put("createResponse", createResponse); + context.put("scenario", scenario); + + byte[] content = scenarioBytes(upload); + String tusUrl = String.valueOf(resolveValue(upload.get("tusUrl"), context, "upload.tusUrl")); + Map metadata = uploadMetadata(upload, context); + + String firstUploadUrl = createInterruptedUpload( + tusUrl, + content, + metadata, + resume.getInt("stopAfterAcceptedBytes")); + + // Remember the interrupted upload by fingerprint, like a TUS client URL storage would. + Map storedUploads = new HashMap(); + storedUploads.put(resume.getString("fingerprint"), firstUploadUrl); + int previousUploadCount = storedUploads.size(); + + AssemblyResponse completedAssembly = transloadit.resumeTusUpload( + storedUploads.get(resume.getString("fingerprint")), + content, + createResponse.getString("assembly_ssl_url")); + JSONObject status = completedAssembly.json(); + if (status.has("error") && !status.isNull("error")) { + throw new IllegalStateException("resumeTusUpload returned " + status.getString("error") + + ": " + status.optString("message")); + } + + if (resume.getBoolean("removeFingerprintOnSuccess")) { + storedUploads.remove(resume.getString("fingerprint")); + } + int remainingPreviousUploadCount = storedUploads.size(); + + JSONObject result = new JSONObject(); + result.put("firstUploadUrl", firstUploadUrl); + result.put("previousUploadCount", previousUploadCount); + result.put("remainingPreviousUploadCount", remainingPreviousUploadCount); + result.put("uploadUrl", firstUploadUrl); + writeResult(result); + + System.out.println("Java SDK devdock scenario " + + scenario.getJSONObject("exampleInput").getString("scenarioId") + + " resumed " + firstUploadUrl); + } + + private static JSONObject loadScenario() throws Exception { + String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath == null || scenarioPath.isEmpty()) { + scenarioPath = "examples/api2-devdock-tus-resume-upload/api2-scenario.json"; + } + + byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException(name + " must be set"); + } + + return value; + } + + private static Object resolveValue(Object valueSpec, Map context, String label) { + if (!(valueSpec instanceof JSONObject)) { + throw new IllegalStateException(label + " value spec must be an object"); + } + + JSONObject spec = (JSONObject) valueSpec; + if (spec.has("value")) { + return spec.get("value"); + } + + JSONObject source = spec.getJSONObject("source"); + Object current = context.get(source.getString("root")); + if (current == null) { + throw new IllegalStateException(label + " value source root is unavailable"); + } + JSONArray pathParts = source.getJSONArray("path"); + for (int index = 0; index < pathParts.length(); index += 1) { + String part = pathParts.getString(index); + if (!(current instanceof JSONObject) || !((JSONObject) current).has(part)) { + throw new IllegalStateException(label + " value source cannot read " + part); + } + current = ((JSONObject) current).get(part); + } + + return current; + } + + private static byte[] scenarioBytes(JSONObject upload) { + JSONObject source = upload.getJSONObject("source"); + if (!"bytes".equals(source.getString("kind"))) { + throw new IllegalStateException("upload.source.kind must be bytes"); + } + if (!"utf8".equals(source.getString("encoding"))) { + throw new IllegalStateException("upload.source.encoding must be utf8"); + } + + return source.getString("value").getBytes(StandardCharsets.UTF_8); + } + + private static Map uploadMetadata(JSONObject upload, Map context) { + Map metadata = new LinkedHashMap(); + JSONArray fields = upload.getJSONArray("metadata"); + for (int index = 0; index < fields.length(); index += 1) { + JSONObject field = fields.getJSONObject(index); + String name = field.getString("name"); + metadata.put(name, String.valueOf(resolveValue(field.get("value"), context, name))); + } + + return metadata; + } + + /** + * Creates a TUS upload and only sends the first chunk, leaving the upload + * interrupted the way a dropped connection would. + */ + private static String createInterruptedUpload( + String tusUrl, + byte[] content, + Map metadata, + int stopAfterAcceptedBytes) throws Exception { + List metadataParts = new ArrayList(); + for (Map.Entry entry : metadata.entrySet()) { + metadataParts.add(entry.getKey() + " " + + Base64.getEncoder().encodeToString(entry.getValue().getBytes(StandardCharsets.UTF_8))); + } + + URL createUrl = new URL(tusUrl); + HttpURLConnection createConnection = (HttpURLConnection) createUrl.openConnection(); + createConnection.setRequestMethod("POST"); + createConnection.setRequestProperty("Tus-Resumable", "1.0.0"); + createConnection.setRequestProperty("Upload-Length", String.valueOf(content.length)); + createConnection.setRequestProperty("Upload-Metadata", String.join(",", metadataParts)); + createConnection.setDoOutput(true); + createConnection.getOutputStream().close(); + if (createConnection.getResponseCode() != 201) { + throw new IllegalStateException("TUS create returned HTTP " + + createConnection.getResponseCode() + ", expected 201"); + } + String location = createConnection.getHeaderField("Location"); + createConnection.disconnect(); + if (location == null || location.isEmpty()) { + throw new IllegalStateException("TUS create did not return a Location header"); + } + String uploadUrl = new URL(createUrl, location).toString(); + + HttpURLConnection patchConnection = (HttpURLConnection) new URL(uploadUrl).openConnection(); + // HttpURLConnection rejects PATCH, so use the same POST + override header TUS supports. + patchConnection.setRequestMethod("POST"); + patchConnection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + patchConnection.setRequestProperty("Tus-Resumable", "1.0.0"); + patchConnection.setRequestProperty("Upload-Offset", "0"); + patchConnection.setRequestProperty("Content-Type", "application/offset+octet-stream"); + patchConnection.setDoOutput(true); + patchConnection.getOutputStream().write(content, 0, stopAfterAcceptedBytes); + patchConnection.getOutputStream().close(); + if (patchConnection.getResponseCode() != 204) { + throw new IllegalStateException("TUS first chunk returned HTTP " + + patchConnection.getResponseCode() + ", expected 204"); + } + String acceptedBytesHeader = patchConnection.getHeaderField("Upload-Offset"); + patchConnection.disconnect(); + int acceptedBytes = acceptedBytesHeader == null ? -1 : Integer.parseInt(acceptedBytesHeader); + if (acceptedBytes != stopAfterAcceptedBytes) { + throw new IllegalStateException("TUS first chunk accepted " + acceptedBytes + + " bytes, expected " + stopAfterAcceptedBytes); + } + + return uploadUrl; + } + + private static void writeResult(JSONObject result) throws Exception { + String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private Api2DevdockTusResumeUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index 0bf0a79..a999e5e 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -370,6 +370,94 @@ public AssemblyResponse createTusAssembly(int fileCount) // + // + + // This block is generated from Transloadit API2 contracts. If it looks wrong, + // please report the issue instead of editing this block by hand; the source fix + // belongs in the contract generator so all SDKs stay in sync. + + /** + * Resumes an interrupted TUS upload from the server-reported offset and waits for the Assembly to finish. + */ + public AssemblyResponse resumeTusUpload(String uploadUrl, byte[] content, String assemblySslUrl) + throws RequestException, LocalOperationException { + java.net.URL storedUploadUrl; + try { + storedUploadUrl = new java.net.URL(uploadUrl); + } catch (java.net.MalformedURLException error) { + throw new LocalOperationException(error); + } + + okhttp3.OkHttpClient httpClient = new okhttp3.OkHttpClient(); + + int resumeOffset; + okhttp3.Request.Builder offsetRequestBuilder = new okhttp3.Request.Builder() + .url(storedUploadUrl) + .method("HEAD", null); + offsetRequestBuilder.addHeader("Tus-Resumable", "1.0.0"); + okhttp3.Request offsetRequest = offsetRequestBuilder.build(); + + okhttp3.Response offsetResponse; + try { + offsetResponse = httpClient.newCall(offsetRequest).execute(); + } catch (java.io.IOException error) { + throw new RequestException(error); + } + try { + if (offsetResponse.code() != 200) { + throw new RequestException(String.format("TUS offset returned HTTP %d, expected 200", offsetResponse.code())); + } + String resumeOffsetHeader = offsetResponse.header("Upload-Offset"); + if (resumeOffsetHeader == null || resumeOffsetHeader.isEmpty()) { + throw new RequestException("TUS offset did not return a Upload-Offset header"); + } + try { + resumeOffset = Integer.parseInt(resumeOffsetHeader); + } catch (NumberFormatException error) { + throw new RequestException("TUS offset returned an invalid Upload-Offset header"); + } + } finally { + offsetResponse.close(); + } + + okhttp3.Request.Builder uploadRequestBuilder = new okhttp3.Request.Builder() + .url(storedUploadUrl) + .method("PATCH", okhttp3.RequestBody.create(null, java.util.Arrays.copyOfRange(content, resumeOffset, content.length))); + uploadRequestBuilder.addHeader("Tus-Resumable", "1.0.0"); + uploadRequestBuilder.addHeader("Upload-Offset", String.valueOf(resumeOffset)); + uploadRequestBuilder.addHeader("Content-Type", "application/offset+octet-stream"); + okhttp3.Request uploadRequest = uploadRequestBuilder.build(); + + okhttp3.Response uploadResponse; + try { + uploadResponse = httpClient.newCall(uploadRequest).execute(); + } catch (java.io.IOException error) { + throw new RequestException(error); + } + try { + if (uploadResponse.code() != 204) { + throw new RequestException(String.format("TUS upload returned HTTP %d, expected 204", uploadResponse.code())); + } + int uploadOffset; + try { + uploadOffset = Integer.parseInt(uploadResponse.header("Upload-Offset")); + } catch (NumberFormatException error) { + throw new LocalOperationException(error); + } + if (uploadOffset != content.length) { + throw new RequestException(String.format("TUS upload offset %d, expected %d", uploadOffset, content.length)); + } + } finally { + uploadResponse.close(); + } + + AssemblyResponse completedAssembly = waitForAssembly(assemblySslUrl); + + return completedAssembly; + } + + // + // // This block is generated from Transloadit API2 contracts. If it looks wrong,