diff --git a/.github/workflows/lintChanges.yml b/.github/workflows/lintChanges.yml index 2b7069dc..ab0b8cd9 100644 --- a/.github/workflows/lintChanges.yml +++ b/.github/workflows/lintChanges.yml @@ -1,11 +1,13 @@ name: Lint Java Code on: + workflow_dispatch: push: branches: - main pull_request: types: - opened + - ready_for_review - synchronize - unlabeled jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84d25cc7..e3722daa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,12 +4,14 @@ name: Tests on: + workflow_dispatch: push: branches: - main pull_request: types: - opened + - ready_for_review - synchronize - unlabeled jobs: diff --git a/example/build.gradle b/example/build.gradle index aeeb70b7..558931d9 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -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 +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java new file mode 100644 index 00000000..33845e69 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -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 uploadMetadata( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + final JSONArray fields = uploadConfig.getJSONArray("metadata"); + final Map metadata = new LinkedHashMap(); + 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"); + } +} diff --git a/src/main/java/io/tus/java/client/TusClient.java b/src/main/java/io/tus/java/client/TusClient.java index a5cac6ad..d4df183f 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -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; @@ -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(); diff --git a/src/main/java/io/tus/java/client/TusProtocol.java b/src/main/java/io/tus/java/client/TusProtocol.java new file mode 100644 index 00000000..b4a53891 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -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() { + } +} diff --git a/src/main/java/io/tus/java/client/TusUpload.java b/src/main/java/io/tus/java/client/TusUpload.java index 5559af59..ecd63732 100644 --- a/src/main/java/io/tus/java/client/TusUpload.java +++ b/src/main/java/io/tus/java/client/TusUpload.java @@ -21,6 +21,7 @@ public class TusUpload { private TusInputStream tusInputStream; private String fingerprint; private Map metadata; + private boolean uploadLengthDeferred; /** * Create a new TusUpload object. @@ -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. diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index d84bd8b4..d3b97d78 100644 --- a/src/main/java/io/tus/java/client/TusUploader.java +++ b/src/main/java/io/tus/java/client/TusUploader.java @@ -21,6 +21,31 @@ * */ public class TusUploader { + /** + * Callback for upload progress events. + */ + public interface ProgressListener { + /** + * Called when upload progress changes. + * @param bytesSent Bytes accepted locally for the upload. + * @param bytesTotal Total upload size. + */ + void onProgress(long bytesSent, long bytesTotal); + } + + /** + * Callback for accepted chunk events. + */ + public interface ChunkCompleteListener { + /** + * Called after the server accepts an upload request. + * @param chunkSize Bytes accepted by the completed request. + * @param bytesAccepted Total bytes accepted by the server. + * @param bytesTotal Total upload size. + */ + void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal); + } + private URL uploadURL; private Proxy proxy; private TusInputStream input; @@ -30,6 +55,12 @@ public class TusUploader { private byte[] buffer; private int requestPayloadSize = 10 * 1024 * 1024; private int bytesRemainingForRequest; + private long requestStartOffset; + private boolean requestDeclaresUploadLength; + private boolean requestProgressStarted; + private boolean uploadLengthDeclared; + private ProgressListener progressListener; + private ChunkCompleteListener chunkCompleteListener; private HttpURLConnection connection; private OutputStream output; @@ -52,6 +83,7 @@ public TusUploader(TusClient client, TusUpload upload, URL uploadURL, TusInputSt this.offset = offset; this.client = client; this.upload = upload; + uploadLengthDeclared = !upload.isUploadLengthDeferred(); input.seekTo(offset); @@ -65,6 +97,9 @@ private void openConnection() throws IOException, ProtocolException { } bytesRemainingForRequest = requestPayloadSize; + requestDeclaresUploadLength = false; + requestStartOffset = offset; + requestProgressStarted = false; input.mark(requestPayloadSize); if (proxy != null) { @@ -74,6 +109,10 @@ private void openConnection() throws IOException, ProtocolException { } client.prepareConnection(connection); connection.setRequestProperty("Upload-Offset", Long.toString(offset)); + if (!uploadLengthDeclared) { + connection.setRequestProperty("Upload-Length", Long.toString(upload.getSize())); + requestDeclaresUploadLength = true; + } connection.setRequestProperty("Content-Type", "application/offset+octet-stream"); connection.setRequestProperty("Expect", "100-continue"); @@ -168,6 +207,24 @@ public int getRequestPayloadSize() { return requestPayloadSize; } + /** + * Set the listener used for upload progress events. + * + * @param listener Progress listener or null to disable events. + */ + public void setProgressListener(ProgressListener listener) { + progressListener = listener; + } + + /** + * Set the listener used for accepted chunk events. + * + * @param listener Chunk-complete listener or null to disable events. + */ + public void setChunkCompleteListener(ChunkCompleteListener listener) { + chunkCompleteListener = listener; + } + /** * Upload a part of the file by reading a chunk from the InputStream and writing * it to the HTTP request's body. If the number of available bytes is lower than the chunk's @@ -184,6 +241,7 @@ public int getRequestPayloadSize() { */ public int uploadChunk() throws IOException, ProtocolException { openConnection(); + notifyProgressAtRequestStart(); int bytesToRead = Math.min(getChunkSize(), bytesRemainingForRequest); @@ -201,6 +259,7 @@ public int uploadChunk() throws IOException, ProtocolException { offset += bytesRead; bytesRemainingForRequest -= bytesRead; + notifyProgress(offset); if (bytesRemainingForRequest <= 0) { finishConnection(); @@ -358,7 +417,32 @@ private void finishConnection() throws ProtocolException, IOException { connection); } + if (requestDeclaresUploadLength) { + uploadLengthDeclared = true; + } + notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); connection = null; + requestDeclaresUploadLength = false; + requestProgressStarted = false; + } + } + + private void notifyProgressAtRequestStart() { + if (!requestProgressStarted) { + notifyProgress(offset); + requestProgressStarted = true; + } + } + + private void notifyProgress(long bytesSent) { + if (progressListener != null) { + progressListener.onProgress(bytesSent, upload.getSize()); + } + } + + private void notifyChunkComplete(long chunkSize, long bytesAccepted) { + if (chunkCompleteListener != null) { + chunkCompleteListener.onChunkComplete(chunkSize, bytesAccepted, upload.getSize()); } } diff --git a/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java new file mode 100644 index 00000000..4eadec8a --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -0,0 +1,963 @@ +/* + * 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 client conformance scenario fixture used by tests. + */ +final class GeneratedTusClientConformanceScenarios { + static final GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "single-upload-lifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload-partial-chunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUploadPartialChunk", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingInput" + ), + "startOptionValidation", + "startValidationMissingInput", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingEndpointOrUploadUrl" + ), + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unsupportedProtocol" + ), + "startOptionValidation", + "startValidationUnsupportedProtocol", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "retryDelaysNotArray" + ), + "startOptionValidation", + "startValidationRetryDelaysNotArray", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadUrl" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadSize" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithDeferredLength" + ), + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadDataDuringCreation" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesWithoutParallelUploads" + ), + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesLengthMismatch" + ), + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch", + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unexpectedCreateResponse" + ), + "detailedErrors", + "detailedCreateResponseError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "createUploadRequestFailed" + ), + "detailedErrors", + "detailedCreateRequestError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "uploadBodyHeaders", + "uploadBodyHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "custom-request-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "resume-from-previous-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "relative-location-resolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "relativeLocationResolution", + "relativeLocationResolution", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "resolve-relative-location", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-view-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferViewInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "web-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "webReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-web-stream", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodeReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-stream", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-path-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "deferred-length-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "deferredLengthUpload", + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "override-patch-method", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-concat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "parallelUploadConcat", + "parallelUploadConcat", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "createTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-abort-cleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + "concatenate-partial-uploads", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:3", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "retry-patch-after-offset-recovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new String[] { + "createTusUpload", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "request-lifecycle-hooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "requestLifecycleHooks", + "requestLifecycleHooks", + new String[] { + "getTusUploadOffset", + }, + new String[] { + "run-request-hooks", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "abort-current-request", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:0", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload-after-stored-url", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUploadAfterStoredUrl", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:1", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "terminate-with-retry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "terminated", + null + ), + "terminateUpload", + "terminateWithRetry", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "terminate-upload", + "retry-with-backoff", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + } + ) + ), + }; + + private GeneratedTusClientConformanceScenarios() { + } +} diff --git a/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java new file mode 100644 index 00000000..cba28960 --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -0,0 +1,1588 @@ +/* + * 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 contract fixture used by tests. + */ +final class GeneratedTusProtocolContract { + static final GeneratedTusWireVersion[] WIRE_VERSIONS = new GeneratedTusWireVersion[] { + new GeneratedTusWireVersion( + true, + "1.0.0" + ), + }; + + static final GeneratedTusProtocolOperation[] OPERATIONS = new GeneratedTusProtocolOperation[] { + new GeneratedTusProtocolOperation( + "discoverTusCapabilities", + "capability-discovery", + "OPTIONS", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Extension", + "tus-extension", + true + ), + new GeneratedTusHeaderField( + "Tus-Max-Size", + "tus-max-size", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Tus-Version", + "tus-version", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "createTusUpload", + "creation", + "POST", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 201, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Location", + "location", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "getTusUploadOffset", + "offset-discovery", + "HEAD", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "patchTusUpload", + "upload-chunk", + "PATCH", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "binary", + "application/offset+octet-stream", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Content-Type", + "content-type", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "terminateTusUpload", + "termination", + "DELETE", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "downloadTusUpload", + "download", + "GET", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "binary", + new GeneratedTusHeaderVariant[0] + ), + } + ), + }; + + static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "singleUploadLifecycle", + }, + "covered-by-generated-scenario" + ), + "Create an upload, store its URL, upload bytes, and finish successfully.", + "singleUploadLifecycle", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "open-input-source", + "", + "Open the caller input as a sliceable source." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the remote upload resource." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes until the accepted offset reaches the known length." + ), + }, + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" + ), + "Resume a stored upload URL by discovering the remote offset before patching.", + "resumeUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resume-from-previous-upload", + "", + "Load a stored upload URL selected by fingerprint." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Read the server offset for the stored upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Continue uploading from the discovered offset." + ), + }, + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "deferredLengthUpload", + }, + "covered-by-generated-scenario" + ), + "Create an upload without a known length and declare the length on final PATCH.", + "deferredLengthUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload with deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "defer-upload-length", + "", + "Track the source until the final chunk reveals the total size." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Declare Upload-Length on the final chunk request." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "creationWithUpload", + "creationWithUploadPartialChunk", + }, + "covered-by-generated-scenario" + ), + "Send the first bytes on the creation request when the server/client support it.", + "creationWithUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload while streaming the initial body." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "upload-during-creation", + "", + "Interpret the creation response as an accepted offset." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "uploadBodyHeaders", + }, + "covered-by-generated-scenario" + ), + "Send protocol-specific upload body headers whenever the client transmits file bytes.", + "uploadBodyHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "send-upload-body-headers", + "", + "Attach the protocol-specific upload body content type when a request has bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the protocol-specific body headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "customRequestHeaders", + }, + "covered-by-generated-scenario" + ), + "Apply user-provided request headers to every upload request.", + "customRequestHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "apply-custom-request-headers", + "", + "Merge user-provided headers after protocol headers are prepared." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with the configured custom headers." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the configured custom headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "overridePatchMethod", + }, + "covered-by-generated-scenario" + ), + "Tunnel PATCH through POST with the method-override header.", + "overridePatchMethod", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Resume from the upload URL before sending bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "override-patch-method", + "", + "Replace PATCH with POST while preserving the protocol operation intent." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes through the overridden request." + ), + }, + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "parallelUploadConcat", + "parallelUploadAbortCleanup", + }, + "covered-by-generated-scenario" + ), + "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", + "parallelUploadConcat", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "split-parallel-upload-boundaries", + "", + "Split the input into stable byte ranges." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create partial uploads for each range." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "concatenate-partial-uploads", + "", + "Create the final upload from completed partial upload URLs." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "abort-current-request", + "concatenate-partial-uploads", + "emit-progress", + "split-parallel-upload-boundaries", + "terminate-upload", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Recover from a failed chunk by reading the server offset before retrying.", + "retryOffsetRecovery", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Attempt the chunk upload." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "recover-offset-after-error", + "", + "Discover the accepted offset after a retryable failure." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Use HEAD to recover the offset before retrying PATCH." + ), + }, + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Schedule retry timers and reset retry attempts after accepted progress.", + "retryStateTransitions", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "schedule-retry-timer", + "", + "Consume the current retry delay and restart the upload after that timer fires." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "reset-retry-attempt-after-progress", + "", + "Reset retry attempts once a later retry observes server-side offset progress." + ), + }, + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "schedule-retry-timer", + "reset-retry-attempt-after-progress", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "terminateWithRetry", + }, + "covered-by-generated-scenario" + ), + "Terminate an upload resource and retry retryable termination failures.", + "terminateUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "terminate-upload", + "", + "Choose server-side termination for an upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "terminateTusUpload", + "", + "", + "Delete the upload resource." + ), + }, + new String[] { + "terminateTusUpload", + }, + new String[] { + "terminate-upload", + "retry-with-backoff", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "abortUpload", + "abortUploadAfterStoredUrl", + }, + "covered-by-generated-scenario" + ), + "Abort the active request, pending retry timer, and any partial uploads.", + "abortUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "abort-current-request", + "", + "Cancel in-flight transport work without emitting user callbacks after abort." + ), + }, + new String[] { + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "singleUploadLifecycle", + "creationWithUpload", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" + ), + "Expose progress and accepted-chunk callbacks from runtime upload activity.", + "uploadCallbacks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-progress", + "", + "Report bytes sent against known or deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-chunk-complete", + "", + "Report chunk size, accepted offset, and total size after server acceptance." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-upload-url", + "", + "Notify once a usable upload URL is known." + ), + }, + new String[0], + new String[] { + "emit-progress", + "emit-chunk-complete", + "emit-upload-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "requestLifecycleHooks", + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Run before-request, after-response, and custom retry hooks around transport.", + "requestLifecycleHooks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "run-request-hooks", + "", + "Call user hooks around each HTTP request/response pair." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "customize-retry", + "", + "Let user retry policy override default retry decisions." + ), + }, + new String[0], + new String[] { + "customize-retry", + "run-request-hooks", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "singleUploadLifecycle", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" + ), + "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + "resumeUrlStorage", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "fingerprint-input", + "", + "Derive a stable key for the input when possible." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-resume-url", + "", + "Persist upload URLs and partial-upload URLs for future resumption." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "remove-stored-url-on-success", + "", + "Remove stored upload URLs when configured after success or invalidation." + ), + }, + new String[0], + new String[] { + "fingerprint-input", + "store-resume-url", + "remove-stored-url-on-success", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "arrayBufferInput", + "arrayBufferViewInput", + "webReadableStreamInput", + "nodeReadableStreamInput", + "nodePathInput", + }, + "covered-by-generated-scenario" + ), + "Support the reference client input/source families across runtimes.", + "inputSources", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-browser-file", + "", + "Read browser Blob/File and ArrayBuffer-family inputs." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-stream", + "", + "Read Node streams when size and chunk constraints are satisfied." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-web-stream", + "", + "Read Web Streams with deferred or configured size." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-file", + "", + "Read filesystem paths and fs streams, including parallel ranges." + ), + }, + new String[0], + new String[] { + "read-browser-file", + "read-node-file", + "read-node-stream", + "read-web-stream", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "webStorageUrlStorageBackend", + "fileUrlStorageBackend", + }, + "covered-by-generated-scenario" + ), + "Support browser and file-backed URL storage implementations.", + "urlStorageBackends", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-browser-url", + "", + "Persist upload records in browser localStorage." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-file-url", + "", + "Persist upload records in the Node file store." + ), + }, + new String[0], + new String[] { + "store-browser-url", + "store-file-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "ietfDraft05CreationWithUpload", + "ietfDraft03ResumeWithoutKnownLength", + }, + "covered-by-generated-scenario" + ), + "Select between tus v1 and supported IETF draft client protocol modes.", + "protocolVersionSelection", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "select-client-protocol", + "", + "Choose request headers and response expectations for the selected protocol." + ), + }, + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "relativeLocationResolution", + }, + "covered-by-generated-scenario" + ), + "Normalize relative Location headers against the request endpoint.", + "relativeLocationResolution", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resolve-relative-location", + "", + "Resolve server Location headers with the creation endpoint as origin." + ), + }, + new String[] { + "createTusUpload", + }, + new String[] { + "resolve-relative-location", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "startValidationMissingInput", + "startValidationMissingEndpointOrUploadUrl", + "startValidationUnsupportedProtocol", + "startValidationRetryDelaysNotArray", + "startValidationParallelUploadsWithUploadUrl", + "startValidationParallelUploadsWithUploadSize", + "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelUploadsWithUploadDataDuringCreation", + "startValidationParallelBoundariesWithoutParallelUploads", + "startValidationParallelBoundariesLengthMismatch", + }, + "covered-by-generated-scenario" + ), + "Validate option combinations before starting runtime work.", + "startOptionValidation", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "validate-start-options", + "", + "Reject missing inputs and incompatible parallel/deferred/resume options." + ), + }, + new String[0], + new String[] { + "validate-start-options", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "detailedCreateResponseError", + "detailedCreateRequestError", + }, + "covered-by-generated-scenario" + ), + "Attach request, response, status, body, and request ID context to errors.", + "detailedErrors", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "report-detailed-errors", + "", + "Return user-facing errors with enough transport context for debugging." + ), + }, + new String[0], + new String[] { + "report-detailed-errors", + } + ), + }; + + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + + static final String[] MANAGED_UPLOAD_PRIMITIVES = + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }; + + static final String[] MANAGED_UPLOAD_RUNTIME_PROFILES = + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + }; + + static final String[] MANAGED_UPLOAD_SCENARIO_IDS = + new String[] { + "managedUploadDurableRetry", + "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", + "managedUploadNetworkConstraint", + }; + + static final GeneratedTusManagedUploadProofCase[] MANAGED_UPLOAD_PROOF_CASES = + new GeneratedTusManagedUploadProofCase[] { + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadDurableRetry", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadPermanentFailure", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadRetryPolicyExhausted", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadSourceUnavailable", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadNetworkConstraint", + new String[] { + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "publish-upload-state", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + }; + + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; + + private GeneratedTusProtocolContract() { + } + + /** + * Generated wire-version fixture. + */ + static final class GeneratedTusWireVersion { + final boolean defaultVersion; + final String value; + + GeneratedTusWireVersion(boolean defaultVersion, String value) { + this.defaultVersion = defaultVersion; + this.value = value; + } + } + + /** + * Generated HTTP header field fixture. + */ + static final class GeneratedTusHeaderField { + final String displayName; + final String name; + final boolean required; + + GeneratedTusHeaderField(String displayName, String name, boolean required) { + this.displayName = displayName; + this.name = name; + this.required = required; + } + } + + /** + * Generated alternative HTTP header set fixture. + */ + static final class GeneratedTusHeaderVariant { + final GeneratedTusHeaderField[] fields; + + GeneratedTusHeaderVariant(GeneratedTusHeaderField[] fields) { + this.fields = fields; + } + } + + /** + * Generated request contract fixture. + */ + static final class GeneratedTusRequestContract { + final String bodyKind; + final String contentType; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusRequestContract( + String bodyKind, + String contentType, + GeneratedTusHeaderVariant[] headerVariants) { + this.bodyKind = bodyKind; + this.contentType = contentType; + this.headerVariants = headerVariants; + } + } + + /** + * Generated response contract fixture. + */ + static final class GeneratedTusResponseContract { + final int statusCode; + final String bodyKind; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusResponseContract( + int statusCode, + String bodyKind, + GeneratedTusHeaderVariant[] headerVariants) { + this.statusCode = statusCode; + this.bodyKind = bodyKind; + this.headerVariants = headerVariants; + } + } + + /** + * Generated protocol operation fixture. + */ + static final class GeneratedTusProtocolOperation { + final String operationId; + final String role; + final String method; + final String path; + final GeneratedTusRequestContract request; + final GeneratedTusResponseContract[] responses; + + GeneratedTusProtocolOperation( + String operationId, + String role, + String method, + String path, + GeneratedTusRequestContract request, + GeneratedTusResponseContract[] responses) { + this.operationId = operationId; + this.role = role; + this.method = method; + this.path = path; + this.request = request; + this.responses = responses; + } + } + + /** + * Generated client feature fixture. + */ + static final class GeneratedTusClientFeature { + final GeneratedTusClientFeatureConformance conformance; + final String description; + final String featureId; + final GeneratedTusClientFeatureFlowStep[] flow; + final String[] operationIds; + final String[] primitives; + + GeneratedTusClientFeature( + GeneratedTusClientFeatureConformance conformance, + String description, + String featureId, + GeneratedTusClientFeatureFlowStep[] flow, + String[] operationIds, + String[] primitives) { + this.conformance = conformance; + this.description = description; + this.featureId = featureId; + this.flow = flow; + this.operationIds = operationIds; + this.primitives = primitives; + } + } + + /** + * Generated client feature conformance coverage. + */ + static final class GeneratedTusClientFeatureConformance { + final String[] scenarioIds; + final String status; + + GeneratedTusClientFeatureConformance(String[] scenarioIds, String status) { + this.scenarioIds = scenarioIds; + this.status = status; + } + } + + /** + * Generated client feature flow step. + */ + static final class GeneratedTusClientFeatureFlowStep { + final String kind; + final String operationId; + final String primitive; + final String condition; + final String summary; + + GeneratedTusClientFeatureFlowStep( + String kind, + String operationId, + String primitive, + String condition, + String summary) { + this.kind = kind; + this.operationId = operationId; + this.primitive = primitive; + this.condition = condition; + this.summary = summary; + } + } + + /** + * Generated managed-upload feature proof fixture. + */ + static final class GeneratedTusManagedUploadProofCase { + final String featureId; + final String layer; + final String scenarioId; + final String[] proofRuntimes; + final String[] requiredPrimitives; + final String[] protocolFeatureIds; + final String[] runtimeProfiles; + + GeneratedTusManagedUploadProofCase( + String featureId, + String layer, + String scenarioId, + String[] proofRuntimes, + String[] requiredPrimitives, + String[] protocolFeatureIds, + String[] runtimeProfiles) { + this.featureId = featureId; + this.layer = layer; + this.scenarioId = scenarioId; + this.proofRuntimes = proofRuntimes; + this.requiredPrimitives = requiredPrimitives; + this.protocolFeatureIds = protocolFeatureIds; + this.runtimeProfiles = runtimeProfiles; + } + } + + /** + * Generated client conformance scenario fixture. + */ + static final class GeneratedTusClientConformanceScenario { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + final String[] operationIds; + final String[] primitives; + final GeneratedTusClientConformanceEventPolicy eventPolicy; + final String[] eventKeys; + + GeneratedTusClientConformanceScenario( + String behavior, + GeneratedTusClientConformanceCompletion completion, + String featureId, + String scenarioId, + String[] operationIds, + String[] primitives, + GeneratedTusClientConformanceEvents events) { + this.behavior = behavior; + this.completionKind = completion.kind; + this.completionReason = completion.reason; + this.featureId = featureId; + this.scenarioId = scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + this.eventPolicy = events.policy; + this.eventKeys = events.keys; + } + } + + /** + * Generated client conformance event fixture bundle. + */ + static final class GeneratedTusClientConformanceEvents { + final GeneratedTusClientConformanceEventPolicy policy; + final String[] keys; + + GeneratedTusClientConformanceEvents( + GeneratedTusClientConformanceEventPolicy policy, + String[] keys) { + this.policy = policy; + this.keys = keys; + } + } + + /** + * Generated client conformance event policy fixture. + */ + static final class GeneratedTusClientConformanceEventPolicy { + final String matching; + final String progress; + final String transportProgress; + + GeneratedTusClientConformanceEventPolicy( + String matching, + String progress, + String transportProgress) { + this.matching = matching; + this.progress = progress; + this.transportProgress = transportProgress; + } + } + + /** + * Generated client conformance completion fixture. + */ + static final class GeneratedTusClientConformanceCompletion { + final String kind; + final String reason; + + GeneratedTusClientConformanceCompletion(String kind, String reason) { + this.kind = kind; + this.reason = reason; + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java new file mode 100644 index 00000000..65b15407 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -0,0 +1,562 @@ +/* + * 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; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * Tests generated TUS client conformance event fixtures. + */ +public class TestGeneratedTusConformanceEvents { + private static final GeneratedTusEventCanaryCase[] CASES = + new GeneratedTusEventCanaryCase[] { + new GeneratedTusEventCanaryCase( + "singleUploadLifecycle", + "singleUploadLifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUploadPartialChunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "resumeUpload", + "resumeFromPreviousUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "relativeLocationResolution", + "relativeLocationResolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferViewInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "webReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodeReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodePathInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "deferredLengthUpload", + "deferredLengthUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadConcat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:3", + } + ), + new GeneratedTusEventCanaryCase( + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ), + new GeneratedTusEventCanaryCase( + "requestLifecycleHooks", + "requestLifecycleHooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:0", + } + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUploadAfterStoredUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:1", + } + ), + new GeneratedTusEventCanaryCase( + "terminateUpload", + "terminateWithRetry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + } + ), + }; + + private static final GeneratedTusProofProfileCase[] PROOF_CASES = + new GeneratedTusProofProfileCase[] { + new GeneratedTusProofProfileCase( + "urlStorageCreateFlow", + "single-upload-lifecycle", + "success", + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + } + ), + new GeneratedTusProofProfileCase( + "customRequestHeaders", + "custom-request-headers", + "success", + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + } + ), + new GeneratedTusProofProfileCase( + "overridePatchMethod", + "override-patch-method", + "success", + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + } + ), + new GeneratedTusProofProfileCase( + "nodePathFileUpload", + "node-path-input", + "success", + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + } + ), + new GeneratedTusProofProfileCase( + "resumeFromPreviousUpload", + "resume-from-previous-upload", + "success", + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + } + ), + }; + + /** + * Verifies generated feature-level event keys survive in the Java fixture. + */ + @Test + public void testGeneratedScenarioEventKeys() { + for (GeneratedTusEventCanaryCase testCase : CASES) { + GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = + findScenario(testCase.scenarioId); + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature(testCase.featureId); + + assertEquals(testCase.featureId, scenario.featureId); + assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertEventPolicyEquals(testCase.eventPolicy, scenario.eventPolicy); + assertArrayEquals(testCase.eventKeys, scenario.eventKeys); + } + } + + /** + * Verifies generated named proof-profile scenarios survive in the Java fixture. + */ + @Test + public void testGeneratedProofProfileScenarios() { + for (GeneratedTusProofProfileCase testCase : PROOF_CASES) { + GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario = + findScenario(testCase.scenarioId); + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature(testCase.featureId); + + assertEquals(testCase.behavior, scenario.behavior); + assertEquals(testCase.completionKind, scenario.completionKind); + assertEquals(testCase.featureId, scenario.featureId); + assertContains(feature.conformance.scenarioIds, scenario.scenarioId); + assertArrayEquals(testCase.operationIds, scenario.operationIds); + assertArrayEquals(testCase.primitives, scenario.primitives); + } + } + + /** + * Verifies managed-upload proof scenarios stay wired to protocol features and primitives. + */ + @Test + public void testGeneratedManagedUploadProofScenarios() { + for (GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase testCase + : GeneratedTusProtocolContract.MANAGED_UPLOAD_PROOF_CASES) { + assertEquals("managedUpload", testCase.featureId); + assertEquals("feature-over-protocol", testCase.layer); + assertContains( + GeneratedTusProtocolContract.MANAGED_UPLOAD_SCENARIO_IDS, + testCase.scenarioId); + assertArrayEquals( + GeneratedTusProtocolContract.MANAGED_UPLOAD_RUNTIME_PROFILES, + testCase.runtimeProfiles); + + for (String primitive : testCase.requiredPrimitives) { + assertContains(GeneratedTusProtocolContract.MANAGED_UPLOAD_PRIMITIVES, primitive); + } + for (String featureId : testCase.protocolFeatureIds) { + findFeature(featureId); + } + } + } + + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( + String featureId) { + for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature + : GeneratedTusProtocolContract.CLIENT_FEATURES) { + if (feature.featureId.equals(featureId)) { + return feature; + } + } + + throw new AssertionError("Missing generated TUS client feature: " + featureId); + } + + private static GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario findScenario( + String scenarioId) { + for (GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario scenario + : GeneratedTusProtocolContract.CLIENT_CONFORMANCE_SCENARIOS) { + if (scenario.scenarioId.equals(scenarioId)) { + return scenario; + } + } + + throw new AssertionError("Missing generated TUS client scenario: " + scenarioId); + } + + private static void assertContains(String[] values, String expected) { + for (String value : values) { + if (value.equals(expected)) { + return; + } + } + + throw new AssertionError("Missing generated value: " + expected); + } + + private static void assertEventPolicyEquals( + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy expected, + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy actual) { + assertEquals(expected.matching, actual.matching); + assertEquals(expected.progress, actual.progress); + assertEquals(expected.transportProgress, actual.transportProgress); + } + + private static final class GeneratedTusEventCanaryCase { + final String featureId; + final String scenarioId; + final GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy; + final String[] eventKeys; + + GeneratedTusEventCanaryCase( + String featureId, + String scenarioId, + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy, + String[] eventKeys) { + this.featureId = featureId; + this.scenarioId = scenarioId; + this.eventPolicy = eventPolicy; + this.eventKeys = eventKeys; + } + } + + private static final class GeneratedTusProofProfileCase { + final String profile; + final String behavior; + final String completionKind; + final String featureId; + final String scenarioId; + final String[] operationIds; + final String[] primitives; + + GeneratedTusProofProfileCase( + String profile, + String behavior, + String completionKind, + String featureId, + String scenarioId, + String[] operationIds, + String[] primitives) { + this.profile = profile; + this.behavior = behavior; + this.completionKind = completionKind; + this.featureId = featureId; + this.scenarioId = scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java new file mode 100644 index 00000000..ddb01db0 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -0,0 +1,1209 @@ +/* + * 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; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.Test; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests generated managed-upload scenarios against the real Java client pieces. + */ +public class TestGeneratedTusManagedUploadRuntime extends MockServerProvider { + private static final GeneratedTusManagedUploadRuntimeCase[] CASES = + new GeneratedTusManagedUploadRuntimeCase[] { + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadDurableRetry", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "available", + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadOutcome( + "terminal", + "succeeded", + "", + "" + ), + new GeneratedTusManagedUploadCleanup( + "remove-owned-source-after-success", + "remove-after-success" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello managed!", + 7, + "managed-durable-retry-fingerprint", + "managed-durable-retry", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "after-accepted-offset", + "io-error", + 7 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 201, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "succeeded", + null, + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "HEAD", + "upload", + 0, + 200, + new GeneratedTusManagedUploadHeader[0], + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ), + } + ), + } + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadPermanentFailure", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "available", + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadOutcome( + "terminal", + "failed", + "unretryable-protocol-error", + "" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello failure!", + 7, + "managed-permanent-failure-fingerprint", + "managed-permanent-failure", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-permanent-failure.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "unretryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 400, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadRetryPolicyExhausted", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "available", + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadOutcome( + "terminal", + "failed", + "retry-policy-exhausted", + "" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed", + }, + new int[] { + 0, + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello retries!", + 7, + "managed-retry-exhausted-fingerprint", + "managed-retry-exhausted", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-retry-exhausted.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 2, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadSourceUnavailable", + "java", + "process-lifetime-worker-pool", + "copy-to-owned-storage", + "missing-before-durable-copy", + "filesystem", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadOutcome( + "terminal", + "failed", + "source-unavailable", + "" + ), + new GeneratedTusManagedUploadCleanup( + "absent-after-source-unavailable", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello missing!", + 7, + "managed-source-unavailable-fingerprint", + "managed-source-unavailable", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-source-unavailable.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "before-protocol-request", + "source-unavailable", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + + } + ), + } + ), + }; + private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = + new GeneratedTusMethodOverride[] { + new GeneratedTusMethodOverride( + "PATCH", + "POST", + "X-HTTP-Method-Override", + "PATCH" + ), + }; + + /** + * Verifies a durable source can retry, resume, finish, and clean up from contract data. + */ + @Test + public void testManagedUploadDurableRetryRuntime() throws Exception { + for (GeneratedTusManagedUploadRuntimeCase testCase : CASES) { + mockServer.reset(); + registerResponses(testCase); + + List states = new ArrayList(); + File source = writeSourceFile(testCase); + File ownedSource = ownedSourceFile(testCase, source); + File stateFile = stateFile(testCase, source); + recordState(testCase, states, stateFile, "pending"); + + final GeneratedTusManagedUploadUrlStore urlStore = new GeneratedTusManagedUploadUrlStore(); + final TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.enableResuming(urlStore); + client.enableRemoveFingerprintOnSuccess(); + + try { + prepareSourceBeforeProtocol(testCase, source, ownedSource, states, stateFile); + if (shouldDeferBeforeProtocol(testCase)) { + assertDeferredResult(testCase); + } else { + TusExecutor executor = + managedExecutorFor(testCase, client, ownedSource, states, stateFile); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTerminalResult(testCase, future); + } finally { + worker.shutdownNow(); + } + } + } catch (IOException error) { + if (!isSourceUnavailableBeforeProtocol(testCase)) { + throw error; + } + assertTerminalFailure(testCase, error); + } + + cleanupAfterTerminalState(testCase, ownedSource); + + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + states.toArray(new String[states.size()])); + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + Files.readAllLines(stateFile.toPath(), StandardCharsets.UTF_8) + .toArray(new String[testCase.expectedStates.length])); + assertResumeUrlState(testCase, urlStore); + assertOwnedSourceState(testCase, ownedSource); + assertInputSourceState(testCase, source); + assertProtocolRequestCount(testCase); + stateFile.delete(); + } + } + + private void assertTerminalResult( + GeneratedTusManagedUploadRuntimeCase testCase, + Future future) throws Exception { + if (!"terminal".equals(testCase.outcomeKind)) { + throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); + } + + try { + boolean result = future.get(); + if (!"succeeded".equals(testCase.outcomeState)) { + throw new AssertionError(testCase.scenarioId + " expected terminal failure"); + } + assertTrue(testCase.scenarioId, result); + } catch (ExecutionException error) { + if (!"failed".equals(testCase.outcomeState)) { + throw error; + } + assertTerminalFailure(testCase, error.getCause()); + } + } + + private void assertTerminalFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + Throwable error) { + if ("unretryable-protocol-error".equals(testCase.outcomeFailure)) { + assertTrue(testCase.scenarioId, error instanceof ProtocolException); + return; + } + if ("source-unavailable".equals(testCase.outcomeFailure)) { + assertTrue(testCase.scenarioId, error instanceof IOException); + return; + } + if ("retry-policy-exhausted".equals(testCase.outcomeFailure)) { + assertTrue( + testCase.scenarioId, + error instanceof ProtocolException || error instanceof IOException); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated terminal failure " + + testCase.outcomeFailure); + } + + private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { + if ( + !"deferred".equals(testCase.outcomeKind) + || !"pending".equals(testCase.outcomeState) + || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) + || !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) + || networkConstraintSatisfied(testCase)) { + throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); + } + } + + private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { + if ("offline".equals(testCase.currentNetwork)) { + return false; + } + if ("any-network".equals(testCase.networkRequired)) { + return "metered-network".equals(testCase.currentNetwork) + || "unmetered-network".equals(testCase.currentNetwork); + } + if ("unmetered-network".equals(testCase.networkRequired)) { + return "unmetered-network".equals(testCase.currentNetwork); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated network requirement " + + testCase.networkRequired); + } + + private TusExecutor managedExecutorFor( + final GeneratedTusManagedUploadRuntimeCase testCase, + final TusClient client, + final File ownedSource, + final List states, + final File stateFile) { + TusExecutor executor = new TusExecutor() { + private int attemptIndex; + + @Override + protected void makeAttempt() throws ProtocolException, IOException { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[attemptIndex]; + attemptIndex += 1; + recordState(testCase, states, stateFile, "running"); + + try { + TusUpload upload = uploadFor(testCase, ownedSource); + TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); + while (uploader.getOffset() < upload.getSize()) { + uploader.uploadChunk(); + if ( + isAfterAcceptedOffsetFailure(attempt) + && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { + uploader.finish(false); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + throw new IOException(attempt.failure.kind); + } + } + uploader.finish(); + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + } catch (ProtocolException error) { + recordDuringProtocolFailure(testCase, states, stateFile, attempt); + throw error; + } catch (IOException error) { + recordDuringProtocolFailure(testCase, states, stateFile, attempt); + throw error; + } + } + }; + executor.setDelays(testCase.retryDelays); + return executor; + } + + private boolean isAfterAcceptedOffsetFailure(GeneratedTusManagedUploadAttempt attempt) { + return attempt.failure != null + && "after-accepted-offset".equals(attempt.failure.phase); + } + + private void recordDuringProtocolFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + File stateFile, + GeneratedTusManagedUploadAttempt attempt) throws IOException { + if (attempt.failure == null || !"during-protocol-request".equals(attempt.failure.phase)) { + return; + } + + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + } + + private TusUpload uploadFor( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + TusUpload upload = new TusUpload(ownedSource); + upload.setFingerprint(testCase.input.fingerprint); + upload.setMetadata(metadataFor(testCase.input.metadata)); + return upload; + } + + private Map metadataFor(GeneratedTusManagedUploadMetadata[] metadata) { + Map result = new LinkedHashMap(); + for (GeneratedTusManagedUploadMetadata entry : metadata) { + result.put(entry.name, entry.value); + } + return result; + } + + private void copyDurableSource( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource) throws IOException { + if (!"copy-to-owned-storage".equals(testCase.sourceDurability)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source durability " + + testCase.sourceDurability); + } + + Files.copy(source.toPath(), ownedSource.toPath(), StandardCopyOption.REPLACE_EXISTING); + assertTrue(testCase.scenarioId, ownedSource.exists()); + } + + private void prepareSourceBeforeProtocol( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource, + List states, + File stateFile) throws IOException { + if ("available".equals(testCase.sourceAvailability)) { + copyDurableSource(testCase, source, ownedSource); + return; + } + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; + if (source.exists() && !source.delete()) { + throw new IOException("Could not remove generated input source " + source); + } + recordState(testCase, states, stateFile, "running"); + try { + copyDurableSource(testCase, source, ownedSource); + } catch (IOException error) { + recordState(testCase, states, stateFile, attempt.stateAfterAttempt); + throw error; + } + throw new AssertionError(testCase.scenarioId + " unexpectedly prepared missing source"); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source availability " + + testCase.sourceAvailability); + } + + private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "source-unavailable".equals(testCase.outcomeFailure) + && "missing-before-durable-copy".equals(testCase.sourceAvailability); + } + + private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "defer-until-network-constraint-satisfied".equals(testCase.networkDecision); + } + + private void cleanupAfterTerminalState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + return; + } + + Files.deleteIfExists(ownedSource.toPath()); + } + + private void assertOwnedSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) { + if ("remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } + if ("retain-owned-source-after-permanent-failure".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } + if ("retain-owned-source-while-deferred".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } + if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated owned-source cleanup " + + testCase.ownedSourceCleanup); + } + + private void assertInputSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + assertFalse(testCase.scenarioId, source.exists()); + return; + } + + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + } + + private void assertResumeUrlState( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadUrlStore urlStore) { + if ( + "remove-after-success".equals(testCase.resumeUrlCleanup) + || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup) + || "absent-while-deferred".equals(testCase.resumeUrlCleanup)) { + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated resume URL cleanup " + + testCase.resumeUrlCleanup); + } + + private void assertProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest()); + assertTrue( + testCase.scenarioId, + requests.length == expectedProtocolRequestCount(testCase)); + } + + private int expectedProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { + int count = 0; + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + count += attempt.requests.length; + } + return count; + } + + private void recordState( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + File stateFile, + String state) throws IOException { + if (!"filesystem".equals(testCase.stateBackend)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated state backend " + + testCase.stateBackend); + } + + states.add(state); + Files.write(stateFile.toPath(), states, StandardCharsets.UTF_8); + } + + private File writeSourceFile(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { + File source = File.createTempFile(testCase.scenarioId, "-source.bin"); + Files.write( + source.toPath(), + testCase.input.content.getBytes(StandardCharsets.UTF_8)); + return source; + } + + private File ownedSourceFile( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + return new File(source.getParentFile(), testCase.scenarioId + "-owned.bin"); + } + + private File stateFile( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + return new File(source.getParentFile(), testCase.scenarioId + "-state.txt"); + } + + private void registerResponses(GeneratedTusManagedUploadRuntimeCase testCase) throws Exception { + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + for (GeneratedTusManagedUploadRequest request : attempt.requests) { + mockServer.when(requestFor(testCase, request, request.method, null)) + .respond(responseFor(testCase, request)); + GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); + if (methodOverride != null) { + mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) + .respond(responseFor(testCase, request)); + } + } + } + } + + private HttpRequest requestFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request, + String method, + GeneratedTusMethodOverride methodOverride) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(method) + .withPath(pathFor(testCase, request)); + for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } + if (methodOverride != null) { + httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); + } + return httpRequest; + } + + private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { + for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { + if (methodOverride.originalMethod.equals(originalMethod)) { + return methodOverride; + } + } + + return null; + } + + private String pathFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request) throws Exception { + if ("endpoint".equals(request.url)) { + return mockServerURL.getPath(); + } + + return uploadUrlFor(testCase).getPath(); + } + + private HttpResponse responseFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request) throws Exception { + HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { + response.withHeader(header.name, headerValueFor(testCase, header)); + } + return response; + } + + private String headerValueFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadHeader header) throws Exception { + if (!testCase.locationHeaderName.equals(header.name)) { + return header.value; + } + + return uploadUrlFor(testCase).toString(); + } + + private URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws Exception { + return new URL(mockServerURL.toString() + "/" + testCase.input.uploadPath); + } + + private static String offsetDiscoveryMethod() { + for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation + : GeneratedTusProtocolContract.OPERATIONS) { + if ("offset-discovery".equals(operation.role)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated offset-discovery operation"); + } + + private static final class GeneratedTusManagedUploadRuntimeCase { + final String scenarioId; + final String runtime; + final String scheduler; + final String sourceDurability; + final String sourceAvailability; + final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; + final String locationHeaderName; + final String outcomeKind; + final String outcomeState; + final String outcomeFailure; + final String outcomeReason; + final String ownedSourceCleanup; + final String resumeUrlCleanup; + final String[] expectedStates; + final int[] retryDelays; + final String offsetDiscoveryMethod; + final GeneratedTusManagedUploadInput input; + final GeneratedTusManagedUploadAttempt[] attempts; + + GeneratedTusManagedUploadRuntimeCase( + GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadOutcome outcome, + GeneratedTusManagedUploadCleanup cleanup, + GeneratedTusManagedUploadRetryPlan retryPlan, + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.scenarioId = profile.scenarioId; + this.runtime = profile.runtime; + this.scheduler = profile.scheduler; + this.sourceDurability = profile.sourceDurability; + this.sourceAvailability = profile.sourceAvailability; + this.stateBackend = profile.stateBackend; + this.networkRequired = profile.networkRequired; + this.currentNetwork = profile.currentNetwork; + this.networkDecision = profile.networkDecision; + this.locationHeaderName = transport.locationHeaderName; + this.outcomeKind = outcome.kind; + this.outcomeState = outcome.state; + this.outcomeFailure = outcome.failure; + this.outcomeReason = outcome.reason; + this.ownedSourceCleanup = cleanup.ownedSource; + this.resumeUrlCleanup = cleanup.resumeUrl; + this.expectedStates = retryPlan.expectedStates; + this.retryDelays = retryPlan.retryDelays; + this.offsetDiscoveryMethod = offsetDiscoveryMethod(); + this.input = input; + this.attempts = attempts; + } + } + + private static final class GeneratedTusManagedUploadOutcome { + final String kind; + final String state; + final String failure; + final String reason; + + GeneratedTusManagedUploadOutcome(String kind, String state, String failure, String reason) { + this.kind = kind; + this.state = state; + this.failure = failure; + this.reason = reason; + } + } + + private static final class GeneratedTusManagedUploadRuntimeProfile { + final String scenarioId; + final String runtime; + final String scheduler; + final String sourceDurability; + final String sourceAvailability; + final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; + + GeneratedTusManagedUploadRuntimeProfile( + String scenarioId, + String runtime, + String scheduler, + String sourceDurability, + String sourceAvailability, + String stateBackend, + GeneratedTusManagedUploadNetwork network) { + this.scenarioId = scenarioId; + this.runtime = runtime; + this.scheduler = scheduler; + this.sourceDurability = sourceDurability; + this.sourceAvailability = sourceAvailability; + this.stateBackend = stateBackend; + this.networkRequired = network.required; + this.currentNetwork = network.current; + this.networkDecision = network.decision; + } + } + + private static final class GeneratedTusManagedUploadNetwork { + final String required; + final String current; + final String decision; + + GeneratedTusManagedUploadNetwork(String required, String current, String decision) { + this.required = required; + this.current = current; + this.decision = decision; + } + } + + private static final class GeneratedTusManagedUploadTransport { + final String locationHeaderName; + + GeneratedTusManagedUploadTransport(String locationHeaderName) { + this.locationHeaderName = locationHeaderName; + } + } + + private static final class GeneratedTusManagedUploadCleanup { + final String ownedSource; + final String resumeUrl; + + GeneratedTusManagedUploadCleanup(String ownedSource, String resumeUrl) { + this.ownedSource = ownedSource; + this.resumeUrl = resumeUrl; + } + } + + private static final class GeneratedTusManagedUploadRetryPlan { + final String[] expectedStates; + final int[] retryDelays; + + GeneratedTusManagedUploadRetryPlan(String[] expectedStates, int[] retryDelays) { + this.expectedStates = expectedStates; + this.retryDelays = retryDelays; + } + } + + private static final class GeneratedTusManagedUploadInput { + final String content; + final int chunkSize; + final String fingerprint; + final String uploadPath; + final GeneratedTusManagedUploadMetadata[] metadata; + + GeneratedTusManagedUploadInput( + String content, + int chunkSize, + String fingerprint, + String uploadPath, + GeneratedTusManagedUploadMetadata[] metadata) { + this.content = content; + this.chunkSize = chunkSize; + this.fingerprint = fingerprint; + this.uploadPath = uploadPath; + this.metadata = metadata; + } + } + + private static final class GeneratedTusManagedUploadAttempt { + final int attemptIndex; + final String stateAfterAttempt; + final GeneratedTusManagedUploadFailure failure; + final GeneratedTusManagedUploadRequest[] requests; + + GeneratedTusManagedUploadAttempt( + int attemptIndex, + String stateAfterAttempt, + GeneratedTusManagedUploadFailure failure, + GeneratedTusManagedUploadRequest[] requests) { + this.attemptIndex = attemptIndex; + this.stateAfterAttempt = stateAfterAttempt; + this.failure = failure; + this.requests = requests; + } + } + + private static final class GeneratedTusManagedUploadFailure { + final String phase; + final String kind; + final long afterAcceptedOffset; + + GeneratedTusManagedUploadFailure(String phase, String kind, long afterAcceptedOffset) { + this.phase = phase; + this.kind = kind; + this.afterAcceptedOffset = afterAcceptedOffset; + } + } + + private static final class GeneratedTusManagedUploadRequest { + final String method; + final String url; + final int bodySize; + final int statusCode; + final GeneratedTusManagedUploadHeader[] requestHeaders; + final GeneratedTusManagedUploadHeader[] responseHeaders; + + GeneratedTusManagedUploadRequest( + String method, + String url, + int bodySize, + int statusCode, + GeneratedTusManagedUploadHeader[] requestHeaders, + GeneratedTusManagedUploadHeader[] responseHeaders) { + this.method = method; + this.url = url; + this.bodySize = bodySize; + this.statusCode = statusCode; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + } + } + + private static final class GeneratedTusManagedUploadHeader { + final String name; + final String value; + + GeneratedTusManagedUploadHeader(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusManagedUploadMetadata { + final String name; + final String value; + + GeneratedTusManagedUploadMetadata(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusMethodOverride { + final String originalMethod; + final String method; + final String headerName; + final String headerValue; + + GeneratedTusMethodOverride( + String originalMethod, + String method, + String headerName, + String headerValue) { + this.originalMethod = originalMethod; + this.method = method; + this.headerName = headerName; + this.headerValue = headerValue; + } + } + + private static final class GeneratedTusManagedUploadUrlStore implements TusURLStore { + private final Map values = new LinkedHashMap(); + + @Override + public URL get(String fingerprint) { + return values.get(fingerprint); + } + + @Override + public void set(String fingerprint, URL url) { + values.put(fingerprint, url); + } + + @Override + public void remove(String fingerprint) { + values.remove(fingerprint); + } + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java new file mode 100644 index 00000000..6d57686e --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -0,0 +1,140 @@ +package io.tus.java.client; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Tests the generated API2 protocol contract canary. + */ +public class TestGeneratedTusProtocolContract { + + /** + * Verifies the runtime constant is sourced from the generated protocol fixture. + */ + @Test + public void testDefaultProtocolVersionMatchesRuntimeConstant() { + String generatedDefault = null; + int defaultCount = 0; + + for (GeneratedTusProtocolContract.GeneratedTusWireVersion wireVersion + : GeneratedTusProtocolContract.WIRE_VERSIONS) { + if (wireVersion.defaultVersion) { + defaultCount++; + generatedDefault = wireVersion.value; + } + } + + assertEquals(1, defaultCount); + assertEquals("1.0.0", generatedDefault); + assertEquals(generatedDefault, TusProtocol.DEFAULT_PROTOCOL_VERSION); + assertEquals(generatedDefault, TusClient.TUS_VERSION); + } + + /** + * Verifies generated request-header variants retain creation requirements. + */ + @Test + public void testCreateUploadOperationKeepsRequiredHeaders() { + GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation = + findOperation("createTusUpload"); + + assertEquals("POST", operation.method); + assertEquals("/resumable/files/", operation.path); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-defer-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, + "tus-resumable", + "upload-concat", + "upload-length"); + assertRequiredHeaderVariant( + operation.request.headerVariants, "tus-resumable", "upload-concat"); + } + + /** + * Verifies the generated high-level lifecycle feature points at raw protocol operations. + */ + @Test + public void testSingleUploadLifecycleFeatureReferencesProtocolOperations() { + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature("singleUploadLifecycle"); + + assertContains(feature.operationIds, "createTusUpload"); + assertContains(feature.operationIds, "getTusUploadOffset"); + assertContains(feature.operationIds, "patchTusUpload"); + assertContains(feature.primitives, "store-resume-url"); + assertContains(feature.primitives, "emit-progress"); + } + + private static GeneratedTusProtocolContract.GeneratedTusProtocolOperation findOperation( + String operationId) { + for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation + : GeneratedTusProtocolContract.OPERATIONS) { + if (operation.operationId.equals(operationId)) { + return operation; + } + } + + throw new AssertionError("Missing generated TUS operation: " + operationId); + } + + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( + String featureId) { + for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature + : GeneratedTusProtocolContract.CLIENT_FEATURES) { + if (feature.featureId.equals(featureId)) { + return feature; + } + } + + throw new AssertionError("Missing generated TUS client feature: " + featureId); + } + + private static boolean hasRequiredHeader( + GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant, + String headerName) { + assertNotNull(variant); + + for (GeneratedTusProtocolContract.GeneratedTusHeaderField field : variant.fields) { + if (field.required && field.name.equals(headerName)) { + return true; + } + } + + return false; + } + + private static void assertRequiredHeaderVariant( + GeneratedTusProtocolContract.GeneratedTusHeaderVariant[] variants, + String... headerNames) { + for (GeneratedTusProtocolContract.GeneratedTusHeaderVariant variant : variants) { + boolean hasAllHeaders = true; + for (String headerName : headerNames) { + if (!hasRequiredHeader(variant, headerName)) { + hasAllHeaders = false; + break; + } + } + + if (hasAllHeaders) { + return; + } + } + + throw new AssertionError("Missing generated header variant"); + } + + private static void assertContains(String[] values, String expected) { + for (String value : values) { + if (value.equals(expected)) { + return; + } + } + + throw new AssertionError("Missing generated value: " + expected); + } +} diff --git a/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java new file mode 100644 index 00000000..2dbcd7be --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -0,0 +1,749 @@ +/* + * 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; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Tests generated TUS client runtime event fixtures against the real uploader. + */ +public class TestGeneratedTusRuntimeEvents extends MockServerProvider { + private static final GeneratedTusRuntimeEventCase[] CASES = + new GeneratedTusRuntimeEventCase[] { + new GeneratedTusRuntimeEventCase( + "singleUploadLifecycle", + "exact-except-extra-progress", + false, + new GeneratedTusRuntimeBeforeStartAction[0], + new GeneratedTusRuntimeEventInput( + "hello world", + "generated-contract", + "absolute", + false, + 11, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/generated-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "resumeFromPreviousUpload", + "exact-except-extra-progress", + false, + new GeneratedTusRuntimeBeforeStartAction[] { + new GeneratedTusRuntimeBeforeStartAction( + "resume-from-previous-upload", + 1, + 0 + ), + }, + new GeneratedTusRuntimeEventInput( + "hello world", + "resume-contract", + "stored", + false, + 6, + new GeneratedTusRuntimeEventStoredUpload( + "contract-resume-fingerprint", + true + ), + new GeneratedTusRuntimeEventMetadata[0] + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "HEAD", + "upload", + 200, + new GeneratedTusRuntimeEventHeader[0], + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "relativeLocationResolution", + "exact-except-extra-progress", + false, + new GeneratedTusRuntimeBeforeStartAction[0], + new GeneratedTusRuntimeEventInput( + "hello world", + "relative-contract", + "relative", + true, + 11, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "relative-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + new GeneratedTusRuntimeEventCase( + "deferredLengthUpload", + "exact-except-extra-progress", + true, + new GeneratedTusRuntimeBeforeStartAction[0], + new GeneratedTusRuntimeEventInput( + "hello world", + "deferred-contract", + "absolute", + false, + 100, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Defer-Length", + "1" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/deferred-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "PATCH", + "upload", + 204, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + }, + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + } + ), + }; + private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = + new GeneratedTusMethodOverride[] { + new GeneratedTusMethodOverride( + "PATCH", + "POST", + "X-HTTP-Method-Override", + "PATCH" + ), + }; + + /** + * Verifies the sync uploader emits generated progress and chunk-complete events. + */ + @Test + public void testSyncUploaderEmitsGeneratedProgressAndChunkEvents() throws Exception { + for (GeneratedTusRuntimeEventCase testCase : CASES) { + mockServer.reset(); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setUploadCreationURL(endpointUrlFor(testCase)); + GeneratedTusRuntimeEventUrlStore urlStore = urlStoreFor(testCase); + if (hasResumeBeforeStartAction(testCase)) { + if (urlStore == null) { + throw new AssertionError( + testCase.scenarioId + " cannot resume without generated URL storage"); + } + client.enableResuming(urlStore); + } + if ( + testCase.input.storedUpload != null + && testCase.input.storedUpload.removeFingerprintOnSuccess) { + client.enableRemoveFingerprintOnSuccess(); + } + + registerResponses(testCase); + + TusUploader uploader = uploaderFor(client, testCase); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add("progress:" + bytesSent + ":" + bytesTotal); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add("chunk-complete:" + chunkSize + ":" + bytesAccepted + ":" + bytesTotal); + } + }); + + while (uploader.uploadChunk() > -1) { + continue; + } + uploader.finish(); + + assertEvents(testCase, events); + assertStoredUploadState(testCase, urlStore); + } + } + + private TusUploader uploaderFor(TusClient client, GeneratedTusRuntimeEventCase testCase) + throws Exception { + GeneratedTusRuntimeBeforeStartAction resumeAction = resumeBeforeStartAction(testCase); + if (resumeAction != null) { + assertStoredUploadAvailableForResume(testCase, resumeAction); + return client.resumeUpload(uploadFor(testCase)); + } + + return client.createUpload(uploadFor(testCase)); + } + + private boolean hasResumeBeforeStartAction(GeneratedTusRuntimeEventCase testCase) { + return resumeBeforeStartAction(testCase) != null; + } + + private GeneratedTusRuntimeBeforeStartAction resumeBeforeStartAction( + GeneratedTusRuntimeEventCase testCase) { + GeneratedTusRuntimeBeforeStartAction action = null; + for (GeneratedTusRuntimeBeforeStartAction candidate : testCase.beforeStartActions) { + if (!"resume-from-previous-upload".equals(candidate.kind)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated beforeStart action " + + candidate.kind); + } + + if (action != null) { + throw new AssertionError( + testCase.scenarioId + " defines more than one resume beforeStart action"); + } + + action = candidate; + } + + return action; + } + + private void assertStoredUploadAvailableForResume( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeBeforeStartAction action) { + if (testCase.input.storedUpload == null) { + throw new AssertionError( + testCase.scenarioId + " cannot resume without a generated stored upload"); + } + assertEquals(testCase.scenarioId, 0, action.selectedPreviousUploadIndex); + assertEquals(testCase.scenarioId, 1, action.expectedPreviousUploadCount); + } + + private TusUpload uploadFor(GeneratedTusRuntimeEventCase testCase) { + byte[] content = testCase.input.content.getBytes(StandardCharsets.UTF_8); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setMetadata(metadataFor(testCase.input.metadata)); + if (testCase.input.storedUpload != null) { + upload.setFingerprint(testCase.input.storedUpload.fingerprint); + } + upload.setUploadLengthDeferred(testCase.uploadLengthDeferred); + return upload; + } + + private Map metadataFor(GeneratedTusRuntimeEventMetadata[] metadata) { + Map result = new LinkedHashMap(); + for (GeneratedTusRuntimeEventMetadata entry : metadata) { + result.put(entry.name, entry.value); + } + return result; + } + + private void registerResponses(GeneratedTusRuntimeEventCase testCase) throws Exception { + for (GeneratedTusRuntimeEventRequest request : testCase.requests) { + mockServer.when(requestFor(testCase, request, request.method, null)) + .respond(responseFor(testCase, request)); + GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); + if (methodOverride != null) { + mockServer.when(requestFor(testCase, request, methodOverride.method, methodOverride)) + .respond(responseFor(testCase, request)); + } + } + } + + private HttpRequest requestFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request, + String method, + GeneratedTusMethodOverride methodOverride) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(method) + .withPath(pathFor(testCase, request)); + for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } + if (methodOverride != null) { + httpRequest.withHeader(methodOverride.headerName, methodOverride.headerValue); + } + return httpRequest; + } + + private GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { + for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { + if (methodOverride.originalMethod.equals(originalMethod)) { + return methodOverride; + } + } + + return null; + } + + private String pathFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request) throws Exception { + if ("endpoint".equals(request.url)) { + return endpointUrlFor(testCase).getPath(); + } + + return uploadUrlFor(testCase).getPath(); + } + + private HttpResponse responseFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request) throws Exception { + HttpResponse response = new HttpResponse().withStatusCode(request.statusCode); + for (GeneratedTusRuntimeEventHeader header : request.responseHeaders) { + response.withHeader(header.name, headerValueFor(testCase, header)); + } + return response; + } + + private String headerValueFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventHeader header) throws Exception { + if (!"Location".equals(header.name)) { + return header.value; + } + + if ("relative".equals(testCase.input.locationHeaderKind)) { + return testCase.input.uploadPath; + } + + return uploadUrlFor(testCase).toString(); + } + + private URL uploadUrlFor(GeneratedTusRuntimeEventCase testCase) throws Exception { + return new URL(mockServerURL.toString() + "/" + testCase.input.uploadPath); + } + + private URL endpointUrlFor(GeneratedTusRuntimeEventCase testCase) throws Exception { + if (testCase.input.endpointHasTrailingSlash) { + return new URL(mockServerURL.toString() + "/"); + } + + return mockServerURL; + } + + private GeneratedTusRuntimeEventUrlStore urlStoreFor( + GeneratedTusRuntimeEventCase testCase) throws Exception { + if (testCase.input.storedUpload == null) { + return null; + } + + GeneratedTusRuntimeEventUrlStore store = new GeneratedTusRuntimeEventUrlStore(); + store.set(testCase.input.storedUpload.fingerprint, uploadUrlFor(testCase)); + return store; + } + + private void assertStoredUploadState( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventUrlStore urlStore) { + if (urlStore == null) { + return; + } + + URL storedUrl = urlStore.get(testCase.input.storedUpload.fingerprint); + if (testCase.input.storedUpload.removeFingerprintOnSuccess) { + assertNull(testCase.scenarioId, storedUrl); + return; + } + + assertEquals(testCase.scenarioId, uploadUrlForUnchecked(testCase), storedUrl); + } + + private URL uploadUrlForUnchecked(GeneratedTusRuntimeEventCase testCase) { + try { + return uploadUrlFor(testCase); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private void assertEvents(GeneratedTusRuntimeEventCase testCase, List events) { + if ("exact".equals(testCase.eventPolicyMatching)) { + assertArrayEquals( + testCase.scenarioId, + testCase.eventKeys, + events.toArray(new String[events.size()])); + return; + } + + if ("exact-except-extra-progress".equals(testCase.eventPolicyMatching)) { + assertEventsExactExceptExtraProgress(testCase, events); + return; + } + + throw new AssertionError( + "Unsupported generated event policy " + + testCase.eventPolicyMatching + + " for " + + testCase.scenarioId); + } + + private void assertEventsExactExceptExtraProgress( + GeneratedTusRuntimeEventCase testCase, + List events) { + int expectedIndex = 0; + for (String event : events) { + if ( + expectedIndex < testCase.eventKeys.length + && event.equals(testCase.eventKeys[expectedIndex])) { + expectedIndex += 1; + continue; + } + + if (event.startsWith("progress:")) { + continue; + } + + throw new AssertionError( + testCase.scenarioId + + " emitted unexpected non-progress event " + + event + + "; expected " + + java.util.Arrays.toString(testCase.eventKeys)); + } + + if (expectedIndex == testCase.eventKeys.length) { + return; + } + + throw new AssertionError( + testCase.scenarioId + + " did not emit every expected non-extra event; observed " + + events + + "; expected " + + java.util.Arrays.toString(testCase.eventKeys)); + } + + private static final class GeneratedTusRuntimeEventCase { + final String scenarioId; + final String eventPolicyMatching; + final boolean uploadLengthDeferred; + final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; + final GeneratedTusRuntimeEventInput input; + final GeneratedTusRuntimeEventRequest[] requests; + final String[] eventKeys; + + GeneratedTusRuntimeEventCase( + String scenarioId, + String eventPolicyMatching, + boolean uploadLengthDeferred, + GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, + GeneratedTusRuntimeEventInput input, + GeneratedTusRuntimeEventRequest[] requests, + String[] eventKeys) { + this.scenarioId = scenarioId; + this.eventPolicyMatching = eventPolicyMatching; + this.uploadLengthDeferred = uploadLengthDeferred; + this.beforeStartActions = beforeStartActions; + this.input = input; + this.requests = requests; + this.eventKeys = eventKeys; + } + } + + private static final class GeneratedTusRuntimeBeforeStartAction { + final String kind; + final int expectedPreviousUploadCount; + final int selectedPreviousUploadIndex; + + GeneratedTusRuntimeBeforeStartAction( + String kind, + int expectedPreviousUploadCount, + int selectedPreviousUploadIndex) { + this.kind = kind; + this.expectedPreviousUploadCount = expectedPreviousUploadCount; + this.selectedPreviousUploadIndex = selectedPreviousUploadIndex; + } + } + + private static final class GeneratedTusRuntimeEventInput { + final String content; + final String uploadPath; + final String locationHeaderKind; + final boolean endpointHasTrailingSlash; + final int chunkSize; + final GeneratedTusRuntimeEventStoredUpload storedUpload; + final GeneratedTusRuntimeEventMetadata[] metadata; + + GeneratedTusRuntimeEventInput( + String content, + String uploadPath, + String locationHeaderKind, + boolean endpointHasTrailingSlash, + int chunkSize, + GeneratedTusRuntimeEventStoredUpload storedUpload, + GeneratedTusRuntimeEventMetadata[] metadata) { + this.content = content; + this.uploadPath = uploadPath; + this.locationHeaderKind = locationHeaderKind; + this.endpointHasTrailingSlash = endpointHasTrailingSlash; + this.chunkSize = chunkSize; + this.storedUpload = storedUpload; + this.metadata = metadata; + } + } + + private static final class GeneratedTusRuntimeEventStoredUpload { + final String fingerprint; + final boolean removeFingerprintOnSuccess; + + GeneratedTusRuntimeEventStoredUpload( + String fingerprint, + boolean removeFingerprintOnSuccess) { + this.fingerprint = fingerprint; + this.removeFingerprintOnSuccess = removeFingerprintOnSuccess; + } + } + + private static final class GeneratedTusRuntimeEventRequest { + final String method; + final String url; + final int statusCode; + final GeneratedTusRuntimeEventHeader[] requestHeaders; + final GeneratedTusRuntimeEventHeader[] responseHeaders; + + GeneratedTusRuntimeEventRequest( + String method, + String url, + int statusCode, + GeneratedTusRuntimeEventHeader[] requestHeaders, + GeneratedTusRuntimeEventHeader[] responseHeaders) { + this.method = method; + this.url = url; + this.statusCode = statusCode; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + } + } + + private static final class GeneratedTusRuntimeEventHeader { + final String name; + final String value; + + GeneratedTusRuntimeEventHeader(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusRuntimeEventMetadata { + final String name; + final String value; + + GeneratedTusRuntimeEventMetadata(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusMethodOverride { + final String originalMethod; + final String method; + final String headerName; + final String headerValue; + + GeneratedTusMethodOverride( + String originalMethod, + String method, + String headerName, + String headerValue) { + this.originalMethod = originalMethod; + this.method = method; + this.headerName = headerName; + this.headerValue = headerValue; + } + } + + private static final class GeneratedTusRuntimeEventUrlStore implements TusURLStore { + private final Map values = new LinkedHashMap(); + + @Override + public URL get(String fingerprint) { + return values.get(fingerprint); + } + + @Override + public void set(String fingerprint, URL url) { + values.put(fingerprint, url); + } + + @Override + public void remove(String fingerprint) { + values.remove(fingerprint); + } + } +} diff --git a/src/test/java/io/tus/java/client/TestTusClient.java b/src/test/java/io/tus/java/client/TestTusClient.java index 9bd1ec5a..33211f2b 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -107,6 +107,40 @@ public void testCreateUpload() throws IOException, ProtocolException { assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); } + + /** + * Verifies if uploads can be created with deferred upload length. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadWithDeferredLength() throws IOException, ProtocolException { + mockServer.when(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Defer-Length", "1")) + .respond(new HttpResponse() + .withStatusCode(201) + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Location", mockServerURL + "/foo")); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setUploadLengthDeferred(true); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + TusUploader uploader = client.createUpload(upload); + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files")); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals(1, requests.length); + assertFalse(requests[0].containsHeader("Upload-Length")); + } + /** * Verifies if uploads can be created with the tus client through a proxy. * @throws IOException if upload data cannot be read. diff --git a/src/test/java/io/tus/java/client/TestTusUploader.java b/src/test/java/io/tus/java/client/TestTusUploader.java index b81e2b75..b1a7b70c 100644 --- a/src/test/java/io/tus/java/client/TestTusUploader.java +++ b/src/test/java/io/tus/java/client/TestTusUploader.java @@ -74,6 +74,42 @@ public void testTusUploader() throws IOException, ProtocolException { uploader.finish(); } + /** + * Tests if deferred-length uploads declare the upload length on the first PATCH request. + * @throws IOException + * @throws ProtocolException + */ + @Test + public void testTusUploaderDeclaresDeferredLength() throws IOException, ProtocolException { + byte[] content = "hello world".getBytes(); + + mockServer.when(new HttpRequest() + .withPath("/files/deferred") + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Length", "11") + .withHeader("Upload-Offset", "0") + .withHeader("Content-Type", "application/offset+octet-stream") + .withBody(content)) + .respond(new HttpResponse() + .withStatusCode(204) + .withHeader("Tus-Resumable", TusClient.TUS_VERSION) + .withHeader("Upload-Offset", "11")); + + TusClient client = new TusClient(); + URL uploadUrl = new URL(mockServerURL + "/deferred"); + TusInputStream input = new TusInputStream(new ByteArrayInputStream(content)); + TusUpload upload = new TusUpload(); + upload.setSize(11); + upload.setUploadLengthDeferred(true); + + TusUploader uploader = new TusUploader(client, upload, uploadUrl, input, 0); + + uploader.setChunkSize(100); + assertEquals(11, uploader.uploadChunk()); + assertEquals(-1, uploader.uploadChunk()); + uploader.finish(); + } + /** * Tests if the {@link TusUploader} actually uploads files through a proxy. * @throws IOException