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/build.gradle b/build.gradle index 5a6f44d4..9c0a8ef4 100644 --- a/build.gradle +++ b/build.gradle @@ -14,10 +14,12 @@ allprojects { } } -// We compile the library using Java 1.7 compatibility +// We compile the library using Java 8 compatibility // in order to ensure interoperability with older Android platforms. -sourceCompatibility = 1.8 -targetCompatibility = 1.8 +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} // load version number from file def config = new ConfigSlurper().parse(new File("${projectDir}/src/main/resources/tus-java-client-version/version.properties").toURI().toURL()) diff --git a/example/build.gradle b/example/build.gradle index aeeb70b7..22fce4ff 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -2,5 +2,132 @@ 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 +} + +tasks.register('api2DevdockTusResumeUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusResumeUpload' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusFileUrlStorageBackend', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusFileUrlStorageBackend' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusCreationWithUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusCreationWithUpload' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusDeferredLengthUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusDeferredLengthUpload' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusRetryStateTransitions', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRetryStateTransitions' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusDetailedError', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusDetailedError' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusAbortUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusAbortUpload' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusStartOptionValidation', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusStartOptionValidation' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusParallelUploadConcat', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusParallelUploadConcat' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusProtocolVersionSelection', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusProtocolVersionSelection' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusNodePathInputSource', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusNodePathInputSource' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusUploadBodyHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusUploadBodyHeaders' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusCustomRequestHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusCustomRequestHeaders' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusRequestIdHeaders', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRequestIdHeaders' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusTerminateUpload', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusTerminateUpload' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusUploadCallbacks', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusUploadCallbacks' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusRelativeLocationResolution', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusRelativeLocationResolution' + workingDir = rootProject.projectDir +} + +tasks.register('api2DevdockTusOverridePatchMethod', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.tus.java.example.Api2DevdockTusOverridePatchMethod' + workingDir = rootProject.projectDir +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java new file mode 100644 index 00000000..0c85029e --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockScenario.java @@ -0,0 +1,509 @@ +package io.tus.java.example; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class Api2DevdockScenario { + static final class UploadCallbackEventKinds { + final String chunkComplete; + final String progress; + final String sourceClose; + final String success; + final String uploadUrlAvailable; + + UploadCallbackEventKinds(JSONObject eventKinds) { + chunkComplete = eventKinds.getString("chunkComplete"); + progress = eventKinds.getString("progress"); + sourceClose = eventKinds.getString("sourceClose"); + success = eventKinds.getString("success"); + uploadUrlAvailable = eventKinds.getString("uploadUrlAvailable"); + } + } + + static final class UploadCallbacksPlan { + final List allowedExtraEventKeyPrefixes; + final List> eventKeyAlternativeGroups; + final UploadCallbackEventKinds eventKinds; + final String eventKeyPartSeparator; + final List eventKeys; + final String eventPolicyMatching; + + UploadCallbacksPlan(JSONObject uploadCallbacks) { + allowedExtraEventKeyPrefixes = stringList( + uploadCallbacks.getJSONArray("allowedExtraEventKeyPrefixes") + ); + eventKeyAlternativeGroups = stringListList( + uploadCallbacks.getJSONArray("eventKeyAlternativeGroups") + ); + eventKinds = new UploadCallbackEventKinds(uploadCallbacks.getJSONObject("eventKinds")); + eventKeyPartSeparator = uploadCallbacks.getString("eventKeyPartSeparator"); + eventKeys = stringList(uploadCallbacks.getJSONArray("eventKeys")); + eventPolicyMatching = uploadCallbacks.getString("eventPolicyMatching"); + } + } + + static final class TerminationPlan { + final int expectedVerificationStatus; + final String method; + final int minimumDeleteRequestCount; + final int stopAfterAcceptedBytes; + final String verificationMethod; + + TerminationPlan(JSONObject termination) { + expectedVerificationStatus = termination.getInt("expectedVerificationStatus"); + method = termination.getString("method"); + minimumDeleteRequestCount = termination.getInt("minimumDeleteRequestCount"); + stopAfterAcceptedBytes = termination.getInt("stopAfterAcceptedBytes"); + verificationMethod = termination.getString("verificationMethod"); + } + } + + 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)); + } + + static void writeResult(JSONObject result) throws IOException { + final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) + ); + } + + static JSONObject createResponse(JSONObject scenario) { + return scenario.getJSONObject("prepared").getJSONObject("createResponse"); + } + + static JSONObject conformanceScenario(JSONObject scenario) { + return scenario.getJSONObject("conformanceScenario"); + } + + static byte[] conformanceInputSourceBytes(JSONObject conformanceScenario) { + final JSONObject inputSource = conformanceScenario.getJSONObject("inputSource"); + if (!inputSource.has("content")) { + throw new IllegalArgumentException( + "unsupported conformance input source kind " + + inputSource.getString("kind") + + " without content" + ); + } + + return inputSource.getString("content").getBytes(StandardCharsets.UTF_8); + } + + static String conformanceInputSourceKind(JSONObject conformanceScenario) { + return conformanceScenario.getJSONObject("inputSource").getString("kind"); + } + + static Map conformanceInputStringMapOption( + JSONObject conformanceScenario, + String key + ) { + final JSONObject values = conformanceInputJSONObjectOption(conformanceScenario, key); + return scalarStringMap(values); + } + + static Map conformanceInputStringMapOptionOrEmpty( + JSONObject conformanceScenario, + String key + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return new LinkedHashMap(); + } + if (!(value instanceof JSONObject)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not an object" + ); + } + + return scalarStringMap((JSONObject) value); + } + + private static Map scalarStringMap(JSONObject values) { + final Map result = new LinkedHashMap(); + for (String name : values.keySet()) { + result.put(name, scalarString(values.get(name))); + } + + return result; + } + + static String conformanceInputStringOption(JSONObject conformanceScenario, String key) { + return scalarString(conformanceInputOption(conformanceScenario, key)); + } + + static boolean conformanceInputBooleanOption( + JSONObject conformanceScenario, + String key, + boolean defaultValue + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return defaultValue; + } + + if (!(value instanceof Boolean)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not a boolean" + ); + } + + return ((Boolean) value).booleanValue(); + } + + static int conformanceInputIntegerOption( + JSONObject conformanceScenario, + String key, + int defaultValue + ) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return defaultValue; + } + + if (!(value instanceof Number)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not a number" + ); + } + + return ((Number) value).intValue(); + } + + static String conformanceInputStringOptionOrNull(JSONObject conformanceScenario, String key) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value == null || JSONObject.NULL.equals(value)) { + return null; + } + + return scalarString(value); + } + + 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 Object conformanceInputOption(JSONObject conformanceScenario, String key) { + final Object value = conformanceInputOptionOrNull(conformanceScenario, key); + if (value != null) { + return value; + } + + throw new IllegalArgumentException("missing conformance input option " + key); + } + + private static Object conformanceInputOptionOrNull(JSONObject conformanceScenario, String key) { + final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries"); + for (int index = 0; index < entries.length(); index++) { + final JSONObject entry = entries.getJSONObject(index); + if (key.equals(entry.getString("key"))) { + return entry.get("value"); + } + } + + return null; + } + + private static JSONObject conformanceInputJSONObjectOption( + JSONObject conformanceScenario, + String key + ) { + final Object value = conformanceInputOption(conformanceScenario, key); + if (!(value instanceof JSONObject)) { + throw new IllegalArgumentException( + "conformance input option " + key + " is not an object" + ); + } + + return (JSONObject) value; + } + + static int fixedChunkSizeBytes(JSONObject uploadConfig) { + final JSONObject chunkSize = uploadConfig.getJSONObject("chunkSize"); + final String kind = chunkSize.getString("kind"); + if (!"fixed-bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported chunk size policy " + kind); + } + + return chunkSize.getInt("bytes"); + } + + static void requireFullFileChunkSize(JSONObject uploadConfig) { + final Object chunkSize = uploadConfig.get("chunkSize"); + if (!"full-file".equals(chunkSize)) { + throw new IllegalArgumentException("unsupported chunk size policy " + chunkSize); + } + } + + static String tusUrl( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + return scalarString(resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse)); + } + + 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; + } + + static Map uploadHeaders(JSONObject uploadConfig) { + return stringMap(uploadConfig.getJSONObject("headers")); + } + + static Map> uploadBodyHeadersByMethod(JSONObject uploadConfig) { + final JSONObject bodyHeadersByMethod = uploadConfig.getJSONObject("bodyHeadersByMethod"); + final Map> result = + new LinkedHashMap>(); + for (String method : bodyHeadersByMethod.keySet()) { + result.put(method, stringMap(bodyHeadersByMethod.getJSONObject(method))); + } + + return result; + } + + static boolean uploadAddRequestId(JSONObject uploadConfig) { + return uploadConfig.getBoolean("addRequestId"); + } + + static String uploadRequestIdHeaderName(JSONObject uploadConfig) { + return uploadConfig.getString("requestIdHeaderName"); + } + + static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) { + return new UploadCallbacksPlan( + scenario.getJSONObject("upload").getJSONObject("uploadCallbacks") + ); + } + + static TerminationPlan termination(JSONObject uploadConfig) { + return new TerminationPlan(uploadConfig.getJSONObject("termination")); + } + + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { + final StringBuilder key = new StringBuilder(); + for (int index = 0; index < parts.length; index++) { + if (index > 0) { + key.append(plan.eventKeyPartSeparator); + } + key.append(parts[index]); + } + + return key.toString(); + } + + static String uploadCallbackEventKeyNumber(long value) { + return Long.toString(value); + } + + static List matchUploadCallbackEventKeys( + UploadCallbacksPlan plan, + List actual + ) { + if (!"exact".equals(plan.eventPolicyMatching) + && !"exact-except-allowed-extra-events".equals(plan.eventPolicyMatching)) { + throw new IllegalArgumentException( + "unsupported upload callback event policy " + plan.eventPolicyMatching + ); + } + + final List matched = new ArrayList(); + int expectedIndex = 0; + for (String event : actual) { + if (expectedIndex < plan.eventKeys.size() + && uploadCallbackEventMatchesExpected(plan, expectedIndex, event)) { + matched.add(plan.eventKeys.get(expectedIndex)); + expectedIndex += 1; + continue; + } + + if ("exact-except-allowed-extra-events".equals(plan.eventPolicyMatching) + && hasAllowedUploadCallbackExtraEventPrefix(plan, event)) { + continue; + } + + throw new IllegalStateException( + "unexpected upload callback event " + + event + + " at expected index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + if (expectedIndex != plan.eventKeys.size()) { + throw new IllegalStateException( + "missing upload callback events after index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + return matched; + } + + 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 static List stringList(JSONArray values) { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(values.getString(index)); + } + + return result; + } + + private static Map stringMap(JSONObject values) { + final Map result = new LinkedHashMap(); + for (String key : values.keySet()) { + result.put(key, values.getString(key)); + } + + return result; + } + + private static List> stringListList(JSONArray values) { + final List> result = new ArrayList>(); + for (int index = 0; index < values.length(); index++) { + result.add(stringList(values.getJSONArray(index))); + } + + return result; + } + + private static boolean uploadCallbackEventMatchesExpected( + UploadCallbacksPlan plan, + int expectedIndex, + String event + ) { + if (plan.eventKeys.get(expectedIndex).equals(event)) { + return true; + } + + final List alternatives = plan.eventKeyAlternativeGroups.get(expectedIndex); + for (String alternative : alternatives) { + if (alternative.equals(event)) { + return true; + } + } + + return false; + } + + private static boolean hasAllowedUploadCallbackExtraEventPrefix( + UploadCallbacksPlan plan, + String event + ) { + for (String prefix : plan.allowedExtraEventKeyPrefixes) { + if (event.startsWith(prefix)) { + return true; + } + } + + return false; + } + + private Api2DevdockScenario() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java new file mode 100644 index 00000000..6ec814f8 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusAbortUpload.java @@ -0,0 +1,208 @@ +package io.tus.java.example; + +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.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public final class Api2DevdockTusAbortUpload { + /** + * Run the API2 devdock TUS abort-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadAndAbort(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " aborted the upload" + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadAndAbort(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final Map headers = Api2DevdockScenario.conformanceInputStringMapOptionOrEmpty( + conformanceScenario, + "headers" + ); + final boolean terminateUploadOnAbort = conformanceScenario + .getJSONObject("runtimeSetup") + .getJSONObject("abort") + .getBoolean("terminateUpload"); + final String fingerprint = runtimeFingerprint(conformanceScenario, scenario); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + + final TusClient client = new TusClient(); + final TusURLMemoryStore urlStore = new TusURLMemoryStore(); + final List events = new ArrayList(); + final AtomicReference activeUploader = new AtomicReference(); + final AtomicBoolean aborted = new AtomicBoolean(false); + final AtomicBoolean successCalled = new AtomicBoolean(false); + final AtomicReference uploadError = new AtomicReference(); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer( + conformanceScenario, + endpointOrigin, + new Api2DevdockTusConformanceServer.RequestAbortHandler() { + @Override + public void abortRequest( + Api2DevdockTusConformanceServer.RequestAbortContext context + ) throws Exception { + aborted.set(true); + events.add(new JSONObject() + .put("kind", "request-abort") + .put("method", context.method()) + .put("requestIndex", context.requestIndex()) + .put("url", context.url())); + + final TusUploader uploader = activeUploader.get(); + if (uploader == null) { + if (terminateUploadOnAbort) { + throw new IllegalStateException( + "abort scenario requested termination " + + "before uploader was available" + ); + } + client.abortUpload(); + return; + } + + client.abortUpload(uploader, false); + } + })) { + client.setUploadCreationURL(conformanceServer.endpointUrl()); + client.enableResuming(urlStore); + client.setHeaders(headers); + + final TusUpload upload = uploadFor(content, fingerprint, metadata); + final Thread uploadThread = new Thread(new Runnable() { + @Override + public void run() { + try { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + activeUploader.set(uploader); + uploader.setChunkSize(content.length); + uploader.setRequestPayloadSize(content.length); + int uploadProgress = uploader.uploadChunk(); + while (uploadProgress > -1) { + uploadProgress = uploader.uploadChunk(); + } + uploader.finish(); + successCalled.set(true); + } catch (Exception error) { + if (!aborted.get()) { + uploadError.set(error); + } + } + } + }); + uploadThread.start(); + uploadThread.join(5000); + if (uploadThread.isAlive()) { + uploadThread.interrupt(); + throw new IllegalStateException("timed out waiting for abort upload thread"); + } + if (uploadError.get() != null) { + throw uploadError.get(); + } + if (!aborted.get()) { + throw new IllegalStateException("abort scenario completed without aborting"); + } + if (terminateUploadOnAbort) { + final TusUploader uploader = activeUploader.get(); + if (uploader == null) { + throw new IllegalStateException( + "abort scenario requested termination before uploader was available" + ); + } + client.abortUpload(uploader, true); + } + + conformanceServer.assertExhausted(); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("events", new JSONArray(events)); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", successCalled.get()); + result.put("uploadUrl", uploadUrlResult(conformanceServer, activeUploader.get())); + + return result; + } + } + + private static TusUpload uploadFor( + byte[] content, + String fingerprint, + Map metadata + ) { + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata(metadata); + return upload; + } + + private static Object uploadUrlResult( + Api2DevdockTusConformanceServer conformanceServer, + TusUploader uploader + ) { + if (uploader == null || uploader.getUploadURL() == null) { + return JSONObject.NULL; + } + + return conformanceServer.canonicalUrl(uploader.getUploadURL().toString()); + } + + private static String runtimeFingerprint(JSONObject conformanceScenario, JSONObject scenario) { + final JSONObject runtimeSetup = conformanceScenario.getJSONObject("runtimeSetup"); + if (!runtimeSetup.has("fingerprint")) { + return scenario.getString("scenarioId") + "-java-abort-upload"; + } + + final JSONObject fingerprint = runtimeSetup.getJSONObject("fingerprint"); + if (!fingerprint.optBoolean("install", false)) { + return scenario.getString("scenarioId") + "-java-abort-upload"; + } + + return fingerprint.getString("value"); + } + + private Api2DevdockTusAbortUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java new file mode 100644 index 00000000..cf4b4adf --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusConformanceServer.java @@ -0,0 +1,674 @@ +package io.tus.java.example; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +final class Api2DevdockTusConformanceServer implements AutoCloseable { + interface RequestAbortHandler { + void abortRequest(RequestAbortContext context) throws Exception; + } + + static final class RequestAbortContext { + private final int requestIndex; + private final String method; + private final String url; + + RequestAbortContext(int requestIndex, String method, String url) { + this.requestIndex = requestIndex; + this.method = method; + this.url = url; + } + + int requestIndex() { + return requestIndex; + } + + String method() { + return method; + } + + String url() { + return url; + } + } + + private final URL endpointOrigin; + private final byte[] inputSourceContent; + private final List requests; + private final boolean[] observedRequests; + private final List requestGates; + private final HttpServer server; + private final ExecutorService executor; + private final List errors; + private final JSONObject[] absentHeaderPresence; + private final Integer[] requestBodySizes; + private final Integer[] requestBodyStarts; + private final JSONObject[] requestHeaders; + private final String[] requestMethods; + private final String[] requestUrls; + private final RequestAbortHandler requestAbortHandler; + private int nextSequentialRequestIndex; + private int observedRequestCount; + + Api2DevdockTusConformanceServer(JSONObject conformanceScenario, URL endpointOrigin) + throws IOException { + this(conformanceScenario, endpointOrigin, null); + } + + Api2DevdockTusConformanceServer( + JSONObject conformanceScenario, + URL endpointOrigin, + RequestAbortHandler requestAbortHandler + ) + throws IOException { + this.endpointOrigin = endpointOrigin; + this.requestAbortHandler = requestAbortHandler; + this.inputSourceContent = inputSourceContent(conformanceScenario); + this.requests = new ArrayList(); + final JSONArray requestArray = conformanceScenario.getJSONArray("requests"); + for (int index = 0; index < requestArray.length(); index++) { + requests.add(requestArray.getJSONObject(index)); + } + this.observedRequests = new boolean[requests.size()]; + this.requestGates = requestGates(conformanceScenario); + this.errors = new ArrayList(); + this.absentHeaderPresence = new JSONObject[requests.size()]; + this.requestBodySizes = new Integer[requests.size()]; + this.requestBodyStarts = new Integer[requests.size()]; + this.requestHeaders = new JSONObject[requests.size()]; + this.requestMethods = new String[requests.size()]; + this.requestUrls = new String[requests.size()]; + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + this.executor = Executors.newCachedThreadPool(); + server.createContext("/", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + handleRequest(exchange); + } + }); + server.setExecutor(executor); + server.start(); + } + + URL endpointUrl() throws IOException { + return localUrl(endpointOrigin.toString()); + } + + URL localUrlFor(String canonicalUrl) throws IOException { + return localUrl(canonicalUrl); + } + + void assertExhausted() { + assertNoErrors(); + if (observedRequestCount == requests.size()) { + return; + } + + throw new IllegalStateException( + "expected " + + requests.size() + + " conformance request(s), got " + + observedRequestCount + + "; observed methods " + + observedStrings(requestMethods) + + "; observed URLs " + + observedStrings(requestUrls) + ); + } + + void assertNoErrors() { + if (errors.isEmpty()) { + return; + } + + throw new IllegalStateException(errorSummary()); + } + + String canonicalUrl(String actualUrl) { + return canonicalValue(actualUrl); + } + + String errorSummary() { + if (errors.isEmpty()) { + return "no conformance server errors"; + } + + return String.join("; ", errors); + } + + JSONObject result() { + return new JSONObject() + .put("absentHeaderPresence", observedObjects(absentHeaderPresence)) + .put("requestBodySizes", observedIntegers(requestBodySizes)) + .put("requestBodyStarts", observedIntegers(requestBodyStarts)) + .put("requestHeaders", observedObjects(requestHeaders)) + .put("requestMethods", observedStrings(requestMethods)) + .put("requestUrls", observedStrings(requestUrls)); + } + + @Override + public void close() { + server.stop(0); + executor.shutdownNow(); + } + + private static List requestGates(JSONObject conformanceScenario) { + final List result = new ArrayList(); + final JSONObject execution = conformanceScenario.optJSONObject("execution"); + if (execution == null) { + return result; + } + + final JSONArray gates = execution.optJSONArray("serverRequestGates"); + if (gates == null) { + return result; + } + + for (int index = 0; index < gates.length(); index++) { + result.add(new RequestGate(gates.getJSONObject(index))); + } + + return result; + } + + private JSONArray observedIntegers(Integer[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index] == null ? JSONObject.NULL : values[index]); + } + } + + return result; + } + + private static byte[] inputSourceContent(JSONObject conformanceScenario) { + final JSONObject inputSource = conformanceScenario.optJSONObject("inputSource"); + if (inputSource == null || !inputSource.has("content")) { + return null; + } + + return inputSource.getString("content").getBytes(StandardCharsets.UTF_8); + } + + private void awaitRequestGate(int requestIndex) throws InterruptedException { + for (RequestGate gate : requestGates) { + gate.awaitIfHeld(requestIndex); + } + } + + private JSONArray observedStrings(String[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index]); + } + } + + return result; + } + + private JSONArray observedObjects(JSONObject[] values) { + final JSONArray result = new JSONArray(); + for (int index = 0; index < values.length; index++) { + if (observedRequests[index]) { + result.put(values[index]); + } + } + + return result; + } + + private void handleRequest(HttpExchange exchange) throws IOException { + try { + final byte[] body = readRequestBody(exchange); + final int requestIndex = observeRequest(exchange, body); + if (requests.get(requestIndex).optBoolean("abort", false)) { + abortRequest(exchange, requestIndex); + return; + } + awaitRequestGate(requestIndex); + writeResponse(exchange, requests.get(requestIndex)); + } catch (Exception error) { + errors.add(error.getMessage()); + final byte[] body = errorSummary().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(500, body.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(body); + } + } + } + + private void abortRequest(HttpExchange exchange, int requestIndex) throws Exception { + if (requestAbortHandler == null) { + throw new IllegalStateException("request " + requestIndex + " expected abort handler"); + } + + requestAbortHandler.abortRequest(new RequestAbortContext( + requestIndex, + requestMethods[requestIndex], + requestUrls[requestIndex] + )); + exchange.close(); + } + + private synchronized int observeRequest(HttpExchange exchange, byte[] body) throws IOException { + if (observedRequestCount >= requests.size()) { + throw new IllegalStateException( + "unexpected request " + + exchange.getRequestMethod() + + " " + + exchange.getRequestURI() + ); + } + + final String actualUrl = canonicalRequestUrl(exchange.getRequestURI()); + final int requestIndex = matchingRequestIndex(exchange, actualUrl, body); + if (requestIndex < 0) { + throw new IllegalStateException( + "unexpected request " + + exchange.getRequestMethod() + + " " + + actualUrl + + "; next planned request " + + nextSequentialRequestIndex + ); + } + final JSONObject requestPlan = requests.get(requestIndex); + assertRequestMatchesPlan(requestIndex, requestPlan, exchange, actualUrl, body); + assertRequestBodyContent(requestIndex, requestPlan, body); + assertAbsentHeaders(requestIndex, requestPlan, exchange.getRequestHeaders()); + final JSONObject expectedHeaders = requestPlan.getJSONObject("effectiveHeaders"); + assertHeaders(requestIndex, expectedHeaders, exchange.getRequestHeaders()); + + absentHeaderPresence[requestIndex] = capturedAbsentHeaderPresence( + requestPlan, + exchange.getRequestHeaders() + ); + requestBodySizes[requestIndex] = requestPlan.isNull("bodySize") + ? null + : Integer.valueOf(body.length); + requestBodyStarts[requestIndex] = requestPlan.has("bodyStart") && !requestPlan.isNull("bodyStart") + ? Integer.valueOf(requestPlan.getInt("bodyStart")) + : null; + requestMethods[requestIndex] = exchange.getRequestMethod(); + requestUrls[requestIndex] = actualUrl; + requestHeaders[requestIndex] = capturedHeaders(expectedHeaders, exchange.getRequestHeaders()); + observedRequests[requestIndex] = true; + observedRequestCount += 1; + while ( + nextSequentialRequestIndex < observedRequests.length + && observedRequests[nextSequentialRequestIndex] + ) { + nextSequentialRequestIndex += 1; + } + + return requestIndex; + } + + private int matchingRequestIndex( + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + if (requestMatchesPlan(nextSequentialRequestIndex, exchange, actualUrl, body)) { + return nextSequentialRequestIndex; + } + + for (RequestGate gate : requestGates) { + final int requestIndex = gate.matchingHeldRequestIndex( + observedRequests, + requests, + exchange, + actualUrl, + body + ); + if (requestIndex >= 0) { + return requestIndex; + } + } + + return -1; + } + + private boolean requestMatchesPlan( + int requestIndex, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + if (requestIndex < 0 || requestIndex >= requests.size() || observedRequests[requestIndex]) { + return false; + } + + final JSONObject requestPlan = requests.get(requestIndex); + return requestPlan.getString("effectiveMethod").equals(exchange.getRequestMethod()) + && requestPlan.getString("expectedUrl").equals(actualUrl) + && (requestPlan.isNull("bodySize") || body.length == requestPlan.getInt("bodySize")); + } + + private void assertRequestMatchesPlan( + int requestIndex, + JSONObject requestPlan, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + final String expectedUrl = requestPlan.getString("expectedUrl"); + final String expectedMethod = requestPlan.getString("effectiveMethod"); + if (!expectedMethod.equals(exchange.getRequestMethod())) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected method " + + expectedMethod + + ", got " + + exchange.getRequestMethod() + ); + } + if (!expectedUrl.equals(actualUrl)) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected URL " + + expectedUrl + + ", got " + + actualUrl + ); + } + if (!requestPlan.isNull("bodySize") && body.length != requestPlan.getInt("bodySize")) { + throw new IllegalStateException( + "request " + + requestIndex + + " expected body size " + + requestPlan.getInt("bodySize") + + ", got " + + body.length + ); + } + } + + private void assertRequestBodyContent(int requestIndex, JSONObject requestPlan, byte[] body) { + if (!requestPlan.has("bodyStart") || requestPlan.isNull("bodyStart") || inputSourceContent == null) { + return; + } + + final int bodyStart = requestPlan.getInt("bodyStart"); + if (bodyStart + body.length > inputSourceContent.length) { + throw new IllegalStateException( + "request " + + requestIndex + + " body range " + + bodyStart + + ".." + + (bodyStart + body.length) + + " exceeds input source length " + + inputSourceContent.length + ); + } + for (int index = 0; index < body.length; index++) { + final byte expected = inputSourceContent[bodyStart + index]; + if (body[index] == expected) { + continue; + } + + throw new IllegalStateException( + "request " + + requestIndex + + " body byte " + + index + + " expected " + + expected + + ", got " + + body[index] + ); + } + } + + private void assertAbsentHeaders(int requestIndex, JSONObject requestPlan, Headers actualHeaders) { + final JSONArray absentHeaders = requestPlan.getJSONArray("absentHeaders"); + for (int index = 0; index < absentHeaders.length(); index++) { + final String name = absentHeaders.getString(index); + if (actualHeaders.getFirst(name) == null) { + continue; + } + + throw new IllegalStateException( + "request " + requestIndex + " expected header " + name + " to be absent" + ); + } + } + + private void assertHeaders(int requestIndex, JSONObject expectedHeaders, Headers actualHeaders) { + for (String name : expectedHeaders.keySet()) { + final String expectedValue = localValue(expectedHeaders.getString(name)); + final String actualValue = actualHeaders.getFirst(name); + if (expectedValue.equals(actualValue)) { + continue; + } + + throw new IllegalStateException( + "request " + + requestIndex + + " expected header " + + name + + "=" + + expectedValue + + ", got " + + actualValue + ); + } + } + + private JSONObject capturedHeaders(JSONObject expectedHeaders, Headers actualHeaders) { + final JSONObject result = new JSONObject(); + for (String name : expectedHeaders.keySet()) { + final String value = actualHeaders.getFirst(name); + if (value != null) { + result.put(name, canonicalValue(value)); + } + } + + return result; + } + + private JSONObject capturedAbsentHeaderPresence( + JSONObject requestPlan, + Headers actualHeaders + ) { + final JSONObject result = new JSONObject(); + final JSONArray absentHeaders = requestPlan.getJSONArray("absentHeaders"); + for (int index = 0; index < absentHeaders.length(); index++) { + final String name = absentHeaders.getString(index); + result.put(name, actualHeaders.getFirst(name) != null); + } + + return result; + } + + private void writeResponse(HttpExchange exchange, JSONObject requestPlan) throws IOException { + final JSONObject responsePlan = requestPlan.getJSONObject("response"); + final Headers headers = exchange.getResponseHeaders(); + final JSONObject responseHeaders = responsePlan.getJSONObject("effectiveHeaders"); + for (String name : responseHeaders.keySet()) { + headers.set(name, localValue(responseHeaders.getString(name))); + } + + final byte[] body; + if (responsePlan.isNull("body")) { + body = new byte[0]; + } else { + body = responsePlan.getString("body").getBytes(StandardCharsets.UTF_8); + } + final long responseBodyLength = body.length == 0 ? -1 : body.length; + exchange.sendResponseHeaders(responsePlan.getInt("statusCode"), responseBodyLength); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(body); + } + } + + private URL localUrl(String canonicalUrl) throws IOException { + return new URL(localValue(canonicalUrl)); + } + + private String localValue(String value) { + return value.replace(canonicalOrigin(), localOrigin()); + } + + private String canonicalValue(String value) { + return value.replace(localOrigin(), canonicalOrigin()); + } + + private String canonicalRequestUrl(URI requestUri) throws IOException { + return new URL(endpointOrigin, requestUri.toString()).toString(); + } + + private static byte[] readRequestBody(HttpExchange exchange) throws IOException { + final ByteArrayOutputStream body = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = exchange.getRequestBody().read(buffer)) != -1) { + body.write(buffer, 0, read); + } + + return body.toByteArray(); + } + + private String canonicalOrigin() { + return endpointOrigin.getProtocol() + "://" + endpointOrigin.getHost(); + } + + private String localOrigin() { + final InetSocketAddress address = server.getAddress(); + return "http://" + address.getHostString() + ":" + address.getPort(); + } + + private static final class RequestGate { + private final List heldRequestIndexes; + private final List releaseAfterRequestIndexes; + private final long timeoutMs; + private final List arrivedRequestIndexes; + private boolean released; + + RequestGate(JSONObject gate) { + if (!"release-after-all-started".equals(gate.getString("kind"))) { + throw new IllegalArgumentException( + "unsupported conformance server request gate " + gate.getString("kind") + ); + } + + heldRequestIndexes = integerList(gate.getJSONArray("heldRequestIndexes")); + releaseAfterRequestIndexes = integerList(gate.getJSONArray("releaseAfterRequestIndexes")); + timeoutMs = gate.getLong("timeoutMs"); + arrivedRequestIndexes = new ArrayList(); + } + + int matchingHeldRequestIndex( + boolean[] observedRequests, + List requests, + HttpExchange exchange, + String actualUrl, + byte[] body + ) { + for (Integer requestIndex : heldRequestIndexes) { + final int index = requestIndex.intValue(); + if (index < 0 || index >= requests.size() || observedRequests[index]) { + continue; + } + + final JSONObject requestPlan = requests.get(index); + if (!requestPlan.getString("effectiveMethod").equals(exchange.getRequestMethod())) { + continue; + } + if (!requestPlan.getString("expectedUrl").equals(actualUrl)) { + continue; + } + if (!requestPlan.isNull("bodySize") && body.length != requestPlan.getInt("bodySize")) { + continue; + } + + return index; + } + + return -1; + } + + synchronized void awaitIfHeld(int requestIndex) throws InterruptedException { + if (!contains(heldRequestIndexes, requestIndex)) { + return; + } + + if (!contains(arrivedRequestIndexes, requestIndex)) { + arrivedRequestIndexes.add(Integer.valueOf(requestIndex)); + } + if (containsAll(arrivedRequestIndexes, releaseAfterRequestIndexes)) { + released = true; + notifyAll(); + return; + } + + final long deadline = System.currentTimeMillis() + timeoutMs; + while (!released) { + final long remainingMs = deadline - System.currentTimeMillis(); + if (remainingMs <= 0) { + throw new IllegalStateException( + "timed out waiting for conformance request gate; arrived " + + arrivedRequestIndexes + + ", expected " + + releaseAfterRequestIndexes + ); + } + wait(remainingMs); + } + } + + private static List integerList(JSONArray values) { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(Integer.valueOf(values.getInt(index))); + } + + return result; + } + + private static boolean contains(List values, int expected) { + for (Integer value : values) { + if (value.intValue() == expected) { + return true; + } + } + + return false; + } + + private static boolean containsAll(List values, List expectedValues) { + for (Integer expected : expectedValues) { + if (!contains(values, expected.intValue())) { + return false; + } + } + + return true; + } + } + + private Api2DevdockTusConformanceServer() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java new file mode 100644 index 00000000..993961b3 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusCreationWithUpload.java @@ -0,0 +1,87 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusCreationWithUpload { + /** + * Run the API2 devdock TUS creation-with-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final String uploadUrl = uploadWithCreationData(scenario, createResponse); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", 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 String uploadWithCreationData( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + if (!uploadConfig.getBoolean("uploadDataDuringCreation")) { + throw new IllegalStateException( + "creation-with-upload scenario must set uploadDataDuringCreation" + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-creation-with-upload"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.createUploadWithData(upload, content.length); + uploader.finish(); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "creation-with-upload accepted " + + uploader.getOffset() + + " bytes, expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("creation-with-upload did not expose a URL"); + } + + return uploader.getUploadURL().toString(); + } + + private Api2DevdockTusCreationWithUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java new file mode 100644 index 00000000..6b385cdd --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusCustomRequestHeaders.java @@ -0,0 +1,132 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusCustomRequestHeaders { + /** + * Run the API2 devdock TUS custom request headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithCustomHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed custom request headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithCustomHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map expectedHeaders = Api2DevdockScenario.uploadHeaders(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setHeaders(expectedHeaders); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) || "PATCH".equals(context.getMethod())) { + headersByMethod.put( + context.getMethod(), + observedCustomHeaders(context.getConnection(), expectedHeaders) + ); + } + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-custom-request-headers"); + upload.setMetadata( + Api2DevdockScenario.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( + "custom request headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("custom request headers upload did not expose a URL"); + } + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedCustomHeaders( + HttpURLConnection connection, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "custom request headers did not observe " + entry.getKey() + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private Api2DevdockTusCustomRequestHeaders() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java new file mode 100644 index 00000000..6d6fb16f --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusDeferredLengthUpload.java @@ -0,0 +1,90 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusDeferredLengthUpload { + /** + * Run the API2 devdock TUS deferred-length upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final String uploadUrl = uploadWithDeferredLength(scenario, createResponse); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", uploadUrl)); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded with deferred length to " + + uploadUrl + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static String uploadWithDeferredLength( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + if (!uploadConfig.getBoolean("uploadLengthDeferred")) { + throw new IllegalStateException( + "deferred-length scenario must set uploadLengthDeferred" + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-deferred-length"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + upload.setUploadLengthDeferred(true); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(chunkSize); + 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("deferred-length upload did not return a URL"); + } + + return uploader.getUploadURL().toString(); + } + + private Api2DevdockTusDeferredLengthUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java new file mode 100644 index 00000000..f9c5bc0b --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusDetailedError.java @@ -0,0 +1,233 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusDetailedError; +import io.tus.java.client.TusUpload; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; + +public final class Api2DevdockTusDetailedError { + /** + * Run the API2 devdock detailed-error scenario. + * + * @param args Unused command-line arguments. + * @throws Exception Thrown when the scenario cannot be executed. + */ + public static void main(String[] args) throws Exception { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadExpectingDetailedError( + Api2DevdockScenario.conformanceScenario(scenario) + ); + + Api2DevdockScenario.writeResult(result); + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed detailed error " + + result.getString("errorMessage") + ); + } + + private static JSONObject uploadExpectingDetailedError(JSONObject conformanceScenario) + throws Exception { + final JSONObject requestPlan = conformanceScenario + .getJSONArray("requests") + .getJSONObject(0); + if (!requestPlan.isNull("errorMessage")) { + return uploadExpectingRequestError(conformanceScenario, requestPlan); + } + + return uploadExpectingResponseError(conformanceScenario); + } + + private static JSONObject uploadExpectingResponseError(JSONObject conformanceScenario) + throws Exception { + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption(conformanceScenario, "endpointUrl") + ); + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = clientFor(conformanceScenario); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final Throwable error = createUploadExpectingError(client, conformanceScenario); + conformanceServer.assertExhausted(); + + final JSONObject serverResult = conformanceServer.result(); + return detailedResult( + error, + canonicalize(serverResult, conformanceServer), + conformanceServer + ); + } + } + + private static JSONObject uploadExpectingRequestError( + JSONObject conformanceScenario, + JSONObject requestPlan + ) throws Exception { + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption(conformanceScenario, "endpointUrl") + ); + final FailingTusClient client = new FailingTusClient(requestPlan.getString("errorMessage")); + configureClient(client, conformanceScenario); + client.setUploadCreationURL(endpointUrl); + + final Throwable error = createUploadExpectingError(client, conformanceScenario); + final JSONObject requestResult = new JSONObject() + .put("requestMethods", new JSONArray().put(requestPlan.getString("effectiveMethod"))) + .put("requestUrls", new JSONArray().put(requestPlan.getString("expectedUrl"))); + + return detailedResult(error, requestResult, null); + } + + private static TusClient clientFor(JSONObject conformanceScenario) { + final TusClient client = new TusClient(); + configureClient(client, conformanceScenario); + return client; + } + + private static void configureClient(TusClient client, JSONObject conformanceScenario) { + final Map headers = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "headers" + ); + client.setHeaders(headers); + } + + private static Throwable createUploadExpectingError( + TusClient client, + JSONObject conformanceScenario + ) throws Exception { + final TusUpload upload = uploadFor(conformanceScenario); + try { + client.createUpload(upload); + } catch (IOException | ProtocolException error) { + return error; + } + + throw new IllegalStateException("detailed error scenario unexpectedly created an upload"); + } + + private static TusUpload uploadFor(JSONObject conformanceScenario) { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setMetadata(metadata); + upload.setSize(content.length); + return upload; + } + + private static JSONObject detailedResult( + Throwable error, + JSONObject requestResult, + Api2DevdockTusConformanceServer conformanceServer + ) { + final JSONObject result = new JSONObject() + .put("errorCaught", true) + .put("errorIsDetailed", error instanceof TusDetailedError) + .put("errorMessage", canonicalValue(error.getMessage(), conformanceServer)) + .put("requestCount", requestResult.getJSONArray("requestMethods").length()) + .put("requestMethods", requestResult.getJSONArray("requestMethods")) + .put("requestUrls", requestResult.getJSONArray("requestUrls")); + + if (!(error instanceof TusDetailedError)) { + return result; + } + + final TusDetailedError detailedError = (TusDetailedError) error; + result.put("causingErrorPresent", detailedError.getCausingError() != null); + if (detailedError.getCausingError() != null) { + result.put("causingErrorMessage", detailedError.getCausingError().getMessage()); + } + result.put("originalRequestMethod", detailedError.getOriginalRequestMethod()); + result.put("originalRequestRequestId", detailedError.getOriginalRequestId()); + result.put( + "originalRequestUrl", + canonicalValue(detailedError.getOriginalRequestURL().toString(), conformanceServer) + ); + result.put("originalResponsePresent", detailedError.hasOriginalResponse()); + if (detailedError.hasOriginalResponse()) { + result.put("originalResponseBody", detailedError.getOriginalResponseBody()); + result.put("originalResponseStatus", detailedError.getOriginalResponseStatus()); + } + + return result; + } + + private static JSONObject canonicalize( + JSONObject requestResult, + Api2DevdockTusConformanceServer conformanceServer + ) { + final JSONArray requestUrls = requestResult.getJSONArray("requestUrls"); + final JSONArray canonicalUrls = new JSONArray(); + for (int index = 0; index < requestUrls.length(); index++) { + canonicalUrls.put(conformanceServer.canonicalUrl(requestUrls.getString(index))); + } + + return new JSONObject(requestResult.toString()).put("requestUrls", canonicalUrls); + } + + private static String canonicalValue( + String value, + Api2DevdockTusConformanceServer conformanceServer + ) { + if (conformanceServer == null) { + return value; + } + + return conformanceServer.canonicalUrl(value); + } + + private static final class FailingTusClient extends TusClient { + private final String errorMessage; + + FailingTusClient(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + protected HttpURLConnection openConnection(URL uploadUrl) { + return new FailingHttpURLConnection(uploadUrl, errorMessage); + } + } + + private static final class FailingHttpURLConnection extends HttpURLConnection { + private final String errorMessage; + + FailingHttpURLConnection(URL url, String errorMessage) { + super(url); + this.errorMessage = errorMessage; + } + + @Override + public void connect() throws IOException { + throw new IOException(errorMessage); + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + } + + private Api2DevdockTusDetailedError() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java new file mode 100644 index 00000000..291c6871 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusFileUrlStorageBackend.java @@ -0,0 +1,177 @@ +package io.tus.java.example; + +import io.tus.java.client.FingerprintNotFoundException; +import io.tus.java.client.ProtocolException; +import io.tus.java.client.ResumingNotEnabledException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLFileStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusFileUrlStorageBackend { + /** + * Run the API2 devdock TUS file URL storage example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithFileStorage(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resumed with file URL storage " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithFileStorage( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException, FingerprintNotFoundException, + ResumingNotEnabledException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject resume = uploadConfig.getJSONObject("resume"); + final JSONObject urlStorageBackend = uploadConfig.getJSONObject("urlStorageBackend"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final String fingerprint = resume.getString("fingerprint"); + final File storageFile = File.createTempFile("api2-devdock-tus-url-storage", ".properties"); + + try { + final TusURLFileStore store = new TusURLFileStore(storageFile); + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(store); + if (resume.getBoolean("removeFingerprintOnSuccess")) { + client.enableRemoveFingerprintOnSuccess(); + } + + final TusUpload firstUpload = uploadFor( + scenario, + createResponse, + content, + fingerprint + ); + final TusUploader firstUploader = client.createUpload(firstUpload); + firstUploader.setChunkSize(chunkSize); + final int firstAcceptedBytes = firstUploader.uploadChunk(); + firstUploader.finish(false); + + if (firstAcceptedBytes != resume.getInt("stopAfterAcceptedBytes")) { + throw new IllegalStateException( + "first upload accepted " + + firstAcceptedBytes + + " bytes, expected " + + resume.getInt("stopAfterAcceptedBytes") + ); + } + + final String firstUploadUrl = firstUploader.getUploadURL().toString(); + final int previousUploadCount = store.size(); + if (previousUploadCount != resume.getInt("expectedPreviousUploadCount")) { + throw new IllegalStateException( + "stored upload count " + + previousUploadCount + + ", expected " + + resume.getInt("expectedPreviousUploadCount") + ); + } + final boolean storedUploadKeyPrefixMatched = store.hasKeyWithPrefix( + urlStorageBackend.getString("expectedStoredUploadKeyPrefix") + ); + + final TusUpload secondUpload = uploadFor( + scenario, + createResponse, + content, + fingerprint + ); + final TusUploader resumedUploader = client.resumeUpload(secondUpload); + resumedUploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = resumedUploader.uploadChunk(); + } while (uploadedChunkSize > -1); + resumedUploader.finish(); + + final String uploadUrl = resumedUploader.getUploadURL().toString(); + if (!firstUploadUrl.equals(uploadUrl)) { + throw new IllegalStateException( + "resumed upload URL " + uploadUrl + ", expected " + firstUploadUrl + ); + } + if (resumedUploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + + resumedUploader.getOffset() + + ", expected " + + content.length + ); + } + + final int remainingPreviousUploadCount = store.size(); + if (remainingPreviousUploadCount != resume.getInt("expectedRemainingPreviousUploadCount")) { + throw new IllegalStateException( + "remaining stored upload count " + + remainingPreviousUploadCount + + ", expected " + + resume.getInt("expectedRemainingPreviousUploadCount") + ); + } + + return new JSONObject() + .put("firstAcceptedBytes", firstAcceptedBytes) + .put("firstUploadUrl", firstUploadUrl) + .put("previousUploadCount", previousUploadCount) + .put("remainingPreviousUploadCount", remainingPreviousUploadCount) + .put("storageFileEntryCount", store.size()) + .put("storedUploadKeyPrefixMatched", storedUploadKeyPrefixMatched) + .put("uploadUrl", uploadUrl) + .put("urlStorageBackend", urlStorageBackend.getString("kind")); + } finally { + if (storageFile.exists() && !storageFile.delete()) { + storageFile.deleteOnExit(); + } + } + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content, + String fingerprint + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private Api2DevdockTusFileUrlStorageBackend() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java new file mode 100644 index 00000000..2c601e60 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusNodePathInputSource.java @@ -0,0 +1,123 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; + +public final class Api2DevdockTusNodePathInputSource { + /** + * Run the API2 devdock TUS node path input source example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithNodePathInputSource(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " read " + + Api2DevdockScenario.conformanceInputSourceKind(conformanceScenario) + + " for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithNodePathInputSource(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final String inputKind = Api2DevdockScenario.conformanceInputSourceKind(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + final JSONArray events = new JSONArray(); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final File source = File.createTempFile("api2-java-tus-node-path-input", ".bin"); + try { + Files.write(source.toPath(), content); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(source); + upload.setMetadata( + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ) + ); + + events.put(new JSONObject() + .put("inputKind", inputKind) + .put("kind", "source-open") + .put("size", upload.getSize())); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(content.length); + boolean uploadFinished = false; + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadFinished = true; + } catch (IOException | ProtocolException error) { + throw new IOException( + "node path input source conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } finally { + if (uploadFinished) { + events.put(new JSONObject().put("kind", "success")); + } + events.put(new JSONObject().put("kind", "source-close")); + } + + if (uploader.getUploadURL() == null) { + throw new IllegalStateException( + "node path input source upload did not expose a URL" + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put("events", events) + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } finally { + Files.deleteIfExists(source.toPath()); + } + } + } + + private Api2DevdockTusNodePathInputSource() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java new file mode 100644 index 00000000..8853f818 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusOverridePatchMethod.java @@ -0,0 +1,91 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusOverridePatchMethod { + /** + * Run the API2 devdock TUS PATCH method override example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithMethodOverride(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " uploaded to " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithMethodOverride(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + final String uploadUrl = Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "uploadUrl" + ); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = new TusClient(); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + + final TusUploader uploader = client.beginOrResumeUploadFromURL( + upload, + conformanceServer.localUrlFor(uploadUrl) + ); + uploader.setChunkSize(content.length); + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + } catch (IOException | ProtocolException error) { + throw new IOException( + "PATCH method override conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } + } + + private Api2DevdockTusOverridePatchMethod() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java new file mode 100644 index 00000000..86f92008 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusParallelUploadConcat.java @@ -0,0 +1,300 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public final class Api2DevdockTusParallelUploadConcat { + /** + * Run the API2 devdock TUS parallel-upload concat example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithParallelConcat(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " concatenated parallel uploads into " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithParallelConcat(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final Map partialMetadata = + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadataForPartialUploads" + ); + final int parallelUploads = Api2DevdockScenario.conformanceInputIntegerOption( + conformanceScenario, + "parallelUploads", + 1 + ); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + final EventKinds eventKinds = EventKinds.from(conformanceScenario); + final List events = Collections.synchronizedList(new ArrayList()); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final List parts = partUploads( + conformanceScenario, + scenario.getString("scenarioId"), + content, + partialMetadata + ); + if (parts.size() != parallelUploads) { + throw new IllegalStateException( + "parallel concat expected " + + parallelUploads + + " part(s), got " + + parts.size() + ); + } + + for (PartUpload part : parts) { + part.uploader = client.createPartialUpload(part.upload); + } + + runPartialUploads(parts, events, eventKinds, content.length); + final List partialURLs = new ArrayList(); + for (PartUpload part : parts) { + partialURLs.add(part.uploader.getUploadURL()); + } + final URL finalUploadURL = client.concatenateUploads(partialURLs, metadata); + + conformanceServer.assertExhausted(); + final JSONArray sortedEvents = sortedEvents(events, eventKinds); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("eventCount", sortedEvents.length()); + result.put("events", sortedEvents); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put("uploadUrl", conformanceServer.canonicalUrl(finalUploadURL.toString())); + + return result; + } + } + + private static List partUploads( + JSONObject conformanceScenario, + String scenarioId, + byte[] content, + Map metadata + ) { + final JSONArray requests = conformanceScenario.getJSONArray("requests"); + final List result = new ArrayList(); + for (int requestIndex = 0; requestIndex < requests.length(); requestIndex++) { + final JSONObject request = requests.getJSONObject(requestIndex); + if (!"upload-partial-chunk".equals(request.optString("role"))) { + continue; + } + + final int bodyStart = request.optInt("bodyStart", 0); + final int bodySize = request.getInt("bodySize"); + final byte[] partContent = new byte[bodySize]; + System.arraycopy(content, bodyStart, partContent, 0, bodySize); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(partContent)); + upload.setSize(bodySize); + upload.setFingerprint( + scenarioId + "-java-parallel-upload-concat-" + result.size() + ); + upload.setMetadata(metadata); + result.add(new PartUpload(bodyStart, bodySize, upload)); + } + + return result; + } + + private static void runPartialUploads( + List parts, + List events, + EventKinds eventKinds, + int totalSize + ) throws Exception { + final List threads = new ArrayList(); + final AtomicReference uploadError = new AtomicReference(); + for (final PartUpload part : parts) { + final Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + uploadPart(part, events, eventKinds, totalSize); + } catch (Exception error) { + uploadError.compareAndSet(null, error); + } + } + }); + threads.add(thread); + thread.start(); + } + + for (Thread thread : threads) { + thread.join(5000); + if (thread.isAlive()) { + thread.interrupt(); + throw new IllegalStateException("timed out waiting for parallel concat upload"); + } + } + if (uploadError.get() != null) { + throw uploadError.get(); + } + } + + private static void uploadPart( + final PartUpload part, + final List events, + final EventKinds eventKinds, + final int totalSize + ) throws Exception { + part.uploader.setChunkSize(part.bodySize); + part.uploader.setRequestPayloadSize(part.bodySize); + part.uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + if (bytesSent <= 0) { + return; + } + + events.add(new JSONObject() + .put("bytesSent", part.bodyStart + bytesSent) + .put("bytesTotal", totalSize) + .put("kind", eventKinds.progress)); + } + }); + part.uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(new JSONObject() + .put("bytesAccepted", part.bodyStart + bytesAccepted) + .put("bytesTotal", totalSize) + .put("chunkSize", chunkSize) + .put("kind", eventKinds.chunkComplete)); + } + }); + + int uploadProgress = part.uploader.uploadChunk(); + while (uploadProgress > -1) { + uploadProgress = part.uploader.uploadChunk(); + } + part.uploader.finish(); + } + + private static JSONArray sortedEvents(List events, final EventKinds eventKinds) { + final List sorted = new ArrayList(events); + Collections.sort(sorted, new Comparator() { + @Override + public int compare(JSONObject left, JSONObject right) { + final int positionCompare = Long.compare(eventPosition(left), eventPosition(right)); + if (positionCompare != 0) { + return positionCompare; + } + + return Integer.compare(eventKindRank(left, eventKinds), eventKindRank(right, eventKinds)); + } + }); + + return new JSONArray(sorted); + } + + private static long eventPosition(JSONObject event) { + if (event.has("bytesSent")) { + return event.getLong("bytesSent"); + } + return event.getLong("bytesAccepted"); + } + + private static int eventKindRank(JSONObject event, EventKinds eventKinds) { + if (eventKinds.progress.equals(event.getString("kind"))) { + return 0; + } + return 1; + } + + private static final class EventKinds { + final String chunkComplete; + final String progress; + + EventKinds(String chunkComplete, String progress) { + this.chunkComplete = chunkComplete; + this.progress = progress; + } + + static EventKinds from(JSONObject conformanceScenario) { + final JSONArray events = conformanceScenario.getJSONArray("events"); + String chunkComplete = null; + String progress = null; + for (int index = 0; index < events.length(); index++) { + final JSONObject event = events.getJSONObject(index); + if (event.has("bytesSent")) { + progress = event.getString("kind"); + } + if (event.has("chunkSize")) { + chunkComplete = event.getString("kind"); + } + } + if (chunkComplete == null || progress == null) { + throw new IllegalArgumentException("parallel concat scenario is missing event kinds"); + } + + return new EventKinds(chunkComplete, progress); + } + } + + private static final class PartUpload { + final int bodySize; + final int bodyStart; + final TusUpload upload; + TusUploader uploader; + + PartUpload(int bodyStart, int bodySize, TusUpload upload) { + this.bodyStart = bodyStart; + this.bodySize = bodySize; + this.upload = upload; + } + } + + private Api2DevdockTusParallelUploadConcat() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java new file mode 100644 index 00000000..972cb5b9 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusProtocolVersionSelection.java @@ -0,0 +1,103 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.util.Map; + +public final class Api2DevdockTusProtocolVersionSelection { + /** + * Run the API2 devdock TUS protocol-version selection example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithSelectedProtocol(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " created upload " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithSelectedProtocol(JSONObject scenario) throws Exception { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final String protocol = Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "protocol" + ); + final boolean uploadDataDuringCreation = + Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadDataDuringCreation", + false + ); + if (!uploadDataDuringCreation) { + throw new IllegalArgumentException( + "Java protocol-version selection proof expects creation with upload" + ); + } + + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setProtocol(protocol); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint( + scenario.getString("scenarioId") + "-java-protocol-version-selection" + ); + upload.setMetadata(metadata); + + final TusUploader uploader = client.createUploadWithData(upload, content.length); + uploader.finish(); + + conformanceServer.assertExhausted(); + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + + return result; + } + } + + private Api2DevdockTusProtocolVersionSelection() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java new file mode 100644 index 00000000..4d2c2412 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRelativeLocationResolution.java @@ -0,0 +1,100 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +public final class Api2DevdockTusRelativeLocationResolution { + /** + * Run the API2 devdock TUS relative Location resolution example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = uploadWithRelativeLocationResolution(conformanceScenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resolved " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRelativeLocationResolution(JSONObject conformanceScenario) + throws IOException, ProtocolException { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final URL endpointUrl = new URL( + Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + ) + ); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointUrl)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint("api2-java-relative-location-conformance-fingerprint"); + upload.setMetadata( + Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ) + ); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(content.length); + try { + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + } catch (IOException | ProtocolException error) { + throw new IOException( + "relative Location conformance failed: " + + conformanceServer.errorSummary(), + error + ); + } + + if (uploader.getUploadURL() == null) { + throw new IllegalStateException( + "relative Location resolution upload did not expose a URL" + ); + } + + conformanceServer.assertExhausted(); + return conformanceServer.result() + .put( + "uploadUrl", + conformanceServer.canonicalUrl(uploader.getUploadURL().toString()) + ); + } + } + + private Api2DevdockTusRelativeLocationResolution() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java new file mode 100644 index 00000000..193d712c --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestIdHeaders.java @@ -0,0 +1,141 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusRequestIdHeaders { + /** + * Run the API2 devdock TUS request ID headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRequestIdHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed request ID headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRequestIdHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final String requestIdHeaderName = + Api2DevdockScenario.uploadRequestIdHeaderName(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setHeaders(Api2DevdockScenario.uploadHeaders(uploadConfig)); + if (Api2DevdockScenario.uploadAddRequestId(uploadConfig)) { + client.enableRequestIdHeader(); + } + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) || "PATCH".equals(context.getMethod())) { + final Map headers = new LinkedHashMap(); + headers.put( + requestIdHeaderName, + observedRequestIdHeader( + context.getConnection(), + context.getMethod(), + requestIdHeaderName + ) + ); + headersByMethod.put(context.getMethod(), headers); + } + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-request-id-headers"); + upload.setMetadata( + Api2DevdockScenario.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( + "request ID headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("request ID headers upload did not expose a URL"); + } + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static String observedRequestIdHeader( + HttpURLConnection connection, + String method, + String requestIdHeaderName + ) { + final String value = connection.getRequestProperty(requestIdHeaderName); + if (value == null || value.isEmpty()) { + throw new IllegalStateException( + "request ID headers did not observe " + + requestIdHeaderName + + " on " + + method + ); + } + + return value; + } + + private Api2DevdockTusRequestIdHeaders() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java new file mode 100644 index 00000000..f13ed75e --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRequestLifecycleHooks.java @@ -0,0 +1,193 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +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.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusRequestLifecycleHooks { + /** + * Run the API2 devdock TUS request lifecycle hooks example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRequestLifecycleHooks(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed lifecycle hooks for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRequestLifecycleHooks( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject hooksConfig = uploadConfig.getJSONObject("requestLifecycleHooks"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final List beforeRequestMethods = new ArrayList(); + final List afterResponseMethods = new ArrayList(); + final List afterResponseStatusCodes = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + beforeRequestMethods.add(context.getMethod()); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + afterResponseMethods.add(context.getMethod()); + afterResponseStatusCodes.add(context.getConnection().getResponseCode()); + } + } + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-request-hooks"); + upload.setMetadata( + Api2DevdockScenario.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( + "request lifecycle hooks upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("request lifecycle hooks upload did not return a URL"); + } + assertStringArray( + beforeRequestMethods, + hooksConfig.getJSONArray("expectedBeforeRequestMethods"), + "before request methods" + ); + assertStringArray( + afterResponseMethods, + hooksConfig.getJSONArray("expectedAfterResponseMethods"), + "after response methods" + ); + assertIntegerArray( + afterResponseStatusCodes, + hooksConfig.getJSONArray("expectedAfterResponseStatusCodes"), + "after response status codes" + ); + + return new JSONObject() + .put("afterResponseMethods", new JSONArray(afterResponseMethods)) + .put("afterResponseStatusCodes", new JSONArray(afterResponseStatusCodes)) + .put("beforeRequestMethods", new JSONArray(beforeRequestMethods)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static void assertStringArray(List actual, JSONArray expected, String label) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (!actual.get(index).equals(expected.getString(index))) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.getString(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private static void assertIntegerArray( + List actual, + JSONArray expected, + String label + ) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (actual.get(index) != expected.getInt(index)) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.getInt(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private Api2DevdockTusRequestLifecycleHooks() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java new file mode 100644 index 00000000..58318732 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusResumeUpload.java @@ -0,0 +1,172 @@ +package io.tus.java.example; + +import io.tus.java.client.FingerprintNotFoundException; +import io.tus.java.client.ProtocolException; +import io.tus.java.client.ResumingNotEnabledException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusURLStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +public final class Api2DevdockTusResumeUpload { + /** + * Run the API2 devdock TUS resume example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithStoredResume(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " resumed " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithStoredResume( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException, FingerprintNotFoundException, + ResumingNotEnabledException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject resume = uploadConfig.getJSONObject("resume"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final String fingerprint = resume.getString("fingerprint"); + + final CountingTusURLStore store = new CountingTusURLStore(); + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(store); + if (resume.getBoolean("removeFingerprintOnSuccess")) { + client.enableRemoveFingerprintOnSuccess(); + } + + final TusUpload firstUpload = uploadFor(scenario, createResponse, content, fingerprint); + final TusUploader firstUploader = client.createUpload(firstUpload); + firstUploader.setChunkSize(chunkSize); + final int firstAcceptedBytes = firstUploader.uploadChunk(); + if (firstAcceptedBytes != resume.getInt("stopAfterAcceptedBytes")) { + throw new IllegalStateException( + "first upload accepted " + + firstAcceptedBytes + + " bytes, expected " + + resume.getInt("stopAfterAcceptedBytes") + ); + } + firstUploader.finish(false); + final String firstUploadUrl = firstUploader.getUploadURL().toString(); + final int previousUploadCount = store.size(); + if (previousUploadCount != resume.getInt("expectedPreviousUploadCount")) { + throw new IllegalStateException( + "stored upload count " + + previousUploadCount + + ", expected " + + resume.getInt("expectedPreviousUploadCount") + ); + } + + final TusUpload secondUpload = uploadFor(scenario, createResponse, content, fingerprint); + final TusUploader resumedUploader = client.resumeUpload(secondUpload); + resumedUploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = resumedUploader.uploadChunk(); + } while (uploadedChunkSize > -1); + resumedUploader.finish(); + + final String uploadUrl = resumedUploader.getUploadURL().toString(); + if (!firstUploadUrl.equals(uploadUrl)) { + throw new IllegalStateException( + "resumed upload URL " + uploadUrl + ", expected " + firstUploadUrl + ); + } + if (resumedUploader.getOffset() != content.length) { + throw new IllegalStateException( + "remote offset " + resumedUploader.getOffset() + ", expected " + content.length + ); + } + + final int remainingPreviousUploadCount = store.size(); + if (remainingPreviousUploadCount != resume.getInt("expectedRemainingPreviousUploadCount")) { + throw new IllegalStateException( + "remaining stored upload count " + + remainingPreviousUploadCount + + ", expected " + + resume.getInt("expectedRemainingPreviousUploadCount") + ); + } + + return new JSONObject() + .put("firstAcceptedBytes", firstAcceptedBytes) + .put("firstUploadUrl", firstUploadUrl) + .put("previousUploadCount", previousUploadCount) + .put("remainingPreviousUploadCount", remainingPreviousUploadCount) + .put("uploadUrl", uploadUrl); + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content, + String fingerprint + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(fingerprint); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private static final class CountingTusURLStore implements TusURLStore { + private final Map urls = new HashMap(); + + @Override + public void set(String fingerprint, URL url) { + urls.put(fingerprint, url); + } + + @Override + public URL get(String fingerprint) { + return urls.get(fingerprint); + } + + @Override + public void remove(String fingerprint) { + urls.remove(fingerprint); + } + + int size() { + return urls.size(); + } + } + + private Api2DevdockTusResumeUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java new file mode 100644 index 00000000..9b0d4a81 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryOffsetRecovery.java @@ -0,0 +1,234 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusRequestLifecycleHooks; +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.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusRetryOffsetRecovery { + /** + * Run the API2 devdock TUS retry-offset recovery example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithRetryOffsetRecovery(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " recovered offset for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRetryOffsetRecovery( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject retry = uploadConfig.getJSONObject("retryOffsetRecovery"); + final JSONObject failAfterResponse = retry.getJSONObject("failAfterResponse"); + final JSONObject recoveryResponse = retry.getJSONObject("recoveryResponse"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final List recoveredOffsets = new ArrayList(); + final List requestMethods = new ArrayList(); + final int[] failureCandidateCount = new int[]{0}; + final int[] simulatedFailureCount = new int[]{0}; + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + if (context.getMethod().equals(recoveryResponse.getString("method"))) { + recoveredOffsets.add(readHeaderInt( + context.getConnection(), + recoveryResponse.getString("offsetHeader") + )); + } + + if (!context.getMethod().equals(failAfterResponse.getString("method"))) { + return; + } + + failureCandidateCount[0] += 1; + if (failureCandidateCount[0] != failAfterResponse.getInt("occurrence")) { + return; + } + + simulatedFailureCount[0] += 1; + throw new IOException(failAfterResponse.getString("message")); + } + } + )); + + final TusUpload upload = uploadFor(scenario, createResponse, content); + final String[] uploadUrl = new String[]{null}; + final long[] finalOffset = new long[]{0}; + final TusExecutor executor = new TusExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(chunkSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadUrl[0] = uploader.getUploadURL().toString(); + finalOffset[0] = uploader.getOffset(); + } + }; + executor.setDelays(new int[uploadConfig.getInt("retries")]); + if (!executor.makeAttempts()) { + throw new IOException("retry offset recovery was interrupted"); + } + + if (uploadUrl[0] == null) { + throw new IllegalStateException("retry offset recovery TUS upload did not expose a URL"); + } + if (finalOffset[0] != content.length) { + throw new IllegalStateException( + "retry offset recovery upload offset " + + finalOffset[0] + + ", expected " + + content.length + ); + } + if (simulatedFailureCount[0] != retry.getInt("expectedFailureCount")) { + throw new IllegalStateException( + "retry offset recovery expected " + + retry.getInt("expectedFailureCount") + + " simulated failure(s), got " + + simulatedFailureCount[0] + ); + } + if (recoveredOffsets.size() != retry.getInt("expectedRecoveryRequestCount")) { + throw new IllegalStateException( + "retry offset recovery expected " + + retry.getInt("expectedRecoveryRequestCount") + + " recovery request(s), got " + + recoveredOffsets.size() + ); + } + if (recoveredOffsets.get(0) != retry.getInt("expectedRecoveredOffset")) { + throw new IllegalStateException( + "retry offset recovery expected recovered offset " + + retry.getInt("expectedRecoveredOffset") + + ", got " + + recoveredOffsets.get(0) + ); + } + assertRequestMethods(requestMethods, retry.getJSONArray("expectedRequestMethods")); + + return new JSONObject() + .put("recoveredOffsets", new JSONArray(recoveredOffsets)) + .put("recoveryRequestCount", recoveredOffsets.size()) + .put("requestMethods", new JSONArray(requestMethods)) + .put("simulatedFailureCount", simulatedFailureCount[0]) + .put("uploadUrl", uploadUrl[0]); + } + + private static TusUpload uploadFor( + JSONObject scenario, + JSONObject createResponse, + byte[] content + ) { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-offset-recovery"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + return upload; + } + + private static int readHeaderInt(HttpURLConnection connection, String headerName) { + final String value = connection.getHeaderField(headerName); + final int offset; + try { + offset = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalStateException( + "retry offset recovery expected numeric " + + headerName + + " response header, got " + + value + ); + } + if (offset < 0) { + throw new IllegalStateException( + "retry offset recovery expected non-negative offset, got " + offset + ); + } + + return offset; + } + + private static void assertRequestMethods(List actual, JSONArray expected) { + if (actual.size() != expected.length()) { + throw new IllegalStateException( + "retry offset recovery expected request methods " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.length(); index++) { + if (!actual.get(index).equals(expected.getString(index))) { + throw new IllegalStateException( + "retry offset recovery expected request method " + + expected.getString(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private Api2DevdockTusRetryOffsetRecovery() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java new file mode 100644 index 00000000..b38e3ea5 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusRetryStateTransitions.java @@ -0,0 +1,340 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusRequestLifecycleHooks; +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.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class Api2DevdockTusRetryStateTransitions { + private static final String UPLOAD_OFFSET_HEADER_NAME = "Upload-Offset"; + + /** + * Run the API2 devdock TUS retry-state transitions example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject result = uploadWithRetryStateTransitions(scenario); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed " + + result.getInt("eventCount") + + " retry event(s) for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithRetryStateTransitions(JSONObject scenario) + throws IOException, ProtocolException { + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes( + conformanceScenario + ); + final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption( + conformanceScenario, + "endpointUrl" + )); + final Map metadata = Api2DevdockScenario.conformanceInputStringMapOption( + conformanceScenario, + "metadata" + ); + final int[] retryDelays = conformanceRetryDelays(conformanceScenario); + final RetryStateObserver observer = new RetryStateObserver( + retryDecisions(conformanceScenario), + retryDelays + ); + final JSONObject completion = conformanceScenario.getJSONObject("completion"); + + try (Api2DevdockTusConformanceServer conformanceServer = + new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) { + final TusClient client = new TusClient(); + client.setUploadCreationURL(conformanceServer.endpointUrl()); + client.enableResuming(new TusURLMemoryStore()); + + final TusUpload upload = uploadFor(scenario, content, metadata); + final long[] lastAcceptedOffset = new long[]{0}; + final RetryStateExecutor executor = new RetryStateExecutor( + client, + upload, + content.length, + observer + ); + executor.setDelays(retryDelays); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + null, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) { + final long acceptedOffset = readAcceptedOffset(context.getConnection()); + if (acceptedOffset <= lastAcceptedOffset[0]) { + return; + } + + lastAcceptedOffset[0] = acceptedOffset; + executor.resetAfterProgress(); + } + } + )); + + if (!executor.makeAttempts()) { + throw new IOException("retry state transition upload was interrupted"); + } + observer.assertComplete(); + conformanceServer.assertExhausted(); + + final JSONObject result = conformanceServer.result(); + result.put("completionKind", completion.getString("kind")); + result.put("errorCalled", false); + result.put("eventCount", observer.events().length()); + result.put("events", observer.events()); + result.put("requestCount", result.getJSONArray("requestMethods").length()); + result.put("successCalled", true); + result.put("uploadUrl", conformanceServer.canonicalUrl(executor.uploadUrl())); + + return result; + } + } + + private static TusUpload uploadFor( + JSONObject scenario, + byte[] content, + Map metadata + ) { + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-state-transitions"); + upload.setMetadata(metadata); + return upload; + } + + private static int[] conformanceRetryDelays(JSONObject conformanceScenario) { + final JSONArray values = conformanceInputJSONArrayOption(conformanceScenario, "retryDelays"); + final int[] result = new int[values.length()]; + for (int index = 0; index < values.length(); index++) { + result[index] = values.getInt(index); + } + + return result; + } + + private static JSONArray conformanceInputJSONArrayOption( + JSONObject conformanceScenario, + String key + ) { + final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries"); + for (int index = 0; index < entries.length(); index++) { + final JSONObject entry = entries.getJSONObject(index); + if (key.equals(entry.getString("key"))) { + return entry.getJSONArray("value"); + } + } + + throw new IllegalArgumentException("missing conformance input array option " + key); + } + + private static List retryDecisions(JSONObject conformanceScenario) { + final JSONArray values = conformanceScenario.getJSONArray("retryDecisions"); + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + final JSONObject value = values.getJSONObject(index); + result.add(new RetryDecision( + value.getBoolean("decision"), + value.getInt("retryAttempt") + )); + } + + return result; + } + + private static long readAcceptedOffset(HttpURLConnection connection) { + final String value = connection.getHeaderField(UPLOAD_OFFSET_HEADER_NAME); + if (value == null || value.length() == 0) { + return -1; + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException error) { + return -1; + } + } + + private static final class RetryStateExecutor extends TusExecutor { + private final TusClient client; + private final TusUpload upload; + private final int requestPayloadSize; + private final RetryStateObserver observer; + private String uploadUrl; + + RetryStateExecutor( + TusClient client, + TusUpload upload, + int requestPayloadSize, + RetryStateObserver observer + ) { + this.client = client; + this.upload = upload; + this.requestPayloadSize = requestPayloadSize; + this.observer = observer; + } + + @Override + protected void makeAttempt() throws ProtocolException, IOException { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(requestPayloadSize); + uploader.setRequestPayloadSize(requestPayloadSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadUrl = uploader.getUploadURL().toString(); + } + + @Override + protected boolean shouldRetry(ProtocolException exception, int retryAttempt) { + return observer.shouldRetry(retryAttempt); + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return observer.shouldRetry(retryAttempt); + } + + @Override + protected void onRetryScheduled(int retryAttempt, int delayMillis) { + observer.retryScheduled(retryAttempt, delayMillis); + } + + void resetAfterProgress() { + resetRetryAttempts(); + } + + String uploadUrl() { + if (uploadUrl == null) { + throw new IllegalStateException("retry state transition upload did not expose a URL"); + } + + return uploadUrl; + } + } + + private static final class RetryStateObserver { + private final List decisions; + private final JSONArray events; + private final int[] retryDelays; + private int decisionIndex; + + RetryStateObserver(List decisions, int[] retryDelays) { + this.decisions = decisions; + this.events = new JSONArray(); + this.retryDelays = retryDelays; + } + + boolean shouldRetry(int retryAttempt) { + if (decisionIndex >= decisions.size()) { + throw new IllegalStateException( + "retry state transition received unexpected retry decision " + + decisionIndex + ); + } + + final RetryDecision decision = decisions.get(decisionIndex); + if (retryAttempt != decision.retryAttempt) { + throw new IllegalStateException( + "retry state transition expected retry attempt " + + decision.retryAttempt + + ", got " + + retryAttempt + ); + } + + events.put(new JSONObject() + .put("decision", decision.decision) + .put("kind", "should-retry") + .put("retryAttempt", retryAttempt)); + decisionIndex += 1; + return decision.decision; + } + + void retryScheduled(int retryAttempt, int delayMillis) { + if (retryAttempt < 0 || retryAttempt >= retryDelays.length) { + throw new IllegalStateException( + "retry state transition retry attempt " + + retryAttempt + + " has no retry delay" + ); + } + if (delayMillis != retryDelays[retryAttempt]) { + throw new IllegalStateException( + "retry state transition expected retry delay " + + retryDelays[retryAttempt] + + ", got " + + delayMillis + ); + } + + events.put(new JSONObject() + .put("delay", delayMillis) + .put("kind", "retry-schedule")); + } + + void assertComplete() { + if (decisionIndex == decisions.size()) { + return; + } + + throw new IllegalStateException( + "retry state transition expected " + + decisions.size() + + " retry decision(s), got " + + decisionIndex + ); + } + + JSONArray events() { + return events; + } + } + + private static final class RetryDecision { + final boolean decision; + final int retryAttempt; + + RetryDecision(boolean decision, int retryAttempt) { + this.decision = decision; + this.retryAttempt = retryAttempt; + } + } + + private Api2DevdockTusRetryStateTransitions() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java new file mode 100644 index 00000000..c6eaa671 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusStartOptionValidation.java @@ -0,0 +1,119 @@ +package io.tus.java.example; + +import io.tus.java.client.TusClient; +import io.tus.java.client.TusStartOptions; +import io.tus.java.client.TusUpload; + +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.net.URL; + +public final class Api2DevdockTusStartOptionValidation { + /** + * Run the API2 devdock start-option validation scenario. + * + * @param args Unused command-line arguments. + * @throws Exception Thrown when the scenario cannot be executed. + */ + public static void main(String[] args) throws Exception { + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario); + final JSONObject result = validateStartOptions(conformanceScenario); + + Api2DevdockScenario.writeResult(result); + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " rejected " + + conformanceScenario.getJSONObject("completion").getString("reason") + ); + } + + private static JSONObject validateStartOptions(JSONObject conformanceScenario) + throws Exception { + final TusClient client = new TusClient(); + final TusStartOptions options = startOptionsFor(conformanceScenario); + + try { + client.validateStartOptions(options); + } catch (IllegalArgumentException error) { + final String expectedMessage = conformanceScenario + .getJSONObject("completion") + .getString("message"); + if (!expectedMessage.equals(error.getMessage())) { + throw new IllegalStateException( + "expected start option validation error " + + expectedMessage + + ", got " + + error.getMessage() + ); + } + + return new JSONObject() + .put("errorCaught", true) + .put("errorMessage", error.getMessage()) + .put("requestCount", 0); + } + + throw new IllegalStateException("start option validation scenario unexpectedly succeeded"); + } + + private static TusStartOptions startOptionsFor(JSONObject conformanceScenario) + throws Exception { + final TusStartOptions options = new TusStartOptions(); + final String endpointURL = Api2DevdockScenario.conformanceInputStringOptionOrNull( + conformanceScenario, + "endpointUrl" + ); + final String uploadURL = Api2DevdockScenario.conformanceInputStringOptionOrNull( + conformanceScenario, + "uploadUrl" + ); + final int parallelUploads = Api2DevdockScenario.conformanceInputIntegerOption( + conformanceScenario, + "parallelUploads", + 1 + ); + final boolean uploadDataDuringCreation = + Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadDataDuringCreation", + false + ); + final boolean uploadLengthDeferred = Api2DevdockScenario.conformanceInputBooleanOption( + conformanceScenario, + "uploadLengthDeferred", + false + ); + + options.setUpload(uploadFor(conformanceScenario, uploadLengthDeferred)); + options.setParallelUploads(parallelUploads); + options.setUploadDataDuringCreation(uploadDataDuringCreation); + options.setUploadLengthDeferred(uploadLengthDeferred); + if (endpointURL != null) { + options.setEndpointURL(new URL(endpointURL)); + } + if (uploadURL != null) { + options.setUploadURL(new URL(uploadURL)); + } + + return options; + } + + private static TusUpload uploadFor( + JSONObject conformanceScenario, + boolean uploadLengthDeferred + ) { + final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(conformanceScenario); + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setUploadLengthDeferred(uploadLengthDeferred); + return upload; + } + + private Api2DevdockTusStartOptionValidation() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java new file mode 100644 index 00000000..29f0ef46 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusTerminateUpload.java @@ -0,0 +1,168 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +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.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusTerminateUpload { + /** + * Run the API2 devdock TUS terminate-upload example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadAndTerminate(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " terminated " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadAndTerminate( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final Api2DevdockScenario.TerminationPlan termination = + Api2DevdockScenario.termination(uploadConfig); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final List requestMethods = new ArrayList(); + + if (termination.stopAfterAcceptedBytes > content.length) { + throw new IllegalStateException( + "terminate upload stop-after bytes " + + termination.stopAfterAcceptedBytes + + " exceeds content length " + + content.length + ); + } + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-terminate-upload"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(termination.stopAfterAcceptedBytes); + final int uploadedChunkSize = uploader.uploadChunk(); + uploader.finish(); + + if (uploadedChunkSize != termination.stopAfterAcceptedBytes) { + throw new IllegalStateException( + "terminate upload wrote " + + uploadedChunkSize + + " bytes, expected " + + termination.stopAfterAcceptedBytes + ); + } + if (uploader.getOffset() != termination.stopAfterAcceptedBytes) { + throw new IllegalStateException( + "terminate upload accepted " + + uploader.getOffset() + + " bytes, expected " + + termination.stopAfterAcceptedBytes + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("terminate upload did not expose a URL"); + } + + final URL uploadUrl = uploader.getUploadURL(); + client.terminateUpload(uploadUrl).disconnect(); + final int verificationStatus = verifyTerminatedUpload( + client, + termination.verificationMethod, + uploadUrl + ); + if (verificationStatus != termination.expectedVerificationStatus) { + throw new IllegalStateException( + "terminate upload verification status " + + verificationStatus + + ", expected " + + termination.expectedVerificationStatus + ); + } + + return new JSONObject() + .put("acceptedBytes", (int) uploader.getOffset()) + .put("deleteRequestCount", countMethod(requestMethods, termination.method)) + .put("requestMethods", new JSONArray(requestMethods)) + .put("terminated", true) + .put("uploadUrl", uploadUrl.toString()) + .put("verificationStatus", verificationStatus); + } + + private static int verifyTerminatedUpload( + TusClient client, + String method, + URL uploadUrl + ) throws IOException { + final HttpURLConnection connection = (HttpURLConnection) uploadUrl.openConnection(); + try { + connection.setRequestMethod(method); + client.prepareConnection(connection); + connection.connect(); + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + private static int countMethod(List methods, String expectedMethod) { + int count = 0; + for (String method : methods) { + if (method.equals(expectedMethod)) { + count += 1; + } + } + + return count; + } + + private Api2DevdockTusTerminateUpload() { + throw new IllegalStateException("Utility class"); + } +} 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..ef4f94f6 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUpload.java @@ -0,0 +1,82 @@ +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.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; + +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 = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final String uploadUrl = uploadWithTus(scenario, createResponse); + Api2DevdockScenario.writeResult(new JSONObject().put("uploadUrl", 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 String uploadWithTus( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse))); + 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(Api2DevdockScenario.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 Api2DevdockTusUpload() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java new file mode 100644 index 00000000..7c90f8f4 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadBodyHeaders.java @@ -0,0 +1,148 @@ +package io.tus.java.example; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusURLMemoryStore; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Api2DevdockTusUploadBodyHeaders { + /** + * Run the API2 devdock TUS upload body headers example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithBodyHeaders(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed upload body headers for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithBodyHeaders( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map> expectedHeadersByMethod = + Api2DevdockScenario.uploadBodyHeadersByMethod(uploadConfig); + final Map> bodyHeadersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + final Map expectedHeaders = + expectedHeadersByMethod.get(context.getMethod()); + if (expectedHeaders == null) { + return; + } + + bodyHeadersByMethod.put( + context.getMethod(), + observedBodyHeaders( + context.getConnection(), + context.getMethod(), + expectedHeaders + ) + ); + } + }, + null + )); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new ByteArrayInputStream(content)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-upload-body-headers"); + upload.setMetadata( + Api2DevdockScenario.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( + "upload body headers upload offset " + + uploader.getOffset() + + ", expected " + + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("upload body headers upload did not expose a URL"); + } + for (String method : expectedHeadersByMethod.keySet()) { + if (!bodyHeadersByMethod.containsKey(method)) { + throw new IllegalStateException( + "upload body headers did not observe " + method + " request" + ); + } + } + + return new JSONObject() + .put("bodyHeadersByMethod", new JSONObject(bodyHeadersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedBodyHeaders( + HttpURLConnection connection, + String method, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "upload body headers did not observe " + entry.getKey() + " on " + method + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private Api2DevdockTusUploadBodyHeaders() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java new file mode 100644 index 00000000..95a65a70 --- /dev/null +++ b/example/src/main/java/io/tus/java/example/Api2DevdockTusUploadCallbacks.java @@ -0,0 +1,155 @@ +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.util.ArrayList; +import java.util.List; + +public final class Api2DevdockTusUploadCallbacks { + /** + * Run the API2 devdock TUS upload callback example. + * + * @param args ignored + */ + public static void main(String[] args) { + try { + System.setProperty("http.strictPostRedirect", "true"); + + final JSONObject scenario = Api2DevdockScenario.loadScenario(); + final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario); + final JSONObject result = uploadWithCallbacks(scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + System.out.println( + "Java TUS SDK devdock scenario " + + scenario.getString("scenarioId") + + " observed upload callbacks for " + + result.getString("uploadUrl") + ); + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + } + + private static JSONObject uploadWithCallbacks( + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Api2DevdockScenario.UploadCallbacksPlan callbacks = + Api2DevdockScenario.uploadCallbacks(scenario); + final List events = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final TusClient client = new TusClient(); + client.setUploadCreationURL( + new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse)) + ); + client.enableResuming(new TusURLMemoryStore()); + + final TusUpload upload = new TusUpload(); + upload.setInputStream(new EventRecordingByteArrayInputStream(content, callbacks, events)); + upload.setSize(content.length); + upload.setFingerprint(scenario.getString("scenarioId") + "-java-upload-callbacks"); + upload.setMetadata( + Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse) + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.uploadUrlAvailable + )); + uploader.setChunkSize(content.length); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.progress, + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesSent), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesTotal) + )); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.chunkComplete, + Api2DevdockScenario.uploadCallbackEventKeyNumber(chunkSize), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesAccepted), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesTotal) + )); + } + }); + + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + + if (uploader.getOffset() != content.length) { + throw new IllegalStateException( + "upload callbacks offset " + uploader.getOffset() + ", expected " + content.length + ); + } + if (uploader.getUploadURL() == null) { + throw new IllegalStateException("upload callbacks TUS upload did not expose a URL"); + } + + uploader.finish(false); + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.success + )); + uploader.finish(); + + final List matchedEvents = + Api2DevdockScenario.matchUploadCallbackEventKeys(callbacks, events); + return new JSONObject() + .put("eventKeys", new JSONArray(matchedEvents)) + .put("rawEventKeys", new JSONArray(events)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static final class EventRecordingByteArrayInputStream extends ByteArrayInputStream { + private final Api2DevdockScenario.UploadCallbacksPlan callbacks; + private final List events; + + EventRecordingByteArrayInputStream( + byte[] content, + Api2DevdockScenario.UploadCallbacksPlan callbacks, + List events + ) { + super(content); + this.callbacks = callbacks; + this.events = events; + } + + @Override + public void close() throws IOException { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.sourceClose + )); + super.close(); + } + } + + private Api2DevdockTusUploadCallbacks() { + 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..3a60bc8b 100644 --- a/src/main/java/io/tus/java/client/TusClient.java +++ b/src/main/java/io/tus/java/client/TusClient.java @@ -1,13 +1,15 @@ package io.tus.java.client; -import java.net.Proxy; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - +import java.io.EOFException; import java.io.IOException; +import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.Proxy; import java.net.URL; +import java.util.List; import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * This class is used for creating or resuming uploads. @@ -17,15 +19,19 @@ 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; private boolean resumingEnabled; private boolean removeFingerprintOnSuccessEnabled; + private boolean addRequestId; private TusURLStore urlStore; private Map headers; + private String protocol = TusProtocol.DEFAULT_CLIENT_PROTOCOL; private int connectTimeout = 5000; + private TusRequestLifecycleHooks requestLifecycleHooks; + private volatile HttpURLConnection currentConnection; /** * Create a new tus client. @@ -164,6 +170,73 @@ public Map getHeaders() { return headers; } + /** + * Select the TUS client protocol mode used for generated protocol headers. + * + * @param protocol The protocol mode, e.g. {@code tus-v1} or {@code ietf-draft-05}. + */ + public void setProtocol(@Nullable String protocol) { + final String normalizedProtocol = TusProtocol.normalizeProtocol(protocol); + if (normalizedProtocol == null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + protocol + ); + } + + this.protocol = normalizedProtocol; + } + + /** + * Return the selected TUS client protocol mode. + * + * @return The selected protocol mode. + */ + public String getProtocol() { + return protocol; + } + + /** + * Enable generated request IDs for every HTTP request made by this TusClient instance. + */ + public void enableRequestIdHeader() { + addRequestId = true; + } + + /** + * Disable generated request IDs for every HTTP request made by this TusClient instance. + */ + public void disableRequestIdHeader() { + addRequestId = false; + } + + /** + * Get the current generated request ID header setting. + * + * @return True if generated request IDs are enabled. + */ + public boolean requestIdHeaderEnabled() { + return addRequestId; + } + + /** + * Set request lifecycle callbacks for every HTTP request/response pair. + * + * @param requestLifecycleHooks Hooks to invoke, or null to disable hooks. + */ + public void setRequestLifecycleHooks(@Nullable TusRequestLifecycleHooks requestLifecycleHooks) { + this.requestLifecycleHooks = requestLifecycleHooks; + } + + /** + * Get the configured request lifecycle callbacks. + * + * @return The configured lifecycle hooks or null. + */ + @Nullable + public TusRequestLifecycleHooks getRequestLifecycleHooks() { + return requestLifecycleHooks; + } + /** * Sets the timeout for a Connection. * @param timeout in milliseconds @@ -180,6 +253,48 @@ public int getConnectTimeout() { return connectTimeout; } + /** + * Validate TUS start options before issuing any HTTP request. + * + * @param options The start options to validate. + * @throws IllegalArgumentException Thrown when the options contain a known conflict. + */ + public void validateStartOptions(@NotNull TusStartOptions options) { + TusStartOptionValidator.validate(options); + } + + /** + * Abort the currently active request, if any. + */ + public void abortUpload() { + abortCurrentRequest(); + } + + /** + * Abort an upload and optionally terminate the remote upload resource. + * + * @param uploader Uploader whose active request and upload URL should be aborted, or null to + * only abort the current request tracked by this client. + * @param terminateUpload True to issue a Termination request when the upload URL is known. + * @throws ProtocolException Thrown if the termination request receives an unexpected response. + * @throws IOException Thrown if the termination request fails. + */ + public void abortUpload(@Nullable TusUploader uploader, boolean terminateUpload) + throws ProtocolException, IOException { + if (uploader == null) { + abortCurrentRequest(); + return; + } + + uploader.abort(); + if (!terminateUpload) { + return; + } + + terminateUpload(uploader.getUploadURL()).disconnect(); + removeStoredUpload(uploader.getUpload()); + } + /** * Create a new upload using the Creation extension. Before calling this function, an "upload * creation URL" must be defined using {@link #setUploadCreationURL(URL)} or else this @@ -194,43 +309,401 @@ public int getConnectTimeout() { * @throws IOException Thrown if an exception occurs while issuing the HTTP request. */ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolException, IOException { + return createUpload(upload, 0); + } + + /** + * Create a partial upload using the Concatenation extension. + * + * @param upload The partial upload source. + * @return Use {@link TusUploader} to upload the partial file bytes. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public TusUploader createPartialUpload(@NotNull TusUpload upload) + throws ProtocolException, IOException { + return createUpload(upload, 0, true); + } + + /** + * Create a new upload and send the first bytes in the creation request using the + * Creation With Upload extension. Before calling this function, an "upload creation URL" + * must be defined using {@link #setUploadCreationURL(URL)} or else this function will fail. + * + * @param upload The file for which a new upload will be created + * @param bytesToUpload Number of bytes to include in the creation request body + * @return Use {@link TusUploader} to upload any remaining file chunks. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public TusUploader createUploadWithData( + @NotNull TusUpload upload, + int bytesToUpload + ) throws ProtocolException, IOException { + if (bytesToUpload < 0) { + throw new IllegalArgumentException("creation upload byte count must not be negative"); + } + if (bytesToUpload == 0) { + return createUpload(upload); + } + if (upload.isUploadLengthDeferred()) { + throw new IllegalArgumentException( + "creation with upload requires a known upload length" + ); + } + if (bytesToUpload > upload.getSize()) { + throw new IllegalArgumentException( + "creation upload byte count " + + bytesToUpload + + " exceeds upload size " + + upload.getSize() + ); + } + + return createUpload(upload, bytesToUpload); + } + + private TusUploader createUpload( + @NotNull TusUpload upload, + int bytesToUpload + ) throws ProtocolException, IOException { + return createUpload(upload, bytesToUpload, false); + } + + private TusUploader createUpload( + @NotNull TusUpload upload, + int bytesToUpload, + boolean partialUpload + ) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadCreationURL); - connection.setRequestMethod("POST"); + connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); prepareConnection(connection); + if (partialUpload) { + connection.setRequestProperty( + TusProtocol.CONCATENATION_HEADER_NAME, + TusProtocol.CONCATENATION_PARTIAL_VALUE + ); + } + prepareUploadCreationHeaders(connection, upload); + if (bytesToUpload > 0) { + prepareUploadBodyHeaders(connection, bytesToUpload >= upload.getSize()); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(bytesToUpload); + } + + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); + TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + try { + if (bytesToUpload > 0) { + writeUploadCreationData(connection, upload, bytesToUpload); + } else { + connection.connect(); + } + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + + int responseCode; + try { + responseCode = connection.getResponseCode(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw TusDetailedErrors.responseException( + TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + requestSnapshot, + connection + ); + } + + String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); + if (urlStr == null || urlStr.length() == 0) { + throw new ProtocolException("missing upload URL in response for creating upload", connection); + } + + // The upload URL must be relative to the URL of the request by which is was returned, + // not the upload creation URL. In most cases, there is no difference between those two + // but there may be cases in which the POST request is redirected. + URL uploadURL = new URL(connection.getURL(), urlStr); + + long offset = bytesToUpload > 0 + ? readUploadCreationOffset(connection, bytesToUpload) + : 0L; + + if (resumingEnabled) { + urlStore.set(upload.getFingerprint(), uploadURL); + } + + return createUploader(upload, uploadURL, offset, bytesToUpload > 0); + } finally { + clearCurrentRequest(connection); + } + } + + /** + * Create the final upload resource by concatenating partial upload URLs. + * + * @param uploadURLs Partial upload URLs in concatenation order. + * @param metadata Metadata for the final upload, or null. + * @return The final upload URL. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes or missing/invalid headers. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public URL concatenateUploads( + @NotNull List uploadURLs, + @Nullable Map metadata + ) throws ProtocolException, IOException { + if (uploadURLs.isEmpty()) { + throw new IllegalArgumentException("at least one partial upload URL is required"); + } + + HttpURLConnection connection = openConnection(uploadCreationURL); + connection.setRequestMethod(TusProtocol.CREATE_UPLOAD_METHOD); + prepareConnection(connection); + connection.setRequestProperty( + TusProtocol.CONCATENATION_HEADER_NAME, + finalUploadConcatValue(uploadURLs) + ); + prepareUploadMetadataHeaders(connection, metadata); + + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.CREATE_UPLOAD_METHOD, connection); + TusRequestSnapshot requestSnapshot = TusRequestSnapshot.fromConnection(connection); + try { + connection.connect(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + + int responseCode; + try { + responseCode = connection.getResponseCode(); + } catch (IOException error) { + throw TusDetailedErrors.requestException( + TusProtocol.DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED, + requestSnapshot, + error + ); + } + runAfterResponse(TusProtocol.CREATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw TusDetailedErrors.responseException( + TusProtocol.DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE, + requestSnapshot, + connection + ); + } + + String urlStr = connection.getHeaderField(TusProtocol.LOCATION_HEADER_NAME); + if (urlStr == null || urlStr.length() == 0) { + throw new ProtocolException( + "missing upload URL in response for concatenating uploads", + connection + ); + } + + return new URL(connection.getURL(), urlStr); + } finally { + clearCurrentRequest(connection); + } + } + + private static void prepareUploadCreationHeaders( + @NotNull HttpURLConnection connection, + @NotNull TusUpload upload + ) { String encodedMetadata = upload.getEncodedMetadata(); if (encodedMetadata.length() > 0) { - connection.setRequestProperty("Upload-Metadata", encodedMetadata); + connection.setRequestProperty(TusProtocol.METADATA_HEADER_NAME, encodedMetadata); } - connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize())); - connection.connect(); + if (upload.isUploadLengthDeferred()) { + connection.addRequestProperty(TusProtocol.UPLOAD_DEFER_LENGTH_HEADER_NAME, "1"); + } else { + connection.addRequestProperty( + TusProtocol.UPLOAD_LENGTH_HEADER_NAME, + Long.toString(upload.getSize()) + ); + } + } - int responseCode = connection.getResponseCode(); - if (!(responseCode >= 200 && responseCode < 300)) { - throw new ProtocolException( - "unexpected status code (" + responseCode + ") while creating upload", connection); + private static void prepareUploadMetadataHeaders( + @NotNull HttpURLConnection connection, + @Nullable Map metadata + ) { + String encodedMetadata = TusUpload.encodeMetadata(metadata); + if (encodedMetadata.length() > 0) { + connection.setRequestProperty(TusProtocol.METADATA_HEADER_NAME, encodedMetadata); } + } - String urlStr = connection.getHeaderField("Location"); - if (urlStr == null || urlStr.length() == 0) { - throw new ProtocolException("missing upload URL in response for creating upload", connection); + private void prepareUploadBodyHeaders( + @NotNull HttpURLConnection connection, + boolean requestCompletesUpload + ) { + final String contentType = TusProtocol.protocolUploadBodyContentType(protocol); + if (contentType != null) { + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + contentType + ); } - // The upload URL must be relative to the URL of the request by which is was returned, - // not the upload creation URL. In most cases, there is no difference between those two - // but there may be cases in which the POST request is redirected. - URL uploadURL = new URL(connection.getURL(), urlStr); + final String uploadCompleteHeaderName = + TusProtocol.protocolUploadCompleteHeaderName(protocol); + if (uploadCompleteHeaderName == null) { + return; + } - if (resumingEnabled) { - urlStore.set(upload.getFingerprint(), uploadURL); + connection.setRequestProperty( + uploadCompleteHeaderName, + TusProtocol.protocolUploadCompleteHeaderValue(protocol, requestCompletesUpload) + ); + } + + private static String finalUploadConcatValue(@NotNull List uploadURLs) { + StringBuilder value = new StringBuilder(TusProtocol.CONCATENATION_FINAL_PREFIX); + for (int index = 0; index < uploadURLs.size(); index++) { + if (index > 0) { + value.append(TusProtocol.CONCATENATION_UPLOAD_URL_SEPARATOR); + } + value.append(uploadURLs.get(index).toString()); } - return createUploader(upload, uploadURL, 0L); + return value.toString(); + } + + private static void writeUploadCreationData( + @NotNull HttpURLConnection connection, + @NotNull TusUpload upload, + int bytesToUpload + ) throws IOException { + TusInputStream input = upload.getTusInputStream(); + + byte[] buffer = new byte[Math.min(bytesToUpload, 8192)]; + int bytesRemaining = bytesToUpload; + try (OutputStream output = connection.getOutputStream()) { + while (bytesRemaining > 0) { + int bytesRead = input.read(buffer, Math.min(buffer.length, bytesRemaining)); + if (bytesRead == -1) { + throw new EOFException( + "upload source ended before creation request wrote " + + bytesToUpload + + " bytes" + ); + } + + output.write(buffer, 0, bytesRead); + bytesRemaining -= bytesRead; + } + } } + private static long readUploadCreationOffset( + @NotNull HttpURLConnection connection, + int bytesToUpload + ) throws ProtocolException { + String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); + if (offsetStr == null || offsetStr.length() == 0) { + throw new ProtocolException( + "missing upload offset in response for creating upload with data", + connection + ); + } + + long offset; + try { + offset = Long.parseLong(offsetStr); + } catch (NumberFormatException e) { + throw new ProtocolException( + "invalid upload offset in response for creating upload with data", + connection + ); + } + + if (offset != bytesToUpload) { + throw new ProtocolException( + "response contains different Upload-Offset value (" + + offset + + ") than expected (" + + bytesToUpload + + ")", + connection + ); + } + + return offset; + } + + /** + * Terminate an upload URL using the Termination extension. + * + * @param uploadURL The upload location URL to terminate. + * @return The completed HTTP connection. + * @throws ProtocolException Thrown if the remote server sent an unexpected response, e.g. + * wrong status codes. + * @throws IOException Thrown if an exception occurs while issuing the HTTP request. + */ + public HttpURLConnection terminateUpload(@NotNull URL uploadURL) + throws ProtocolException, IOException { + HttpURLConnection connection = openConnection(uploadURL); + connection.setRequestMethod(TusProtocol.TERMINATE_UPLOAD_METHOD); + prepareConnection(connection); + + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + connection.connect(); + + int responseCode = connection.getResponseCode(); + runAfterResponse(TusProtocol.TERMINATE_UPLOAD_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException( + "unexpected status code (" + responseCode + ") while terminating upload", + connection); + } + + return connection; + } finally { + clearCurrentRequest(connection); + } + } + + /** + * Opens the HTTP connection used by this client. + * + *

Subclasses may override this method to provide a custom transport for tests or specialized + * environments. Implementations should return a fresh {@link HttpURLConnection} for the given + * URL and must not connect it; callers configure headers, method, and hooks after this method + * returns. + * + * @param uploadURL The request URL. + * @return A new, unconnected HTTP connection. + * @throws IOException Thrown if a connection cannot be opened. + */ @NotNull - private HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { + protected HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOException { if (proxy != null) { return (HttpURLConnection) uploadURL.openConnection(proxy); } @@ -240,7 +713,25 @@ private HttpURLConnection openConnection(@NotNull URL uploadURL) throws IOExcept @NotNull private TusUploader createUploader(@NotNull TusUpload upload, @NotNull URL uploadURL, long offset) throws IOException { - TusUploader uploader = new TusUploader(this, upload, uploadURL, upload.getTusInputStream(), offset); + return createUploader(upload, uploadURL, offset, false); + } + + @NotNull + private TusUploader createUploader( + @NotNull TusUpload upload, + @NotNull URL uploadURL, + long offset, + boolean inputAlreadyAtOffset + ) + throws IOException { + TusUploader uploader = new TusUploader( + this, + upload, + uploadURL, + upload.getTusInputStream(), + offset, + inputAlreadyAtOffset + ); uploader.setProxy(proxy); return uploader; } @@ -296,24 +787,31 @@ public TusUploader resumeUpload(@NotNull TusUpload upload) throws public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNull URL uploadURL) throws ProtocolException, IOException { HttpURLConnection connection = openConnection(uploadURL); - connection.setRequestMethod("HEAD"); + connection.setRequestMethod(TusProtocol.OFFSET_DISCOVERY_METHOD); prepareConnection(connection); - connection.connect(); + registerCurrentRequest(connection); + try { + runBeforeRequest(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); + connection.connect(); + + int responseCode = connection.getResponseCode(); + runAfterResponse(TusProtocol.OFFSET_DISCOVERY_METHOD, connection); + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException( + "unexpected status code (" + responseCode + ") while resuming upload", connection); + } - int responseCode = connection.getResponseCode(); - if (!(responseCode >= 200 && responseCode < 300)) { - throw new ProtocolException( - "unexpected status code (" + responseCode + ") while resuming upload", connection); - } + String offsetStr = connection.getHeaderField(TusProtocol.UPLOAD_OFFSET_HEADER_NAME); + if (offsetStr == null || offsetStr.length() == 0) { + throw new ProtocolException("missing upload offset in response for resuming upload", connection); + } + long offset = Long.parseLong(offsetStr); - String offsetStr = connection.getHeaderField("Upload-Offset"); - if (offsetStr == null || offsetStr.length() == 0) { - throw new ProtocolException("missing upload offset in response for resuming upload", connection); + return createUploader(upload, uploadURL, offset); + } finally { + clearCurrentRequest(connection); } - long offset = Long.parseLong(offsetStr); - - return createUploader(upload, uploadURL, offset); } /** @@ -347,8 +845,8 @@ public TusUploader resumeOrCreateUpload(@NotNull TusUpload upload) throws Protoc } /** - * Set headers used for every HTTP request. Currently, this will add the Tus-Resumable header - * and any custom header which can be configured using {@link #setHeaders(Map)}, + * Set headers used for every HTTP request. Currently, this will add generated protocol default + * headers and any custom header which can be configured using {@link #setHeaders(Map)}, * * @param connection The connection whose headers will be modified. */ @@ -363,13 +861,56 @@ public void prepareConnection(@NotNull HttpURLConnection connection) { connection.setInstanceFollowRedirects(Boolean.getBoolean("http.strictPostRedirect")); connection.setConnectTimeout(connectTimeout); - connection.addRequestProperty("Tus-Resumable", TUS_VERSION); + TusProtocol.prepareRequestHeaders(connection, headers, addRequestId, protocol); + } - if (headers != null) { - for (Map.Entry entry : headers.entrySet()) { - connection.addRequestProperty(entry.getKey(), entry.getValue()); - } + final void runBeforeRequest( + @NotNull String method, @NotNull HttpURLConnection connection + ) throws IOException { + if (requestLifecycleHooks == null || requestLifecycleHooks.getBeforeRequest() == null) { + return; } + + requestLifecycleHooks.getBeforeRequest().beforeRequest( + new TusRequestLifecycleHooks.RequestContext(method, connection) + ); + } + + final void runAfterResponse( + @NotNull String method, @NotNull HttpURLConnection connection + ) throws IOException { + if (requestLifecycleHooks == null || requestLifecycleHooks.getAfterResponse() == null) { + return; + } + + requestLifecycleHooks.getAfterResponse().afterResponse( + new TusRequestLifecycleHooks.RequestContext(method, connection) + ); + } + + final void registerCurrentRequest(@NotNull HttpURLConnection connection) { + currentConnection = connection; + } + + final void clearCurrentRequest(@NotNull HttpURLConnection connection) { + if (currentConnection == connection) { + currentConnection = null; + } + } + + private void abortCurrentRequest() { + HttpURLConnection connection = currentConnection; + if (connection != null) { + connection.disconnect(); + } + } + + private void removeStoredUpload(@NotNull TusUpload upload) { + if (!resumingEnabled) { + return; + } + + urlStore.remove(upload.getFingerprint()); } /** diff --git a/src/main/java/io/tus/java/client/TusDetailedError.java b/src/main/java/io/tus/java/client/TusDetailedError.java new file mode 100644 index 00000000..7ee1b006 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusDetailedError.java @@ -0,0 +1,57 @@ +package io.tus.java.client; + +import java.net.URL; + +/** + * Exposes request and response context for TUS failures. + */ +public interface TusDetailedError { + /** + * Returns the lower-level cause for request failures, or null for response failures. + * + * @return Causing error, or null. + */ + Throwable getCausingError(); + + /** + * Returns the method of the original TUS request. + * + * @return HTTP method. + */ + String getOriginalRequestMethod(); + + /** + * Returns the request ID attached to the original TUS request. + * + * @return Request ID, or the generated missing-value marker. + */ + String getOriginalRequestId(); + + /** + * Returns the URL of the original TUS request. + * + * @return Request URL. + */ + URL getOriginalRequestURL(); + + /** + * Returns whether a response was available. + * + * @return True when a response was captured. + */ + boolean hasOriginalResponse(); + + /** + * Returns the captured response body. + * + * @return Response body, or the generated missing-value marker. + */ + String getOriginalResponseBody(); + + /** + * Returns the captured response status. + * + * @return Response status, or -1 when absent. + */ + int getOriginalResponseStatus(); +} diff --git a/src/main/java/io/tus/java/client/TusDetailedErrors.java b/src/main/java/io/tus/java/client/TusDetailedErrors.java new file mode 100644 index 00000000..5b9a45ae --- /dev/null +++ b/src/main/java/io/tus/java/client/TusDetailedErrors.java @@ -0,0 +1,131 @@ +package io.tus.java.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +final class TusDetailedErrors { + static TusRequestException requestException( + String baseMessage, + TusRequestSnapshot request, + IOException cause + ) { + return new TusRequestException( + detailedErrorMessage(baseMessage, cause, request, -1, null), + request, + cause + ); + } + + static TusResponseException responseException( + String baseMessage, + TusRequestSnapshot request, + HttpURLConnection connection + ) { + final int responseStatus = responseStatus(connection); + final String responseBody = responseBody(connection); + return new TusResponseException( + detailedErrorMessage(baseMessage, null, request, responseStatus, responseBody), + connection, + request, + responseStatus, + responseBody + ); + } + + private static String detailedErrorMessage( + String baseMessage, + IOException cause, + TusRequestSnapshot request, + int responseStatus, + String responseBody + ) { + String message = baseMessage; + if (cause != null) { + final Map causeValues = new LinkedHashMap(); + causeValues.put("message", missingIfEmpty(cause.getMessage())); + final String causeMessage = TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_CAUSE_STRING_TEMPLATE, + causeValues + ); + + final Map causedByValues = new LinkedHashMap(); + causedByValues.put("cause", causeMessage); + message += TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_CAUSED_BY_TEMPLATE, + causedByValues + ); + } + + final Map contextValues = new LinkedHashMap(); + contextValues.put("body", responseBody == null + ? TusProtocol.DETAILED_ERROR_MISSING_VALUE + : responseBody); + contextValues.put("method", request.method); + contextValues.put("requestId", request.requestId); + contextValues.put("status", responseStatus < 0 + ? TusProtocol.DETAILED_ERROR_MISSING_VALUE + : Integer.toString(responseStatus)); + contextValues.put("url", request.url.toString()); + + return message + TusProtocol.formatDetailedErrorMessage( + TusProtocol.DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE, + contextValues + ); + } + + private static String responseBody(HttpURLConnection connection) { + InputStream stream = connection.getErrorStream(); + if (stream == null) { + try { + stream = connection.getInputStream(); + } catch (IOException error) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + } + + try { + final byte[] body = readAllBytes(stream); + if (body.length == 0) { + return TusProtocol.DETAILED_ERROR_EMPTY_RESPONSE_BODY; + } + return new String(body, StandardCharsets.UTF_8); + } catch (IOException error) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + } + + private static int responseStatus(HttpURLConnection connection) { + try { + return connection.getResponseCode(); + } catch (IOException error) { + return -1; + } + } + + private static byte[] readAllBytes(InputStream stream) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + + return output.toByteArray(); + } + + private static String missingIfEmpty(String value) { + if (value == null || value.length() == 0) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + return value; + } + + private TusDetailedErrors() { + } +} diff --git a/src/main/java/io/tus/java/client/TusExecutor.java b/src/main/java/io/tus/java/client/TusExecutor.java index 19f7b902..4589d2af 100644 --- a/src/main/java/io/tus/java/client/TusExecutor.java +++ b/src/main/java/io/tus/java/client/TusExecutor.java @@ -32,6 +32,7 @@ */ public abstract class TusExecutor { private int[] delays = new int[]{500, 1000, 2000, 3000}; + private int retryAttempt; /** * Set the delays at which TusExecutor will issue a retry if {@link #makeAttempt()} throws an @@ -77,10 +78,8 @@ public int[] getDelays() { * @throws IOException */ public boolean makeAttempts() throws ProtocolException, IOException { - int attempt = -1; + retryAttempt = 0; while (true) { - attempt++; - try { makeAttempt(); // Returning true is the signal that the makeAttempt() function exited without @@ -88,26 +87,34 @@ public boolean makeAttempts() throws ProtocolException, IOException { return true; } catch (ProtocolException e) { // Do not attempt a retry, if the Exception suggests so. - if (!e.shouldRetry()) { + if (!shouldRetry(e, retryAttempt)) { throw e; } - if (attempt >= delays.length) { + if (retryAttempt >= delays.length) { // We exceeds the number of maximum retries. In this case the latest exception // is thrown. throw e; } } catch (IOException e) { - if (attempt >= delays.length) { + if (!shouldRetry(e, retryAttempt)) { + throw e; + } + + if (retryAttempt >= delays.length) { // We exceeds the number of maximum retries. In this case the latest exception // is thrown. throw e; } } + int delay = delays[retryAttempt]; + onRetryScheduled(retryAttempt, delay); + retryAttempt++; + try { // Sleep for the specified delay before attempting the next retry. - Thread.sleep(delays[attempt]); + Thread.sleep(delay); } catch (InterruptedException e) { // If we get interrupted while waiting for the next retry, the user has cancelled // the upload willingly and we return false as a signal. @@ -116,6 +123,44 @@ public boolean makeAttempts() throws ProtocolException, IOException { } } + /** + * Reset the retry attempt counter. Call this from {@link #makeAttempt()} after observing accepted + * server-side upload progress, so later retry decisions start from the first retry delay again. + */ + protected void resetRetryAttempts() { + retryAttempt = 0; + } + + /** + * Decide whether a protocol failure should be retried. + * + * @param exception Protocol failure from the current attempt. + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @return {@code true} if another attempt should be scheduled. + */ + protected boolean shouldRetry(ProtocolException exception, int retryAttempt) { + return exception.shouldRetry(); + } + + /** + * Decide whether an I/O failure should be retried. + * + * @param exception I/O failure from the current attempt. + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @return {@code true} if another attempt should be scheduled. + */ + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return true; + } + + /** + * Observe a scheduled retry. + * + * @param retryAttempt Zero-based retry attempt since the last successful progress reset. + * @param delayMillis Delay in milliseconds before the next attempt. + */ + protected void onRetryScheduled(int retryAttempt, int delayMillis) { } + /** * This method must be implemented by the specific caller. It will be invoked once or multiple * times by the {@link #makeAttempts()} method. 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..96499c42 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusProtocol.java @@ -0,0 +1,311 @@ +/* + * 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.net.HttpURLConnection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Generated TUS protocol constants used by the runtime client. + */ +final class TusProtocol { + static final String CONCATENATION_FINAL_PREFIX = + "final;"; + static final String CONCATENATION_HEADER_NAME = + "Upload-Concat"; + static final String CONCATENATION_PARTIAL_VALUE = + "partial"; + static final String CONCATENATION_UPLOAD_URL_SEPARATOR = + " "; + static final String CREATE_UPLOAD_METHOD = "POST"; + static final String DETAILED_ERROR_CAUSE_STRING_TEMPLATE = + "Error: {message}"; + static final String DETAILED_ERROR_CAUSED_BY_TEMPLATE = + ", caused by {cause}"; + static final String DETAILED_ERROR_CREATE_UPLOAD_REQUEST_FAILED = + "tus: failed to create upload"; + static final String DETAILED_ERROR_EMPTY_RESPONSE_BODY = + ""; + static final String DETAILED_ERROR_MISSING_VALUE = + "n/a"; + static final String DETAILED_ERROR_REQUEST_CONTEXT_TEMPLATE = + ", originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})"; + static final String DETAILED_ERROR_UNEXPECTED_CREATE_RESPONSE = + "tus: unexpected response while creating upload"; + static final String DEFAULT_PROTOCOL_VERSION = "1.0.0"; + static final String DEFAULT_CLIENT_PROTOCOL = "tus-v1"; + static final int DEFAULT_PARALLEL_UPLOADS = 1; + static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); + static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final String PROTOCOL_TUS_V1 = "tus-v1"; + static final String PROTOCOL_IETF_DRAFT_03 = "ietf-draft-03"; + static final String PROTOCOL_IETF_DRAFT_05 = "ietf-draft-05"; + private static final Map + CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS = clientProtocolCompatibilityVersions(); + static final String LOCATION_HEADER_NAME = "Location"; + static final String METADATA_HEADER_NAME = "Upload-Metadata"; + static final int MINIMUM_PARALLEL_UPLOADS = 2; + static final String OFFSET_DISCOVERY_METHOD = "HEAD"; + static final String REQUEST_ID_HEADER_NAME = "X-Request-ID"; + static final String START_OPTION_VALIDATION_MISSING_ENDPOINT_OR_UPLOAD_URL = + "tus: neither an endpoint or an upload URL is provided"; + static final String START_OPTION_VALIDATION_MISSING_INPUT = + "tus: no file or stream to upload provided"; + static final String START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_LENGTH_MISMATCH = + "tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`"; + static final String START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_WITHOUT_PARALLEL_UPLOADS = + "tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_DEFERRED_LENGTH = + "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION = + "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_SIZE = + "tus: cannot use the `uploadSize` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL = + "tus: cannot use the `uploadUrl` option when parallelUploads is enabled"; + static final String START_OPTION_VALIDATION_RETRY_DELAYS_NOT_ARRAY = + "tus: the `retryDelays` option must either be an array or null"; + static final String START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX = + "tus: unsupported protocol "; + static final int SUCCESS_RESPONSE_STATUS_CATEGORY = 200; + static final String TERMINATE_UPLOAD_METHOD = "DELETE"; + static final String UPLOAD_BODY_CONTENT_TYPE = "application/offset+octet-stream"; + static final String UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME = "Content-Type"; + static final String UPLOAD_CHUNK_METHOD = "PATCH"; + static final String UPLOAD_DEFER_LENGTH_HEADER_NAME = "Upload-Defer-Length"; + static final String UPLOAD_LENGTH_HEADER_NAME = "Upload-Length"; + static final String UPLOAD_OFFSET_HEADER_NAME = "Upload-Offset"; + + private TusProtocol() { + } + + static boolean isSuccessfulResponseStatus(int responseStatusCode) { + return responseStatusCode >= SUCCESS_RESPONSE_STATUS_CATEGORY + && responseStatusCode < SUCCESS_RESPONSE_STATUS_CATEGORY + 100; + } + + static String formatDetailedErrorMessage(String template, Map values) { + String result = template; + for (Map.Entry entry : values.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + static void prepareRequestHeaders( + HttpURLConnection connection, + Map customHeaders, + boolean addRequestId, + String protocolVersion + ) { + addProtocolRequestHeaders(connection, protocolVersion); + addCustomRequestHeaders(connection, customHeaders); + addRequestIdHeader(connection, addRequestId); + } + + static boolean isSupportedProtocol(String protocolVersion) { + return clientProtocolCompatibilityVersionFor(protocolVersion) != null; + } + + static String normalizeProtocol(String protocolVersion) { + if (protocolVersion == null + || protocolVersion.length() == 0 + || DEFAULT_PROTOCOL_VERSION.equals(protocolVersion)) { + return DEFAULT_CLIENT_PROTOCOL; + } + + if (!CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS.containsKey(protocolVersion)) { + return null; + } + + return protocolVersion; + } + + static String protocolUploadBodyContentType(String protocolVersion) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null) { + return null; + } + + return compatibilityVersion.uploadBodyContentType; + } + + static String protocolUploadCompleteHeaderName(String protocolVersion) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null || compatibilityVersion.uploadCompleteHeader == null) { + return null; + } + + return compatibilityVersion.uploadCompleteHeader.name; + } + + static String protocolUploadCompleteHeaderValue(String protocolVersion, boolean done) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null || compatibilityVersion.uploadCompleteHeader == null) { + return null; + } + + return compatibilityVersion.uploadCompleteHeader.value(done); + } + + private static Map defaultRequestHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map defaultResponseHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map + clientProtocolCompatibilityVersions() { + Map result = + new LinkedHashMap(); + result.put( + "tus-v1", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + {"Tus-Resumable", "1.0.0"}, + }), + stringMap(new String[][] { + {"Tus-Resumable", "1.0.0"}, + }), + "application/offset+octet-stream", + null + ) + ); + result.put( + "ietf-draft-03", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + {"Upload-Draft-Interop-Version", "5"}, + }), + stringMap(new String[0][0]), + null, + new UploadCompleteHeader("Upload-Complete", "?1", "?0") + ) + ); + result.put( + "ietf-draft-05", + new ClientProtocolCompatibilityVersion( + stringMap(new String[][] { + {"Upload-Draft-Interop-Version", "6"}, + }), + stringMap(new String[0][0]), + "application/partial-upload", + new UploadCompleteHeader("Upload-Complete", "?1", "?0") + ) + ); + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(String[][] entries) { + Map result = new LinkedHashMap(); + for (String[] entry : entries) { + result.put(entry[0], entry[1]); + } + + return Collections.unmodifiableMap(result); + } + + private static void addProtocolRequestHeaders( + HttpURLConnection connection, + String protocolVersion + ) { + ClientProtocolCompatibilityVersion compatibilityVersion = + clientProtocolCompatibilityVersionFor(protocolVersion); + if (compatibilityVersion == null) { + throw new IllegalArgumentException( + START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + protocolVersion + ); + } + + for (Map.Entry entry : compatibilityVersion.requestHeaders.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } + } + + private static ClientProtocolCompatibilityVersion clientProtocolCompatibilityVersionFor( + String protocolVersion + ) { + String normalizedProtocol = normalizeProtocol(protocolVersion); + if (normalizedProtocol == null) { + return null; + } + + return CLIENT_PROTOCOL_COMPATIBILITY_VERSIONS.get(normalizedProtocol); + } + + private static void addCustomRequestHeaders( + HttpURLConnection connection, + Map customHeaders + ) { + if (customHeaders == null) { + return; + } + + for (Map.Entry entry : customHeaders.entrySet()) { + connection.addRequestProperty(entry.getKey(), entry.getValue()); + } + } + + private static void addRequestIdHeader(HttpURLConnection connection, boolean addRequestId) { + if (!addRequestId) { + return; + } + + connection.setRequestProperty(REQUEST_ID_HEADER_NAME, UUID.randomUUID().toString()); + } + + private static final class ClientProtocolCompatibilityVersion { + private final Map requestHeaders; + private final Map responseHeaders; + private final String uploadBodyContentType; + private final UploadCompleteHeader uploadCompleteHeader; + + ClientProtocolCompatibilityVersion( + Map requestHeaders, + Map responseHeaders, + String uploadBodyContentType, + UploadCompleteHeader uploadCompleteHeader + ) { + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + this.uploadBodyContentType = uploadBodyContentType; + this.uploadCompleteHeader = uploadCompleteHeader; + } + } + + private static final class UploadCompleteHeader { + private final String name; + private final String completeValue; + private final String incompleteValue; + + UploadCompleteHeader(String name, String completeValue, String incompleteValue) { + this.name = name; + this.completeValue = completeValue; + this.incompleteValue = incompleteValue; + } + + private String value(boolean done) { + if (done) { + return completeValue; + } + + return incompleteValue; + } + } +} diff --git a/src/main/java/io/tus/java/client/TusRequestException.java b/src/main/java/io/tus/java/client/TusRequestException.java new file mode 100644 index 00000000..6850d332 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestException.java @@ -0,0 +1,51 @@ +package io.tus.java.client; + +import java.io.IOException; +import java.net.URL; + +/** + * An {@link IOException} with TUS request context. + */ +public final class TusRequestException extends IOException implements TusDetailedError { + private final TusRequestSnapshot snapshot; + + TusRequestException(String message, TusRequestSnapshot snapshot, IOException cause) { + super(message, cause); + this.snapshot = snapshot; + } + + @Override + public Throwable getCausingError() { + return getCause(); + } + + @Override + public String getOriginalRequestMethod() { + return snapshot.method; + } + + @Override + public String getOriginalRequestId() { + return snapshot.requestId; + } + + @Override + public URL getOriginalRequestURL() { + return snapshot.url; + } + + @Override + public boolean hasOriginalResponse() { + return false; + } + + @Override + public String getOriginalResponseBody() { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + @Override + public int getOriginalResponseStatus() { + return -1; + } +} diff --git a/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java new file mode 100644 index 00000000..52c4b862 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestLifecycleHooks.java @@ -0,0 +1,88 @@ +package io.tus.java.client; + +import java.io.IOException; +import java.net.HttpURLConnection; + +/** + * Callbacks invoked around each HTTP request/response pair. + */ +public final class TusRequestLifecycleHooks { + /** + * Request context passed to lifecycle hooks. + */ + public static final class RequestContext { + private final String method; + private final HttpURLConnection connection; + + RequestContext(String method, HttpURLConnection connection) { + this.method = method; + this.connection = connection; + } + + /** + * Get the logical TUS request method for this request. + * + * @return The request method. + */ + public String getMethod() { + return method; + } + + /** + * Get the HTTP connection for this request. + * + * @return The mutable HTTP connection. + */ + public HttpURLConnection getConnection() { + return connection; + } + } + + /** + * Callback invoked before transport sends the request. + */ + public interface BeforeRequest { + /** + * Handle a request before it is sent. + * + * @param context The request context. + * @throws IOException when the request should fail. + */ + void beforeRequest(RequestContext context) throws IOException; + } + + /** + * Callback invoked after transport receives the response. + */ + public interface AfterResponse { + /** + * Handle a response after it has been received. + * + * @param context The request context. + * @throws IOException when the response should fail. + */ + void afterResponse(RequestContext context) throws IOException; + } + + private final BeforeRequest beforeRequest; + private final AfterResponse afterResponse; + + /** + * Create request lifecycle hooks. + * + * @param beforeRequest Callback invoked before a request is sent. + * @param afterResponse Callback invoked after a response is received. + */ + public TusRequestLifecycleHooks(BeforeRequest beforeRequest, AfterResponse afterResponse) { + this.beforeRequest = beforeRequest; + this.afterResponse = afterResponse; + } + + BeforeRequest getBeforeRequest() { + return beforeRequest; + } + + AfterResponse getAfterResponse() { + return afterResponse; + } +} diff --git a/src/main/java/io/tus/java/client/TusRequestSnapshot.java b/src/main/java/io/tus/java/client/TusRequestSnapshot.java new file mode 100644 index 00000000..0a4be9bd --- /dev/null +++ b/src/main/java/io/tus/java/client/TusRequestSnapshot.java @@ -0,0 +1,33 @@ +package io.tus.java.client; + +import java.net.HttpURLConnection; +import java.net.URL; + +final class TusRequestSnapshot { + final String method; + final String requestId; + final URL url; + + private TusRequestSnapshot(String method, URL url, String requestId) { + this.method = method; + this.requestId = requestId; + this.url = url; + } + + static TusRequestSnapshot fromConnection(HttpURLConnection connection) { + return new TusRequestSnapshot( + connection.getRequestMethod(), + connection.getURL(), + requestId(connection) + ); + } + + private static String requestId(HttpURLConnection connection) { + final String requestId = connection.getRequestProperty(TusProtocol.REQUEST_ID_HEADER_NAME); + if (requestId == null || requestId.length() == 0) { + return TusProtocol.DETAILED_ERROR_MISSING_VALUE; + } + + return requestId; + } +} diff --git a/src/main/java/io/tus/java/client/TusResponseException.java b/src/main/java/io/tus/java/client/TusResponseException.java new file mode 100644 index 00000000..8079a832 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusResponseException.java @@ -0,0 +1,61 @@ +package io.tus.java.client; + +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * A {@link ProtocolException} with TUS request and response context. + */ +public final class TusResponseException extends ProtocolException implements TusDetailedError { + private final String responseBody; + private final int responseStatus; + private final TusRequestSnapshot snapshot; + + TusResponseException( + String message, + HttpURLConnection connection, + TusRequestSnapshot snapshot, + int responseStatus, + String responseBody + ) { + super(message, connection); + this.responseBody = responseBody; + this.responseStatus = responseStatus; + this.snapshot = snapshot; + } + + @Override + public Throwable getCausingError() { + return null; + } + + @Override + public String getOriginalRequestMethod() { + return snapshot.method; + } + + @Override + public String getOriginalRequestId() { + return snapshot.requestId; + } + + @Override + public URL getOriginalRequestURL() { + return snapshot.url; + } + + @Override + public boolean hasOriginalResponse() { + return true; + } + + @Override + public String getOriginalResponseBody() { + return responseBody; + } + + @Override + public int getOriginalResponseStatus() { + return responseStatus; + } +} diff --git a/src/main/java/io/tus/java/client/TusStartOptionValidator.java b/src/main/java/io/tus/java/client/TusStartOptionValidator.java new file mode 100644 index 00000000..8e7d35ae --- /dev/null +++ b/src/main/java/io/tus/java/client/TusStartOptionValidator.java @@ -0,0 +1,79 @@ +package io.tus.java.client; + +final class TusStartOptionValidator { + static void validate(TusStartOptions options) { + if (missingInput(options)) { + throw new IllegalArgumentException(TusProtocol.START_OPTION_VALIDATION_MISSING_INPUT); + } + + if (options.getEndpointURL() == null && options.getUploadURL() == null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_MISSING_ENDPOINT_OR_UPLOAD_URL + ); + } + + if (!TusProtocol.isSupportedProtocol(options.getProtocol())) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_UNSUPPORTED_PROTOCOL_PREFIX + + options.getProtocol() + ); + } + + final boolean parallelUploadsEnabled = + options.getParallelUploads() >= TusProtocol.MINIMUM_PARALLEL_UPLOADS; + final boolean parallelBoundariesSet = options.getParallelUploadBoundariesCount() >= 0; + + if (parallelBoundariesSet && !parallelUploadsEnabled) { + throw new IllegalArgumentException( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_WITHOUT_PARALLEL_UPLOADS + ); + } + + if (!parallelUploadsEnabled) { + return; + } + + if (options.getUploadURL() != null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL + ); + } + + if (options.getUploadSize() != null) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_SIZE + ); + } + + if (options.isUploadLengthDeferred() || options.getUpload().isUploadLengthDeferred()) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_DEFERRED_LENGTH + ); + } + + if (options.isUploadDataDuringCreation()) { + throw new IllegalArgumentException( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION + ); + } + + if (parallelBoundariesSet + && options.getParallelUploadBoundariesCount() != options.getParallelUploads()) { + throw new IllegalArgumentException( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_BOUNDARIES_LENGTH_MISMATCH + ); + } + } + + private static boolean missingInput(TusStartOptions options) { + return options == null + || options.getUpload() == null + || options.getUpload().getInputStream() == null; + } + + private TusStartOptionValidator() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/io/tus/java/client/TusStartOptions.java b/src/main/java/io/tus/java/client/TusStartOptions.java new file mode 100644 index 00000000..a5434dd1 --- /dev/null +++ b/src/main/java/io/tus/java/client/TusStartOptions.java @@ -0,0 +1,192 @@ +package io.tus.java.client; + +import org.jetbrains.annotations.Nullable; + +import java.net.URL; + +/** + * Options that can be validated before starting a TUS upload. + */ +public final class TusStartOptions { + private URL endpointURL; + private int parallelUploadBoundariesCount = -1; + private int parallelUploads = TusProtocol.DEFAULT_PARALLEL_UPLOADS; + private String protocol = TusProtocol.DEFAULT_PROTOCOL_VERSION; + private TusUpload upload; + private boolean uploadDataDuringCreation; + private boolean uploadLengthDeferred; + private Long uploadSize; + private URL uploadURL; + + /** + * Create default TUS start options. + */ + public TusStartOptions() { + } + + /** + * Return the endpoint URL used to create a new upload. + * + * @return The endpoint URL, or null. + */ + @Nullable + public URL getEndpointURL() { + return endpointURL; + } + + /** + * Set the endpoint URL used to create a new upload. + * + * @param endpointURL The endpoint URL, or null. + */ + public void setEndpointURL(@Nullable URL endpointURL) { + this.endpointURL = endpointURL; + } + + /** + * Return the number of explicit parallel upload boundaries. + * + * @return The boundary count, or -1 when unset. + */ + public int getParallelUploadBoundariesCount() { + return parallelUploadBoundariesCount; + } + + /** + * Set the number of explicit parallel upload boundaries. + * + * @param parallelUploadBoundariesCount The boundary count, or -1 when unset. + */ + public void setParallelUploadBoundariesCount(int parallelUploadBoundariesCount) { + this.parallelUploadBoundariesCount = parallelUploadBoundariesCount; + } + + /** + * Return the configured parallel upload count. + * + * @return The parallel upload count. + */ + public int getParallelUploads() { + return parallelUploads; + } + + /** + * Set the configured parallel upload count. + * + * @param parallelUploads The parallel upload count. + */ + public void setParallelUploads(int parallelUploads) { + this.parallelUploads = parallelUploads; + } + + /** + * Return the requested TUS protocol version. + * + * @return The protocol version. + */ + public String getProtocol() { + return protocol; + } + + /** + * Set the requested TUS protocol version. + * + * @param protocol The protocol version. + */ + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + /** + * Return the upload source. + * + * @return The upload source, or null. + */ + @Nullable + public TusUpload getUpload() { + return upload; + } + + /** + * Set the upload source. + * + * @param upload The upload source, or null. + */ + public void setUpload(@Nullable TusUpload upload) { + this.upload = upload; + } + + /** + * Return whether bytes should be sent during creation. + * + * @return True when creation with upload is requested. + */ + public boolean isUploadDataDuringCreation() { + return uploadDataDuringCreation; + } + + /** + * Set whether bytes should be sent during creation. + * + * @param uploadDataDuringCreation True to request creation with upload. + */ + public void setUploadDataDuringCreation(boolean uploadDataDuringCreation) { + this.uploadDataDuringCreation = uploadDataDuringCreation; + } + + /** + * Return whether upload length declaration should be deferred. + * + * @return True when upload length should be deferred. + */ + public boolean isUploadLengthDeferred() { + return uploadLengthDeferred; + } + + /** + * Set whether upload length declaration should be deferred. + * + * @param uploadLengthDeferred True to defer the upload length declaration. + */ + public void setUploadLengthDeferred(boolean uploadLengthDeferred) { + this.uploadLengthDeferred = uploadLengthDeferred; + } + + /** + * Return the configured upload size. + * + * @return The configured upload size, or null. + */ + @Nullable + public Long getUploadSize() { + return uploadSize; + } + + /** + * Set the configured upload size. + * + * @param uploadSize The configured upload size, or null. + */ + public void setUploadSize(@Nullable Long uploadSize) { + this.uploadSize = uploadSize; + } + + /** + * Return the existing upload URL to resume. + * + * @return The upload URL, or null. + */ + @Nullable + public URL getUploadURL() { + return uploadURL; + } + + /** + * Set the existing upload URL to resume. + * + * @param uploadURL The upload URL, or null. + */ + public void setUploadURL(@Nullable URL uploadURL) { + this.uploadURL = uploadURL; + } +} diff --git a/src/main/java/io/tus/java/client/TusURLFileStore.java b/src/main/java/io/tus/java/client/TusURLFileStore.java new file mode 100644 index 00000000..983cfb4b --- /dev/null +++ b/src/main/java/io/tus/java/client/TusURLFileStore.java @@ -0,0 +1,157 @@ +package io.tus.java.client; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +/** + * Persistent URL store backed by a local properties file. + */ +public class TusURLFileStore implements TusURLStore { + private static final String NAMESPACE = "tus"; + private static final String SEPARATOR = "::"; + + private final File file; + + /** + * Create a persistent URL store. + * + * @param file File used for storing upload URLs. + */ + public TusURLFileStore(@NotNull File file) { + this.file = file; + } + + /** + * Stores the upload's fingerprint and URL. + * + * @param fingerprint An upload's fingerprint. + * @param url The corresponding upload URL. + */ + @Override + public synchronized void set(String fingerprint, URL url) { + Properties properties = readProperties(); + properties.setProperty(newKey(fingerprint), url.toString()); + writeProperties(properties); + } + + /** + * Returns the first stored upload URL for a fingerprint. + * + * @param fingerprint An upload's fingerprint. + * @return The corresponding upload URL. + */ + @Override + public synchronized URL get(String fingerprint) { + Properties properties = readProperties(); + List keys = keysForFingerprint(properties, fingerprint); + if (keys.isEmpty()) { + return null; + } + + try { + return new URL(properties.getProperty(keys.get(0))); + } catch (MalformedURLException error) { + return null; + } + } + + /** + * Removes all stored upload URLs for a fingerprint. + * + * @param fingerprint An upload's fingerprint. + */ + @Override + public synchronized void remove(String fingerprint) { + Properties properties = readProperties(); + for (String key : keysForFingerprint(properties, fingerprint)) { + properties.remove(key); + } + writeProperties(properties); + } + + /** + * Returns the number of stored upload URLs. + * + * @return Stored upload URL count. + */ + public synchronized int size() { + return readProperties().size(); + } + + /** + * Returns whether a stored key starts with the given prefix. + * + * @param prefix Key prefix. + * @return True if a stored key starts with the prefix. + */ + public synchronized boolean hasKeyWithPrefix(String prefix) { + Properties properties = readProperties(); + for (Object key : properties.keySet()) { + if (String.valueOf(key).startsWith(prefix)) { + return true; + } + } + + return false; + } + + private String newKey(String fingerprint) { + return keyPrefix(fingerprint) + UUID.randomUUID().toString(); + } + + private static String keyPrefix(String fingerprint) { + return NAMESPACE + SEPARATOR + fingerprint + SEPARATOR; + } + + private static List keysForFingerprint(Properties properties, String fingerprint) { + final String prefix = keyPrefix(fingerprint); + final List result = new ArrayList(); + for (Object key : properties.keySet()) { + final String stringKey = String.valueOf(key); + if (stringKey.startsWith(prefix)) { + result.add(stringKey); + } + } + Collections.sort(result); + return result; + } + + private Properties readProperties() { + final Properties properties = new Properties(); + if (!file.exists()) { + return properties; + } + + try (FileInputStream input = new FileInputStream(file)) { + properties.load(input); + } catch (IOException error) { + throw new IllegalStateException("could not read TUS URL storage file", error); + } + + return properties; + } + + private void writeProperties(Properties properties) { + final File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IllegalStateException("could not create TUS URL storage directory"); + } + + try (FileOutputStream output = new FileOutputStream(file)) { + properties.store(output, "tus-java-client URL storage"); + } catch (IOException error) { + throw new IllegalStateException("could not write TUS URL storage file", error); + } + } +} diff --git a/src/main/java/io/tus/java/client/TusUpload.java b/src/main/java/io/tus/java/client/TusUpload.java index 5559af59..41f9678b 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 final upload 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. @@ -129,6 +150,10 @@ public Map getMetadata() { * @return Encoded metadata */ public String getEncodedMetadata() { + return encodeMetadata(metadata); + } + + static String encodeMetadata(Map metadata) { if (metadata == null || metadata.size() == 0) { return ""; } diff --git a/src/main/java/io/tus/java/client/TusUploader.java b/src/main/java/io/tus/java/client/TusUploader.java index d84bd8b4..a03841af 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,13 @@ 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 volatile boolean aborted; private HttpURLConnection connection; private OutputStream output; @@ -46,14 +78,29 @@ public class TusUploader { * @throws IOException Thrown if an exception occurs while issuing the HTTP request. */ public TusUploader(TusClient client, TusUpload upload, URL uploadURL, TusInputStream input, long offset) + throws IOException { + this(client, upload, uploadURL, input, offset, false); + } + + TusUploader( + TusClient client, + TusUpload upload, + URL uploadURL, + TusInputStream input, + long offset, + boolean inputAlreadyAtOffset + ) throws IOException { this.uploadURL = uploadURL; this.input = input; this.offset = offset; this.client = client; this.upload = upload; + uploadLengthDeclared = !upload.isUploadLengthDeferred(); - input.seekTo(offset); + if (!inputAlreadyAtOffset) { + input.seekTo(offset); + } setChunkSize(2 * 1024 * 1024); } @@ -65,6 +112,9 @@ private void openConnection() throws IOException, ProtocolException { } bytesRemainingForRequest = requestPayloadSize; + requestDeclaresUploadLength = false; + requestStartOffset = offset; + requestProgressStarted = false; input.mark(requestPayloadSize); if (proxy != null) { @@ -73,20 +123,27 @@ private void openConnection() throws IOException, ProtocolException { connection = (HttpURLConnection) uploadURL.openConnection(); } client.prepareConnection(connection); - connection.setRequestProperty("Upload-Offset", Long.toString(offset)); - connection.setRequestProperty("Content-Type", "application/offset+octet-stream"); + client.registerCurrentRequest(connection); + connection.setRequestProperty(TusProtocol.UPLOAD_OFFSET_HEADER_NAME, Long.toString(offset)); + if (shouldDeclareUploadLength()) { + connection.setRequestProperty(TusProtocol.UPLOAD_LENGTH_HEADER_NAME, Long.toString(upload.getSize())); + requestDeclaresUploadLength = true; + } + prepareUploadBodyHeaders(connection); connection.setRequestProperty("Expect", "100-continue"); try { - connection.setRequestMethod("PATCH"); + connection.setRequestMethod(TusProtocol.UPLOAD_CHUNK_METHOD); // Check whether we are running on a buggy JRE } catch (java.net.ProtocolException pe) { connection.setRequestMethod("POST"); - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + connection.setRequestProperty("X-HTTP-Method-Override", TusProtocol.UPLOAD_CHUNK_METHOD); } connection.setDoOutput(true); connection.setChunkedStreamingMode(0); + client.runBeforeRequest(TusProtocol.UPLOAD_CHUNK_METHOD, connection); + throwIfAborted(); try { output = connection.getOutputStream(); } catch (java.net.ProtocolException pe) { @@ -168,6 +225,60 @@ public int getRequestPayloadSize() { return requestPayloadSize; } + private boolean shouldDeclareUploadLength() { + if (uploadLengthDeclared) { + return false; + } + + return offset + requestPayloadSize >= upload.getSize(); + } + + private void prepareUploadBodyHeaders(HttpURLConnection connection) { + final String contentType = TusProtocol.protocolUploadBodyContentType(client.getProtocol()); + if (contentType != null) { + connection.setRequestProperty( + TusProtocol.UPLOAD_BODY_CONTENT_TYPE_HEADER_NAME, + contentType + ); + } + + final String uploadCompleteHeaderName = + TusProtocol.protocolUploadCompleteHeaderName(client.getProtocol()); + if (uploadCompleteHeaderName == null) { + return; + } + + connection.setRequestProperty( + uploadCompleteHeaderName, + TusProtocol.protocolUploadCompleteHeaderValue( + client.getProtocol(), + requestCompletesUpload() + ) + ); + } + + private boolean requestCompletesUpload() { + return upload.getSize() > 0 && offset + requestPayloadSize >= upload.getSize(); + } + + /** + * 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 @@ -183,7 +294,14 @@ public int getRequestPayloadSize() { * to the HTTP request. */ public int uploadChunk() throws IOException, ProtocolException { + throwIfAborted(); + if (isUploadComplete()) { + return -1; + } + openConnection(); + throwIfAborted(); + notifyProgressAtRequestStart(); int bytesToRead = Math.min(getChunkSize(), bytesRemainingForRequest); @@ -196,11 +314,20 @@ public int uploadChunk() throws IOException, ProtocolException { // Do not write the entire buffer to the stream since the array will // be filled up with 0x00s if the number of read bytes is lower then // the chunk's size. - output.write(buffer, 0, bytesRead); - output.flush(); + try { + output.write(buffer, 0, bytesRead); + output.flush(); + } catch (IOException error) { + if (aborted) { + cleanupConnection(); + } + throw error; + } + throwIfAborted(); offset += bytesRead; bytesRemainingForRequest -= bytesRead; + notifyProgress(offset); if (bytesRemainingForRequest <= 0) { finishConnection(); @@ -235,7 +362,13 @@ public int uploadChunk() throws IOException, ProtocolException { * to the HTTP request. */ @Deprecated public int uploadChunk(int chunkSize) throws IOException, ProtocolException { + throwIfAborted(); + if (isUploadComplete()) { + return -1; + } + openConnection(); + throwIfAborted(); byte[] buf = new byte[chunkSize]; int bytesRead = input.read(buf, chunkSize); @@ -247,8 +380,16 @@ public int uploadChunk() throws IOException, ProtocolException { // Do not write the entire buffer to the stream since the array will // be filled up with 0x00s if the number of read bytes is lower then // the chunk's size. - output.write(buf, 0, bytesRead); - output.flush(); + try { + output.write(buf, 0, bytesRead); + output.flush(); + } catch (IOException error) { + if (aborted) { + cleanupConnection(); + } + throw error; + } + throwIfAborted(); offset += bytesRead; @@ -274,6 +415,26 @@ public URL getUploadURL() { return uploadURL; } + /** + * Abort the active upload request, if any. + */ + public void abort() { + aborted = true; + HttpURLConnection currentConnection = connection; + if (currentConnection != null) { + currentConnection.disconnect(); + } + } + + /** + * Get whether this uploader has been aborted. + * + * @return True when {@link #abort()} has been called. + */ + public boolean isAborted() { + return aborted; + } + /** * Set the proxy that will be used when uploading. * @@ -331,34 +492,85 @@ public void finish(boolean closeInputStream) throws ProtocolException, IOExcepti } private void finishConnection() throws ProtocolException, IOException { - if (output != null) { - output.close(); + if (aborted) { + cleanupConnection(); + return; } if (connection != null) { - int responseCode = connection.getResponseCode(); - connection.disconnect(); - - if (!(responseCode >= 200 && responseCode < 300)) { - throw new ProtocolException("unexpected status code (" + responseCode + ") while uploading chunk", - connection); + HttpURLConnection currentConnection = connection; + try { + if (output != null) { + output.close(); + } + + int responseCode = currentConnection.getResponseCode(); + client.runAfterResponse(TusProtocol.UPLOAD_CHUNK_METHOD, currentConnection); + + if (!TusProtocol.isSuccessfulResponseStatus(responseCode)) { + throw new ProtocolException("unexpected status code (" + responseCode + ") while uploading chunk", + currentConnection); + } + + // TODO detect changes and seek accordingly + long serverOffset = getHeaderFieldLong( + currentConnection, + TusProtocol.UPLOAD_OFFSET_HEADER_NAME + ); + if (serverOffset == -1) { + throw new ProtocolException("response to PATCH request contains no or invalid Upload-Offset header", + currentConnection); + } + if (offset != serverOffset) { + throw new ProtocolException( + String.format("response contains different Upload-Offset value (%d) than expected (%d)", + serverOffset, + offset), + currentConnection); + } + + if (requestDeclaresUploadLength) { + uploadLengthDeclared = true; + } + notifyChunkComplete(serverOffset - requestStartOffset, serverOffset); + } finally { + cleanupConnection(); } + } + } - // TODO detect changes and seek accordingly - long serverOffset = getHeaderFieldLong(connection, "Upload-Offset"); - if (serverOffset == -1) { - throw new ProtocolException("response to PATCH request contains no or invalid Upload-Offset header", - connection); - } - if (offset != serverOffset) { - throw new ProtocolException( - String.format("response contains different Upload-Offset value (%d) than expected (%d)", - serverOffset, - offset), - connection); - } + private void cleanupConnection() { + HttpURLConnection currentConnection = connection; + if (currentConnection != null) { + currentConnection.disconnect(); + client.clearCurrentRequest(currentConnection); + } + connection = null; + output = null; + requestDeclaresUploadLength = false; + requestProgressStarted = false; + } - connection = null; + private void notifyProgressAtRequestStart() { + if (!requestProgressStarted) { + notifyProgress(offset); + requestProgressStarted = true; + } + } + + private void notifyProgress(long bytesSent) { + if (progressListener != null) { + progressListener.onProgress(bytesSent, upload.getSize()); + } + } + + private boolean isUploadComplete() { + return upload.getSize() > 0 && offset >= upload.getSize(); + } + + private void notifyChunkComplete(long chunkSize, long bytesAccepted) { + if (chunkCompleteListener != null) { + chunkCompleteListener.onChunkComplete(chunkSize, bytesAccepted, upload.getSize()); } } @@ -374,4 +586,14 @@ private long getHeaderFieldLong(URLConnection connection, String field) { return -1; } } + + final TusUpload getUpload() { + return upload; + } + + private void throwIfAborted() throws IOException { + if (aborted) { + throw new IOException("upload aborted"); + } + } } 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..06571911 --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusClientConformanceScenarios.java @@ -0,0 +1,1348 @@ +/* + * 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( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "single-upload-lifecycle", + "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-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", + "success", + null, + "creationWithUpload", + "creationWithUpload" + ), + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload-partial-chunk", + "success", + null, + "creationWithUpload", + "creationWithUploadPartialChunk" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", + "success", + null, + "protocolVersionSelection", + "ietfDraft05CreationWithUpload" + ), + new String[] { + "createTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete" + ), + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength" + ), + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "missingInput", + "startOptionValidation", + "startValidationMissingInput" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "missingEndpointOrUploadUrl", + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "unsupportedProtocol", + "startOptionValidation", + "startValidationUnsupportedProtocol" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "retryDelaysNotArray", + "startOptionValidation", + "startValidationRetryDelaysNotArray" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadUrl", + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadSize", + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithDeferredLength", + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelUploadsWithUploadDataDuringCreation", + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelBoundariesWithoutParallelUploads", + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", + "error", + "parallelBoundariesLengthMismatch", + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch" + ), + new String[0], + new String[] { + "validate-start-options", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", + "error", + "unexpectedCreateResponse", + "detailedErrors", + "detailedCreateResponseError" + ), + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", + "error", + "createUploadRequestFailed", + "detailedErrors", + "detailedCreateRequestError" + ), + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", + "success", + null, + "uploadBodyHeaders", + "uploadBodyHeaders" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "custom-request-headers", + "success", + null, + "customRequestHeaders", + "customRequestHeaders" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-id-headers", + "success", + null, + "requestIdHeaders", + "requestIdHeaders" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "apply-custom-request-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "resume-from-previous-upload", + "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-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "relative-location-resolution", + "success", + null, + "relativeLocationResolution", + "relativeLocationResolution" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "resolve-relative-location", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-input", + "success", + null, + "inputSources", + "arrayBufferInput" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-view-input", + "success", + null, + "inputSources", + "arrayBufferViewInput" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "web-readable-stream-input", + "success", + null, + "inputSources", + "webReadableStreamInput" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-web-stream", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-readable-stream-input", + "success", + null, + "inputSources", + "nodeReadableStreamInput" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-stream", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-path-input", + "success", + null, + "inputSources", + "nodePathInput" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", + "success", + null, + "deferredLengthUpload", + "deferredLengthUpload" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + "allow-known-total-before-declaration", + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", + "success", + null, + "deferredLengthUpload", + "deferredLengthChunkedUpload" + ), + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-chunk-complete", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + "allow-known-total-before-declaration", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "override-patch-method", + "success", + null, + "overridePatchMethod", + "overridePatchMethod" + ), + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[0], + new String[0][0], + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-concat", + "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-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-abort-cleanup", + "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, + null + ), + new String[] { + "request-abort:3", + }, + new String[][] { + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "retry-patch-after-offset-recovery", + "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, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-lifecycle-hooks", + "success", + null, + "requestLifecycleHooks", + "requestLifecycleHooks" + ), + new String[] { + "getTusUploadOffset", + }, + new String[] { + "run-request-hooks", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload", + "aborted", + null, + "abortUpload", + "abortUpload" + ), + new String[] { + "createTusUpload", + }, + new String[] { + "abort-current-request", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "request-abort:0", + }, + new String[][] { + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload-after-stored-url", + "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, + null + ), + new String[] { + "request-abort:1", + }, + new String[][] { + new String[0], + }, + new String[0] + ) + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "terminate-with-retry", + "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, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + }, + new String[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..1c45dd50 --- /dev/null +++ b/src/test/java/io/tus/java/client/GeneratedTusProtocolContract.java @@ -0,0 +1,1732 @@ +/* + * 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.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Generated TUS protocol contract fixture used by tests. + */ +final class GeneratedTusProtocolContract { + static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); + static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + + 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 GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + 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 GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + 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 GeneratedTusResponseContract( + 423, + "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 String OFFSET_DISCOVERY_OPERATION_ID = + "getTusUploadOffset"; + static final String OFFSET_DISCOVERY_METHOD = operationMethod(OFFSET_DISCOVERY_OPERATION_ID); + + 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", + "deferredLengthChunkedUpload", + }, + "covered-by-generated-scenario" + ), + "Create an upload without a known length and declare the length on the final upload request.", + "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 upload request reveals the total size." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Declare Upload-Length on the final upload request." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-chunk-complete", + "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[] { + "requestIdHeaders", + }, + "covered-by-generated-scenario" + ), + "Add generated request IDs after protocol and custom request headers.", + "requestIdHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "add-request-id-header", + "", + "Generate a request ID and apply it after custom request headers so it is authoritative." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with a generated request ID." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with a generated request ID." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "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", + "ietfDraft05ChunkedUploadComplete", + "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 \"transportProfileId\": \"java-http-url-connection\"\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 \"transportProfileId\": \"java-http-url-connection\"\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() { + } + + private static String operationMethod(String operationId) { + for (GeneratedTusProtocolOperation operation : OPERATIONS) { + if (operationId.equals(operation.operationId)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated operation " + operationId); + } + + private static Map defaultRequestHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map defaultResponseHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + /** + * 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 metadata. + */ + static final class GeneratedTusClientConformanceScenarioMetadata { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + + GeneratedTusClientConformanceScenarioMetadata( + String behavior, + String completionKind, + String completionReason, + String featureId, + String scenarioId) { + this.behavior = behavior; + this.completionKind = completionKind; + this.completionReason = completionReason; + this.featureId = featureId; + this.scenarioId = scenarioId; + } + } + + /** + * 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; + final String[][] eventKeyAlternativeGroups; + final String[] eventKeyExtraPrefixes; + + GeneratedTusClientConformanceScenario( + GeneratedTusClientConformanceScenarioMetadata metadata, + String[] operationIds, + String[] primitives, + GeneratedTusClientConformanceEvents events) { + this.behavior = metadata.behavior; + this.completionKind = metadata.completionKind; + this.completionReason = metadata.completionReason; + this.featureId = metadata.featureId; + this.scenarioId = metadata.scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + this.eventPolicy = events.policy; + this.eventKeys = events.keys; + this.eventKeyAlternativeGroups = events.alternativeGroups; + this.eventKeyExtraPrefixes = events.extraPrefixes; + } + } + + /** + * Generated client conformance event fixture bundle. + */ + static final class GeneratedTusClientConformanceEvents { + final GeneratedTusClientConformanceEventPolicy policy; + final String[] keys; + final String[][] alternativeGroups; + final String[] extraPrefixes; + + GeneratedTusClientConformanceEvents( + GeneratedTusClientConformanceEventPolicy policy, + String[] keys, + String[][] alternativeGroups, + String[] extraPrefixes) { + this.policy = policy; + this.keys = keys; + this.alternativeGroups = alternativeGroups; + this.extraPrefixes = extraPrefixes; + } + } + + /** + * Generated client conformance event policy fixture. + */ + static final class GeneratedTusClientConformanceEventPolicy { + final String matching; + final String deferredLengthBytesTotal; + final String progress; + final String transportProgress; + + GeneratedTusClientConformanceEventPolicy( + String matching, + String deferredLengthBytesTotal, + String progress, + String transportProgress) { + this.matching = matching; + this.deferredLengthBytesTotal = deferredLengthBytesTotal; + this.progress = progress; + this.transportProgress = transportProgress; + } + } + +} diff --git a/src/test/java/io/tus/java/client/MockServerProvider.java b/src/test/java/io/tus/java/client/MockServerProvider.java index 791b9a31..ec70d90a 100644 --- a/src/test/java/io/tus/java/client/MockServerProvider.java +++ b/src/test/java/io/tus/java/client/MockServerProvider.java @@ -3,9 +3,12 @@ import org.junit.After; import org.junit.Before; import org.mockserver.client.MockServerClient; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; import org.mockserver.socket.PortFactory; import java.net.URL; +import java.util.Map; import static org.mockserver.integration.ClientAndServer.startClientAndServer; @@ -36,4 +39,18 @@ public void setUp() throws Exception { public void tearDown() { mockServer.stop(); } + + protected final HttpRequest withDefaultProtocolRequestHeaders(HttpRequest request) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + request.withHeader(entry.getKey(), entry.getValue()); + } + return request; + } + + protected final HttpResponse withDefaultProtocolResponseHeaders(HttpResponse response) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + return response; + } } 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..f57659e5 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusConformanceEvents.java @@ -0,0 +1,862 @@ +/* + * 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-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "creationWithUpload", + "creationWithUploadPartialChunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "resumeUpload", + "resumeFromPreviousUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "relativeLocationResolution", + "relativeLocationResolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "arrayBufferViewInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "webReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodeReadableStreamInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "inputSources", + "nodePathInput", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "deferredLengthUpload", + "deferredLengthUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + "allow-known-total-before-declaration", + "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 String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "deferredLengthUpload", + "deferredLengthChunkedUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + "allow-known-total-before-declaration", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadConcat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-allowed-extra-events", + null, + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + new GeneratedTusEventCanaryCase( + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "request-abort:3", + }, + new String[][] { + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "requestLifecycleHooks", + "requestLifecycleHooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUpload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "request-abort:0", + }, + new String[][] { + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "abortUpload", + "abortUploadAfterStoredUrl", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "request-abort:1", + }, + new String[][] { + new String[0], + }, + new String[0] + ), + new GeneratedTusEventCanaryCase( + "terminateUpload", + "terminateWithRetry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + }, + new String[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); + assertStringMatrixEquals( + testCase.eventKeyAlternativeGroups, + scenario.eventKeyAlternativeGroups); + assertArrayEquals(testCase.eventKeyExtraPrefixes, scenario.eventKeyExtraPrefixes); + } + } + + /** + * 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.deferredLengthBytesTotal, actual.deferredLengthBytesTotal); + assertEquals(expected.progress, actual.progress); + assertEquals(expected.transportProgress, actual.transportProgress); + } + + private static void assertStringMatrixEquals(String[][] expected, String[][] actual) { + assertEquals(expected.length, actual.length); + for (int index = 0; index < expected.length; index += 1) { + assertArrayEquals(expected[index], actual[index]); + } + } + + private static final class GeneratedTusEventCanaryCase { + final String featureId; + final String scenarioId; + final GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy; + final String[] eventKeys; + final String[][] eventKeyAlternativeGroups; + final String[] eventKeyExtraPrefixes; + + GeneratedTusEventCanaryCase( + String featureId, + String scenarioId, + GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy eventPolicy, + String[] eventKeys, + String[][] eventKeyAlternativeGroups, + String[] eventKeyExtraPrefixes) { + this.featureId = featureId; + this.scenarioId = scenarioId; + this.eventPolicy = eventPolicy; + this.eventKeys = eventKeys; + this.eventKeyAlternativeGroups = eventKeyAlternativeGroups; + this.eventKeyExtraPrefixes = eventKeyExtraPrefixes; + } + } + + 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..80274448 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusManagedUploadRuntime.java @@ -0,0 +1,1405 @@ +/* + * 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" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false + ), + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } + ), + new GeneratedTusManagedUploadOutcomeExpectations( + false, + false, + true, + true + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + true, + false, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + -1, + false + ) + ), + new GeneratedTusManagedUploadStateExpectations( + true, + false, + false + ), + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( + "hello managed!", + 7, + "managed-durable-retry-fingerprint", + "managed-durable-retry", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + true, + false, + false, + "io-error", + 7 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 201, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC50eHQ=" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ) + ), + new GeneratedTusManagedUploadRequest( + "POST", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "running", + "succeeded", + null, + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "HEAD", + "upload", + 0, + 200, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) + ), + new GeneratedTusManagedUploadRequest( + "POST", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ) + ), + } + ), + } + ) + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadPermanentFailure" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false + ), + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + false, + false, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + -1, + false + ) + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false + ), + new GeneratedTusManagedUploadWorkload( + 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, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + false, + false, + true, + "unretryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 400, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) + ), + } + ), + } + ) + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadRetryPolicyExhausted" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false + ), + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed", + }, + new int[] { + 0, + 0, + } + ), + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + -1, + false + ) + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false + ), + new GeneratedTusManagedUploadWorkload( + 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, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + false, + false, + true, + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] + ) + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + false, + false, + true, + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] + ) + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 2, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + false, + false, + true, + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } + ), + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] + ) + ), + } + ), + } + ) + ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadSourceUnavailable" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + false, + true, + false + ), + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + false, + true, + 0, + true + ) + ), + new GeneratedTusManagedUploadStateExpectations( + false, + false, + false + ), + new GeneratedTusManagedUploadWorkload( + 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, + "running", + "failed", + new GeneratedTusManagedUploadFailure( + false, + true, + false, + "source-unavailable", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + + } + ), + } + ) + ), + }; + + /** + * 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, testCase.initialState); + + 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 (!testCase.expectTerminalResult) { + throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); + } + + try { + boolean result = future.get(); + if (!testCase.expectTerminalSuccess) { + throw new AssertionError(testCase.scenarioId + " expected terminal failure"); + } + assertTrue(testCase.scenarioId, result); + } catch (ExecutionException error) { + if (!testCase.expectTerminalFailure) { + throw error; + } + assertTerminalFailure(testCase, error.getCause()); + } + } + + private void assertTerminalFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + Throwable error) { + if (testCase.expectProtocolExceptionOnTerminalFailure && error instanceof ProtocolException) { + assertTrue(testCase.scenarioId, error instanceof ProtocolException); + return; + } + if (testCase.expectIoExceptionOnTerminalFailure && error instanceof IOException) { + assertTrue(testCase.scenarioId, error instanceof IOException); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " observed unexpected generated terminal failure " + + error); + } + + private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { + if ( + !testCase.expectDeferredNetworkResult + || !testCase.deferBeforeProtocol + || testCase.networkConstraintSatisfied) { + throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); + } + } + + 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, attempt.stateBeforeAttempt); + + 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.failureMessage); + } + } + 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 + && attempt.failure.failAfterAcceptedOffset; + } + + private void recordDuringProtocolFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + File stateFile, + GeneratedTusManagedUploadAttempt attempt) throws IOException { + if (attempt.failure == null || !attempt.failure.failDuringProtocolRequest) { + 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 (!testCase.copySourceToOwnedStorage) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source durability capability"); + } + + 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 (testCase.prepareDurableSourceBeforeProtocol) { + copyDurableSource(testCase, source, ownedSource); + return; + } + if (testCase.simulateMissingSourceBeforeDurableCopy) { + GeneratedTusManagedUploadAttempt attempt = testCase.sourcePreparationFailureAttempt; + if (attempt == null) { + throw new AssertionError( + testCase.scenarioId + + " is missing generated source preparation failure attempt"); + } + if (source.exists() && !source.delete()) { + throw new IOException("Could not remove generated input source " + source); + } + recordState(testCase, states, stateFile, attempt.stateBeforeAttempt); + 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 preparation expectations"); + } + + private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return testCase.sourceUnavailableBeforeProtocol; + } + + private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return testCase.deferBeforeProtocol; + } + + private void cleanupAfterTerminalState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + if (!testCase.cleanupOwnedSourceAfterTerminalState) { + return; + } + + Files.deleteIfExists(ownedSource.toPath()); + } + + private void assertOwnedSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) { + if (testCase.expectOwnedSourceExists) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } + + assertFalse(testCase.scenarioId, ownedSource.exists()); + } + + private void assertInputSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + if (testCase.expectInputSourceExists) { + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + return; + } + + assertFalse(testCase.scenarioId, source.exists()); + } + + private void assertResumeUrlState( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadUrlStore urlStore) { + if (testCase.expectResumeUrlExists) { + assertTrue(testCase.scenarioId, urlStore.get(testCase.input.fingerprint) != null); + return; + } + + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + } + + 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 (!testCase.useFilesystemStateBackend) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated state backend capability"); + } + + 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)) + .respond(responseFor(testCase, request)); + } + } + } + + private HttpRequest requestFor( + GeneratedTusManagedUploadRuntimeCase testCase, + GeneratedTusManagedUploadRequest request) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(request.method) + .withPath(pathFor(testCase, request)); + if (request.requestHeaders.includesDefaultProtocolHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + httpRequest.withHeader(entry.getKey(), entry.getValue()); + } + } + for (GeneratedTusManagedUploadHeader header : request.requestHeaders.headers) { + httpRequest.withHeader(header.name, header.value); + } + return httpRequest; + } + + 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); + if (request.responseHeaders.includesDefaultProtocolHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + } + for (GeneratedTusManagedUploadHeader header : request.responseHeaders.headers) { + 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() { + return GeneratedTusProtocolContract.OFFSET_DISCOVERY_METHOD; + } + + private static final class GeneratedTusManagedUploadRuntimeCase { + final String scenarioId; + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; + final String initialState; + final String locationHeaderName; + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + final boolean expectInputSourceExists; + final boolean expectOwnedSourceExists; + final boolean expectResumeUrlExists; + final String[] expectedStates; + final int[] retryDelays; + final String offsetDiscoveryMethod; + final GeneratedTusManagedUploadInput input; + final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadAttempt sourcePreparationFailureAttempt; + + GeneratedTusManagedUploadRuntimeCase( + GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, + GeneratedTusManagedUploadRuntimePlan runtimePlan, + GeneratedTusManagedUploadOutcomeExpectations outcomeExpectations, + GeneratedTusManagedUploadExecution execution, + GeneratedTusManagedUploadStateExpectations stateExpectations, + GeneratedTusManagedUploadWorkload workload) { + this.scenarioId = profile.scenarioId; + this.copySourceToOwnedStorage = runtimeCapabilities.copySourceToOwnedStorage; + this.useDurableOsScheduler = runtimeCapabilities.useDurableOsScheduler; + this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = + runtimeCapabilities.usePlatformKeyValueStateBackend; + this.initialState = runtimePlan.initialState; + this.locationHeaderName = runtimePlan.locationHeaderName; + this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; + this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; + this.expectTerminalResult = outcomeExpectations.expectTerminalResult; + this.expectTerminalSuccess = outcomeExpectations.expectTerminalSuccess; + this.cleanupOwnedSourceAfterTerminalState = execution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = execution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = execution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = execution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = execution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = execution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = execution.simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = execution.sourceUnavailableBeforeProtocol; + this.expectInputSourceExists = stateExpectations.inputSourceExists; + this.expectOwnedSourceExists = stateExpectations.ownedSourceExists; + this.expectResumeUrlExists = stateExpectations.resumeUrlExists; + this.expectedStates = runtimePlan.expectedStates; + this.retryDelays = runtimePlan.retryDelays; + this.offsetDiscoveryMethod = offsetDiscoveryMethod(); + this.input = workload.input; + this.attempts = workload.attempts; + this.sourcePreparationFailureAttempt = + execution.sourcePreparationFailureAttemptIndex < 0 + ? null + : workload.attempts[execution.sourcePreparationFailureAttemptIndex]; + } + } + + private static final class GeneratedTusManagedUploadOutcomeExpectations { + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; + + GeneratedTusManagedUploadOutcomeExpectations( + boolean expectDeferredNetworkResult, + boolean expectTerminalFailure, + boolean expectTerminalResult, + boolean expectTerminalSuccess) { + this.expectDeferredNetworkResult = expectDeferredNetworkResult; + this.expectTerminalFailure = expectTerminalFailure; + this.expectTerminalResult = expectTerminalResult; + this.expectTerminalSuccess = expectTerminalSuccess; + } + } + + private static final class GeneratedTusManagedUploadRuntimeProfile { + final String scenarioId; + + GeneratedTusManagedUploadRuntimeProfile(String scenarioId) { + this.scenarioId = scenarioId; + } + } + + private static final class GeneratedTusManagedUploadRuntimeCapabilities { + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; + + GeneratedTusManagedUploadRuntimeCapabilities( + boolean copySourceToOwnedStorage, + boolean useDurableOsScheduler, + boolean useFilesystemStateBackend, + boolean usePlatformKeyValueStateBackend) { + this.copySourceToOwnedStorage = copySourceToOwnedStorage; + this.useDurableOsScheduler = useDurableOsScheduler; + this.useFilesystemStateBackend = useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = usePlatformKeyValueStateBackend; + } + } + + private static final class GeneratedTusManagedUploadRuntimePlan { + final String[] expectedStates; + final String initialState; + final String locationHeaderName; + final int[] retryDelays; + + GeneratedTusManagedUploadRuntimePlan( + String locationHeaderName, + String initialState, + String[] expectedStates, + int[] retryDelays) { + this.expectedStates = expectedStates; + this.initialState = initialState; + this.locationHeaderName = locationHeaderName; + this.retryDelays = retryDelays; + } + } + + private static final class GeneratedTusManagedUploadExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadExecution( + GeneratedTusManagedUploadTerminalExecution terminalExecution, + GeneratedTusManagedUploadSchedulingExecution schedulingExecution, + GeneratedTusManagedUploadSourceExecution sourceExecution) { + this.cleanupOwnedSourceAfterTerminalState = + terminalExecution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = schedulingExecution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = + terminalExecution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = + terminalExecution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = schedulingExecution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = + sourceExecution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = + sourceExecution.simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = + sourceExecution.sourcePreparationFailureAttemptIndex; + this.sourceUnavailableBeforeProtocol = sourceExecution.sourceUnavailableBeforeProtocol; + } + } + + private static final class GeneratedTusManagedUploadTerminalExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + + GeneratedTusManagedUploadTerminalExecution( + boolean cleanupOwnedSourceAfterTerminalState, + boolean expectIoExceptionOnTerminalFailure, + boolean expectProtocolExceptionOnTerminalFailure) { + this.cleanupOwnedSourceAfterTerminalState = cleanupOwnedSourceAfterTerminalState; + this.expectIoExceptionOnTerminalFailure = expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = expectProtocolExceptionOnTerminalFailure; + } + } + + private static final class GeneratedTusManagedUploadSchedulingExecution { + final boolean deferBeforeProtocol; + final boolean networkConstraintSatisfied; + + GeneratedTusManagedUploadSchedulingExecution( + boolean deferBeforeProtocol, + boolean networkConstraintSatisfied) { + this.deferBeforeProtocol = deferBeforeProtocol; + this.networkConstraintSatisfied = networkConstraintSatisfied; + } + } + + private static final class GeneratedTusManagedUploadSourceExecution { + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadSourceExecution( + boolean prepareDurableSourceBeforeProtocol, + boolean simulateMissingSourceBeforeDurableCopy, + int sourcePreparationFailureAttemptIndex, + boolean sourceUnavailableBeforeProtocol) { + this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = sourcePreparationFailureAttemptIndex; + this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; + } + } + + private static final class GeneratedTusManagedUploadStateExpectations { + final boolean inputSourceExists; + final boolean ownedSourceExists; + final boolean resumeUrlExists; + + GeneratedTusManagedUploadStateExpectations( + boolean inputSourceExists, + boolean ownedSourceExists, + boolean resumeUrlExists) { + this.inputSourceExists = inputSourceExists; + this.ownedSourceExists = ownedSourceExists; + this.resumeUrlExists = resumeUrlExists; + } + } + + 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 GeneratedTusManagedUploadWorkload { + final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadInput input; + + GeneratedTusManagedUploadWorkload( + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.attempts = attempts; + this.input = input; + } + } + + private static final class GeneratedTusManagedUploadAttempt { + final int attemptIndex; + final String stateAfterAttempt; + final String stateBeforeAttempt; + final GeneratedTusManagedUploadFailure failure; + final GeneratedTusManagedUploadRequest[] requests; + + GeneratedTusManagedUploadAttempt( + int attemptIndex, + String stateBeforeAttempt, + String stateAfterAttempt, + GeneratedTusManagedUploadFailure failure, + GeneratedTusManagedUploadRequest[] requests) { + this.attemptIndex = attemptIndex; + this.stateAfterAttempt = stateAfterAttempt; + this.stateBeforeAttempt = stateBeforeAttempt; + this.failure = failure; + this.requests = requests; + } + } + + private static final class GeneratedTusManagedUploadFailure { + final long afterAcceptedOffset; + final boolean failAfterAcceptedOffset; + final boolean failBeforeProtocolRequest; + final boolean failDuringProtocolRequest; + final String failureMessage; + + GeneratedTusManagedUploadFailure( + boolean failAfterAcceptedOffset, + boolean failBeforeProtocolRequest, + boolean failDuringProtocolRequest, + String failureMessage, + long afterAcceptedOffset) { + this.afterAcceptedOffset = afterAcceptedOffset; + this.failAfterAcceptedOffset = failAfterAcceptedOffset; + this.failBeforeProtocolRequest = failBeforeProtocolRequest; + this.failDuringProtocolRequest = failDuringProtocolRequest; + this.failureMessage = failureMessage; + } + } + + private static final class GeneratedTusManagedUploadRequest { + final String method; + final String url; + final int bodySize; + final int statusCode; + final GeneratedTusManagedUploadHeaderSet requestHeaders; + final GeneratedTusManagedUploadHeaderSet responseHeaders; + + GeneratedTusManagedUploadRequest( + String method, + String url, + int bodySize, + int statusCode, + GeneratedTusManagedUploadHeaderSet requestHeaders, + GeneratedTusManagedUploadHeaderSet responseHeaders) { + this.method = method; + this.url = url; + this.bodySize = bodySize; + this.statusCode = statusCode; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + } + } + + private static final class GeneratedTusManagedUploadHeaderSet { + final boolean includesDefaultProtocolHeaders; + final GeneratedTusManagedUploadHeader[] headers; + + GeneratedTusManagedUploadHeaderSet( + boolean includesDefaultProtocolHeaders, + GeneratedTusManagedUploadHeader[] headers) { + this.includesDefaultProtocolHeaders = includesDefaultProtocolHeaders; + this.headers = headers; + } + } + + 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 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..22e98fb4 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusProtocolContract.java @@ -0,0 +1,156 @@ +package io.tus.java.client; + +import java.util.Map; + +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(generatedDefault, TusProtocol.DEFAULT_PROTOCOL_VERSION); + assertEquals(generatedDefault, TusClient.TUS_VERSION); + assertEquals( + generatedDefault, + onlyGeneratedProtocolHeader(TusProtocol.DEFAULT_REQUEST_HEADERS)); + assertEquals( + generatedDefault, + onlyGeneratedProtocolHeader(TusProtocol.DEFAULT_RESPONSE_HEADERS)); + } + + /** + * 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 String onlyGeneratedProtocolHeader(Map headers) { + assertEquals(1, headers.size()); + for (Map.Entry entry : headers.entrySet()) { + return entry.getValue(); + } + + throw new AssertionError("Generated protocol header map was empty"); + } + + 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..5df4abba --- /dev/null +++ b/src/test/java/io/tus/java/client/TestGeneratedTusRuntimeEvents.java @@ -0,0 +1,1152 @@ +/* + * 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 boolean GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK = + true; + private static final boolean GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION = + true; + private static final boolean GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED = + true; + private static final boolean GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION = + true; + + private static final GeneratedTusRuntimeEventCase[] CASES = + new GeneratedTusRuntimeEventCase[] { + new GeneratedTusRuntimeEventCase( + "singleUploadLifecycle", + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-allowed-extra-events" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[] { + "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, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/generated-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + } + ), + new GeneratedTusRuntimeEventCase( + "resumeFromPreviousUpload", + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-allowed-extra-events" + ), + new String[] { + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[] { + "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, + true, + new GeneratedTusRuntimeEventHeader[0], + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + } + ), + new GeneratedTusRuntimeEventCase( + "relativeLocationResolution", + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-allowed-extra-events" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[] { + "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, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "relative-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + } + ), + new GeneratedTusRuntimeEventCase( + "deferredLengthUpload", + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-allowed-extra-events" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + }, + new String[] { + "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, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Defer-Length", + "1" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/deferred-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + } + ), + new GeneratedTusRuntimeEventCase( + "deferredLengthChunkedUpload", + new GeneratedTusRuntimeEventExpectations( + new GeneratedTusRuntimeEventPolicy( + "exact-except-allowed-extra-events" + ), + new String[] { + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + }, + new String[][] { + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + }, + new String[] { + "progress:", + } + ), + true, + new GeneratedTusRuntimeBeforeStartAction[0], + new GeneratedTusRuntimeEventInput( + "hello world", + "deferred-chunked-contract", + "absolute", + false, + 5, + null, + new GeneratedTusRuntimeEventMetadata[] { + new GeneratedTusRuntimeEventMetadata( + "filename", + "hello.txt" + ), + } + ), + new GeneratedTusRuntimeEventRequest[] { + new GeneratedTusRuntimeEventRequest( + "POST", + "endpoint", + 201, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Defer-Length", + "1" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Metadata", + "filename aGVsbG8udHh0" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Location", + "https://tus.io/uploads/deferred-chunked-contract" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "5" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "10" + ), + } + ), + new GeneratedTusRuntimeEventRequest( + "POST", + "upload", + 204, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Length", + "11" + ), + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "10" + ), + new GeneratedTusRuntimeEventHeader( + "X-HTTP-Method-Override", + "PATCH" + ), + }, + true, + new GeneratedTusRuntimeEventHeader[] { + new GeneratedTusRuntimeEventHeader( + "Upload-Offset", + "11" + ), + } + ), + } + ), + }; + + /** + * 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.setRequestPayloadSize(testCase.input.chunkSize); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add(generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusEventKeyNumber(bytesTotal))); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusEventKeyNumber(bytesTotal))); + } + }); + + while (uploader.uploadChunk() > -1) { + continue; + } + uploader.finish(); + + assertEvents(testCase, events); + assertStoredUploadState(testCase, urlStore); + } + } + + private static final String GENERATED_TUS_EVENT_KEY_PART_SEPARATOR = ":"; + + private static String generatedTusEventKey(String... parts) { + return String.join(GENERATED_TUS_EVENT_KEY_PART_SEPARATOR, parts); + } + + private static String generatedTusEventKeyNumber(long value) { + return Long.toString(value); + } + + private static String generatedTusEventKeyAfterResponse(String requestIndex) { + return generatedTusEventKey("after-response", requestIndex); + } + + private static String generatedTusEventKeyBeforeRequest(String requestIndex) { + return generatedTusEventKey("before-request", requestIndex); + } + + private static String generatedTusEventKeyChunkComplete(String chunkSize, String bytesAccepted, String bytesTotal) { + return generatedTusEventKey("chunk-complete", chunkSize, bytesAccepted, bytesTotal); + } + + private static String generatedTusEventKeyFingerprint(String fingerprint) { + return generatedTusEventKey("fingerprint", fingerprint); + } + + private static String generatedTusEventKeyProgress(String bytesSent, String bytesTotal) { + return generatedTusEventKey("progress", bytesSent, bytesTotal); + } + + private static String generatedTusEventKeyRequestAbort(String requestIndex) { + return generatedTusEventKey("request-abort", requestIndex); + } + + private static String generatedTusEventKeyRetrySchedule(String delay) { + return generatedTusEventKey("retry-schedule", delay); + } + + private static String generatedTusEventKeyShouldRetry(String retryAttempt, String decision) { + return generatedTusEventKey("should-retry", retryAttempt, decision); + } + + private static String generatedTusEventKeySourceClose() { + return generatedTusEventKey("source-close"); + } + + private static String generatedTusEventKeySourceOpen(String inputKind, String size) { + return generatedTusEventKey("source-open", inputKind, size); + } + + private static String generatedTusEventKeySuccess() { + return generatedTusEventKey("success"); + } + + private static String generatedTusEventKeyUploadUrlAvailable() { + return generatedTusEventKey("upload-url-available"); + } + + private static String generatedTusEventKeyUrlStorageAdd(String fingerprint, String uploadUrl) { + return generatedTusEventKey("url-storage-add", fingerprint, uploadUrl); + } + + private static String generatedTusEventKeyUrlStorageFind(String fingerprint, String count) { + return generatedTusEventKey("url-storage-find", fingerprint, count); + } + + private static String generatedTusEventKeyUrlStorageRemove(String urlStorageKey) { + return generatedTusEventKey("url-storage-remove", urlStorageKey); + } + + 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)) + .respond(responseFor(testCase, request)); + } + } + + private HttpRequest requestFor( + GeneratedTusRuntimeEventCase testCase, + GeneratedTusRuntimeEventRequest request) throws Exception { + HttpRequest httpRequest = new HttpRequest() + .withMethod(request.method) + .withPath(pathFor(testCase, request)); + if (request.includesDefaultProtocolRequestHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + httpRequest.withHeader(entry.getKey(), entry.getValue()); + } + } + for (GeneratedTusRuntimeEventHeader header : request.requestHeaders) { + httpRequest.withHeader(header.name, header.value); + } + return httpRequest; + } + + 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); + if (request.includesDefaultProtocolResponseHeaders) { + for (Map.Entry entry : TusProtocol.DEFAULT_RESPONSE_HEADERS.entrySet()) { + response.withHeader(entry.getKey(), entry.getValue()); + } + } + 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 (shouldRemoveStoredUploadOnSuccess(testCase.input.storedUpload)) { + assertNull(testCase.scenarioId, storedUrl); + return; + } + + assertEquals(testCase.scenarioId, uploadUrlForUnchecked(testCase), storedUrl); + } + + private boolean shouldRemoveStoredUploadOnSuccess( + GeneratedTusRuntimeEventStoredUpload storedUpload) { + if (!GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_BEFORE_HOOK) { + return false; + } + if (!GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_ENABLED) { + return false; + } + if ( + GENERATED_TUS_SUCCESS_REMOVE_STORED_URL_REQUIRES_OPTION + || GENERATED_TUS_URL_STORAGE_REMOVE_ON_SUCCESS_REQUIRES_OPTION) { + return storedUpload.removeFingerprintOnSuccess; + } + + return true; + } + + 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.eventExpectations.policy.matching)) { + assertArrayEquals( + testCase.scenarioId, + testCase.eventExpectations.keys, + events.toArray(new String[events.size()])); + return; + } + + if ("exact-except-allowed-extra-events".equals(testCase.eventExpectations.policy.matching)) { + assertEventsExactExceptAllowedExtraEvents(testCase, events); + return; + } + + throw new AssertionError( + "Unsupported generated event policy " + + testCase.eventExpectations.policy.matching + + " for " + + testCase.scenarioId); + } + + private void assertEventsExactExceptAllowedExtraEvents( + GeneratedTusRuntimeEventCase testCase, + List events) { + int expectedIndex = 0; + for (String event : events) { + if ( + expectedIndex < testCase.eventExpectations.keys.length + && eventMatchesExpected(testCase, event, expectedIndex)) { + expectedIndex += 1; + continue; + } + + if (eventHasAllowedExtraPrefix(testCase, event)) { + continue; + } + + throw new AssertionError( + testCase.scenarioId + + " emitted unexpected non-allowed extra event " + + event + + "; expected " + + java.util.Arrays.toString(testCase.eventExpectations.keys)); + } + + if (expectedIndex == testCase.eventExpectations.keys.length) { + return; + } + + throw new AssertionError( + testCase.scenarioId + + " did not emit every expected non-extra event; observed " + + events + + "; expected " + + java.util.Arrays.toString(testCase.eventExpectations.keys)); + } + + private boolean eventMatchesExpected( + GeneratedTusRuntimeEventCase testCase, + String event, + int expectedIndex) { + String expected = testCase.eventExpectations.keys[expectedIndex]; + if (event.equals(expected)) { + return true; + } + + for (String alternative : testCase.eventExpectations.alternativeGroups[expectedIndex]) { + if (event.equals(alternative)) { + return true; + } + } + + return false; + } + + private boolean eventHasAllowedExtraPrefix( + GeneratedTusRuntimeEventCase testCase, + String event) { + for (String prefix : testCase.eventExpectations.extraPrefixes) { + if (event.startsWith(prefix)) { + return true; + } + } + + return false; + } + + private static final class GeneratedTusRuntimeEventCase { + final String scenarioId; + final GeneratedTusRuntimeEventExpectations eventExpectations; + final boolean uploadLengthDeferred; + final GeneratedTusRuntimeBeforeStartAction[] beforeStartActions; + final GeneratedTusRuntimeEventInput input; + final GeneratedTusRuntimeEventRequest[] requests; + + GeneratedTusRuntimeEventCase( + String scenarioId, + GeneratedTusRuntimeEventExpectations eventExpectations, + boolean uploadLengthDeferred, + GeneratedTusRuntimeBeforeStartAction[] beforeStartActions, + GeneratedTusRuntimeEventInput input, + GeneratedTusRuntimeEventRequest[] requests) { + this.scenarioId = scenarioId; + this.eventExpectations = eventExpectations; + this.uploadLengthDeferred = uploadLengthDeferred; + this.beforeStartActions = beforeStartActions; + this.input = input; + this.requests = requests; + } + } + + private static final class GeneratedTusRuntimeEventExpectations { + final GeneratedTusRuntimeEventPolicy policy; + final String[] keys; + final String[][] alternativeGroups; + final String[] extraPrefixes; + + GeneratedTusRuntimeEventExpectations( + GeneratedTusRuntimeEventPolicy policy, + String[] keys, + String[][] alternativeGroups, + String[] extraPrefixes) { + this.policy = policy; + this.keys = keys; + this.alternativeGroups = alternativeGroups; + this.extraPrefixes = extraPrefixes; + } + } + + private static final class GeneratedTusRuntimeEventPolicy { + final String matching; + + GeneratedTusRuntimeEventPolicy(String matching) { + this.matching = matching; + } + } + + 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 boolean includesDefaultProtocolRequestHeaders; + final GeneratedTusRuntimeEventHeader[] requestHeaders; + final boolean includesDefaultProtocolResponseHeaders; + final GeneratedTusRuntimeEventHeader[] responseHeaders; + + GeneratedTusRuntimeEventRequest( + String method, + String url, + int statusCode, + boolean includesDefaultProtocolRequestHeaders, + GeneratedTusRuntimeEventHeader[] requestHeaders, + boolean includesDefaultProtocolResponseHeaders, + GeneratedTusRuntimeEventHeader[] responseHeaders) { + this.method = method; + this.url = url; + this.statusCode = statusCode; + this.includesDefaultProtocolRequestHeaders = includesDefaultProtocolRequestHeaders; + this.requestHeaders = requestHeaders; + this.includesDefaultProtocolResponseHeaders = includesDefaultProtocolResponseHeaders; + 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 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..f8308dc6 100644 --- a/src/test/java/io/tus/java/client/TestTusClient.java +++ b/src/test/java/io/tus/java/client/TestTusClient.java @@ -8,14 +8,18 @@ import java.net.Proxy; import java.net.Proxy.Type; import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; 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.assertFalse; import static org.junit.Assert.assertNull; @@ -81,17 +85,15 @@ public void testEnableResuming() { */ @Test public void testCreateUpload() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Metadata", "foo aGVsbG8=,bar d29ybGQ=") - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); Map metadata = new LinkedHashMap(); metadata.put("foo", "hello"); @@ -107,6 +109,211 @@ public void testCreateUpload() throws IOException, ProtocolException { assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); } + + /** + * Verifies if uploads can be created while sending data in the creation request. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadWithData() throws IOException, ProtocolException { + byte[] content = new byte[] { + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j' + }; + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Connection", "keep-alive") + .withHeader("Content-Type", "application/offset+octet-stream") + .withHeader("Upload-Length", "10") + .withBody(Arrays.copyOfRange(content, 0, 4)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/foo") + .withHeader("Upload-Offset", "4"))); + mockServer.when(new HttpRequest() + .withMethod("POST") + .withPath("/files/foo")) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "10"))); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + upload.setInputStream(new ByteArrayInputStream(content)); + + TusUploader uploader = client.createUploadWithData(upload, 4); + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals(uploader.getOffset(), 4); + + uploader.setChunkSize(6); + assertEquals(uploader.uploadChunk(), 6); + uploader.finish(); + assertEquals(uploader.getOffset(), content.length); + + HttpRequest[] patchRequests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files/foo")); + assertEquals(1, patchRequests.length); + assertTrue(patchRequests[0].containsHeader("X-HTTP-Method-Override")); + assertArrayEquals(Arrays.copyOfRange(content, 4, 10), patchRequests[0].getBodyAsRawBytes()); + } + + /** + * Verifies if request lifecycle hooks run around upload creation. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreateUploadRequestLifecycleHooks() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("X-Hook", "before") + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/foo"))); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + } + } + )); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + TusUploader uploader = client.createUpload(upload); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL + "/foo")); + assertEquals("before:POST", events.get(0)); + assertEquals("after:POST:201", events.get(1)); + } + + /** + * 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(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Upload-Defer-Length", "1"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .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 partial uploads can be created using the Concatenation extension. + * @throws IOException if upload data cannot be read. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testCreatePartialUpload() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Upload-Concat", "partial") + .withHeader("Upload-Metadata", "test d29ybGQ=") + .withHeader("Upload-Length", "5"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/part-1"))); + + Map metadata = new LinkedHashMap(); + metadata.put("test", "world"); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(5); + upload.setInputStream(new ByteArrayInputStream(new byte[5])); + upload.setMetadata(metadata); + TusUploader uploader = client.createPartialUpload(upload); + + assertEquals(new URL(mockServerURL + "/part-1"), uploader.getUploadURL()); + } + + /** + * Verifies if partial uploads can be concatenated into a final upload. + * @throws IOException if the request cannot be issued. + * @throws ProtocolException if the final upload cannot be constructed. + */ + @Test + public void testConcatenateUploads() throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader( + "Upload-Concat", + "final;" + + mockServerURL + + "/part-1 " + + mockServerURL + + "/part-2" + ) + .withHeader("Upload-Metadata", "foo aGVsbG8="))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201) + .withHeader("Location", mockServerURL + "/final"))); + + Map metadata = new LinkedHashMap(); + metadata.put("foo", "hello"); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + URL uploadURL = client.concatenateUploads(Arrays.asList( + new URL(mockServerURL + "/part-1"), + new URL(mockServerURL + "/part-2") + ), metadata); + + HttpRequest[] requests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("POST") + .withPath("/files")); + + assertEquals(new URL(mockServerURL + "/final"), uploadURL); + 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. @@ -114,17 +321,15 @@ public void testCreateUpload() throws IOException, ProtocolException { */ @Test public void testCreateUploadWithProxy() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Proxy-Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Metadata", "foo aGVsbG8=,bar d29ybGQ=") - .withHeader("Upload-Length", "11")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "11"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); Map metadata = new LinkedHashMap(); metadata.put("foo", "hello"); @@ -148,14 +353,12 @@ public void testCreateUploadWithProxy() throws IOException, ProtocolException { */ @Test public void testCreateUploadWithMissingLocationHeader() throws Exception { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() - .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)); + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(201))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -170,6 +373,139 @@ public void testCreateUploadWithMissingLocationHeader() throws Exception { } } + /** + * Tests if create-upload response failures expose detailed request and response context. + * @throws Exception if unreachable code has been reached. + */ + @Test + public void testCreateUploadWithDetailedResponseError() throws Exception { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("POST") + .withPath("/files") + .withHeader("Upload-Length", "10") + .withHeader("X-Request-ID", "contract-request-id"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(500) + .withBody("server_error"))); + + Map headers = new LinkedHashMap(); + headers.put("X-Request-ID", "contract-request-id"); + + TusClient client = new TusClient(); + client.setHeaders(headers); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + try { + client.createUpload(upload); + throw new Exception("unreachable code reached"); + } catch (TusResponseException error) { + assertEquals( + "tus: unexpected response while creating upload, originated from request " + + "(method: POST, url: " + + mockServerURL + + ", response code: 500, response text: server_error, request id: " + + "contract-request-id)", + error.getMessage() + ); + assertNull(error.getCausingError()); + assertEquals("POST", error.getOriginalRequestMethod()); + assertEquals("contract-request-id", error.getOriginalRequestId()); + assertEquals(mockServerURL, error.getOriginalRequestURL()); + assertTrue(error.hasOriginalResponse()); + assertEquals("server_error", error.getOriginalResponseBody()); + assertEquals(500, error.getOriginalResponseStatus()); + } + } + + /** + * Tests if create-upload request failures expose detailed request context. + * @throws Exception if unreachable code has been reached. + */ + @Test + public void testCreateUploadWithDetailedRequestError() throws Exception { + Map headers = new LinkedHashMap(); + headers.put("X-Request-ID", "contract-request-id"); + + TusClient client = new TusClient() { + @Override + protected HttpURLConnection openConnection(URL uploadURL) { + return new FailingHttpURLConnection(uploadURL, "socket down"); + } + }; + client.setHeaders(headers); + client.setUploadCreationURL(mockServerURL); + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + try { + client.createUpload(upload); + throw new Exception("unreachable code reached"); + } catch (TusRequestException error) { + assertEquals( + "tus: failed to create upload, caused by Error: socket down, " + + "originated from request (method: POST, url: " + + mockServerURL + + ", response code: n/a, response text: n/a, request id: " + + "contract-request-id)", + error.getMessage() + ); + assertEquals("socket down", error.getCausingError().getMessage()); + assertEquals("POST", error.getOriginalRequestMethod()); + assertEquals("contract-request-id", error.getOriginalRequestId()); + assertEquals(mockServerURL, error.getOriginalRequestURL()); + assertFalse(error.hasOriginalResponse()); + assertEquals(TusProtocol.DETAILED_ERROR_MISSING_VALUE, error.getOriginalResponseBody()); + assertEquals(-1, error.getOriginalResponseStatus()); + } + } + + /** + * Tests if start option validation rejects parallel uploads with an upload URL. + * @throws MalformedURLException if the provided URL is malformed. + */ + @Test + public void testValidateStartOptionsRejectsParallelUploadsWithUploadURL() + throws MalformedURLException { + TusStartOptions options = validStartOptions(); + options.setParallelUploads(TusProtocol.MINIMUM_PARALLEL_UPLOADS); + options.setUploadURL(new URL("https://tus.io/uploads/start-validation-upload-url")); + + try { + new TusClient().validateStartOptions(options); + fail("start option validation unexpectedly succeeded"); + } catch (IllegalArgumentException error) { + assertEquals( + TusProtocol.START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_URL, + error.getMessage() + ); + } + } + + /** + * Tests if start option validation rejects parallel uploads with creation data. + * @throws MalformedURLException if the provided URL is malformed. + */ + @Test + public void testValidateStartOptionsRejectsParallelUploadsWithUploadDataDuringCreation() + throws MalformedURLException { + TusStartOptions options = validStartOptions(); + options.setParallelUploads(TusProtocol.MINIMUM_PARALLEL_UPLOADS); + options.setUploadDataDuringCreation(true); + + try { + new TusClient().validateStartOptions(options); + fail("start option validation unexpectedly succeeded"); + } catch (IllegalArgumentException error) { + assertEquals( + TusProtocol + .START_OPTION_VALIDATION_PARALLEL_UPLOADS_WITH_UPLOAD_DATA_DURING_CREATION, + error.getMessage() + ); + } + } + /** * Tests if uploads with relative upload destinations are working. * @throws Exception @@ -180,24 +516,21 @@ public void testCreateUploadWithRelativeLocation() throws Exception { System.setProperty("http.strictPostRedirect", "true"); // Attempt a real redirect - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirect") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) + .withHeader("Upload-Length", "10"))) .respond(new HttpResponse() .withStatusCode(301) .withHeader("Location", mockServerURL + "Redirected/")); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirected/") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", "foo")); + .withHeader("Location", "foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(new URL(mockServerURL + "Redirect")); @@ -222,18 +555,70 @@ public void testCreateUploadWithRelativeLocation() throws Exception { @Test public void testResumeUpload() throws ResumingNotEnabledException, FingerprintNotFoundException, IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("HEAD") + .withPath("/files/foo"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "3"))); + + TusClient client = new TusClient(); + client.setUploadCreationURL(mockServerURL); + client.enableResuming(new TestResumeUploadStore()); + + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + upload.setFingerprint("test-fingerprint"); + + TusUploader uploader = client.resumeUpload(upload); + + assertEquals(uploader.getUploadURL(), new URL(mockServerURL.toString() + "/foo")); + assertEquals(uploader.getOffset(), 3); + } + + /** + * Verifies if request lifecycle hooks run around offset discovery. + * @throws ResumingNotEnabledException if resuming is disabled. + * @throws FingerprintNotFoundException if the stored URL is missing. + * @throws IOException if the request cannot be issued. + * @throws ProtocolException if the upload cannot be resumed. + */ + @Test + public void testResumeUploadRequestLifecycleHooks() throws ResumingNotEnabledException, + FingerprintNotFoundException, IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) - .respond(new HttpResponse() + .withHeader("X-Hook", "before"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "3")); + .withHeader("Upload-Offset", "3"))); + final List events = new ArrayList(); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); client.enableResuming(new TestResumeUploadStore()); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + } + } + )); TusUpload upload = new TusUpload(); upload.setSize(10); @@ -244,6 +629,8 @@ public void testResumeUpload() throws ResumingNotEnabledException, FingerprintNo assertEquals(uploader.getUploadURL(), new URL(mockServerURL.toString() + "/foo")); assertEquals(uploader.getOffset(), 3); + assertEquals("before:HEAD", events.get(0)); + assertEquals("after:HEAD:204", events.get(1)); } /** @@ -275,16 +662,14 @@ public void remove(String fingerprint) { */ @Test public void testResumeOrCreateUpload() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -304,16 +689,14 @@ public void testResumeOrCreateUpload() throws IOException, ProtocolException { */ @Test public void testResumeOrCreateUploadWithProxy() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") .withHeader("Proxy-Connection", "keep-alive") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "11")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "11"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -335,22 +718,19 @@ public void testResumeOrCreateUploadWithProxy() throws IOException, ProtocolExce */ @Test public void testResumeOrCreateUploadNotFound() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") - .withPath("/files/not_found") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) + .withPath("/files/not_found"))) .respond(new HttpResponse() .withStatusCode(404)); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/files") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); TusClient client = new TusClient(); client.setUploadCreationURL(mockServerURL); @@ -375,14 +755,12 @@ public void testResumeOrCreateUploadNotFound() throws IOException, ProtocolExcep */ @Test public void testBeginOrResumeUploadFromURL() throws IOException, ProtocolException { - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("HEAD") - .withPath("/files/fooFromURL") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)) - .respond(new HttpResponse() + .withPath("/files/fooFromURL"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "3")); + .withHeader("Upload-Offset", "3"))); TusClient client = new TusClient(); URL uploadURL = new URL(mockServerURL.toString() + "/fooFromURL"); @@ -397,6 +775,42 @@ public void testBeginOrResumeUploadFromURL() throws IOException, ProtocolExcepti assertEquals(uploader.getOffset(), 3); } + /** + * Tests if aborting with termination deletes the remote upload and removes stored fingerprints. + * @throws IOException if the upload cannot be constructed or terminated. + * @throws ProtocolException if the upload cannot be terminated. + */ + @Test + public void testAbortUploadTerminatesAndRemovesFingerprint() + throws IOException, ProtocolException { + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withMethod("DELETE") + .withPath("/files/abort"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204))); + + TusClient client = new TusClient(); + TusURLStore store = new TusURLMemoryStore(); + URL uploadURL = new URL(mockServerURL.toString() + "/abort"); + store.set("fingerprint", uploadURL); + client.enableResuming(store); + + TusUpload upload = new TusUpload(); + upload.setSize(10); + upload.setInputStream(new ByteArrayInputStream(new byte[10])); + upload.setFingerprint("fingerprint"); + TusUploader uploader = new TusUploader(client, upload, uploadURL, upload.getTusInputStream(), 0); + + client.abortUpload(uploader, true); + + assertTrue(uploader.isAborted()); + assertNull(store.get("fingerprint")); + HttpRequest[] deleteRequests = mockServer.retrieveRecordedRequests(new HttpRequest() + .withMethod("DELETE") + .withPath("/files/abort")); + assertEquals(1, deleteRequests.length); + } + /** * Tests if connections are prepared correctly, which means all header are getting set. * @throws IOException @@ -407,7 +821,9 @@ public void testPrepareConnection() throws IOException { TusClient client = new TusClient(); client.prepareConnection(connection); - assertEquals(connection.getRequestProperty("Tus-Resumable"), TusClient.TUS_VERSION); + for (Map.Entry entry : TusProtocol.DEFAULT_REQUEST_HEADERS.entrySet()) { + assertEquals(entry.getValue(), connection.getRequestProperty(entry.getKey())); + } } /** @@ -471,24 +887,21 @@ public void testFollowRedirects() throws Exception { assertTrue(connection.getInstanceFollowRedirects()); // Attempt a real redirect - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirect") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) + .withHeader("Upload-Length", "10"))) .respond(new HttpResponse() .withStatusCode(301) .withHeader("Location", mockServerURL + "Redirected")); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withMethod("POST") .withPath("/filesRedirected") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Length", "10")) - .respond(new HttpResponse() + .withHeader("Upload-Length", "10"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(201) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Location", mockServerURL + "/foo")); + .withHeader("Location", mockServerURL + "/foo"))); client.setUploadCreationURL(new URL(mockServerURL + "Redirect")); TusUpload upload = new TusUpload(); @@ -552,4 +965,41 @@ public void testRemoveFingerprintOnSuccessEnabled() throws IOException, Protocol assertNull(store.get("fingerprint")); } + + /** + * A mocked HttpURLConnection which fails while connecting. + */ + private static final class FailingHttpURLConnection extends HttpURLConnection { + private final String errorMessage; + + FailingHttpURLConnection(URL url, String errorMessage) { + super(url); + this.errorMessage = errorMessage; + } + + @Override + public void connect() throws IOException { + throw new IOException(errorMessage); + } + + @Override + public void disconnect() { + } + + @Override + public boolean usingProxy() { + return false; + } + } + + private static TusStartOptions validStartOptions() throws MalformedURLException { + TusUpload upload = new TusUpload(); + upload.setSize(11); + upload.setInputStream(new ByteArrayInputStream(new byte[11])); + + TusStartOptions options = new TusStartOptions(); + options.setEndpointURL(new URL("https://tus.io/uploads")); + options.setUpload(upload); + return options; + } } diff --git a/src/test/java/io/tus/java/client/TestTusExecutor.java b/src/test/java/io/tus/java/client/TestTusExecutor.java index e773e566..e114e2d4 100644 --- a/src/test/java/io/tus/java/client/TestTusExecutor.java +++ b/src/test/java/io/tus/java/client/TestTusExecutor.java @@ -6,6 +6,8 @@ import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; +import java.util.List; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -76,6 +78,74 @@ public void testMakeAttempts() throws Exception { assertEquals(exec.getCalls(), 1); } + /** + * Tests if retry decisions and schedules can be observed, and progress can reset retry state. + * @throws Exception + */ + @Test + public void testRetryHooksAndReset() throws Exception { + final List events = new ArrayList(); + CountingExecutor exec = new CountingExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + super.makeAttempt(); + if (getCalls() == 1) { + throw new IOException(); + } + if (getCalls() == 2) { + resetRetryAttempts(); + throw new IOException(); + } + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + events.add("should-retry:" + retryAttempt); + return true; + } + + @Override + protected void onRetryScheduled(int retryAttempt, int delayMillis) { + events.add("retry-schedule:" + retryAttempt + ":" + delayMillis); + } + }; + + exec.setDelays(new int[]{1, 2, 3}); + assertTrue(exec.makeAttempts()); + assertEquals(exec.getCalls(), 3); + assertEquals(events.size(), 4); + assertEquals(events.get(0), "should-retry:0"); + assertEquals(events.get(1), "retry-schedule:0:1"); + assertEquals(events.get(2), "should-retry:0"); + assertEquals(events.get(3), "retry-schedule:0:1"); + } + + /** + * Tests if retry hooks can stop retrying I/O failures. + * @throws Exception + */ + @Test(expected = IOException.class) + public void testRetryHookCanStopIoRetry() throws Exception { + CountingExecutor exec = new CountingExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + super.makeAttempt(); + throw new IOException(); + } + + @Override + protected boolean shouldRetry(IOException exception, int retryAttempt) { + return false; + } + }; + + try { + exec.makeAttempts(); + } finally { + assertEquals(exec.getCalls(), 1); + } + } + /** * Tests if every attempt can throw an IOException. diff --git a/src/test/java/io/tus/java/client/TestTusURLFileStore.java b/src/test/java/io/tus/java/client/TestTusURLFileStore.java new file mode 100644 index 00000000..695be515 --- /dev/null +++ b/src/test/java/io/tus/java/client/TestTusURLFileStore.java @@ -0,0 +1,44 @@ +package io.tus.java.client; + +import org.junit.Test; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Test class for {@link TusURLFileStore}. + */ +public class TestTusURLFileStore { + /** + * Tests if file-backed URL storage persists and removes upload URLs. + * + * @throws Exception if the temporary file or URL cannot be created. + */ + @Test + public void test() throws Exception { + File file = Files.createTempFile("tus-url-store", ".properties").toFile(); + assertTrue(file.delete()); + + URL url = new URL("https://tusd.tusdemo.net/files/hello"); + TusURLFileStore store = new TusURLFileStore(file); + store.set("foo", url); + + assertEquals(url, store.get("foo")); + assertEquals(1, store.size()); + assertTrue(store.hasKeyWithPrefix("tus::foo::")); + + TusURLFileStore restoredStore = new TusURLFileStore(file); + assertEquals(url, restoredStore.get("foo")); + + restoredStore.remove("foo"); + assertNull(restoredStore.get("foo")); + assertEquals(0, restoredStore.size()); + + assertTrue(file.delete()); + } +} diff --git a/src/test/java/io/tus/java/client/TestTusUploader.java b/src/test/java/io/tus/java/client/TestTusUploader.java index b81e2b75..66b41c1b 100644 --- a/src/test/java/io/tus/java/client/TestTusUploader.java +++ b/src/test/java/io/tus/java/client/TestTusUploader.java @@ -2,9 +2,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.io.BufferedReader; import java.io.ByteArrayInputStream; @@ -18,7 +19,9 @@ import java.net.ServerSocket; import java.net.Socket; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import org.junit.Assume; import org.junit.Test; @@ -42,17 +45,15 @@ public class TestTusUploader extends MockServerProvider { public void testTusUploader() throws IOException, ProtocolException { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "3") .withHeader("Content-Type", "application/offset+octet-stream") .withHeader("Connection", "keep-alive") - .withBody(Arrays.copyOfRange(content, 3, 11))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 3, 11)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "11")); + .withHeader("Upload-Offset", "11"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/foo"); @@ -74,6 +75,103 @@ public void testTusUploader() throws IOException, ProtocolException { uploader.finish(); } + /** + * Verifies if request lifecycle hooks run around PATCH uploads. + * @throws IOException if the upload cannot be read or sent. + * @throws ProtocolException if the upload cannot be constructed. + */ + @Test + public void testTusUploaderRequestLifecycleHooks() throws IOException, ProtocolException { + byte[] content = "hello".getBytes(); + + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/hooks") + .withHeader("X-Hook", "before") + .withHeader("Upload-Offset", "0") + .withHeader("Content-Type", "application/offset+octet-stream") + .withBody(content))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .withHeader("Upload-Offset", "5"))); + + final List events = new ArrayList(); + TusClient client = new TusClient(); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + events.add("before:" + context.getMethod()); + context.getConnection().addRequestProperty("X-Hook", "before"); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse(TusRequestLifecycleHooks.RequestContext context) throws IOException { + events.add( + "after:" + + context.getMethod() + + ":" + + context.getConnection().getResponseCode() + ); + throw new IOException("hook failure"); + } + } + )); + URL uploadUrl = new URL(mockServerURL + "/hooks"); + TusInputStream input = new TusInputStream(new ByteArrayInputStream(content)); + TusUpload upload = new TusUpload(); + upload.setSize(content.length); + + TusUploader uploader = new TusUploader(client, upload, uploadUrl, input, 0); + uploader.setChunkSize(content.length); + uploader.setRequestPayloadSize(content.length); + + try { + uploader.uploadChunk(); + fail("expected hook failure"); + } catch (IOException e) { + assertEquals("hook failure", e.getMessage()); + } + + assertEquals(5, uploader.getOffset()); + assertEquals("before:PATCH", events.get(0)); + assertEquals("after:PATCH:204", events.get(1)); + } + + /** + * 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(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/deferred") + .withHeader("Upload-Length", "11") + .withHeader("Upload-Offset", "0") + .withHeader("Content-Type", "application/offset+octet-stream") + .withBody(content))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204) + .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 @@ -83,17 +181,15 @@ public void testTusUploader() throws IOException, ProtocolException { public void testTusUploaderWithProxy() throws IOException, ProtocolException { byte[] content = "hello world with proxy".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/foo") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "0") .withHeader("Content-Type", "application/offset+octet-stream") .withHeader("Proxy-Connection", "keep-alive") - .withBody(Arrays.copyOf(content, content.length))) - .respond(new HttpResponse() + .withBody(Arrays.copyOf(content, content.length)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "22")); + .withHeader("Upload-Offset", "22"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/foo"); @@ -247,38 +343,32 @@ public URL getURL() { public void testSetRequestPayloadSize() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "0") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 0, 5))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 0, 5)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "5")); + .withHeader("Upload-Offset", "5"))); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "5") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 5, 10))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 5, 10)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "10")); + .withHeader("Upload-Offset", "10"))); - mockServer.when(new HttpRequest() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() .withPath("/files/payload") - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) .withHeader("Upload-Offset", "10") .withHeader("Content-Type", "application/offset+octet-stream") - .withBody(Arrays.copyOfRange(content, 10, 11))) - .respond(new HttpResponse() + .withBody(Arrays.copyOfRange(content, 10, 11)))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "11")); + .withHeader("Upload-Offset", "11"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/payload"); @@ -338,11 +428,10 @@ public void testSetRequestPayloadSizeThrows() throws Exception { public void testMissingUploadOffsetHeader() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() - .withPath("/files/missingHeader")) - .respond(new HttpResponse() - .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION)); + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/missingHeader"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() + .withStatusCode(204))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/missingHeader"); @@ -372,12 +461,11 @@ public void testMissingUploadOffsetHeader() throws Exception { public void testUnmatchingUploadOffsetHeader() throws Exception { byte[] content = "hello world".getBytes(); - mockServer.when(new HttpRequest() - .withPath("/files/unmatchingHeader")) - .respond(new HttpResponse() + mockServer.when(withDefaultProtocolRequestHeaders(new HttpRequest() + .withPath("/files/unmatchingHeader"))) + .respond(withDefaultProtocolResponseHeaders(new HttpResponse() .withStatusCode(204) - .withHeader("Tus-Resumable", TusClient.TUS_VERSION) - .withHeader("Upload-Offset", "44")); + .withHeader("Upload-Offset", "44"))); TusClient client = new TusClient(); URL uploadUrl = new URL(mockServerURL + "/unmatchingHeader");