Skip to content

Commit 7015953

Browse files
kvzclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 7ac3073 commit 7015953

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

examples/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,8 @@ tasks.register('api2DevdockTusAssembly', JavaExec) {
5454
classpath = sourceSets.main.runtimeClasspath
5555
mainClass = 'com.transloadit.examples.Api2DevdockTusAssembly'
5656
}
57+
58+
tasks.register('api2DevdockTusResumeUpload', JavaExec) {
59+
classpath = sourceSets.main.runtimeClasspath
60+
mainClass = 'com.transloadit.examples.Api2DevdockTusResumeUpload'
61+
}

examples/src/main/java/com/transloadit/examples/Api2DevdockAssemblyLifecycle.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ public static void main(String[] args) throws Exception {
4040
Map<String, Object> listOptions = new HashMap<String, Object>();
4141
listOptions.put("assembly_id", created.getId());
4242
listOptions.put("pagesize", scenario.getJSONObject("list").getInt("pageSize"));
43+
// The Assembly list is eventually consistent: the API acknowledges creation before
44+
// the list storage row lands, so poll briefly until the created Assembly shows up.
4345
ListResponse listed = transloadit.listAssemblies(listOptions);
46+
for (int attempt = 0; attempt < 20; attempt += 1) {
47+
if (listContainsAssembly(listed.getItems(), created.getId())) {
48+
break;
49+
}
50+
Thread.sleep(500);
51+
listed = transloadit.listAssemblies(listOptions);
52+
}
4453

4554
AssemblyResponse cancelled = transloadit.cancelAssembly(createdAssemblySslUrl);
4655
cancelAssembly = false;
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package com.transloadit.examples;
2+
3+
import com.transloadit.sdk.Transloadit;
4+
import com.transloadit.sdk.response.AssemblyResponse;
5+
import org.json.JSONArray;
6+
import org.json.JSONObject;
7+
8+
import java.net.HttpURLConnection;
9+
import java.net.URL;
10+
import java.nio.charset.StandardCharsets;
11+
import java.nio.file.Files;
12+
import java.nio.file.Paths;
13+
import java.util.ArrayList;
14+
import java.util.Base64;
15+
import java.util.HashMap;
16+
import java.util.LinkedHashMap;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
/**
21+
* Runs API2's contract-owned TUS resume scenario against devdock.
22+
*
23+
* <p>This example is intentionally checked into the SDK repository: it reads the
24+
* API/TUS facts from API2's injected scenario JSON, interrupts an upload like an
25+
* unlucky user would, and resumes it through the public SDK method.</p>
26+
*/
27+
public final class Api2DevdockTusResumeUpload {
28+
/**
29+
* Runs the TUS resume scenario.
30+
*
31+
* @param args ignored
32+
* @throws Exception if the scenario cannot be completed
33+
*/
34+
public static void main(String[] args) throws Exception {
35+
JSONObject scenario = loadScenario();
36+
Transloadit transloadit = new Transloadit(
37+
requiredEnv("TRANSLOADIT_KEY"),
38+
requiredEnv("TRANSLOADIT_SECRET"),
39+
requiredEnv("TRANSLOADIT_ENDPOINT"));
40+
41+
JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse");
42+
JSONObject upload = scenario.getJSONObject("upload");
43+
JSONObject resume = upload.getJSONObject("resume");
44+
45+
Map<String, Object> context = new HashMap<String, Object>();
46+
context.put("createResponse", createResponse);
47+
context.put("scenario", scenario);
48+
49+
byte[] content = scenarioBytes(upload);
50+
String tusUrl = String.valueOf(resolveValue(upload.get("tusUrl"), context, "upload.tusUrl"));
51+
Map<String, String> metadata = uploadMetadata(upload, context);
52+
53+
String firstUploadUrl = createInterruptedUpload(
54+
tusUrl,
55+
content,
56+
metadata,
57+
resume.getInt("stopAfterAcceptedBytes"));
58+
59+
// Remember the interrupted upload by fingerprint, like a TUS client URL storage would.
60+
Map<String, String> storedUploads = new HashMap<String, String>();
61+
storedUploads.put(resume.getString("fingerprint"), firstUploadUrl);
62+
int previousUploadCount = storedUploads.size();
63+
64+
AssemblyResponse completedAssembly = transloadit.resumeTusUpload(
65+
storedUploads.get(resume.getString("fingerprint")),
66+
content,
67+
createResponse.getString("assembly_ssl_url"));
68+
JSONObject status = completedAssembly.json();
69+
if (status.has("error") && !status.isNull("error")) {
70+
throw new IllegalStateException("resumeTusUpload returned " + status.getString("error")
71+
+ ": " + status.optString("message"));
72+
}
73+
74+
if (resume.getBoolean("removeFingerprintOnSuccess")) {
75+
storedUploads.remove(resume.getString("fingerprint"));
76+
}
77+
int remainingPreviousUploadCount = storedUploads.size();
78+
79+
JSONObject result = new JSONObject();
80+
result.put("firstUploadUrl", firstUploadUrl);
81+
result.put("previousUploadCount", previousUploadCount);
82+
result.put("remainingPreviousUploadCount", remainingPreviousUploadCount);
83+
result.put("uploadUrl", firstUploadUrl);
84+
writeResult(result);
85+
86+
System.out.println("Java SDK devdock scenario "
87+
+ scenario.getJSONObject("exampleInput").getString("scenarioId")
88+
+ " resumed " + firstUploadUrl);
89+
}
90+
91+
private static JSONObject loadScenario() throws Exception {
92+
String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO");
93+
if (scenarioPath == null || scenarioPath.isEmpty()) {
94+
scenarioPath = "examples/api2-devdock-tus-resume-upload/api2-scenario.json";
95+
}
96+
97+
byte[] contents = Files.readAllBytes(Paths.get(scenarioPath));
98+
return new JSONObject(new String(contents, StandardCharsets.UTF_8));
99+
}
100+
101+
private static String requiredEnv(String name) {
102+
String value = System.getenv(name);
103+
if (value == null || value.isEmpty()) {
104+
throw new IllegalStateException(name + " must be set");
105+
}
106+
107+
return value;
108+
}
109+
110+
private static Object resolveValue(Object valueSpec, Map<String, Object> context, String label) {
111+
if (!(valueSpec instanceof JSONObject)) {
112+
throw new IllegalStateException(label + " value spec must be an object");
113+
}
114+
115+
JSONObject spec = (JSONObject) valueSpec;
116+
if (spec.has("value")) {
117+
return spec.get("value");
118+
}
119+
120+
JSONObject source = spec.getJSONObject("source");
121+
Object current = context.get(source.getString("root"));
122+
if (current == null) {
123+
throw new IllegalStateException(label + " value source root is unavailable");
124+
}
125+
JSONArray pathParts = source.getJSONArray("path");
126+
for (int index = 0; index < pathParts.length(); index += 1) {
127+
String part = pathParts.getString(index);
128+
if (!(current instanceof JSONObject) || !((JSONObject) current).has(part)) {
129+
throw new IllegalStateException(label + " value source cannot read " + part);
130+
}
131+
current = ((JSONObject) current).get(part);
132+
}
133+
134+
return current;
135+
}
136+
137+
private static byte[] scenarioBytes(JSONObject upload) {
138+
JSONObject source = upload.getJSONObject("source");
139+
if (!"bytes".equals(source.getString("kind"))) {
140+
throw new IllegalStateException("upload.source.kind must be bytes");
141+
}
142+
if (!"utf8".equals(source.getString("encoding"))) {
143+
throw new IllegalStateException("upload.source.encoding must be utf8");
144+
}
145+
146+
return source.getString("value").getBytes(StandardCharsets.UTF_8);
147+
}
148+
149+
private static Map<String, String> uploadMetadata(JSONObject upload, Map<String, Object> context) {
150+
Map<String, String> metadata = new LinkedHashMap<String, String>();
151+
JSONArray fields = upload.getJSONArray("metadata");
152+
for (int index = 0; index < fields.length(); index += 1) {
153+
JSONObject field = fields.getJSONObject(index);
154+
String name = field.getString("name");
155+
metadata.put(name, String.valueOf(resolveValue(field.get("value"), context, name)));
156+
}
157+
158+
return metadata;
159+
}
160+
161+
/**
162+
* Creates a TUS upload and only sends the first chunk, leaving the upload
163+
* interrupted the way a dropped connection would.
164+
*/
165+
private static String createInterruptedUpload(
166+
String tusUrl,
167+
byte[] content,
168+
Map<String, String> metadata,
169+
int stopAfterAcceptedBytes) throws Exception {
170+
List<String> metadataParts = new ArrayList<String>();
171+
for (Map.Entry<String, String> entry : metadata.entrySet()) {
172+
metadataParts.add(entry.getKey() + " "
173+
+ Base64.getEncoder().encodeToString(entry.getValue().getBytes(StandardCharsets.UTF_8)));
174+
}
175+
176+
URL createUrl = new URL(tusUrl);
177+
HttpURLConnection createConnection = (HttpURLConnection) createUrl.openConnection();
178+
createConnection.setRequestMethod("POST");
179+
createConnection.setRequestProperty("Tus-Resumable", "1.0.0");
180+
createConnection.setRequestProperty("Upload-Length", String.valueOf(content.length));
181+
createConnection.setRequestProperty("Upload-Metadata", String.join(",", metadataParts));
182+
createConnection.setDoOutput(true);
183+
createConnection.getOutputStream().close();
184+
if (createConnection.getResponseCode() != 201) {
185+
throw new IllegalStateException("TUS create returned HTTP "
186+
+ createConnection.getResponseCode() + ", expected 201");
187+
}
188+
String location = createConnection.getHeaderField("Location");
189+
createConnection.disconnect();
190+
if (location == null || location.isEmpty()) {
191+
throw new IllegalStateException("TUS create did not return a Location header");
192+
}
193+
String uploadUrl = new URL(createUrl, location).toString();
194+
195+
HttpURLConnection patchConnection = (HttpURLConnection) new URL(uploadUrl).openConnection();
196+
// HttpURLConnection rejects PATCH, so use the same POST + override header TUS supports.
197+
patchConnection.setRequestMethod("POST");
198+
patchConnection.setRequestProperty("X-HTTP-Method-Override", "PATCH");
199+
patchConnection.setRequestProperty("Tus-Resumable", "1.0.0");
200+
patchConnection.setRequestProperty("Upload-Offset", "0");
201+
patchConnection.setRequestProperty("Content-Type", "application/offset+octet-stream");
202+
patchConnection.setDoOutput(true);
203+
patchConnection.getOutputStream().write(content, 0, stopAfterAcceptedBytes);
204+
patchConnection.getOutputStream().close();
205+
if (patchConnection.getResponseCode() != 204) {
206+
throw new IllegalStateException("TUS first chunk returned HTTP "
207+
+ patchConnection.getResponseCode() + ", expected 204");
208+
}
209+
String acceptedBytesHeader = patchConnection.getHeaderField("Upload-Offset");
210+
patchConnection.disconnect();
211+
int acceptedBytes = acceptedBytesHeader == null ? -1 : Integer.parseInt(acceptedBytesHeader);
212+
if (acceptedBytes != stopAfterAcceptedBytes) {
213+
throw new IllegalStateException("TUS first chunk accepted " + acceptedBytes
214+
+ " bytes, expected " + stopAfterAcceptedBytes);
215+
}
216+
217+
return uploadUrl;
218+
}
219+
220+
private static void writeResult(JSONObject result) throws Exception {
221+
String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT");
222+
if (resultPath == null || resultPath.isEmpty()) {
223+
return;
224+
}
225+
226+
Files.write(
227+
Paths.get(resultPath),
228+
(result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8));
229+
}
230+
231+
private Api2DevdockTusResumeUpload() {
232+
throw new IllegalStateException("Utility class");
233+
}
234+
}

src/main/java/com/transloadit/sdk/Transloadit.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,94 @@ public AssemblyResponse createTusAssembly(int fileCount)
370370

371371
// </api2-generated-feature createTusAssembly>
372372

373+
// <api2-generated-feature resumeTusUpload>
374+
375+
// This block is generated from Transloadit API2 contracts. If it looks wrong,
376+
// please report the issue instead of editing this block by hand; the source fix
377+
// belongs in the contract generator so all SDKs stay in sync.
378+
379+
/**
380+
* Resumes an interrupted TUS upload from the server-reported offset and waits for the Assembly to finish.
381+
*/
382+
public AssemblyResponse resumeTusUpload(String uploadUrl, byte[] content, String assemblySslUrl)
383+
throws RequestException, LocalOperationException {
384+
java.net.URL storedUploadUrl;
385+
try {
386+
storedUploadUrl = new java.net.URL(uploadUrl);
387+
} catch (java.net.MalformedURLException error) {
388+
throw new LocalOperationException(error);
389+
}
390+
391+
okhttp3.OkHttpClient httpClient = new okhttp3.OkHttpClient();
392+
393+
int resumeOffset;
394+
okhttp3.Request.Builder offsetRequestBuilder = new okhttp3.Request.Builder()
395+
.url(storedUploadUrl)
396+
.method("HEAD", null);
397+
offsetRequestBuilder.addHeader("Tus-Resumable", "1.0.0");
398+
okhttp3.Request offsetRequest = offsetRequestBuilder.build();
399+
400+
okhttp3.Response offsetResponse;
401+
try {
402+
offsetResponse = httpClient.newCall(offsetRequest).execute();
403+
} catch (java.io.IOException error) {
404+
throw new RequestException(error);
405+
}
406+
try {
407+
if (offsetResponse.code() != 200) {
408+
throw new RequestException(String.format("TUS offset returned HTTP %d, expected 200", offsetResponse.code()));
409+
}
410+
String resumeOffsetHeader = offsetResponse.header("Upload-Offset");
411+
if (resumeOffsetHeader == null || resumeOffsetHeader.isEmpty()) {
412+
throw new RequestException("TUS offset did not return a Upload-Offset header");
413+
}
414+
try {
415+
resumeOffset = Integer.parseInt(resumeOffsetHeader);
416+
} catch (NumberFormatException error) {
417+
throw new RequestException("TUS offset returned an invalid Upload-Offset header");
418+
}
419+
} finally {
420+
offsetResponse.close();
421+
}
422+
423+
okhttp3.Request.Builder uploadRequestBuilder = new okhttp3.Request.Builder()
424+
.url(storedUploadUrl)
425+
.method("PATCH", okhttp3.RequestBody.create(null, java.util.Arrays.copyOfRange(content, resumeOffset, content.length)));
426+
uploadRequestBuilder.addHeader("Tus-Resumable", "1.0.0");
427+
uploadRequestBuilder.addHeader("Upload-Offset", String.valueOf(resumeOffset));
428+
uploadRequestBuilder.addHeader("Content-Type", "application/offset+octet-stream");
429+
okhttp3.Request uploadRequest = uploadRequestBuilder.build();
430+
431+
okhttp3.Response uploadResponse;
432+
try {
433+
uploadResponse = httpClient.newCall(uploadRequest).execute();
434+
} catch (java.io.IOException error) {
435+
throw new RequestException(error);
436+
}
437+
try {
438+
if (uploadResponse.code() != 204) {
439+
throw new RequestException(String.format("TUS upload returned HTTP %d, expected 204", uploadResponse.code()));
440+
}
441+
int uploadOffset;
442+
try {
443+
uploadOffset = Integer.parseInt(uploadResponse.header("Upload-Offset"));
444+
} catch (NumberFormatException error) {
445+
throw new LocalOperationException(error);
446+
}
447+
if (uploadOffset != content.length) {
448+
throw new RequestException(String.format("TUS upload offset %d, expected %d", uploadOffset, content.length));
449+
}
450+
} finally {
451+
uploadResponse.close();
452+
}
453+
454+
AssemblyResponse completedAssembly = waitForAssembly(assemblySslUrl);
455+
456+
return completedAssembly;
457+
}
458+
459+
// </api2-generated-feature resumeTusUpload>
460+
373461
// <api2-generated-feature uploadTusAssembly>
374462

375463
// This block is generated from Transloadit API2 contracts. If it looks wrong,

0 commit comments

Comments
 (0)