Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
700c863
Add generated TUS protocol contract canary
kvz May 26, 2026
8ad4983
Allow manual Java client workflow runs
kvz May 26, 2026
19adf55
Regenerate TUS protocol contract fixture
kvz May 26, 2026
194b752
Fix generated contract lint
kvz May 26, 2026
9d1277f
Regenerate TUS feature contract fixture
kvz May 26, 2026
210974e
Regenerate upload body protocol fixture
kvz May 27, 2026
6e7a32a
Assert generated TUS upload events
kvz May 28, 2026
d97c79c
Cover TUS request lifecycle conformance
kvz May 28, 2026
d8b4e38
Cover TUS abort conformance
kvz May 29, 2026
a211c17
Cover TUS URL storage conformance
kvz May 29, 2026
22e352b
Cover TUS relative Location conformance
kvz May 29, 2026
2c32dd5
Refresh TUS input source contract
kvz May 29, 2026
db63d33
Refresh TUS retry state contract
kvz May 29, 2026
296da7c
Refresh TUS URL storage contract
kvz May 29, 2026
c9338d9
Refresh TUS protocol selection contract
kvz May 29, 2026
bead997
Refresh TUS start validation contract
kvz May 29, 2026
0c72eab
Update detailed error conformance
kvz May 29, 2026
84862fb
Expose generated conformance scenarios
kvz May 31, 2026
9a0d85a
Add generated conformance event canary
kvz May 31, 2026
6a8c8fa
Regenerate TUS protocol fixture for lint
kvz May 31, 2026
1a3a085
Add generated runtime event canary
kvz May 31, 2026
e892ed1
Cover generated resume runtime events
kvz May 31, 2026
420dea9
Keep generated runtime canary lint-clean
kvz May 31, 2026
4014384
Support deferred length uploads
kvz May 31, 2026
78a231b
Regenerate TUS event contract
kvz Jun 1, 2026
5c1f676
Carry generated TUS event policy
kvz Jun 1, 2026
269dc22
Keep generated event fixtures lintable
kvz Jun 1, 2026
bca3207
Update generated TUS retry events
kvz Jun 1, 2026
86cdecd
Add generated TUS proof profile canaries
kvz Jun 1, 2026
c34bd54
Use generated TUS execution hints in runtime tests
kvz Jun 1, 2026
25c42b7
Expose TUS managed upload contract
kvz Jun 1, 2026
fe0fbf6
Expose managed upload proof cases
kvz Jun 1, 2026
2b62396
Add managed upload runtime proof
kvz Jun 1, 2026
c7e4c15
Update managed upload proof fixture
kvz Jun 1, 2026
0f840a9
Add managed upload permanent failure proof
kvz Jun 1, 2026
cf572c4
Respect managed upload fixture lint
kvz Jun 1, 2026
862a262
Add managed upload retry exhaustion proof
kvz Jun 1, 2026
2d7600d
Add generated managed source unavailable proof
kvz Jun 1, 2026
729c107
Add generated managed network deferral proof
kvz Jun 1, 2026
a165b6b
Add devdock TUS upload example
kvz Jun 1, 2026
bd7aaa1
Run devdock example from repo root
kvz Jun 1, 2026
f444876
Satisfy lint for devdock example
kvz Jun 1, 2026
9108bda
Emit devdock example result
kvz Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lintChanges.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
name: Lint Java Code
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types:
- opened
- ready_for_review
- synchronize
- unlabeled
jobs:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
name: Tests

on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types:
- opened
- ready_for_review
- synchronize
- unlabeled
jobs:
Expand Down
7 changes: 7 additions & 0 deletions example/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@ apply plugin: 'java'

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.json:json:20240303'
implementation rootProject
}

tasks.register('api2DevdockTusUpload', JavaExec) {
classpath = sourceSets.main.runtimeClasspath
mainClass = 'io.tus.java.example.Api2DevdockTusUpload'
workingDir = rootProject.projectDir
}
196 changes: 196 additions & 0 deletions example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package io.tus.java.example;

import io.tus.java.client.ProtocolException;
import io.tus.java.client.TusClient;
import io.tus.java.client.TusURLMemoryStore;
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.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;

public final class Api2DevdockTusUpload {
/**
* Run the API2 devdock TUS upload example.
*
* @param args ignored
*/
public static void main(String[] args) {
try {
System.setProperty("http.strictPostRedirect", "true");

final JSONObject scenario = loadScenario();
final JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse");
final String uploadUrl = uploadWithTus(scenario, createResponse);
writeResult(uploadUrl);

System.out.println(
"Java TUS SDK devdock scenario "
+ scenario.getString("scenarioId")
+ " uploaded to "
+ uploadUrl
);
} catch (Exception e) {
e.printStackTrace();
System.exit(1);
}
}

private static JSONObject loadScenario() throws IOException {
String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO");
if (scenarioPath == null || scenarioPath.isEmpty()) {
scenarioPath = "example/api2-scenario.json";
}

final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath));
return new JSONObject(new String(contents, StandardCharsets.UTF_8));
}

private static void writeResult(String uploadUrl) throws IOException {
final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT");
if (resultPath == null || resultPath.isEmpty()) {
return;
}

final JSONObject result = new JSONObject();
result.put("uploadUrl", uploadUrl);
Files.write(
Paths.get(resultPath),
(result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8)
);
}

private static String uploadWithTus(
JSONObject scenario,
JSONObject createResponse
) throws IOException, ProtocolException {
final JSONObject uploadConfig = scenario.getJSONObject("upload");
final Object endpointValue = resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse);
final byte[] content = scenarioBytes(uploadConfig);

final TusClient client = new TusClient();
client.setUploadCreationURL(new URL(scalarString(endpointValue)));
client.enableResuming(new TusURLMemoryStore());

final TusUpload upload = new TusUpload();
upload.setInputStream(new ByteArrayInputStream(content));
upload.setSize(content.length);
upload.setFingerprint(scenario.getString("scenarioId") + "-java-devdock-example");
upload.setMetadata(uploadMetadata(uploadConfig, scenario, createResponse));

final TusUploader uploader = client.resumeOrCreateUpload(upload);
uploader.setChunkSize(content.length);
int uploadedChunkSize;
do {
uploadedChunkSize = uploader.uploadChunk();
} while (uploadedChunkSize > -1);
uploader.finish();

if (uploader.getOffset() != content.length) {
throw new IllegalStateException(
"remote offset " + uploader.getOffset() + ", expected " + content.length
);
}
if (uploader.getUploadURL() == null) {
throw new IllegalStateException("upload did not return a URL");
}

return uploader.getUploadURL().toString();
}

private static byte[] scenarioBytes(JSONObject uploadConfig) {
final JSONObject source = uploadConfig.getJSONObject("source");
final String kind = source.getString("kind");
if (!"bytes".equals(kind)) {
throw new IllegalArgumentException("unsupported source kind " + kind);
}

final String encoding = source.getString("encoding");
if (!"utf8".equals(encoding)) {
throw new IllegalArgumentException("unsupported source encoding " + encoding);
}

return source.getString("value").getBytes(StandardCharsets.UTF_8);
}

private static Map<String, String> uploadMetadata(
JSONObject uploadConfig,
JSONObject scenario,
JSONObject createResponse
) {
final JSONArray fields = uploadConfig.getJSONArray("metadata");
final Map<String, String> metadata = new LinkedHashMap<String, String>();
for (int index = 0; index < fields.length(); index++) {
final JSONObject field = fields.getJSONObject(index);
metadata.put(
field.getString("name"),
scalarString(resolveValue(field.getJSONObject("value"), scenario, createResponse))
);
}

return metadata;
}

private static Object resolveValue(
JSONObject valueSpec,
JSONObject scenario,
JSONObject createResponse
) {
if (valueSpec.has("value")) {
return valueSpec.get("value");
}

final JSONObject source = valueSpec.getJSONObject("source");
final String root = source.getString("root");
final Object rootValue;
if ("scenario".equals(root)) {
rootValue = scenario;
} else if ("createResponse".equals(root)) {
rootValue = createResponse;
} else {
throw new IllegalArgumentException("unsupported scenario value root " + root);
}

return readPath(rootValue, source.getJSONArray("path"));
}

private static Object readPath(Object value, JSONArray pathParts) {
Object current = value;
for (int index = 0; index < pathParts.length(); index++) {
final Object part = pathParts.get(index);
if (current instanceof JSONObject && part instanceof String) {
current = ((JSONObject) current).get((String) part);
continue;
}

if (current instanceof JSONArray && part instanceof Number) {
current = ((JSONArray) current).get(((Number) part).intValue());
continue;
}

throw new IllegalArgumentException("cannot read scenario path part " + part);
}

return current;
}

private static String scalarString(Object value) {
if (JSONObject.NULL.equals(value)) {
return "null";
}

return String.valueOf(value);
}

private Api2DevdockTusUpload() {
throw new IllegalStateException("Utility class");
}
}
8 changes: 6 additions & 2 deletions src/main/java/io/tus/java/client/TusClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class TusClient {
* Version of the tus protocol used by the client. The remote server needs to support this
* version, too.
*/
public static final String TUS_VERSION = "1.0.0";
public static final String TUS_VERSION = TusProtocol.DEFAULT_PROTOCOL_VERSION;

private URL uploadCreationURL;
private Proxy proxy;
Expand Down Expand Up @@ -203,7 +203,11 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept
connection.setRequestProperty("Upload-Metadata", encodedMetadata);
}

connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize()));
if (upload.isUploadLengthDeferred()) {
connection.addRequestProperty("Upload-Defer-Length", "1");
} else {
connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize()));
}
connection.connect();

int responseCode = connection.getResponseCode();
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/io/tus/java/client/TusProtocol.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT.
* If it looks wrong, please report the issue instead of editing this file by hand;
* the source fix belongs in the protocol contract generator so all TUS clients stay in sync.
*/

package io.tus.java.client;

/**
* Generated TUS protocol constants used by the runtime client.
*/
final class TusProtocol {
static final String DEFAULT_PROTOCOL_VERSION = "1.0.0";

private TusProtocol() {
}
}
21 changes: 21 additions & 0 deletions src/main/java/io/tus/java/client/TusUpload.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class TusUpload {
private TusInputStream tusInputStream;
private String fingerprint;
private Map<String, String> metadata;
private boolean uploadLengthDeferred;

/**
* Create a new TusUpload object.
Expand Down Expand Up @@ -62,6 +63,26 @@ public void setSize(long size) {
this.size = size;
}

/**
* Returns whether upload creation should defer declaring the upload length.
* @return True if the Upload-Defer-Length creation header should be used.
*/
public boolean isUploadLengthDeferred() {
return uploadLengthDeferred;
}

/**
* Set whether upload creation should defer declaring the upload length.
*
* When enabled, the upload is created with Upload-Defer-Length and the uploader declares
* Upload-Length on the first PATCH request.
*
* @param uploadLengthDeferred True to use deferred upload length creation.
*/
public void setUploadLengthDeferred(boolean uploadLengthDeferred) {
this.uploadLengthDeferred = uploadLengthDeferred;
}

/**
* Returns the file specific fingerprint.
* @return Fingerprint as String.
Expand Down
Loading