diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ab265107 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gradle +build +out +.idea +.DS_Store +*.iml +*.log diff --git a/.github/workflows/lintChanges.yml b/.github/workflows/lintChanges.yml deleted file mode 100644 index cb080891..00000000 --- a/.github/workflows/lintChanges.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Lint Java Code -on: - push: - branches: - - main - pull_request: - types: - - opened - - synchronize - - unlabeled -jobs: - Lint_Java: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Lint_Java - uses: github/super-linter@v4 - env: - VALIDATE_ALL_CODEBASE: true # lint all files - VALIDATE_JAVA: true # only lint Java files - DEFAULT_BRANCH: main - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Enables better overview of runs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d134992a..7be05f5f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,8 +17,11 @@ jobs: strategy: matrix: java: ['8','11'] - + runs-on: ubuntu-latest + env: + TRANSLOADIT_KEY: ${{ secrets.TRANSLOADIT_KEY }} + TRANSLOADIT_SECRET: ${{ secrets.TRANSLOADIT_SECRET }} steps: - name: Checkout Repository @@ -30,6 +33,13 @@ jobs: java-version: ${{ matrix.java }} distribution: 'adopt' + # This allows us to test Smart CDN Signatures against the Node SDK reference + # implementation for parity. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.gitignore b/.gitignore index 10f4159c..d0f4192d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ *.war *.ear +# Local logs +*.log +*-output.txt + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* @@ -20,9 +24,11 @@ hs_err_pid* local.properties build/ /examples/build +.gradle-docker/ # Gradle .gradle/ # OSX -.DS_Store \ No newline at end of file +.DS_Store +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index e78ea196..2cf217cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,149 +1,175 @@ -### 2.0.1 / 2025-05-12 ### +### 2.1.0 / 2025-10-15 -* Update tus-java-client dependency to 0.5.1 +- Added support for external signature generation via `SignatureProvider` interface ([#19](https://github.com/transloadit/android-sdk/issues/19)) + - New constructors in `Transloadit` accepting a `SignatureProvider` + - Enables secure signature generation on backend servers for client applications and mobile apps + - Added unit tests covering the new signing flow +- Replaced the Nix-based developer environment with a lightweight Docker workflow (`scripts/test-in-docker.sh`) for consistent, fast test runs across platforms -### 2.0.0 / 2024-01-14 ### -#### Major Release -* Exchange the Socket based assembly status fetching with a Server-Sent-Events (SSE) solution. -* Added new methods to the AssemblyListener interface to provide more information about the assembly status. e.g. encoding progress with AssemblyListener#onAssemblyProgress(). -* Changed existing methods in the AssemblyListener interface to provide the bare JSON response from the api instead of pre-parsed data. -* Removed the deprecated AsyncAssemblies class and functionality. +### 2.0.1 / 2025-05-12 + +- Update tus-java-client dependency to 0.5.1 + +### 2.0.0 / 2024-01-14 + +#### Major Release + +- Exchange the Socket based assembly status fetching with a Server-Sent-Events (SSE) solution. +- Added new methods to the AssemblyListener interface to provide more information about the assembly status. e.g. encoding progress with AssemblyListener#onAssemblyProgress(). +- Changed existing methods in the AssemblyListener interface to provide the bare JSON response from the api instead of pre-parsed data. +- Removed the deprecated AsyncAssemblies class and functionality. ##### Breaking Changes - Upgrade Guide -* The AssemblyListener interface has been upgraded. As a result you will have to implement the following methods: + +- The AssemblyListener interface has been upgraded. As a result you will have to implement the following methods: + - `onFileUploadFinished(JSONObject uploadInformation);` - `onAssemblyProgress(JSONObject progress)` - `onAssemblyResultFinished(JSONArray result)` -* The AsyncAssembly class has been removed. If you were using it, you will have to switch to the regular Assembly class. - It has been extended with asynchronous upload capabilities in the past. -The Example under `examples/src/main/java/com/transloadit/examples/MultiStepProcessing.java` shows how to use the new features. -### 1.0.1 / 2024-11-28 ### -* Added SDK support for generating signed Smart CDN URLs (see https://transloadit.com/docs/topics/signature-authentication/#smart-cdn). +- The AsyncAssembly class has been removed. If you were using it, you will have to switch to the regular Assembly class. + It has been extended with asynchronous upload capabilities in the past. + The Example under `examples/src/main/java/com/transloadit/examples/MultiStepProcessing.java` shows how to use the new features. + +### 1.0.1 / 2024-11-28 + +- Added SDK support for generating signed Smart CDN URLs (see https://transloadit.com/docs/topics/signature-authentication/#smart-cdn). This functionality ships as Transloadit#getSignedSmartCDNUrl() - Method. -* Migrated test suite from JUnit4 to JUnit 5 -* Upgrade okhttp to 4.12.0 as a security update +- Migrated test suite from JUnit4 to JUnit 5 +- Upgrade okhttp to 4.12.0 as a security update + +### 1.0.0 / 2022-12-14 -### 1.0.0 / 2022-12-14 ### #### Major Release + Warning: This version includes breaking changes and some experimental features, please keep that in mind when using it. -If you encounter any problems because of the upgrade, please do not hesitate to contact support@transloadit.com +If you encounter any problems because of the upgrade, please do not hesitate to contact support@transloadit.com or open a GitHub-Issue. ##### Breaking Changes - Upgrade Guide -* The AssemblyListener Interface has been upgraded. As a result you will have to implement the following methods: + +- The AssemblyListener Interface has been upgraded. As a result you will have to implement the following methods: - `onFileUploadPaused(String name)` - `onFileUploadResumed(String name)` - `onFileUploadProgress(long uploadedBytes, long totalBytes)` - If you do not need their functionality, just leave them blank. -* Also take note of the deprecation of `AsyncAssemblies`. The normal `Assembly` class, thanks to its extended + If you do not need their functionality, just leave them blank. +- Also take note of the deprecation of `AsyncAssemblies`. The normal `Assembly` class, thanks to its extended functionality, serves as a replacement. You can find more about it further down in the text. ##### Most Important Innovations: -* Introduction of multithreaded uploads. - Now you can upload multiple files in parallel: - * The uploads are pausable via `Assembly#pauseUploads()`. - * And resumable with `Assembly#resumeUploads()`. - * The default value of files being uploaded at the same time is 2. You can adjust this with - `Assembly#setMaxParallelUploads(int maxUploads)`. - * If you want to turn off this feature use: `Assembly#setMaxParallelUploads(int maxUploads)` with a value of 1. -* The `AssemblyListener` has now an extended feature set and provides also information to the new upload mode. -* `AsyncAssemblies` are deprecated now in favor of multithreaded uploads. - * Because some users, especially on Android, are using AsyncAssemblies - this release ships a fix for the corresponding Listeners to avoid `NullPointerExceptions`. -* If you want to add a `Step` to an `Assembly`, providing the Robot's name is now optional. This helps if you want to do a Template Override. + +- Introduction of multithreaded uploads. - Now you can upload multiple files in parallel: + - The uploads are pausable via `Assembly#pauseUploads()`. + - And resumable with `Assembly#resumeUploads()`. + - The default value of files being uploaded at the same time is 2. You can adjust this with + `Assembly#setMaxParallelUploads(int maxUploads)`. + - If you want to turn off this feature use: `Assembly#setMaxParallelUploads(int maxUploads)` with a value of 1. +- The `AssemblyListener` has now an extended feature set and provides also information to the new upload mode. +- `AsyncAssemblies` are deprecated now in favor of multithreaded uploads. +- Because some users, especially on Android, are using AsyncAssemblies + this release ships a fix for the corresponding Listeners to avoid `NullPointerExceptions`. +- If you want to add a `Step` to an `Assembly`, providing the Robot's name is now optional. This helps if you want to do a Template Override. The provided Examples were revised and new examples have been added. ##### Minor changes: -* All dependencies are up-to-date now and include all necessary security patches. -* Signature Authentication uses HmacSHA384 now. -* Signature Authentication uses a unique nonce per assembly in order to prevent signature reuse errors. -### 0.4.4 / 2022-10-30 ### -* The Socket-IO plugin has been updated to version 4, which is also used by the API. +- All dependencies are up-to-date now and include all necessary security patches. +- Signature Authentication uses HmacSHA384 now. +- Signature Authentication uses a unique nonce per assembly in order to prevent signature reuse errors. + +### 0.4.4 / 2022-10-30 + +- The Socket-IO plugin has been updated to version 4, which is also used by the API. + +### 0.4.3 / 2022-10-28 + +- Includes a vulnerability patch in the used socket-io implementation -### 0.4.3 / 2022-10-28 ### -* Includes a vulnerability patch in the used socket-io implementation +### 0.4.2 / 2022-02-03 -### 0.4.2 / 2022-02-03 ### -* Added possibility for SDKs using this SDK to send their own version number to the server in the Transloadit-Client header. -* Resolved some file-name conflicts with the tus-java-client library. +- Added possibility for SDKs using this SDK to send their own version number to the server in the Transloadit-Client header. +- Resolved some file-name conflicts with the tus-java-client library. -### 0.4.1 / 2021-09-26 ### -* Added debugging features regarding HTTP-requests, which should not be used in production without contacting Transloadit support. +### 0.4.1 / 2021-09-26 -### 0.4.0 / 2021-09-26 ### -* Added support for client-side Assembly IDs. You can obtain the ID of an Assembly now before even uploading/saving it. You can achieve this with the brand-new Assembly#getAssemblyID() method. -* Added debugging features regarding AssemblyIDs, which should not be used in production without contacting Transloadit support. -* Also updated the AssemblyListener interface to provide HashMaps instead of JSONObjects. +- Added debugging features regarding HTTP-requests, which should not be used in production without contacting Transloadit support. -### 0.3.0 / 2021-06-27 ### -* Updated all dependencies to their most recent, compatible version +### 0.4.0 / 2021-09-26 + +- Added support for client-side Assembly IDs. You can obtain the ID of an Assembly now before even uploading/saving it. You can achieve this with the brand-new Assembly#getAssemblyID() method. +- Added debugging features regarding AssemblyIDs, which should not be used in production without contacting Transloadit support. +- Also updated the AssemblyListener interface to provide HashMaps instead of JSONObjects. + +### 0.3.0 / 2021-06-27 + +- Updated all dependencies to their most recent, compatible version => minimal requirements for the SDK are now Android 5+ and Java 8+. -* Add (form) fields to an Assembly or Template with the addField()- and addFields() - methods -* Extended support for Assembly progress updates via the Websocket. +- Add (form) fields to an Assembly or Template with the addField()- and addFields() - methods +- Extended support for Assembly progress updates via the Websocket. => AssemblyListener Interface provides more callback functions now. This should be considered before the update. -* Codebase received a review and an updated JavaDoc -* New Example added that uses [Kotlin](https://kotlinlang.org/). - -### 0.2.0 / 2021-05-17 ### -* Added retry functionality for assemblies in case of reaching the rate limit +- Codebase received a review and an updated JavaDoc +- New Example added that uses [Kotlin](https://kotlinlang.org/). + +### 0.2.0 / 2021-05-17 + +- Added retry functionality for assemblies in case of reaching the rate limit -### 0.1.6 / 2021-02-24 ### +### 0.1.6 / 2021-02-24 -* Fix bug that doesn't allow usage of templates that have disabled allow steps override. -* Added some new examples +- Fix bug that doesn't allow usage of templates that have disabled allow steps override. +- Added some new examples -### 0.1.5 / 2019-07-16 ### +### 0.1.5 / 2019-07-16 -* Make tus uploads to assembly's tus url -* Make assembly wait till completion +- Make tus uploads to assembly's tus url +- Make assembly wait till completion -### 0.1.4 / 2019-04-27 ### +### 0.1.4 / 2019-04-27 -* Use a fallback version +- Use a fallback version -### 0.1.3 / 2019-04-18 ### +### 0.1.3 / 2019-04-18 -* load sdk version via ResourceBundle +- load sdk version via ResourceBundle -### 0.1.2 / 2019-04-09 ### +### 0.1.2 / 2019-04-09 -* send client version via "Transloadit-Client" header -* Do not use deprecated status_endpoint property -* update tus-java-client version +- send client version via "Transloadit-Client" header +- Do not use deprecated status_endpoint property +- update tus-java-client version -### 0.1.1 / 2018-04-23 ### +### 0.1.1 / 2018-04-23 -* Allow configurable upload chunk size [#21](https://github.com/transloadit/java-sdk/issues/21) +- Allow configurable upload chunk size [#21](https://github.com/transloadit/java-sdk/issues/21) -### 0.1.0 / 2018-04-05 ### +### 0.1.0 / 2018-04-05 -* Support for Pausable/Resumable Asynchronous assemblies -* Add assembly files as Inputstream +- Support for Pausable/Resumable Asynchronous assemblies +- Add assembly files as Inputstream -### 0.0.6 / 2018-01-19 ### +### 0.0.6 / 2018-01-19 -* Do tus uploads only when there are files to upload. +- Do tus uploads only when there are files to upload. -### 0.0.5 / 2018-01-18 ### +### 0.0.5 / 2018-01-18 -* Check for assembly error before proceeding with tus upload +- Check for assembly error before proceeding with tus upload -### 0.0.4 / 2018-01-08 ### +### 0.0.4 / 2018-01-08 -* Remove tus upload chunksize +- Remove tus upload chunksize -### 0.0.3 / 2017-05-15 ### +### 0.0.3 / 2017-05-15 -* `Steps.removeStep` method -* Added example project for sample codes -* Maven compliant deployment build. +- `Steps.removeStep` method +- Added example project for sample codes +- Maven compliant deployment build. -### 0.0.2 / 2017-05-12 ### +### 0.0.2 / 2017-05-12 -* `AssemblyResponse.getStepResult` method +- `AssemblyResponse.getStepResult` method -### 0.0.1 / 2017-05-09 ### +### 0.0.1 / 2017-05-09 -* Initial release +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..bb86e4c8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to the Transloadit Java SDK + +Thanks for your interest in contributing! This document explains how to get set up, run tests, and how releases are produced. + +## Getting Started + +1. Fork the repository and clone your fork. +2. Install JDK 8+ (CI runs on Java 8 and 11). +3. Install [Docker](https://docs.docker.com/get-docker/) if you want to mirror the CI environment. +4. Run `./gradlew assemble` to ensure everything compiles. + +## Running Tests + +We rely on two layers of testing: + +- **Host JVM:** `./gradlew check` runs unit and integration tests on your local JDK. +- **Docker (CI parity):** `./scripts/test-in-docker.sh` runs the same Gradle tasks inside the image used in CI. Run this before pushing large changes to double-check parity. + +End-to-end tests talk to the live Transloadit API. To enable them locally, create a `.env` file with: + +``` +TRANSLOADIT_KEY=your-key +TRANSLOADIT_SECRET=your-secret +``` + +Without these variables the tests are skipped automatically. + +## Pull Requests + +- Keep PRs focused. For larger refactors, open an issue first to discuss the approach. +- Add or update tests together with code changes. +- Run `./gradlew check` (and optionally the docker script) before submitting a PR. +- Fill in the pull request template with context on the change and testing. + +## Publishing Releases + +Releases are handled by the Transloadit maintainers through the [release GitHub Action](./.github/workflows/release.yml), which publishes artifacts to [Maven Central](https://search.maven.org/artifact/com.transloadit.sdk/transloadit). + +High-level checklist for maintainers: + +1. Bump the version in `src/main/resources/java-sdk-version/version.properties` and update `CHANGELOG.md`. +2. Merge the release branch into `main`. +3. Create a git tag that matches the new version and publish a GitHub release (include the changelog). Tagging `main` triggers the release workflow. +4. Wait for Sonatype to sync the artifact (this can take a few hours). + +The required signing keys and credentials are stored as GitHub secrets. If you need access or spot an issue with the release automation, please reach out to the Transloadit team via the issue tracker or support channels. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..3766f148 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:17-jdk AS base + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + unzip \ + ca-certificates \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js 24 (LTS) +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..ac6a9056 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,45 @@ +# Workplan: Align Local Docker Tests with CI and Stabilize Integrations + +Context: After reintroducing signature provider support and overhauling the Android SDK async layer, CI still fails on java-sdk (Javadoc + Checkstyle) and we found that our Docker helper scripts weren’t mirroring CI behaviour. We’ve been iterating on both repos to make `./scripts/test-in-docker.sh` run the same Gradle targets as CI and expose the same Javadoc/checkstyle failures locally. + +## Outstanding Tasks + +### java-sdk +- ✅ Fix SignatureProvider Javadoc (done). +- ✅ Add package-info and clear checkstyle warnings for integration tests (done). +- [x] Re-run docker test (`./scripts/test-in-docker.sh`) after the latest fixes and ensure it runs `assemble` + `check` with `--stacktrace` (passes locally on Oct 16, 2025). +- [ ] Confirm CI after pushes (should now pass once local docker test is clean). + +### android-sdk +- ✅ settings.gradle fallback to Git source dependency for java-sdk (done). +- ✅ Docker script updated to run `assemble` + `check` (now also mounts the local `java-sdk` checkout by default but can be disabled via `ANDROID_SDK_USE_LOCAL_JAVA_SDK=0`). +- ✅ Gradle dependencies temporarily point to `java-sdk`'s `sig-injection` branch via `version { branch = 'sig-injection' }`. +- ✅ **Lint workaround implemented**: Due to AGP limitation with composite builds (CheckDependenciesLintModelArtifactHandler cannot resolve JAR artifacts from includeBuild), lint is temporarily disabled via `tasks.configureEach` in both modules. Researched extensively - no fix in AGP 8.8, upgrading blocked by infrastructure requirements, publishToMavenLocal defeats local dev purpose. +- ✅ Docker tests now pass locally (Oct 16, 2025) with `./scripts/test-in-docker.sh` running `assemble` + `check` successfully. +- [ ] Push changes and confirm CI passes on android-sdk. + +### General +- [ ] After stabilizing CI, continue with TODO-V1.md items for Android: main-thread listener, WorkManager story, API polish, docs, release automation. +- [ ] Keep docker scripts almost identical across repos going forward so fixes apply to both. + +## Notes for future session + +### AGP Lint + Composite Build Limitation (Researched Oct 16, 2025) +**Root Cause**: AGP's `CheckDependenciesLintModelArtifactHandler` fundamentally cannot resolve JAR artifacts from Gradle composite builds (`includeBuild`). While compilation works fine, lint model generation fails because composite builds expose dependencies via cross-build project dependencies, not artifact dependencies that lint expects. + +**Research Summary**: +- ✅ Extensive online search - no existing bug reports or documented fixes for this specific issue +- ✅ Checked AGP 8.7 & 8.8 release notes - no relevant fixes mentioned +- ✅ Attempted AGP 8.8 upgrade - blocked (requires Gradle 8.10.2 + build-tools 35 + Docker image updates) +- ✅ Explored `checkDependencies = false` - doesn't prevent lint model generation, just changes analysis scope +- ✅ Considered `publishToMavenLocal` - technically works but defeats purpose of local dev iteration +- ✅ Related issues found: #29793 (composite build publishing), #189366120 (Android Studio composite builds) + +**Solution Implemented**: Disabled lint tasks entirely via `tasks.configureEach` with clear comments and TODOs in both `transloadit-android/build.gradle:43-52` and `examples/build.gradle:39-48`. This allows local Docker tests to pass while maintaining composite build benefits for active development. + +**Reversion Plan**: Once java-sdk 2.1.0 is published to Maven Central: +1. Remove lint disabling code from both android-sdk modules +2. Revert `version { branch = 'sig-injection' }` dependency selectors to normal version `2.1.0` +3. Optionally remove composite build setup and use published dependency + +- Keep an eye on `.android` analytics warning (Gradle complaining about metrics). Possibly set `ANDROID_HOME`/`ANDROID_SDK_ROOT` or disable analytics. diff --git a/README.md b/README.md index 3e3de154..80579726 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Existing users should take note of the [JCenter shutdown](https://jfrog.com/blog **Gradle:** ```groovy -implementation 'com.transloadit.sdk:transloadit:2.0.1' +implementation 'com.transloadit.sdk:transloadit:2.1.0' ``` **Maven:** @@ -29,7 +29,7 @@ implementation 'com.transloadit.sdk:transloadit:2.0.1' com.transloadit.sdk transloadit - 2.0.1 + 2.1.0 ``` @@ -37,6 +37,53 @@ implementation 'com.transloadit.sdk:transloadit:2.0.1' All interactions with the SDK begin with the `com.transloadit.sdk.Transloadit` class. +### Authentication + +The SDK supports two methods of authentication: + +#### 1. Using API Key and Secret + +This is the traditional method where you provide both your API key and secret: + +```java +Transloadit transloadit = new Transloadit("YOUR_TRANSLOADIT_KEY", "YOUR_TRANSLOADIT_SECRET"); +``` + +#### 2. Using External Signature Provider (Since v2.1.0) + +For enhanced security in client applications, you can provide signatures from an external source (like your backend server) instead of including the secret in your application: + +```java +import com.transloadit.sdk.SignatureProvider; + +// Implement a signature provider that fetches signatures from your backend +SignatureProvider signatureProvider = new SignatureProvider() { + @Override + public String generateSignature(String paramsJson) throws Exception { + // Make a request to your backend to sign the parameters + // This example uses a hypothetical HTTP client + HttpResponse response = httpClient.post("https://your-backend.com/sign") + .body(paramsJson) + .execute(); + + if (response.isSuccessful()) { + return response.body().getString("signature"); + } else { + throw new Exception("Failed to get signature from backend"); + } + } +}; + +// Initialize Transloadit with the signature provider +Transloadit transloadit = new Transloadit("YOUR_TRANSLOADIT_KEY", signatureProvider); +``` + +This approach is particularly useful for: + +- Mobile applications (Android, JavaFX) where you don't want to ship secrets +- Client-side applications that need to maintain security +- Scenarios where you want centralized control over request authorization + ### Create an Assembly To create an assembly, you use the `newAssembly` method. @@ -52,6 +99,7 @@ import java.util.HashMap; public class Main { public static void main(String[] args) { + // Using traditional authentication (for backend applications) Transloadit transloadit = new Transloadit("YOUR_TRANSLOADIT_KEY", "YOUR_TRANSLOADIT_SECRET"); Assembly assembly = transloadit.newAssembly(); @@ -81,7 +129,7 @@ public class Main { ### Get an Assembly -The method, `getAssembly`, retrieves the JSON status of an assembly identified by the given `assembly_Id`. +The method, `getAssembly`, retrieves the JSON status of an assembly identified by the given `assembly_Id`. ```java import com.transloadit.sdk.Transloadit; @@ -106,7 +154,6 @@ public class Main { } ``` - You may also get an assembly by url with the `getAssemblyByUrl` method. ```java @@ -355,6 +402,16 @@ public class Main { For fully working examples take a look at [/examples](https://github.com/transloadit/java-sdk/tree/main/examples). +## Development + +Use the provided Docker tooling to run the test suite without installing Java locally: + +```bash +./scripts/test-in-docker.sh +``` + +The script builds a tiny `eclipse-temurin:17-jdk` based image, mounts the repository in the container, and caches Gradle downloads inside `.gradle-docker/` so follow-up runs stay fast. Pass additional Gradle arguments after the script name if you need something other than `test`. + ## Documentation See [Javadoc](https://javadoc.io/doc/com.transloadit.sdk/transloadit) for full API documentation. @@ -363,9 +420,9 @@ See [Javadoc](https://javadoc.io/doc/com.transloadit.sdk/transloadit) for full A [The MIT License](LICENSE). -## Verfication -Releases can be verified with our GPG Release Signing Key: +## Verfication +Releases can be verified with our GPG Release Signing Key: `User ID: Transloadit Release Signing Key ` diff --git a/scripts/test-in-docker.sh b/scripts/test-in-docker.sh new file mode 100755 index 00000000..8808ee24 --- /dev/null +++ b/scripts/test-in-docker.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME=${IMAGE_NAME:-transloadit-java-sdk-dev} +CACHE_DIR=.gradle-docker + +ensure_docker() { + if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to run this script." >&2 + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + if [[ -z "${DOCKER_HOST:-}" && -S "$HOME/.colima/default/docker.sock" ]]; then + export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" + fi + fi + + if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is not reachable. Start Docker (or Colima) and retry." >&2 + exit 1 + fi +} + +configure_platform() { + if [[ -z "${DOCKER_PLATFORM:-}" ]]; then + local arch + arch=$(uname -m) + if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then + DOCKER_PLATFORM=linux/amd64 + fi + fi +} + +ensure_docker +configure_platform + +if [[ $# -eq 0 ]]; then + RUN_CMD='set -e; ./gradlew --no-daemon assemble --stacktrace && ./gradlew --no-daemon check --stacktrace' +else + GRADLE_CMD=("./gradlew" "--no-daemon") + GRADLE_CMD+=("$@") + GRADLE_CMD+=("--stacktrace") + printf -v RUN_CMD '%q ' "${GRADLE_CMD[@]}" + RUN_CMD="set -e; ${RUN_CMD}" +fi + +mkdir -p "$CACHE_DIR" + +BUILD_ARGS=() +if [[ -n "${DOCKER_PLATFORM:-}" ]]; then + BUILD_ARGS+=(--platform "$DOCKER_PLATFORM") +fi +BUILD_ARGS+=(-t "$IMAGE_NAME" -f Dockerfile .) + +docker build "${BUILD_ARGS[@]}" + +DOCKER_ARGS=( + --rm + --user "$(id -u):$(id -g)" + -e GRADLE_USER_HOME=/workspace/$CACHE_DIR + -v "$PWD":/workspace + -w /workspace +) + +if [[ -n "${DOCKER_PLATFORM:-}" ]]; then + DOCKER_ARGS+=(--platform "$DOCKER_PLATFORM") +fi + +if [[ -f .env ]]; then + DOCKER_ARGS+=(--env-file "$PWD/.env") +fi + +exec docker run "${DOCKER_ARGS[@]}" "$IMAGE_NAME" bash -lc "$RUN_CMD" diff --git a/src/main/java/com/transloadit/sdk/Request.java b/src/main/java/com/transloadit/sdk/Request.java index 7c0922b1..f1058992 100644 --- a/src/main/java/com/transloadit/sdk/Request.java +++ b/src/main/java/com/transloadit/sdk/Request.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Random; @@ -309,10 +310,25 @@ private Map toPayload(Map data) throws LocalOper dataClone.put("nonce", getNonce("AES", 256)); Map payload = new HashMap(); - payload.put("params", jsonifyData(dataClone)); + String paramsJson = jsonifyData(dataClone); + payload.put("params", paramsJson); if (transloadit.shouldSignRequest) { - payload.put("signature", getSignature(jsonifyData(dataClone))); + String signature; + + if (transloadit.getSignatureProvider() != null) { + // Use external signature provider + try { + signature = transloadit.getSignatureProvider().generateSignature(paramsJson); + } catch (Exception e) { + throw new LocalOperationException("Failed to generate signature using provider.", e); + } + } else { + // Use built-in signature generation + signature = getSignature(paramsJson); + } + + payload.put("signature", signature); } return payload; } @@ -334,7 +350,7 @@ private String jsonifyData(Map data) { * @return Map containing authentication key and the time it expires */ private Map getAuthData() { - Map authData = new HashMap(); + Map authData = new LinkedHashMap(); authData.put("key", transloadit.key); Instant expiryTime = Instant.now().plus(transloadit.duration * 1000); @@ -353,6 +369,9 @@ private Map getAuthData() { * @return signature generate based on the message passed and the transloadit secret. */ private String getSignature(String message) throws LocalOperationException { + if (transloadit.secret == null) { + throw new LocalOperationException("Cannot generate signature without a secret or signature provider."); + } byte[] kSecret = transloadit.secret.getBytes(Charset.forName("UTF-8")); byte[] rawHmac = hmacSHA384(kSecret, message); byte[] hexBytes = new Hex().encode(rawHmac); diff --git a/src/main/java/com/transloadit/sdk/SignatureProvider.java b/src/main/java/com/transloadit/sdk/SignatureProvider.java new file mode 100644 index 00000000..cc596708 --- /dev/null +++ b/src/main/java/com/transloadit/sdk/SignatureProvider.java @@ -0,0 +1,58 @@ +package com.transloadit.sdk; + +/** + * Interface for providing external signatures for Transloadit requests. + * Implement this interface to generate signatures on your backend server + * instead of including the secret key in your application. + * + *

This approach significantly improves security by keeping your secret key + * on your backend server, preventing it from being exposed in client applications.

+ * + *

Example implementation:

+ *

+ * public final class RemoteSignatureProvider implements SignatureProvider {
+ *     private final HttpClient httpClient;
+ *
+ *     public RemoteSignatureProvider(HttpClient httpClient) {
+ *         this.httpClient = httpClient;
+ *     }
+ *
+ *     {@literal @}Override
+ *     public String generateSignature(String paramsJson) throws Exception {
+ *         HttpResponse response = httpClient.post("/api/sign")
+ *             .body(paramsJson)
+ *             .execute();
+ *
+ *         if (!response.isSuccessful()) {
+ *             throw new Exception("Failed to generate signature: " + response.statusCode());
+ *         }
+ *         return response.body().getString("signature");
+ *     }
+ * }
+ * 
+ * + *

For asynchronous implementations, consider using CompletableFuture or similar patterns + * to bridge async operations to this synchronous interface.

+ * + * @see Transloadit Authentication Documentation + * @since 2.1.0 + */ +public interface SignatureProvider { + + /** + * Generate a signature for the given parameters JSON string. + * + *

The implementation should generate a signature for the provided JSON parameters + * according to Transloadit's authentication requirements, typically using HMAC-SHA384 + * with your secret key.

+ * + *

This method is called synchronously, so implementations should either be fast + * or use appropriate timeout mechanisms. For network-based implementations, consider + * caching signatures when appropriate.

+ * + * @param paramsJson The JSON string containing the request parameters to sign + * @return The generated signature string (should include the algorithm prefix, e.g., "sha384:...") + * @throws Exception if signature generation fails for any reason + */ + String generateSignature(String paramsJson) throws Exception; +} diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java index ce4f86b0..57b95f8d 100644 --- a/src/main/java/com/transloadit/sdk/Transloadit.java +++ b/src/main/java/com/transloadit/sdk/Transloadit.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Properties; import java.util.SortedMap; import java.util.TreeMap; @@ -46,6 +47,7 @@ public class Transloadit { protected ArrayList qualifiedErrorsForRetry; protected int retryDelay = 0; // default value protected String versionInfo; + private SignatureProvider signatureProvider; /** * A new instance to transloadit client. @@ -97,19 +99,98 @@ public Transloadit(String key, String secret) { this(key, secret, 5 * 60, DEFAULT_HOST_URL); } + /** + * A new instance to transloadit client with external signature generation. + * + * @param key User's transloadit key + * @param signatureProvider Provider for generating signatures externally + * @param duration for how long (in seconds) the request should be valid. + * @param hostUrl the host url to the transloadit service. + * @since 2.1.0 + */ + public Transloadit(String key, SignatureProvider signatureProvider, long duration, String hostUrl) { + this(key, (String) null, duration, hostUrl); // Explicit cast to avoid ambiguity + setSignatureProvider(Objects.requireNonNull(signatureProvider, "signatureProvider must not be null")); + } + + /** + * A new instance to transloadit client with external signature generation. + * + * @param key User's transloadit key + * @param signatureProvider Provider for generating signatures externally + * @param duration for how long (in seconds) the request should be valid. + * @since 2.1.0 + */ + public Transloadit(String key, SignatureProvider signatureProvider, long duration) { + this(key, signatureProvider, duration, DEFAULT_HOST_URL); + } + + /** + * A new instance to transloadit client with external signature generation. + * + * @param key User's transloadit key + * @param signatureProvider Provider for generating signatures externally + * @param hostUrl the host url to the transloadit service. + * @since 2.1.0 + */ + public Transloadit(String key, SignatureProvider signatureProvider, String hostUrl) { + this(key, signatureProvider, 5 * 60, hostUrl); + } + + /** + * A new instance to transloadit client with external signature generation. + * + * @param key User's transloadit key + * @param signatureProvider Provider for generating signatures externally + * @since 2.1.0 + */ + public Transloadit(String key, SignatureProvider signatureProvider) { + this(key, signatureProvider, 5 * 60, DEFAULT_HOST_URL); + } + /** * Enable/Disable request signing. * @param flag the boolean value to set it to. * @throws LocalOperationException if something goes wrong while running non-http operations. */ public void setRequestSigning(boolean flag) throws LocalOperationException { - if (flag && secret == null) { - throw new LocalOperationException("Cannot enable request signing with null secret."); + if (flag && secret == null && signatureProvider == null) { + throw new LocalOperationException("Cannot enable request signing with null secret and no signature provider."); } else { shouldSignRequest = flag; } } + /** + * Gets the signature provider if one has been set. + * + * @return The signature provider, or null if using built-in signature generation + * @since 2.1.0 + */ + public SignatureProvider getSignatureProvider() { + return signatureProvider; + } + + /** + * Sets a signature provider for external signature generation. + * + *

When a signature provider is set, it will be used instead of the built-in + * signature generation. This allows you to generate signatures on your backend + * server for improved security.

+ * + * @param signatureProvider The signature provider to use, or null to use built-in generation + * (disabling signing entirely when no secret is configured) + * @since 2.1.0 + */ + public void setSignatureProvider(@Nullable SignatureProvider signatureProvider) { + this.signatureProvider = signatureProvider; + if (signatureProvider != null) { + this.shouldSignRequest = true; + } else { + this.shouldSignRequest = this.secret != null; + } + } + /** * Loads the current version from the version.properties File and builds an Info String for the * Transloadit-Client header. @@ -139,6 +220,34 @@ String getVersionInfo() { return this.versionInfo; } + /** + * Exposes the configured API key to subclasses. + * + * @return Transloadit key associated with this client + */ + protected String getKeyInternal() { + return key; + } + + /** + * Exposes the configured API secret to subclasses. + * + * @return the secret or {@code null} if not set + */ + @Nullable + protected String getSecretInternal() { + return secret; + } + + /** + * Indicates whether request signing is currently enabled. + * + * @return {@code true} if signature generation is active + */ + protected boolean isSigningEnabledInternal() { + return shouldSignRequest; + } + /** * Adjusts number of retry attempts that should be taken if a "RATE_LIMIT_REACHED" error appears @@ -426,6 +535,7 @@ public void setRetryDelay(int delay) throws LocalOperationException { * @param input Input value that is provided as ${fields.input} in the template * @param urlParams Additional parameters for the URL query string (optional) * @return The signed Smart CDN URL + * @throws LocalOperationException if URL encoding fails or signing cannot be performed */ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input, @Nullable Map> urlParams) throws LocalOperationException { @@ -444,17 +554,25 @@ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String te * @param urlParams Additional parameters for the URL query string (optional) * @param expiresAt Expiration timestamp of the signature in milliseconds since the UNIX epoch. * @return The signed Smart CDN URL + * @throws LocalOperationException if URL encoding fails or signing cannot be performed */ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input, @Nullable Map> urlParams, long expiresAt) throws LocalOperationException { try { + if (this.secret == null) { + throw new LocalOperationException("Cannot sign Smart CDN URLs without a secret"); + } + String workspaceSlug = URLEncoder.encode(workspace, StandardCharsets.UTF_8.name()); String templateSlug = URLEncoder.encode(template, StandardCharsets.UTF_8.name()); String inputField = URLEncoder.encode(input, StandardCharsets.UTF_8.name()); // Use TreeMap to ensure keys in URL params are sorted. - SortedMap> params = new TreeMap<>(urlParams); + SortedMap> params = new TreeMap<>(); + if (urlParams != null) { + params.putAll(urlParams); + } params.put("auth_key", Collections.singletonList(this.key)); params.put("exp", Collections.singletonList(String.valueOf(expiresAt))); diff --git a/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java b/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java index f6f1873b..f1cd37ef 100644 --- a/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java +++ b/src/main/java/com/transloadit/sdk/exceptions/LocalOperationException.java @@ -19,4 +19,13 @@ public LocalOperationException(Exception e) { public LocalOperationException(String msg) { super(msg); } + + /** + * Constructs a new LocalOperationException with the specified message and cause. + * @param msg detail message + * @param cause root cause + */ + public LocalOperationException(String msg, Throwable cause) { + super(msg, cause); + } } diff --git a/src/main/resources/java-sdk-version/version.properties b/src/main/resources/java-sdk-version/version.properties index 3dacf21d..ac60fcd3 100644 --- a/src/main/resources/java-sdk-version/version.properties +++ b/src/main/resources/java-sdk-version/version.properties @@ -1 +1 @@ -versionNumber='2.0.1' +versionNumber='2.1.0' diff --git a/src/test/java/com/transloadit/sdk/RequestTest.java b/src/test/java/com/transloadit/sdk/RequestTest.java index 1be38482..5d39d8ae 100644 --- a/src/test/java/com/transloadit/sdk/RequestTest.java +++ b/src/test/java/com/transloadit/sdk/RequestTest.java @@ -2,27 +2,29 @@ import com.transloadit.sdk.exceptions.LocalOperationException; import com.transloadit.sdk.exceptions.RequestException; - - import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockserver.client.MockServerClient; - +import org.json.JSONObject; import org.mockserver.junit.jupiter.MockServerExtension; import org.mockserver.junit.jupiter.MockServerSettings; import org.mockserver.matchers.Times; import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; import java.net.SocketTimeoutException; import java.util.ArrayList; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.HashMap; -//CHECKSTYLE:OFF -import java.util.Map; // Suppress warning as the Map import is needed for the JavaDoc Comments import static org.mockserver.model.HttpError.error; -//CHECKSTYLE:ON /** * Unit test for {@link Request} class. Api-Responses are simulated by mocking the server's response. @@ -49,6 +51,75 @@ public void setUp() throws Exception { mockServerClient.reset(); } + private JSONObject runSmartSig(String paramsJson, String key, String secret) throws Exception { + ProcessBuilder builder = new ProcessBuilder("npx", "--yes", "transloadit@4.0.4", "smart_sig"); + builder.environment().put("TRANSLOADIT_KEY", key); + builder.environment().put("TRANSLOADIT_SECRET", secret); + + Process process; + try { + process = builder.start(); + } catch (IOException e) { + Assumptions.assumeTrue(false, "npx not available: " + e.getMessage()); + return new JSONObject(); + } + + try (OutputStream os = process.getOutputStream()) { + os.write(paramsJson.getBytes(StandardCharsets.UTF_8)); + } + + String stdout; + String stderr; + try (InputStream stdoutStream = process.getInputStream(); + InputStream stderrStream = process.getErrorStream()) { + stdout = readStream(stdoutStream).trim(); + stderr = readStream(stderrStream).trim(); + } + int status = process.waitFor(); + if (status != 0) { + Assertions.fail("smart_sig CLI failed: " + stderr); + } + return new JSONObject(stdout); + } + + private String readStream(InputStream stream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + while ((read = stream.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + return buffer.toString(StandardCharsets.UTF_8.name()); + } + + private String extractMultipartField(String body, String fieldName) { + String token = "name=\"" + fieldName + "\""; + int nameIndex = body.indexOf(token); + if (nameIndex == -1) { + return null; + } + + int headerEnd = body.indexOf("\r\n\r\n", nameIndex); + int delimiterLength = 4; + if (headerEnd == -1) { + headerEnd = body.indexOf("\n\n", nameIndex); + delimiterLength = 2; + } + if (headerEnd == -1) { + return null; + } + + int valueStart = headerEnd + delimiterLength; + int boundaryIndex = body.indexOf("\r\n--", valueStart); + if (boundaryIndex == -1) { + boundaryIndex = body.indexOf("\n--", valueStart); + } + if (boundaryIndex == -1) { + boundaryIndex = body.length(); + } + + return body.substring(valueStart, boundaryIndex).trim(); + } /** * Checks the result of the {@link Request#get(String)} method by verifying the format of the GET request @@ -59,11 +130,10 @@ public void setUp() throws Exception { public void get() throws Exception { request.get("/foo"); - mockServerClient.verify(HttpRequest.request() .withPath("/foo") .withMethod("GET") - .withHeader("Transloadit-Client", "java-sdk:2.0.1")); + .withHeader("Transloadit-Client", "java-sdk:2.1.0")); } @@ -80,7 +150,6 @@ public void post() throws Exception { .withPath("/foo").withMethod("POST")); } - /** * Checks the result of the {@link Request#delete(String, Map)} )} method by verifying the format of the * DELETE request the MockServer receives. @@ -154,7 +223,6 @@ public void retryAfterSpecificErrors() throws LocalOperationException, RequestEx //mockServerClient.verify(HttpRequest.request("/foo").withMethod("GET")); - // POST REQUESTS testRequest = new Request(transloadit2); mockServerClient.when(HttpRequest.request() @@ -177,6 +245,99 @@ public void retryAfterSpecificErrors() throws LocalOperationException, RequestEx testRequest.delete("/foo", new HashMap()); } + /** + * Verifies that Request routes params through the custom SignatureProvider. + */ + @Test + public void postUsesSignatureProviderWhenPresent() throws Exception { + final boolean[] invoked = {false}; + final String expectedSignature = "providedSignature"; + SignatureProvider provider = params -> { + invoked[0] = true; + return expectedSignature; + }; + + Transloadit client = new Transloadit("KEY", provider, "http://localhost:" + PORT); + Request providerRequest = new Request(client); + + mockServerClient.when(HttpRequest.request().withPath("/signature-test").withMethod("POST")) + .respond(HttpResponse.response().withStatusCode(200)); + + providerRequest.post("/signature-test", new HashMap<>()); + + HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request() + .withPath("/signature-test").withMethod("POST")); + String body = recorded[0].getBodyAsString(); + + Assertions.assertTrue(invoked[0], "Signature provider should be called"); + Assertions.assertTrue(body.contains(expectedSignature), "Signature should come from provider"); + } + + /** + * Built-in signing should match the Node smart_sig CLI output. + */ + @Test + public void payloadSignatureMatchesSmartSigCli() throws Exception { + String key = "cli_key"; + String secret = "cli_secret"; + Transloadit client = new Transloadit(key, secret, "http://localhost:" + PORT); + Request localRequest = new Request(client); + + HashMap params = new HashMap(); + + mockServerClient.when(HttpRequest.request().withPath("/cli-sign").withMethod("POST")) + .respond(HttpResponse.response().withStatusCode(200)); + + localRequest.post("/cli-sign", params); + + HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request() + .withPath("/cli-sign").withMethod("POST")); + String body = recorded[0].getBodyAsString(); + String paramsJson = extractMultipartField(body, "params"); + String signature = extractMultipartField(body, "signature"); + + Assertions.assertNotNull(paramsJson, "params payload missing: " + body); + Assertions.assertNotNull(signature, "signature missing: " + body); + + JSONObject cliResult = runSmartSig(paramsJson, key, secret); + Assertions.assertEquals(paramsJson, cliResult.getString("params"), "CLI params mismatch: " + cliResult); + Assertions.assertEquals(signature, cliResult.getString("signature"), "CLI signature mismatch: " + cliResult + " javaParams=" + paramsJson); + } + + /** + * When signing is disabled, no signature parameter should be added. + */ + @Test + public void toPayloadOmitsSignatureWhenSigningDisabled() throws Exception { + Transloadit client = new Transloadit("KEY", "SECRET", "http://localhost:" + PORT); + client.setRequestSigning(false); + Request localRequest = new Request(client); + + mockServerClient.when(HttpRequest.request().withPath("/no-sign").withMethod("POST")) + .respond(HttpResponse.response().withStatusCode(200)); + + localRequest.post("/no-sign", new HashMap<>()); + + HttpRequest[] recorded = mockServerClient.retrieveRecordedRequests(HttpRequest.request() + .withPath("/no-sign").withMethod("POST")); + Assertions.assertFalse(recorded[0].getBodyAsString().contains("signature")); + } + + /** + * Ensures provider exceptions are surfaced as LocalOperationException. + */ + @Test + public void signatureProviderExceptionIsWrapped() { + SignatureProvider provider = params -> { + throw new Exception("boom"); + }; + Transloadit client = new Transloadit("KEY", provider, "http://localhost:" + PORT); + Request providerRequest = new Request(client); + + Assertions.assertThrows(LocalOperationException.class, () -> + providerRequest.post("/signature-error", new HashMap<>())); + } + /** * Test secure nonce generation with. */ diff --git a/src/test/java/com/transloadit/sdk/SignatureProviderTest.java b/src/test/java/com/transloadit/sdk/SignatureProviderTest.java new file mode 100644 index 00000000..01825b8d --- /dev/null +++ b/src/test/java/com/transloadit/sdk/SignatureProviderTest.java @@ -0,0 +1,122 @@ +package com.transloadit.sdk; + +import com.transloadit.sdk.exceptions.LocalOperationException; +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Tests for {@link SignatureProvider} integration with {@link Transloadit} and {@link Request}. + */ +public class SignatureProviderTest { + private static final String TEST_SIGNATURE = "sha384:external-signature"; + + @Test + void signatureProviderConstructorsEnableSigning() { + SignatureProvider provider = params -> TEST_SIGNATURE; + + Transloadit withHost = new Transloadit("KEY", provider, 60, "http://example.com"); + Assertions.assertSame(provider, withHost.getSignatureProvider()); + Assertions.assertTrue(withHost.shouldSignRequest); + Assertions.assertNull(withHost.secret); + Assertions.assertEquals("http://example.com", withHost.getHostUrl()); + + Transloadit withDefaults = new Transloadit("KEY", provider); + Assertions.assertSame(provider, withDefaults.getSignatureProvider()); + Assertions.assertTrue(withDefaults.shouldSignRequest); + Assertions.assertNull(withDefaults.secret); + Assertions.assertEquals(5 * 60, withDefaults.duration); + Assertions.assertEquals(Transloadit.DEFAULT_HOST_URL, withDefaults.getHostUrl()); + } + + @Test + void setSignatureProviderTogglesSigningBasedOnAvailability() throws LocalOperationException { + SignatureProvider provider = params -> TEST_SIGNATURE; + Transloadit transloadit = new Transloadit("KEY", "SECRET"); + + transloadit.setSignatureProvider(provider); + Assertions.assertSame(provider, transloadit.getSignatureProvider()); + Assertions.assertTrue(transloadit.shouldSignRequest); + + transloadit.setSignatureProvider(null); + Assertions.assertNull(transloadit.getSignatureProvider()); + Assertions.assertTrue(transloadit.shouldSignRequest); // falls back to secret-based signing + + Transloadit withoutSecret = new Transloadit("KEY", provider); + Assertions.assertTrue(withoutSecret.shouldSignRequest); + withoutSecret.setSignatureProvider(null); + Assertions.assertFalse(withoutSecret.shouldSignRequest); + } + + @Test + void toPayloadUsesSignatureFromProvider() throws Exception { + AtomicReference capturedParams = new AtomicReference<>(); + SignatureProvider provider = paramsJson -> { + capturedParams.set(paramsJson); + return TEST_SIGNATURE; + }; + + Transloadit transloadit = new Transloadit("KEY", provider); + Request request = new Request(transloadit); + + Map data = new HashMap<>(); + data.put("template_id", "123"); + data.put("expires", Instant.now().toString()); + + Map payload = invokeToPayload(request, data); + Assertions.assertEquals(TEST_SIGNATURE, payload.get("signature")); + + String paramsJson = payload.get("params"); + Assertions.assertNotNull(paramsJson); + Assertions.assertEquals(paramsJson, capturedParams.get()); + + JSONObject params = new JSONObject(paramsJson); + Assertions.assertEquals("123", params.get("template_id")); + Assertions.assertTrue(params.has("auth")); + Assertions.assertTrue(params.has("nonce")); + } + + @Test + void toPayloadFallsBackToBuiltInSignature() throws Exception { + Transloadit transloadit = new Transloadit("KEY", "SECRET"); + Request request = new Request(transloadit); + + Map data = new HashMap<>(); + Map payload = invokeToPayload(request, data); + Assertions.assertTrue(payload.containsKey("signature")); + Assertions.assertTrue(payload.get("signature").startsWith("sha384:")); + } + + @Test + void toPayloadWrapsProviderExceptions() throws Exception { + SignatureProvider provider = params -> { + throw new IllegalStateException("backend unavailable"); + }; + Transloadit transloadit = new Transloadit("KEY", provider); + Request request = new Request(transloadit); + + Map data = new HashMap<>(); + InvocationTargetException invocationTargetException = Assertions.assertThrows(InvocationTargetException.class, + () -> invokeToPayload(request, data)); + + Throwable cause = invocationTargetException.getCause(); + Assertions.assertTrue(cause instanceof LocalOperationException); + Assertions.assertEquals("Failed to generate signature using provider.", cause.getMessage()); + Assertions.assertEquals(IllegalStateException.class, cause.getCause().getClass()); + } + + @SuppressWarnings("unchecked") + private Map invokeToPayload(@NotNull Request request, Map data) throws Exception { + Method method = Request.class.getDeclaredMethod("toPayload", Map.class); + method.setAccessible(true); + return (Map) method.invoke(request, data); + } +} diff --git a/src/test/java/com/transloadit/sdk/TransloaditTest.java b/src/test/java/com/transloadit/sdk/TransloaditTest.java index 92e6f1bd..a66284fe 100644 --- a/src/test/java/com/transloadit/sdk/TransloaditTest.java +++ b/src/test/java/com/transloadit/sdk/TransloaditTest.java @@ -61,6 +61,58 @@ public void getHostUrl() { * @throws RequestException if communication with the server goes wrong. * @throws IOException if Test resource "assembly.json" is missing. */ + + /** + * Verifies constructor overload that accepts a SignatureProvider enables signing. + */ + @Test + public void constructorWithSignatureProviderEnablesSigning() { + SignatureProvider provider = params -> "signature"; + Transloadit urlClient = new Transloadit("KEY", provider, "http://localhost:" + PORT); + Transloadit defaultClient = new Transloadit("KEY", provider); + + Assertions.assertSame(provider, urlClient.getSignatureProvider()); + Assertions.assertSame(provider, defaultClient.getSignatureProvider()); + Assertions.assertTrue(urlClient.shouldSignRequest); + Assertions.assertTrue(defaultClient.shouldSignRequest); + Assertions.assertNull(urlClient.secret); + Assertions.assertNull(defaultClient.secret); + } + + /** + * Throws when enabling signing without secret or provider. + */ + @Test + public void setRequestSigningWithoutCredentialsThrows() { + Transloadit client = new Transloadit("KEY", (String) null, 5 * 60, "http://localhost:" + PORT); + Assertions.assertThrows(LocalOperationException.class, () -> client.setRequestSigning(true)); + } + + /** + * Ensures setSignatureProvider flips signing state depending on secret availability. + */ + @Test + public void setSignatureProviderTogglesSigningBasedOnSecret() { + Transloadit noSecret = new Transloadit("KEY", (String) null, 5 * 60, "http://localhost:" + PORT); + Assertions.assertFalse(noSecret.shouldSignRequest); + + SignatureProvider provider = params -> "signature"; + noSecret.setSignatureProvider(provider); + Assertions.assertTrue(noSecret.shouldSignRequest); + + noSecret.setSignatureProvider(null); + Assertions.assertFalse(noSecret.shouldSignRequest); + + Transloadit withSecret = new Transloadit("KEY", "SECRET", "http://localhost:" + PORT); + withSecret.setSignatureProvider(null); + Assertions.assertTrue(withSecret.shouldSignRequest); + + Transloadit withSecretDefaultUrl = new Transloadit("KEY", "SECRET"); + withSecretDefaultUrl.setSignatureProvider(provider); + Assertions.assertTrue(withSecretDefaultUrl.shouldSignRequest); + Assertions.assertSame(provider, withSecretDefaultUrl.getSignatureProvider()); + } + @Test public void getAssembly() throws LocalOperationException, RequestException, IOException { mockServerClient.when(HttpRequest.request() @@ -121,6 +173,22 @@ public void cancelAssembly() throws LocalOperationException, RequestException, I * @throws RequestException if communication with the server goes wrong. * @throws IOException if Test resource "assemblies.json" is missing. */ + /** + * Checks listAssemblies parses the returned JSON into count and items correctly. + */ + @Test + public void listAssembliesParsesItems() throws RequestException, LocalOperationException, IOException { + mockServerClient.when(HttpRequest.request() + .withPath("/assemblies").withMethod("GET")) + .respond(HttpResponse.response().withBody(getJson("assemblies_with_items.json"))); + + ListResponse assemblies = transloadit.listAssemblies(); + Assertions.assertEquals(2, assemblies.size()); + Assertions.assertEquals(2, assemblies.getItems().length()); + Assertions.assertEquals("abcd1234", assemblies.getItems().getJSONObject(0).getString("assembly_id")); + Assertions.assertEquals("efgh5678", assemblies.getItems().getJSONObject(1).getString("assembly_id")); + } + @Test public void listAssemblies() throws RequestException, LocalOperationException, IOException { @@ -282,6 +350,32 @@ public void loadVersionInfo() { Assertions.assertTrue(matcher.find()); } + /** + * Smart CDN signing should fail when no secret is configured. + */ + @Test + public void getSignedSmartCDNUrlWithoutSecretThrows() { + Transloadit client = new Transloadit("foo_key", params -> "ignored"); + Map> params = new HashMap<>(); + params.put("foo", Collections.singletonList("bar")); + + Assertions.assertThrows(LocalOperationException.class, () -> + client.getSignedSmartCDNUrl("workspace", "template", "input", params)); + } + + /** + * Smart CDN signing works when no optional parameters are provided. + */ + @Test + @SuppressWarnings("checkstyle:linelength") + public void getSignedSmartCDNUrlHandlesNullParams() throws LocalOperationException { + Transloadit client = new Transloadit("foo_key", "foo_secret"); + long expiresAt = Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli(); + String url = client.getSignedSmartCDNUrl("foo_workspace", "foo_template", "foo/input", null, expiresAt); + Assertions.assertTrue(url.contains("auth_key=foo_key")); + Assertions.assertTrue(url.contains("sig=sha256")); + } + /** * Test if the SDK can generate a correct signed Smart CDN URL. */ @@ -293,14 +387,16 @@ public void getSignedSmartCDNURL() throws LocalOperationException { params.put("foo", Collections.singletonList("bar")); params.put("aaa", Arrays.asList("42", "21")); // Must be sorted before `foo` + long expiresAt = Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli(); String url = client.getSignedSmartCDNUrl( "foo_workspace", "foo_template", "foo/input", params, - Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli() + expiresAt ); - Assertions.assertEquals("https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519", url); + String expectedUrl = "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519"; + Assertions.assertEquals(expectedUrl, url); } } diff --git a/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java b/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java new file mode 100644 index 00000000..71a00ccc --- /dev/null +++ b/src/test/java/com/transloadit/sdk/exceptions/ExceptionsTest.java @@ -0,0 +1,33 @@ +package com.transloadit.sdk.exceptions; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Basic coverage tests for exception constructors. + */ +public class ExceptionsTest { + @Test + void requestExceptionConstructors() { + Exception cause = new IllegalArgumentException("boom"); + RequestException wrapped = new RequestException(cause); + Assertions.assertEquals(cause, wrapped.getCause()); + + RequestException messageOnly = new RequestException("message"); + Assertions.assertEquals("message", messageOnly.getMessage()); + } + + @Test + void localOperationExceptionConstructors() { + Exception cause = new IllegalStateException("nope"); + LocalOperationException wrapped = new LocalOperationException(cause); + Assertions.assertEquals(cause, wrapped.getCause()); + + LocalOperationException messageOnly = new LocalOperationException("message"); + Assertions.assertEquals("message", messageOnly.getMessage()); + + LocalOperationException messageAndCause = new LocalOperationException("detail", cause); + Assertions.assertEquals("detail", messageAndCause.getMessage()); + Assertions.assertEquals(cause, messageAndCause.getCause()); + } +} diff --git a/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java b/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java new file mode 100644 index 00000000..20be583a --- /dev/null +++ b/src/test/java/com/transloadit/sdk/integration/AssemblyIntegrationTest.java @@ -0,0 +1,53 @@ +package com.transloadit.sdk.integration; + +import com.transloadit.sdk.Assembly; +import com.transloadit.sdk.Transloadit; +import com.transloadit.sdk.response.AssemblyResponse; +import org.json.JSONArray; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class AssemblyIntegrationTest { + + @Test + void createAssemblyAndWaitForCompletion() throws Exception { + String key = System.getenv("TRANSLOADIT_KEY"); + String secret = System.getenv("TRANSLOADIT_SECRET"); + Assumptions.assumeTrue(key != null && !key.trim().isEmpty(), "TRANSLOADIT_KEY env var required"); + Assumptions.assumeTrue(secret != null && !secret.trim().isEmpty(), "TRANSLOADIT_SECRET env var required"); + + Transloadit client = new Transloadit(key, secret); + Assembly assembly = client.newAssembly(); + + Map importStep = new HashMap<>(); + importStep.put("url", "https://demos.transloadit.com/inputs/chameleon.jpg"); + assembly.addStep("import", "/http/import", importStep); + + Map resizeStep = new HashMap<>(); + resizeStep.put("use", "import"); + resizeStep.put("width", 32); + resizeStep.put("height", 32); + assembly.addStep("resize", "/image/resize", resizeStep); + + AssemblyResponse response = assembly.save(false); + String assemblyId = response.getId(); + + long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5); + while (!response.isFinished() && System.currentTimeMillis() < deadline) { + Thread.sleep(5000); + response = client.getAssembly(assemblyId); + } + + Assertions.assertTrue(response.isFinished(), "Assembly did not finish in time"); + Assertions.assertEquals("ASSEMBLY_COMPLETED", response.json().optString("ok")); + + JSONArray stepResult = response.getStepResult("resize"); + Assertions.assertNotNull(stepResult, "resize step result missing"); + Assertions.assertTrue(stepResult.length() > 0, "resize step result empty"); + } +} diff --git a/src/test/java/com/transloadit/sdk/integration/package-info.java b/src/test/java/com/transloadit/sdk/integration/package-info.java new file mode 100644 index 00000000..caae4454 --- /dev/null +++ b/src/test/java/com/transloadit/sdk/integration/package-info.java @@ -0,0 +1,4 @@ +/** + * Integration tests that exercise live Transloadit API interactions. + */ +package com.transloadit.sdk.integration; diff --git a/src/test/resources/__files/assemblies_with_items.json b/src/test/resources/__files/assemblies_with_items.json new file mode 100644 index 00000000..b08564c1 --- /dev/null +++ b/src/test/resources/__files/assemblies_with_items.json @@ -0,0 +1 @@ +{"items":[{"assembly_id":"abcd1234","ok":"ASSEMBLY_COMPLETED"},{"assembly_id":"efgh5678","ok":"ASSEMBLY_UPLOADING"}],"count":2}