From 5414d5556d0bcdff52a0d1c4735470ad37e4c39d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:24:36 +0000 Subject: [PATCH 01/12] feat: add Java client for Apify API (spec v2-2026-07-01T115402Z) Bespoke, idiomatic Java client mirroring the reference JS client: resource clients for actors, builds, runs, datasets, key-value stores, request queues, tasks, schedules, webhooks, dispatches, store, users and logs, plus setStatusMessage. Replaceable HTTP transport (HttpBackend), bearer auth, mandated User-Agent, exponential-backoff retries with jitter, Optional-based 404 handling, and byte-for-byte storage URL signing. Includes offline unit tests, live integration tests (one GET + one CRUD flow per resource), 7 runnable example programs, doc-snippet compile test, docs/, CHANGELOG, Spotless + SpotBugs gates, and CI workflows for integration tests and Maven Central publishing (OIDC Trusted Publisher). --- .github/workflows/java-integration-tests.yml | 77 ++++ .github/workflows/java-publish.yml | 121 +++++ .gitignore | 8 + CHANGELOG.md | 61 +++ README.md | 184 +++++++- docs/README.md | 111 +++++ docs/actors.md | 102 +++++ docs/builds.md | 36 ++ docs/examples.md | 98 ++++ docs/misc.md | 62 +++ docs/runs.md | 59 +++ docs/schedules.md | 38 ++ docs/storages.md | 147 ++++++ docs/tasks.md | 44 ++ docs/webhooks.md | 61 +++ pom.xml | 193 ++++++++ spotbugs-exclude.xml | 26 ++ .../AbstractWebhookCollectionClient.java | 22 + src/main/java/com/apify/client/Actor.java | 61 +++ .../com/apify/client/ActorBuildOptions.java | 40 ++ .../java/com/apify/client/ActorClient.java | 130 ++++++ .../apify/client/ActorCollectionClient.java | 22 + .../java/com/apify/client/ActorEnvVar.java | 45 ++ .../com/apify/client/ActorEnvVarClient.java | 30 ++ .../client/ActorEnvVarCollectionClient.java | 23 + .../com/apify/client/ActorListOptions.java | 48 ++ src/main/java/com/apify/client/ActorRun.java | 94 ++++ .../com/apify/client/ActorStartOptions.java | 117 +++++ .../com/apify/client/ActorStoreListItem.java | 29 ++ .../java/com/apify/client/ActorVersion.java | 17 + .../com/apify/client/ActorVersionClient.java | 44 ++ .../client/ActorVersionCollectionClient.java | 22 + .../java/com/apify/client/ApiResponse.java | 19 + .../com/apify/client/ApifyApiException.java | 83 ++++ .../java/com/apify/client/ApifyClient.java | 233 ++++++++++ .../com/apify/client/ApifyClientBuilder.java | 153 +++++++ .../java/com/apify/client/ApifyResource.java | 29 ++ .../apify/client/BatchAddRequestsOptions.java | 53 +++ .../java/com/apify/client/BatchAddResult.java | 37 ++ src/main/java/com/apify/client/Build.java | 52 +++ .../java/com/apify/client/BuildClient.java | 66 +++ .../apify/client/BuildCollectionClient.java | 20 + .../java/com/apify/client/DataEnvelope.java | 10 + src/main/java/com/apify/client/Dataset.java | 43 ++ .../java/com/apify/client/DatasetClient.java | 152 +++++++ .../apify/client/DatasetCollectionClient.java | 25 + .../apify/client/DatasetDownloadOptions.java | 85 ++++ .../apify/client/DatasetListItemsOptions.java | 140 ++++++ .../com/apify/client/DefaultHttpBackend.java | 46 ++ .../com/apify/client/DownloadItemsFormat.java | 30 ++ .../com/apify/client/GetRecordOptions.java | 23 + .../java/com/apify/client/HttpBackend.java | 33 ++ .../java/com/apify/client/HttpClientCore.java | 291 ++++++++++++ src/main/java/com/apify/client/Json.java | 99 ++++ .../java/com/apify/client/KeyValueStore.java | 37 ++ .../com/apify/client/KeyValueStoreClient.java | 156 +++++++ .../client/KeyValueStoreCollectionClient.java | 25 + .../com/apify/client/KeyValueStoreKey.java | 17 + .../apify/client/KeyValueStoreKeysPage.java | 40 ++ .../com/apify/client/KeyValueStoreRecord.java | 33 ++ .../java/com/apify/client/LastRunOptions.java | 36 ++ .../com/apify/client/ListKeysOptions.java | 52 +++ .../java/com/apify/client/ListOptions.java | 34 ++ .../com/apify/client/ListRequestsOptions.java | 75 +++ src/main/java/com/apify/client/LogClient.java | 80 ++++ .../java/com/apify/client/LogOptions.java | 23 + .../com/apify/client/MetamorphOptions.java | 29 ++ .../client/NestedWebhookCollectionClient.java | 14 + .../java/com/apify/client/PaginationList.java | 82 ++++ .../java/com/apify/client/QueryParams.java | 103 +++++ .../java/com/apify/client/RequestQueue.java | 43 ++ .../com/apify/client/RequestQueueClient.java | 429 ++++++++++++++++++ .../client/RequestQueueCollectionClient.java | 25 + .../com/apify/client/RequestQueueHead.java | 26 ++ .../client/RequestQueueOperationInfo.java | 35 ++ .../com/apify/client/RequestQueueRequest.java | 73 +++ .../com/apify/client/ResourceContext.java | 392 ++++++++++++++++ .../java/com/apify/client/RetryConfig.java | 29 ++ .../com/apify/client/RunChargeOptions.java | 46 ++ src/main/java/com/apify/client/RunClient.java | 188 ++++++++ .../com/apify/client/RunCollectionClient.java | 29 ++ .../java/com/apify/client/RunListOptions.java | 41 ++ .../com/apify/client/RunResurrectOptions.java | 56 +++ src/main/java/com/apify/client/Schedule.java | 35 ++ .../java/com/apify/client/ScheduleClient.java | 37 ++ .../client/ScheduleCollectionClient.java | 22 + .../com/apify/client/SetRecordOptions.java | 35 ++ .../java/com/apify/client/Signatures.java | 93 ++++ src/main/java/com/apify/client/Statuses.java | 17 + .../com/apify/client/StorageListOptions.java | 52 +++ .../apify/client/StoreCollectionClient.java | 78 ++++ .../com/apify/client/StoreListOptions.java | 120 +++++ src/main/java/com/apify/client/Task.java | 49 ++ .../java/com/apify/client/TaskClient.java | 109 +++++ .../apify/client/TaskCollectionClient.java | 22 + .../com/apify/client/TaskStartOptions.java | 80 ++++ src/main/java/com/apify/client/User.java | 20 + .../java/com/apify/client/UserClient.java | 79 ++++ src/main/java/com/apify/client/Version.java | 25 + src/main/java/com/apify/client/Webhook.java | 32 ++ .../java/com/apify/client/WebhookClient.java | 39 ++ .../apify/client/WebhookCollectionClient.java | 17 + .../com/apify/client/WebhookDispatch.java | 17 + .../apify/client/WebhookDispatchClient.java | 17 + .../WebhookDispatchCollectionClient.java | 20 + .../java/com/apify/client/ClientMetaTest.java | 53 +++ .../java/com/apify/client/ConfigTest.java | 47 ++ .../com/apify/client/DocSnippetsTest.java | 150 ++++++ .../java/com/apify/client/ExamplesTest.java | 66 +++ .../java/com/apify/client/MockBackend.java | 186 ++++++++ .../com/apify/client/ReviewFixesTest.java | 291 ++++++++++++ .../java/com/apify/client/SignatureTest.java | 35 ++ .../java/com/apify/client/UnitHttpTest.java | 118 +++++ .../client/examples/CreateBuildRunActor.java | 67 +++ .../com/apify/client/examples/GetAccount.java | 24 + .../apify/client/examples/IterateStore.java | 26 ++ .../apify/client/examples/LogRedirection.java | 26 ++ .../examples/RunAndLastRunStorages.java | 32 ++ .../apify/client/examples/RunStoreActor.java | 24 + .../com/apify/client/examples/Storages.java | 59 +++ .../integration/ActorIntegrationTest.java | 127 ++++++ .../integration/ActorRunIntegrationTest.java | 56 +++ .../integration/BuildIntegrationTest.java | 37 ++ .../integration/DatasetIntegrationTest.java | 75 +++ .../client/integration/IntegrationBase.java | 67 +++ .../KeyValueStoreIntegrationTest.java | 106 +++++ .../RequestQueueIntegrationTest.java | 131 ++++++ .../integration/ScheduleIntegrationTest.java | 76 ++++ .../integration/StoreIntegrationTest.java | 32 ++ .../integration/TaskIntegrationTest.java | 62 +++ .../integration/UserIntegrationTest.java | 39 ++ .../integration/WebhookIntegrationTest.java | 83 ++++ 132 files changed, 9250 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/java-integration-tests.yml create mode 100644 .github/workflows/java-publish.yml create mode 100644 CHANGELOG.md create mode 100644 docs/README.md create mode 100644 docs/actors.md create mode 100644 docs/builds.md create mode 100644 docs/examples.md create mode 100644 docs/misc.md create mode 100644 docs/runs.md create mode 100644 docs/schedules.md create mode 100644 docs/storages.md create mode 100644 docs/tasks.md create mode 100644 docs/webhooks.md create mode 100644 pom.xml create mode 100644 spotbugs-exclude.xml create mode 100644 src/main/java/com/apify/client/AbstractWebhookCollectionClient.java create mode 100644 src/main/java/com/apify/client/Actor.java create mode 100644 src/main/java/com/apify/client/ActorBuildOptions.java create mode 100644 src/main/java/com/apify/client/ActorClient.java create mode 100644 src/main/java/com/apify/client/ActorCollectionClient.java create mode 100644 src/main/java/com/apify/client/ActorEnvVar.java create mode 100644 src/main/java/com/apify/client/ActorEnvVarClient.java create mode 100644 src/main/java/com/apify/client/ActorEnvVarCollectionClient.java create mode 100644 src/main/java/com/apify/client/ActorListOptions.java create mode 100644 src/main/java/com/apify/client/ActorRun.java create mode 100644 src/main/java/com/apify/client/ActorStartOptions.java create mode 100644 src/main/java/com/apify/client/ActorStoreListItem.java create mode 100644 src/main/java/com/apify/client/ActorVersion.java create mode 100644 src/main/java/com/apify/client/ActorVersionClient.java create mode 100644 src/main/java/com/apify/client/ActorVersionCollectionClient.java create mode 100644 src/main/java/com/apify/client/ApiResponse.java create mode 100644 src/main/java/com/apify/client/ApifyApiException.java create mode 100644 src/main/java/com/apify/client/ApifyClient.java create mode 100644 src/main/java/com/apify/client/ApifyClientBuilder.java create mode 100644 src/main/java/com/apify/client/ApifyResource.java create mode 100644 src/main/java/com/apify/client/BatchAddRequestsOptions.java create mode 100644 src/main/java/com/apify/client/BatchAddResult.java create mode 100644 src/main/java/com/apify/client/Build.java create mode 100644 src/main/java/com/apify/client/BuildClient.java create mode 100644 src/main/java/com/apify/client/BuildCollectionClient.java create mode 100644 src/main/java/com/apify/client/DataEnvelope.java create mode 100644 src/main/java/com/apify/client/Dataset.java create mode 100644 src/main/java/com/apify/client/DatasetClient.java create mode 100644 src/main/java/com/apify/client/DatasetCollectionClient.java create mode 100644 src/main/java/com/apify/client/DatasetDownloadOptions.java create mode 100644 src/main/java/com/apify/client/DatasetListItemsOptions.java create mode 100644 src/main/java/com/apify/client/DefaultHttpBackend.java create mode 100644 src/main/java/com/apify/client/DownloadItemsFormat.java create mode 100644 src/main/java/com/apify/client/GetRecordOptions.java create mode 100644 src/main/java/com/apify/client/HttpBackend.java create mode 100644 src/main/java/com/apify/client/HttpClientCore.java create mode 100644 src/main/java/com/apify/client/Json.java create mode 100644 src/main/java/com/apify/client/KeyValueStore.java create mode 100644 src/main/java/com/apify/client/KeyValueStoreClient.java create mode 100644 src/main/java/com/apify/client/KeyValueStoreCollectionClient.java create mode 100644 src/main/java/com/apify/client/KeyValueStoreKey.java create mode 100644 src/main/java/com/apify/client/KeyValueStoreKeysPage.java create mode 100644 src/main/java/com/apify/client/KeyValueStoreRecord.java create mode 100644 src/main/java/com/apify/client/LastRunOptions.java create mode 100644 src/main/java/com/apify/client/ListKeysOptions.java create mode 100644 src/main/java/com/apify/client/ListOptions.java create mode 100644 src/main/java/com/apify/client/ListRequestsOptions.java create mode 100644 src/main/java/com/apify/client/LogClient.java create mode 100644 src/main/java/com/apify/client/LogOptions.java create mode 100644 src/main/java/com/apify/client/MetamorphOptions.java create mode 100644 src/main/java/com/apify/client/NestedWebhookCollectionClient.java create mode 100644 src/main/java/com/apify/client/PaginationList.java create mode 100644 src/main/java/com/apify/client/QueryParams.java create mode 100644 src/main/java/com/apify/client/RequestQueue.java create mode 100644 src/main/java/com/apify/client/RequestQueueClient.java create mode 100644 src/main/java/com/apify/client/RequestQueueCollectionClient.java create mode 100644 src/main/java/com/apify/client/RequestQueueHead.java create mode 100644 src/main/java/com/apify/client/RequestQueueOperationInfo.java create mode 100644 src/main/java/com/apify/client/RequestQueueRequest.java create mode 100644 src/main/java/com/apify/client/ResourceContext.java create mode 100644 src/main/java/com/apify/client/RetryConfig.java create mode 100644 src/main/java/com/apify/client/RunChargeOptions.java create mode 100644 src/main/java/com/apify/client/RunClient.java create mode 100644 src/main/java/com/apify/client/RunCollectionClient.java create mode 100644 src/main/java/com/apify/client/RunListOptions.java create mode 100644 src/main/java/com/apify/client/RunResurrectOptions.java create mode 100644 src/main/java/com/apify/client/Schedule.java create mode 100644 src/main/java/com/apify/client/ScheduleClient.java create mode 100644 src/main/java/com/apify/client/ScheduleCollectionClient.java create mode 100644 src/main/java/com/apify/client/SetRecordOptions.java create mode 100644 src/main/java/com/apify/client/Signatures.java create mode 100644 src/main/java/com/apify/client/Statuses.java create mode 100644 src/main/java/com/apify/client/StorageListOptions.java create mode 100644 src/main/java/com/apify/client/StoreCollectionClient.java create mode 100644 src/main/java/com/apify/client/StoreListOptions.java create mode 100644 src/main/java/com/apify/client/Task.java create mode 100644 src/main/java/com/apify/client/TaskClient.java create mode 100644 src/main/java/com/apify/client/TaskCollectionClient.java create mode 100644 src/main/java/com/apify/client/TaskStartOptions.java create mode 100644 src/main/java/com/apify/client/User.java create mode 100644 src/main/java/com/apify/client/UserClient.java create mode 100644 src/main/java/com/apify/client/Version.java create mode 100644 src/main/java/com/apify/client/Webhook.java create mode 100644 src/main/java/com/apify/client/WebhookClient.java create mode 100644 src/main/java/com/apify/client/WebhookCollectionClient.java create mode 100644 src/main/java/com/apify/client/WebhookDispatch.java create mode 100644 src/main/java/com/apify/client/WebhookDispatchClient.java create mode 100644 src/main/java/com/apify/client/WebhookDispatchCollectionClient.java create mode 100644 src/test/java/com/apify/client/ClientMetaTest.java create mode 100644 src/test/java/com/apify/client/ConfigTest.java create mode 100644 src/test/java/com/apify/client/DocSnippetsTest.java create mode 100644 src/test/java/com/apify/client/ExamplesTest.java create mode 100644 src/test/java/com/apify/client/MockBackend.java create mode 100644 src/test/java/com/apify/client/ReviewFixesTest.java create mode 100644 src/test/java/com/apify/client/SignatureTest.java create mode 100644 src/test/java/com/apify/client/UnitHttpTest.java create mode 100644 src/test/java/com/apify/client/examples/CreateBuildRunActor.java create mode 100644 src/test/java/com/apify/client/examples/GetAccount.java create mode 100644 src/test/java/com/apify/client/examples/IterateStore.java create mode 100644 src/test/java/com/apify/client/examples/LogRedirection.java create mode 100644 src/test/java/com/apify/client/examples/RunAndLastRunStorages.java create mode 100644 src/test/java/com/apify/client/examples/RunStoreActor.java create mode 100644 src/test/java/com/apify/client/examples/Storages.java create mode 100644 src/test/java/com/apify/client/integration/ActorIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/BuildIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/DatasetIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/IntegrationBase.java create mode 100644 src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/StoreIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/TaskIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/UserIntegrationTest.java create mode 100644 src/test/java/com/apify/client/integration/WebhookIntegrationTest.java diff --git a/.github/workflows/java-integration-tests.yml b/.github/workflows/java-integration-tests.yml new file mode 100644 index 0000000..5bc57a0 --- /dev/null +++ b/.github/workflows/java-integration-tests.yml @@ -0,0 +1,77 @@ +name: Java integration tests + +# Language-specific workflow: only runs for the Java client. Triggers on PRs to master that touch +# Java client, test, example, or documentation code, and can be dispatched manually from any branch. +on: + pull_request: + branches: [master] + paths: + - '**/*.java' + - 'pom.xml' + # The "Test examples" step validates the in-documentation snippets, so doc changes must + # re-run the workflow even though Markdown is not Java code. + - 'docs/**' + - 'README.md' + - '.github/workflows/java-integration-tests.yml' + workflow_dispatch: + +# Avoid concurrent runs of the same ref racing on the shared test account. +concurrency: + group: java-integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + # The formatter/lint gate mandated by the coding rules (google-java-format + import hygiene). + - name: Check formatting (Spotless) + run: mvn -B spotless:check + + - name: Build + run: mvn -B -DskipTests test-compile + + # Static-analysis linter (bug finder), the coding-rules-mandated CI lint gate. + - name: Static analysis (SpotBugs) + run: mvn -B -DskipTests compile spotbugs:check + + # Offline unit tests (mock HTTP backend): prove the retry/error/signature logic without the API. + - name: Unit tests + run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=false + + # Fail fast if the integration-test secret is missing or empty. Without this guard the + # integration tests silently "pass" (JUnit assumptions skip them when APIFY_TOKEN is unset), + # so a green run would not prove the API logic actually executed. + - name: Require APIFY_TOKEN secret + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: | + if [ -z "${APIFY_TOKEN}" ]; then + echo "::error::APIFY_TOKEN secret is empty or missing; integration tests would not run against the API." + exit 1 + fi + + - name: Integration tests + env: + # The integration-test token is stored as a repository secret. + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + # Run every test except the example/doc-snippet harnesses (exercised by the step below). + run: mvn -B test -Dtest='!ExamplesTest,!DocSnippetsTest' -DfailIfNoTests=false + + # Standalone CI step that verifies the documentation examples actually work end-to-end against + # the live API (ExamplesTest runs each example's main), and that every in-documentation Java + # snippet is valid, runnable code (DocSnippetsTest compiles each fenced block). + - name: Test examples + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: mvn -B test -Dtest='ExamplesTest,DocSnippetsTest' -DfailIfNoTests=false diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml new file mode 100644 index 0000000..a9f6c5e --- /dev/null +++ b/.github/workflows/java-publish.yml @@ -0,0 +1,121 @@ +name: Publish Java client + +# Language-specific publish workflow for the Java client. It is triggered manually only +# (workflow_dispatch) so a maintainer deliberately decides when a release is cut. The release +# version is the single source of truth in pom.xml (). The artifact is published to Maven +# Central via the Sonatype Central Publisher Portal. +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Run all checks and build artifacts but do not deploy or create the release.' + type: boolean + default: false + +# Never allow two publish runs to race; publishing two releases concurrently is hard to undo. +concurrency: + group: java-publish + cancel-in-progress: false + +permissions: + contents: write # create the tagged GitHub release + id-token: write # OIDC Trusted Publishing exchange with the Sonatype Central Portal + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # A release must only ever be cut from master. + - name: Require master branch + run: | + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "::error::Publishing is only allowed from master, but this run is on '${GITHUB_REF}'." + exit 1 + fi + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + # Provision a Maven settings.xml with the Central Portal server credentials. All secrets + # are read from repository secrets; nothing is stored in the repo. + server-id: central + server-username: CENTRAL_USERNAME + server-password: CENTRAL_PASSWORD + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + # Gate the release on the same quality bar as CI so a broken build can never be published. + - name: Check formatting (Spotless) + run: mvn -B spotless:check + + - name: Static analysis (SpotBugs) + run: mvn -B -DskipTests compile spotbugs:check + + - name: Unit tests + run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=false + + - name: Resolve version from pom.xml + id: version + run: | + version=$(mvn -B -q -DforceStdout help:evaluate -Dexpression=project.version) + if ! echo "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::project.version '${version}' is not a bare semver (X.Y.Z)." + exit 1 + fi + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + echo "Resolved release tag: v${version}" + + - name: Ensure tag does not already exist + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then + echo "::error::Tag ${TAG} already exists on origin; bump the version in pom.xml first." + exit 1 + fi + + # Build, sign and publish to Maven Central via the Sonatype Central Publisher Portal. + # The Central Portal supports OIDC Trusted Publishing (id-token above); the token exchange is + # performed by the central-publishing-maven-plugin, with the CENTRAL_* / GPG secrets as the + # credential fallback. All values come from repository secrets. + - name: Publish to Maven Central + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: mvn -B -Prelease -DskipTests deploy + + - name: Build artifacts only (dry run) + if: ${{ github.event.inputs.dry_run == 'true' }} + env: + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: mvn -B -Prelease -DskipTests verify + + - name: Create and push release tag + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + + - name: Create GitHub release + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + gh release create "${TAG}" \ + --title "${TAG}" \ + --notes "Apify Java client ${TAG}. See CHANGELOG.md for details." diff --git a/.gitignore b/.gitignore index 524f096..07ad7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,11 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# Maven build output +target/ + +# IDE +.idea/ +*.iml +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2acf7c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to the Apify Java client are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-07-03 + +Initial release of the official (experimental, AI-generated and AI-maintained) Java client for the +Apify API, verified against OpenAPI specification version `v2-2026-07-01T115402Z`. + +### Added + +- Resource-oriented `ApifyClient` mirroring the JavaScript reference client, with accessors for + Actors, Actor versions and environment variables, builds, runs, + datasets, key-value stores, request queues, tasks, schedules, webhooks, webhook dispatches, the + Apify Store, users, and logs. +- Replaceable HTTP transport via the `HttpBackend` interface (default `DefaultHttpBackend`, backed + by the JDK `java.net.http.HttpClient`), configurable through `ApifyClient.builder().httpBackend(...)`. +- Cross-cutting request behaviour applied to every call: bearer-token authentication, the mandated + `User-Agent` header, exponential-backoff-with-jitter retries (429, 5xx and network errors), and a + growing-but-capped per-attempt timeout. +- Convenience helpers matching the reference client: `actor(...).call` / `task(...).call` (start and + wait), `run(...).waitForFinish` / `build(...).waitForFinish`, `actor(...).defaultBuild`, + `run(...).metamorph`/`reboot`/`resurrect`/`charge`, run-nested default storages, lazy + `store().iterate()` and `requestQueue(...).paginateRequests()` iterators, dataset + `downloadItems`/`getStatistics`/`createItemsPublicUrl`, key-value-store record and key-list public + URLs with HMAC-SHA256 signing, the request-queue lock lifecycle, and `setStatusMessage`. +- `setRecord` write options (`SetRecordOptions`: `timeoutSecs`, `doNotRetryTimeouts`), + `createKeysPublicUrl(ListKeysOptions, ...)` key-listing filters, and `batchAddRequests` + tuning (`BatchAddRequestsOptions`: `maxUnprocessedRequestsRetries`, `maxParallel`, + `minDelayBetweenUnprocessedRequestsRetriesMillis`) with automatic retry of unprocessed requests, + all matching the reference client. `downloadItems` forwards the full set of item-selection + parameters via `DatasetDownloadOptions.items(...)`. +- Public version constants `Version.CLIENT_VERSION` and `Version.API_SPEC_VERSION`. +- Forward-compatible models that capture unmodelled API fields in an `extra` map. +- Offline unit tests (mock HTTP backend) covering retries, error parsing, 404→empty mapping, the + User-Agent format, base-URL resolution, and the storage-signature scheme (pinned to the upstream + `@apify/utilities` algorithm with a known-answer test). +- Integration test suite (one simple GET plus one CRUD/complex flow per resource) and runnable, + CI-tested documentation examples. +- Language-specific GitHub Actions workflows: `Java integration tests` (Spotless format check, + build, unit tests, integration tests, and a standalone `Test examples` step) and a manually + triggered `Publish Java client` workflow that releases to Maven Central via the Sonatype Central + Publisher Portal (OIDC Trusted Publisher) and creates a tagged GitHub release. + +### Fixed + +- Nested webhook collections (`actor(id).webhooks()`, `task(id).webhooks()`) are now read-only + (`NestedWebhookCollectionClient`); `create(...)` is exposed only on the account-wide + `client.webhooks()`, matching the API (those nested endpoints are GET-only). +- `store().iterate(options)` no longer mutates the caller's `StoreListOptions` and now honors its + initial `offset`. +- `waitForFinish` clamps the server-side wait to the configured request timeout, so a short client + timeout no longer aborts every poll. +- Single-resource getters return an empty `Optional` (instead of throwing) when the API responds + `200` with `{"data": null}`. +- `RunCollectionClient.list` tolerates a `null` options/filter argument. +- `RunChargeOptions.eventName` is validated before a charge request is sent. +- `KeyValueStoreRecord` defensively copies its byte payload on the way in and out. diff --git a/README.md b/README.md index 6198dfe..fe2b8ec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,182 @@ -# apify-client-java -Apify API client for Java—Programmatically run Actors, manage and stream data from storages (datasets, key-value stores, request queues), schedule and monitor runs, and access the full Apify platform API. Sync and async interfaces with automatic retries and pagination. +# Apify API client for Java + +> **Official, but experimental — AI-generated and AI-maintained.** This is an official Apify client, +> but it is experimental: it is generated and maintained by AI. Review the code before relying on it +> in production and report issues on the repository. + +A resource-oriented Java client for the [Apify API](https://docs.apify.com/api/v2), mirroring the +official [JavaScript](https://github.com/apify/apify-client-js) reference client: start from an +`ApifyClient`, then drill down into resources (Actors, runs, datasets, key-value stores, request +queues, tasks, schedules, webhooks, the store, users and logs). + +## Requirements + +- Java 17 or newer. + +## Installation + +Maven: + +```xml + + com.apify + apify-client + 0.1.0 + +``` + +Gradle: + +```groovy +implementation 'com.apify:apify-client:0.1.0' +``` + +## Quick start + +```java +ApifyClient client = ApifyClient.create("my-api-token"); + +// Start an Actor and wait for it to finish. The last argument is the wait budget in seconds; +// pass a value (e.g. 120L) to bound the wait, or null to wait indefinitely (as here). +ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), null); + +// Read items from the run's default dataset. +PaginationList items = + client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); +System.out.println("Item count: " + items.getCount()); +``` + +`ApifyClient.create` takes the token as an explicit argument — it does **not** read `APIFY_TOKEN` (or +any other environment variable) automatically. Read it yourself if you want that, e.g. +`ApifyClient.create(System.getenv("APIFY_TOKEN"))`. + +## Configuration + +Use `ApifyClient.builder()` for non-default settings: + +```java +ApifyClient configured = + ApifyClient.builder() + .token("my-api-token") + .baseUrl("https://api.apify.com") // /v2 is appended automatically + .maxRetries(8) + .minDelayBetweenRetries(Duration.ofMillis(500)) + .timeout(Duration.ofSeconds(360)) + .userAgentSuffix("MyTool/1.0") + .build(); +``` + +### Replaceable HTTP transport + +The transport is a replaceable component. The default is `DefaultHttpBackend` (backed by the JDK's +`java.net.http.HttpClient`); provide your own `HttpBackend` to share a connection pool or customize +proxy/TLS: + +```java +HttpBackend backend = new DefaultHttpBackend(java.net.http.HttpClient.newHttpClient()); +ApifyClient withBackend = ApifyClient.builder().token("t").httpBackend(backend).build(); +``` + +Cross-cutting behaviour applied to every request lives in the client, not the backend: +bearer-token authentication, the mandated `User-Agent` header, and retries with exponential +backoff and jitter on `429`, `5xx` and network errors. + +## Fetching single resources + +Methods that fetch a single resource return an `Optional`: a missing resource is reported by an +empty `Optional` rather than an exception. + +```java +Optional maybeActor = client.actor("apify/hello-world").get(); +if (maybeActor.isPresent()) { + System.out.println(maybeActor.get().getTitle()); +} +``` + +## Error handling + +API failures (a request that reaches the API but returns a non-success status) are thrown as +`ApifyApiException`, an unchecked exception exposing the parsed error details: + +```java +try { + client.actor("does/not-exist").update(Map.of("title", "x")); +} catch (ApifyApiException e) { + System.out.println("status=" + e.getStatusCode() + " type=" + e.getType()); +} +``` + +| Accessor | Meaning | +|---|---| +| `getStatusCode()` | HTTP status code of the error response. | +| `getType()` | Machine-readable error type (e.g. `record-not-found`). | +| `getMessage()` | Human-readable description. | +| `getAttempt()` | The (1-based) attempt number that produced the error. | +| `getHttpMethod()` / `getPath()` | The request method and path. | +| `getData()` | Additional structured error data, if any. | + +## Versioning + +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.1.0`). +- `Version.API_SPEC_VERSION` — the Apify OpenAPI specification version this client was verified + against (`v2-2026-07-01T115402Z`). + +Changes to the public interface other than additive ones are considered breaking changes and follow +[Semantic Versioning](https://semver.org/). + +### Releasing + +Releases are published to Maven Central through the Sonatype Central Publisher Portal by the +manually-triggered `Publish Java client` GitHub Actions workflow. The workflow authenticates to the +portal using an OIDC **Trusted Publisher** exchange (no long-lived registry password stored in the +repo), signs the artifacts with GPG, publishes the `com.apify:apify-client` artifact, and creates a +tagged GitHub release. The release version is taken from the `` in `pom.xml`. + +## Scope + +The client covers the documented Apify API endpoints that the JavaScript reference client exposes. + +For cross-client parity, the following documented spec endpoints are intentionally **not** +implemented (the JS reference exposes none of them): + +- The synchronous run endpoints (`run-sync`, `run-sync-get-dataset-items`). +- The cryptographic tools `POST /v2/tools/encode-and-sign` and `POST /v2/tools/decode-and-verify` + (this client performs the same HMAC-SHA256 URL signing locally). +- `/v2/browser-info`. +- The keyed-`POST` create variants that duplicate the covered `PUT` writes. + +## Documentation + +Full documentation is in the [`docs/`](docs/README.md) directory, organized by resource: + +- [Actors, versions & environment variables](docs/actors.md) +- [Builds](docs/builds.md) +- [Runs](docs/runs.md) +- [Storages (datasets, key-value stores, request queues)](docs/storages.md) +- [Tasks](docs/tasks.md) +- [Schedules](docs/schedules.md) +- [Webhooks & dispatches](docs/webhooks.md) +- [Store, users & logs](docs/misc.md) +- [Runnable examples](docs/examples.md) + +## Resources + +| Accessor | Client | Description | +|---|---|---| +| `actors()` / `actor(id)` | `ActorCollectionClient` / `ActorClient` | Actors | +| `builds()` / `build(id)` | `BuildCollectionClient` / `BuildClient` | Actor builds | +| `runs()` / `run(id)` | `RunCollectionClient` / `RunClient` | Actor runs | +| `datasets()` / `dataset(id)` | `DatasetCollectionClient` / `DatasetClient` | Datasets | +| `keyValueStores()` / `keyValueStore(id)` | `KeyValueStoreCollectionClient` / `KeyValueStoreClient` | Key-value stores | +| `requestQueues()` / `requestQueue(id)` | `RequestQueueCollectionClient` / `RequestQueueClient` | Request queues | +| `tasks()` / `task(id)` | `TaskCollectionClient` / `TaskClient` | Actor tasks | +| `schedules()` / `schedule(id)` | `ScheduleCollectionClient` / `ScheduleClient` | Schedules | +| `webhooks()` / `webhook(id)` | `WebhookCollectionClient` / `WebhookClient` | Webhooks | +| `webhookDispatches()` / `webhookDispatch(id)` | `WebhookDispatchCollectionClient` / `WebhookDispatchClient` | Webhook dispatches | +| `store()` | `StoreCollectionClient` | Apify Store | +| `me()` / `user(id)` | `UserClient` | Users | +| `log(id)` | `LogClient` | Build/run logs | + +## License + +[Apache License 2.0](LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..cf8223b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,111 @@ +# Apify Java client documentation + +> **Official, but experimental — AI-generated and AI-maintained.** This is an official Apify client, +> but it is experimental: it is generated and maintained by AI. Review the code before relying on it +> in production and report issues on the repository. + +This directory documents the public API of the Apify Java client, organized by resource. Each page +lists the available methods with their parameters and short, runnable snippets. For an overview, +configuration, error handling and the full resource table, see the [top-level README](../README.md). + +All snippets assume a configured client: + +```java +ApifyClient client = ApifyClient.create("my-api-token"); +``` + +`ApifyClient.create` takes the token as an explicit argument — it does not read `APIFY_TOKEN` +automatically. Use `ApifyClient.builder()` for non-default settings (base URL, retries, timeout, +user-agent suffix, custom HTTP backend). + +Get your API token from the +[Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations). + +Methods that fetch a single resource return an `Optional`: a missing resource is reported by an +empty `Optional` rather than an exception. API failures are thrown as `ApifyApiException` (see +[error handling](../README.md#error-handling)). + +## Imports and dependencies + +Snippets in these docs assume the client types are imported from `com.apify.client` (e.g. +`import com.apify.client.*;`) plus standard-library types (`java.util.List`, `java.util.Map`, +`java.util.Optional`, `java.util.Iterator`, `java.time.Duration`, `java.io.InputStream`). + +Raw-JSON return values use Jackson's `com.fasterxml.jackson.databind.JsonNode`. Jackson is a +transitive dependency of this client, so it is already on your classpath. + +## Raw JSON values + +A few methods return data whose shape is not modelled by this client and is instead exposed as a +Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): + +- Read: `me().monthlyUsage(...)`, `me().limits()`, + `task(id).getInput()`/`updateInput(...)`, `build(id).getOpenApiDefinition()`, + `dataset(id).getStatistics()`, and the raw request-queue operations (`listRequests`, + `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, `batchDeleteRequests`). +- Write: definition/`update`/`create` arguments accept any JSON-serializable value — a `Map`, a + `JsonNode`, or your own POJO. + +Navigate a `JsonNode` with `node.get("field")`, `node.path("a").asText()`, etc. + +## Model fields and unmodeled data (`getExtra`) + +Response models expose the commonly-used fields as typed getters. The API returns more fields than +are modelled; every model also carries a `getExtra()` map (`Map`) holding any field +not mapped to a typed getter, so nothing the API returns is lost. For example a `Schedule`'s +`actions`/`isExclusive`, or a `me()` `User`'s private account details (email, plan, proxy settings, +…), are available via `getExtra()`. + +```java +Schedule schedule = client.schedule("SCHEDULE_ID").get().orElseThrow(); +Object actions = schedule.getExtra().get("actions"); +``` + +## Setting single-resource status + +`client.setStatusMessage(String message, boolean isTerminal)` updates the status message of the +current Actor run (identified by the `ACTOR_RUN_ID` environment variable); it only works from inside +a run and throws `IllegalStateException` otherwise. Returns the updated `ActorRun`. + +## Optional option fields + +Option objects use fluent setters and nullable (boxed) fields; an unset field means "use the API +default". Leave a setter uncalled to omit that parameter. + +```java +ActorListOptions options = new ActorListOptions().my(true).limit(10L); +PaginationList page = client.actors().list(options); +``` + +## Common list options — `ListOptions` + +Most `list` methods (builds, runs, tasks, schedules, webhooks, Actor versions) take the shared +`ListOptions`, which carries the standard pagination/ordering controls. + +| Method | Type | Meaning | +|---|---|---| +| `offset(Long)` | `Long` | Number of items to skip from the start of the list. | +| `limit(Long)` | `Long` | Maximum number of items to return. | +| `desc(Boolean)` | `Boolean` | If `true`, return items newest-first. | + +```java +PaginationList builds = client.builds().list(new ListOptions().limit(50L).desc(true)); +``` + +## Pagination — `PaginationList` + +`list` methods return a `PaginationList` page with `getTotal()`, `getOffset()`, `getLimit()`, +`getCount()`, `isDesc()` and `getItems()`. Within-storage listers (`listKeys`, `listHead`) return +their own page/head containers instead. + +## Resource pages + +- [Actors, versions & environment variables](actors.md) +- [Builds](builds.md) +- [Runs](runs.md) +- [Storages (datasets, key-value stores, request queues)](storages.md) +- [Tasks](tasks.md) +- [Schedules](schedules.md) +- [Webhooks & dispatches](webhooks.md) +- [Store, users & logs](misc.md) +- [Runnable examples](examples.md) diff --git a/docs/actors.md b/docs/actors.md new file mode 100644 index 0000000..0d1ee3c --- /dev/null +++ b/docs/actors.md @@ -0,0 +1,102 @@ +# Actors, versions & environment variables + +Access the Actor collection with `client.actors()` and a single Actor with `client.actor(id)`, +where `id` is an Actor ID or `username~name` (a `/` in the id is accepted and normalized). + +## `ActorCollectionClient` + +| Method | Description | +|---|---| +| `list(ActorListOptions)` | List the account's Actors. Returns `PaginationList`. | +| `create(Object)` | Create a new Actor from a JSON-serializable definition. Returns `Actor`. | + +`ActorListOptions` adds `my(Boolean)` (only Actors owned by the current user) and +`sortBy(String)` (e.g. `createdAt`, `stats.lastRunStartedAt`) on top of the standard offset/limit/desc. + +```java +PaginationList mine = client.actors().list(new ActorListOptions().my(true).limit(5L)); +for (Actor actor : mine.getItems()) { + System.out.println(actor.getName()); +} +``` + +Create an Actor with a `SOURCE_FILES` version (`sourceType` is one of `SOURCE_FILES`, `GIT_REPO`, +`TARBALL`, `GITHUB_GIST`, `SOURCE_CODE`; a source-file `format` is `TEXT` or `BASE64`): + +```java +Actor created = client.actors().create(Map.of( + "name", "my-actor", + "isPublic", false, + "versions", List.of(Map.of( + "versionNumber", "0.0", + "sourceType", "SOURCE_FILES", + "buildTag", "latest", + "sourceFiles", List.of( + Map.of("name", "Dockerfile", "format", "TEXT", + "content", "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"), + Map.of("name", "main.js", "format", "TEXT", + "content", "console.log('hi');")))))); +``` + +## `ActorClient` + +| Method | Description | +|---|---| +| `get()` | Fetch the Actor. Returns `Optional`. | +| `update(Object)` | Update the Actor with the given fields. Returns `Actor`. | +| `delete()` | Delete the Actor. | +| `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Returns `ActorRun`. | +| `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely). Returns `ActorRun`. | +| `build(String versionNumber, ActorBuildOptions)` | Build a version. Returns `Build`. | +| `defaultBuild(Long waitForFinish)` | Resolve the default build. Returns `BuildClient`. | +| `lastRun(String status)` / `lastRun(LastRunOptions)` | A `RunClient` for the last run. | +| `builds()` / `runs()` / `versions()` | Nested collection clients. | +| `webhooks()` | Read-only nested webhook collection (`NestedWebhookCollectionClient`, list only). | +| `version(String)` | An `ActorVersionClient`. | + +`ActorStartOptions` fields (all optional): `build`, `memoryMbytes`, `timeoutSecs`, `waitForFinish`, +`maxItems`, `maxTotalChargeUsd`, `contentType`, `restartOnError`, `forcePermissionLevel` +(`LIMITED_PERMISSIONS`/`FULL_PERMISSIONS`), and `webhooks(List)` — ad-hoc webhook definitions +(each a JSON-serializable `Map`, as in [Webhooks](webhooks.md)) that the client base64-encodes on the +wire. + +`lastRun(String status)` filters only by status; `lastRun(LastRunOptions)` also accepts an origin +filter. `LastRunOptions` has fluent setters `status(String)` (e.g. `SUCCEEDED`, `RUNNING`) and +`origin(String)` (e.g. `API`, `WEB`, `SCHEDULER`); leave a setter uncalled to omit that filter. + +```java +Optional last = + client.actor("apify/hello-world").lastRun(new LastRunOptions().status("SUCCEEDED").origin("API")).get(); +``` + +```java +ActorRun run = client.actor("apify/hello-world") + .call(Map.of("greeting", "hi"), new ActorStartOptions().memoryMbytes(512L), 120L); +System.out.println(run.getStatus()); +``` + +`Actor` fields: `getId()`, `getUserId()`, `getName()`, `getUsername()`, `getTitle()`, +`getDescription()`, `isPublic()`, `getCreatedAt()`, `getModifiedAt()`, plus `getExtra()` for any +unmodelled fields. + +## `ActorVersionClient` and `ActorVersionCollectionClient` + +`client.actor(id).versions()` lists/creates versions; `client.actor(id).version(v)` reads, updates +and deletes a single version and exposes its environment variables. + +```java +ActorVersion version = client.actor("me/my-actor").version("0.0").get().orElseThrow(); +System.out.println(version.getSourceType()); +``` + +## `ActorEnvVarClient` and `ActorEnvVarCollectionClient` + +Attach environment variables to a version. `ActorEnvVar` has a `(name, value)` constructor plus +fluent setters `setName`, `setValue`, `setIsSecret(Boolean)` (when secret, the value is stored +encrypted), and matching getters `getName()`, `getValue()`, `getIsSecret()`. + +```java +client.actor("me/my-actor").version("0.0").envVars() + .create(new ActorEnvVar("API_KEY", "secret").setIsSecret(true)); +Optional ev = client.actor("me/my-actor").version("0.0").envVar("API_KEY").get(); +``` diff --git a/docs/builds.md b/docs/builds.md new file mode 100644 index 0000000..063ced5 --- /dev/null +++ b/docs/builds.md @@ -0,0 +1,36 @@ +# Builds + +Access the build collection with `client.builds()` (or `client.actor(id).builds()` for an Actor's +builds) and a single build with `client.build(id)`. + +## `BuildCollectionClient` + +| Method | Description | +|---|---| +| `list(ListOptions)` | List builds. Returns `PaginationList`. | + +## `BuildClient` + +| Method | Description | +|---|---| +| `get()` | Fetch the build. Returns `Optional`. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, waiting up to `waitForFinishSecs` (max 60) server-side. | +| `abort()` | Abort the build. Returns `Build`. | +| `delete()` | Delete the build. | +| `waitForFinish(Long waitSecs)` | Poll until the build finishes (`null` waits indefinitely). Returns `Build`. | +| `getOpenApiDefinition()` | The build's OpenAPI definition. Returns `Optional`. | +| `log()` | A `LogClient` for the build's log. | + +`Build` fields: `getId()`, `getActId()`, `getStatus()`, `getStartedAt()`, `getFinishedAt()`, +`getBuildNumber()`, plus `isTerminal()` and `getExtra()`. The status is one of `READY`, `RUNNING`, +`SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. + +```java +Build build = client.actor("me/my-actor").build("0.0", new ActorBuildOptions().tag("latest")); +Build finished = client.build(build.getId()).waitForFinish(300L); +if (finished.isTerminal()) { + System.out.println("built " + finished.getBuildNumber()); +} +``` + +`ActorBuildOptions` fields (all optional): `betaPackages`, `tag`, `useCache`, `waitForFinish`. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..4d30cc4 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,98 @@ +# Runnable examples + +Each example below is a self-contained snippet assuming a configured `client`. The same programs +live under [`src/test/java/com/apify/client/examples/`](../src/test/java/com/apify/client/examples) +and are executed end-to-end against the live API by the `Test examples` CI step (see +`ExamplesTest`), so they are guaranteed to stay runnable. + +## Run a store Actor and read its default dataset + +```java +ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); +PaginationList items = + client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); +System.out.println("Item count: " + items.getCount()); +``` + +## Each storage: create, push, read + +```java +// Dataset +Dataset dataset = client.datasets().getOrCreate("example-ds"); +client.dataset(dataset.getId()).pushItems(List.of(Map.of("hello", "world"))); +PaginationList dsItems = client.dataset(dataset.getId()).listItems(new DatasetListItemsOptions()); + +// Key-value store +KeyValueStore store = client.keyValueStores().getOrCreate("example-kvs"); +client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); +Optional record = client.keyValueStore(store.getId()).getRecord("OUTPUT"); + +// Request queue +RequestQueue queue = client.requestQueues().getOrCreate("example-rq"); +client.requestQueue(queue.getId()).addRequest(new RequestQueueRequest("https://example.com", "example"), false); +RequestQueueHead head = client.requestQueue(queue.getId()).listHead(10L); +``` + +## Get own account details + +```java +Optional user = client.me().get(); +user.ifPresent(u -> System.out.println("Account " + u.getId() + " / " + u.getUsername())); +``` + +## Create a new Actor, build it, run it, wait, and print the finished run log + +```java +Actor created = client.actors().create(Map.of( + "name", "my-example-actor", + "isPublic", false, + "versions", List.of(Map.of( + "versionNumber", "0.0", + "sourceType", "SOURCE_FILES", + "buildTag", "latest", + "sourceFiles", List.of( + Map.of("name", "Dockerfile", "format", "TEXT", + "content", "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"), + Map.of("name", "main.js", "format", "TEXT", "content", "console.log('hi');")))))); +try { + Build build = client.actor(created.getId()).build("0.0", new ActorBuildOptions()); + client.build(build.getId()).waitForFinish(300L); + ActorRun run = client.actor(created.getId()).call(null, new ActorStartOptions(), 120L); + Optional log = client.run(run.getId()).log().get(); + log.ifPresent(System.out::println); +} finally { + client.actor(created.getId()).delete(); +} +``` + +## Start a run, wait, then fetch the Actor's last run and its storages + +```java +client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); +Optional last = client.actor("apify/hello-world").lastRun("SUCCEEDED").get(); +if (last.isPresent()) { + ActorRun run = last.get(); + client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); + client.keyValueStore(run.getDefaultKeyValueStoreId()).getRecord("OUTPUT"); +} +``` + +## Lazy iteration of Store Actors + +```java +Iterator it = client.store().iterate(new StoreListOptions().limit(10L)); +int shown = 0; +while (shown < 5 && it.hasNext()) { + System.out.println(it.next().getName()); + shown++; +} +``` + +## Run an Actor with log redirection + +```java +ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); +try (InputStream stream = client.run(run.getId()).getStreamedLog()) { + stream.transferTo(System.out); +} +``` diff --git a/docs/misc.md b/docs/misc.md new file mode 100644 index 0000000..b2aed38 --- /dev/null +++ b/docs/misc.md @@ -0,0 +1,62 @@ +# Store, users & logs + +## Apify Store — `client.store()` + +Browse public Actors in the Apify Store. + +| Method | Description | +|---|---| +| `list(StoreListOptions)` | A page of Store Actors. Returns `PaginationList`. | +| `iterate(StoreListOptions)` | A lazy `Iterator` fetching pages on demand. | + +`StoreListOptions` fields: `offset`, `limit`, `search`, `sortBy`, `category`, `username`, +`pricingModel` (`FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`), +`includeUnrunnableActors`, `allowsAgenticUsers`, `responseFormat` (`full`, `agent`). + +```java +Iterator it = client.store().iterate(new StoreListOptions().search("crawler").limit(20L)); +int shown = 0; +while (shown < 5 && it.hasNext()) { + ActorStoreListItem item = it.next(); + System.out.println(item.getUsername() + "/" + item.getName()); + shown++; +} +``` + +`ActorStoreListItem` fields: `getId()`, `getName()`, `getUsername()`, `getTitle()`. + +## Users — `client.me()` / `client.user(id)` + +| Method | Description | +|---|---| +| `get()` | Fetch the user. Returns `Optional` (private details for `me()` via `getExtra()`). | +| `monthlyUsage()` / `monthlyUsage(String date)` | Account monthly usage (`me()` only). Returns `JsonNode`. | +| `limits()` | Account resource limits (`me()` only). Returns `JsonNode`. | +| `updateLimits(Object)` | Update account limits (`me()` only). | + +The usage/limits methods are only available for `me()`; calling them on `user(id)` throws +`IllegalStateException`. + +```java +Optional me = client.me().get(); +me.ifPresent(u -> System.out.println("Account: " + u.getId())); +JsonNode usage = client.me().monthlyUsage(); +``` + +`User` fields: `getId()`, `getUsername()`, plus `getExtra()`. + +## Logs — `client.log(id)` + +Access a build's or run's log directly, or via `client.run(id).log()` / `client.build(id).log()`. + +| Method | Description | +|---|---| +| `get()` / `get(LogOptions)` | The whole log as text. Returns `Optional`. | +| `stream()` / `stream(LogOptions)` | A live `InputStream` over the log (for redirection). | + +`LogOptions` fields: `raw(Boolean)`, `download(Boolean)`. + +```java +Optional log = client.log("RUN_OR_BUILD_ID").get(); +log.ifPresent(System.out::println); +``` diff --git a/docs/runs.md b/docs/runs.md new file mode 100644 index 0000000..1da639b --- /dev/null +++ b/docs/runs.md @@ -0,0 +1,59 @@ +# Runs + +Access the run collection with `client.runs()` (or `client.actor(id).runs()` / +`client.task(id).runs()`) and a single run with `client.run(id)`. + +## `RunCollectionClient` + +| Method | Description | +|---|---| +| `list(ListOptions, RunListOptions)` | List runs. Returns `PaginationList`. | + +`RunListOptions` adds `status(List)` (e.g. `SUCCEEDED`, `RUNNING`; sent comma-separated) and, +for Actor/task-scoped collections, `startedAfter(String)` / `startedBefore(String)` (ISO-8601). + +```java +PaginationList runs = client.runs().list( + new ListOptions().limit(10L), + new RunListOptions().status(List.of("SUCCEEDED"))); +``` + +## `RunClient` + +| Method | Description | +|---|---| +| `get()` | Fetch the run. Returns `Optional`. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, waiting up to `waitForFinishSecs` (max 60) server-side. | +| `update(Object)` | Update the run. Returns `ActorRun`. | +| `delete()` | Delete the run. | +| `abort(Boolean gracefully)` | Abort the run (`null` = server default). Returns `ActorRun`. | +| `metamorph(String targetActorId, Object input, MetamorphOptions)` | Metamorph into another Actor. | +| `reboot()` | Reboot the run. Returns `ActorRun`. | +| `resurrect(RunResurrectOptions)` | Resurrect a finished run. Returns `ActorRun`. | +| `charge(RunChargeOptions)` | Charge a pay-per-event run for a named event. | +| `waitForFinish(Long waitSecs)` | Poll until the run finishes (`null` waits indefinitely). Returns `ActorRun`. | +| `dataset()` / `keyValueStore()` / `requestQueue()` | Clients for the run's default storages. | +| `log()` | A `LogClient` for the run's log (see [Store, users & logs](misc.md#logs--clientlogid)). | +| `getStreamedLog()` | A live raw log `InputStream` (for log redirection). | + +`getStreamedLog()` is a convenience equivalent to `run(id).log().stream(new LogOptions().raw(true))`; +use `log()` for the full log text or for non-raw/download options. + +To set the current run's status message from inside an Actor, use the top-level +`client.setStatusMessage(...)` (see [the docs index](README.md#setting-single-resource-status)). + +`ActorRun` fields include `getId()`, `getActId()`, `getUserId()`, `getStatus()`, +`getStatusMessage()`, `getStartedAt()`, `getFinishedAt()`, `getBuildId()`, `getDefaultDatasetId()`, +`getDefaultKeyValueStoreId()`, `getDefaultRequestQueueId()`, `getContainerUrl()`, plus `isTerminal()`. + +`RunChargeOptions` (constructed with the required event name) uses plain values: `count(Long)` and +`idempotencyKey(String)` — the latter is auto-generated when unset so a transport-retried charge is +applied at most once. + +```java +client.run("RUN_ID").charge(new RunChargeOptions("my-event").count(3L)); +``` + +`MetamorphOptions` uses plain values `build(String)` and `contentType(String)`. +`RunResurrectOptions` fields: `build`, `memoryMbytes`, `timeoutSecs`, `maxItems`, +`maxTotalChargeUsd`, `restartOnError`. diff --git a/docs/schedules.md b/docs/schedules.md new file mode 100644 index 0000000..fed9409 --- /dev/null +++ b/docs/schedules.md @@ -0,0 +1,38 @@ +# Schedules + +Schedules automatically start Actor or task runs at specified times. Access the collection with +`client.schedules()` and a single schedule with `client.schedule(id)`. + +## `ScheduleCollectionClient` + +| Method | Description | +|---|---| +| `list(ListOptions)` | List schedules. Returns `PaginationList`. | +| `create(Object)` | Create a schedule from a JSON-serializable definition. Returns `Schedule`. | + +The `actions` array describes what the schedule runs (e.g. a `RUN_ACTOR` action): + +```java +Schedule schedule = client.schedules().create(Map.of( + "name", "nightly", + "cronExpression", "0 0 * * *", + "isEnabled", true, + "isExclusive", true, + "actions", List.of(Map.of( + "type", "RUN_ACTOR", + "actorId", "apify/hello-world")))); +``` + +## `ScheduleClient` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | CRUD. | +| `getLog()` | The schedule's invocation log. Returns `Optional`. | + +```java +Optional s = client.schedule("SCHEDULE_ID").get(); +s.ifPresent(sched -> System.out.println(sched.getCronExpression())); +``` + +`Schedule` fields: `getId()`, `getUserId()`, `getName()`, `getCronExpression()`, `isEnabled()`. diff --git a/docs/storages.md b/docs/storages.md new file mode 100644 index 0000000..47ea699 --- /dev/null +++ b/docs/storages.md @@ -0,0 +1,147 @@ +# Storages: datasets, key-value stores, request queues + +The three storage types share a consistent shape: a collection client (`list`, `getOrCreate`) and a +single-resource client (`get`, `update`, `delete`, plus storage-specific operations). Run-nested +default storages are reachable via `client.run(id).dataset()` / `.keyValueStore()` / +`.requestQueue()`. + +## Datasets + +### `DatasetCollectionClient` — `client.datasets()` + +| Method | Description | +|---|---| +| `list(StorageListOptions)` | List datasets. Returns `PaginationList`. | +| `getOrCreate(String name)` | Get or create a named dataset (empty name → unnamed). Returns `Dataset`. | + +`StorageListOptions` adds `unnamed(Boolean)` and `ownership(String)` on top of offset/limit/desc. + +### `DatasetClient` — `client.dataset(id)` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | Metadata CRUD. | +| `listItems(DatasetListItemsOptions)` | List items as `PaginationList`. | +| `listItems(DatasetListItemsOptions, Class)` | List items decoded into `T`. | +| `downloadItems(DownloadItemsFormat, DatasetDownloadOptions)` | Serialized bytes (JSON/JSONL/CSV/XLSX/XML/RSS/HTML). | +| `pushItems(Object)` | Push a single item or a list of items. | +| `getStatistics()` | Dataset statistics. Returns `Optional`. | +| `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` | A public (optionally signed) items URL. | + +```java +Dataset ds = client.datasets().getOrCreate("my-dataset"); +client.dataset(ds.getId()).pushItems(List.of(Map.of("url", "https://a.com"))); +PaginationList page = client.dataset(ds.getId()).listItems(new DatasetListItemsOptions().limit(100L)); +byte[] csv = client.dataset(ds.getId()).downloadItems(DownloadItemsFormat.CSV, new DatasetDownloadOptions().bom(true)); +``` + +`DatasetListItemsOptions` fields: `offset`, `limit`, `desc`, `fields`, `outputFields`, `omit`, +`skipEmpty`, `skipHidden`, `clean`, `unwind`, `flatten`, `view`, `simplified`, `skipFailedPages`, +`signature`. `fields` selects which source fields to include; `outputFields` positionally *renames* +the fields chosen by `fields` in the output (the i-th name renames the i-th `fields` entry), so it +only makes sense together with `fields`. `downloadItems(...)` returns `byte[]` (the serialized +export). `DatasetDownloadOptions` wraps a `DatasetListItemsOptions` (`items(...)`) and adds +`attachment`, `bom`, `delimiter`, `skipHeaderRow`, `xmlRoot`, `xmlRow`, `feedTitle`, +`feedDescription`. + +`createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` returns a `String` URL. If the +dataset is private, the client fetches it, reads its URL-signing secret, and appends an HMAC-SHA256 +signature (bounded by `expiresInSecs`, or non-expiring when `null`); for public datasets the URL is +unsigned. + +## Key-value stores + +### `KeyValueStoreCollectionClient` — `client.keyValueStores()` + +`list(StorageListOptions)` and `getOrCreate(String)`, as for datasets. + +### `KeyValueStoreClient` — `client.keyValueStore(id)` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | Metadata CRUD. | +| `listKeys(ListKeysOptions)` | List keys. Returns `KeyValueStoreKeysPage`. | +| `recordExists(String key)` | Whether a record exists. | +| `getRecord(String key)` / `getRecord(String key, GetRecordOptions)` | Fetch a record. Returns `Optional`. | +| `setRecord(String key, byte[] value, String contentType)` | Store raw bytes. | +| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`timeoutSecs`, `doNotRetryTimeouts`). | +| `setRecordJson(String key, Object value)` | Store JSON. | +| `deleteRecord(String key)` | Delete a record. | +| `getRecordPublicUrl(String key)` | A public (optionally signed) record URL. | +| `createKeysPublicUrl(Long expiresInSecs)` | A public (optionally signed) key-list URL. | +| `createKeysPublicUrl(ListKeysOptions, Long expiresInSecs)` | As above, forwarding key-listing filters (`limit`, `prefix`, `collection`, `exclusiveStartKey`). | + +```java +KeyValueStore store = client.keyValueStores().getOrCreate("my-store"); +client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); +Optional rec = client.keyValueStore(store.getId()).getRecord("OUTPUT"); +rec.ifPresent(r -> System.out.println(new String(r.getValue()))); +``` + +`KeyValueStoreRecord` exposes `getKey()`, `getValue()` (raw bytes) and `getContentType()`. +`KeyValueStoreKeysPage` exposes `getItems()` (a list of `KeyValueStoreKey` with `getKey()`/`getSize()`), +`isTruncated()`, `getExclusiveStartKey()` and `getNextExclusiveStartKey()`. + +Both `getRecordPublicUrl` and `createKeysPublicUrl` return a `String` URL, signed for private stores +and unsigned for public ones. They differ because they sign different things: a single record URL +signs the record key directly (there is no expiry to bound), while a key-list URL uses an +expiry-aware storage-content signature — hence only `createKeysPublicUrl` takes an `expiresInSecs` +(`null` = non-expiring). + +## Request queues + +### `RequestQueueCollectionClient` — `client.requestQueues()` + +`list(StorageListOptions)` and `getOrCreate(String)`, as for datasets. + +### `RequestQueueClient` — `client.requestQueue(id)` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | Metadata CRUD. | +| `withClientKey(String)` | A copy that identifies its requests with a stable client key (required for lock operations). | +| `listHead(Long limit)` | Requests at the head. Returns `RequestQueueHead`. | +| `addRequest(RequestQueueRequest, boolean forefront)` | Add a request. Returns `RequestQueueOperationInfo`. | +| `getRequest(String id)` | Fetch a request. Returns `Optional`. | +| `updateRequest(RequestQueueRequest, boolean forefront)` | Update a request. | +| `deleteRequest(String id)` | Delete a request. | +| `batchAddRequests(List, boolean forefront)` | Add many (auto-chunked at 25, unprocessed requests retried). Returns `BatchAddResult`. | +| `batchAddRequests(List, boolean forefront, BatchAddRequestsOptions)` | As above, tuning `maxUnprocessedRequestsRetries`, `maxParallel` and `minDelayBetweenUnprocessedRequestsRetriesMillis`. | +| `batchDeleteRequests(Object)` | Delete many. Returns `JsonNode`. | +| `listAndLockHead(long lockSecs, Long limit)` | Atomically lock the head. Returns `JsonNode`. | +| `listRequests(ListRequestsOptions)` | List requests. Returns `JsonNode`. | +| `prolongRequestLock(String id, long lockSecs, boolean forefront)` | Extend a lock. Returns `JsonNode`. | +| `deleteRequestLock(String id, boolean forefront)` | Release a lock. | +| `unlockRequests()` | Release all the client's locks. Returns `JsonNode`. | +| `paginateRequests(Long pageLimit)` | A lazy `Iterator` over all requests. | + +```java +RequestQueue rq = client.requestQueues().getOrCreate("my-queue"); +RequestQueueClient queue = client.requestQueue(rq.getId()); +queue.addRequest(new RequestQueueRequest("https://example.com", "example"), false); +Iterator it = queue.paginateRequests(100L); +while (it.hasNext()) { + System.out.println(it.next().getUrl()); +} +``` + +`ListRequestsOptions` fields: `limit`, `exclusiveStartId`, `cursor` (mutually exclusive with +`exclusiveStartId`), and `filter(List)` restricted to `ListRequestsOptions.FILTER_LOCKED` / +`FILTER_PENDING`. + +`RequestQueueRequest` models a request. Its `(url, uniqueKey)` constructor covers the common case +(`uniqueKey` is the deduplication key); fluent setters `setId`, `setUrl`, `setUniqueKey`, +`setMethod`, `setUserData(JsonNode)` and matching getters cover the rest (unset fields are omitted on +the wire). + +Return types: +- `RequestQueueOperationInfo` (from `addRequest`/`updateRequest`): `getRequestId()`, + `isWasAlreadyPresent()`, `isWasAlreadyHandled()`. +- `RequestQueueHead` (from `listHead`): `getItems()` (a list of `RequestQueueRequest`), + `getLimit()`, `isHadMultipleClients()`. +- `BatchAddResult` (from `batchAddRequests`): `getProcessedRequests()` (a list of + `RequestQueueOperationInfo`) and `getUnprocessedRequests()` (a list of `RequestQueueRequest`). + +The remaining lock/list operations return raw `JsonNode` (see +[Raw JSON values](README.md#raw-json-values)); `batchDeleteRequests(Object)` accepts a +JSON-serializable list of request identifiers (each with an `id` or `uniqueKey`). diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..2db0d62 --- /dev/null +++ b/docs/tasks.md @@ -0,0 +1,44 @@ +# Tasks + +Tasks are pre-configured Actor runs with stored input. Access the task collection with +`client.tasks()` and a single task with `client.task(id)`. + +## `TaskCollectionClient` + +| Method | Description | +|---|---| +| `list(ListOptions)` | List tasks. Returns `PaginationList`. | +| `create(Object)` | Create a task from a JSON-serializable definition. Returns `Task`. | + +```java +Task task = client.tasks().create(Map.of( + "actId", "apify/hello-world", + "name", "my-task", + "options", Map.of("build", "latest", "memoryMbytes", 256, "timeoutSecs", 60), + "input", Map.of("message", "hello"))); +``` + +## `TaskClient` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | CRUD. | +| `start(Object input, TaskStartOptions)` | Start a task run (input overrides stored input; `null` uses it). | +| `call(Object input, TaskStartOptions, Long waitSecs)` | Start and poll until finished. | +| `getInput()` | The stored input. Returns `Optional`. | +| `updateInput(Object)` | Replace the stored input. Returns `JsonNode`. | +| `lastRun(String status)` / `lastRun(LastRunOptions)` | A `RunClient` for the last run (see [`LastRunOptions`](actors.md#actorclient)). | +| `runs()` | Nested run collection client. | +| `webhooks()` | Read-only nested webhook collection (`NestedWebhookCollectionClient`, list only). | + +`TaskStartOptions` mirrors `ActorStartOptions` but omits the Actor-only `contentType` and +`forcePermissionLevel`: `build`, `memoryMbytes`, `timeoutSecs`, `waitForFinish`, `maxItems`, +`maxTotalChargeUsd`, `restartOnError`, `webhooks`. + +```java +ActorRun run = client.task("TASK_ID").call(null, new TaskStartOptions().memoryMbytes(512L), 120L); +System.out.println(run.getStatus()); +``` + +`Task` fields: `getId()`, `getActId()`, `getUserId()`, `getName()`, `getTitle()`, `getCreatedAt()`, +`getModifiedAt()`. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..59a8533 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,61 @@ +# Webhooks & dispatches + +Webhooks notify an external service when specific events occur. Access the collection with +`client.webhooks()` and a single webhook with `client.webhook(id)`. Dispatches (individual +invocations) are available account-wide via `client.webhookDispatches()` / +`client.webhookDispatch(id)` and per-webhook via `client.webhook(id).dispatches()`. + +## `WebhookCollectionClient` — `client.webhooks()` + +The account-wide collection supports both listing and creation. + +| Method | Description | +|---|---| +| `list(ListOptions)` | List webhooks. Returns `PaginationList`. | +| `create(Object)` | Create a webhook. Returns `Webhook`. | + +### Nested webhook collections (read-only) + +`client.actor(id).webhooks()` and `client.task(id).webhooks()` return a +`NestedWebhookCollectionClient`. The Apify API only supports **listing** webhooks on those nested +paths (`GET /v2/acts/{id}/webhooks`, `GET /v2/actor-tasks/{id}/webhooks`), so this read-only type +exposes `list(ListOptions)` only — it has no `create(...)`. To create a webhook targeting a specific +Actor or task, use `client.webhooks().create(...)` and set the Actor/task in the webhook's +`condition`. + +A webhook definition supplies `eventTypes` (a list of event-type strings such as +`ACTOR.RUN.SUCCEEDED`, `ACTOR.RUN.FAILED`, `ACTOR.RUN.ABORTED`, `ACTOR.RUN.TIMED_OUT`, +`ACTOR.RUN.CREATED`, `ACTOR.BUILD.SUCCEEDED`, …), a `condition` and a `requestUrl`. The definition is +a plain JSON-serializable value (e.g. a `Map`); this client does not wrap it in a typed enum: + +```java +Webhook webhook = client.webhooks().create(Map.of( + "isAdHoc", false, + "eventTypes", List.of("ACTOR.RUN.SUCCEEDED", "ACTOR.RUN.FAILED"), + "condition", Map.of("actorId", "apify/hello-world"), + "requestUrl", "https://example.com/webhook")); +``` + +## `WebhookClient` + +| Method | Description | +|---|---| +| `get()` / `update(Object)` / `delete()` | CRUD. | +| `test()` | Dispatch the webhook immediately. Returns `WebhookDispatch`. | +| `dispatches()` | This webhook's dispatch collection. | + +```java +WebhookDispatch dispatch = client.webhook("WEBHOOK_ID").test(); +System.out.println(dispatch.getId()); +``` + +`Webhook` fields: `getId()`, `getUserId()`, `getRequestUrl()`, `getEventTypes()`. + +## `WebhookDispatchCollectionClient` and `WebhookDispatchClient` + +| Method | Description | +|---|---| +| `webhookDispatches().list(ListOptions)` | List dispatches. Returns `PaginationList`. | +| `webhookDispatch(id).get()` | Fetch a dispatch. Returns `Optional`. | + +`WebhookDispatch` fields: `getId()`, `getWebhookId()`. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..12eba97 --- /dev/null +++ b/pom.xml @@ -0,0 +1,193 @@ + + + 4.0.0 + + com.apify + apify-client + 0.1.0 + jar + + Apify Java Client + Official (experimental, AI-generated and AI-maintained) Java client for the Apify API. + https://github.com/apify/apify-client-java + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Apify + support@apify.com + Apify + https://apify.com + + + + + scm:git:https://github.com/apify/apify-client-java.git + scm:git:git@github.com:apify/apify-client-java.git + https://github.com/apify/apify-client-java + + + + 17 + UTF-8 + 2.17.2 + 5.10.2 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + -Xlint:all + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + 1 + true + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.4 + + Max + Medium + true + false + spotbugs-exclude.xml + + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.43.0 + + + + 1.22.0 + + + + + + + + + + + + + + + + release + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.7.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.5.0 + true + + central + true + + + + + + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..252e702 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java b/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java new file mode 100644 index 0000000..14750dd --- /dev/null +++ b/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java @@ -0,0 +1,22 @@ +package com.apify.client; + +/** + * Shared read-only behavior for webhook collections. Both the account-wide collection ({@link + * WebhookCollectionClient}) and the read-only collections nested under an Actor or task ({@link + * NestedWebhookCollectionClient}) can list webhooks; only the account-wide collection can create + * them. Internal base class. + */ +abstract class AbstractWebhookCollectionClient { + final ResourceContext ctx; + + AbstractWebhookCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "webhooks"); + } + + /** Lists webhooks. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Webhook.class); + } +} diff --git a/src/main/java/com/apify/client/Actor.java b/src/main/java/com/apify/client/Actor.java new file mode 100644 index 0000000..135fa92 --- /dev/null +++ b/src/main/java/com/apify/client/Actor.java @@ -0,0 +1,61 @@ +package com.apify.client; + +import java.time.Instant; + +/** An Actor on the Apify platform. */ +public final class Actor extends ApifyResource { + private String id; + private String userId; + private String name; + private String username; + private String title; + private String description; + private boolean isPublic; + private Instant createdAt; + private Instant modifiedAt; + + /** The unique Actor ID. */ + public String getId() { + return id; + } + + /** The ID of the user who owns the Actor. */ + public String getUserId() { + return userId; + } + + /** The technical name of the Actor (used in API paths). */ + public String getName() { + return name; + } + + /** The username of the Actor's owner. */ + public String getUsername() { + return username; + } + + /** The human-readable title shown in the UI. */ + public String getTitle() { + return title; + } + + /** A description of what the Actor does. */ + public String getDescription() { + return description; + } + + /** Whether the Actor is publicly available in Apify Store. */ + public boolean isPublic() { + return isPublic; + } + + /** When the Actor was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the Actor was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } +} diff --git a/src/main/java/com/apify/client/ActorBuildOptions.java b/src/main/java/com/apify/client/ActorBuildOptions.java new file mode 100644 index 0000000..990ee5a --- /dev/null +++ b/src/main/java/com/apify/client/ActorBuildOptions.java @@ -0,0 +1,40 @@ +package com.apify.client; + +/** Configures {@link ActorClient#build(String, ActorBuildOptions)}. */ +public final class ActorBuildOptions { + private Boolean betaPackages; + private String tag; + private Boolean useCache; + private Long waitForFinish; + + /** If {@code true}, use beta versions of Apify packages. */ + public ActorBuildOptions betaPackages(Boolean betaPackages) { + this.betaPackages = betaPackages; + return this; + } + + /** The tag to apply to the build (e.g. {@code "latest"}). */ + public ActorBuildOptions tag(String tag) { + this.tag = tag; + return this; + } + + /** Whether to use the Docker build cache (default true). */ + public ActorBuildOptions useCache(Boolean useCache) { + this.useCache = useCache; + return this; + } + + /** Maximum seconds to wait server-side for the build (max 60). */ + public ActorBuildOptions waitForFinish(Long waitForFinish) { + this.waitForFinish = waitForFinish; + return this; + } + + void apply(QueryParams q) { + q.addBool("betaPackages", betaPackages) + .addString("tag", tag) + .addBool("useCache", useCache) + .addLong("waitForFinish", waitForFinish); + } +} diff --git a/src/main/java/com/apify/client/ActorClient.java b/src/main/java/com/apify/client/ActorClient.java new file mode 100644 index 0000000..ac968f1 --- /dev/null +++ b/src/main/java/com/apify/client/ActorClient.java @@ -0,0 +1,130 @@ +package com.apify.client; + +import java.util.Optional; + +/** + * A client for a specific Actor. + * + *

It provides CRUD methods plus convenience helpers to start/call the Actor, build it, and + * access its runs, builds, versions and webhooks. + */ +public final class ActorClient { + private final ApifyClient root; + private final HttpClientCore http; + private final ResourceContext ctx; + private final String baseUrl; + private final String id; + + ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { + this.root = root; + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, "actors", id); + this.baseUrl = baseUrl; + this.id = id; + } + + /** The Actor's ID (or {@code username~name}) as provided. */ + public String getId() { + return id; + } + + /** Fetches the Actor object, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), Actor.class); + } + + /** Updates the Actor with the given fields and returns the updated object. */ + public Actor update(Object newFields) { + return ctx.updateResource("", newFields, Actor.class); + } + + /** Deletes the Actor. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Starts the Actor and returns immediately with the created run. {@code input} is any + * JSON-serializable value (or {@code null} for no input). + */ + public ActorRun start(Object input, ActorStartOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + byte[] body = input == null ? null : Json.toBytes(input); + return ctx.postWithBody("runs", params, body, options.contentTypeOrDefault(), ActorRun.class); + } + + /** + * Starts the Actor and waits (client-side polling) for it to finish. {@code waitSecs} bounds the + * wait; {@code null} waits indefinitely. Returns the finished run (or the still-running run if + * the wait budget was exhausted). + */ + public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { + ActorRun run = start(input, options); + return root.run(run.getId()).waitForFinish(waitSecs); + } + + /** Builds the given version of the Actor and returns the created build. */ + public Build build(String versionNumber, ActorBuildOptions options) { + QueryParams params = new QueryParams(); + params.addString("version", versionNumber); + options.apply(params); + return ctx.postWithBody("builds", params, null, ResourceContext.CONTENT_TYPE_JSON, Build.class); + } + + /** + * Resolves the Actor's default build and returns a client for it. {@code waitForFinish} + * optionally bounds how long (in seconds) the API waits for the build to finish before + * responding. + */ + public BuildClient defaultBuild(Long waitForFinish) { + QueryParams params = new QueryParams(); + params.addLong("waitForFinish", waitForFinish); + Build build = ctx.getResourceRequired("builds/default", params, Build.class); + return new BuildClient(http, baseUrl, build.getId()); + } + + /** + * Returns a client for the last run of this Actor, optionally filtered by status (e.g. {@code + * "SUCCEEDED"}). Pass {@code null} or empty for no filter. + */ + public RunClient lastRun(String status) { + return lastRun(new LastRunOptions().status(status)); + } + + /** + * Returns a client for the last run of this Actor, optionally filtered by status and/or origin. + */ + public RunClient lastRun(LastRunOptions options) { + RunClient client = new RunClient(root, http, ctx.subUrl(""), "runs", "last"); + client.setLastRunParams(options); + return client; + } + + /** A client for this Actor's build collection. */ + public BuildCollectionClient builds() { + return new BuildCollectionClient(http, ctx.subUrl(""), "builds"); + } + + /** A client for this Actor's run collection. */ + public RunCollectionClient runs() { + return new RunCollectionClient(http, ctx.subUrl(""), "runs"); + } + + /** A client for a specific version of this Actor. */ + public ActorVersionClient version(String versionNumber) { + return new ActorVersionClient(http, ctx.subUrl(""), versionNumber); + } + + /** A client for this Actor's version collection. */ + public ActorVersionCollectionClient versions() { + return new ActorVersionCollectionClient(http, ctx.subUrl("")); + } + + /** + * A read-only client for this Actor's webhook collection ({@code GET /v2/actors/{id}/webhooks}). + */ + public NestedWebhookCollectionClient webhooks() { + return new NestedWebhookCollectionClient(http, ctx.subUrl("")); + } +} diff --git a/src/main/java/com/apify/client/ActorCollectionClient.java b/src/main/java/com/apify/client/ActorCollectionClient.java new file mode 100644 index 0000000..ea631cd --- /dev/null +++ b/src/main/java/com/apify/client/ActorCollectionClient.java @@ -0,0 +1,22 @@ +package com.apify.client; + +/** A client for the Actor collection ({@code GET/POST /v2/actors}). */ +public final class ActorCollectionClient { + private final ResourceContext ctx; + + ActorCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "actors"); + } + + /** Lists the account's Actors. */ + public PaginationList list(ActorListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Actor.class); + } + + /** Creates a new Actor. {@code actor} is any JSON-serializable Actor definition. */ + public Actor create(Object actor) { + return ctx.createResource(new QueryParams(), actor, Actor.class); + } +} diff --git a/src/main/java/com/apify/client/ActorEnvVar.java b/src/main/java/com/apify/client/ActorEnvVar.java new file mode 100644 index 0000000..7a0f99f --- /dev/null +++ b/src/main/java/com/apify/client/ActorEnvVar.java @@ -0,0 +1,45 @@ +package com.apify.client; + +/** An environment variable attached to an Actor version. */ +public final class ActorEnvVar extends ApifyResource { + private String name; + private String value; + private Boolean isSecret; + + public ActorEnvVar() {} + + public ActorEnvVar(String name, String value) { + this.name = name; + this.value = value; + } + + /** The environment variable name. */ + public String getName() { + return name; + } + + public ActorEnvVar setName(String name) { + this.name = name; + return this; + } + + /** The environment variable value. */ + public String getValue() { + return value; + } + + public ActorEnvVar setValue(String value) { + this.value = value; + return this; + } + + /** Whether the value is stored as a secret. */ + public Boolean getIsSecret() { + return isSecret; + } + + public ActorEnvVar setIsSecret(Boolean isSecret) { + this.isSecret = isSecret; + return this; + } +} diff --git a/src/main/java/com/apify/client/ActorEnvVarClient.java b/src/main/java/com/apify/client/ActorEnvVarClient.java new file mode 100644 index 0000000..7e6760c --- /dev/null +++ b/src/main/java/com/apify/client/ActorEnvVarClient.java @@ -0,0 +1,30 @@ +package com.apify.client; + +import java.util.Optional; + +/** + * A client for a single environment variable ({@code GET/PUT/DELETE + * /v2/actors/{actorId}/versions/{versionNumber}/env-vars/{name}}). + */ +public final class ActorEnvVarClient { + private final ResourceContext ctx; + + ActorEnvVarClient(HttpClientCore http, String versionUrl, String name) { + this.ctx = ResourceContext.single(http, versionUrl, "env-vars", name); + } + + /** Fetches the environment variable, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), ActorEnvVar.class); + } + + /** Updates the environment variable and returns the updated object. */ + public ActorEnvVar update(ActorEnvVar envVar) { + return ctx.updateResource("", envVar, ActorEnvVar.class); + } + + /** Deletes the environment variable. */ + public void delete() { + ctx.deleteResource(""); + } +} diff --git a/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java b/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java new file mode 100644 index 0000000..1058867 --- /dev/null +++ b/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java @@ -0,0 +1,23 @@ +package com.apify.client; + +/** + * A client for an Actor version's environment variable collection ({@code GET/POST + * /v2/actors/{actorId}/versions/{versionNumber}/env-vars}). + */ +public final class ActorEnvVarCollectionClient { + private final ResourceContext ctx; + + ActorEnvVarCollectionClient(HttpClientCore http, String versionUrl) { + this.ctx = ResourceContext.collection(http, versionUrl, "env-vars"); + } + + /** Lists the version's environment variables. */ + public PaginationList list() { + return ctx.listResource("", new QueryParams(), ActorEnvVar.class); + } + + /** Creates a new environment variable. */ + public ActorEnvVar create(ActorEnvVar envVar) { + return ctx.createResource(new QueryParams(), envVar, ActorEnvVar.class); + } +} diff --git a/src/main/java/com/apify/client/ActorListOptions.java b/src/main/java/com/apify/client/ActorListOptions.java new file mode 100644 index 0000000..efddf2b --- /dev/null +++ b/src/main/java/com/apify/client/ActorListOptions.java @@ -0,0 +1,48 @@ +package com.apify.client; + +/** Options for {@link ActorCollectionClient#list(ActorListOptions)}. */ +public final class ActorListOptions { + private Long offset; + private Long limit; + private Boolean desc; + private Boolean my; + private String sortBy; + + /** Number of Actors to skip. */ + public ActorListOptions offset(Long offset) { + this.offset = offset; + return this; + } + + /** Maximum number of Actors to return. */ + public ActorListOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** If {@code true}, return Actors newest-first. */ + public ActorListOptions desc(Boolean desc) { + this.desc = desc; + return this; + } + + /** If {@code true}, return only Actors owned by the current user. */ + public ActorListOptions my(Boolean my) { + this.my = my; + return this; + } + + /** The sort field (e.g. {@code "createdAt"}, {@code "stats.lastRunStartedAt"}). */ + public ActorListOptions sortBy(String sortBy) { + this.sortBy = sortBy; + return this; + } + + void apply(QueryParams q) { + q.addLong("offset", offset) + .addLong("limit", limit) + .addBool("desc", desc) + .addBool("my", my) + .addString("sortBy", sortBy); + } +} diff --git a/src/main/java/com/apify/client/ActorRun.java b/src/main/java/com/apify/client/ActorRun.java new file mode 100644 index 0000000..31e635b --- /dev/null +++ b/src/main/java/com/apify/client/ActorRun.java @@ -0,0 +1,94 @@ +package com.apify.client; + +import java.time.Instant; + +/** A single execution of an Actor. */ +public final class ActorRun extends ApifyResource { + private String id; + private String actId; + private String actorTaskId; + private String userId; + private String status; + private String statusMessage; + private Instant startedAt; + private Instant finishedAt; + private String buildId; + private String defaultDatasetId; + private String defaultKeyValueStoreId; + private String defaultRequestQueueId; + private String containerUrl; + + /** The unique run ID. */ + public String getId() { + return id; + } + + /** The ID of the Actor that produced this run. */ + public String getActId() { + return actId; + } + + /** The ID of the task that started this run, if any. */ + public String getActorTaskId() { + return actorTaskId; + } + + /** The ID of the user who owns the run. */ + public String getUserId() { + return userId; + } + + /** + * The current run status. One of the eight {@code ActorJobStatus} values: {@code READY}, {@code + * RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, {@code TIMED-OUT}, {@code + * ABORTING}, {@code ABORTED}. + */ + public String getStatus() { + return status; + } + + /** An optional human-readable status message. */ + public String getStatusMessage() { + return statusMessage; + } + + /** When the run started. */ + public Instant getStartedAt() { + return startedAt; + } + + /** When the run finished (absent while still running). */ + public Instant getFinishedAt() { + return finishedAt; + } + + /** The ID of the build used for the run. */ + public String getBuildId() { + return buildId; + } + + /** The ID of the run's default dataset. */ + public String getDefaultDatasetId() { + return defaultDatasetId; + } + + /** The ID of the run's default key-value store. */ + public String getDefaultKeyValueStoreId() { + return defaultKeyValueStoreId; + } + + /** The ID of the run's default request queue. */ + public String getDefaultRequestQueueId() { + return defaultRequestQueueId; + } + + /** The URL of the run's container (for live access). */ + public String getContainerUrl() { + return containerUrl; + } + + /** Whether the run has reached a terminal (finished) status. */ + public boolean isTerminal() { + return Statuses.isTerminal(status); + } +} diff --git a/src/main/java/com/apify/client/ActorStartOptions.java b/src/main/java/com/apify/client/ActorStartOptions.java new file mode 100644 index 0000000..3d5c111 --- /dev/null +++ b/src/main/java/com/apify/client/ActorStartOptions.java @@ -0,0 +1,117 @@ +package com.apify.client; + +import java.util.Base64; +import java.util.List; + +/** + * Configures starting an Actor run ({@link ActorClient#start}/{@link ActorClient#call}). All fields + * are optional. + */ +public final class ActorStartOptions { + private String build; + private Long memoryMbytes; + private Long timeoutSecs; + private Long waitForFinish; + private Long maxItems; + private Double maxTotalChargeUsd; + private String contentType; + private Boolean restartOnError; + private String forcePermissionLevel; + private List webhooks; + + /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ + public ActorStartOptions build(String build) { + this.build = build; + return this; + } + + /** Memory in megabytes allocated for the run. */ + public ActorStartOptions memoryMbytes(Long memoryMbytes) { + this.memoryMbytes = memoryMbytes; + return this; + } + + /** Timeout for the run in seconds (0 means no timeout). */ + public ActorStartOptions timeoutSecs(Long timeoutSecs) { + this.timeoutSecs = timeoutSecs; + return this; + } + + /** Maximum seconds to wait server-side for the run to finish (max 60). */ + public ActorStartOptions waitForFinish(Long waitForFinish) { + this.waitForFinish = waitForFinish; + return this; + } + + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public ActorStartOptions maxItems(Long maxItems) { + this.maxItems = maxItems; + return this; + } + + /** Maximum total charge in USD (pay-per-event Actors). */ + public ActorStartOptions maxTotalChargeUsd(Double maxTotalChargeUsd) { + this.maxTotalChargeUsd = maxTotalChargeUsd; + return this; + } + + /** The content type of the input body. Defaults to {@code application/json}. */ + public ActorStartOptions contentType(String contentType) { + this.contentType = contentType; + return this; + } + + /** If {@code true}, restart the run if it fails. */ + public ActorStartOptions restartOnError(Boolean restartOnError) { + this.restartOnError = restartOnError; + return this; + } + + /** + * Override the Actor's permission level for this run ({@code LIMITED_PERMISSIONS}/{@code + * FULL_PERMISSIONS}). + */ + public ActorStartOptions forcePermissionLevel(String forcePermissionLevel) { + this.forcePermissionLevel = forcePermissionLevel; + return this; + } + + /** + * Ad-hoc webhooks to attach to this run. They are serialized to base64-encoded JSON as the {@code + * webhooks} query parameter, matching the reference clients. + */ + public ActorStartOptions webhooks(List webhooks) { + this.webhooks = webhooks == null ? null : List.copyOf(webhooks); + return this; + } + + String contentTypeOrDefault() { + return (contentType != null && !contentType.isEmpty()) + ? contentType + : ResourceContext.CONTENT_TYPE_JSON; + } + + void apply(QueryParams q) { + q.addString("build", build) + .addLong("memory", memoryMbytes) + .addLong("timeout", timeoutSecs) + .addLong("waitForFinish", waitForFinish) + .addLong("maxItems", maxItems) + .addDouble("maxTotalChargeUsd", maxTotalChargeUsd) + .addBool("restartOnError", restartOnError) + .addString("forcePermissionLevel", forcePermissionLevel); + q.addString("webhooks", encodeWebhooks(webhooks)); + } + + /** + * Encodes an ad-hoc webhooks list as base64-encoded JSON, as the API's {@code webhooks} query + * parameter requires. Returns {@code null} for a {@code null} list. Shared by Actor and task + * start options (DRY). + */ + static String encodeWebhooks(List webhooks) { + if (webhooks == null) { + return null; + } + return Base64.getEncoder().encodeToString(Json.toBytes(webhooks)); + } +} diff --git a/src/main/java/com/apify/client/ActorStoreListItem.java b/src/main/java/com/apify/client/ActorStoreListItem.java new file mode 100644 index 0000000..d0891f0 --- /dev/null +++ b/src/main/java/com/apify/client/ActorStoreListItem.java @@ -0,0 +1,29 @@ +package com.apify.client; + +/** An Actor as listed in the Apify Store. */ +public final class ActorStoreListItem extends ApifyResource { + private String id; + private String name; + private String username; + private String title; + + /** The unique Actor ID. */ + public String getId() { + return id; + } + + /** The technical name of the Actor. */ + public String getName() { + return name; + } + + /** The username of the Actor's owner. */ + public String getUsername() { + return username; + } + + /** The human-readable title. */ + public String getTitle() { + return title; + } +} diff --git a/src/main/java/com/apify/client/ActorVersion.java b/src/main/java/com/apify/client/ActorVersion.java new file mode 100644 index 0000000..53f088f --- /dev/null +++ b/src/main/java/com/apify/client/ActorVersion.java @@ -0,0 +1,17 @@ +package com.apify.client; + +/** A single version of an Actor. */ +public final class ActorVersion extends ApifyResource { + private String versionNumber; + private String sourceType; + + /** The version identifier (e.g. {@code "0.1"}). */ + public String getVersionNumber() { + return versionNumber; + } + + /** How the version's source is provided (e.g. {@code "SOURCE_FILES"}). */ + public String getSourceType() { + return sourceType; + } +} diff --git a/src/main/java/com/apify/client/ActorVersionClient.java b/src/main/java/com/apify/client/ActorVersionClient.java new file mode 100644 index 0000000..9f9c86d --- /dev/null +++ b/src/main/java/com/apify/client/ActorVersionClient.java @@ -0,0 +1,44 @@ +package com.apify.client; + +import java.util.Optional; + +/** + * A client for a specific Actor version ({@code GET/PUT/DELETE + * /v2/actors/{actorId}/versions/{versionNumber}}). + */ +public final class ActorVersionClient { + private final HttpClientCore http; + private final ResourceContext ctx; + private final String versionUrl; + + ActorVersionClient(HttpClientCore http, String actorUrl, String versionNumber) { + this.http = http; + this.ctx = ResourceContext.single(http, actorUrl, "versions", versionNumber); + this.versionUrl = ctx.subUrl(""); + } + + /** Fetches the version, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), ActorVersion.class); + } + + /** Updates the version with the given fields and returns the updated object. */ + public ActorVersion update(Object newFields) { + return ctx.updateResource("", newFields, ActorVersion.class); + } + + /** Deletes the version. */ + public void delete() { + ctx.deleteResource(""); + } + + /** A client for a specific environment variable of this version. */ + public ActorEnvVarClient envVar(String name) { + return new ActorEnvVarClient(http, versionUrl, name); + } + + /** A client for this version's environment variable collection. */ + public ActorEnvVarCollectionClient envVars() { + return new ActorEnvVarCollectionClient(http, versionUrl); + } +} diff --git a/src/main/java/com/apify/client/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/ActorVersionCollectionClient.java new file mode 100644 index 0000000..22c7fdf --- /dev/null +++ b/src/main/java/com/apify/client/ActorVersionCollectionClient.java @@ -0,0 +1,22 @@ +package com.apify.client; + +/** A client for an Actor's version collection ({@code GET/POST /v2/actors/{actorId}/versions}). */ +public final class ActorVersionCollectionClient { + private final ResourceContext ctx; + + ActorVersionCollectionClient(HttpClientCore http, String actorUrl) { + this.ctx = ResourceContext.collection(http, actorUrl, "versions"); + } + + /** Lists the Actor's versions. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, ActorVersion.class); + } + + /** Creates a new Actor version. {@code version} is any JSON-serializable version definition. */ + public ActorVersion create(Object version) { + return ctx.createResource(new QueryParams(), version, ActorVersion.class); + } +} diff --git a/src/main/java/com/apify/client/ApiResponse.java b/src/main/java/com/apify/client/ApiResponse.java new file mode 100644 index 0000000..a888f12 --- /dev/null +++ b/src/main/java/com/apify/client/ApiResponse.java @@ -0,0 +1,19 @@ +package com.apify.client; + +import java.net.http.HttpHeaders; + +/** + * The parsed result of a single API call: the status code, headers and the fully-buffered response + * body. Internal to the client. + */ +final class ApiResponse { + final int statusCode; + final HttpHeaders headers; + final byte[] body; + + ApiResponse(int statusCode, HttpHeaders headers, byte[] body) { + this.statusCode = statusCode; + this.headers = headers; + this.body = body; + } +} diff --git a/src/main/java/com/apify/client/ApifyApiException.java b/src/main/java/com/apify/client/ApifyApiException.java new file mode 100644 index 0000000..ced3cbd --- /dev/null +++ b/src/main/java/com/apify/client/ApifyApiException.java @@ -0,0 +1,83 @@ +package com.apify.client; + +import java.util.Collections; +import java.util.Map; + +/** + * Thrown for HTTP requests that reach the Apify API but receive a non-success status code. + * + *

It mirrors the {@code ApifyApiError} of the reference JavaScript client and exposes the parsed + * error {@link #getType() type}, the human-readable {@link #getMessage() message}, the HTTP {@link + * #getStatusCode() status code}, the number of the final {@link #getAttempt() attempt}, and the + * request {@link #getHttpMethod() method}/{@link #getPath() path}. + * + *

It is an unchecked exception so callers are not forced to wrap every call; recover from it + * with a normal {@code try}/{@code catch} where relevant. + */ +public class ApifyApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + private final String type; + private final int attempt; + private final String httpMethod; + private final String path; + private final transient Map data; + + ApifyApiException( + int statusCode, + String type, + String message, + int attempt, + String httpMethod, + String path, + Map data) { + super(message); + this.statusCode = statusCode; + this.type = type; + this.attempt = attempt; + this.httpMethod = httpMethod; + this.path = path; + this.data = data; + } + + /** The HTTP status code of the error response. */ + public int getStatusCode() { + return statusCode; + } + + /** The machine-readable error type returned by the API (e.g. {@code "record-not-found"}). */ + public String getType() { + return type; + } + + /** The number of the API call attempt that produced this error (1-based). */ + public int getAttempt() { + return attempt; + } + + /** The HTTP method of the API call (e.g. {@code "GET"}, {@code "POST"}). */ + public String getHttpMethod() { + return httpMethod; + } + + /** The path of the API endpoint (URL excluding origin). */ + public String getPath() { + return path; + } + + /** + * Additional structured data provided by the API about the error, if any (may be {@code null}). + */ + public Map getData() { + return data == null ? null : Collections.unmodifiableMap(data); + } + + @Override + public String getMessage() { + String errType = (type == null || type.isEmpty()) ? "unknown" : type; + return String.format( + "apify API error (status %d, type %s): %s", statusCode, errType, super.getMessage()); + } +} diff --git a/src/main/java/com/apify/client/ApifyClient.java b/src/main/java/com/apify/client/ApifyClient.java new file mode 100644 index 0000000..bc59f35 --- /dev/null +++ b/src/main/java/com/apify/client/ApifyClient.java @@ -0,0 +1,233 @@ +package com.apify.client; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The entry point for interacting with the Apify API. + * + *

Official, but experimental — AI-generated and AI-maintained. This is an official Apify + * client, but it is experimental: it is generated and maintained by AI. Review the code before + * relying on it in production and report issues on the repository. + * + *

Construct it with {@link #create(String)} (token-only) or {@link #builder()}, then obtain + * resource clients via the accessor methods, e.g. {@link #actor(String)}, {@link #dataset(String)}, + * {@link #run(String)}. It is safe for concurrent use. + * + *

Quick start

+ * + *
{@code
+ * ApifyClient client = ApifyClient.create("my-api-token");
+ * ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), null);
+ * PaginationList items = client.dataset(run.getDefaultDatasetId())
+ *     .listItems(new DatasetListItemsOptions());
+ * }
+ * + *

Architecture

+ * + *
    + *
  • Public interface: {@link ApifyClient} and the resource clients it returns. + *
  • Replaceable transport: the {@link HttpBackend} interface, with a default {@link + * DefaultHttpBackend}; swap it via {@link ApifyClientBuilder#httpBackend(HttpBackend)}. + *
  • Cross-cutting behaviour (auth, User-Agent, retries with exponential backoff, timeouts) + * lives in the internal HTTP client and is applied to every request. + *
+ */ +public final class ApifyClient { + + /** Addresses the current user ({@code /users/me}). */ + private static final String ME_USER_PLACEHOLDER = "me"; + + private final HttpClientCore http; + private final String baseUrl; + private final String publicBaseUrl; + + ApifyClient(HttpClientCore http, String baseUrl, String publicBaseUrl) { + this.http = http; + this.baseUrl = baseUrl; + this.publicBaseUrl = publicBaseUrl; + } + + /** Creates a client authenticated with the given API token and default settings. */ + public static ApifyClient create(String token) { + return builder().token(token).build(); + } + + /** Returns a new {@link ApifyClientBuilder} for configuring a client. */ + public static ApifyClientBuilder builder() { + return new ApifyClientBuilder(); + } + + /** Returns the {@code User-Agent} header value this client sends. */ + public String getUserAgent() { + return http.userAgent(); + } + + /** + * Returns the fully-qualified API base URL this client targets (including the {@code /v2} + * suffix). + */ + public String getApiBaseUrl() { + return baseUrl; + } + + // ----- Actor accessors ----------------------------------------------------- + + /** A client for the Actor collection (list & create Actors). */ + public ActorCollectionClient actors() { + return new ActorCollectionClient(http, baseUrl); + } + + /** A client for a specific Actor, addressed by ID or {@code username~name}. */ + public ActorClient actor(String id) { + return new ActorClient(this, http, baseUrl, id); + } + + // ----- Build accessors ----------------------------------------------------- + + /** A client for the Actor build collection (list builds). */ + public BuildCollectionClient builds() { + return new BuildCollectionClient(http, baseUrl, "actor-builds"); + } + + /** A client for a specific Actor build. */ + public BuildClient build(String id) { + return new BuildClient(http, baseUrl, id); + } + + // ----- Run accessors ------------------------------------------------------- + + /** A client for the Actor run collection (list runs). */ + public RunCollectionClient runs() { + return new RunCollectionClient(http, baseUrl, "actor-runs"); + } + + /** A client for a specific Actor run. */ + public RunClient run(String id) { + return new RunClient(this, http, baseUrl, "actor-runs", id); + } + + // ----- Dataset accessors --------------------------------------------------- + + /** A client for the dataset collection (list & get-or-create datasets). */ + public DatasetCollectionClient datasets() { + return new DatasetCollectionClient(http, baseUrl); + } + + /** A client for a specific dataset, addressed by ID or name. */ + public DatasetClient dataset(String id) { + return new DatasetClient(http, baseUrl, "datasets", id).withPublicBase(publicBaseUrl); + } + + // ----- Key-value store accessors ------------------------------------------- + + /** A client for the key-value store collection. */ + public KeyValueStoreCollectionClient keyValueStores() { + return new KeyValueStoreCollectionClient(http, baseUrl); + } + + /** A client for a specific key-value store, addressed by ID or name. */ + public KeyValueStoreClient keyValueStore(String id) { + return new KeyValueStoreClient(http, baseUrl, "key-value-stores", id) + .withPublicBase(publicBaseUrl); + } + + // ----- Request queue accessors --------------------------------------------- + + /** A client for the request queue collection. */ + public RequestQueueCollectionClient requestQueues() { + return new RequestQueueCollectionClient(http, baseUrl); + } + + /** A client for a specific request queue, addressed by ID or name. */ + public RequestQueueClient requestQueue(String id) { + return new RequestQueueClient(http, baseUrl, "request-queues", id); + } + + // ----- Task accessors ------------------------------------------------------ + + /** A client for the Actor task collection (list & create tasks). */ + public TaskCollectionClient tasks() { + return new TaskCollectionClient(http, baseUrl); + } + + /** A client for a specific Actor task. */ + public TaskClient task(String id) { + return new TaskClient(this, http, baseUrl, id); + } + + // ----- Schedule accessors -------------------------------------------------- + + /** A client for the schedule collection (list & create schedules). */ + public ScheduleCollectionClient schedules() { + return new ScheduleCollectionClient(http, baseUrl); + } + + /** A client for a specific schedule. */ + public ScheduleClient schedule(String id) { + return new ScheduleClient(http, baseUrl, id); + } + + // ----- Webhook accessors --------------------------------------------------- + + /** A client for the webhook collection (list & create webhooks). */ + public WebhookCollectionClient webhooks() { + return new WebhookCollectionClient(http, baseUrl); + } + + /** A client for a specific webhook. */ + public WebhookClient webhook(String id) { + return new WebhookClient(http, baseUrl, id); + } + + /** A client for the webhook dispatch collection. */ + public WebhookDispatchCollectionClient webhookDispatches() { + return new WebhookDispatchCollectionClient(http, baseUrl, "webhook-dispatches"); + } + + /** A client for a specific webhook dispatch. */ + public WebhookDispatchClient webhookDispatch(String id) { + return new WebhookDispatchClient(http, baseUrl, id); + } + + // ----- Misc accessors ------------------------------------------------------ + + /** A client for browsing the Apify Store. */ + public StoreCollectionClient store() { + return new StoreCollectionClient(http, baseUrl); + } + + /** A client for accessing a build's or run's log. */ + public LogClient log(String buildOrRunId) { + return new LogClient(http, baseUrl, "logs", buildOrRunId); + } + + /** A client for the current user ({@code /users/me}). */ + public UserClient me() { + return new UserClient(http, baseUrl, ME_USER_PLACEHOLDER); + } + + /** A client for a specific user by ID or username. */ + public UserClient user(String id) { + return new UserClient(http, baseUrl, id); + } + + /** + * Sets the status message of the current Actor run. + * + *

This convenience method updates the run identified by the {@code ACTOR_RUN_ID} environment + * variable, so it only works when called from inside an Actor run. If {@code isTerminal} is true, + * the message becomes final and won't be overwritten. Throws {@link IllegalStateException} if + * {@code ACTOR_RUN_ID} is not set. + */ + public ActorRun setStatusMessage(String message, boolean isTerminal) { + String runId = System.getenv("ACTOR_RUN_ID"); + if (runId == null || runId.isEmpty()) { + throw new IllegalStateException("ACTOR_RUN_ID environment variable is not set"); + } + Map body = new LinkedHashMap<>(); + body.put("statusMessage", message); + body.put("isStatusMessageTerminal", isTerminal); + return run(runId).update(body); + } +} diff --git a/src/main/java/com/apify/client/ApifyClientBuilder.java b/src/main/java/com/apify/client/ApifyClientBuilder.java new file mode 100644 index 0000000..a073f1c --- /dev/null +++ b/src/main/java/com/apify/client/ApifyClientBuilder.java @@ -0,0 +1,153 @@ +package com.apify.client; + +import java.time.Duration; +import java.util.function.BooleanSupplier; + +/** + * Builder for {@link ApifyClient}. Obtain one via {@link ApifyClient#builder()}, configure it, then + * call {@link #build()}. + */ +public final class ApifyClientBuilder { + + /** Default base URL of the Apify API (without the {@code /v2} suffix). */ + static final String DEFAULT_BASE_URL = "https://api.apify.com"; + + static final int DEFAULT_MAX_RETRIES = 8; + static final Duration DEFAULT_MIN_DELAY = Duration.ofMillis(500); + static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(360); + + /** Environment variable that signals the client is running on the Apify platform. */ + static final String ENV_IS_AT_HOME = "APIFY_IS_AT_HOME"; + + private String token; + private String baseUrl = DEFAULT_BASE_URL; + private String publicBaseUrl; + private int maxRetries = DEFAULT_MAX_RETRIES; + private Duration minDelayBetweenRetries = DEFAULT_MIN_DELAY; + private Duration maxDelayBetweenRetries = DEFAULT_TIMEOUT; + private Duration timeout = DEFAULT_TIMEOUT; + private String userAgentSuffix; + private HttpBackend httpBackend; + private BooleanSupplier isAtHomeFn = ApifyClientBuilder::defaultIsAtHome; + + ApifyClientBuilder() {} + + /** Sets the API token used for authentication (sent as a Bearer token). */ + public ApifyClientBuilder token(String token) { + this.token = token; + return this; + } + + /** Overrides the base URL of the API. The {@code /v2} suffix is appended automatically. */ + public ApifyClientBuilder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** + * Overrides the base URL used when building public, shareable resource URLs (e.g. a signed + * dataset-items URL). Defaults to the API base URL. The {@code /v2} suffix is appended + * automatically. + */ + public ApifyClientBuilder publicBaseUrl(String publicBaseUrl) { + this.publicBaseUrl = publicBaseUrl; + return this; + } + + /** Sets the maximum number of retries for failed requests (default 8). */ + public ApifyClientBuilder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** Sets the minimum delay between retries (default 500ms). */ + public ApifyClientBuilder minDelayBetweenRetries(Duration minDelayBetweenRetries) { + this.minDelayBetweenRetries = minDelayBetweenRetries; + return this; + } + + /** Sets the maximum (exponentially-grown) delay between retries (default equals the timeout). */ + public ApifyClientBuilder maxDelayBetweenRetries(Duration maxDelayBetweenRetries) { + this.maxDelayBetweenRetries = maxDelayBetweenRetries; + return this; + } + + /** Sets the overall per-request timeout (default 360s). */ + public ApifyClientBuilder timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** Appends a custom suffix to the {@code User-Agent} header. */ + public ApifyClientBuilder userAgentSuffix(String userAgentSuffix) { + this.userAgentSuffix = userAgentSuffix; + return this; + } + + /** Replaces the default HTTP backend with a custom implementation (the replaceable transport). */ + public ApifyClientBuilder httpBackend(HttpBackend httpBackend) { + this.httpBackend = httpBackend; + return this; + } + + /** Test seam: overrides how the client decides the {@code isAtHome} User-Agent flag. */ + ApifyClientBuilder isAtHomeFn(BooleanSupplier isAtHomeFn) { + this.isAtHomeFn = isAtHomeFn; + return this; + } + + /** Builds the configured {@link ApifyClient}. */ + public ApifyClient build() { + HttpBackend backend = httpBackend != null ? httpBackend : new DefaultHttpBackend(); + String userAgent = buildUserAgent(userAgentSuffix, isAtHomeFn); + RetryConfig retry = + new RetryConfig(maxRetries, minDelayBetweenRetries, maxDelayBetweenRetries, timeout); + HttpClientCore http = new HttpClientCore(backend, token, userAgent, retry); + + String apiBase = trimTrailingSlash(baseUrl) + "/v2"; + String publicSource = publicBaseUrl != null ? publicBaseUrl : baseUrl; + String publicBase = trimTrailingSlash(publicSource) + "/v2"; + return new ApifyClient(http, apiBase, publicBase); + } + + private static String trimTrailingSlash(String s) { + int end = s.length(); + while (end > 0 && s.charAt(end - 1) == '/') { + end--; + } + return s.substring(0, end); + } + + /** + * Reports whether the client is running on the Apify platform, by reading the {@code + * APIFY_IS_AT_HOME} environment variable (set to a non-empty value on the platform). Per the + * client requirements the flag is based solely on this variable, matching the JS reference. + */ + static boolean defaultIsAtHome() { + String v = System.getenv(ENV_IS_AT_HOME); + return v != null && !v.isEmpty(); + } + + /** + * Builds the {@code User-Agent} header value mandated by the client requirements: {@code + * ApifyClient/{version} ({os}; Java/{javaVersion}); isAtHome/{true|false}}. + */ + static String buildUserAgent(String suffix, BooleanSupplier isAtHomeFn) { + String os = System.getProperty("os.name", "unknown").toLowerCase(java.util.Locale.ROOT); + String javaVersion = System.getProperty("java.version", "unknown"); + String atHome = isAtHomeFn.getAsBoolean() ? "true" : "false"; + String ua = + "ApifyClient/" + + Version.CLIENT_VERSION + + " (" + + os + + "; Java/" + + javaVersion + + "); isAtHome/" + + atHome; + if (suffix != null && !suffix.isEmpty()) { + ua += "; " + suffix; + } + return ua; + } +} diff --git a/src/main/java/com/apify/client/ApifyResource.java b/src/main/java/com/apify/client/ApifyResource.java new file mode 100644 index 0000000..4e58cb3 --- /dev/null +++ b/src/main/java/com/apify/client/ApifyResource.java @@ -0,0 +1,29 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Base class for API resource models. It captures any JSON keys not mapped to a modelled field in + * an {@link #getExtra() extra} map, so additive changes to the API never break deserialization + * (forward compatibility). + */ +public abstract class ApifyResource { + + @JsonIgnore private final Map extra = new LinkedHashMap<>(); + + @JsonAnySetter + void putExtra(String key, Object value) { + extra.put(key, value); + } + + /** Any fields returned by the API that are not mapped to a typed property on this model. */ + @JsonAnyGetter + public Map getExtra() { + return Collections.unmodifiableMap(extra); + } +} diff --git a/src/main/java/com/apify/client/BatchAddRequestsOptions.java b/src/main/java/com/apify/client/BatchAddRequestsOptions.java new file mode 100644 index 0000000..f11c2a2 --- /dev/null +++ b/src/main/java/com/apify/client/BatchAddRequestsOptions.java @@ -0,0 +1,53 @@ +package com.apify.client; + +/** + * Tuning options for {@link RequestQueueClient#batchAddRequests(java.util.List, boolean, + * BatchAddRequestsOptions)}, mirroring the reference client. Requests the API reports as + * unprocessed (typically due to rate limiting) are automatically retried with exponential backoff. + */ +public final class BatchAddRequestsOptions { + + /** Default number of retry rounds for unprocessed requests (matches the reference client). */ + static final int DEFAULT_MAX_UNPROCESSED_RETRIES = 3; + + /** Default maximum number of batch API calls made in parallel (matches the reference client). */ + static final int DEFAULT_MAX_PARALLEL = 5; + + /** Default minimum delay before retrying unprocessed requests (matches the reference client). */ + static final long DEFAULT_MIN_DELAY_BETWEEN_UNPROCESSED_RETRIES_MILLIS = 500; + + private int maxUnprocessedRequestsRetries = DEFAULT_MAX_UNPROCESSED_RETRIES; + private int maxParallel = DEFAULT_MAX_PARALLEL; + private long minDelayBetweenUnprocessedRequestsRetriesMillis = + DEFAULT_MIN_DELAY_BETWEEN_UNPROCESSED_RETRIES_MILLIS; + + /** Maximum number of retry attempts for requests the API leaves unprocessed. Default is 3. */ + public BatchAddRequestsOptions maxUnprocessedRequestsRetries(int maxUnprocessedRequestsRetries) { + this.maxUnprocessedRequestsRetries = Math.max(0, maxUnprocessedRequestsRetries); + return this; + } + + /** Maximum number of batch API calls made in parallel. Default is 5. */ + public BatchAddRequestsOptions maxParallel(int maxParallel) { + this.maxParallel = Math.max(1, maxParallel); + return this; + } + + /** Minimum delay before retrying unprocessed requests, in milliseconds. Default is 500. */ + public BatchAddRequestsOptions minDelayBetweenUnprocessedRequestsRetriesMillis(long millis) { + this.minDelayBetweenUnprocessedRequestsRetriesMillis = Math.max(0, millis); + return this; + } + + int maxUnprocessedRequestsRetriesValue() { + return maxUnprocessedRequestsRetries; + } + + int maxParallelValue() { + return maxParallel; + } + + long minDelayBetweenUnprocessedRequestsRetriesMillisValue() { + return minDelayBetweenUnprocessedRequestsRetriesMillis; + } +} diff --git a/src/main/java/com/apify/client/BatchAddResult.java b/src/main/java/com/apify/client/BatchAddResult.java new file mode 100644 index 0000000..2e3de5b --- /dev/null +++ b/src/main/java/com/apify/client/BatchAddResult.java @@ -0,0 +1,37 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** The result of {@link RequestQueueClient#batchAddRequests}: accepted and unprocessed requests. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BatchAddResult { + private List processedRequests = new ArrayList<>(); + private List unprocessedRequests = new ArrayList<>(); + + /** The requests the API successfully added (unmodifiable). */ + public List getProcessedRequests() { + return Collections.unmodifiableList(processedRequests); + } + + /** The requests the API did not process (unmodifiable). */ + public List getUnprocessedRequests() { + return Collections.unmodifiableList(unprocessedRequests); + } + + /** Appends another result's requests into this one (used to merge per-chunk batch results). */ + void merge(BatchAddResult other) { + this.processedRequests.addAll(other.processedRequests); + this.unprocessedRequests.addAll(other.unprocessedRequests); + } + + void setProcessedRequests(List processedRequests) { + this.processedRequests = new ArrayList<>(processedRequests); + } + + void setUnprocessedRequests(List unprocessedRequests) { + this.unprocessedRequests = new ArrayList<>(unprocessedRequests); + } +} diff --git a/src/main/java/com/apify/client/Build.java b/src/main/java/com/apify/client/Build.java new file mode 100644 index 0000000..ebbc867 --- /dev/null +++ b/src/main/java/com/apify/client/Build.java @@ -0,0 +1,52 @@ +package com.apify.client; + +import java.time.Instant; + +/** A single build of an Actor. */ +public final class Build extends ApifyResource { + private String id; + private String actId; + private String status; + private Instant startedAt; + private Instant finishedAt; + private String buildNumber; + + /** The unique build ID. */ + public String getId() { + return id; + } + + /** The ID of the Actor this build belongs to. */ + public String getActId() { + return actId; + } + + /** + * The current build status. One of the eight {@code ActorJobStatus} values: {@code READY}, {@code + * RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, {@code TIMED-OUT}, {@code + * ABORTING}, {@code ABORTED}. + */ + public String getStatus() { + return status; + } + + /** When the build started. */ + public Instant getStartedAt() { + return startedAt; + } + + /** When the build finished (absent while still building). */ + public Instant getFinishedAt() { + return finishedAt; + } + + /** The human-readable build number (e.g. {@code "0.1.2"}). */ + public String getBuildNumber() { + return buildNumber; + } + + /** Whether the build has reached a terminal (finished) status. */ + public boolean isTerminal() { + return Statuses.isTerminal(status); + } +} diff --git a/src/main/java/com/apify/client/BuildClient.java b/src/main/java/com/apify/client/BuildClient.java new file mode 100644 index 0000000..933bb7b --- /dev/null +++ b/src/main/java/com/apify/client/BuildClient.java @@ -0,0 +1,66 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Optional; + +/** A client for a specific Actor build ({@code /v2/actor-builds/{buildId}}). */ +public final class BuildClient { + private final HttpClientCore http; + private final ResourceContext ctx; + + BuildClient(HttpClientCore http, String baseUrl, String id) { + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, "actor-builds", id); + } + + /** Fetches the build object, or empty if it does not exist. */ + public Optional get() { + return getWithWait(null); + } + + /** + * Fetches the build, optionally asking the API to wait up to {@code waitForFinishSecs} seconds + * (max 60) for the build to finish before responding. Pass {@code null} for an immediate fetch. + */ + public Optional getWithWait(Long waitForFinishSecs) { + QueryParams params = new QueryParams(); + // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. + params.addLong("waitForFinish", ctx.clampServerWait(waitForFinishSecs)); + return ctx.getResource("", params, Build.class); + } + + /** Aborts the build and returns its updated state. */ + public Build abort() { + return ctx.postWithBody("abort", new QueryParams(), null, "", Build.class); + } + + /** Deletes the build. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Polls until the build reaches a terminal state or {@code waitSecs} elapses ({@code null} waits + * indefinitely). Returns the latest build. + */ + public Build waitForFinish(Long waitSecs) { + return ctx.waitForFinish(waitSecs, "build", Json.type(Build.class), Build::isTerminal); + } + + /** + * Returns the OpenAPI definition generated for the build, or empty if it is not available. The + * result is the raw OpenAPI document. + */ + public Optional getOpenApiDefinition() { + ApiResponse resp = ctx.getRaw("openapi.json", new QueryParams()); + if (resp == null) { + return Optional.empty(); + } + return Optional.of(Json.parse(resp.body, JsonNode.class)); + } + + /** A client for accessing this build's log. */ + public LogClient log() { + return LogClient.nested(http, ctx.subUrl("")); + } +} diff --git a/src/main/java/com/apify/client/BuildCollectionClient.java b/src/main/java/com/apify/client/BuildCollectionClient.java new file mode 100644 index 0000000..3552395 --- /dev/null +++ b/src/main/java/com/apify/client/BuildCollectionClient.java @@ -0,0 +1,20 @@ +package com.apify.client; + +/** + * A client for a build collection: the account-wide collection ({@code GET /v2/actor-builds}) or an + * Actor's builds ({@code GET /v2/actors/{id}/builds}). + */ +public final class BuildCollectionClient { + private final ResourceContext ctx; + + BuildCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); + } + + /** Lists builds. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Build.class); + } +} diff --git a/src/main/java/com/apify/client/DataEnvelope.java b/src/main/java/com/apify/client/DataEnvelope.java new file mode 100644 index 0000000..73bd7b2 --- /dev/null +++ b/src/main/java/com/apify/client/DataEnvelope.java @@ -0,0 +1,10 @@ +package com.apify.client; + +/** + * Unwraps the top-level {@code {"data": ...}} wrapper used by most Apify endpoints. Internal. + * + * @param the type of the wrapped payload + */ +final class DataEnvelope { + public T data; +} diff --git a/src/main/java/com/apify/client/Dataset.java b/src/main/java/com/apify/client/Dataset.java new file mode 100644 index 0000000..7efb24d --- /dev/null +++ b/src/main/java/com/apify/client/Dataset.java @@ -0,0 +1,43 @@ +package com.apify.client; + +import java.time.Instant; + +/** A dataset stores structured results from Actor runs. */ +public final class Dataset extends ApifyResource { + private String id; + private String name; + private String userId; + private Instant createdAt; + private Instant modifiedAt; + private long itemCount; + + /** The unique dataset ID. */ + public String getId() { + return id; + } + + /** The dataset name (empty for unnamed datasets). */ + public String getName() { + return name; + } + + /** The ID of the user who owns the dataset. */ + public String getUserId() { + return userId; + } + + /** When the dataset was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the dataset was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } + + /** The number of items currently stored. */ + public long getItemCount() { + return itemCount; + } +} diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/DatasetClient.java new file mode 100644 index 0000000..6a6fde0 --- /dev/null +++ b/src/main/java/com/apify/client/DatasetClient.java @@ -0,0 +1,152 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Optional; + +/** A client for a specific dataset (and run-nested variants). */ +public final class DatasetClient { + private final HttpClientCore http; + private ResourceContext ctx; + + DatasetClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + } + + private DatasetClient(HttpClientCore http, ResourceContext ctx) { + this.http = http; + this.ctx = ctx; + } + + /** Creates a dataset client for a run's default dataset (nested path only, no ID). */ + static DatasetClient nested(HttpClientCore http, String base, String subPath) { + return new DatasetClient(http, ResourceContext.collection(http, base, subPath)); + } + + DatasetClient withPublicBase(String publicBaseUrl) { + this.ctx = ctx.withPublicOrigin(publicBaseUrl); + return this; + } + + /** Fetches the dataset metadata, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), Dataset.class); + } + + /** Updates the dataset metadata (e.g. name, title) and returns the updated object. */ + public Dataset update(Object newFields) { + return ctx.updateResource("", newFields, Dataset.class); + } + + /** Deletes the dataset. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Lists items from the dataset, decoding each into a generic {@link JsonNode}. For typed decoding + * use {@link #listItems(DatasetListItemsOptions, Class)}. + */ + public PaginationList listItems(DatasetListItemsOptions options) { + return listItems(options, JsonNode.class); + } + + /** + * Lists items from the dataset, decoding each item into {@code itemClass}. + * + *

The dataset items endpoint returns a bare JSON array (not a data envelope) and reports + * pagination via {@code X-Apify-Pagination-*} headers, surfaced in the returned {@link + * PaginationList}. + */ + public PaginationList listItems(DatasetListItemsOptions options, Class itemClass) { + QueryParams params = new QueryParams(); + options.apply(params); + String url = params.applyToUrl(ctx.subUrl("items")); + ApiResponse resp = http.call("GET", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + + JavaType listType = Json.parametric(List.class, Json.type(itemClass)); + List items = Json.parse(resp.body, listType); + long count = items.size(); + + PaginationList result = new PaginationList<>(); + result.setItems(items); + result.setCount(count); + result.setTotal(headerLong(resp, "X-Apify-Pagination-Total", count)); + result.setOffset(headerLong(resp, "X-Apify-Pagination-Offset", 0)); + result.setLimit(headerLong(resp, "X-Apify-Pagination-Limit", count)); + if (options.descValue() != null) { + result.setDesc(options.descValue()); + } + return result; + } + + /** + * Downloads dataset items serialized in the given format, returning the raw bytes. Unlike {@link + * #listItems} (parsed items), this returns the items already serialized to JSON, CSV, XLSX, XML, + * RSS or HTML — useful for exporting. + */ + public byte[] downloadItems(DownloadItemsFormat format, DatasetDownloadOptions options) { + QueryParams params = new QueryParams(); + params.addString("format", format.wireValue()); + options.apply(params); + String url = params.applyToUrl(ctx.subUrl("items")); + ApiResponse resp = http.call("GET", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + return resp.body; + } + + /** + * Pushes one or more items to the dataset. {@code items} must serialize to a JSON object or an + * array of objects. + */ + public void pushItems(Object items) { + http.call( + "POST", + ctx.subUrl("items"), + Json.toBytes(items), + ResourceContext.CONTENT_TYPE_JSON_CHARSET, + ResourceContext.DEFAULT_REQUEST_TIMEOUT); + } + + /** Returns statistical information about the dataset, or empty if unavailable. */ + public Optional getStatistics() { + ApiResponse resp = ctx.getRaw("statistics", new QueryParams()); + if (resp == null) { + return Optional.empty(); + } + return Optional.of(Json.parseData(resp.body, JsonNode.class)); + } + + /** + * Builds a public URL for downloading this dataset's items. + * + *

It fetches the dataset, and if the dataset exposes a URL-signing secret key (i.e. it is + * private), appends an HMAC-SHA256 signature so the URL grants access without an API token. + * {@code expiresInSecs} optionally bounds the validity of a signed URL ({@code null} for + * non-expiring). The URL is built from the configured public base URL. + */ + public String createItemsPublicUrl(DatasetListItemsOptions options, Long expiresInSecs) { + QueryParams params = new QueryParams(); + options.apply(params); + Optional dataset = get(); + if (dataset.isPresent()) { + String secret = extractString(dataset.get().getExtra(), "urlSigningSecretKey"); + if (secret != null) { + String sig = Signatures.signStorageContent(secret, dataset.get().getId(), expiresInSecs); + params.addString("signature", sig); + } + } + return params.applyToUrl(ctx.publicUrl("items")); + } + + private static long headerLong(ApiResponse resp, String name, long fallback) { + return resp.headers.firstValueAsLong(name).orElse(fallback); + } + + /** Reads a string field from an extra map, returning {@code null} if absent or not a string. */ + static String extractString(java.util.Map extra, String key) { + Object v = extra.get(key); + return (v instanceof String) ? (String) v : null; + } +} diff --git a/src/main/java/com/apify/client/DatasetCollectionClient.java b/src/main/java/com/apify/client/DatasetCollectionClient.java new file mode 100644 index 0000000..b2a28b1 --- /dev/null +++ b/src/main/java/com/apify/client/DatasetCollectionClient.java @@ -0,0 +1,25 @@ +package com.apify.client; + +/** A client for the dataset collection ({@code GET/POST /v2/datasets}). */ +public final class DatasetCollectionClient { + private final ResourceContext ctx; + + DatasetCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "datasets"); + } + + /** Lists datasets. */ + public PaginationList list(StorageListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Dataset.class); + } + + /** + * Gets the dataset with the given name, creating it if it does not exist. An empty/{@code null} + * name creates a new unnamed dataset. + */ + public Dataset getOrCreate(String name) { + return ctx.getOrCreateNamed(name, Dataset.class); + } +} diff --git a/src/main/java/com/apify/client/DatasetDownloadOptions.java b/src/main/java/com/apify/client/DatasetDownloadOptions.java new file mode 100644 index 0000000..529ae6f --- /dev/null +++ b/src/main/java/com/apify/client/DatasetDownloadOptions.java @@ -0,0 +1,85 @@ +package com.apify.client; + +/** + * Adds format-specific options for {@link DatasetClient#downloadItems} on top of the shared item + * filtering/projection options. + */ +public final class DatasetDownloadOptions { + private DatasetListItemsOptions items = new DatasetListItemsOptions(); + private Boolean attachment; + private Boolean bom; + private String delimiter; + private Boolean skipHeaderRow; + private String xmlRoot; + private String xmlRow; + private String feedTitle; + private String feedDescription; + + /** The shared filtering/projection options. */ + public DatasetDownloadOptions items(DatasetListItemsOptions items) { + this.items = items; + return this; + } + + /** Set {@code Content-Disposition: attachment} on the response. */ + public DatasetDownloadOptions attachment(Boolean attachment) { + this.attachment = attachment; + return this; + } + + /** Prepend a UTF-8 BOM (useful for Excel-compatible CSV). */ + public DatasetDownloadOptions bom(Boolean bom) { + this.bom = bom; + return this; + } + + /** The CSV field delimiter (default {@code ","}). */ + public DatasetDownloadOptions delimiter(String delimiter) { + this.delimiter = delimiter; + return this; + } + + /** Omit the CSV header row. */ + public DatasetDownloadOptions skipHeaderRow(Boolean skipHeaderRow) { + this.skipHeaderRow = skipHeaderRow; + return this; + } + + /** The name of the root XML element (default {@code "items"}). */ + public DatasetDownloadOptions xmlRoot(String xmlRoot) { + this.xmlRoot = xmlRoot; + return this; + } + + /** The name of the per-item XML element (default {@code "item"}). */ + public DatasetDownloadOptions xmlRow(String xmlRow) { + this.xmlRow = xmlRow; + return this; + } + + /** The title used for RSS/Atom feed exports. */ + public DatasetDownloadOptions feedTitle(String feedTitle) { + this.feedTitle = feedTitle; + return this; + } + + /** The description used for RSS/Atom feed exports. */ + public DatasetDownloadOptions feedDescription(String feedDescription) { + this.feedDescription = feedDescription; + return this; + } + + void apply(QueryParams q) { + if (items != null) { + items.apply(q); + } + q.addBool("attachment", attachment) + .addBool("bom", bom) + .addString("delimiter", delimiter) + .addBool("skipHeaderRow", skipHeaderRow) + .addString("xmlRoot", xmlRoot) + .addString("xmlRow", xmlRow) + .addString("feedTitle", feedTitle) + .addString("feedDescription", feedDescription); + } +} diff --git a/src/main/java/com/apify/client/DatasetListItemsOptions.java b/src/main/java/com/apify/client/DatasetListItemsOptions.java new file mode 100644 index 0000000..a449924 --- /dev/null +++ b/src/main/java/com/apify/client/DatasetListItemsOptions.java @@ -0,0 +1,140 @@ +package com.apify.client; + +import java.util.List; + +/** + * Configures listing or downloading dataset items ({@code GET /v2/datasets/{datasetId}/items}). All + * fields are optional. + */ +public final class DatasetListItemsOptions { + private Long offset; + private Long limit; + private Boolean desc; + private List fields; + private List outputFields; + private List omit; + private Boolean skipEmpty; + private Boolean skipHidden; + private Boolean clean; + private List unwind; + private List flatten; + private String view; + private Boolean simplified; + private Boolean skipFailedPages; + private String signature; + + /** Number of items to skip. */ + public DatasetListItemsOptions offset(Long offset) { + this.offset = offset; + return this; + } + + /** Maximum number of items to return. */ + public DatasetListItemsOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** Return items newest-first. */ + public DatasetListItemsOptions desc(Boolean desc) { + this.desc = desc; + return this; + } + + /** Restrict the output to these fields. */ + public DatasetListItemsOptions fields(List fields) { + this.fields = fields == null ? null : List.copyOf(fields); + return this; + } + + /** + * Positionally rename the fields selected by {@code fields} in the output (requires {@code + * fields}). + */ + public DatasetListItemsOptions outputFields(List outputFields) { + this.outputFields = outputFields == null ? null : List.copyOf(outputFields); + return this; + } + + /** Exclude these fields from the output. */ + public DatasetListItemsOptions omit(List omit) { + this.omit = omit == null ? null : List.copyOf(omit); + return this; + } + + /** Skip empty items. */ + public DatasetListItemsOptions skipEmpty(Boolean skipEmpty) { + this.skipEmpty = skipEmpty; + return this; + } + + /** Skip hidden fields (those starting with {@code "#"}). */ + public DatasetListItemsOptions skipHidden(Boolean skipHidden) { + this.skipHidden = skipHidden; + return this; + } + + /** Return only clean (non-empty, non-hidden) items. */ + public DatasetListItemsOptions clean(Boolean clean) { + this.clean = clean; + return this; + } + + /** Expand these fields (each array element becomes a separate item). */ + public DatasetListItemsOptions unwind(List unwind) { + this.unwind = unwind == null ? null : List.copyOf(unwind); + return this; + } + + /** Flatten these nested fields into dot-notation keys. */ + public DatasetListItemsOptions flatten(List flatten) { + this.flatten = flatten == null ? null : List.copyOf(flatten); + return this; + } + + /** Select a predefined dataset view for field selection. */ + public DatasetListItemsOptions view(String view) { + this.view = view; + return this; + } + + /** Return simplified (flattened, cleaned) items. */ + public DatasetListItemsOptions simplified(Boolean simplified) { + this.simplified = simplified; + return this; + } + + /** Skip items that come from failed pages. */ + public DatasetListItemsOptions skipFailedPages(Boolean skipFailedPages) { + this.skipFailedPages = skipFailedPages; + return this; + } + + /** A pre-shared URL signature granting access without an API token. */ + public DatasetListItemsOptions signature(String signature) { + this.signature = signature; + return this; + } + + Boolean descValue() { + return desc; + } + + void apply(QueryParams q) { + q.addLong("offset", offset) + .addLong("limit", limit) + .addBool("desc", desc) + .addCsv("fields", fields) + .addCsv("outputFields", outputFields) + .addCsv("omit", omit) + .addBool("skipEmpty", skipEmpty) + .addBool("skipHidden", skipHidden) + .addBool("clean", clean) + .addCsv("unwind", unwind) + .addCsv("flatten", flatten) + .addString("view", view) + .addBool("simplified", simplified) + .addBool("skipFailedPages", skipFailedPages) + .addString("signature", signature); + } +} diff --git a/src/main/java/com/apify/client/DefaultHttpBackend.java b/src/main/java/com/apify/client/DefaultHttpBackend.java new file mode 100644 index 0000000..cf17489 --- /dev/null +++ b/src/main/java/com/apify/client/DefaultHttpBackend.java @@ -0,0 +1,46 @@ +package com.apify.client; + +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * The default {@link HttpBackend}, backed by the JDK's {@link java.net.http.HttpClient}. + * + *

The per-attempt timeout is applied to each {@link HttpRequest} by the orchestrating client, so + * this backend sets only a connection timeout of its own. It follows normal redirects and reuses + * connections from the underlying client's pool. + */ +public final class DefaultHttpBackend implements HttpBackend { + + /** + * Connection-establishment timeout (distinct from the per-request timeout the client applies). + */ + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); + + private final HttpClient client; + + /** Creates a backend with a sensible default {@link java.net.http.HttpClient}. */ + public DefaultHttpBackend() { + this(HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build()); + } + + /** Wraps a caller-provided {@link java.net.http.HttpClient} (share a pool, custom proxy/TLS). */ + public DefaultHttpBackend(HttpClient client) { + this.client = client; + } + + @Override + public HttpResponse send(HttpRequest request) throws IOException, InterruptedException { + return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } + + @Override + public HttpResponse sendStreaming(HttpRequest request) + throws IOException, InterruptedException { + return client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } +} diff --git a/src/main/java/com/apify/client/DownloadItemsFormat.java b/src/main/java/com/apify/client/DownloadItemsFormat.java new file mode 100644 index 0000000..a165746 --- /dev/null +++ b/src/main/java/com/apify/client/DownloadItemsFormat.java @@ -0,0 +1,30 @@ +package com.apify.client; + +/** An output format for {@link DatasetClient#downloadItems}. */ +public enum DownloadItemsFormat { + /** JSON array. */ + JSON("json"), + /** Newline-delimited JSON. */ + JSONL("jsonl"), + /** Comma-separated values. */ + CSV("csv"), + /** Microsoft Excel (XLSX) workbook. */ + XLSX("xlsx"), + /** XML. */ + XML("xml"), + /** RSS feed. */ + RSS("rss"), + /** HTML table. */ + HTML("html"); + + private final String wireValue; + + DownloadItemsFormat(String wireValue) { + this.wireValue = wireValue; + } + + /** The value sent in the {@code format} query parameter. */ + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/com/apify/client/GetRecordOptions.java b/src/main/java/com/apify/client/GetRecordOptions.java new file mode 100644 index 0000000..70e40fe --- /dev/null +++ b/src/main/java/com/apify/client/GetRecordOptions.java @@ -0,0 +1,23 @@ +package com.apify.client; + +/** Configures {@link KeyValueStoreClient#getRecord(String, GetRecordOptions)}. */ +public final class GetRecordOptions { + private Boolean attachment; + private String signature; + + /** Controls the {@code Content-Disposition: attachment} behaviour. */ + public GetRecordOptions attachment(Boolean attachment) { + this.attachment = attachment; + return this; + } + + /** A pre-shared URL signature granting access without an API token. */ + public GetRecordOptions signature(String signature) { + this.signature = signature; + return this; + } + + void apply(QueryParams q) { + q.addBool("attachment", attachment).addString("signature", signature); + } +} diff --git a/src/main/java/com/apify/client/HttpBackend.java b/src/main/java/com/apify/client/HttpBackend.java new file mode 100644 index 0000000..f75202a --- /dev/null +++ b/src/main/java/com/apify/client/HttpBackend.java @@ -0,0 +1,33 @@ +package com.apify.client; + +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * The replaceable transport contract of the client. + * + *

Implementations are responsible only for sending a single, fully-prepared request and + * returning the raw response. Authentication, the {@code User-Agent} header, retries and + * (de)serialization are handled by the client, so a backend only needs to perform one network + * round-trip. + * + *

A non-2xx HTTP status is not an error at this layer — return it as a normal {@link + * HttpResponse}. Only transport-level failures (connection refused, DNS, timeout) should be thrown. + * + *

Swap the default implementation via {@link ApifyClientBuilder#httpBackend(HttpBackend)} to + * share a connection pool, customize TLS/proxy settings, or inject a mock in tests. + */ +public interface HttpBackend { + + /** Sends a single request and buffers the whole response body as bytes. */ + HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + + /** + * Sends a single request and returns the response body as a live {@link InputStream}, for + * incremental consumption (used by log streaming). The caller closes the stream. + */ + HttpResponse sendStreaming(HttpRequest request) + throws IOException, InterruptedException; +} diff --git a/src/main/java/com/apify/client/HttpClientCore.java b/src/main/java/com/apify/client/HttpClientCore.java new file mode 100644 index 0000000..2241caf --- /dev/null +++ b/src/main/java/com/apify/client/HttpClientCore.java @@ -0,0 +1,291 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * The orchestrating HTTP client shared by every resource client. It owns the backend, the optional + * API token, the {@code User-Agent}, and the retry/timeout policy, and applies them to every + * request. Internal to the client; safe for concurrent use. + */ +final class HttpClientCore { + + /** Status returned when the per-resource rate limit is hit. */ + private static final int RATE_LIMIT_EXCEEDED = 429; + + /** Statuses at or above this value are treated as retryable internal server errors. */ + private static final int MIN_SERVER_ERROR = 500; + + /** Responses with status below this value are treated as success. */ + static final int MAX_SUCCESS_STATUS = 300; + + /** Exponential-backoff multiplier applied to the inter-retry delay after each attempt. */ + private static final int BACKOFF_FACTOR = 2; + + private final HttpBackend backend; + private final String token; + private final String userAgent; + private final RetryConfig retry; + + HttpClientCore(HttpBackend backend, String token, String userAgent, RetryConfig retry) { + this.backend = backend; + this.token = token; + this.userAgent = userAgent; + this.retry = retry; + } + + String userAgent() { + return userAgent; + } + + HttpBackend backend() { + return backend; + } + + /** The configured overall per-request timeout budget, in whole seconds. */ + long requestTimeoutSeconds() { + return retry.timeout.getSeconds(); + } + + /** Sends a request with auth, User-Agent and the retry policy applied. */ + ApiResponse call( + String method, String url, byte[] body, String contentType, Duration baseTimeout) { + return callWithHeaders(method, url, body, contentType, null, baseTimeout, false); + } + + /** + * Like {@link #call} but, when {@code doNotRetryTimeouts} is {@code true}, a transport timeout is + * treated as a terminal failure rather than being retried (other transport/network errors and + * retryable statuses are still retried). + */ + ApiResponse call( + String method, + String url, + byte[] body, + String contentType, + Duration baseTimeout, + boolean doNotRetryTimeouts) { + return callWithHeaders(method, url, body, contentType, null, baseTimeout, doNotRetryTimeouts); + } + + /** Like {@link #call} but additionally sets the given extra headers on every attempt. */ + ApiResponse callWithHeaders( + String method, + String url, + byte[] body, + String contentType, + Map extraHeaders, + Duration baseTimeout) { + return callWithHeaders(method, url, body, contentType, extraHeaders, baseTimeout, false); + } + + ApiResponse callWithHeaders( + String method, + String url, + byte[] body, + String contentType, + Map extraHeaders, + Duration baseTimeout, + boolean doNotRetryTimeouts) { + + Duration delay = retry.minDelayBetweenRetries; + int maxAttempts = retry.maxRetries + 1; + String path = extractPath(url); + RuntimeException lastError = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + boolean retryable; + try { + ApiResponse resp = + doAttempt( + method, url, body, contentType, extraHeaders, attemptTimeout(baseTimeout, attempt)); + if (resp.statusCode < MAX_SUCCESS_STATUS) { + return resp; + } + lastError = buildApiError(resp.statusCode, resp.body, attempt, method, path); + retryable = isStatusRetryable(resp.statusCode); + } catch (TransportException e) { + lastError = e; + // Network/timeout failures are retryable, unless the caller opted out of retrying timeouts. + retryable = !(doNotRetryTimeouts && isTimeout(e)); + } + + if (!retryable || attempt == maxAttempts) { + throw lastError; + } + + sleep(randomizedDelay(delay)); + delay = minDuration(delay.multipliedBy(BACKOFF_FACTOR), retry.maxDelayBetweenRetries); + } + throw lastError; // unreachable in practice (maxAttempts >= 1), defensive + } + + /** + * Builds a fully-prepared {@link HttpRequest} with auth, User-Agent, timeout and extra headers. + */ + HttpRequest buildRequest( + String method, + String url, + byte[] body, + String contentType, + Map extraHeaders, + Duration timeout) { + HttpRequest.BodyPublisher publisher = + body != null + ? HttpRequest.BodyPublishers.ofByteArray(body) + : HttpRequest.BodyPublishers.noBody(); + HttpRequest.Builder b = + HttpRequest.newBuilder(URI.create(url)).method(method, publisher).timeout(timeout); + b.header("User-Agent", userAgent); + if (token != null && !token.isEmpty()) { + b.header("Authorization", "Bearer " + token); + } + if (contentType != null && !contentType.isEmpty()) { + b.header("Content-Type", contentType); + } + if (extraHeaders != null) { + extraHeaders.forEach(b::header); + } + return b.build(); + } + + private ApiResponse doAttempt( + String method, + String url, + byte[] body, + String contentType, + Map extraHeaders, + Duration timeout) { + HttpRequest request = buildRequest(method, url, body, contentType, extraHeaders, timeout); + try { + HttpResponse resp = backend.send(request); + return new ApiResponse(resp.statusCode(), resp.headers(), resp.body()); + } catch (IOException e) { + throw new TransportException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException(e); + } + } + + /** + * Returns {@code min(overall, base * 2^(attempt-1))}. The first attempt uses the per-endpoint + * base timeout; each retry doubles it (so a slow-but-progressing connection gets more time) while + * never exceeding the overall budget. + */ + private Duration attemptTimeout(Duration base, int attempt) { + Duration scaled = base; + for (int i = 1; i < attempt; i++) { + scaled = scaled.multipliedBy(2); + if (scaled.compareTo(retry.timeout) >= 0) { + return retry.timeout; + } + } + return minDuration(scaled, retry.timeout); + } + + private static boolean isStatusRetryable(int status) { + return status == RATE_LIMIT_EXCEEDED || status >= MIN_SERVER_ERROR; + } + + /** Reports whether a transport failure was caused by a request timeout. */ + private static boolean isTimeout(TransportException e) { + return e.getCause() instanceof java.net.http.HttpTimeoutException; + } + + /** + * Returns a delay chosen randomly from {@code [delay, 2*delay)} (exponential backoff + jitter). + */ + private static Duration randomizedDelay(Duration delay) { + long millis = delay.toMillis(); + if (millis <= 0) { + return delay; + } + return Duration.ofMillis(millis + ThreadLocalRandom.current().nextLong(millis)); + } + + private static void sleep(Duration d) { + try { + Thread.sleep(Math.max(0, d.toMillis())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException(e); + } + } + + private static Duration minDuration(Duration a, Duration b) { + return a.compareTo(b) < 0 ? a : b; + } + + /** Builds an {@link ApifyApiException} from an API error response body. */ + static ApifyApiException buildApiError( + int status, byte[] body, int attempt, String method, String path) { + String type = null; + String message = null; + Map data = null; + try { + JsonNode root = Json.MAPPER.readTree(body); + JsonNode error = root.get("error"); + if (error != null && error.hasNonNull("message")) { + type = error.path("type").asText(null); + message = error.path("message").asText(null); + JsonNode dataNode = error.get("data"); + if (dataNode != null && dataNode.isObject()) { + data = + Json.MAPPER.convertValue( + dataNode, + new com.fasterxml.jackson.core.type.TypeReference>() {}); + } + } + } catch (IOException ignored) { + // Fall through to the generic message below. + } + if (message == null) { + message = + (body == null || body.length == 0) + ? "unexpected error with status " + status + : "unexpected error: " + new String(body, java.nio.charset.StandardCharsets.UTF_8); + } + return new ApifyApiException(status, type, message, attempt, method, path, data); + } + + /** Returns the path+query portion of a URL, for error reporting. */ + static String extractPath(String url) { + String rest = url; + int scheme = rest.indexOf("://"); + if (scheme >= 0) { + rest = rest.substring(scheme + 3); + } + int slash = rest.indexOf('/'); + return slash >= 0 ? rest.substring(slash) : ""; + } + + /** Internal marker for transport-level (network/timeout) failures, which are retryable. */ + static final class TransportException extends RuntimeException { + private static final long serialVersionUID = 1L; + + TransportException(Throwable cause) { + super(cause); + } + } + + /** Opens a live streaming response (single attempt, no retry). Used by log streaming. */ + HttpResponse stream(String url) { + HttpRequest request = buildRequest("GET", url, null, null, null, retry.timeout); + try { + return backend.sendStreaming(request); + } catch (IOException e) { + throw new TransportException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException(e); + } + } +} diff --git a/src/main/java/com/apify/client/Json.java b/src/main/java/com/apify/client/Json.java new file mode 100644 index 0000000..5d6382d --- /dev/null +++ b/src/main/java/com/apify/client/Json.java @@ -0,0 +1,99 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.io.UncheckedIOException; + +/** + * Shared JSON (de)serialization for the client, centered on a single configured {@link + * ObjectMapper}. Internal to the client. + * + *

The mapper ignores unknown properties (models also collect them in an {@code extra} map for + * forward compatibility), renders dates as ISO-8601 strings, and omits {@code null} fields when + * serializing request bodies. + */ +final class Json { + + static final ObjectMapper MAPPER = + new ObjectMapper() + .registerModule(new JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + // Bind directly to (private) fields so models need no setters — getters remain the + // public read surface. Any-getters/setters are still honored for the `extra` map. + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE); + + private Json() {} + + /** Serializes a value to JSON bytes. */ + static byte[] toBytes(Object value) { + try { + return MAPPER.writeValueAsBytes(value); + } catch (IOException e) { + throw new UncheckedIOException("failed to serialize request body", e); + } + } + + /** Parses JSON bytes into the given type. */ + static T parse(byte[] body, JavaType type) { + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + throw new UncheckedIOException("failed to parse API response", e); + } + } + + /** Parses JSON bytes into the given class. */ + static T parse(byte[] body, Class type) { + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + throw new UncheckedIOException("failed to parse API response", e); + } + } + + /** Parses JSON bytes into the given {@link TypeReference} (for generic types). */ + static T parse(byte[] body, TypeReference type) { + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + throw new UncheckedIOException("failed to parse API response", e); + } + } + + /** Constructs a {@link JavaType} for a raw class. */ + static JavaType type(Class raw) { + return MAPPER.getTypeFactory().constructType(raw); + } + + /** Constructs a parametric {@link JavaType}, e.g. {@code PaginationList}. */ + static JavaType parametric(Class raw, JavaType... params) { + return MAPPER.getTypeFactory().constructParametricType(raw, params); + } + + /** + * Parses a JSON response body wrapped in a {@code {"data": ...}} envelope, returning the + * unwrapped {@code data} value of the given type. + */ + static T parseData(byte[] body, JavaType dataType) { + JavaType envelopeType = parametric(DataEnvelope.class, dataType); + DataEnvelope envelope = parse(body, envelopeType); + return envelope.data; + } + + /** Parses a data-envelope whose {@code data} is of the given class. */ + static T parseData(byte[] body, Class dataClass) { + return parseData(body, type(dataClass)); + } +} diff --git a/src/main/java/com/apify/client/KeyValueStore.java b/src/main/java/com/apify/client/KeyValueStore.java new file mode 100644 index 0000000..d7ce8d4 --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStore.java @@ -0,0 +1,37 @@ +package com.apify.client; + +import java.time.Instant; + +/** A key-value store holds arbitrary data records. */ +public final class KeyValueStore extends ApifyResource { + private String id; + private String name; + private String userId; + private Instant createdAt; + private Instant modifiedAt; + + /** The unique store ID. */ + public String getId() { + return id; + } + + /** The store name (empty for unnamed stores). */ + public String getName() { + return name; + } + + /** The ID of the user who owns the store. */ + public String getUserId() { + return userId; + } + + /** When the store was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the store was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } +} diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/KeyValueStoreClient.java new file mode 100644 index 0000000..713207d --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStoreClient.java @@ -0,0 +1,156 @@ +package com.apify.client; + +import java.util.Optional; + +/** A client for a specific key-value store (and run-nested variants). */ +public final class KeyValueStoreClient { + private ResourceContext ctx; + + KeyValueStoreClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { + this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + } + + private KeyValueStoreClient(ResourceContext ctx) { + this.ctx = ctx; + } + + /** Creates a client for a run's default key-value store (nested path only, no ID). */ + static KeyValueStoreClient nested(HttpClientCore http, String base, String subPath) { + return new KeyValueStoreClient(ResourceContext.collection(http, base, subPath)); + } + + KeyValueStoreClient withPublicBase(String publicBaseUrl) { + this.ctx = ctx.withPublicOrigin(publicBaseUrl); + return this; + } + + /** Fetches the store metadata, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), KeyValueStore.class); + } + + /** Updates the store metadata (e.g. name) and returns the updated object. */ + public KeyValueStore update(Object newFields) { + return ctx.updateResource("", newFields, KeyValueStore.class); + } + + /** Deletes the store. */ + public void delete() { + ctx.deleteResource(""); + } + + /** Lists the keys stored in this key-value store. */ + public KeyValueStoreKeysPage listKeys(ListKeysOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class); + } + + /** Reports whether a record with the given key exists. */ + public boolean recordExists(String key) { + return ctx.headExists("records/" + ResourceContext.encodePathSegment(key), new QueryParams()); + } + + /** + * Fetches a record by key, or empty if it does not exist. Like the reference client, it requests + * the record as an attachment so the API returns the raw bytes directly rather than redirecting. + */ + public Optional getRecord(String key) { + return getRecord(key, new GetRecordOptions().attachment(true)); + } + + /** Fetches a record with explicit options (attachment, signature). */ + public Optional getRecord(String key, GetRecordOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + ApiResponse resp = ctx.getRaw("records/" + ResourceContext.encodePathSegment(key), params); + if (resp == null) { + return Optional.empty(); + } + String contentType = resp.headers.firstValue("Content-Type").orElse(null); + return Optional.of(new KeyValueStoreRecord(key, resp.body, contentType)); + } + + /** Stores a record with raw bytes and the given content type. */ + public void setRecord(String key, byte[] value, String contentType) { + setRecord(key, value, contentType, new SetRecordOptions()); + } + + /** + * Stores a record with raw bytes and the given content type, honoring the given write options + * ({@code timeoutSecs}, {@code doNotRetryTimeouts}). + */ + public void setRecord(String key, byte[] value, String contentType, SetRecordOptions options) { + java.time.Duration timeout = + options.timeoutSecsValue() != null + ? java.time.Duration.ofSeconds(options.timeoutSecsValue()) + : ResourceContext.DEFAULT_REQUEST_TIMEOUT; + ctx.putRaw( + "records/" + ResourceContext.encodePathSegment(key), + new QueryParams(), + value, + contentType, + timeout, + options.doNotRetryTimeoutsValue()); + } + + /** Stores a record holding the JSON serialization of {@code value}. */ + public void setRecordJson(String key, Object value) { + setRecord(key, Json.toBytes(value), ResourceContext.CONTENT_TYPE_JSON_CHARSET); + } + + /** Deletes a record by key. */ + public void deleteRecord(String key) { + ctx.deleteResource("records/" + ResourceContext.encodePathSegment(key)); + } + + /** + * Builds a public URL for fetching the given record. It fetches the store, and if the store + * exposes a URL-signing secret key (i.e. it is private), appends an HMAC-SHA256 signature so the + * URL grants access without an API token. The URL is built from the configured public base URL. + */ + public String getRecordPublicUrl(String key) { + QueryParams params = new QueryParams(); + Optional store = get(); + if (store.isPresent()) { + String secret = DatasetClient.extractString(store.get().getExtra(), "urlSigningSecretKey"); + if (secret != null) { + params.addString("signature", Signatures.createHmacSignature(secret, key)); + } + } + return params.applyToUrl(ctx.publicUrl("records/" + ResourceContext.encodePathSegment(key))); + } + + /** + * Builds a public URL for listing this store's keys. As with {@link #getRecordPublicUrl}, a + * signature is appended for private stores. {@code expiresInSecs} optionally bounds the validity + * of a signed URL ({@code null} for non-expiring). + */ + public String createKeysPublicUrl(Long expiresInSecs) { + return createKeysPublicUrl(new ListKeysOptions(), expiresInSecs); + } + + /** + * Builds a public URL for listing this store's keys, forwarding the given key-listing filters + * ({@code limit}, {@code prefix}, {@code collection}, {@code exclusiveStartKey}) into the URL. As + * with {@link #getRecordPublicUrl}, a signature is appended for private stores unless the caller + * already supplied one. {@code expiresInSecs} optionally bounds the validity of a signed URL + * ({@code null} for non-expiring). + */ + public String createKeysPublicUrl(ListKeysOptions options, Long expiresInSecs) { + QueryParams params = new QueryParams(); + options.apply(params); + if (options.signatureValue() == null) { + Optional store = get(); + if (store.isPresent()) { + String secret = DatasetClient.extractString(store.get().getExtra(), "urlSigningSecretKey"); + if (secret != null) { + params.addString( + "signature", + Signatures.signStorageContent(secret, store.get().getId(), expiresInSecs)); + } + } + } + return params.applyToUrl(ctx.publicUrl("keys")); + } +} diff --git a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java new file mode 100644 index 0000000..e49382d --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java @@ -0,0 +1,25 @@ +package com.apify.client; + +/** A client for the key-value store collection ({@code GET/POST /v2/key-value-stores}). */ +public final class KeyValueStoreCollectionClient { + private final ResourceContext ctx; + + KeyValueStoreCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "key-value-stores"); + } + + /** Lists key-value stores. */ + public PaginationList list(StorageListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, KeyValueStore.class); + } + + /** + * Gets the store with the given name, creating it if it does not exist. An empty/{@code null} + * name creates a new unnamed store. + */ + public KeyValueStore getOrCreate(String name) { + return ctx.getOrCreateNamed(name, KeyValueStore.class); + } +} diff --git a/src/main/java/com/apify/client/KeyValueStoreKey.java b/src/main/java/com/apify/client/KeyValueStoreKey.java new file mode 100644 index 0000000..cf0aa7f --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStoreKey.java @@ -0,0 +1,17 @@ +package com.apify.client; + +/** A single key listed from a key-value store. */ +public final class KeyValueStoreKey extends ApifyResource { + private String key; + private long size; + + /** The record key. */ + public String getKey() { + return key; + } + + /** The record size in bytes. */ + public long getSize() { + return size; + } +} diff --git a/src/main/java/com/apify/client/KeyValueStoreKeysPage.java b/src/main/java/com/apify/client/KeyValueStoreKeysPage.java new file mode 100644 index 0000000..3a59e8f --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStoreKeysPage.java @@ -0,0 +1,40 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Collections; +import java.util.List; + +/** A page of keys from a key-value store. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class KeyValueStoreKeysPage { + private long limit; + private boolean isTruncated; + private String exclusiveStartKey; + private String nextExclusiveStartKey; + private List items = List.of(); + + /** The maximum number of keys requested. */ + public long getLimit() { + return limit; + } + + /** Whether more keys are available. */ + public boolean isTruncated() { + return isTruncated; + } + + /** The key the listing started after. */ + public String getExclusiveStartKey() { + return exclusiveStartKey; + } + + /** The key to pass to fetch the next page. */ + public String getNextExclusiveStartKey() { + return nextExclusiveStartKey; + } + + /** The listed keys. */ + public List getItems() { + return Collections.unmodifiableList(items); + } +} diff --git a/src/main/java/com/apify/client/KeyValueStoreRecord.java b/src/main/java/com/apify/client/KeyValueStoreRecord.java new file mode 100644 index 0000000..a477227 --- /dev/null +++ b/src/main/java/com/apify/client/KeyValueStoreRecord.java @@ -0,0 +1,33 @@ +package com.apify.client; + +/** + * A single record retrieved from a key-value store. Its {@link #getValue() value} holds the raw + * bytes; callers can decode it according to {@link #getContentType() content type}. + */ +public final class KeyValueStoreRecord { + private final String key; + private final byte[] value; + private final String contentType; + + KeyValueStoreRecord(String key, byte[] value, String contentType) { + this.key = key; + // Defensive copy: this value type must not alias caller-visible mutable state. + this.value = value == null ? null : value.clone(); + this.contentType = contentType; + } + + /** The record key. */ + public String getKey() { + return key; + } + + /** The raw record bytes (a fresh copy on each call; mutating it does not affect the record). */ + public byte[] getValue() { + return value == null ? null : value.clone(); + } + + /** The record's MIME type, as reported by the API. */ + public String getContentType() { + return contentType; + } +} diff --git a/src/main/java/com/apify/client/LastRunOptions.java b/src/main/java/com/apify/client/LastRunOptions.java new file mode 100644 index 0000000..08f3008 --- /dev/null +++ b/src/main/java/com/apify/client/LastRunOptions.java @@ -0,0 +1,36 @@ +package com.apify.client; + +/** + * Filters which "last" run the {@link ActorClient#lastRun}/{@link TaskClient#lastRun} accessors + * resolve to. Leave a field unset to leave that filter unset. + * + *

{@code origin} is an Apify-platform convenience exposed by the reference client but not + * documented as a query parameter in the OpenAPI spec; it is included for parity, threaded to the + * same {@code runs/last} endpoint. + */ +public final class LastRunOptions { + private String status; + private String origin; + + /** Filter by run status (e.g. {@code "SUCCEEDED"}, {@code "FAILED"}, {@code "RUNNING"}). */ + public LastRunOptions status(String status) { + this.status = status; + return this; + } + + /** + * Filter by how the run was started (e.g. {@code "DEVELOPMENT"}, {@code "WEB"}, {@code "API"}). + */ + public LastRunOptions origin(String origin) { + this.origin = origin; + return this; + } + + String statusValue() { + return status; + } + + String originValue() { + return origin; + } +} diff --git a/src/main/java/com/apify/client/ListKeysOptions.java b/src/main/java/com/apify/client/ListKeysOptions.java new file mode 100644 index 0000000..a9d3d70 --- /dev/null +++ b/src/main/java/com/apify/client/ListKeysOptions.java @@ -0,0 +1,52 @@ +package com.apify.client; + +/** Configures {@link KeyValueStoreClient#listKeys(ListKeysOptions)}. */ +public final class ListKeysOptions { + private Long limit; + private String exclusiveStartKey; + private String prefix; + private String collection; + private String signature; + + /** Maximum number of keys to return. */ + public ListKeysOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** List keys after this one (for pagination). */ + public ListKeysOptions exclusiveStartKey(String exclusiveStartKey) { + this.exclusiveStartKey = exclusiveStartKey; + return this; + } + + /** Restrict the listing to keys with this prefix. */ + public ListKeysOptions prefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** Restrict the listing to a named collection of keys. */ + public ListKeysOptions collection(String collection) { + this.collection = collection; + return this; + } + + /** A pre-shared URL signature granting access without an API token. */ + public ListKeysOptions signature(String signature) { + this.signature = signature; + return this; + } + + String signatureValue() { + return signature; + } + + void apply(QueryParams q) { + q.addLong("limit", limit) + .addString("exclusiveStartKey", exclusiveStartKey) + .addString("prefix", prefix) + .addString("collection", collection) + .addString("signature", signature); + } +} diff --git a/src/main/java/com/apify/client/ListOptions.java b/src/main/java/com/apify/client/ListOptions.java new file mode 100644 index 0000000..f18a0dc --- /dev/null +++ b/src/main/java/com/apify/client/ListOptions.java @@ -0,0 +1,34 @@ +package com.apify.client; + +/** + * The standard offset/limit pagination shared by most {@code list} endpoints (builds, runs, tasks, + * schedules, webhooks, Actor versions). All fields are optional; leave one unset to use the API + * default. + */ +public final class ListOptions { + private Long offset; + private Long limit; + private Boolean desc; + + /** Number of items to skip from the beginning of the list. */ + public ListOptions offset(Long offset) { + this.offset = offset; + return this; + } + + /** Maximum number of items to return. */ + public ListOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** If {@code true}, return items newest-first. */ + public ListOptions desc(Boolean desc) { + this.desc = desc; + return this; + } + + void apply(QueryParams q) { + q.addLong("offset", offset).addLong("limit", limit).addBool("desc", desc); + } +} diff --git a/src/main/java/com/apify/client/ListRequestsOptions.java b/src/main/java/com/apify/client/ListRequestsOptions.java new file mode 100644 index 0000000..131d9fd --- /dev/null +++ b/src/main/java/com/apify/client/ListRequestsOptions.java @@ -0,0 +1,75 @@ +package com.apify.client; + +import java.util.List; + +/** Configures {@link RequestQueueClient#listRequests(ListRequestsOptions)}. */ +public final class ListRequestsOptions { + + /** Filter value: currently locked requests. */ + public static final String FILTER_LOCKED = "locked"; + + /** Filter value: pending (not-yet-handled) requests. */ + public static final String FILTER_PENDING = "pending"; + + private Long limit; + private String exclusiveStartId; + private String cursor; + private List filter; + + /** Maximum number of requests to return. */ + public ListRequestsOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** List requests after this ID. */ + public ListRequestsOptions exclusiveStartId(String exclusiveStartId) { + this.exclusiveStartId = exclusiveStartId; + return this; + } + + /** An opaque pagination cursor (alternative to {@code exclusiveStartId}). */ + public ListRequestsOptions cursor(String cursor) { + this.cursor = cursor; + return this; + } + + /** + * Restrict the listing to requests in the given states. Each value must be {@link #FILTER_LOCKED} + * or {@link #FILTER_PENDING}; multiple values are sent comma-separated and mean the union of + * those states. + */ + public ListRequestsOptions filter(List filter) { + this.filter = filter == null ? null : List.copyOf(filter); + return this; + } + + /** Validates the options for API-level constraints. */ + void validate() { + if (exclusiveStartId != null && cursor != null) { + throw new IllegalArgumentException( + "ListRequestsOptions: exclusiveStartId and cursor are mutually exclusive"); + } + if (filter != null) { + for (String f : filter) { + if (!FILTER_LOCKED.equals(f) && !FILTER_PENDING.equals(f)) { + throw new IllegalArgumentException( + "ListRequestsOptions: filter entries must be \"" + + FILTER_LOCKED + + "\" or \"" + + FILTER_PENDING + + "\", got \"" + + f + + "\""); + } + } + } + } + + void apply(QueryParams q) { + q.addLong("limit", limit) + .addString("exclusiveStartId", exclusiveStartId) + .addString("cursor", cursor) + .addCsv("filter", filter); + } +} diff --git a/src/main/java/com/apify/client/LogClient.java b/src/main/java/com/apify/client/LogClient.java new file mode 100644 index 0000000..4d50820 --- /dev/null +++ b/src/main/java/com/apify/client/LogClient.java @@ -0,0 +1,80 @@ +package com.apify.client; + +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** + * A client for accessing the log of an Actor build or run ({@code /v2/logs/{buildOrRunId}}, or the + * run/build-nested {@code .../log}). + */ +public final class LogClient { + private final ResourceContext ctx; + + LogClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { + this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + } + + private LogClient(ResourceContext ctx) { + this.ctx = ctx; + } + + /** Creates a log client for a run's or build's nested log endpoint (e.g. {@code .../log}). */ + static LogClient nested(HttpClientCore http, String base) { + return new LogClient(ResourceContext.collection(http, base, "log")); + } + + /** Fetches the entire log as text, or empty if the log does not exist. */ + public Optional get() { + return get(new LogOptions()); + } + + /** Fetches the log with explicit options (raw, download). */ + public Optional get(LogOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + ApiResponse resp = ctx.getRaw("", params); + if (resp == null) { + return Optional.empty(); + } + return Optional.of(new String(resp.body, StandardCharsets.UTF_8)); + } + + /** + * Opens a live, streaming connection to the log and returns a stream over the log bytes. The + * caller is responsible for closing the returned {@link InputStream}. + * + *

Unlike {@link #get()}, this bypasses the buffered/retrying transport so the log can be + * followed in real time as the run produces it (the {@code stream=1} query parameter). Because + * the response is consumed incrementally, it is not retried. + */ + public InputStream stream() { + return stream(new LogOptions()); + } + + /** Opens a live log stream with explicit options (raw, download). */ + public InputStream stream(LogOptions options) { + QueryParams params = new QueryParams(); + params.addBool("stream", true); + options.apply(params); + String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("")); + + HttpResponse resp = ctx.http.stream(url); + if (resp.statusCode() >= HttpClientCore.MAX_SUCCESS_STATUS) { + byte[] body = drain(resp.body()); + throw HttpClientCore.buildApiError( + resp.statusCode(), body, 1, "GET", HttpClientCore.extractPath(url)); + } + return resp.body(); + } + + private static byte[] drain(InputStream in) { + try (InputStream stream = in) { + return stream.readAllBytes(); + } catch (IOException e) { + return new byte[0]; + } + } +} diff --git a/src/main/java/com/apify/client/LogOptions.java b/src/main/java/com/apify/client/LogOptions.java new file mode 100644 index 0000000..f8762ff --- /dev/null +++ b/src/main/java/com/apify/client/LogOptions.java @@ -0,0 +1,23 @@ +package com.apify.client; + +/** Configures log retrieval/streaming. */ +public final class LogOptions { + private Boolean raw; + private Boolean download; + + /** If {@code true}, return the unprocessed log content (no platform post-processing). */ + public LogOptions raw(Boolean raw) { + this.raw = raw; + return this; + } + + /** If {@code true}, set Content-Disposition so the log is served as a download. */ + public LogOptions download(Boolean download) { + this.download = download; + return this; + } + + void apply(QueryParams q) { + q.addBool("raw", raw).addBool("download", download); + } +} diff --git a/src/main/java/com/apify/client/MetamorphOptions.java b/src/main/java/com/apify/client/MetamorphOptions.java new file mode 100644 index 0000000..5e2230b --- /dev/null +++ b/src/main/java/com/apify/client/MetamorphOptions.java @@ -0,0 +1,29 @@ +package com.apify.client; + +/** Configures {@link RunClient#metamorph}. */ +public final class MetamorphOptions { + private String build; + private String contentType; + + /** Optionally pins the target Actor's build (unset for default). */ + public MetamorphOptions build(String build) { + this.build = build; + return this; + } + + /** The content type of the input body. Defaults to {@code application/json}. */ + public MetamorphOptions contentType(String contentType) { + this.contentType = contentType; + return this; + } + + String buildValue() { + return build; + } + + String contentTypeOrDefault() { + return (contentType != null && !contentType.isEmpty()) + ? contentType + : ResourceContext.CONTENT_TYPE_JSON; + } +} diff --git a/src/main/java/com/apify/client/NestedWebhookCollectionClient.java b/src/main/java/com/apify/client/NestedWebhookCollectionClient.java new file mode 100644 index 0000000..e3f4100 --- /dev/null +++ b/src/main/java/com/apify/client/NestedWebhookCollectionClient.java @@ -0,0 +1,14 @@ +package com.apify.client; + +/** + * A read-only client for the webhooks nested under an Actor ({@code GET /v2/actors/{id}/webhooks}) + * or a task ({@code GET /v2/actor-tasks/{id}/webhooks}). These endpoints only support listing; + * webhooks are created through the account-wide {@link WebhookCollectionClient} (which targets an + * Actor or task via the webhook's {@code condition}), so {@code create} is intentionally not + * exposed here. + */ +public final class NestedWebhookCollectionClient extends AbstractWebhookCollectionClient { + NestedWebhookCollectionClient(HttpClientCore http, String baseUrl) { + super(http, baseUrl); + } +} diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java new file mode 100644 index 0000000..01f3a1b --- /dev/null +++ b/src/main/java/com/apify/client/PaginationList.java @@ -0,0 +1,82 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Collections; +import java.util.List; + +/** + * A single page of an offset/limit-paginated list. + * + *

The pagination metadata ({@link #getTotal() total}, {@link #getOffset() offset}, {@link + * #getLimit() limit}, {@link #getCount() count}, {@link #isDesc() desc}) accompanies the {@link + * #getItems() items}. Note: {@code total} reflects the API's reported total, which can briefly lag + * immediately after a write (e.g. right after pushing items) because the count is computed + * asynchronously — re-read after a short delay if you need an exact post-write total. + * + * @param the item type + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PaginationList { + + private long total; + private long offset; + private long limit; + private long count; + private boolean desc; + private List items = List.of(); + + /** Total number of items available across all pages. */ + public long getTotal() { + return total; + } + + /** Number of items skipped at the start. */ + public long getOffset() { + return offset; + } + + /** Maximum number of items the API would return for this request. */ + public long getLimit() { + return limit; + } + + /** Number of items actually returned in this page. */ + public long getCount() { + return count; + } + + /** Whether the items are in descending order. */ + public boolean isDesc() { + return desc; + } + + /** The items of this page (never {@code null}; unmodifiable). */ + public List getItems() { + return Collections.unmodifiableList(items); + } + + // Package-private setters used by the dataset-items path, which builds pages from headers. + void setTotal(long total) { + this.total = total; + } + + void setOffset(long offset) { + this.offset = offset; + } + + void setLimit(long limit) { + this.limit = limit; + } + + void setCount(long count) { + this.count = count; + } + + void setDesc(boolean desc) { + this.desc = desc; + } + + void setItems(List items) { + this.items = items; + } +} diff --git a/src/main/java/com/apify/client/QueryParams.java b/src/main/java/com/apify/client/QueryParams.java new file mode 100644 index 0000000..5abcafe --- /dev/null +++ b/src/main/java/com/apify/client/QueryParams.java @@ -0,0 +1,103 @@ +package com.apify.client; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * An ordered collection of query parameters that omits absent ({@code null}) values and encodes + * booleans as {@code 1}/{@code 0}, matching the Apify API conventions. Internal to the client. + */ +final class QueryParams { + + private final List pairs = new ArrayList<>(); + + QueryParams() {} + + /** Adds a string parameter if the value is non-null. */ + QueryParams addString(String key, String value) { + if (value != null) { + pairs.add(new String[] {key, value}); + } + return this; + } + + /** Adds an integer parameter if the value is non-null. */ + QueryParams addLong(String key, Long value) { + if (value != null) { + pairs.add(new String[] {key, Long.toString(value)}); + } + return this; + } + + /** Adds a floating-point parameter if the value is non-null. */ + QueryParams addDouble(String key, Double value) { + if (value != null) { + pairs.add(new String[] {key, Double.toString(value)}); + } + return this; + } + + /** Adds a boolean parameter, encoded as {@code 1}/{@code 0}, if the value is non-null. */ + QueryParams addBool(String key, Boolean value) { + if (value != null) { + pairs.add(new String[] {key, value ? "1" : "0"}); + } + return this; + } + + /** Adds a comma-joined list parameter if the list is non-null and non-empty. */ + QueryParams addCsv(String key, List values) { + if (values != null && !values.isEmpty()) { + pairs.add(new String[] {key, String.join(",", values)}); + } + return this; + } + + /** Appends an already-stringified key/value pair unconditionally. */ + QueryParams addRaw(String key, String value) { + pairs.add(new String[] {key, value}); + return this; + } + + boolean isEmpty() { + return pairs.isEmpty(); + } + + /** Returns a shallow copy of this instance. */ + QueryParams copy() { + QueryParams out = new QueryParams(); + out.pairs.addAll(pairs); + return out; + } + + /** Appends all pairs from {@code other} to this instance. */ + QueryParams extend(QueryParams other) { + if (other != null) { + pairs.addAll(other.pairs); + } + return this; + } + + /** Appends the parameters to {@code rawUrl} as a URL-encoded query string. */ + String applyToUrl(String rawUrl) { + if (pairs.isEmpty()) { + return rawUrl; + } + StringBuilder b = new StringBuilder(); + for (int i = 0; i < pairs.size(); i++) { + if (i > 0) { + b.append('&'); + } + String[] p = pairs.get(i); + b.append(encode(p[0])).append('=').append(encode(p[1])); + } + String sep = rawUrl.contains("?") ? "&" : "?"; + return rawUrl + sep + b; + } + + private static String encode(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/apify/client/RequestQueue.java b/src/main/java/com/apify/client/RequestQueue.java new file mode 100644 index 0000000..6d5a3f5 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueue.java @@ -0,0 +1,43 @@ +package com.apify.client; + +import java.time.Instant; + +/** A request queue stores URLs to be crawled. */ +public final class RequestQueue extends ApifyResource { + private String id; + private String name; + private String userId; + private Instant createdAt; + private Instant modifiedAt; + private long totalRequestCount; + + /** The unique queue ID. */ + public String getId() { + return id; + } + + /** The queue name (empty for unnamed queues). */ + public String getName() { + return name; + } + + /** The ID of the user who owns the queue. */ + public String getUserId() { + return userId; + } + + /** When the queue was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the queue was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } + + /** The total number of requests ever added. */ + public long getTotalRequestCount() { + return totalRequestCount; + } +} diff --git a/src/main/java/com/apify/client/RequestQueueClient.java b/src/main/java/com/apify/client/RequestQueueClient.java new file mode 100644 index 0000000..ca73d18 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueueClient.java @@ -0,0 +1,429 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; + +/** A client for a specific request queue (and run-nested variants). */ +public final class RequestQueueClient { + + /** The API limit on requests per batch call; larger inputs are split into chunks of this size. */ + private static final int MAX_REQUESTS_PER_BATCH = 25; + + private final HttpClientCore http; + private final ResourceContext ctx; + private final String clientKey; + + RequestQueueClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { + this(http, ResourceContext.single(http, baseUrl, resourcePath, id), null); + } + + private RequestQueueClient(HttpClientCore http, ResourceContext ctx, String clientKey) { + this.http = http; + this.ctx = ctx; + this.clientKey = clientKey; + } + + /** Creates a client for a run's default request queue (nested path only, no ID). */ + static RequestQueueClient nested(HttpClientCore http, String base, String subPath) { + return new RequestQueueClient(http, ResourceContext.collection(http, base, subPath), null); + } + + /** + * Returns a copy of the client that identifies its requests with {@code clientKey}. A stable + * client key is required to operate on locks the client itself created (e.g. to unlock its own + * requests), and lets the API detect whether multiple clients access a queue. + */ + public RequestQueueClient withClientKey(String clientKey) { + return new RequestQueueClient(http, ctx, clientKey); + } + + private QueryParams withClientKey(QueryParams params) { + if (clientKey != null && !clientKey.isEmpty()) { + params.addString("clientKey", clientKey); + } + return params; + } + + /** Fetches the queue metadata, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), RequestQueue.class); + } + + /** Updates the queue metadata (e.g. name) and returns the updated object. */ + public RequestQueue update(Object newFields) { + return ctx.updateResource("", newFields, RequestQueue.class); + } + + /** Deletes the queue. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Returns the requests at the head (front) of the queue, up to {@code limit} ({@code null} for + * the server default). + */ + public RequestQueueHead listHead(Long limit) { + QueryParams params = new QueryParams(); + params.addLong("limit", limit); + withClientKey(params); + return ctx.getResourceRequired("head", params, RequestQueueHead.class); + } + + /** Adds a request to the queue. If {@code forefront} is true, it is added to the front. */ + public RequestQueueOperationInfo addRequest(RequestQueueRequest request, boolean forefront) { + QueryParams params = new QueryParams(); + params.addBool("forefront", forefront); + withClientKey(params); + return ctx.postWithBody( + "requests", + params, + Json.toBytes(request), + ResourceContext.CONTENT_TYPE_JSON, + RequestQueueOperationInfo.class); + } + + /** Fetches a request by ID, or empty if it does not exist. */ + public Optional getRequest(String id) { + return ctx.getResource( + "requests/" + ResourceContext.encodePathSegment(id), + new QueryParams(), + RequestQueueRequest.class); + } + + /** + * Updates an existing request (identified by its ID field) and returns the operation info. If + * {@code forefront} is true, the request is moved to the front of the queue. + */ + public RequestQueueOperationInfo updateRequest(RequestQueueRequest request, boolean forefront) { + QueryParams params = new QueryParams(); + params.addBool("forefront", forefront); + withClientKey(params); + String url = + ctx.mergedParams(params) + .applyToUrl( + ctx.subUrl("requests/" + ResourceContext.encodePathSegment(request.getId()))); + ApiResponse resp = + http.call( + "PUT", + url, + Json.toBytes(request), + ResourceContext.CONTENT_TYPE_JSON, + ResourceContext.DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, RequestQueueOperationInfo.class); + } + + /** Deletes a request by ID. */ + public void deleteRequest(String id) { + QueryParams params = withClientKey(new QueryParams()); + String url = + ctx.mergedParams(params) + .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id))); + try { + http.call("DELETE", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + } catch (ApifyApiException e) { + if (!ResourceContext.isNotFound(e)) { + throw e; + } + } + } + + /** + * Atomically returns and locks up to {@code limit} requests from the head of the queue for {@code + * lockSecs} seconds. Returns the raw locked-head object. + */ + public JsonNode listAndLockHead(long lockSecs, Long limit) { + QueryParams params = new QueryParams(); + params.addLong("lockSecs", lockSecs).addLong("limit", limit); + withClientKey(params); + return ctx.postWithBody("head/lock", params, null, "", Json.type(JsonNode.class)); + } + + /** + * Adds multiple requests to the queue with the default batch options. If {@code forefront} is + * true, they are added to the front. + */ + public BatchAddResult batchAddRequests(List requests, boolean forefront) { + return batchAddRequests(requests, forefront, new BatchAddRequestsOptions()); + } + + /** + * Adds multiple requests to the queue. If {@code forefront} is true, they are added to the front. + * + *

The input is automatically split into chunks of at most 25 requests (the API limit). Chunks + * are sent using up to {@link BatchAddRequestsOptions#maxParallel} parallel API calls, and any + * requests the API leaves unprocessed (typically rate-limited) are retried with exponential + * backoff up to {@link BatchAddRequestsOptions#maxUnprocessedRequestsRetries} times. The + * per-chunk results are merged into a single {@link BatchAddResult}. + */ + public BatchAddResult batchAddRequests( + List requests, boolean forefront, BatchAddRequestsOptions options) { + List> chunks = new ArrayList<>(); + for (int start = 0; start < requests.size(); start += MAX_REQUESTS_PER_BATCH) { + int end = Math.min(start + MAX_REQUESTS_PER_BATCH, requests.size()); + chunks.add(new ArrayList<>(requests.subList(start, end))); + } + + BatchAddResult merged = new BatchAddResult(); + if (chunks.isEmpty()) { + return merged; + } + + int maxParallel = options.maxParallelValue(); + if (maxParallel <= 1 || chunks.size() == 1) { + for (List chunk : chunks) { + merged.merge(batchAddChunkWithRetries(chunk, forefront, options)); + } + return merged; + } + + ExecutorService pool = Executors.newFixedThreadPool(Math.min(maxParallel, chunks.size())); + try { + List> futures = new ArrayList<>(); + for (List chunk : chunks) { + futures.add(pool.submit(() -> batchAddChunkWithRetries(chunk, forefront, options))); + } + for (Future future : futures) { + merged.merge(awaitResult(future)); + } + } finally { + pool.shutdownNow(); + } + return merged; + } + + private static BatchAddResult awaitResult(Future future) { + try { + return future.get(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IllegalStateException("batch add request failed", cause); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new HttpClientCore.TransportException(e); + } + } + + /** + * Adds one chunk (already sized to the API limit), retrying requests the API leaves unprocessed + * with exponential backoff. Never throws for an API error: on a non-retryable failure the + * remaining requests are returned as unprocessed, matching the reference client's contract. + */ + private BatchAddResult batchAddChunkWithRetries( + List chunk, boolean forefront, BatchAddRequestsOptions options) { + int maxRetries = options.maxUnprocessedRequestsRetriesValue(); + long minDelayMillis = options.minDelayBetweenUnprocessedRequestsRetriesMillisValue(); + + List remaining = chunk; + List processed = new ArrayList<>(); + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + BatchAddResult response = batchAddChunk(remaining, forefront); + processed.addAll(response.getProcessedRequests()); + remaining = requestsNotYetProcessed(chunk, processed); + if (remaining.isEmpty()) { + break; + } + } catch (ApifyApiException e) { + // Transport did not (or was told not to) retry: stop and report everything not yet added as + // unprocessed so the call keeps its non-throwing signature. + break; + } + if (attempt < maxRetries) { + sleepBackoff(attempt, minDelayMillis); + } + } + + BatchAddResult result = new BatchAddResult(); + result.setProcessedRequests(processed); + // Unprocessed = everything sent minus everything acknowledged processed. Computing it here + // (instead of trusting the last response) stays correct even if the API returns fewer entries. + result.setUnprocessedRequests(requestsNotYetProcessed(chunk, processed)); + return result; + } + + private static List requestsNotYetProcessed( + List chunk, List processed) { + Set processedKeys = new HashSet<>(); + for (RequestQueueOperationInfo info : processed) { + if (info.getUniqueKey() != null) { + processedKeys.add(info.getUniqueKey()); + } + } + List remaining = new ArrayList<>(); + for (RequestQueueRequest request : chunk) { + if (!processedKeys.contains(request.getUniqueKey())) { + remaining.add(request); + } + } + return remaining; + } + + private static void sleepBackoff(int attempt, long minDelayMillis) { + if (minDelayMillis <= 0) { + return; + } + // (1 + random) * 2^attempt * minDelay — exponential backoff with jitter, matching the + // reference. + double factor = (1 + ThreadLocalRandom.current().nextDouble()) * Math.pow(2, attempt); + long delayMillis = (long) Math.floor(factor * minDelayMillis); + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new HttpClientCore.TransportException(e); + } + } + + private BatchAddResult batchAddChunk(List requests, boolean forefront) { + QueryParams params = new QueryParams(); + params.addBool("forefront", forefront); + withClientKey(params); + return ctx.postWithBody( + "requests/batch", + params, + Json.toBytes(requests), + ResourceContext.CONTENT_TYPE_JSON, + Json.type(BatchAddResult.class)); + } + + /** + * Deletes multiple requests in a single call. Each entry identifies a request (e.g. by id or + * uniqueKey). Returns the raw batch result. + */ + public JsonNode batchDeleteRequests(Object requests) { + QueryParams params = withClientKey(new QueryParams()); + return ctx.deleteWithBody("requests/batch", params, requests, JsonNode.class); + } + + /** Lists the queue's requests with pagination. Returns the raw response. */ + public JsonNode listRequests(ListRequestsOptions options) { + options.validate(); + QueryParams params = new QueryParams(); + options.apply(params); + withClientKey(params); + return ctx.getResourceRequired("requests", params, Json.type(JsonNode.class)); + } + + /** + * Extends the lock on a request by {@code lockSecs} seconds. If {@code forefront} is true, the + * request is moved to the front when its lock expires. Returns the raw response. + */ + public JsonNode prolongRequestLock(String id, long lockSecs, boolean forefront) { + QueryParams params = new QueryParams(); + params.addLong("lockSecs", lockSecs).addBool("forefront", forefront); + withClientKey(params); + String url = + ctx.mergedParams(params) + .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); + ApiResponse resp = http.call("PUT", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, Json.type(JsonNode.class)); + } + + /** + * Releases the lock on a request. If {@code forefront} is true, the request is moved to the front + * of the queue. + */ + public void deleteRequestLock(String id, boolean forefront) { + QueryParams params = new QueryParams(); + params.addBool("forefront", forefront); + withClientKey(params); + String url = + ctx.mergedParams(params) + .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); + try { + http.call("DELETE", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + } catch (ApifyApiException e) { + if (!ResourceContext.isNotFound(e)) { + throw e; + } + } + } + + /** Releases all locks the client holds on this queue's requests. Returns the raw response. */ + public JsonNode unlockRequests() { + QueryParams params = withClientKey(new QueryParams()); + return ctx.postWithBody("requests/unlock", params, null, "", Json.type(JsonNode.class)); + } + + /** + * Returns a lazy iterator over all requests in the queue, fetching pages of up to {@code + * pageLimit} requests at a time ({@code null} for the server default). + */ + public Iterator paginateRequests(Long pageLimit) { + return new RequestsIterator(pageLimit); + } + + /** Shape of a paginated requests listing. */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class RequestsPage { + public List items = List.of(); + public String nextCursor; + } + + /** Lazily iterates over a request queue's requests via the cursor-based listing endpoint. */ + private final class RequestsIterator implements Iterator { + private final Long pageLimit; + private List buffer = List.of(); + private int pos; + private String nextCursor; + private boolean started; + private boolean exhausted; + + RequestsIterator(Long pageLimit) { + this.pageLimit = pageLimit; + } + + @Override + public boolean hasNext() { + while (pos >= buffer.size()) { + if (exhausted || (started && (nextCursor == null || nextCursor.isEmpty()))) { + return false; + } + fetchPage(); + } + return true; + } + + @Override + public RequestQueueRequest next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return buffer.get(pos++); + } + + private void fetchPage() { + QueryParams params = new QueryParams(); + params.addLong("limit", pageLimit); + if (nextCursor != null && !nextCursor.isEmpty()) { + params.addString("cursor", nextCursor); + } + withClientKey(params); + RequestsPage page = ctx.getResourceRequired("requests", params, RequestsPage.class); + started = true; + buffer = page.items; + pos = 0; + nextCursor = page.nextCursor; + if (page.items.isEmpty() && (nextCursor == null || nextCursor.isEmpty())) { + exhausted = true; + } + } + } +} diff --git a/src/main/java/com/apify/client/RequestQueueCollectionClient.java b/src/main/java/com/apify/client/RequestQueueCollectionClient.java new file mode 100644 index 0000000..a713b32 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueueCollectionClient.java @@ -0,0 +1,25 @@ +package com.apify.client; + +/** A client for the request queue collection ({@code GET/POST /v2/request-queues}). */ +public final class RequestQueueCollectionClient { + private final ResourceContext ctx; + + RequestQueueCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "request-queues"); + } + + /** Lists request queues. */ + public PaginationList list(StorageListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, RequestQueue.class); + } + + /** + * Gets the queue with the given name, creating it if it does not exist. An empty/{@code null} + * name creates a new unnamed queue. + */ + public RequestQueue getOrCreate(String name) { + return ctx.getOrCreateNamed(name, RequestQueue.class); + } +} diff --git a/src/main/java/com/apify/client/RequestQueueHead.java b/src/main/java/com/apify/client/RequestQueueHead.java new file mode 100644 index 0000000..80cb015 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueueHead.java @@ -0,0 +1,26 @@ +package com.apify.client; + +import java.util.Collections; +import java.util.List; + +/** The head (front) of a request queue. */ +public final class RequestQueueHead extends ApifyResource { + private long limit; + private boolean hadMultipleClients; + private List items = List.of(); + + /** The maximum number of requests requested. */ + public long getLimit() { + return limit; + } + + /** Whether multiple clients have accessed the queue. */ + public boolean isHadMultipleClients() { + return hadMultipleClients; + } + + /** The requests at the head of the queue. */ + public List getItems() { + return Collections.unmodifiableList(items); + } +} diff --git a/src/main/java/com/apify/client/RequestQueueOperationInfo.java b/src/main/java/com/apify/client/RequestQueueOperationInfo.java new file mode 100644 index 0000000..dc56c22 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueueOperationInfo.java @@ -0,0 +1,35 @@ +package com.apify.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Returned when adding or updating a request in a queue. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class RequestQueueOperationInfo { + private String requestId; + private String uniqueKey; + private boolean wasAlreadyPresent; + private boolean wasAlreadyHandled; + + /** The ID of the affected request. */ + public String getRequestId() { + return requestId; + } + + /** + * The unique key of the affected request. Populated for batch-add results; may be {@code null} + * for single add/update operations. + */ + public String getUniqueKey() { + return uniqueKey; + } + + /** Whether the request was already in the queue. */ + public boolean isWasAlreadyPresent() { + return wasAlreadyPresent; + } + + /** Whether the request had already been handled. */ + public boolean isWasAlreadyHandled() { + return wasAlreadyHandled; + } +} diff --git a/src/main/java/com/apify/client/RequestQueueRequest.java b/src/main/java/com/apify/client/RequestQueueRequest.java new file mode 100644 index 0000000..6379bf2 --- /dev/null +++ b/src/main/java/com/apify/client/RequestQueueRequest.java @@ -0,0 +1,73 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A single request stored in a request queue. Fields left {@code null} are omitted when the request + * is sent to the API. + */ +public final class RequestQueueRequest extends ApifyResource { + private String id; + private String url; + private String uniqueKey; + private String method; + private JsonNode userData; + + public RequestQueueRequest() {} + + /** Convenience constructor for the common case of a URL + unique key. */ + public RequestQueueRequest(String url, String uniqueKey) { + this.url = url; + this.uniqueKey = uniqueKey; + } + + /** The unique request ID (assigned by the API; absent on create). */ + public String getId() { + return id; + } + + public RequestQueueRequest setId(String id) { + this.id = id; + return this; + } + + /** The request URL. */ + public String getUrl() { + return url; + } + + public RequestQueueRequest setUrl(String url) { + this.url = url; + return this; + } + + /** The deduplication key for the request. */ + public String getUniqueKey() { + return uniqueKey; + } + + public RequestQueueRequest setUniqueKey(String uniqueKey) { + this.uniqueKey = uniqueKey; + return this; + } + + /** The HTTP method (e.g. {@code "GET"}, {@code "POST"}). */ + public String getMethod() { + return method; + } + + public RequestQueueRequest setMethod(String method) { + this.method = method; + return this; + } + + /** Arbitrary user-attached metadata. */ + public JsonNode getUserData() { + return userData; + } + + public RequestQueueRequest setUserData(JsonNode userData) { + this.userData = userData; + return this; + } +} diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/ResourceContext.java new file mode 100644 index 0000000..0d095ac --- /dev/null +++ b/src/main/java/com/apify/client/ResourceContext.java @@ -0,0 +1,392 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JavaType; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * The resolved context for a resource client: its base URL and the shared HTTP client. The methods + * here implement the CRUD primitives once, so each resource client stays small and consistent + * (DRY). Internal to the client. + */ +final class ResourceContext { + + /** Per-request timeout applied to all API calls (6 minutes). Single source of truth. */ + static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(360); + + static final String CONTENT_TYPE_JSON = "application/json"; + static final String CONTENT_TYPE_JSON_CHARSET = "application/json; charset=utf-8"; + + /** How long to wait between polls while waiting for a run/build to finish. */ + private static final Duration WAIT_POLL_INTERVAL = Duration.ofMillis(250); + + /** Server-side waitForFinish chunk size (the API caps server waiting at 60 seconds). */ + private static final long WAIT_REQUEST_SECS = 60; + + /** + * Safety margin subtracted from the configured per-request timeout when choosing the server-side + * {@code waitForFinish} value, so the server responds before the client's socket timeout fires + * (otherwise a short client timeout would abort every healthy poll and burn all retries). + */ + private static final long WAIT_TIMEOUT_MARGIN_SECS = 5; + + /** + * Finite upper bound used when the caller asks to wait "indefinitely" ({@code waitSecs == null}). + * The API will not accept "Infinity", and an unbounded loop can spin forever on a transient 404; + * 999999s (~11.5 days) is effectively indefinite while guaranteeing termination. Mirrors the + * reference client's MAX_WAIT_FOR_FINISH. + */ + private static final long MAX_WAIT_FOR_FINISH_SECS = 999999; + + private static final int NOT_FOUND = 404; + + final HttpClientCore http; + + /** Fully-qualified base URL of the resource, e.g. {@code https://api.apify.com/v2/actors/ID}. */ + final String url; + + final QueryParams baseParams; + + /** Origin (scheme + host) the API is reached through. */ + final String apiOrigin; + + /** Origin used to build public, shareable URLs (defaults to {@link #apiOrigin}). */ + String publicOrigin; + + private ResourceContext(HttpClientCore http, String url, String baseUrl) { + this.http = http; + this.url = url; + this.baseParams = new QueryParams(); + this.apiOrigin = originOf(baseUrl); + this.publicOrigin = this.apiOrigin; + } + + /** Creates a context for a collection endpoint: {@code {base}/{resourcePath}}. */ + static ResourceContext collection(HttpClientCore http, String baseUrl, String resourcePath) { + return new ResourceContext(http, baseUrl + "/" + resourcePath, baseUrl); + } + + /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ + static ResourceContext single( + HttpClientCore http, String baseUrl, String resourcePath, String id) { + return new ResourceContext(http, baseUrl + "/" + resourcePath + "/" + toSafeId(id), baseUrl); + } + + /** Overrides the origin used when building public URLs. */ + ResourceContext withPublicOrigin(String publicBaseUrl) { + this.publicOrigin = originOf(publicBaseUrl); + return this; + } + + /** This resource's URL with an optional extra path segment appended. */ + String subUrl(String subPath) { + return (subPath == null || subPath.isEmpty()) ? url : url + "/" + subPath; + } + + /** + * The public (shareable) form of this resource's URL, swapping the API origin for the public one. + */ + String publicUrl(String subPath) { + String apiUrl = subUrl(subPath); + if (publicOrigin.equals(apiOrigin)) { + return apiUrl; + } + if (apiUrl.startsWith(apiOrigin)) { + return publicOrigin + apiUrl.substring(apiOrigin.length()); + } + return apiUrl; + } + + /** Merges the inherited base params with per-call params. */ + QueryParams mergedParams(QueryParams params) { + return baseParams.copy().extend(params); + } + + // ---- CRUD primitives ------------------------------------------------------ + + Optional getResource(String subPath, QueryParams params, JavaType dataType) { + try { + // ofNullable, not of: an HTTP 200 with body {"data": null} unwraps to null, which is a valid + // "no resource" answer rather than a programming error — never surface it as a raw NPE. + return Optional.ofNullable(getResourceRequired(subPath, params, dataType)); + } catch (ApifyApiException e) { + if (isNotFound(e)) { + return Optional.empty(); + } + throw e; + } + } + + Optional getResource(String subPath, QueryParams params, Class dataClass) { + return getResource(subPath, params, Json.type(dataClass)); + } + + T getResourceRequired(String subPath, QueryParams params, JavaType dataType) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = http.call("GET", u, null, "", DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataType); + } + + T getResourceRequired(String subPath, QueryParams params, Class dataClass) { + return getResourceRequired(subPath, params, Json.type(dataClass)); + } + + T updateResource(String subPath, Object body, Class dataClass) { + String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); + ApiResponse resp = + http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataClass); + } + + /** Performs a DELETE; a not-found is treated as a successful no-op. */ + void deleteResource(String subPath) { + String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); + try { + http.call("DELETE", u, null, "", DEFAULT_REQUEST_TIMEOUT); + } catch (ApifyApiException e) { + if (!isNotFound(e)) { + throw e; + } + } + } + + PaginationList listResource(String subPath, QueryParams params, Class itemClass) { + JavaType listType = Json.parametric(PaginationList.class, Json.type(itemClass)); + return getResourceRequired(subPath, params, listType); + } + + T createResource(QueryParams params, Object body, Class dataClass) { + String u = mergedParams(params).applyToUrl(subUrl("")); + ApiResponse resp = + http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataClass); + } + + /** POST that gets-or-creates a named resource ({@code POST {collection}?name=...}). */ + T getOrCreateNamed(String name, Class dataClass) { + QueryParams params = new QueryParams(); + if (name != null && !name.isEmpty()) { + params.addString("name", name); + } + String u = params.applyToUrl(subUrl("")); + ApiResponse resp = http.call("POST", u, null, "", DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataClass); + } + + /** POST with a raw body (optional) and content type, unwrapping the data envelope. */ + T postWithBody( + String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + return postWithBody(subPath, params, body, contentType, Json.type(dataClass)); + } + + T postWithBody( + String subPath, QueryParams params, byte[] body, String contentType, JavaType dataType) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = http.call("POST", u, body, contentType, DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataType); + } + + /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ + T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = + http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + return Json.parseData(resp.body, dataClass); + } + + /** GET returning the raw response (no data envelope). Returns {@code null} on not-found. */ + ApiResponse getRaw(String subPath, QueryParams params) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + try { + return http.call("GET", u, null, "", DEFAULT_REQUEST_TIMEOUT); + } catch (ApifyApiException e) { + if (isNotFound(e)) { + return null; + } + throw e; + } + } + + /** HEAD request; returns whether the resource exists. */ + boolean headExists(String subPath, QueryParams params) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + try { + http.call("HEAD", u, null, "", DEFAULT_REQUEST_TIMEOUT); + return true; + } catch (ApifyApiException e) { + if (isNotFound(e)) { + return false; + } + throw e; + } + } + + /** PUT with raw bytes and a content type (used for key-value-store record uploads). */ + void putRaw(String subPath, QueryParams params, byte[] body, String contentType) { + putRaw(subPath, params, body, contentType, DEFAULT_REQUEST_TIMEOUT, false); + } + + /** + * PUT with raw bytes and a content type, with an explicit per-request {@code timeout} and control + * over whether transport timeouts are retried. Used by key-value-store record uploads that expose + * the reference client's {@code timeoutSecs}/{@code doNotRetryTimeouts} write options. + */ + void putRaw( + String subPath, + QueryParams params, + byte[] body, + String contentType, + Duration timeout, + boolean doNotRetryTimeouts) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + http.call("PUT", u, body, contentType, timeout, doNotRetryTimeouts); + } + + /** + * The largest server-side {@code waitForFinish} value that is safe to send: below the configured + * per-request timeout by a safety margin (or the API's 60s cap when no finite timeout is set), so + * the server always responds before the client's socket timeout fires. + */ + private long serverWaitCapSecs() { + long configuredTimeoutSecs = http.requestTimeoutSeconds(); + return configuredTimeoutSecs > 0 + ? Math.max(0, configuredTimeoutSecs - WAIT_TIMEOUT_MARGIN_SECS) + : WAIT_REQUEST_SECS; + } + + /** + * Clamps a caller-supplied server-side {@code waitForFinish} value (seconds) to {@link + * #serverWaitCapSecs()}, so a synchronous get/wait can never ask the server to hold the + * connection longer than the client's own per-request timeout. Returns {@code null} for a {@code + * null} input (no server-side wait requested). + */ + Long clampServerWait(Long waitForFinishSecs) { + if (waitForFinishSecs == null) { + return null; + } + return Math.min(Math.max(0, waitForFinishSecs), serverWaitCapSecs()); + } + + /** + * Polls a GET endpoint with {@code waitForFinish} until the resource reaches a terminal state or + * the wait budget elapses. {@code waitSecs == null} means "wait indefinitely", implemented as a + * finite but very large bound so the loop always terminates. + * + *

The budget is a pure time bound, evaluated independently of whether the resource is + * currently present: a just-started run/build can transiently return 404 (database-replica lag), + * which is treated as "not yet available". + */ + T waitForFinish( + Long waitSecs, String resourceName, JavaType dataType, Predicate isTerminal) { + // Clamp to MAX_WAIT_FOR_FINISH_SECS so a pathological waitSecs near Long.MAX_VALUE cannot + // overflow budgetMillis into a negative value (which would degrade the wait into a single + // poll). + long effectiveWaitSecs = + waitSecs != null + ? Math.min(Math.max(waitSecs, 0), MAX_WAIT_FOR_FINISH_SECS) + : MAX_WAIT_FOR_FINISH_SECS; + long budgetMillis = effectiveWaitSecs * 1000L; + long start = System.currentTimeMillis(); + + // Never ask the server to hold the connection longer than the client's own per-request timeout, + // or a short configured timeout would abort every poll (HttpTimeoutException) and exhaust the + // retry budget on an otherwise-healthy run. A value of 0 disables server-side waiting and falls + // back to pure client-side polling. + long serverWaitCap = serverWaitCapSecs(); + + T resource = null; + boolean present = false; + + while (true) { + long elapsed = System.currentTimeMillis() - start; + long remainingSecs = (budgetMillis - elapsed) / 1000L; + long requestSecs = + Math.min(Math.min(Math.max(remainingSecs, 0), WAIT_REQUEST_SECS), serverWaitCap); + + QueryParams params = new QueryParams(); + params.addLong("waitForFinish", requestSecs); + + Optional res = getResource("", params, dataType); + if (res.isPresent()) { + resource = res.get(); + present = true; + if (isTerminal.test(resource)) { + return resource; + } + } + + if (System.currentTimeMillis() - start >= budgetMillis) { + break; + } + sleep(WAIT_POLL_INTERVAL); + } + + if (present) { + return resource; + } + throw new IllegalStateException( + "waiting for " + + resourceName + + " to finish failed: cannot fetch " + + resourceName + + " details from the server"); + } + + private static void sleep(Duration d) { + try { + Thread.sleep(d.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new HttpClientCore.TransportException(e); + } + } + + // ---- URL / id helpers ----------------------------------------------------- + + /** Reports whether an exception represents a "resource not found" API error. */ + static boolean isNotFound(ApifyApiException e) { + if (e.getStatusCode() != NOT_FOUND) { + return false; + } + String type = e.getType(); + return "record-not-found".equals(type) + || "record-or-token-not-found".equals(type) + || "HEAD".equals(e.getHttpMethod()); + } + + /** + * Encodes a resource id so it is safe to embed in a URL path. Apify uses the {@code + * username~resourcename} form, so the first {@code /} of an id is replaced with {@code ~}. + */ + static String toSafeId(String id) { + int slash = id.indexOf('/'); + return slash < 0 ? id : id.substring(0, slash) + "~" + id.substring(slash + 1); + } + + /** + * Percent-encodes a single URL path segment, so that values interpolated into the path (record + * keys, request IDs) cannot break out of the segment. + */ + static String encodePathSegment(String input) { + return URLEncoder.encode(input, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /** Extracts the origin ({@code scheme://host[:port]}) from a URL, dropping any path. */ + static String originOf(String rawUrl) { + String rest = rawUrl; + String scheme = ""; + int i = rest.indexOf("://"); + if (i >= 0) { + scheme = rest.substring(0, i + 3); + rest = rest.substring(i + 3); + } + int slash = rest.indexOf('/'); + if (slash >= 0) { + rest = rest.substring(0, slash); + } + return scheme + rest; + } +} diff --git a/src/main/java/com/apify/client/RetryConfig.java b/src/main/java/com/apify/client/RetryConfig.java new file mode 100644 index 0000000..a3c263b --- /dev/null +++ b/src/main/java/com/apify/client/RetryConfig.java @@ -0,0 +1,29 @@ +package com.apify.client; + +import java.time.Duration; + +/** Retry/timeout policy for the orchestrating HTTP client. Internal to the client. */ +final class RetryConfig { + /** Maximum number of retries (the request is attempted up to {@code maxRetries + 1} times). */ + final int maxRetries; + + /** Minimum delay between retries; doubled on each subsequent retry (exponential backoff). */ + final Duration minDelayBetweenRetries; + + /** Upper bound on the (exponentially growing) inter-retry delay. */ + final Duration maxDelayBetweenRetries; + + /** Overall per-request timeout budget. Each attempt's timeout grows but is capped here. */ + final Duration timeout; + + RetryConfig( + int maxRetries, + Duration minDelayBetweenRetries, + Duration maxDelayBetweenRetries, + Duration timeout) { + this.maxRetries = maxRetries; + this.minDelayBetweenRetries = minDelayBetweenRetries; + this.maxDelayBetweenRetries = maxDelayBetweenRetries; + this.timeout = timeout; + } +} diff --git a/src/main/java/com/apify/client/RunChargeOptions.java b/src/main/java/com/apify/client/RunChargeOptions.java new file mode 100644 index 0000000..f22a198 --- /dev/null +++ b/src/main/java/com/apify/client/RunChargeOptions.java @@ -0,0 +1,46 @@ +package com.apify.client; + +/** Configures {@link RunClient#charge(RunChargeOptions)}. */ +public final class RunChargeOptions { + private String eventName; + private Long count; + private String idempotencyKey; + + /** Creates options for the given (required) event name. */ + public RunChargeOptions(String eventName) { + this.eventName = eventName; + } + + /** The name of the event to charge for. Required. */ + public RunChargeOptions eventName(String eventName) { + this.eventName = eventName; + return this; + } + + /** The number of times to charge the event (defaults to 1). */ + public RunChargeOptions count(Long count) { + this.count = count; + return this; + } + + /** + * A key that deduplicates the charge across retries. If unset, one is auto-generated as {@code + * "{runId}-{eventName}-{timestampMillis}-{random}"}, matching the reference client. + */ + public RunChargeOptions idempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + return this; + } + + String eventNameValue() { + return eventName; + } + + long countValue() { + return count != null ? count : 1; + } + + String idempotencyKeyValue() { + return idempotencyKey; + } +} diff --git a/src/main/java/com/apify/client/RunClient.java b/src/main/java/com/apify/client/RunClient.java new file mode 100644 index 0000000..914454f --- /dev/null +++ b/src/main/java/com/apify/client/RunClient.java @@ -0,0 +1,188 @@ +package com.apify.client; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +/** + * A client for a specific Actor run. + * + *

It provides CRUD methods plus convenience helpers (abort, metamorph, reboot, resurrect, + * charge, wait-for-finish) and accessors for the run's default storages and log. + */ +public final class RunClient { + + /** Header the API uses to deduplicate charge requests. */ + private static final String CHARGE_IDEMPOTENCY_HEADER = "idempotency-key"; + + private final ApifyClient root; + private final ResourceContext ctx; + private final String id; + + RunClient(ApifyClient root, HttpClientCore http, String baseUrl, String resourcePath, String id) { + this.root = root; + this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + this.id = id; + } + + /** + * Pins the {@code status} and/or {@code origin} query parameters inherited by all calls on this + * client (used by the last-run accessors). Empty values are skipped. + */ + void setLastRunParams(LastRunOptions options) { + if (options.statusValue() != null && !options.statusValue().isEmpty()) { + ctx.baseParams.addRaw("status", options.statusValue()); + } + if (options.originValue() != null && !options.originValue().isEmpty()) { + ctx.baseParams.addRaw("origin", options.originValue()); + } + } + + /** Fetches the run object, or empty if it does not exist. */ + public Optional get() { + return getWithWait(null); + } + + /** + * Fetches the run, optionally asking the API to wait up to {@code waitForFinishSecs} seconds (max + * 60) for the run to reach a terminal state before responding. Pass {@code null} for an immediate + * fetch. + */ + public Optional getWithWait(Long waitForFinishSecs) { + QueryParams params = new QueryParams(); + // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. + params.addLong("waitForFinish", ctx.clampServerWait(waitForFinishSecs)); + return ctx.getResource("", params, ActorRun.class); + } + + /** Updates the run with the given fields and returns the updated object. */ + public ActorRun update(Object newFields) { + return ctx.updateResource("", newFields, ActorRun.class); + } + + /** Deletes the run. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Aborts the run. If {@code gracefully} is {@code true}, the run is signalled so it can finish + * the current request before terminating; if {@code false} it is aborted immediately. Pass {@code + * null} to omit the parameter and let the server apply its default (immediate abort). + */ + public ActorRun abort(Boolean gracefully) { + QueryParams params = new QueryParams(); + params.addBool("gracefully", gracefully); + return ctx.postWithBody("abort", params, null, "", ActorRun.class); + } + + /** + * Transforms the run into a run of another Actor with a new input. {@code targetActorId} is the + * Actor to metamorph into; {@code input} is the new input ({@code null} for none). + */ + public ActorRun metamorph(String targetActorId, Object input, MetamorphOptions options) { + QueryParams params = new QueryParams(); + params.addString("targetActorId", targetActorId); + if (options.buildValue() != null && !options.buildValue().isEmpty()) { + params.addString("build", options.buildValue()); + } + byte[] body = input == null ? null : Json.toBytes(input); + return ctx.postWithBody( + "metamorph", params, body, options.contentTypeOrDefault(), ActorRun.class); + } + + /** Reboots the run (restarts its container while keeping the same run). */ + public ActorRun reboot() { + return ctx.postWithBody("reboot", new QueryParams(), null, "", ActorRun.class); + } + + /** Resurrects a finished run, starting it again from the beginning. */ + public ActorRun resurrect(RunResurrectOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.postWithBody("resurrect", params, null, "", ActorRun.class); + } + + /** + * Charges for a pay-per-event Actor run: records occurrences of a named event. Only meaningful + * for runs of pay-per-event Actors. + * + *

An idempotency key is always sent (auto-generated if not provided), so a charge that is + * retried by the transport is applied at most once, matching the reference client. + */ + public void charge(RunChargeOptions options) { + String eventName = options.eventNameValue(); + if (eventName == null || eventName.isEmpty()) { + throw new IllegalArgumentException( + "RunChargeOptions.eventName is required and must not be empty"); + } + String idempotencyKey = options.idempotencyKeyValue(); + if (idempotencyKey == null || idempotencyKey.isEmpty()) { + idempotencyKey = generateIdempotencyKey(eventName); + } + Map body = new LinkedHashMap<>(); + body.put("eventName", eventName); + body.put("count", options.countValue()); + Map headers = Map.of(CHARGE_IDEMPOTENCY_HEADER, idempotencyKey); + ctx.http.callWithHeaders( + "POST", + ctx.subUrl("charge"), + Json.toBytes(body), + ResourceContext.CONTENT_TYPE_JSON, + headers, + ResourceContext.DEFAULT_REQUEST_TIMEOUT); + } + + /** + * Builds a per-charge idempotency key of the form {@code + * "{runId}-{eventName}-{timestampMillis}-{random}"}. It need not be cryptographically secure, + * only unique enough to avoid collisions within a request. + */ + private String generateIdempotencyKey(String eventName) { + return id + + "-" + + eventName + + "-" + + System.currentTimeMillis() + + "-" + + ThreadLocalRandom.current().nextLong(1_000_000); + } + + /** + * Polls until the run reaches a terminal state or {@code waitSecs} elapses ({@code null} waits + * indefinitely). Returns the latest run. + */ + public ActorRun waitForFinish(Long waitSecs) { + return ctx.waitForFinish(waitSecs, "run", Json.type(ActorRun.class), ActorRun::isTerminal); + } + + /** A client for this run's default dataset. */ + public DatasetClient dataset() { + return DatasetClient.nested(ctx.http, ctx.subUrl(""), "dataset"); + } + + /** A client for this run's default key-value store. */ + public KeyValueStoreClient keyValueStore() { + return KeyValueStoreClient.nested(ctx.http, ctx.subUrl(""), "key-value-store"); + } + + /** A client for this run's default request queue. */ + public RequestQueueClient requestQueue() { + return RequestQueueClient.nested(ctx.http, ctx.subUrl(""), "request-queue"); + } + + /** A client for accessing this run's log. */ + public LogClient log() { + return LogClient.nested(ctx.http, ctx.subUrl("")); + } + + /** + * Opens a live stream of this run's raw log, for convenient log redirection. The caller must + * close the returned stream. + */ + public InputStream getStreamedLog() { + return log().stream(new LogOptions().raw(true)); + } +} diff --git a/src/main/java/com/apify/client/RunCollectionClient.java b/src/main/java/com/apify/client/RunCollectionClient.java new file mode 100644 index 0000000..1bcde19 --- /dev/null +++ b/src/main/java/com/apify/client/RunCollectionClient.java @@ -0,0 +1,29 @@ +package com.apify.client; + +/** + * A client for a run collection: the account-wide collection ({@code GET /v2/actor-runs}), an + * Actor's runs ({@code GET /v2/actors/{id}/runs}), or a task's runs ({@code GET + * /v2/actor-tasks/{id}/runs}). + */ +public final class RunCollectionClient { + private final ResourceContext ctx; + + RunCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); + } + + /** + * Lists runs, applying the standard pagination and the run-specific filters. Both {@code options} + * and {@code filter} may be {@code null}, which is treated as "no options"/"no filter". + */ + public PaginationList list(ListOptions options, RunListOptions filter) { + QueryParams params = new QueryParams(); + if (options != null) { + options.apply(params); + } + if (filter != null) { + filter.apply(params); + } + return ctx.listResource("", params, ActorRun.class); + } +} diff --git a/src/main/java/com/apify/client/RunListOptions.java b/src/main/java/com/apify/client/RunListOptions.java new file mode 100644 index 0000000..3f21d70 --- /dev/null +++ b/src/main/java/com/apify/client/RunListOptions.java @@ -0,0 +1,41 @@ +package com.apify.client; + +import java.util.List; + +/** + * Run-specific filters for {@link RunCollectionClient#list(ListOptions, RunListOptions)}. The + * {@code startedAfter}/{@code startedBefore} filters are only honoured by the Actor-scoped and + * task-scoped run collections. + */ +public final class RunListOptions { + private List status; + private String startedAfter; + private String startedBefore; + + /** + * Filter by one or more run statuses (e.g. {@code "SUCCEEDED"}, {@code "RUNNING"}). Sent as a + * comma-separated list, as the API accepts. + */ + public RunListOptions status(List status) { + this.status = status == null ? null : List.copyOf(status); + return this; + } + + /** Filter to runs started after this ISO-8601 timestamp. */ + public RunListOptions startedAfter(String startedAfter) { + this.startedAfter = startedAfter; + return this; + } + + /** Filter to runs started before this ISO-8601 timestamp. */ + public RunListOptions startedBefore(String startedBefore) { + this.startedBefore = startedBefore; + return this; + } + + void apply(QueryParams q) { + q.addCsv("status", status) + .addString("startedAfter", startedAfter) + .addString("startedBefore", startedBefore); + } +} diff --git a/src/main/java/com/apify/client/RunResurrectOptions.java b/src/main/java/com/apify/client/RunResurrectOptions.java new file mode 100644 index 0000000..a26555f --- /dev/null +++ b/src/main/java/com/apify/client/RunResurrectOptions.java @@ -0,0 +1,56 @@ +package com.apify.client; + +/** Configures {@link RunClient#resurrect(RunResurrectOptions)}. */ +public final class RunResurrectOptions { + private String build; + private Long memoryMbytes; + private Long timeoutSecs; + private Long maxItems; + private Double maxTotalChargeUsd; + private Boolean restartOnError; + + /** The tag or number of the build to resurrect with. */ + public RunResurrectOptions build(String build) { + this.build = build; + return this; + } + + /** Memory in megabytes to allocate. */ + public RunResurrectOptions memoryMbytes(Long memoryMbytes) { + this.memoryMbytes = memoryMbytes; + return this; + } + + /** The run timeout in seconds. */ + public RunResurrectOptions timeoutSecs(Long timeoutSecs) { + this.timeoutSecs = timeoutSecs; + return this; + } + + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public RunResurrectOptions maxItems(Long maxItems) { + this.maxItems = maxItems; + return this; + } + + /** Maximum total charge in USD (pay-per-event Actors). */ + public RunResurrectOptions maxTotalChargeUsd(Double maxTotalChargeUsd) { + this.maxTotalChargeUsd = maxTotalChargeUsd; + return this; + } + + /** If {@code true}, restart the run if it fails. */ + public RunResurrectOptions restartOnError(Boolean restartOnError) { + this.restartOnError = restartOnError; + return this; + } + + void apply(QueryParams q) { + q.addString("build", build) + .addLong("memory", memoryMbytes) + .addLong("timeout", timeoutSecs) + .addLong("maxItems", maxItems) + .addDouble("maxTotalChargeUsd", maxTotalChargeUsd) + .addBool("restartOnError", restartOnError); + } +} diff --git a/src/main/java/com/apify/client/Schedule.java b/src/main/java/com/apify/client/Schedule.java new file mode 100644 index 0000000..c65f5e3 --- /dev/null +++ b/src/main/java/com/apify/client/Schedule.java @@ -0,0 +1,35 @@ +package com.apify.client; + +/** A schedule automatically starts Actor or task runs at specified times. */ +public final class Schedule extends ApifyResource { + private String id; + private String userId; + private String name; + private String cronExpression; + private boolean isEnabled; + + /** The unique schedule ID. */ + public String getId() { + return id; + } + + /** The ID of the user who owns the schedule. */ + public String getUserId() { + return userId; + } + + /** The schedule name. */ + public String getName() { + return name; + } + + /** The cron expression governing when the schedule fires. */ + public String getCronExpression() { + return cronExpression; + } + + /** Whether the schedule is currently active. */ + public boolean isEnabled() { + return isEnabled; + } +} diff --git a/src/main/java/com/apify/client/ScheduleClient.java b/src/main/java/com/apify/client/ScheduleClient.java new file mode 100644 index 0000000..38984f9 --- /dev/null +++ b/src/main/java/com/apify/client/ScheduleClient.java @@ -0,0 +1,37 @@ +package com.apify.client; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** A client for a specific schedule ({@code /v2/schedules/{scheduleId}}). */ +public final class ScheduleClient { + private final ResourceContext ctx; + + ScheduleClient(HttpClientCore http, String baseUrl, String id) { + this.ctx = ResourceContext.single(http, baseUrl, "schedules", id); + } + + /** Fetches the schedule, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), Schedule.class); + } + + /** Updates the schedule with the given fields and returns the updated object. */ + public Schedule update(Object newFields) { + return ctx.updateResource("", newFields, Schedule.class); + } + + /** Deletes the schedule. */ + public void delete() { + ctx.deleteResource(""); + } + + /** Fetches the schedule's invocation log as text, or empty if absent. */ + public Optional getLog() { + ApiResponse resp = ctx.getRaw("log", new QueryParams()); + if (resp == null) { + return Optional.empty(); + } + return Optional.of(new String(resp.body, StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/apify/client/ScheduleCollectionClient.java b/src/main/java/com/apify/client/ScheduleCollectionClient.java new file mode 100644 index 0000000..b82d569 --- /dev/null +++ b/src/main/java/com/apify/client/ScheduleCollectionClient.java @@ -0,0 +1,22 @@ +package com.apify.client; + +/** A client for the schedule collection ({@code GET/POST /v2/schedules}). */ +public final class ScheduleCollectionClient { + private final ResourceContext ctx; + + ScheduleCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "schedules"); + } + + /** Lists the account's schedules. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Schedule.class); + } + + /** Creates a new schedule. {@code schedule} is any JSON-serializable schedule definition. */ + public Schedule create(Object schedule) { + return ctx.createResource(new QueryParams(), schedule, Schedule.class); + } +} diff --git a/src/main/java/com/apify/client/SetRecordOptions.java b/src/main/java/com/apify/client/SetRecordOptions.java new file mode 100644 index 0000000..3820d43 --- /dev/null +++ b/src/main/java/com/apify/client/SetRecordOptions.java @@ -0,0 +1,35 @@ +package com.apify.client; + +/** + * Write options for {@link KeyValueStoreClient#setRecord(String, byte[], String, + * SetRecordOptions)}, mirroring the reference client's {@code timeoutSecs}/{@code + * doNotRetryTimeouts}. + */ +public final class SetRecordOptions { + private Long timeoutSecs; + private Boolean doNotRetryTimeouts; + + /** + * Per-request timeout for the upload, in seconds. Use it to shorten the wait for this upload; + * defaults to (and is capped at) the client's configured overall request timeout, so a value + * larger than that timeout has no effect. + */ + public SetRecordOptions timeoutSecs(Long timeoutSecs) { + this.timeoutSecs = timeoutSecs; + return this; + } + + /** If {@code true}, do not retry the upload when it fails with a request timeout. */ + public SetRecordOptions doNotRetryTimeouts(Boolean doNotRetryTimeouts) { + this.doNotRetryTimeouts = doNotRetryTimeouts; + return this; + } + + Long timeoutSecsValue() { + return timeoutSecs; + } + + boolean doNotRetryTimeoutsValue() { + return Boolean.TRUE.equals(doNotRetryTimeouts); + } +} diff --git a/src/main/java/com/apify/client/Signatures.java b/src/main/java/com/apify/client/Signatures.java new file mode 100644 index 0000000..1c45766 --- /dev/null +++ b/src/main/java/com/apify/client/Signatures.java @@ -0,0 +1,93 @@ +package com.apify.client; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Apify storage-content URL signing, byte-for-byte compatible with the platform's + * {@code @apify/utilities} implementation that the reference clients rely on. Internal to the + * client. + */ +final class Signatures { + + /** Version tag embedded in storage-content signatures (upstream default). */ + private static final String STORAGE_CONTENT_SIGNATURE_VERSION = "0"; + + /** Number of leading hex characters of the HMAC digest used. */ + private static final int HMAC_SIGNATURE_HEX_LEN = 30; + + /** Base62 alphabet (digits, then lowercase, then uppercase), matching upstream. */ + private static final String BASE62_ALPHABET = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private static final BigInteger BASE62 = BigInteger.valueOf(BASE62_ALPHABET.length()); + + private Signatures() {} + + /** + * Computes an Apify URL-signing signature, byte-for-byte compatible with upstream {@code + * createHmacSignature}: HMAC-SHA256(secret, message) as lowercase hex, take the first 30 hex + * characters, interpret them as a big integer, then base62-encode (alphabet {@code 0-9a-zA-Z}). + */ + static String createHmacSignature(String secretKey, String message) { + byte[] digest = hmacSha256(secretKey, message); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + String truncated = hex.substring(0, HMAC_SIGNATURE_HEX_LEN); + return toBase62(new BigInteger(truncated, 16)); + } + + /** Encodes a non-negative big integer in base62 using the {@code 0-9a-zA-Z} alphabet. */ + static String toBase62(BigInteger value) { + if (value.signum() == 0) { + return "0"; + } + StringBuilder digits = new StringBuilder(); + BigInteger v = value; + while (v.signum() > 0) { + BigInteger[] divRem = v.divideAndRemainder(BASE62); + digits.append(BASE62_ALPHABET.charAt(divRem[1].intValue())); + v = divRem[0]; + } + return digits.reverse().toString(); + } + + /** + * Builds a storage-content signature for a resource's public URL, byte-for-byte compatible with + * upstream {@code createStorageContentSignature}. + * + *

It signs the message {@code "{version}.{expiresAtMillis}.{resourceId}"} ({@code + * expiresAtMillis} is the absolute expiry in ms, or {@code 0} for a non-expiring URL) with {@link + * #createHmacSignature}, then returns the base64url (no padding) encoding of {@code + * "{version}.{expiresAtMillis}.{hmac}"}. + * + * @param expiresInSecs optional expiry in seconds ({@code null} for a non-expiring URL) + */ + static String signStorageContent(String secretKey, String resourceId, Long expiresInSecs) { + long expiresAtMillis = + expiresInSecs != null ? System.currentTimeMillis() + expiresInSecs * 1000L : 0L; + String version = STORAGE_CONTENT_SIGNATURE_VERSION; + String message = version + "." + expiresAtMillis + "." + resourceId; + String hmac = createHmacSignature(secretKey, message); + String envelope = version + "." + expiresAtMillis + "." + hmac; + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(envelope.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] hmacSha256(String secretKey, String message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + } catch (java.security.GeneralSecurityException e) { + throw new IllegalStateException("HMAC-SHA256 is unavailable", e); + } + } +} diff --git a/src/main/java/com/apify/client/Statuses.java b/src/main/java/com/apify/client/Statuses.java new file mode 100644 index 0000000..71f74f3 --- /dev/null +++ b/src/main/java/com/apify/client/Statuses.java @@ -0,0 +1,17 @@ +package com.apify.client; + +import java.util.Set; + +/** Run/build status helpers. Internal to the client. */ +final class Statuses { + + /** Terminal run/build statuses: a resource in any of these is finished and will not change. */ + private static final Set TERMINAL = Set.of("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"); + + private Statuses() {} + + /** Reports whether the status is a terminal (finished) run/build status. */ + static boolean isTerminal(String status) { + return status != null && TERMINAL.contains(status); + } +} diff --git a/src/main/java/com/apify/client/StorageListOptions.java b/src/main/java/com/apify/client/StorageListOptions.java new file mode 100644 index 0000000..8993a81 --- /dev/null +++ b/src/main/java/com/apify/client/StorageListOptions.java @@ -0,0 +1,52 @@ +package com.apify.client; + +/** + * Options for the storage collection list endpoints ({@code GET /v2/datasets}, {@code + * /v2/key-value-stores}, {@code /v2/request-queues}), which add {@code unnamed} and {@code + * ownership} filters on top of the standard pagination. + */ +public final class StorageListOptions { + private Long offset; + private Long limit; + private Boolean desc; + private Boolean unnamed; + private String ownership; + + /** Number of items to skip from the beginning of the list. */ + public StorageListOptions offset(Long offset) { + this.offset = offset; + return this; + } + + /** Maximum number of items to return. */ + public StorageListOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** If {@code true}, return items newest-first. */ + public StorageListOptions desc(Boolean desc) { + this.desc = desc; + return this; + } + + /** If {@code true}, include unnamed storages in the result. */ + public StorageListOptions unnamed(Boolean unnamed) { + this.unnamed = unnamed; + return this; + } + + /** Filter by ownership (e.g. {@code "OWNED"} / {@code "ACCESSIBLE"}). */ + public StorageListOptions ownership(String ownership) { + this.ownership = ownership; + return this; + } + + void apply(QueryParams q) { + q.addLong("offset", offset) + .addLong("limit", limit) + .addBool("desc", desc) + .addBool("unnamed", unnamed) + .addString("ownership", ownership); + } +} diff --git a/src/main/java/com/apify/client/StoreCollectionClient.java b/src/main/java/com/apify/client/StoreCollectionClient.java new file mode 100644 index 0000000..78b58e3 --- /dev/null +++ b/src/main/java/com/apify/client/StoreCollectionClient.java @@ -0,0 +1,78 @@ +package com.apify.client; + +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** A client for browsing the Apify Store ({@code GET /v2/store}). */ +public final class StoreCollectionClient { + private final ResourceContext ctx; + + StoreCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "store"); + } + + /** Returns a single page of Store Actors matching the options. */ + public PaginationList list(StoreListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, ActorStoreListItem.class); + } + + /** + * Returns a lazy iterator over all Store Actors matching the options, fetching pages on demand. + * The options' {@code limit} (if set) is used as the per-page size. + */ + public Iterator iterate(StoreListOptions options) { + return new StoreIterator(options); + } + + /** Lazily iterates over Apify Store Actors, fetching one page at a time. */ + private final class StoreIterator implements Iterator { + private final StoreListOptions options; + private List buffer = List.of(); + private int pos; + private long offset; + private long total; + private boolean exhausted; + + StoreIterator(StoreListOptions options) { + // Copy so paging state stays internal: the caller's instance is never mutated (safe to reuse + // or iterate twice), and its initial offset is honored as the starting page. + this.options = options.copy(); + Long initialOffset = this.options.offsetValue(); + this.offset = initialOffset != null ? initialOffset : 0; + } + + @Override + public boolean hasNext() { + while (pos >= buffer.size()) { + if (exhausted) { + return false; + } + fetchPage(); + } + return true; + } + + @Override + public ActorStoreListItem next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return buffer.get(pos++); + } + + private void fetchPage() { + options.offsetInternal(offset); + PaginationList page = list(options); + buffer = page.getItems(); + pos = 0; + total = page.getTotal(); + offset += page.getItems().size(); + if (page.getItems().isEmpty() || offset >= total) { + exhausted = true; + } + } + } +} diff --git a/src/main/java/com/apify/client/StoreListOptions.java b/src/main/java/com/apify/client/StoreListOptions.java new file mode 100644 index 0000000..00b76a7 --- /dev/null +++ b/src/main/java/com/apify/client/StoreListOptions.java @@ -0,0 +1,120 @@ +package com.apify.client; + +/** Options for listing/iterating the Apify Store ({@code GET /v2/store}). */ +public final class StoreListOptions { + private Long offset; + private Long limit; + private String search; + private String sortBy; + private String category; + private String username; + private String pricingModel; + private Boolean includeUnrunnableActors; + private Boolean allowsAgenticUsers; + private String responseFormat; + + /** Number of Actors to skip. */ + public StoreListOptions offset(Long offset) { + this.offset = offset; + return this; + } + + /** Maximum number of Actors to return. */ + public StoreListOptions limit(Long limit) { + this.limit = limit; + return this; + } + + /** Full-text search query. */ + public StoreListOptions search(String search) { + this.search = search; + return this; + } + + /** The sort field (e.g. {@code "popularity"}, {@code "newest"}). */ + public StoreListOptions sortBy(String sortBy) { + this.sortBy = sortBy; + return this; + } + + /** Filter Actors by category. */ + public StoreListOptions category(String category) { + this.category = category; + return this; + } + + /** Filter Actors by owner username. */ + public StoreListOptions username(String username) { + this.username = username; + return this; + } + + /** + * Filter Actors by pricing model ({@code FREE}, {@code FLAT_PRICE_PER_MONTH}, {@code + * PRICE_PER_DATASET_ITEM}, {@code PAY_PER_EVENT}). + */ + public StoreListOptions pricingModel(String pricingModel) { + this.pricingModel = pricingModel; + return this; + } + + /** Include Actors the current user cannot run. */ + public StoreListOptions includeUnrunnableActors(Boolean includeUnrunnableActors) { + this.includeUnrunnableActors = includeUnrunnableActors; + return this; + } + + /** Filter to Actors that allow agentic users. */ + public StoreListOptions allowsAgenticUsers(Boolean allowsAgenticUsers) { + this.allowsAgenticUsers = allowsAgenticUsers; + return this; + } + + /** The response format ({@code full}, {@code agent}). */ + public StoreListOptions responseFormat(String responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + Long limitValue() { + return limit; + } + + Long offsetValue() { + return offset; + } + + StoreListOptions offsetInternal(long offset) { + this.offset = offset; + return this; + } + + /** Returns an independent copy, so iteration paging cannot mutate a caller-owned instance. */ + StoreListOptions copy() { + StoreListOptions c = new StoreListOptions(); + c.offset = offset; + c.limit = limit; + c.search = search; + c.sortBy = sortBy; + c.category = category; + c.username = username; + c.pricingModel = pricingModel; + c.includeUnrunnableActors = includeUnrunnableActors; + c.allowsAgenticUsers = allowsAgenticUsers; + c.responseFormat = responseFormat; + return c; + } + + void apply(QueryParams q) { + q.addLong("offset", offset) + .addLong("limit", limit) + .addString("search", search) + .addString("sortBy", sortBy) + .addString("category", category) + .addString("username", username) + .addString("pricingModel", pricingModel) + .addBool("includeUnrunnableActors", includeUnrunnableActors) + .addBool("allowsAgenticUsers", allowsAgenticUsers) + .addString("responseFormat", responseFormat); + } +} diff --git a/src/main/java/com/apify/client/Task.java b/src/main/java/com/apify/client/Task.java new file mode 100644 index 0000000..a8618ee --- /dev/null +++ b/src/main/java/com/apify/client/Task.java @@ -0,0 +1,49 @@ +package com.apify.client; + +import java.time.Instant; + +/** A pre-configured Actor run (an Actor task). */ +public final class Task extends ApifyResource { + private String id; + private String actId; + private String userId; + private String name; + private String title; + private Instant createdAt; + private Instant modifiedAt; + + /** The unique task ID. */ + public String getId() { + return id; + } + + /** The ID of the Actor this task runs. */ + public String getActId() { + return actId; + } + + /** The ID of the user who owns the task. */ + public String getUserId() { + return userId; + } + + /** The technical name of the task. */ + public String getName() { + return name; + } + + /** The human-readable title shown in the UI. */ + public String getTitle() { + return title; + } + + /** When the task was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the task was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } +} diff --git a/src/main/java/com/apify/client/TaskClient.java b/src/main/java/com/apify/client/TaskClient.java new file mode 100644 index 0000000..d1350f2 --- /dev/null +++ b/src/main/java/com/apify/client/TaskClient.java @@ -0,0 +1,109 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Optional; + +/** + * A client for a specific Actor task. + * + *

Tasks are pre-configured Actor runs with stored input. The client provides CRUD methods plus + * convenience helpers to start/call the task and access its input, runs and webhooks. + */ +public final class TaskClient { + private final ApifyClient root; + private final HttpClientCore http; + private final ResourceContext ctx; + + TaskClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { + this.root = root; + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, "actor-tasks", id); + } + + /** Fetches the task object, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), Task.class); + } + + /** Updates the task with the given fields and returns the updated object. */ + public Task update(Object newFields) { + return ctx.updateResource("", newFields, Task.class); + } + + /** Deletes the task. */ + public void delete() { + ctx.deleteResource(""); + } + + /** + * Starts the task and returns immediately with the created run. {@code input} optionally + * overrides the task's stored input ({@code null} to use the stored input). + */ + public ActorRun start(Object input, TaskStartOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + byte[] body = input == null ? null : Json.toBytes(input); + return ctx.postWithBody( + "runs", params, body, ResourceContext.CONTENT_TYPE_JSON, ActorRun.class); + } + + /** + * Starts the task and waits (client-side polling) for it to finish. {@code waitSecs} bounds the + * wait; {@code null} waits indefinitely. + */ + public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { + ActorRun run = start(input, options); + return root.run(run.getId()).waitForFinish(waitSecs); + } + + /** Fetches the task's stored input, or empty if none is set. */ + public Optional getInput() { + ApiResponse resp = ctx.getRaw("input", new QueryParams()); + if (resp == null) { + return Optional.empty(); + } + return Optional.of(Json.parse(resp.body, JsonNode.class)); + } + + /** Replaces the task's stored input and returns the updated input. */ + public JsonNode updateInput(Object input) { + ApiResponse resp = + http.call( + "PUT", + ctx.subUrl("input"), + Json.toBytes(input), + ResourceContext.CONTENT_TYPE_JSON, + ResourceContext.DEFAULT_REQUEST_TIMEOUT); + return Json.parse(resp.body, JsonNode.class); + } + + /** + * Returns a client for the last run of this task, optionally filtered by status (e.g. {@code + * "SUCCEEDED"}). Pass {@code null} or empty for no filter. + */ + public RunClient lastRun(String status) { + return lastRun(new LastRunOptions().status(status)); + } + + /** + * Returns a client for the last run of this task, optionally filtered by status and/or origin. + */ + public RunClient lastRun(LastRunOptions options) { + RunClient client = new RunClient(root, http, ctx.subUrl(""), "runs", "last"); + client.setLastRunParams(options); + return client; + } + + /** A client for this task's run collection. */ + public RunCollectionClient runs() { + return new RunCollectionClient(http, ctx.subUrl(""), "runs"); + } + + /** + * A read-only client for this task's webhook collection ({@code GET + * /v2/actor-tasks/{id}/webhooks}). + */ + public NestedWebhookCollectionClient webhooks() { + return new NestedWebhookCollectionClient(http, ctx.subUrl("")); + } +} diff --git a/src/main/java/com/apify/client/TaskCollectionClient.java b/src/main/java/com/apify/client/TaskCollectionClient.java new file mode 100644 index 0000000..9ca5a0c --- /dev/null +++ b/src/main/java/com/apify/client/TaskCollectionClient.java @@ -0,0 +1,22 @@ +package com.apify.client; + +/** A client for the Actor task collection ({@code GET/POST /v2/actor-tasks}). */ +public final class TaskCollectionClient { + private final ResourceContext ctx; + + TaskCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, "actor-tasks"); + } + + /** Lists the account's tasks. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, Task.class); + } + + /** Creates a new task. {@code task} is any JSON-serializable task definition. */ + public Task create(Object task) { + return ctx.createResource(new QueryParams(), task, Task.class); + } +} diff --git a/src/main/java/com/apify/client/TaskStartOptions.java b/src/main/java/com/apify/client/TaskStartOptions.java new file mode 100644 index 0000000..acc12d2 --- /dev/null +++ b/src/main/java/com/apify/client/TaskStartOptions.java @@ -0,0 +1,80 @@ +package com.apify.client; + +import java.util.List; + +/** + * Configures starting a task run ({@link TaskClient#start}/{@link TaskClient#call}). + * + *

It mirrors {@link ActorStartOptions} but omits the fields the task run endpoint does not + * accept (the Actor-only {@code contentType} and {@code forcePermissionLevel}), matching the + * reference client. + */ +public final class TaskStartOptions { + private String build; + private Long memoryMbytes; + private Long timeoutSecs; + private Long waitForFinish; + private Long maxItems; + private Double maxTotalChargeUsd; + private Boolean restartOnError; + private List webhooks; + + /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ + public TaskStartOptions build(String build) { + this.build = build; + return this; + } + + /** Memory in megabytes allocated for the run. */ + public TaskStartOptions memoryMbytes(Long memoryMbytes) { + this.memoryMbytes = memoryMbytes; + return this; + } + + /** Timeout for the run in seconds (0 means no timeout). */ + public TaskStartOptions timeoutSecs(Long timeoutSecs) { + this.timeoutSecs = timeoutSecs; + return this; + } + + /** Maximum seconds to wait server-side for the run to finish (max 60). */ + public TaskStartOptions waitForFinish(Long waitForFinish) { + this.waitForFinish = waitForFinish; + return this; + } + + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public TaskStartOptions maxItems(Long maxItems) { + this.maxItems = maxItems; + return this; + } + + /** Maximum total charge in USD (pay-per-event Actors). */ + public TaskStartOptions maxTotalChargeUsd(Double maxTotalChargeUsd) { + this.maxTotalChargeUsd = maxTotalChargeUsd; + return this; + } + + /** If {@code true}, restart the run if it fails. */ + public TaskStartOptions restartOnError(Boolean restartOnError) { + this.restartOnError = restartOnError; + return this; + } + + /** Ad-hoc webhooks to attach to this run (serialized to base64-encoded JSON). */ + public TaskStartOptions webhooks(List webhooks) { + this.webhooks = webhooks == null ? null : List.copyOf(webhooks); + return this; + } + + void apply(QueryParams q) { + q.addString("build", build) + .addLong("memory", memoryMbytes) + .addLong("timeout", timeoutSecs) + .addLong("waitForFinish", waitForFinish) + .addLong("maxItems", maxItems) + .addDouble("maxTotalChargeUsd", maxTotalChargeUsd) + .addBool("restartOnError", restartOnError); + q.addString("webhooks", ActorStartOptions.encodeWebhooks(webhooks)); + } +} diff --git a/src/main/java/com/apify/client/User.java b/src/main/java/com/apify/client/User.java new file mode 100644 index 0000000..d5eb8ca --- /dev/null +++ b/src/main/java/com/apify/client/User.java @@ -0,0 +1,20 @@ +package com.apify.client; + +/** + * An Apify user account. Private account details for {@code me} are available via {@link + * #getExtra()}. + */ +public final class User extends ApifyResource { + private String id; + private String username; + + /** The unique user ID. */ + public String getId() { + return id; + } + + /** The user's username. */ + public String getUsername() { + return username; + } +} diff --git a/src/main/java/com/apify/client/UserClient.java b/src/main/java/com/apify/client/UserClient.java new file mode 100644 index 0000000..e25082e --- /dev/null +++ b/src/main/java/com/apify/client/UserClient.java @@ -0,0 +1,79 @@ +package com.apify.client; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Optional; + +/** + * A client for accessing user data ({@code /v2/users/{userId}} or {@code /v2/users/me}). + * + *

For the current user ({@code me}), it also exposes account usage and limits. Those endpoints + * only exist for {@code me} and throw {@link IllegalStateException} if called on another user's + * client. + */ +public final class UserClient { + private static final String ME = "me"; + + private final HttpClientCore http; + private final ResourceContext ctx; + private final boolean isMe; + + UserClient(HttpClientCore http, String baseUrl, String id) { + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, "users", id); + this.isMe = ME.equals(id); + } + + /** + * Fetches the user. For {@code me} it returns private account details (via {@link + * User#getExtra()}); for other users it returns the public profile. Returns empty if the user + * does not exist. + */ + public Optional get() { + return ctx.getResource("", new QueryParams(), User.class); + } + + /** + * Fetches the current account's monthly usage for the current month. Only available for {@code + * me}. Returns the raw JSON usage report. + */ + public JsonNode monthlyUsage() { + return monthlyUsage(""); + } + + /** + * Fetches the current account's monthly usage for the month containing the given date (formatted + * as {@code YYYY-MM-DD}). An empty date reports the current month. Only available for {@code me}. + */ + public JsonNode monthlyUsage(String date) { + requireMe(); + QueryParams params = new QueryParams(); + if (date != null && !date.isEmpty()) { + params.addString("date", date); + } + return ctx.getResourceRequired("usage/monthly", params, JsonNode.class); + } + + /** Fetches the current account's resource limits. Only available for {@code me}. */ + public JsonNode limits() { + requireMe(); + return ctx.getResourceRequired("limits", new QueryParams(), JsonNode.class); + } + + /** Updates the current account's resource limits. Only available for {@code me}. */ + public void updateLimits(Object newLimits) { + requireMe(); + http.call( + "PUT", + ctx.subUrl("limits"), + Json.toBytes(newLimits), + ResourceContext.CONTENT_TYPE_JSON, + ResourceContext.DEFAULT_REQUEST_TIMEOUT); + } + + private void requireMe() { + if (!isMe) { + throw new IllegalStateException( + "this operation is only available for the current user (use me())"); + } + } +} diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java new file mode 100644 index 0000000..76305bf --- /dev/null +++ b/src/main/java/com/apify/client/Version.java @@ -0,0 +1,25 @@ +package com.apify.client; + +/** + * Public version constants for the Apify Java client. + * + *

{@link #CLIENT_VERSION} is the semantic version of this library and {@link #API_SPEC_VERSION} + * is the {@code info.version} of the Apify OpenAPI specification this client was generated and + * verified against. + */ +public final class Version { + + /** + * The semantic version of this client library (see SemVer). + * Changes to the public interface other than additive ones are considered breaking changes. + */ + public static final String CLIENT_VERSION = "0.1.0"; + + /** + * The version of the Apify OpenAPI specification this client was generated and verified against. + * Corresponds to the {@code info.version} field of the Apify OpenAPI document. + */ + public static final String API_SPEC_VERSION = "v2-2026-07-01T115402Z"; + + private Version() {} +} diff --git a/src/main/java/com/apify/client/Webhook.java b/src/main/java/com/apify/client/Webhook.java new file mode 100644 index 0000000..0222c3e --- /dev/null +++ b/src/main/java/com/apify/client/Webhook.java @@ -0,0 +1,32 @@ +package com.apify.client; + +import java.util.Collections; +import java.util.List; + +/** A webhook notifies an external service when specific events occur. */ +public final class Webhook extends ApifyResource { + private String id; + private String userId; + private String requestUrl; + private List eventTypes = List.of(); + + /** The unique webhook ID. */ + public String getId() { + return id; + } + + /** The ID of the user who owns the webhook. */ + public String getUserId() { + return userId; + } + + /** The URL the webhook posts to. */ + public String getRequestUrl() { + return requestUrl; + } + + /** The events that trigger the webhook. */ + public List getEventTypes() { + return Collections.unmodifiableList(eventTypes); + } +} diff --git a/src/main/java/com/apify/client/WebhookClient.java b/src/main/java/com/apify/client/WebhookClient.java new file mode 100644 index 0000000..1d5a37c --- /dev/null +++ b/src/main/java/com/apify/client/WebhookClient.java @@ -0,0 +1,39 @@ +package com.apify.client; + +import java.util.Optional; + +/** A client for a specific webhook ({@code /v2/webhooks/{webhookId}}). */ +public final class WebhookClient { + private final HttpClientCore http; + private final ResourceContext ctx; + + WebhookClient(HttpClientCore http, String baseUrl, String id) { + this.http = http; + this.ctx = ResourceContext.single(http, baseUrl, "webhooks", id); + } + + /** Fetches the webhook, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), Webhook.class); + } + + /** Updates the webhook with the given fields and returns the updated object. */ + public Webhook update(Object newFields) { + return ctx.updateResource("", newFields, Webhook.class); + } + + /** Deletes the webhook. */ + public void delete() { + ctx.deleteResource(""); + } + + /** Dispatches the webhook immediately and returns the resulting dispatch. */ + public WebhookDispatch test() { + return ctx.postWithBody("test", new QueryParams(), null, "", WebhookDispatch.class); + } + + /** A client for this webhook's dispatch collection. */ + public WebhookDispatchCollectionClient dispatches() { + return new WebhookDispatchCollectionClient(http, ctx.subUrl(""), "dispatches"); + } +} diff --git a/src/main/java/com/apify/client/WebhookCollectionClient.java b/src/main/java/com/apify/client/WebhookCollectionClient.java new file mode 100644 index 0000000..c5669e9 --- /dev/null +++ b/src/main/java/com/apify/client/WebhookCollectionClient.java @@ -0,0 +1,17 @@ +package com.apify.client; + +/** + * A client for the account-wide webhook collection ({@code GET/POST /v2/webhooks}), supporting both + * listing and creation. Webhooks nested under an Actor or task are read-only and use {@link + * NestedWebhookCollectionClient} instead. + */ +public final class WebhookCollectionClient extends AbstractWebhookCollectionClient { + WebhookCollectionClient(HttpClientCore http, String baseUrl) { + super(http, baseUrl); + } + + /** Creates a new webhook. {@code webhook} is any JSON-serializable webhook definition. */ + public Webhook create(Object webhook) { + return ctx.createResource(new QueryParams(), webhook, Webhook.class); + } +} diff --git a/src/main/java/com/apify/client/WebhookDispatch.java b/src/main/java/com/apify/client/WebhookDispatch.java new file mode 100644 index 0000000..76ed263 --- /dev/null +++ b/src/main/java/com/apify/client/WebhookDispatch.java @@ -0,0 +1,17 @@ +package com.apify.client; + +/** A single invocation of a webhook. */ +public final class WebhookDispatch extends ApifyResource { + private String id; + private String webhookId; + + /** The unique dispatch ID. */ + public String getId() { + return id; + } + + /** The ID of the webhook that produced this dispatch. */ + public String getWebhookId() { + return webhookId; + } +} diff --git a/src/main/java/com/apify/client/WebhookDispatchClient.java b/src/main/java/com/apify/client/WebhookDispatchClient.java new file mode 100644 index 0000000..7234a21 --- /dev/null +++ b/src/main/java/com/apify/client/WebhookDispatchClient.java @@ -0,0 +1,17 @@ +package com.apify.client; + +import java.util.Optional; + +/** A client for a specific webhook dispatch ({@code /v2/webhook-dispatches/{dispatchId}}). */ +public final class WebhookDispatchClient { + private final ResourceContext ctx; + + WebhookDispatchClient(HttpClientCore http, String baseUrl, String id) { + this.ctx = ResourceContext.single(http, baseUrl, "webhook-dispatches", id); + } + + /** Fetches the dispatch, or empty if it does not exist. */ + public Optional get() { + return ctx.getResource("", new QueryParams(), WebhookDispatch.class); + } +} diff --git a/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java b/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java new file mode 100644 index 0000000..65063fb --- /dev/null +++ b/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java @@ -0,0 +1,20 @@ +package com.apify.client; + +/** + * A client for a webhook dispatch collection: the account-wide collection ({@code GET + * /v2/webhook-dispatches}) or dispatches nested under a webhook. + */ +public final class WebhookDispatchCollectionClient { + private final ResourceContext ctx; + + WebhookDispatchCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); + } + + /** Lists webhook dispatches. */ + public PaginationList list(ListOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.listResource("", params, WebhookDispatch.class); + } +} diff --git a/src/test/java/com/apify/client/ClientMetaTest.java b/src/test/java/com/apify/client/ClientMetaTest.java new file mode 100644 index 0000000..b1159a4 --- /dev/null +++ b/src/test/java/com/apify/client/ClientMetaTest.java @@ -0,0 +1,53 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Offline tests for version constants, User-Agent formatting, and base-URL resolution. */ +class ClientMetaTest { + + @Test + void versionConstants() { + assertTrue(Character.isDigit(Version.CLIENT_VERSION.charAt(0)), Version.CLIENT_VERSION); + assertTrue(Version.API_SPEC_VERSION.startsWith("v2-"), Version.API_SPEC_VERSION); + assertTrue(Version.API_SPEC_VERSION.endsWith("Z"), Version.API_SPEC_VERSION); + } + + @Test + void userAgentFormat() { + String ua = ApifyClient.create("token").getUserAgent(); + assertTrue(ua.startsWith("ApifyClient/" + Version.CLIENT_VERSION + " ("), ua); + assertTrue(ua.contains("Java/"), ua); + assertTrue(ua.contains("isAtHome/"), ua); + String after = ua.split("Java/", 2)[1]; + assertTrue(Character.isDigit(after.charAt(0)), "Java version must be a real version: " + ua); + } + + @Test + void userAgentIsAtHomeFlag() { + ApifyClient off = ApifyClient.builder().token("t").isAtHomeFn(() -> false).build(); + assertTrue(off.getUserAgent().contains("isAtHome/false"), off.getUserAgent()); + ApifyClient on = ApifyClient.builder().token("t").isAtHomeFn(() -> true).build(); + assertTrue(on.getUserAgent().contains("isAtHome/true"), on.getUserAgent()); + } + + @Test + void userAgentSuffix() { + ApifyClient client = ApifyClient.builder().token("t").userAgentSuffix("MyTool/1.0").build(); + assertTrue(client.getUserAgent().endsWith("; MyTool/1.0"), client.getUserAgent()); + } + + @Test + void baseUrlDefaultAndV2Suffix() { + assertEquals("https://api.apify.com/v2", ApifyClient.create("t").getApiBaseUrl()); + } + + @Test + void baseUrlOverrideAppendsV2() { + ApifyClient client = + ApifyClient.builder().token("t").baseUrl("https://api.example.com/").build(); + assertEquals("https://api.example.com/v2", client.getApiBaseUrl()); + } +} diff --git a/src/test/java/com/apify/client/ConfigTest.java b/src/test/java/com/apify/client/ConfigTest.java new file mode 100644 index 0000000..56a1330 --- /dev/null +++ b/src/test/java/com/apify/client/ConfigTest.java @@ -0,0 +1,47 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Offline tests for the {@code APIFY_API_URL} → base-URL resolution used by the integration suite + * (the client appends {@code /v2}, so the env-var suffix must be stripped). + */ +class ConfigTest { + + /** Mirror of the integration base's resolution logic (kept in sync with IntegrationBase). */ + private static String resolveBaseUrl(String apiUrl) { + if (apiUrl == null || apiUrl.isEmpty()) { + apiUrl = "https://api.apify.com/v2"; + } + String trimmed = apiUrl; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + if (trimmed.endsWith("/v2")) { + trimmed = trimmed.substring(0, trimmed.length() - "/v2".length()); + } + return trimmed; + } + + @Test + void resolveBaseUrlDefault() { + assertEquals("https://api.apify.com", resolveBaseUrl("")); + assertEquals("https://api.apify.com", resolveBaseUrl(null)); + } + + @Test + void resolveBaseUrlStripsV2() { + assertEquals("https://api.example.com", resolveBaseUrl("https://api.example.com/v2")); + assertEquals("https://api.example.com", resolveBaseUrl("https://api.example.com/v2/")); + } + + @Test + void resolvedBaseUrlFeedsClientBaseUrl() { + // The client re-appends /v2, reproducing the original APIFY_API_URL value. + String base = resolveBaseUrl("https://api.example.com/v2"); + ApifyClient client = ApifyClient.builder().token("t").baseUrl(base).build(); + assertEquals("https://api.example.com/v2", client.getApiBaseUrl()); + } +} diff --git a/src/test/java/com/apify/client/DocSnippetsTest.java b/src/test/java/com/apify/client/DocSnippetsTest.java new file mode 100644 index 0000000..2db55d5 --- /dev/null +++ b/src/test/java/com/apify/client/DocSnippetsTest.java @@ -0,0 +1,150 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; + +/** + * Compiles every {@code ```java} fenced code block in the README and {@code docs/} to verify that + * each in-documentation snippet is valid, runnable code (offline). Statement-level snippets are + * wrapped in a synthetic class exposing a {@code client} field; snippets that declare their own + * {@code class} are compiled as-is. + */ +class DocSnippetsTest { + + private static final Pattern FENCE = Pattern.compile("```java\\s*\\n(.*?)```", Pattern.DOTALL); + + @Test + void allDocSnippetsCompile() throws IOException { + Path root = Path.of(System.getProperty("user.dir")); + List docFiles = new ArrayList<>(); + Path readme = root.resolve("README.md"); + if (Files.exists(readme)) { + docFiles.add(readme); + } + Path docsDir = root.resolve("docs"); + if (Files.isDirectory(docsDir)) { + try (var stream = Files.walk(docsDir)) { + stream.filter(p -> p.toString().endsWith(".md")).forEach(docFiles::add); + } + } + assertTrue(!docFiles.isEmpty(), "expected at least one documentation file with snippets"); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertTrue(compiler != null, "a JDK (not just a JRE) is required to compile doc snippets"); + + List failures = new ArrayList<>(); + int snippetCount = 0; + + for (Path doc : docFiles) { + String content = Files.readString(doc, StandardCharsets.UTF_8); + Matcher m = FENCE.matcher(content); + int index = 0; + while (m.find()) { + String snippet = m.group(1); + String className = + "Snippet_" + doc.getFileName().toString().replaceAll("\\W", "_") + "_" + index; + String source = wrap(className, snippet); + String error = compile(compiler, className, source); + if (error != null) { + failures.add( + doc.getFileName() + + " snippet #" + + index + + ":\n" + + error + + "\n--- snippet ---\n" + + snippet); + } + index++; + snippetCount++; + } + } + + if (!failures.isEmpty()) { + fail("Doc snippets failed to compile:\n\n" + String.join("\n\n", failures)); + } + assertTrue( + snippetCount > 0, "expected to find at least one ```java snippet in the documentation"); + } + + /** Wraps a snippet. Snippets that declare their own class compile as-is; others are wrapped. */ + private static String wrap(String className, String snippet) { + if (snippet.contains("class ")) { + return snippet; + } + return "import com.apify.client.*;\n" + + "import com.fasterxml.jackson.databind.*;\n" + + "import java.util.*;\n" + + "import java.time.*;\n" + + "import java.io.*;\n" + + "@SuppressWarnings(\"all\")\n" + + "public class " + + className + + " {\n" + + " ApifyClient client;\n" + + " void run() throws Exception {\n" + + snippet + + "\n }\n}\n"; + } + + private static String compile(JavaCompiler compiler, String className, String source) { + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaFileObject file = new StringSource(className, source); + // Compile against the current runtime classpath (built classes + Jackson). + List options = + List.of("-classpath", System.getProperty("java.class.path"), "-proc:none", "-d", tempDir()); + JavaCompiler.CompilationTask task = + compiler.getTask(null, null, diagnostics, options, null, List.of(file)); + boolean ok = task.call(); + if (ok) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (Diagnostic d : diagnostics.getDiagnostics()) { + if (d.getKind() == Diagnostic.Kind.ERROR) { + sb.append(d.getMessage(null)).append('\n'); + } + } + return sb.toString(); + } + + private static String tempDir() { + try { + return Files.createTempDirectory("doc-snippets").toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** An in-memory Java source file. */ + private static final class StringSource extends SimpleJavaFileObject { + private final String code; + + StringSource(String className, String code) { + super(URI.create("string:///" + className + ".java"), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } +} diff --git a/src/test/java/com/apify/client/ExamplesTest.java b/src/test/java/com/apify/client/ExamplesTest.java new file mode 100644 index 0000000..5c6647e --- /dev/null +++ b/src/test/java/com/apify/client/ExamplesTest.java @@ -0,0 +1,66 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.apify.client.examples.CreateBuildRunActor; +import com.apify.client.examples.GetAccount; +import com.apify.client.examples.IterateStore; +import com.apify.client.examples.LogRedirection; +import com.apify.client.examples.RunAndLastRunStorages; +import com.apify.client.examples.RunStoreActor; +import com.apify.client.examples.Storages; +import org.junit.jupiter.api.Test; + +/** + * Runs each documentation example end-to-end against the live API, so the docs stay runnable. Each + * test is skipped when {@code APIFY_TOKEN} is unset. + */ +class ExamplesTest { + + private static void requireToken() { + String token = System.getenv("APIFY_TOKEN"); + assumeTrue(token != null && !token.isEmpty(), "skipping: APIFY_TOKEN is not set"); + } + + @Test + void getAccount() { + requireToken(); + GetAccount.main(new String[] {}); + } + + @Test + void storages() { + requireToken(); + Storages.main(new String[] {}); + } + + @Test + void runStoreActor() { + requireToken(); + RunStoreActor.main(new String[] {}); + } + + @Test + void runAndLastRunStorages() { + requireToken(); + RunAndLastRunStorages.main(new String[] {}); + } + + @Test + void iterateStore() { + requireToken(); + IterateStore.main(new String[] {}); + } + + @Test + void logRedirection() throws Exception { + requireToken(); + LogRedirection.main(new String[] {}); + } + + @Test + void createBuildRunActor() { + requireToken(); + CreateBuildRunActor.main(new String[] {}); + } +} diff --git a/src/test/java/com/apify/client/MockBackend.java b/src/test/java/com/apify/client/MockBackend.java new file mode 100644 index 0000000..895236e --- /dev/null +++ b/src/test/java/com/apify/client/MockBackend.java @@ -0,0 +1,186 @@ +package com.apify.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import javax.net.ssl.SSLSession; + +/** + * A deterministic {@link HttpBackend} for offline unit tests. It serves a queue of scripted + * responses/errors, records how many times it was called, and captures the last request's headers, + * URL and body. + */ +final class MockBackend implements HttpBackend { + + /** One scripted response: an HTTP status + body, or a transport error. */ + static final class Scripted { + final int status; + final byte[] body; + final IOException error; + + Scripted(int status, String body, IOException error) { + this.status = status; + this.body = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8); + this.error = error; + } + } + + private final List responses; + int calls = 0; + HttpHeaders lastHeaders; + String lastUrl; + String lastBody; + final List bodies = new ArrayList<>(); + + MockBackend(List responses) { + this.responses = responses; + } + + static MockBackend ofConstant(int status, String body) { + return new MockBackend(List.of(new Scripted(status, body, null))); + } + + static Scripted ok(int status, String body) { + return new Scripted(status, body, null); + } + + static Scripted networkError() { + return new Scripted(0, null, new IOException("connection refused")); + } + + static Scripted timeoutError() { + return new Scripted(0, null, new java.net.http.HttpTimeoutException("request timed out")); + } + + // synchronized: batchAddRequests may drive this backend from several threads at once. + @Override + public synchronized HttpResponse send(HttpRequest request) throws IOException { + int idx = calls++; + lastHeaders = request.headers(); + lastUrl = request.uri().toString(); + lastBody = readBody(request); + if (lastBody != null) { + bodies.add(lastBody); + } + if (idx >= responses.size()) { + idx = responses.size() - 1; // repeat the last entry + } + Scripted r = responses.get(idx); + if (r.error != null) { + throw r.error; + } + return new FakeResponse(request.uri(), r.status, r.body); + } + + @Override + public HttpResponse sendStreaming(HttpRequest request) { + throw new UnsupportedOperationException("streaming not used in unit tests"); + } + + private static String readBody(HttpRequest request) { + Optional publisher = request.bodyPublisher(); + if (publisher.isEmpty() || publisher.get().contentLength() == 0) { + return null; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + CountDownLatch latch = new CountDownLatch(1); + publisher + .get() + .subscribe( + new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + byte[] chunk = new byte[item.remaining()]; + item.get(chunk); + out.writeBytes(chunk); + } + + @Override + public void onError(Throwable throwable) { + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return out.toString(StandardCharsets.UTF_8); + } + + /** Minimal {@link HttpResponse} implementation over a byte[] body. */ + private static final class FakeResponse implements HttpResponse { + private final URI uri; + private final int status; + private final byte[] body; + + FakeResponse(URI uri, int status, byte[] body) { + this.uri = uri; + this.status = status; + this.body = body; + } + + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public byte[] body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return uri; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + } +} diff --git a/src/test/java/com/apify/client/ReviewFixesTest.java b/src/test/java/com/apify/client/ReviewFixesTest.java new file mode 100644 index 0000000..2e21ec8 --- /dev/null +++ b/src/test/java/com/apify/client/ReviewFixesTest.java @@ -0,0 +1,291 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Offline tests pinning correctness behaviours surfaced in review (idempotency, chunking, ...). */ +class ReviewFixesTest { + + private static ApifyClient client(MockBackend backend) { + return client(backend, 0); + } + + private static ApifyClient client(MockBackend backend, int maxRetries) { + return ApifyClient.builder() + .token("test-token") + .httpBackend(backend) + .maxRetries(maxRetries) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .build(); + } + + @Test + void chargeSendsIdempotencyKey() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + client(backend).run("run123").charge(new RunChargeOptions("my-event")); + String key = backend.lastHeaders.firstValue("idempotency-key").orElse(""); + assertTrue( + key.startsWith("run123-my-event-"), "idempotency key should embed run id + event: " + key); + assertTrue(backend.lastBody.contains("\"eventName\":\"my-event\""), backend.lastBody); + } + + @Test + void chargeHonorsExplicitIdempotencyKey() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + client(backend).run("run123").charge(new RunChargeOptions("e").idempotencyKey("fixed-key")); + assertEquals("fixed-key", backend.lastHeaders.firstValue("idempotency-key").orElse("")); + } + + @Test + void chargeRejectsMissingEventName() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + ApifyClient client = client(backend); + assertThrows( + IllegalArgumentException.class, () -> client.run("r").charge(new RunChargeOptions(null))); + assertThrows( + IllegalArgumentException.class, () -> client.run("r").charge(new RunChargeOptions(""))); + assertEquals(0, backend.calls, "no request should be sent for an invalid charge"); + } + + @Test + void getRecordDefaultsAttachment() { + MockBackend backend = MockBackend.ofConstant(200, "raw-bytes"); + client(backend).keyValueStore("store1").getRecord("OUTPUT"); + assertTrue(backend.lastUrl.contains("attachment=1"), backend.lastUrl); + } + + @Test + void keyValueStoreRecordDefensivelyCopiesBytes() { + MockBackend backend = MockBackend.ofConstant(200, "raw-bytes"); + KeyValueStoreRecord record = + client(backend).keyValueStore("s").getRecord("OUTPUT").orElseThrow(); + byte[] first = record.getValue(); + first[0] = 0; // mutate the returned array + assertEquals('r', record.getValue()[0], "mutating a returned copy must not affect the record"); + assertEquals( + "raw-bytes", new String(record.getValue(), java.nio.charset.StandardCharsets.UTF_8)); + } + + @Test + void setRecordDoesNotRetryTimeoutsWhenOptedOut() { + MockBackend timeouts = new MockBackend(List.of(MockBackend.timeoutError())); + assertThrows( + HttpClientCore.TransportException.class, + () -> + client(timeouts, 3) + .keyValueStore("s") + .setRecord( + "k", + new byte[] {1}, + "application/octet-stream", + new SetRecordOptions().doNotRetryTimeouts(true))); + assertEquals(1, timeouts.calls, "a timeout must not be retried when doNotRetryTimeouts is set"); + } + + @Test + void setRecordRetriesTimeoutsByDefault() { + MockBackend timeouts = new MockBackend(List.of(MockBackend.timeoutError())); + assertThrows( + HttpClientCore.TransportException.class, + () -> client(timeouts, 3).keyValueStore("s").setRecord("k", new byte[] {1}, "text/plain")); + assertEquals(4, timeouts.calls, "timeouts should be retried (maxRetries + 1 attempts)"); + } + + @Test + void createKeysPublicUrlForwardsFilterOptions() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{\"id\":\"store1\"}}"); + String url = + client(backend) + .keyValueStore("store1") + .createKeysPublicUrl(new ListKeysOptions().prefix("img-").limit(10L), null); + assertTrue(url.contains("prefix=img-"), url); + assertTrue(url.contains("limit=10"), url); + assertFalse(url.contains("signature="), "a public store should get no signature: " + url); + } + + @Test + void downloadItemsForwardsItemSelectionParams() { + MockBackend backend = MockBackend.ofConstant(200, "col1,col2\n"); + client(backend) + .dataset("d1") + .downloadItems( + DownloadItemsFormat.CSV, + new DatasetDownloadOptions() + .items(new DatasetListItemsOptions().limit(5L).desc(true).fields(List.of("a", "b"))) + .bom(true)); + assertTrue(backend.lastUrl.contains("format=csv"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("limit=5"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("desc=1"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("bom=1"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("fields=a%2Cb"), backend.lastUrl); + } + + @Test + void batchAddRequestsChunks() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"processedRequests\":[],\"unprocessedRequests\":[]}}"); + List requests = new ArrayList<>(); + for (int i = 0; i < 60; i++) { + requests.add(new RequestQueueRequest("https://example.com", "k" + i)); + } + client(backend) + .requestQueue("q1") + .batchAddRequests( + requests, + false, + new BatchAddRequestsOptions().maxParallel(1).maxUnprocessedRequestsRetries(0)); + assertEquals(3, backend.calls); // 25 + 25 + 10 + } + + @Test + void batchAddRequestsRetriesUnprocessed() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"processedRequests\":[{\"uniqueKey\":\"k0\",\"requestId\":\"r0\"}]," + + "\"unprocessedRequests\":[{\"uniqueKey\":\"k1\",\"url\":\"https://example.com\"}]}}"), + MockBackend.ok( + 200, + "{\"data\":{\"processedRequests\":[{\"uniqueKey\":\"k1\",\"requestId\":\"r1\"}]," + + "\"unprocessedRequests\":[]}}"))); + List requests = + List.of( + new RequestQueueRequest("https://example.com", "k0"), + new RequestQueueRequest("https://example.com", "k1")); + BatchAddResult result = + client(backend) + .requestQueue("q1") + .batchAddRequests( + requests, + false, + new BatchAddRequestsOptions().minDelayBetweenUnprocessedRequestsRetriesMillis(1L)); + assertEquals(2, backend.calls, "the unprocessed request should trigger one retry call"); + assertEquals(2, result.getProcessedRequests().size()); + assertTrue(result.getUnprocessedRequests().isEmpty(), "all requests should end up processed"); + } + + @Test + void getWithWaitForwardsWaitForFinish() { + MockBackend backend = + MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); + Optional run = client(backend).run("r1").getWithWait(30L); + assertTrue(run.isPresent(), "run should be present"); + assertEquals("r1", run.get().getId()); + assertTrue(backend.lastUrl.contains("waitForFinish=30"), backend.lastUrl); + } + + @Test + void getWithWaitClampsServerWaitToConfiguredTimeout() { + // With a 10s per-request timeout, a caller asking for waitForFinish=60 must be clamped below + // the + // timeout (10 - 5s margin = 5) so the synchronous get can't abort itself on the socket timeout. + MockBackend backend = + MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); + ApifyClient client = + ApifyClient.builder() + .token("t") + .httpBackend(backend) + .maxRetries(0) + .timeout(Duration.ofSeconds(10)) + .build(); + client.run("r1").getWithWait(60L); + assertTrue(backend.lastUrl.contains("waitForFinish=5"), backend.lastUrl); + assertFalse(backend.lastUrl.contains("waitForFinish=60"), backend.lastUrl); + } + + @Test + void waitForFinishClampsServerWaitToConfiguredTimeout() { + MockBackend backend = + MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); + ApifyClient client = + ApifyClient.builder() + .token("t") + .httpBackend(backend) + .maxRetries(0) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .timeout(Duration.ofSeconds(20)) + .build(); + client.run("r1").waitForFinish(120L); + // 20s timeout - 5s margin = 15s server wait cap (below the 60s API cap and the 120s budget). + assertTrue(backend.lastUrl.contains("waitForFinish=15"), backend.lastUrl); + } + + @Test + void getResourceReturnsEmptyOnNullData() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":null}"); + Optional store = client(backend).keyValueStore("s").get(); + assertTrue(store.isEmpty(), "a 200 with null data must map to an empty Optional, not throw"); + } + + @Test + void storeIterateDoesNotMutateCallerOptionsAndHonorsOffset() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"items\":[],\"total\":0,\"offset\":0,\"limit\":0,\"count\":0}}"); + StoreListOptions options = new StoreListOptions().offset(100L).limit(50L); + client(backend).store().iterate(options).hasNext(); + // The caller's initial offset must be honored for paging and left untouched afterwards. + assertTrue(backend.lastUrl.contains("offset=100"), backend.lastUrl); + assertEquals(100L, options.offsetValue(), "iteration must not mutate the caller's options"); + } + + @Test + void storeIterateWalksMultiplePages() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{},{}],\"total\":3,\"offset\":0,\"limit\":2,\"count\":2}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{}],\"total\":3,\"offset\":2,\"limit\":2,\"count\":1}}"))); + java.util.Iterator it = + client(backend).store().iterate(new StoreListOptions().limit(2L)); + int count = 0; + while (it.hasNext()) { + it.next(); + count++; + } + assertEquals(3, count, "iteration should walk across both pages"); + assertEquals(2, backend.calls, "one API call per page"); + } + + @Test + void runCollectionListToleratesNullOptionsAndFilter() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"items\":[],\"total\":0,\"offset\":0,\"limit\":0,\"count\":0}}"); + PaginationList runs = client(backend).runs().list(null, null); + assertEquals(0, runs.getItems().size()); + } + + @Test + void nestedWebhookCollectionListsWithoutCreate() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"items\":[],\"total\":0,\"offset\":0,\"limit\":0,\"count\":0}}"); + // Compile-time guarantee: the nested collection type has no create(); it only lists. + NestedWebhookCollectionClient nested = client(backend).task("t1").webhooks(); + assertEquals(0, nested.list(new ListOptions()).getItems().size()); + } + + @Test + void accountWebhookCollectionCanCreate() { + MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{\"id\":\"wh1\"}}"); + Webhook created = client(backend).webhooks().create(java.util.Map.of("eventTypes", List.of())); + assertEquals("wh1", created.getId()); + assertTrue(backend.lastUrl.endsWith("/webhooks"), backend.lastUrl); + } +} diff --git a/src/test/java/com/apify/client/SignatureTest.java b/src/test/java/com/apify/client/SignatureTest.java new file mode 100644 index 0000000..e7ace13 --- /dev/null +++ b/src/test/java/com/apify/client/SignatureTest.java @@ -0,0 +1,35 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +/** Known-answer tests pinning the URL-signing scheme against upstream {@code @apify/utilities}. */ +class SignatureTest { + + @Test + void hmacSignatureMatchesUpstream() { + // HMAC-SHA256 -> hex -> first 30 hex chars -> big integer -> base62 (alphabet 0-9a-zA-Z). + assertEquals("11GYWmGxviysIBMtnQHBk", Signatures.createHmacSignature("secret", "message")); + } + + @Test + void base62Encoding() { + assertEquals("0", Signatures.toBase62(BigInteger.ZERO)); + assertEquals("Z", Signatures.toBase62(BigInteger.valueOf(61))); + assertEquals("10", Signatures.toBase62(BigInteger.valueOf(62))); + } + + @Test + void storageContentSignatureNonExpiring() { + // A non-expiring signature uses version "0" and expiresAt 0: base64url("0.0."). + String sig = Signatures.signStorageContent("secret", "RESID", null); + String decoded = new String(Base64.getUrlDecoder().decode(sig), StandardCharsets.UTF_8); + String expected = "0.0." + Signatures.createHmacSignature("secret", "0.0.RESID"); + assertEquals(expected, decoded); + } +} diff --git a/src/test/java/com/apify/client/UnitHttpTest.java b/src/test/java/com/apify/client/UnitHttpTest.java new file mode 100644 index 0000000..a6d2334 --- /dev/null +++ b/src/test/java/com/apify/client/UnitHttpTest.java @@ -0,0 +1,118 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Offline unit tests for the retry/error/404 logic, using a mock HTTP backend. */ +class UnitHttpTest { + + private static ApifyClient client(MockBackend backend, int maxRetries) { + return ApifyClient.builder() + .token("test-token") + .httpBackend(backend) + .maxRetries(maxRetries) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .build(); + } + + @Test + void successSingleCall() { + MockBackend backend = + MockBackend.ofConstant(200, "{\"data\":{\"id\":\"u1\",\"username\":\"bob\"}}"); + Optional user = client(backend, 8).me().get(); + assertTrue(user.isPresent()); + assertEquals("u1", user.get().getId()); + assertEquals("bob", user.get().getUsername()); + assertEquals(1, backend.calls); + } + + @Test + void rateLimitIsRetried() { + MockBackend backend = + MockBackend.ofConstant( + 429, "{\"error\":{\"type\":\"rate-limit-exceeded\",\"message\":\"slow down\"}}"); + ApifyApiException ex = + assertThrows(ApifyApiException.class, () -> client(backend, 2).me().get()); + assertEquals(429, ex.getStatusCode()); + assertEquals(3, backend.calls); // 1 initial + 2 retries + assertEquals(3, ex.getAttempt()); + } + + @Test + void serverErrorIsRetried() { + MockBackend backend = + MockBackend.ofConstant(503, "{\"error\":{\"type\":\"internal\",\"message\":\"boom\"}}"); + assertThrows(ApifyApiException.class, () -> client(backend, 1).me().get()); + assertEquals(2, backend.calls); + } + + @Test + void clientErrorNotRetried() { + MockBackend backend = + MockBackend.ofConstant(400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"nope\"}}"); + assertThrows(ApifyApiException.class, () -> client(backend, 5).me().get()); + assertEquals(1, backend.calls); + } + + @Test + void networkErrorIsRetried() { + MockBackend backend = new MockBackend(List.of(MockBackend.networkError())); + assertThrows(RuntimeException.class, () -> client(backend, 3).me().get()); + assertEquals(4, backend.calls); + } + + @Test + void retryThenSuccess() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), + MockBackend.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), + MockBackend.ok(200, "{\"data\":{\"id\":\"ok\"}}"))); + Optional user = client(backend, 5).me().get(); + assertTrue(user.isPresent()); + assertEquals("ok", user.get().getId()); + assertEquals(3, backend.calls); + } + + @Test + void notFoundMapsToEmpty() { + MockBackend backend = + MockBackend.ofConstant( + 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"); + Optional actor = client(backend, 5).actor("nope").get(); + assertFalse(actor.isPresent()); + assertEquals(1, backend.calls); // no retry on 404 + } + + @Test + void errorBodyIsParsed() { + MockBackend backend = + MockBackend.ofConstant( + 400, + "{\"error\":{\"type\":\"bad-request\",\"message\":\"invalid input\",\"data\":{\"field\":\"name\"}}}"); + ApifyApiException ex = + assertThrows(ApifyApiException.class, () -> client(backend, 0).me().get()); + assertEquals(400, ex.getStatusCode()); + assertEquals("bad-request", ex.getType()); + assertTrue(ex.getMessage().contains("invalid input")); + assertEquals("GET", ex.getHttpMethod()); + assertFalse(ex.getPath().isEmpty()); + assertEquals("name", ex.getData().get("field")); + } + + @Test + void zeroRetriesSingleAttempt() { + MockBackend backend = + MockBackend.ofConstant(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"); + assertThrows(ApifyApiException.class, () -> client(backend, 0).me().get()); + assertEquals(1, backend.calls); + } +} diff --git a/src/test/java/com/apify/client/examples/CreateBuildRunActor.java b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java new file mode 100644 index 0000000..a3b4ed4 --- /dev/null +++ b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java @@ -0,0 +1,67 @@ +package com.apify.client.examples; + +import com.apify.client.Actor; +import com.apify.client.ActorBuildOptions; +import com.apify.client.ActorClient; +import com.apify.client.ActorRun; +import com.apify.client.ActorStartOptions; +import com.apify.client.ApifyClient; +import com.apify.client.Build; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Demonstrates the full Actor lifecycle: create a new Actor from source files, build it, run it, + * wait for it to finish, then fetch and print the run log. + */ +public final class CreateBuildRunActor { + private CreateBuildRunActor() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + String name = "java-example-" + System.currentTimeMillis(); + + Map actorDef = + Map.of( + "name", + name, + "isPublic", + false, + "versions", + List.of( + Map.of( + "versionNumber", "0.0", + "sourceType", "SOURCE_FILES", + "buildTag", "latest", + "sourceFiles", + List.of( + Map.of( + "name", "Dockerfile", + "format", "TEXT", + "content", "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"), + Map.of( + "name", "main.js", + "format", "TEXT", + "content", + "console.log('hello from the java client example');"))))); + + Actor created = client.actors().create(actorDef); + try { + System.out.println("Created actor " + created.getId()); + ActorClient actor = client.actor(created.getId()); + + Build build = actor.build("0.0", new ActorBuildOptions()); + client.build(build.getId()).waitForFinish(300L); + System.out.println("Built actor (build " + build.getId() + ")"); + + ActorRun run = actor.call(null, new ActorStartOptions(), 120L); + System.out.println("Run " + run.getId() + " finished with status " + run.getStatus()); + + Optional log = client.run(run.getId()).log().get(); + log.ifPresent(text -> System.out.println("--- run log ---\n" + text)); + } finally { + client.actor(created.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/examples/GetAccount.java b/src/test/java/com/apify/client/examples/GetAccount.java new file mode 100644 index 0000000..b5c7c33 --- /dev/null +++ b/src/test/java/com/apify/client/examples/GetAccount.java @@ -0,0 +1,24 @@ +package com.apify.client.examples; + +import com.apify.client.ApifyClient; +import com.apify.client.User; +import java.util.Optional; + +/** + * Fetches and prints the current user's account details. + * + *

Run with: {@code APIFY_TOKEN= mvn ...} or by executing {@link #main(String[])}. + */ +public final class GetAccount { + private GetAccount() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + Optional user = client.me().get(); + if (user.isEmpty()) { + throw new IllegalStateException("current user not found"); + } + System.out.println("Account ID: " + user.get().getId()); + System.out.println("Username: " + user.get().getUsername()); + } +} diff --git a/src/test/java/com/apify/client/examples/IterateStore.java b/src/test/java/com/apify/client/examples/IterateStore.java new file mode 100644 index 0000000..c1946c7 --- /dev/null +++ b/src/test/java/com/apify/client/examples/IterateStore.java @@ -0,0 +1,26 @@ +package com.apify.client.examples; + +import com.apify.client.ActorStoreListItem; +import com.apify.client.ApifyClient; +import com.apify.client.StoreListOptions; +import java.util.Iterator; + +/** + * Lazily iterates over Actors in the Apify Store using the convenience iteration method, printing + * the first few. + */ +public final class IterateStore { + private IterateStore() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + + Iterator it = client.store().iterate(new StoreListOptions().limit(10L)); + int count = 0; + while (count < 5 && it.hasNext()) { + ActorStoreListItem item = it.next(); + System.out.println((count + 1) + ". " + item.getUsername() + "/" + item.getName()); + count++; + } + } +} diff --git a/src/test/java/com/apify/client/examples/LogRedirection.java b/src/test/java/com/apify/client/examples/LogRedirection.java new file mode 100644 index 0000000..eff05ab --- /dev/null +++ b/src/test/java/com/apify/client/examples/LogRedirection.java @@ -0,0 +1,26 @@ +package com.apify.client.examples; + +import com.apify.client.ActorRun; +import com.apify.client.ActorStartOptions; +import com.apify.client.ApifyClient; +import java.io.InputStream; + +/** + * Starts an Actor without waiting, then streams its log output to stdout in real time (log + * redirection). + */ +public final class LogRedirection { + private LogRedirection() {} + + public static void main(String[] args) throws Exception { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + + // Start the Actor and return immediately (do not wait for it to finish). + ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); + + // Open a live (raw) log stream and copy it to stdout as the run produces output. + try (InputStream stream = client.run(run.getId()).getStreamedLog()) { + stream.transferTo(System.out); + } + } +} diff --git a/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java b/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java new file mode 100644 index 0000000..6f49757 --- /dev/null +++ b/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java @@ -0,0 +1,32 @@ +package com.apify.client.examples; + +import com.apify.client.ActorRun; +import com.apify.client.ActorStartOptions; +import com.apify.client.ApifyClient; +import com.apify.client.DatasetListItemsOptions; +import java.util.Optional; + +/** + * Starts an Actor run, waits for it to finish, then fetches the Actor's last run and reads its + * default storages. + */ +public final class RunAndLastRunStorages { + private RunAndLastRunStorages() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + + client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + + Optional lastRun = client.actor("apify/hello-world").lastRun("SUCCEEDED").get(); + if (lastRun.isEmpty()) { + throw new IllegalStateException("no succeeded run found"); + } + ActorRun run = lastRun.get(); + System.out.println("Last run: " + run.getId()); + + var items = client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); + System.out.println("Last run dataset item count: " + items.getCount()); + client.keyValueStore(run.getDefaultKeyValueStoreId()).getRecord("OUTPUT"); + } +} diff --git a/src/test/java/com/apify/client/examples/RunStoreActor.java b/src/test/java/com/apify/client/examples/RunStoreActor.java new file mode 100644 index 0000000..480785f --- /dev/null +++ b/src/test/java/com/apify/client/examples/RunStoreActor.java @@ -0,0 +1,24 @@ +package com.apify.client.examples; + +import com.apify.client.ActorRun; +import com.apify.client.ActorStartOptions; +import com.apify.client.ApifyClient; +import com.apify.client.DatasetListItemsOptions; + +/** + * Runs an existing store Actor ({@code apify/hello-world}), waits for it to finish, and reads its + * default dataset. + */ +public final class RunStoreActor { + private RunStoreActor() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + + ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + System.out.println("Run " + run.getId() + " finished with status " + run.getStatus()); + + var items = client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); + System.out.println("Default dataset item count: " + items.getCount()); + } +} diff --git a/src/test/java/com/apify/client/examples/Storages.java b/src/test/java/com/apify/client/examples/Storages.java new file mode 100644 index 0000000..f1bdec9 --- /dev/null +++ b/src/test/java/com/apify/client/examples/Storages.java @@ -0,0 +1,59 @@ +package com.apify.client.examples; + +import com.apify.client.ApifyClient; +import com.apify.client.Dataset; +import com.apify.client.DatasetListItemsOptions; +import com.apify.client.KeyValueStore; +import com.apify.client.KeyValueStoreRecord; +import com.apify.client.RequestQueue; +import com.apify.client.RequestQueueRequest; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Demonstrates each storage type: create the storage, push data to it, then read the data back. + * + *

Run by executing {@link #main(String[])} with {@code APIFY_TOKEN} set. + */ +public final class Storages { + private Storages() {} + + public static void main(String[] args) { + ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); + String suffix = Long.toString(System.currentTimeMillis()); + + // Dataset: create, push items, read them back. + Dataset dataset = client.datasets().getOrCreate("java-example-ds-" + suffix); + try { + client.dataset(dataset.getId()).pushItems(List.of(Map.of("hello", "world"))); + var items = client.dataset(dataset.getId()).listItems(new DatasetListItemsOptions()); + System.out.println("Dataset items: " + items.getItems()); + } finally { + client.dataset(dataset.getId()).delete(); + } + + // Key-value store: create, set a record, read it back. + KeyValueStore store = client.keyValueStores().getOrCreate("java-example-kvs-" + suffix); + try { + client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); + Optional record = + client.keyValueStore(store.getId()).getRecord("OUTPUT"); + record.ifPresent(r -> System.out.println("KVS record bytes: " + r.getValue().length)); + } finally { + client.keyValueStore(store.getId()).delete(); + } + + // Request queue: create, add a request, read the head. + RequestQueue queue = client.requestQueues().getOrCreate("java-example-rq-" + suffix); + try { + client + .requestQueue(queue.getId()) + .addRequest(new RequestQueueRequest("https://example.com", "example"), false); + var head = client.requestQueue(queue.getId()).listHead(10L); + System.out.println("Request queue head size: " + head.getItems().size()); + } finally { + client.requestQueue(queue.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java new file mode 100644 index 0000000..a290d6f --- /dev/null +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -0,0 +1,127 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.Actor; +import com.apify.client.ActorClient; +import com.apify.client.ActorEnvVar; +import com.apify.client.ActorListOptions; +import com.apify.client.ActorVersion; +import com.apify.client.ApifyClient; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ActorIntegrationTest extends IntegrationBase { + + /** A minimal Actor definition; the API requires at least one version. */ + static Map minimalActor(String name) { + return Map.of( + "name", + name, + "isPublic", + false, + "description", + "Integration test actor", + "versions", + List.of( + Map.of( + "versionNumber", "0.0", + "sourceType", "SOURCE_FILES", + "buildTag", "latest", + "sourceFiles", + List.of( + Map.of( + "name", "Dockerfile", + "format", "TEXT", + "content", "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"), + Map.of( + "name", "main.js", + "format", "TEXT", + "content", "console.log('hello from java client test');"))))); + } + + @Test + void listActors() { + ApifyClient client = requireClient(); + PaginationList page = client.actors().list(new ActorListOptions().my(true).limit(5L)); + assertTrue(page.getTotal() >= 0); + } + + @Test + void getActor() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(minimalActor(uniqueName("get"))); + try { + var got = client.actor(created.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(created.getId(), got.get().getId()); + } finally { + client.actor(created.getId()).delete(); + } + } + + @Test + void actorCrudFlow() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(minimalActor(uniqueName("crud"))); + try { + ActorClient actor = client.actor(created.getId()); + assertTrue(actor.get().isPresent()); + Actor updated = actor.update(Map.of("title", "Updated Title")); + assertEquals("Updated Title", updated.getTitle()); + actor.builds().list(new ListOptions()); + actor.versions().list(new ListOptions()); + } finally { + client.actor(created.getId()).delete(); + } + } + + @Test + void actorVersionCrudFlow() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(minimalActor(uniqueName("ver"))); + try { + ActorClient actor = client.actor(created.getId()); + ActorVersion version = + actor + .versions() + .create( + Map.of( + "versionNumber", "0.1", + "sourceType", "SOURCE_FILES", + "buildTag", "latest", + "sourceFiles", List.of())); + assertEquals("0.1", version.getVersionNumber()); + assertTrue(actor.version("0.1").get().isPresent()); + actor.versions().list(new ListOptions()); + actor + .version("0.1") + .update( + Map.of("buildTag", "beta", "sourceType", "SOURCE_FILES", "sourceFiles", List.of())); + actor.version("0.1").delete(); + } finally { + client.actor(created.getId()).delete(); + } + } + + @Test + void actorEnvVarCrudFlow() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(minimalActor(uniqueName("env"))); + try { + ActorClient actor = client.actor(created.getId()); + var envVars = actor.version("0.0").envVars(); + envVars.create(new ActorEnvVar("MY_VAR", "value1")); + assertTrue(actor.version("0.0").envVar("MY_VAR").get().isPresent()); + envVars.list(); + actor.version("0.0").envVar("MY_VAR").update(new ActorEnvVar("MY_VAR", "value2")); + actor.version("0.0").envVar("MY_VAR").delete(); + } finally { + client.actor(created.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java new file mode 100644 index 0000000..6bcb9ce --- /dev/null +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -0,0 +1,56 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ActorRun; +import com.apify.client.ActorStartOptions; +import com.apify.client.ApifyClient; +import com.apify.client.LastRunOptions; +import com.apify.client.ListOptions; +import com.apify.client.RunListOptions; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ActorRunIntegrationTest extends IntegrationBase { + + @Test + void listRuns() { + ApifyClient client = requireClient(); + var page = client.runs().list(new ListOptions().limit(5L), new RunListOptions()); + assertTrue(page.getTotal() >= 0); + } + + @Test + void runActorAndReadOutputs() { + ApifyClient client = requireClient(); + ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + assertEquals("SUCCEEDED", run.getStatus()); + + assertTrue(client.run(run.getId()).get().isPresent()); + + Optional log = client.run(run.getId()).log().get(); + assertTrue(log.isPresent() && !log.get().isEmpty()); + + client.run(run.getId()).dataset().listItems(new com.apify.client.DatasetListItemsOptions()); + client.run(run.getId()).keyValueStore().getRecord("OUTPUT"); + } + + @Test + void lastRunAccess() { + ApifyClient client = requireClient(); + client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + + var lastRun = client.actor("apify/hello-world").lastRun("SUCCEEDED").get(); + assertTrue(lastRun.isPresent()); + assertEquals("SUCCEEDED", lastRun.get().getStatus()); + + var byOrigin = + client + .actor("apify/hello-world") + .lastRun(new LastRunOptions().status("SUCCEEDED").origin("API")) + .get(); + assertTrue(byOrigin.isPresent()); + assertEquals("SUCCEEDED", byOrigin.get().getStatus()); + } +} diff --git a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java new file mode 100644 index 0000000..8a2d28f --- /dev/null +++ b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java @@ -0,0 +1,37 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.Actor; +import com.apify.client.ActorBuildOptions; +import com.apify.client.ApifyClient; +import com.apify.client.Build; +import com.apify.client.ListOptions; +import org.junit.jupiter.api.Test; + +class BuildIntegrationTest extends IntegrationBase { + + @Test + void listBuilds() { + ApifyClient client = requireClient(); + var page = client.builds().list(new ListOptions().limit(5L)); + assertTrue(page.getTotal() >= 0); + } + + @Test + void buildActorFlow() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(ActorIntegrationTest.minimalActor(uniqueName("build"))); + try { + Build build = client.actor(created.getId()).build("0.0", new ActorBuildOptions()); + Build finished = client.build(build.getId()).waitForFinish(300L); + assertTrue(finished.isTerminal(), "build did not finish: " + finished.getStatus()); + + assertTrue(client.build(build.getId()).get().isPresent()); + client.build(build.getId()).log().get(); + client.build(build.getId()).getOpenApiDefinition(); + } finally { + client.actor(created.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java new file mode 100644 index 0000000..7cde9ca --- /dev/null +++ b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java @@ -0,0 +1,75 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.Dataset; +import com.apify.client.DatasetClient; +import com.apify.client.DatasetDownloadOptions; +import com.apify.client.DatasetListItemsOptions; +import com.apify.client.DownloadItemsFormat; +import com.apify.client.PaginationList; +import com.apify.client.StorageListOptions; +import com.fasterxml.jackson.databind.JsonNode; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DatasetIntegrationTest extends IntegrationBase { + + @Test + void listDatasets() { + ApifyClient client = requireClient(); + assertTrue(client.datasets().list(new StorageListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getDataset() { + ApifyClient client = requireClient(); + Dataset ds = client.datasets().getOrCreate(uniqueName("ds-get")); + try { + var got = client.dataset(ds.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(ds.getId(), got.get().getId()); + } finally { + client.dataset(ds.getId()).delete(); + } + } + + @Test + void datasetCrudFlow() { + ApifyClient client = requireClient(); + Dataset ds = client.datasets().getOrCreate(uniqueName("ds-crud")); + try { + DatasetClient dataset = client.dataset(ds.getId()); + assertTrue(dataset.get().isPresent()); + + dataset.pushItems( + List.of( + Map.of("url", "https://a.com", "n", 1), + Map.of("url", "https://b.com", "n", 2), + Map.of("url", "https://c.com", "n", 3))); + + PaginationList page = dataset.listItems(new DatasetListItemsOptions()); + assertEquals(3, page.getCount()); + assertEquals(3, page.getItems().size()); + assertEquals(1, page.getItems().get(0).get("n").asInt()); + + byte[] csv = + dataset.downloadItems(DownloadItemsFormat.CSV, new DatasetDownloadOptions().bom(true)); + assertTrue(new String(csv, StandardCharsets.UTF_8).contains("url")); + + String url = dataset.createItemsPublicUrl(new DatasetListItemsOptions(), null); + assertTrue(url != null && !url.isEmpty()); + + dataset.getStatistics(); + + Dataset updated = dataset.update(Map.of("name", uniqueName("ds-renamed"))); + assertTrue(updated.getName() != null && !updated.getName().isEmpty()); + } finally { + client.dataset(ds.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java new file mode 100644 index 0000000..f655631 --- /dev/null +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -0,0 +1,67 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.apify.client.ApifyClient; +import java.security.SecureRandom; + +/** + * Shared setup for the integration test suite. + * + *

All integration tests require a valid {@code APIFY_TOKEN} for the test account. The API base + * URL is taken from {@code APIFY_API_URL} (which includes the {@code /v2} suffix) and falls back to + * {@code https://api.apify.com/v2}. + * + *

Tests are designed to run concurrently — including against the same test account from several + * language clients at once — so every test creates uniquely-named resources and cleans them up. + */ +abstract class IntegrationBase { + + /** The integration-test contract fallback base URL. */ + static final String DEFAULT_API_URL = "https://api.apify.com/v2"; + + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * Derives the client base URL from an optional {@code APIFY_API_URL}. The variable includes the + * {@code /v2} suffix (per the integration-test contract) and falls back to {@link + * #DEFAULT_API_URL}. Since the client appends {@code /v2} itself, the suffix is stripped. + */ + static String resolveBaseUrl(String apiUrl) { + if (apiUrl == null || apiUrl.isEmpty()) { + apiUrl = DEFAULT_API_URL; + } + String trimmed = apiUrl; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + if (trimmed.endsWith("/v2")) { + trimmed = trimmed.substring(0, trimmed.length() - "/v2".length()); + } + return trimmed; + } + + /** Returns a configured client, or skips the test if {@code APIFY_TOKEN} is unset. */ + static ApifyClient requireClient() { + String token = System.getenv("APIFY_TOKEN"); + assumeTrue(token != null && !token.isEmpty(), "skipping: APIFY_TOKEN is not set"); + return ApifyClient.builder() + .token(token) + .baseUrl(resolveBaseUrl(System.getenv("APIFY_API_URL"))) + .build(); + } + + /** + * Generates a collision-resistant resource name for test isolation. The random component lets the + * same test run in parallel (across processes and languages) without clobbering shared state. + */ + static String uniqueName(String prefix) { + byte[] buf = new byte[6]; + RANDOM.nextBytes(buf); + StringBuilder hex = new StringBuilder(); + for (byte b : buf) { + hex.append(String.format("%02x", b)); + } + return "java-test-" + prefix + "-" + hex; + } +} diff --git a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java new file mode 100644 index 0000000..f01bf7f --- /dev/null +++ b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java @@ -0,0 +1,106 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.GetRecordOptions; +import com.apify.client.KeyValueStore; +import com.apify.client.KeyValueStoreClient; +import com.apify.client.KeyValueStoreRecord; +import com.apify.client.ListKeysOptions; +import com.apify.client.StorageListOptions; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class KeyValueStoreIntegrationTest extends IntegrationBase { + + @Test + void listKeyValueStores() { + ApifyClient client = requireClient(); + assertTrue(client.keyValueStores().list(new StorageListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getKeyValueStore() { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("kvs-get")); + try { + var got = client.keyValueStore(store.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(store.getId(), got.get().getId()); + } finally { + client.keyValueStore(store.getId()).delete(); + } + } + + @Test + void recordKeyWithSpecialChars() { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("kvs-special")); + try { + KeyValueStoreClient kvs = client.keyValueStore(store.getId()); + String key = "weird-key!'()"; + kvs.setRecordJson(key, Map.of("ok", true)); + assertTrue(kvs.recordExists(key)); + Optional rec = kvs.getRecord(key); + assertTrue(rec.isPresent()); + kvs.deleteRecord(key); + } finally { + client.keyValueStore(store.getId()).delete(); + } + } + + @Test + void keyValueStoreCrudFlow() throws Exception { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("kvs-crud")); + try { + KeyValueStoreClient kvs = client.keyValueStore(store.getId()); + assertTrue(kvs.get().isPresent()); + kvs.setRecordJson("OUTPUT", Map.of("hello", "world")); + assertTrue(kvs.recordExists("OUTPUT")); + Optional rec = kvs.getRecord("OUTPUT"); + assertTrue(rec.isPresent()); + String value = new String(rec.get().getValue(), StandardCharsets.UTF_8); + assertTrue(value.contains("world"), value); + kvs.getRecord("OUTPUT", new GetRecordOptions().attachment(false)); + var keys = kvs.listKeys(new ListKeysOptions()); + assertTrue(!keys.getItems().isEmpty()); + kvs.update(Map.of("name", uniqueName("kvs-renamed"))); + kvs.deleteRecord("OUTPUT"); + } finally { + client.keyValueStore(store.getId()).delete(); + } + } + + @Test + void recordPublicUrlIsFetchable() throws IOException, InterruptedException { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("kvs-pub")); + try { + KeyValueStoreClient kvs = client.keyValueStore(store.getId()); + kvs.setRecordJson("OUTPUT", Map.of("pub", true)); + String url = kvs.getRecordPublicUrl("OUTPUT"); + assertTrue(url != null && !url.isEmpty()); + + HttpResponse resp = + HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(URI.create(url)).build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertTrue( + resp.statusCode() < 300, + "expected success fetching public url, got " + resp.statusCode()); + } finally { + client.keyValueStore(store.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java new file mode 100644 index 0000000..69277f2 --- /dev/null +++ b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java @@ -0,0 +1,131 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.BatchAddResult; +import com.apify.client.ListRequestsOptions; +import com.apify.client.RequestQueue; +import com.apify.client.RequestQueueClient; +import com.apify.client.RequestQueueOperationInfo; +import com.apify.client.RequestQueueRequest; +import com.apify.client.StorageListOptions; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class RequestQueueIntegrationTest extends IntegrationBase { + + @Test + void listRequestQueues() { + ApifyClient client = requireClient(); + assertTrue(client.requestQueues().list(new StorageListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getRequestQueue() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-get")); + try { + var got = client.requestQueue(rq.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(rq.getId(), got.get().getId()); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + + @Test + void requestQueueCrudFlow() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-crud")); + try { + RequestQueueClient queue = client.requestQueue(rq.getId()); + assertTrue(queue.get().isPresent()); + + RequestQueueOperationInfo info = + queue.addRequest( + new RequestQueueRequest("https://example.com", "example").setMethod("GET"), false); + assertTrue(info.getRequestId() != null && !info.getRequestId().isEmpty()); + + var got = queue.getRequest(info.getRequestId()); + assertTrue(got.isPresent()); + assertEquals("https://example.com", got.get().getUrl()); + + assertTrue(!queue.listHead(10L).getItems().isEmpty()); + queue.update(Map.of("name", uniqueName("rq-renamed"))); + queue.deleteRequest(info.getRequestId()); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + + @Test + void requestQueuePaginateMultiplePages() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-page")); + try { + RequestQueueClient queue = client.requestQueue(rq.getId()); + int total = 5; + for (int i = 0; i < total; i++) { + String url = "https://example.com/" + i; + queue.addRequest(new RequestQueueRequest(url, url), false); + } + Set seen = new HashSet<>(); + Iterator it = queue.paginateRequests(2L); + while (it.hasNext()) { + seen.add(it.next().getUrl()); + } + assertEquals(total, seen.size()); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + + @Test + void requestQueueBatchAddRequests() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-batch")); + try { + RequestQueueClient queue = client.requestQueue(rq.getId()); + int total = 30; // > 25, so the client must split into multiple chunks + List requests = new ArrayList<>(); + for (int i = 0; i < total; i++) { + String url = "https://batch.example.com/" + i; + requests.add(new RequestQueueRequest(url, url)); + } + BatchAddResult result = queue.batchAddRequests(requests, false); + assertEquals(total, result.getProcessedRequests().size()); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + + @Test + void requestQueueLockLifecycle() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-lock")); + try { + RequestQueueClient queue = + client.requestQueue(rq.getId()).withClientKey("java-test-client-key"); + RequestQueueOperationInfo info = + queue.addRequest(new RequestQueueRequest("https://lock.example.com", "lock"), false); + queue.listRequests(new ListRequestsOptions()); + queue.listRequests( + new ListRequestsOptions() + .filter( + List.of(ListRequestsOptions.FILTER_LOCKED, ListRequestsOptions.FILTER_PENDING))); + queue.listAndLockHead(60, 10L); + queue.prolongRequestLock(info.getRequestId(), 30, false); + queue.deleteRequestLock(info.getRequestId(), false); + queue.unlockRequests(); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java new file mode 100644 index 0000000..fb5e09c --- /dev/null +++ b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java @@ -0,0 +1,76 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.ListOptions; +import com.apify.client.Schedule; +import com.apify.client.ScheduleClient; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ScheduleIntegrationTest extends IntegrationBase { + + static Map scheduleDef(String name) { + return Map.of( + "name", + name, + "cronExpression", + "0 0 * * *", + "isEnabled", + false, + "isExclusive", + true, + "actions", + List.of()); + } + + @Test + void listSchedules() { + ApifyClient client = requireClient(); + assertTrue(client.schedules().list(new ListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getSchedule() { + ApifyClient client = requireClient(); + Schedule sch = client.schedules().create(scheduleDef(uniqueName("sch-get"))); + try { + var got = client.schedule(sch.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(sch.getId(), got.get().getId()); + } finally { + client.schedule(sch.getId()).delete(); + } + } + + @Test + void getScheduleLog() { + ApifyClient client = requireClient(); + Schedule sch = client.schedules().create(scheduleDef(uniqueName("sch-log"))); + try { + // Simple GET on the schedule-log endpoint; a fresh schedule may have no log yet (empty + // Optional), which is a valid result — we only assert the call itself succeeds. + client.schedule(sch.getId()).getLog(); + } finally { + client.schedule(sch.getId()).delete(); + } + } + + @Test + void scheduleCrudFlow() { + ApifyClient client = requireClient(); + Schedule sch = client.schedules().create(scheduleDef(uniqueName("sch-crud"))); + try { + ScheduleClient schedule = client.schedule(sch.getId()); + assertTrue(schedule.get().isPresent()); + Schedule updated = schedule.update(Map.of("cronExpression", "0 12 * * *")); + assertEquals("0 12 * * *", updated.getCronExpression()); + schedule.getLog(); + } finally { + client.schedule(sch.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java new file mode 100644 index 0000000..08d6abd --- /dev/null +++ b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java @@ -0,0 +1,32 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ActorStoreListItem; +import com.apify.client.ApifyClient; +import com.apify.client.StoreListOptions; +import java.util.Iterator; +import org.junit.jupiter.api.Test; + +class StoreIntegrationTest extends IntegrationBase { + + @Test + void listStore() { + ApifyClient client = requireClient(); + var page = client.store().list(new StoreListOptions().limit(5L)); + assertTrue(page.getItems().size() <= 5); + } + + @Test + void iterateStore() { + ApifyClient client = requireClient(); + Iterator it = client.store().iterate(new StoreListOptions().limit(5L)); + int count = 0; + while (count < 12 && it.hasNext()) { + ActorStoreListItem item = it.next(); + assertTrue(item.getId() != null && !item.getId().isEmpty()); + count++; + } + assertTrue(count >= 12, "expected to iterate at least 12 store actors, got " + count); + } +} diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java new file mode 100644 index 0000000..f1ea420 --- /dev/null +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -0,0 +1,62 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.ListOptions; +import com.apify.client.RunListOptions; +import com.apify.client.Task; +import com.apify.client.TaskClient; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TaskIntegrationTest extends IntegrationBase { + + static Map taskDef(String name) { + return Map.of( + "actId", + "apify/hello-world", + "name", + name, + "options", + Map.of("build", "latest", "memoryMbytes", 256, "timeoutSecs", 60), + "input", + Map.of("message", "hello")); + } + + @Test + void listTasks() { + ApifyClient client = requireClient(); + assertTrue(client.tasks().list(new ListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getTask() { + ApifyClient client = requireClient(); + Task task = client.tasks().create(taskDef(uniqueName("task-get"))); + try { + var got = client.task(task.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(task.getId(), got.get().getId()); + } finally { + client.task(task.getId()).delete(); + } + } + + @Test + void taskCrudFlow() { + ApifyClient client = requireClient(); + Task task = client.tasks().create(taskDef(uniqueName("task-crud"))); + try { + TaskClient tc = client.task(task.getId()); + assertTrue(tc.get().isPresent()); + tc.updateInput(Map.of("message", "updated")); + assertTrue(tc.getInput().isPresent()); + tc.update(Map.of("name", uniqueName("task-renamed"))); + tc.runs().list(new ListOptions(), new RunListOptions()); + } finally { + client.task(task.getId()).delete(); + } + } +} diff --git a/src/test/java/com/apify/client/integration/UserIntegrationTest.java b/src/test/java/com/apify/client/integration/UserIntegrationTest.java new file mode 100644 index 0000000..f64d2b9 --- /dev/null +++ b/src/test/java/com/apify/client/integration/UserIntegrationTest.java @@ -0,0 +1,39 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +class UserIntegrationTest extends IntegrationBase { + + @Test + void getOwnAccount() { + ApifyClient client = requireClient(); + var user = client.me().get(); + assertTrue(user.isPresent()); + assertTrue(user.get().getId() != null && !user.get().getId().isEmpty()); + } + + @Test + void getMonthlyUsage() { + ApifyClient client = requireClient(); + JsonNode usage = client.me().monthlyUsage(); + assertTrue(usage != null && !usage.isEmpty()); + } + + @Test + void getMonthlyUsageForDate() { + ApifyClient client = requireClient(); + JsonNode usage = client.me().monthlyUsage("2026-06-01"); + assertTrue(usage != null && !usage.isEmpty()); + } + + @Test + void getLimits() { + ApifyClient client = requireClient(); + JsonNode limits = client.me().limits(); + assertTrue(limits != null && !limits.isEmpty()); + } +} diff --git a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java new file mode 100644 index 0000000..eb7e9b9 --- /dev/null +++ b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java @@ -0,0 +1,83 @@ +package com.apify.client.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.ApifyClient; +import com.apify.client.ListOptions; +import com.apify.client.Webhook; +import com.apify.client.WebhookClient; +import com.apify.client.WebhookDispatch; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class WebhookIntegrationTest extends IntegrationBase { + + static Map webhookDef(String url) { + return Map.of( + "isAdHoc", + true, + "eventTypes", + List.of("ACTOR.RUN.SUCCEEDED"), + "condition", + Map.of("actorRunId", "ZZZZZZZZZZZZZZZZZ"), + "requestUrl", + url); + } + + @Test + void listWebhooks() { + ApifyClient client = requireClient(); + assertTrue(client.webhooks().list(new ListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void listWebhookDispatches() { + ApifyClient client = requireClient(); + assertTrue(client.webhookDispatches().list(new ListOptions().limit(5L)).getTotal() >= 0); + } + + @Test + void getWebhook() { + ApifyClient client = requireClient(); + Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")); + try { + var got = client.webhook(wh.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(wh.getId(), got.get().getId()); + } finally { + client.webhook(wh.getId()).delete(); + } + } + + @Test + void getWebhookDispatch() { + ApifyClient client = requireClient(); + Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")); + try { + WebhookDispatch dispatch = client.webhook(wh.getId()).test(); + var got = client.webhookDispatch(dispatch.getId()).get(); + assertTrue(got.isPresent()); + assertEquals(dispatch.getId(), got.get().getId()); + } finally { + client.webhook(wh.getId()).delete(); + } + } + + @Test + void webhookCrudFlow() { + ApifyClient client = requireClient(); + Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")); + try { + WebhookClient webhook = client.webhook(wh.getId()); + assertTrue(webhook.get().isPresent()); + Webhook updated = webhook.update(Map.of("requestUrl", "https://example.com/updated")); + assertEquals("https://example.com/updated", updated.getRequestUrl()); + webhook.dispatches().list(new ListOptions()); + webhook.test(); + } finally { + client.webhook(wh.getId()).delete(); + } + } +} From 5c3d8dd505d1c48d56959324654584e278a73b25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 11:42:45 +0000 Subject: [PATCH 02/12] feat: add ActorClient.validateInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore validateInput(input[, ValidateInputOptions]) — present in both the OpenAPI spec (POST /v2/actors/{actorId}/validate-input) and the JS reference client. Parses the bare {"valid": } response (no data envelope) via a new no-envelope POST helper. Adds a live integration test. --- CHANGELOG.md | 2 ++ docs/actors.md | 1 + .../java/com/apify/client/ActorClient.java | 25 +++++++++++++++ .../com/apify/client/ResourceContext.java | 12 +++++++ .../apify/client/ValidateInputOptions.java | 32 +++++++++++++++++++ .../integration/ActorIntegrationTest.java | 9 ++++++ 6 files changed, 81 insertions(+) create mode 100644 src/main/java/com/apify/client/ValidateInputOptions.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acf7c4..57a8224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Apify API, verified against OpenAPI specification version `v2-2026-07-01T115402Z `minDelayBetweenUnprocessedRequestsRetriesMillis`) with automatic retry of unprocessed requests, all matching the reference client. `downloadItems` forwards the full set of item-selection parameters via `DatasetDownloadOptions.items(...)`. +- `actor(...).validateInput(input[, ValidateInputOptions])` to validate an input against an Actor's + input schema, matching the reference client. - Public version constants `Version.CLIENT_VERSION` and `Version.API_SPEC_VERSION`. - Forward-compatible models that capture unmodelled API fields in an `extra` map. - Offline unit tests (mock HTTP backend) covering retries, error parsing, 404→empty mapping, the diff --git a/docs/actors.md b/docs/actors.md index 0d1ee3c..72df502 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -47,6 +47,7 @@ Actor created = client.actors().create(Map.of( | `delete()` | Delete the Actor. | | `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Returns `ActorRun`. | | `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely). Returns `ActorRun`. | +| `validateInput(Object input)` / `validateInput(Object input, ValidateInputOptions)` | Validate an input against the Actor's input schema. Returns `boolean`. `ValidateInputOptions` fields (optional): `build`, `contentType`. | | `build(String versionNumber, ActorBuildOptions)` | Build a version. Returns `Build`. | | `defaultBuild(Long waitForFinish)` | Resolve the default build. Returns `BuildClient`. | | `lastRun(String status)` / `lastRun(LastRunOptions)` | A `RunClient` for the last run. | diff --git a/src/main/java/com/apify/client/ActorClient.java b/src/main/java/com/apify/client/ActorClient.java index ac968f1..9e68fc0 100644 --- a/src/main/java/com/apify/client/ActorClient.java +++ b/src/main/java/com/apify/client/ActorClient.java @@ -1,5 +1,6 @@ package com.apify.client; +import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; /** @@ -64,6 +65,30 @@ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { return root.run(run.getId()).waitForFinish(waitSecs); } + /** Validates the given input against the Actor's default-build input schema. */ + public boolean validateInput(Object input) { + return validateInput(input, new ValidateInputOptions()); + } + + /** + * Validates {@code input} against the Actor's input schema and returns whether it is valid. + * {@code input} is any JSON-serializable value (or {@code null}). {@code options} may pin the + * build whose schema is used and the content type of the input body. + */ + public boolean validateInput(Object input, ValidateInputOptions options) { + ValidateInputOptions opts = options == null ? new ValidateInputOptions() : options; + QueryParams params = new QueryParams(); + opts.apply(params); + byte[] body = input == null ? null : Json.toBytes(input); + // The validate-input endpoint returns a bare {"valid": } object, not the standard + // {"data": ...} envelope, so parse it without unwrapping. + JsonNode result = + ctx.postWithBodyNoEnvelope( + "validate-input", params, body, opts.contentTypeOrDefault(), JsonNode.class); + JsonNode valid = result == null ? null : result.get("valid"); + return valid != null && valid.asBoolean(); + } + /** Builds the given version of the Actor and returns the created build. */ public Build build(String versionNumber, ActorBuildOptions options) { QueryParams params = new QueryParams(); diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/ResourceContext.java index 0d095ac..32ce96f 100644 --- a/src/main/java/com/apify/client/ResourceContext.java +++ b/src/main/java/com/apify/client/ResourceContext.java @@ -189,6 +189,18 @@ T postWithBody( return Json.parseData(resp.body, dataType); } + /** + * POST with a raw body, parsing the response body directly without unwrapping a {@code + * {"data": ...}} envelope. Used by endpoints (e.g. actor input validation) whose response is a + * plain object rather than the standard data envelope. + */ + T postWithBodyNoEnvelope( + String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = http.call("POST", u, body, contentType, DEFAULT_REQUEST_TIMEOUT); + return Json.parse(resp.body, dataClass); + } + /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); diff --git a/src/main/java/com/apify/client/ValidateInputOptions.java b/src/main/java/com/apify/client/ValidateInputOptions.java new file mode 100644 index 0000000..f0ead2a --- /dev/null +++ b/src/main/java/com/apify/client/ValidateInputOptions.java @@ -0,0 +1,32 @@ +package com.apify.client; + +/** + * Configures {@link ActorClient#validateInput(Object, ValidateInputOptions)}. All fields are + * optional. + */ +public final class ValidateInputOptions { + private String build; + private String contentType; + + /** The tag or number of the build whose input schema is used for validation. */ + public ValidateInputOptions build(String build) { + this.build = build; + return this; + } + + /** The content type of the input body. Defaults to {@code application/json}. */ + public ValidateInputOptions contentType(String contentType) { + this.contentType = contentType; + return this; + } + + String contentTypeOrDefault() { + return (contentType != null && !contentType.isEmpty()) + ? contentType + : ResourceContext.CONTENT_TYPE_JSON; + } + + void apply(QueryParams q) { + q.addString("build", build); + } +} diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java index a290d6f..a6f93af 100644 --- a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -108,6 +108,15 @@ void actorVersionCrudFlow() { } } + @Test + void validateInput() { + ApifyClient client = requireClient(); + // apify/hello-world is a public store Actor; validate-input is read-only and returns + // {"valid": }. A well-formed input validates true. + boolean valid = client.actor("apify/hello-world").validateInput(Map.of("firstNumber", 1)); + assertTrue(valid); + } + @Test void actorEnvVarCrudFlow() { ApifyClient client = requireClient(); From 932278195bfd7a82a24707508af4ce0d8ee75bca Mon Sep 17 00:00:00 2001 From: apify-bot Date: Fri, 3 Jul 2026 22:55:38 +0000 Subject: [PATCH 03/12] chore: sync Java client to spec v2-2026-07-02T131926Z and address review - Bump API_SPEC_VERSION to v2-2026-07-02T131926Z (version-only spec sync; in-scope typed surface unchanged, validateInput confirmed in JS reference scope). - Publishing workflow: use GitHub environment `Publishing` with the mandated Maven Central secret names + credential fail-fast guard. - Review fixes: single-source per-attempt request timeout, batchAddRequests rethrows non-retryable 4xx, store iterator handles empty/short pages, getOrCreate(name, schema) overloads, propagate last-run status/origin to nested storage/log accessors, CI failIfNoTests, docs and CHANGELOG fixes, and additional offline unit tests. --- .github/workflows/java-integration-tests.yml | 6 +- .github/workflows/java-publish.yml | 48 ++++++-- CHANGELOG.md | 40 +++--- README.md | 15 +-- docs/README.md | 11 +- docs/examples.md | 5 + pom.xml | 5 +- .../java/com/apify/client/DatasetClient.java | 19 ++- .../apify/client/DatasetCollectionClient.java | 12 +- .../java/com/apify/client/HttpClientCore.java | 21 +++- .../com/apify/client/KeyValueStoreClient.java | 11 +- .../client/KeyValueStoreCollectionClient.java | 12 +- src/main/java/com/apify/client/LogClient.java | 7 +- .../com/apify/client/RequestQueueClient.java | 67 ++++++++-- .../com/apify/client/ResourceContext.java | 48 +++++--- .../java/com/apify/client/RetryConfig.java | 6 +- src/main/java/com/apify/client/RunClient.java | 14 ++- .../apify/client/StoreCollectionClient.java | 10 +- .../java/com/apify/client/TaskClient.java | 2 +- .../java/com/apify/client/UserClient.java | 2 +- src/main/java/com/apify/client/Version.java | 2 +- .../java/com/apify/client/MockBackend.java | 72 ++++++++++- .../com/apify/client/ReviewFixesTest.java | 116 ++++++++++++++++++ .../integration/UserIntegrationTest.java | 7 ++ 24 files changed, 452 insertions(+), 106 deletions(-) diff --git a/.github/workflows/java-integration-tests.yml b/.github/workflows/java-integration-tests.yml index 5bc57a0..51160d9 100644 --- a/.github/workflows/java-integration-tests.yml +++ b/.github/workflows/java-integration-tests.yml @@ -47,7 +47,7 @@ jobs: # Offline unit tests (mock HTTP backend): prove the retry/error/signature logic without the API. - name: Unit tests - run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=false + run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=true # Fail fast if the integration-test secret is missing or empty. Without this guard the # integration tests silently "pass" (JUnit assumptions skip them when APIFY_TOKEN is unset), @@ -66,7 +66,7 @@ jobs: # The integration-test token is stored as a repository secret. APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} # Run every test except the example/doc-snippet harnesses (exercised by the step below). - run: mvn -B test -Dtest='!ExamplesTest,!DocSnippetsTest' -DfailIfNoTests=false + run: mvn -B test -Dtest='!ExamplesTest,!DocSnippetsTest' -DfailIfNoTests=true # Standalone CI step that verifies the documentation examples actually work end-to-end against # the live API (ExamplesTest runs each example's main), and that every in-documentation Java @@ -74,4 +74,4 @@ jobs: - name: Test examples env: APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} - run: mvn -B test -Dtest='ExamplesTest,DocSnippetsTest' -DfailIfNoTests=false + run: mvn -B test -Dtest='ExamplesTest,DocSnippetsTest' -DfailIfNoTests=true diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index a9f6c5e..4ad8af1 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -19,11 +19,13 @@ concurrency: permissions: contents: write # create the tagged GitHub release - id-token: write # OIDC Trusted Publishing exchange with the Sonatype Central Portal jobs: publish: runs-on: ubuntu-latest + # The Maven Central repository credentials live in this protected GitHub environment, so the + # publish job can only read them here (and any environment protection rules gate the release). + environment: Publishing steps: - name: Checkout uses: actions/checkout@v4 @@ -44,11 +46,12 @@ jobs: distribution: temurin java-version: '17' cache: maven - # Provision a Maven settings.xml with the Central Portal server credentials. All secrets - # are read from repository secrets; nothing is stored in the repo. + # Write a Maven settings.xml whose "central" server reads its username/password from the + # named environment variables at deploy time (populated from the "Publishing" + # environment's secrets in the publish step). Nothing is stored in the repo. server-id: central - server-username: CENTRAL_USERNAME - server-password: CENTRAL_PASSWORD + server-username: MAVEN_CENTRAL_REPOSITORY_USERNAME + server-password: MAVEN_CENTRAL_REPOSITORY_PASSWORD gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} gpg-passphrase: MAVEN_GPG_PASSPHRASE @@ -60,7 +63,7 @@ jobs: run: mvn -B -DskipTests compile spotbugs:check - name: Unit tests - run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=false + run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=true - name: Resolve version from pom.xml id: version @@ -82,15 +85,36 @@ jobs: exit 1 fi - # Build, sign and publish to Maven Central via the Sonatype Central Publisher Portal. - # The Central Portal supports OIDC Trusted Publishing (id-token above); the token exchange is - # performed by the central-publishing-maven-plugin, with the CENTRAL_* / GPG secrets as the - # credential fallback. All values come from repository secrets. + # Verify the "Publishing" environment holds the three Maven Central repository secrets that the + # project's publishing policy mandates, so a release cannot silently proceed with a + # mis-provisioned environment. The mvn deploy below authenticates the "central" server with the + # username/password pair; the base64 form is the same credential pre-encoded as the Central + # Portal bearer token (username:password), kept in the environment per policy for tooling that + # talks to the Central Portal API directly. + - name: Require Maven Central credentials + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + MAVEN_CENTRAL_REPOSITORY_USERNAME: ${{ secrets.MAVEN_CENTRAL_REPOSITORY_USERNAME }} + MAVEN_CENTRAL_REPOSITORY_PASSWORD: ${{ secrets.MAVEN_CENTRAL_REPOSITORY_PASSWORD }} + MAVEN_CENTRAL_REPOSITORY_USERNAME_PASSWORD_BASE64: ${{ secrets.MAVEN_CENTRAL_REPOSITORY_USERNAME_PASSWORD_BASE64 }} + run: | + missing="" + [ -z "${MAVEN_CENTRAL_REPOSITORY_USERNAME}" ] && missing="${missing} MAVEN_CENTRAL_REPOSITORY_USERNAME" + [ -z "${MAVEN_CENTRAL_REPOSITORY_PASSWORD}" ] && missing="${missing} MAVEN_CENTRAL_REPOSITORY_PASSWORD" + [ -z "${MAVEN_CENTRAL_REPOSITORY_USERNAME_PASSWORD_BASE64}" ] && missing="${missing} MAVEN_CENTRAL_REPOSITORY_USERNAME_PASSWORD_BASE64" + if [ -n "${missing}" ]; then + echo "::error::Missing Publishing environment secret(s):${missing}" + exit 1 + fi + + # Build, sign and publish to Maven Central via the Sonatype Central Publisher Portal. The + # central-publishing-maven-plugin authenticates the "central" server with the username/password + # provisioned by setup-java above; both come from the "Publishing" environment's secrets. - name: Publish to Maven Central if: ${{ github.event.inputs.dry_run != 'true' }} env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_CENTRAL_REPOSITORY_USERNAME: ${{ secrets.MAVEN_CENTRAL_REPOSITORY_USERNAME }} + MAVEN_CENTRAL_REPOSITORY_PASSWORD: ${{ secrets.MAVEN_CENTRAL_REPOSITORY_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} run: mvn -B -Prelease -DskipTests deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a8224..f06e082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.0] - 2026-07-03 Initial release of the official (experimental, AI-generated and AI-maintained) Java client for the -Apify API, verified against OpenAPI specification version `v2-2026-07-01T115402Z`. +Apify API, verified against OpenAPI specification version `v2-2026-07-02T131926Z`. ### Added @@ -35,29 +35,19 @@ Apify API, verified against OpenAPI specification version `v2-2026-07-01T115402Z parameters via `DatasetDownloadOptions.items(...)`. - `actor(...).validateInput(input[, ValidateInputOptions])` to validate an input against an Actor's input schema, matching the reference client. +- `datasets()`/`keyValueStores()` `getOrCreate(name, schema)` overloads that send a creation schema, + matching the reference client. +- `batchAddRequests` surfaces a non-retryable client error (a 4xx other than 429) as an + `ApifyApiException`; persistent rate-limit/server failures are returned as `unprocessedRequests`. +- Read-only nested webhook collections (`actor(id).webhooks()`, `task(id).webhooks()`); `create(...)` + is exposed only on the account-wide `client.webhooks()`, matching the GET-only nested API. +- Single-resource getters return an empty `Optional` when the API responds `200` with `{"data": null}`. - Public version constants `Version.CLIENT_VERSION` and `Version.API_SPEC_VERSION`. - Forward-compatible models that capture unmodelled API fields in an `extra` map. -- Offline unit tests (mock HTTP backend) covering retries, error parsing, 404→empty mapping, the - User-Agent format, base-URL resolution, and the storage-signature scheme (pinned to the upstream - `@apify/utilities` algorithm with a known-answer test). -- Integration test suite (one simple GET plus one CRUD/complex flow per resource) and runnable, - CI-tested documentation examples. -- Language-specific GitHub Actions workflows: `Java integration tests` (Spotless format check, - build, unit tests, integration tests, and a standalone `Test examples` step) and a manually - triggered `Publish Java client` workflow that releases to Maven Central via the Sonatype Central - Publisher Portal (OIDC Trusted Publisher) and creates a tagged GitHub release. - -### Fixed - -- Nested webhook collections (`actor(id).webhooks()`, `task(id).webhooks()`) are now read-only - (`NestedWebhookCollectionClient`); `create(...)` is exposed only on the account-wide - `client.webhooks()`, matching the API (those nested endpoints are GET-only). -- `store().iterate(options)` no longer mutates the caller's `StoreListOptions` and now honors its - initial `offset`. -- `waitForFinish` clamps the server-side wait to the configured request timeout, so a short client - timeout no longer aborts every poll. -- Single-resource getters return an empty `Optional` (instead of throwing) when the API responds - `200` with `{"data": null}`. -- `RunCollectionClient.list` tolerates a `null` options/filter argument. -- `RunChargeOptions.eventName` is validated before a charge request is sent. -- `KeyValueStoreRecord` defensively copies its byte payload on the way in and out. +- Offline unit tests (mock HTTP backend) and an integration test suite (one simple GET plus one + CRUD/complex flow per resource), with runnable, CI-tested documentation examples. +- Language-specific GitHub Actions workflows: `Java integration tests` (Spotless, SpotBugs, build, + unit, integration, and a standalone `Test examples` step) and a manually triggered `Publish Java + client` workflow that releases to Maven Central via the Sonatype Central Publisher Portal, + authenticating with the Maven Central repository credentials from the protected `Publishing` + GitHub environment, and creates a tagged GitHub release. diff --git a/README.md b/README.md index fe2b8ec..bdf6f5c 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ implementation 'com.apify:apify-client:0.1.0' ApifyClient client = ApifyClient.create("my-api-token"); // Start an Actor and wait for it to finish. The last argument is the wait budget in seconds; -// pass a value (e.g. 120L) to bound the wait, or null to wait indefinitely (as here). -ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), null); +// pass a value (e.g. 120L) to bound the wait, or null to wait indefinitely. +ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); // Read items from the run's default dataset. PaginationList items = @@ -119,7 +119,7 @@ try { - `Version.CLIENT_VERSION` — the semantic version of this client (`0.1.0`). - `Version.API_SPEC_VERSION` — the Apify OpenAPI specification version this client was verified - against (`v2-2026-07-01T115402Z`). + against (`v2-2026-07-02T131926Z`). Changes to the public interface other than additive ones are considered breaking changes and follow [Semantic Versioning](https://semver.org/). @@ -127,10 +127,11 @@ Changes to the public interface other than additive ones are considered breaking ### Releasing Releases are published to Maven Central through the Sonatype Central Publisher Portal by the -manually-triggered `Publish Java client` GitHub Actions workflow. The workflow authenticates to the -portal using an OIDC **Trusted Publisher** exchange (no long-lived registry password stored in the -repo), signs the artifacts with GPG, publishes the `com.apify:apify-client` artifact, and creates a -tagged GitHub release. The release version is taken from the `` in `pom.xml`. +manually-triggered `Publish Java client` GitHub Actions workflow. The workflow runs in the +protected `Publishing` GitHub environment and authenticates to the portal with the Maven Central +repository credentials held there, signs the artifacts with GPG, publishes the +`com.apify:apify-client` artifact, and creates a tagged GitHub release. The release version is taken +from the `` in `pom.xml`. ## Scope diff --git a/docs/README.md b/docs/README.md index cf8223b..d390635 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,11 +39,12 @@ transitive dependency of this client, so it is already on your classpath. A few methods return data whose shape is not modelled by this client and is instead exposed as a Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): -- Read: `me().monthlyUsage(...)`, `me().limits()`, - `task(id).getInput()`/`updateInput(...)`, `build(id).getOpenApiDefinition()`, - `dataset(id).getStatistics()`, and the raw request-queue operations (`listRequests`, - `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, `batchDeleteRequests`). -- Write: definition/`update`/`create` arguments accept any JSON-serializable value — a `Map`, a +- Read: `me().monthlyUsage(...)`, `me().limits()`, `task(id).getInput()`, + `build(id).getOpenApiDefinition()`, `dataset(id).getStatistics()`, and the raw request-queue + operations (`listRequests`, `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, + `batchDeleteRequests`). +- Write: `task(id).updateInput(...)` and `me().updateLimits(...)` accept an arbitrary + JSON-serializable value, as do definition/`update`/`create` arguments generally — a `Map`, a `JsonNode`, or your own POJO. Navigate a `JsonNode` with `node.get("field")`, `node.path("a").asText()`, etc. diff --git a/docs/examples.md b/docs/examples.md index 4d30cc4..9e5eaaf 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -31,6 +31,11 @@ Optional record = client.keyValueStore(store.getId()).getRe RequestQueue queue = client.requestQueues().getOrCreate("example-rq"); client.requestQueue(queue.getId()).addRequest(new RequestQueueRequest("https://example.com", "example"), false); RequestQueueHead head = client.requestQueue(queue.getId()).listHead(10L); + +// Named storages persist on your account; delete them when done so the example does not leak them. +client.dataset(dataset.getId()).delete(); +client.keyValueStore(store.getId()).delete(); +client.requestQueue(queue.getId()).delete(); ``` ## Get own account details diff --git a/pom.xml b/pom.xml index 12eba97..1d08b2b 100644 --- a/pom.xml +++ b/pom.xml @@ -130,8 +130,9 @@ + to Maven Central via the Sonatype Central Publisher Portal. The publish workflow + authenticates the "central" server with the Maven Central repository credentials from + the "Publishing" GitHub environment. Activated by the publish CI workflow with -Prelease. --> release diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/DatasetClient.java index 6a6fde0..d4d1dd3 100644 --- a/src/main/java/com/apify/client/DatasetClient.java +++ b/src/main/java/com/apify/client/DatasetClient.java @@ -22,7 +22,14 @@ private DatasetClient(HttpClientCore http, ResourceContext ctx) { /** Creates a dataset client for a run's default dataset (nested path only, no ID). */ static DatasetClient nested(HttpClientCore http, String base, String subPath) { - return new DatasetClient(http, ResourceContext.collection(http, base, subPath)); + return nested(http, base, subPath, null); + } + + /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ + static DatasetClient nested( + HttpClientCore http, String base, String subPath, QueryParams inherited) { + return new DatasetClient( + http, ResourceContext.collection(http, base, subPath).seedParams(inherited)); } DatasetClient withPublicBase(String publicBaseUrl) { @@ -63,8 +70,8 @@ public PaginationList listItems(DatasetListItemsOptions options) { public PaginationList listItems(DatasetListItemsOptions options, Class itemClass) { QueryParams params = new QueryParams(); options.apply(params); - String url = params.applyToUrl(ctx.subUrl("items")); - ApiResponse resp = http.call("GET", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); + ApiResponse resp = http.call("GET", url, null, "", http.baseRequestTimeout()); JavaType listType = Json.parametric(List.class, Json.type(itemClass)); List items = Json.parse(resp.body, listType); @@ -91,8 +98,8 @@ public byte[] downloadItems(DownloadItemsFormat format, DatasetDownloadOptions o QueryParams params = new QueryParams(); params.addString("format", format.wireValue()); options.apply(params); - String url = params.applyToUrl(ctx.subUrl("items")); - ApiResponse resp = http.call("GET", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); + ApiResponse resp = http.call("GET", url, null, "", http.baseRequestTimeout()); return resp.body; } @@ -106,7 +113,7 @@ public void pushItems(Object items) { ctx.subUrl("items"), Json.toBytes(items), ResourceContext.CONTENT_TYPE_JSON_CHARSET, - ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.baseRequestTimeout()); } /** Returns statistical information about the dataset, or empty if unavailable. */ diff --git a/src/main/java/com/apify/client/DatasetCollectionClient.java b/src/main/java/com/apify/client/DatasetCollectionClient.java index b2a28b1..6c7972f 100644 --- a/src/main/java/com/apify/client/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/DatasetCollectionClient.java @@ -20,6 +20,16 @@ public PaginationList list(StorageListOptions options) { * name creates a new unnamed dataset. */ public Dataset getOrCreate(String name) { - return ctx.getOrCreateNamed(name, Dataset.class); + return getOrCreate(name, null); + } + + /** + * Gets the dataset with the given name, creating it if it does not exist, applying the given + * {@code schema} on creation. Mirrors the reference client's {@code getOrCreate(name, {schema})}; + * a {@code null} schema behaves like {@link #getOrCreate(String)}. + */ + public Dataset getOrCreate(String name, Object schema) { + Object body = schema == null ? null : java.util.Collections.singletonMap("schema", schema); + return ctx.getOrCreateNamed(name, body, Dataset.class); } } diff --git a/src/main/java/com/apify/client/HttpClientCore.java b/src/main/java/com/apify/client/HttpClientCore.java index 2241caf..54ce7f5 100644 --- a/src/main/java/com/apify/client/HttpClientCore.java +++ b/src/main/java/com/apify/client/HttpClientCore.java @@ -49,11 +49,21 @@ HttpBackend backend() { return backend; } - /** The configured overall per-request timeout budget, in whole seconds. */ + /** The configured maximum per-attempt request timeout, in whole seconds. */ long requestTimeoutSeconds() { return retry.timeout.getSeconds(); } + /** + * The default per-attempt request timeout used as the base for standard API calls. It is the + * client's configured timeout (single source of truth), so a caller-configured timeout applies to + * the first attempt too. Individual calls may pass a smaller base (e.g. key-value-store uploads), + * which then grows back up toward this value on retries. + */ + Duration baseRequestTimeout() { + return retry.timeout; + } + /** Sends a request with auth, User-Agent and the retry policy applied. */ ApiResponse call( String method, String url, byte[] body, String contentType, Duration baseTimeout) { @@ -176,9 +186,12 @@ private ApiResponse doAttempt( } /** - * Returns {@code min(overall, base * 2^(attempt-1))}. The first attempt uses the per-endpoint - * base timeout; each retry doubles it (so a slow-but-progressing connection gets more time) while - * never exceeding the overall budget. + * Returns {@code min(configured, base * 2^(attempt-1))}: the per-attempt socket timeout. The + * first attempt uses the per-call base timeout; each retry doubles it (so a slow-but-progressing + * connection gets more time) while never exceeding the configured maximum. This mirrors the + * reference client's per-try timeout model ({@code Math.min(timeoutMillis, base * + * 2**(attempt-1))}). Note this bounds each individual attempt, not the cumulative wall-clock time + * across all retries. */ private Duration attemptTimeout(Duration base, int attempt) { Duration scaled = base; diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/KeyValueStoreClient.java index 713207d..45e989c 100644 --- a/src/main/java/com/apify/client/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreClient.java @@ -16,7 +16,14 @@ private KeyValueStoreClient(ResourceContext ctx) { /** Creates a client for a run's default key-value store (nested path only, no ID). */ static KeyValueStoreClient nested(HttpClientCore http, String base, String subPath) { - return new KeyValueStoreClient(ResourceContext.collection(http, base, subPath)); + return nested(http, base, subPath, null); + } + + /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ + static KeyValueStoreClient nested( + HttpClientCore http, String base, String subPath, QueryParams inherited) { + return new KeyValueStoreClient( + ResourceContext.collection(http, base, subPath).seedParams(inherited)); } KeyValueStoreClient withPublicBase(String publicBaseUrl) { @@ -84,7 +91,7 @@ public void setRecord(String key, byte[] value, String contentType, SetRecordOpt java.time.Duration timeout = options.timeoutSecsValue() != null ? java.time.Duration.ofSeconds(options.timeoutSecsValue()) - : ResourceContext.DEFAULT_REQUEST_TIMEOUT; + : ctx.http.baseRequestTimeout(); ctx.putRaw( "records/" + ResourceContext.encodePathSegment(key), new QueryParams(), diff --git a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java index e49382d..63e2b29 100644 --- a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java @@ -20,6 +20,16 @@ public PaginationList list(StorageListOptions options) { * name creates a new unnamed store. */ public KeyValueStore getOrCreate(String name) { - return ctx.getOrCreateNamed(name, KeyValueStore.class); + return getOrCreate(name, null); + } + + /** + * Gets the store with the given name, creating it if it does not exist, applying the given {@code + * schema} on creation. Mirrors the reference client's {@code getOrCreate(name, {schema})}; a + * {@code null} schema behaves like {@link #getOrCreate(String)}. + */ + public KeyValueStore getOrCreate(String name, Object schema) { + Object body = schema == null ? null : java.util.Collections.singletonMap("schema", schema); + return ctx.getOrCreateNamed(name, body, KeyValueStore.class); } } diff --git a/src/main/java/com/apify/client/LogClient.java b/src/main/java/com/apify/client/LogClient.java index 4d50820..2b2624f 100644 --- a/src/main/java/com/apify/client/LogClient.java +++ b/src/main/java/com/apify/client/LogClient.java @@ -23,7 +23,12 @@ private LogClient(ResourceContext ctx) { /** Creates a log client for a run's or build's nested log endpoint (e.g. {@code .../log}). */ static LogClient nested(HttpClientCore http, String base) { - return new LogClient(ResourceContext.collection(http, base, "log")); + return nested(http, base, null); + } + + /** As {@link #nested(HttpClientCore, String)} but inheriting parent query params. */ + static LogClient nested(HttpClientCore http, String base, QueryParams inherited) { + return new LogClient(ResourceContext.collection(http, base, "log").seedParams(inherited)); } /** Fetches the entire log as text, or empty if the log does not exist. */ diff --git a/src/main/java/com/apify/client/RequestQueueClient.java b/src/main/java/com/apify/client/RequestQueueClient.java index ca73d18..5352e11 100644 --- a/src/main/java/com/apify/client/RequestQueueClient.java +++ b/src/main/java/com/apify/client/RequestQueueClient.java @@ -21,6 +21,20 @@ public final class RequestQueueClient { /** The API limit on requests per batch call; larger inputs are split into chunks of this size. */ private static final int MAX_REQUESTS_PER_BATCH = 25; + /** + * Upper bound on the unprocessed-requests backoff exponent, so {@code 2^attempt} cannot blow up. + */ + private static final int MAX_BACKOFF_EXPONENT = 10; + + /** Lowest client-error status; statuses in {@code [400, 500)} are client errors. */ + private static final int MIN_CLIENT_ERROR_STATUS = 400; + + /** Lowest server-error status; statuses at or above this are server errors. */ + private static final int MIN_SERVER_ERROR_STATUS = 500; + + /** Rate-limit status; retryable, so it is not treated as a hard client error. */ + private static final int RATE_LIMIT_STATUS = 429; + private final HttpClientCore http; private final ResourceContext ctx; private final String clientKey; @@ -37,7 +51,14 @@ private RequestQueueClient(HttpClientCore http, ResourceContext ctx, String clie /** Creates a client for a run's default request queue (nested path only, no ID). */ static RequestQueueClient nested(HttpClientCore http, String base, String subPath) { - return new RequestQueueClient(http, ResourceContext.collection(http, base, subPath), null); + return nested(http, base, subPath, null); + } + + /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ + static RequestQueueClient nested( + HttpClientCore http, String base, String subPath, QueryParams inherited) { + return new RequestQueueClient( + http, ResourceContext.collection(http, base, subPath).seedParams(inherited), null); } /** @@ -121,7 +142,7 @@ public RequestQueueOperationInfo updateRequest(RequestQueueRequest request, bool url, Json.toBytes(request), ResourceContext.CONTENT_TYPE_JSON, - ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.baseRequestTimeout()); return Json.parseData(resp.body, RequestQueueOperationInfo.class); } @@ -132,7 +153,7 @@ public void deleteRequest(String id) { ctx.mergedParams(params) .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id))); try { - http.call("DELETE", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.call("DELETE", url, null, "", http.baseRequestTimeout()); } catch (ApifyApiException e) { if (!ResourceContext.isNotFound(e)) { throw e; @@ -167,6 +188,13 @@ public BatchAddResult batchAddRequests(List requests, boole * requests the API leaves unprocessed (typically rate-limited) are retried with exponential * backoff up to {@link BatchAddRequestsOptions#maxUnprocessedRequestsRetries} times. The * per-chunk results are merged into a single {@link BatchAddResult}. + * + *

Requests that remain unprocessed after all retries (typically due to persistent + * rate-limiting or server errors) are returned in {@link BatchAddResult#getUnprocessedRequests()} + * rather than raising an exception. A non-retryable client error (a 4xx other than 429, such as + * an invalid token or insufficient permissions) is instead thrown as an {@link + * ApifyApiException}, since it will not succeed on retry and should not be silently hidden as + * "unprocessed". */ public BatchAddResult batchAddRequests( List requests, boolean forefront, BatchAddRequestsOptions options) { @@ -241,8 +269,15 @@ private BatchAddResult batchAddChunkWithRetries( break; } } catch (ApifyApiException e) { - // Transport did not (or was told not to) retry: stop and report everything not yet added as - // unprocessed so the call keeps its non-throwing signature. + // A non-retryable client error (bad token, insufficient permissions, invalid request) is a + // hard failure, not a transient one — surface it rather than hiding it as "unprocessed", + // where a caller could not tell it apart from ordinary rate-limiting. Rate-limit (429) and + // server (5xx) errors have already exhausted the transport's retries by this point; keep + // the + // non-throwing contract for those and report the remainder as unprocessed. + if (isNonRetryableClientError(e)) { + throw e; + } break; } if (attempt < maxRetries) { @@ -258,6 +293,18 @@ private BatchAddResult batchAddChunkWithRetries( return result; } + /** + * Reports whether an API error is a non-retryable client error (a 4xx other than 429). Such + * errors (e.g. bad token, insufficient permissions, invalid request) will not succeed on retry, + * so the batch helper surfaces them instead of masking them as unprocessed requests. + */ + private static boolean isNonRetryableClientError(ApifyApiException e) { + int status = e.getStatusCode(); + return status >= MIN_CLIENT_ERROR_STATUS + && status < MIN_SERVER_ERROR_STATUS + && status != RATE_LIMIT_STATUS; + } + private static List requestsNotYetProcessed( List chunk, List processed) { Set processedKeys = new HashSet<>(); @@ -280,8 +327,10 @@ private static void sleepBackoff(int attempt, long minDelayMillis) { return; } // (1 + random) * 2^attempt * minDelay — exponential backoff with jitter, matching the - // reference. - double factor = (1 + ThreadLocalRandom.current().nextDouble()) * Math.pow(2, attempt); + // reference. The exponent is capped so a pathologically large retry count cannot overflow the + // delay into an absurd (or negative, after the long cast) sleep. + int cappedAttempt = Math.min(attempt, MAX_BACKOFF_EXPONENT); + double factor = (1 + ThreadLocalRandom.current().nextDouble()) * Math.pow(2, cappedAttempt); long delayMillis = (long) Math.floor(factor * minDelayMillis); try { Thread.sleep(delayMillis); @@ -332,7 +381,7 @@ public JsonNode prolongRequestLock(String id, long lockSecs, boolean forefront) String url = ctx.mergedParams(params) .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); - ApiResponse resp = http.call("PUT", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + ApiResponse resp = http.call("PUT", url, null, "", http.baseRequestTimeout()); return Json.parseData(resp.body, Json.type(JsonNode.class)); } @@ -348,7 +397,7 @@ public void deleteRequestLock(String id, boolean forefront) { ctx.mergedParams(params) .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); try { - http.call("DELETE", url, null, "", ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.call("DELETE", url, null, "", http.baseRequestTimeout()); } catch (ApifyApiException e) { if (!ResourceContext.isNotFound(e)) { throw e; diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/ResourceContext.java index 32ce96f..e75cf4c 100644 --- a/src/main/java/com/apify/client/ResourceContext.java +++ b/src/main/java/com/apify/client/ResourceContext.java @@ -14,9 +14,6 @@ */ final class ResourceContext { - /** Per-request timeout applied to all API calls (6 minutes). Single source of truth. */ - static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(360); - static final String CONTENT_TYPE_JSON = "application/json"; static final String CONTENT_TYPE_JSON_CHARSET = "application/json; charset=utf-8"; @@ -105,6 +102,18 @@ QueryParams mergedParams(QueryParams params) { return baseParams.copy().extend(params); } + /** + * Seeds this context's inherited params from a parent context (e.g. so a last-run client's pinned + * {@code status}/{@code origin} filters carry into its nested storage/log accessors). No-op when + * {@code inherited} is null or empty, so ordinary nested clients are unaffected. + */ + ResourceContext seedParams(QueryParams inherited) { + if (inherited != null && !inherited.isEmpty()) { + baseParams.extend(inherited); + } + return this; + } + // ---- CRUD primitives ------------------------------------------------------ Optional getResource(String subPath, QueryParams params, JavaType dataType) { @@ -126,7 +135,7 @@ Optional getResource(String subPath, QueryParams params, Class dataCla T getResourceRequired(String subPath, QueryParams params, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - ApiResponse resp = http.call("GET", u, null, "", DEFAULT_REQUEST_TIMEOUT); + ApiResponse resp = http.call("GET", u, null, "", http.baseRequestTimeout()); return Json.parseData(resp.body, dataType); } @@ -137,7 +146,7 @@ T getResourceRequired(String subPath, QueryParams params, Class dataClass T updateResource(String subPath, Object body, Class dataClass) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); ApiResponse resp = - http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); return Json.parseData(resp.body, dataClass); } @@ -145,7 +154,7 @@ T updateResource(String subPath, Object body, Class dataClass) { void deleteResource(String subPath) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); try { - http.call("DELETE", u, null, "", DEFAULT_REQUEST_TIMEOUT); + http.call("DELETE", u, null, "", http.baseRequestTimeout()); } catch (ApifyApiException e) { if (!isNotFound(e)) { throw e; @@ -161,18 +170,29 @@ PaginationList listResource(String subPath, QueryParams params, Class T createResource(QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl("")); ApiResponse resp = - http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); return Json.parseData(resp.body, dataClass); } /** POST that gets-or-creates a named resource ({@code POST {collection}?name=...}). */ T getOrCreateNamed(String name, Class dataClass) { + return getOrCreateNamed(name, null, dataClass); + } + + /** + * POST that gets-or-creates a named resource, optionally sending a JSON request body (e.g. a + * storage {@code schema}). A {@code null} body sends no body, matching the plain get-or-create. + */ + T getOrCreateNamed(String name, Object body, Class dataClass) { QueryParams params = new QueryParams(); if (name != null && !name.isEmpty()) { params.addString("name", name); } String u = params.applyToUrl(subUrl("")); - ApiResponse resp = http.call("POST", u, null, "", DEFAULT_REQUEST_TIMEOUT); + byte[] bodyBytes = body == null ? null : Json.toBytes(body); + ApiResponse resp = + http.call( + "POST", u, bodyBytes, body == null ? "" : CONTENT_TYPE_JSON, http.baseRequestTimeout()); return Json.parseData(resp.body, dataClass); } @@ -185,7 +205,7 @@ T postWithBody( T postWithBody( String subPath, QueryParams params, byte[] body, String contentType, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - ApiResponse resp = http.call("POST", u, body, contentType, DEFAULT_REQUEST_TIMEOUT); + ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); return Json.parseData(resp.body, dataType); } @@ -197,7 +217,7 @@ T postWithBody( T postWithBodyNoEnvelope( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - ApiResponse resp = http.call("POST", u, body, contentType, DEFAULT_REQUEST_TIMEOUT); + ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); return Json.parse(resp.body, dataClass); } @@ -205,7 +225,7 @@ T postWithBodyNoEnvelope( T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = - http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, DEFAULT_REQUEST_TIMEOUT); + http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); return Json.parseData(resp.body, dataClass); } @@ -213,7 +233,7 @@ T deleteWithBody(String subPath, QueryParams params, Object body, Class d ApiResponse getRaw(String subPath, QueryParams params) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); try { - return http.call("GET", u, null, "", DEFAULT_REQUEST_TIMEOUT); + return http.call("GET", u, null, "", http.baseRequestTimeout()); } catch (ApifyApiException e) { if (isNotFound(e)) { return null; @@ -226,7 +246,7 @@ ApiResponse getRaw(String subPath, QueryParams params) { boolean headExists(String subPath, QueryParams params) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); try { - http.call("HEAD", u, null, "", DEFAULT_REQUEST_TIMEOUT); + http.call("HEAD", u, null, "", http.baseRequestTimeout()); return true; } catch (ApifyApiException e) { if (isNotFound(e)) { @@ -238,7 +258,7 @@ boolean headExists(String subPath, QueryParams params) { /** PUT with raw bytes and a content type (used for key-value-store record uploads). */ void putRaw(String subPath, QueryParams params, byte[] body, String contentType) { - putRaw(subPath, params, body, contentType, DEFAULT_REQUEST_TIMEOUT, false); + putRaw(subPath, params, body, contentType, http.baseRequestTimeout(), false); } /** diff --git a/src/main/java/com/apify/client/RetryConfig.java b/src/main/java/com/apify/client/RetryConfig.java index a3c263b..31c5ae2 100644 --- a/src/main/java/com/apify/client/RetryConfig.java +++ b/src/main/java/com/apify/client/RetryConfig.java @@ -13,7 +13,11 @@ final class RetryConfig { /** Upper bound on the (exponentially growing) inter-retry delay. */ final Duration maxDelayBetweenRetries; - /** Overall per-request timeout budget. Each attempt's timeout grows but is capped here. */ + /** + * Maximum per-attempt request (socket) timeout, and the default base timeout for a call. Each + * attempt's timeout may grow from a smaller per-call base but is capped at this value. This + * bounds a single attempt, not the cumulative time across all retries. + */ final Duration timeout; RetryConfig( diff --git a/src/main/java/com/apify/client/RunClient.java b/src/main/java/com/apify/client/RunClient.java index 914454f..14d6950 100644 --- a/src/main/java/com/apify/client/RunClient.java +++ b/src/main/java/com/apify/client/RunClient.java @@ -132,7 +132,7 @@ public void charge(RunChargeOptions options) { Json.toBytes(body), ResourceContext.CONTENT_TYPE_JSON, headers, - ResourceContext.DEFAULT_REQUEST_TIMEOUT); + ctx.http.baseRequestTimeout()); } /** @@ -160,27 +160,31 @@ public ActorRun waitForFinish(Long waitSecs) { /** A client for this run's default dataset. */ public DatasetClient dataset() { - return DatasetClient.nested(ctx.http, ctx.subUrl(""), "dataset"); + return DatasetClient.nested(ctx.http, ctx.subUrl(""), "dataset", ctx.baseParams); } /** A client for this run's default key-value store. */ public KeyValueStoreClient keyValueStore() { - return KeyValueStoreClient.nested(ctx.http, ctx.subUrl(""), "key-value-store"); + return KeyValueStoreClient.nested(ctx.http, ctx.subUrl(""), "key-value-store", ctx.baseParams); } /** A client for this run's default request queue. */ public RequestQueueClient requestQueue() { - return RequestQueueClient.nested(ctx.http, ctx.subUrl(""), "request-queue"); + return RequestQueueClient.nested(ctx.http, ctx.subUrl(""), "request-queue", ctx.baseParams); } /** A client for accessing this run's log. */ public LogClient log() { - return LogClient.nested(ctx.http, ctx.subUrl("")); + return LogClient.nested(ctx.http, ctx.subUrl(""), ctx.baseParams); } /** * Opens a live stream of this run's raw log, for convenient log redirection. The caller must * close the returned stream. + * + *

This is a shorthand for the common raw-log case. For full control over the streamed log + * (e.g. non-raw content or a download disposition), use {@link #log()}{@code .stream(options)} + * directly with a {@link LogOptions}. */ public InputStream getStreamedLog() { return log().stream(new LogOptions().raw(true)); diff --git a/src/main/java/com/apify/client/StoreCollectionClient.java b/src/main/java/com/apify/client/StoreCollectionClient.java index 78b58e3..2d43ca7 100644 --- a/src/main/java/com/apify/client/StoreCollectionClient.java +++ b/src/main/java/com/apify/client/StoreCollectionClient.java @@ -33,7 +33,6 @@ private final class StoreIterator implements Iterator { private List buffer = List.of(); private int pos; private long offset; - private long total; private boolean exhausted; StoreIterator(StoreListOptions options) { @@ -68,9 +67,14 @@ private void fetchPage() { PaginationList page = list(options); buffer = page.getItems(); pos = 0; - total = page.getTotal(); offset += page.getItems().size(); - if (page.getItems().isEmpty() || offset >= total) { + // Terminate on an empty page, or on a short page when a page size (limit) is known — a page + // returning fewer items than requested is the last one. We deliberately do NOT stop on + // `offset >= total`: a momentarily stale (under-reported) `total`, e.g. from read-replica + // lag right after a write, could otherwise cut iteration short while items remain. + Long limit = options.limitValue(); + boolean shortPage = limit != null && limit > 0 && page.getItems().size() < limit; + if (page.getItems().isEmpty() || shortPage) { exhausted = true; } } diff --git a/src/main/java/com/apify/client/TaskClient.java b/src/main/java/com/apify/client/TaskClient.java index d1350f2..2973113 100644 --- a/src/main/java/com/apify/client/TaskClient.java +++ b/src/main/java/com/apify/client/TaskClient.java @@ -73,7 +73,7 @@ public JsonNode updateInput(Object input) { ctx.subUrl("input"), Json.toBytes(input), ResourceContext.CONTENT_TYPE_JSON, - ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.baseRequestTimeout()); return Json.parse(resp.body, JsonNode.class); } diff --git a/src/main/java/com/apify/client/UserClient.java b/src/main/java/com/apify/client/UserClient.java index e25082e..192f02e 100644 --- a/src/main/java/com/apify/client/UserClient.java +++ b/src/main/java/com/apify/client/UserClient.java @@ -67,7 +67,7 @@ public void updateLimits(Object newLimits) { ctx.subUrl("limits"), Json.toBytes(newLimits), ResourceContext.CONTENT_TYPE_JSON, - ResourceContext.DEFAULT_REQUEST_TIMEOUT); + http.baseRequestTimeout()); } private void requireMe() { diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index 76305bf..6b3cebf 100644 --- a/src/main/java/com/apify/client/Version.java +++ b/src/main/java/com/apify/client/Version.java @@ -19,7 +19,7 @@ public final class Version { * The version of the Apify OpenAPI specification this client was generated and verified against. * Corresponds to the {@code info.version} field of the Apify OpenAPI document. */ - public static final String API_SPEC_VERSION = "v2-2026-07-01T115402Z"; + public static final String API_SPEC_VERSION = "v2-2026-07-02T131926Z"; private Version() {} } diff --git a/src/test/java/com/apify/client/MockBackend.java b/src/test/java/com/apify/client/MockBackend.java index 895236e..fb54c0d 100644 --- a/src/test/java/com/apify/client/MockBackend.java +++ b/src/test/java/com/apify/client/MockBackend.java @@ -85,9 +85,24 @@ public synchronized HttpResponse send(HttpRequest request) throws IOExce return new FakeResponse(request.uri(), r.status, r.body); } + /** + * Optional scripted response for {@link #sendStreaming}; defaults to the first {@code send} + * entry. + */ + private Scripted streamResponse; + + /** Scripts the next {@link #sendStreaming} call to return the given status and body. */ + void scriptStream(int status, String body) { + this.streamResponse = new Scripted(status, body, null); + } + @Override - public HttpResponse sendStreaming(HttpRequest request) { - throw new UnsupportedOperationException("streaming not used in unit tests"); + public synchronized HttpResponse sendStreaming(HttpRequest request) { + calls++; + lastUrl = request.uri().toString(); + Scripted r = streamResponse != null ? streamResponse : responses.get(0); + return new FakeStreamResponse( + request.uri(), r.status, new java.io.ByteArrayInputStream(r.body)); } private static String readBody(HttpRequest request) { @@ -183,4 +198,57 @@ public HttpClient.Version version() { return HttpClient.Version.HTTP_1_1; } } + + /** Minimal {@link HttpResponse} over an {@link InputStream} body, for streaming tests. */ + private static final class FakeStreamResponse implements HttpResponse { + private final URI uri; + private final int status; + private final InputStream body; + + FakeStreamResponse(URI uri, int status, InputStream body) { + this.uri = uri; + this.status = status; + this.body = body; + } + + @Override + public int statusCode() { + return status; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public InputStream body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return uri; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + } } diff --git a/src/test/java/com/apify/client/ReviewFixesTest.java b/src/test/java/com/apify/client/ReviewFixesTest.java index 2e21ec8..3c17be2 100644 --- a/src/test/java/com/apify/client/ReviewFixesTest.java +++ b/src/test/java/com/apify/client/ReviewFixesTest.java @@ -37,6 +37,39 @@ void chargeSendsIdempotencyKey() { assertTrue(backend.lastBody.contains("\"eventName\":\"my-event\""), backend.lastBody); } + @Test + void lastRunForwardsStatusFilterToNestedStorages() { + // A status/origin-filtered last-run client must forward those filters to its nested + // dataset/key-value-store/request-queue/log accessors, so they resolve the same run. + MockBackend ds = MockBackend.ofConstant(200, "[]"); + client(ds) + .actor("me/x") + .lastRun("SUCCEEDED") + .dataset() + .listItems(new DatasetListItemsOptions()); + assertTrue(ds.lastUrl.contains("runs/last/dataset/items"), ds.lastUrl); + assertTrue(ds.lastUrl.contains("status=SUCCEEDED"), ds.lastUrl); + + MockBackend log = MockBackend.ofConstant(200, "log-text"); + client(log) + .actor("me/x") + .lastRun(new LastRunOptions().status("SUCCEEDED").origin("API")) + .log() + .get(); + assertTrue(log.lastUrl.contains("runs/last/log"), log.lastUrl); + assertTrue(log.lastUrl.contains("status=SUCCEEDED"), log.lastUrl); + assertTrue(log.lastUrl.contains("origin=API"), log.lastUrl); + } + + @Test + void plainRunNestedStoragesCarryNoInheritedFilter() { + // A non-last-run client has no pinned params, so nested accessors stay filter-free. + MockBackend ds = MockBackend.ofConstant(200, "[]"); + client(ds).run("run123").dataset().listItems(new DatasetListItemsOptions()); + assertTrue(ds.lastUrl.contains("actor-runs/run123/dataset/items"), ds.lastUrl); + assertFalse(ds.lastUrl.contains("status="), ds.lastUrl); + } + @Test void chargeHonorsExplicitIdempotencyKey() { MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); @@ -288,4 +321,87 @@ void accountWebhookCollectionCanCreate() { assertEquals("wh1", created.getId()); assertTrue(backend.lastUrl.endsWith("/webhooks"), backend.lastUrl); } + + @Test + void updateLimitsSendsPutToMeLimits() { + MockBackend backend = MockBackend.ofConstant(200, "{}"); + client(backend).me().updateLimits(java.util.Map.of("maxMonthlyUsageUsd", 100)); + assertTrue(backend.lastUrl.endsWith("/users/me/limits"), backend.lastUrl); + assertTrue(backend.lastBody.contains("\"maxMonthlyUsageUsd\":100"), backend.lastBody); + assertEquals(1, backend.calls); + } + + @Test + void batchAddRequestsThrowsOnNonRetryableClientError() { + // A hard 4xx (e.g. bad token / insufficient permissions) must surface, not be masked as + // "unprocessed" — otherwise a caller cannot tell it apart from ordinary rate-limiting. + MockBackend backend = + MockBackend.ofConstant( + 403, "{\"error\":{\"type\":\"insufficient-permissions\",\"message\":\"no\"}}"); + ApifyApiException ex = + assertThrows( + ApifyApiException.class, + () -> + client(backend) + .requestQueue("q1") + .batchAddRequests( + List.of(new RequestQueueRequest("https://example.com", "k0")), + false, + new BatchAddRequestsOptions().maxUnprocessedRequestsRetries(0))); + assertEquals(403, ex.getStatusCode()); + } + + @Test + void batchAddRequestsRunsChunksInParallel() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"processedRequests\":[],\"unprocessedRequests\":[]}}"); + List requests = new ArrayList<>(); + for (int i = 0; i < 60; i++) { + requests.add(new RequestQueueRequest("https://example.com", "k" + i)); + } + client(backend) + .requestQueue("q1") + .batchAddRequests( + requests, + false, + new BatchAddRequestsOptions().maxParallel(3).maxUnprocessedRequestsRetries(0)); + assertEquals(3, backend.calls, "60 requests must be sent as 3 parallel chunks of 25/25/10"); + } + + @Test + void waitForFinishReturnsWhenResourceAppearsAfter404() { + // A just-started run can transiently 404 (replica lag); the wait must keep polling until it + // appears and reaches a terminal state rather than giving up on the first 404. + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"), + MockBackend.ok(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"))); + ActorRun run = client(backend).run("r1").waitForFinish(5L); + assertEquals("r1", run.getId()); + assertEquals("SUCCEEDED", run.getStatus()); + assertEquals(2, backend.calls, "one 404 poll then the terminal poll"); + } + + @Test + void waitForFinishThrowsWhenResourceNeverAppears() { + // If the resource is never fetchable within the budget, the wait must fail loudly rather than + // return a phantom result. + MockBackend backend = + MockBackend.ofConstant( + 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"); + assertThrows(IllegalStateException.class, () -> client(backend).run("r1").waitForFinish(0L)); + } + + @Test + void logStreamThrowsOnNonSuccessStatus() { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream( + 403, "{\"error\":{\"type\":\"insufficient-permissions\",\"message\":\"no\"}}"); + ApifyApiException ex = + assertThrows(ApifyApiException.class, () -> client(backend).log("run1").stream()); + assertEquals(403, ex.getStatusCode()); + } } diff --git a/src/test/java/com/apify/client/integration/UserIntegrationTest.java b/src/test/java/com/apify/client/integration/UserIntegrationTest.java index f64d2b9..94c25b1 100644 --- a/src/test/java/com/apify/client/integration/UserIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/UserIntegrationTest.java @@ -36,4 +36,11 @@ void getLimits() { JsonNode limits = client.me().limits(); assertTrue(limits != null && !limits.isEmpty()); } + + // Note: `me().updateLimits(...)` is intentionally NOT exercised by a live integration test. It + // mutates account-global state that cannot be isolated between the concurrent cross-language test + // runs the test-requirements mandate (unlike the per-run resources every other test creates), so + // a + // mutating test here would race those runs. Its request behaviour is covered offline instead by + // ReviewFixesTest#updateLimitsSendsPutToMeLimits. } From 1a6831fd592eddc8bcc96bff2494fe388404db02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 20:49:22 +0000 Subject: [PATCH 04/12] fix: route dataset pushItems through merged params; add standalone log test and doc fixes - DatasetClient.pushItems now applies ctx.mergedParams like every sibling, so a last-run-seeded context targets the same run's dataset on write as on read. - Add a direct GET /v2/logs/{id} assertion (client.log(id)) in BuildIntegrationTest. - README: state the import package (com.apify.client) and JsonNode/Duration origins. - docs/storages.md: document the getOrCreate(name, schema) overload for dataset and KVS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- README.md | 4 ++++ docs/storages.md | 4 +++- src/main/java/com/apify/client/DatasetClient.java | 5 ++++- .../com/apify/client/integration/BuildIntegrationTest.java | 4 ++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bdf6f5c..fb2e5bf 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ PaginationList items = System.out.println("Item count: " + items.getCount()); ``` +All public client types live in the `com.apify.client` package (e.g. `import com.apify.client.*;`). +The snippets also use `com.fasterxml.jackson.databind.JsonNode` (from the Jackson dependency) for +untyped data and `java.time.Duration` in the configuration examples below. + `ApifyClient.create` takes the token as an explicit argument — it does **not** read `APIFY_TOKEN` (or any other environment variable) automatically. Read it yourself if you want that, e.g. `ApifyClient.create(System.getenv("APIFY_TOKEN"))`. diff --git a/docs/storages.md b/docs/storages.md index 47ea699..326016c 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -13,6 +13,7 @@ default storages are reachable via `client.run(id).dataset()` / `.keyValueStore( |---|---| | `list(StorageListOptions)` | List datasets. Returns `PaginationList`. | | `getOrCreate(String name)` | Get or create a named dataset (empty name → unnamed). Returns `Dataset`. | +| `getOrCreate(String name, Object schema)` | As above, sending a creation-time dataset `schema` when a new dataset is created. Returns `Dataset`. | `StorageListOptions` adds `unnamed(Boolean)` and `ownership(String)` on top of offset/limit/desc. @@ -53,7 +54,8 @@ unsigned. ### `KeyValueStoreCollectionClient` — `client.keyValueStores()` -`list(StorageListOptions)` and `getOrCreate(String)`, as for datasets. +`list(StorageListOptions)`, `getOrCreate(String)`, and `getOrCreate(String, Object schema)` (the +latter sends a creation-time store `schema`), as for datasets. ### `KeyValueStoreClient` — `client.keyValueStore(id)` diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/DatasetClient.java index d4d1dd3..73bf4a3 100644 --- a/src/main/java/com/apify/client/DatasetClient.java +++ b/src/main/java/com/apify/client/DatasetClient.java @@ -108,9 +108,12 @@ public byte[] downloadItems(DownloadItemsFormat format, DatasetDownloadOptions o * array of objects. */ public void pushItems(Object items) { + // Route through mergedParams like every sibling method, so a context seeded with pinned filters + // (e.g. actor(id).lastRun(...).dataset()) targets the same run's dataset on write as on read. + String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("items")); http.call( "POST", - ctx.subUrl("items"), + url, Json.toBytes(items), ResourceContext.CONTENT_TYPE_JSON_CHARSET, http.baseRequestTimeout()); diff --git a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java index 8a2d28f..1642a64 100644 --- a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java @@ -1,5 +1,6 @@ package com.apify.client.integration; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.Actor; @@ -29,6 +30,9 @@ void buildActorFlow() { assertTrue(client.build(build.getId()).get().isPresent()); client.build(build.getId()).log().get(); + // Exercise the standalone log endpoint (GET /v2/logs/{buildOrRunId}) directly, not only via + // the build-nested .../log accessor, so the "simple GET per endpoint" rule is met for it. + assertNotNull(client.log(build.getId()).get()); client.build(build.getId()).getOpenApiDefinition(); } finally { client.actor(created.getId()).delete(); From a95b8736cb4adb8fd68a9f1946e80f6200d9677c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:16:43 +0000 Subject: [PATCH 05/12] fix: route run charge + task updateInput through merged params; doc/comment polish - RunClient.charge and TaskClient.updateInput now apply ctx.mergedParams like their siblings, so a seeded context targets the same run/task on write as on read (IT2-1). - README: import hint now also covers java.util.Optional/Map (IT2-2). - RequestQueueClient: rephrase batch-retry comment to wrap cleanly (IT2-3). - RunClient.getWithWait javadoc reworded to match clamp behaviour (IT2-4). - docs/examples.md: use the storage snippet locals so none are dead (IT2-5). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- README.md | 3 ++- docs/examples.md | 3 +++ .../java/com/apify/client/RequestQueueClient.java | 5 ++--- src/main/java/com/apify/client/RunClient.java | 12 ++++++++---- src/main/java/com/apify/client/TaskClient.java | 5 ++++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fb2e5bf..68bce1e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ System.out.println("Item count: " + items.getCount()); All public client types live in the `com.apify.client` package (e.g. `import com.apify.client.*;`). The snippets also use `com.fasterxml.jackson.databind.JsonNode` (from the Jackson dependency) for -untyped data and `java.time.Duration` in the configuration examples below. +untyped data, `java.time.Duration` in the configuration examples, and standard JDK types such as +`java.util.Optional` and `java.util.Map` (`import java.util.*;`). `ApifyClient.create` takes the token as an explicit argument — it does **not** read `APIFY_TOKEN` (or any other environment variable) automatically. Read it yourself if you want that, e.g. diff --git a/docs/examples.md b/docs/examples.md index 9e5eaaf..7593e6b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -21,16 +21,19 @@ System.out.println("Item count: " + items.getCount()); Dataset dataset = client.datasets().getOrCreate("example-ds"); client.dataset(dataset.getId()).pushItems(List.of(Map.of("hello", "world"))); PaginationList dsItems = client.dataset(dataset.getId()).listItems(new DatasetListItemsOptions()); +System.out.println("Dataset items: " + dsItems.getCount()); // Key-value store KeyValueStore store = client.keyValueStores().getOrCreate("example-kvs"); client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); Optional record = client.keyValueStore(store.getId()).getRecord("OUTPUT"); +record.ifPresent(r -> System.out.println("OUTPUT bytes: " + r.getValue().length)); // Request queue RequestQueue queue = client.requestQueues().getOrCreate("example-rq"); client.requestQueue(queue.getId()).addRequest(new RequestQueueRequest("https://example.com", "example"), false); RequestQueueHead head = client.requestQueue(queue.getId()).listHead(10L); +System.out.println("Queue head items: " + head.getItems().size()); // Named storages persist on your account; delete them when done so the example does not leak them. client.dataset(dataset.getId()).delete(); diff --git a/src/main/java/com/apify/client/RequestQueueClient.java b/src/main/java/com/apify/client/RequestQueueClient.java index 5352e11..c2d4f83 100644 --- a/src/main/java/com/apify/client/RequestQueueClient.java +++ b/src/main/java/com/apify/client/RequestQueueClient.java @@ -272,9 +272,8 @@ private BatchAddResult batchAddChunkWithRetries( // A non-retryable client error (bad token, insufficient permissions, invalid request) is a // hard failure, not a transient one — surface it rather than hiding it as "unprocessed", // where a caller could not tell it apart from ordinary rate-limiting. Rate-limit (429) and - // server (5xx) errors have already exhausted the transport's retries by this point; keep - // the - // non-throwing contract for those and report the remainder as unprocessed. + // server (5xx) errors have already exhausted the transport's retries by this point, so for + // those we keep the non-throwing contract and report the remainder as unprocessed. if (isNonRetryableClientError(e)) { throw e; } diff --git a/src/main/java/com/apify/client/RunClient.java b/src/main/java/com/apify/client/RunClient.java index 14d6950..374e77f 100644 --- a/src/main/java/com/apify/client/RunClient.java +++ b/src/main/java/com/apify/client/RunClient.java @@ -46,9 +46,10 @@ public Optional get() { } /** - * Fetches the run, optionally asking the API to wait up to {@code waitForFinishSecs} seconds (max - * 60) for the run to reach a terminal state before responding. Pass {@code null} for an immediate - * fetch. + * Fetches the run, optionally asking the API to wait up to {@code waitForFinishSecs} seconds for + * the run to reach a terminal state before responding. The value is clamped so the server always + * responds before the client's per-request timeout; the API itself caps server-side waiting at 60 + * seconds. Pass {@code null} for an immediate fetch. */ public Optional getWithWait(Long waitForFinishSecs) { QueryParams params = new QueryParams(); @@ -126,9 +127,12 @@ public void charge(RunChargeOptions options) { body.put("eventName", eventName); body.put("count", options.countValue()); Map headers = Map.of(CHARGE_IDEMPOTENCY_HEADER, idempotencyKey); + // Route through mergedParams like the run's other actions, so a last-run-seeded context charges + // the same run its read methods resolve to (its pinned status/origin filters are preserved). + String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("charge")); ctx.http.callWithHeaders( "POST", - ctx.subUrl("charge"), + url, Json.toBytes(body), ResourceContext.CONTENT_TYPE_JSON, headers, diff --git a/src/main/java/com/apify/client/TaskClient.java b/src/main/java/com/apify/client/TaskClient.java index 2973113..5849be0 100644 --- a/src/main/java/com/apify/client/TaskClient.java +++ b/src/main/java/com/apify/client/TaskClient.java @@ -67,10 +67,13 @@ public Optional getInput() { /** Replaces the task's stored input and returns the updated input. */ public JsonNode updateInput(Object input) { + // Route through mergedParams like the task's other calls, so any inherited params are + // preserved. + String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("input")); ApiResponse resp = http.call( "PUT", - ctx.subUrl("input"), + url, Json.toBytes(input), ResourceContext.CONTENT_TYPE_JSON, http.baseRequestTimeout()); From fe64631884b02fc21de761b877c8457deae6857c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:38:23 +0000 Subject: [PATCH 06/12] fix: clamp defaultBuild wait, align updateLimits, doc completeness nits - ActorClient.defaultBuild clamps waitForFinish via clampServerWait like the twins (IT3-3). - UserClient.updateLimits routes through mergedParams for call-site uniformity (IT3-2). - BuildClient.getWithWait javadoc reworded to match RunClient twin (IT3-1). - docs/storages.md: add storage metadata-model field table; add missing return types (IT3-4/5). - docs/runs.md: add return types for metamorph and charge (IT3-5). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- docs/runs.md | 4 ++-- docs/storages.md | 18 +++++++++++++++--- .../java/com/apify/client/ActorClient.java | 4 +++- .../java/com/apify/client/BuildClient.java | 4 +++- src/main/java/com/apify/client/UserClient.java | 5 ++++- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/runs.md b/docs/runs.md index 1da639b..19632b4 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -27,10 +27,10 @@ PaginationList runs = client.runs().list( | `update(Object)` | Update the run. Returns `ActorRun`. | | `delete()` | Delete the run. | | `abort(Boolean gracefully)` | Abort the run (`null` = server default). Returns `ActorRun`. | -| `metamorph(String targetActorId, Object input, MetamorphOptions)` | Metamorph into another Actor. | +| `metamorph(String targetActorId, Object input, MetamorphOptions)` | Metamorph into another Actor. Returns `ActorRun`. | | `reboot()` | Reboot the run. Returns `ActorRun`. | | `resurrect(RunResurrectOptions)` | Resurrect a finished run. Returns `ActorRun`. | -| `charge(RunChargeOptions)` | Charge a pay-per-event run for a named event. | +| `charge(RunChargeOptions)` | Charge a pay-per-event run for a named event. No return value. | | `waitForFinish(Long waitSecs)` | Poll until the run finishes (`null` waits indefinitely). Returns `ActorRun`. | | `dataset()` / `keyValueStore()` / `requestQueue()` | Clients for the run's default storages. | | `log()` | A `LogClient` for the run's log (see [Store, users & logs](misc.md#logs--clientlogid)). | diff --git a/docs/storages.md b/docs/storages.md index 326016c..9169308 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -5,6 +5,18 @@ single-resource client (`get`, `update`, `delete`, plus storage-specific operati default storages are reachable via `client.run(id).dataset()` / `.keyValueStore()` / `.requestQueue()`. +### Metadata models + +The `get()`/`getOrCreate(...)` calls return the storage metadata model. All three share the common +getters `getId()`, `getName()`, `getUserId()`, `getCreatedAt()` (`Instant`), and `getModifiedAt()` +(`Instant`), plus a type-specific size counter: + +| Model | Common getters | Type-specific | +|---|---|---| +| `Dataset` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | `getItemCount()` (`long`) | +| `KeyValueStore` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | — | +| `RequestQueue` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | `getTotalRequestCount()` (`long`) | + ## Datasets ### `DatasetCollectionClient` — `client.datasets()` @@ -105,15 +117,15 @@ expiry-aware storage-content signature — hence only `createKeysPublicUrl` take | `listHead(Long limit)` | Requests at the head. Returns `RequestQueueHead`. | | `addRequest(RequestQueueRequest, boolean forefront)` | Add a request. Returns `RequestQueueOperationInfo`. | | `getRequest(String id)` | Fetch a request. Returns `Optional`. | -| `updateRequest(RequestQueueRequest, boolean forefront)` | Update a request. | -| `deleteRequest(String id)` | Delete a request. | +| `updateRequest(RequestQueueRequest, boolean forefront)` | Update a request. Returns `RequestQueueOperationInfo`. | +| `deleteRequest(String id)` | Delete a request. No return value. | | `batchAddRequests(List, boolean forefront)` | Add many (auto-chunked at 25, unprocessed requests retried). Returns `BatchAddResult`. | | `batchAddRequests(List, boolean forefront, BatchAddRequestsOptions)` | As above, tuning `maxUnprocessedRequestsRetries`, `maxParallel` and `minDelayBetweenUnprocessedRequestsRetriesMillis`. | | `batchDeleteRequests(Object)` | Delete many. Returns `JsonNode`. | | `listAndLockHead(long lockSecs, Long limit)` | Atomically lock the head. Returns `JsonNode`. | | `listRequests(ListRequestsOptions)` | List requests. Returns `JsonNode`. | | `prolongRequestLock(String id, long lockSecs, boolean forefront)` | Extend a lock. Returns `JsonNode`. | -| `deleteRequestLock(String id, boolean forefront)` | Release a lock. | +| `deleteRequestLock(String id, boolean forefront)` | Release a lock. No return value. | | `unlockRequests()` | Release all the client's locks. Returns `JsonNode`. | | `paginateRequests(Long pageLimit)` | A lazy `Iterator` over all requests. | diff --git a/src/main/java/com/apify/client/ActorClient.java b/src/main/java/com/apify/client/ActorClient.java index 9e68fc0..ad05ddd 100644 --- a/src/main/java/com/apify/client/ActorClient.java +++ b/src/main/java/com/apify/client/ActorClient.java @@ -104,7 +104,9 @@ public Build build(String versionNumber, ActorBuildOptions options) { */ public BuildClient defaultBuild(Long waitForFinish) { QueryParams params = new QueryParams(); - params.addLong("waitForFinish", waitForFinish); + // Clamp like the getWithWait twins so a large wait paired with a short client timeout cannot + // abort every attempt and burn the retry budget (the API caps server-side waiting at 60s). + params.addLong("waitForFinish", ctx.clampServerWait(waitForFinish)); Build build = ctx.getResourceRequired("builds/default", params, Build.class); return new BuildClient(http, baseUrl, build.getId()); } diff --git a/src/main/java/com/apify/client/BuildClient.java b/src/main/java/com/apify/client/BuildClient.java index 933bb7b..77d7ce9 100644 --- a/src/main/java/com/apify/client/BuildClient.java +++ b/src/main/java/com/apify/client/BuildClient.java @@ -20,7 +20,9 @@ public Optional get() { /** * Fetches the build, optionally asking the API to wait up to {@code waitForFinishSecs} seconds - * (max 60) for the build to finish before responding. Pass {@code null} for an immediate fetch. + * for the build to finish before responding. The value is clamped so the server always responds + * before the client's per-request timeout; the API itself caps server-side waiting at 60 seconds. + * Pass {@code null} for an immediate fetch. */ public Optional getWithWait(Long waitForFinishSecs) { QueryParams params = new QueryParams(); diff --git a/src/main/java/com/apify/client/UserClient.java b/src/main/java/com/apify/client/UserClient.java index 192f02e..9d5b090 100644 --- a/src/main/java/com/apify/client/UserClient.java +++ b/src/main/java/com/apify/client/UserClient.java @@ -62,9 +62,12 @@ public JsonNode limits() { /** Updates the current account's resource limits. Only available for {@code me}. */ public void updateLimits(Object newLimits) { requireMe(); + // Route through mergedParams for consistency with the sibling limits() read (a no-op here since + // UserClient is never seeded with inherited params, but keeps every call site uniform). + String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("limits")); http.call( "PUT", - ctx.subUrl("limits"), + url, Json.toBytes(newLimits), ResourceContext.CONTENT_TYPE_JSON, http.baseRequestTimeout()); From 9956fc202173380330caacb8ca0667b99bbd301c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 21:55:01 +0000 Subject: [PATCH 07/12] docs: document public-URL last-run limitation (JS-consistent); document GetRecordOptions - IT4-1: the storage public-URL builders match the JS reference (URL = resource path + signature over the resolved concrete id + explicit options; seeded status/origin filters are not carried). Documented the last-run-nested limitation in docs/storages.md and added maintainer comments; no behavioral change (matching the reference is required). - IT4-2: document GetRecordOptions (attachment, signature) in docs/storages.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- docs/storages.md | 11 +++++++++++ src/main/java/com/apify/client/DatasetClient.java | 4 ++++ .../java/com/apify/client/KeyValueStoreClient.java | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/docs/storages.md b/docs/storages.md index 9169308..8330471 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -62,6 +62,14 @@ dataset is private, the client fetches it, reads its URL-signing secret, and app signature (bounded by `expiresInSecs`, or non-expiring when `null`); for public datasets the URL is unsigned. +> **Public-URL limitation (matches the JavaScript reference client).** The public-URL builders +> (`createItemsPublicUrl`, and the key-value-store `getRecordPublicUrl` / `createKeysPublicUrl`) +> build the URL from the client's own resource path plus the signature. When called on a *last-run +> nested* client (e.g. `client.actor(id).lastRun("SUCCEEDED").dataset().createItemsPublicUrl(...)`) +> the returned URL keeps the unpinned `.../runs/last/dataset` path and does not carry the `status` +> filter, so it may resolve a different run when opened. Build public URLs from a concrete +> `client.dataset(id)` / `client.keyValueStore(id)` client when you need a stable shareable URL. + ## Key-value stores ### `KeyValueStoreCollectionClient` — `client.keyValueStores()` @@ -92,6 +100,9 @@ Optional rec = client.keyValueStore(store.getId()).getRecor rec.ifPresent(r -> System.out.println(new String(r.getValue()))); ``` +`GetRecordOptions` (for `getRecord(String key, GetRecordOptions)`) fields: `attachment(Boolean)` +(request a download disposition) and `signature(String)` (a pre-computed access signature). + `KeyValueStoreRecord` exposes `getKey()`, `getValue()` (raw bytes) and `getContentType()`. `KeyValueStoreKeysPage` exposes `getItems()` (a list of `KeyValueStoreKey` with `getKey()`/`getSize()`), `isTruncated()`, `getExclusiveStartKey()` and `getNextExclusiveStartKey()`. diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/DatasetClient.java index 73bf4a3..24d57b1 100644 --- a/src/main/java/com/apify/client/DatasetClient.java +++ b/src/main/java/com/apify/client/DatasetClient.java @@ -147,6 +147,10 @@ public String createItemsPublicUrl(DatasetListItemsOptions options, Long expires params.addString("signature", sig); } } + // Mirror the JS reference: the public URL is this client's resource path plus the signature + // (over the resolved concrete dataset id) and the explicit options — the seeded status/origin + // filters are deliberately not carried. On a last-run-nested client this keeps the unpinned + // ".../runs/last/dataset" path; see docs/storages.md for that limitation. return params.applyToUrl(ctx.publicUrl("items")); } diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/KeyValueStoreClient.java index 45e989c..3afc2cf 100644 --- a/src/main/java/com/apify/client/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreClient.java @@ -125,6 +125,9 @@ public String getRecordPublicUrl(String key) { params.addString("signature", Signatures.createHmacSignature(secret, key)); } } + // Public URL = resource path + signature only, matching the JS reference. + // Seeded filters are not carried; see DatasetClient.createItemsPublicUrl and + // docs/storages.md for the last-run caveat. return params.applyToUrl(ctx.publicUrl("records/" + ResourceContext.encodePathSegment(key))); } @@ -158,6 +161,8 @@ public String createKeysPublicUrl(ListKeysOptions options, Long expiresInSecs) { } } } + // Public URL = resource path + signature only, matching the JS reference (see the last-run + // caveat documented in docs/storages.md). return params.applyToUrl(ctx.publicUrl("keys")); } } From 0292192104aea068c755b34c408e8667a0e66d1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:01:46 +0000 Subject: [PATCH 08/12] docs: correct createKeysPublicUrl maintainer comment (forwards options) The comment said 'resource path + signature only', but createKeysPublicUrl forwards the ListKeysOptions filters into the URL; reword to 'resource path + explicit options + signature' to match the method's Javadoc and the DatasetClient sibling (IT5-1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- src/main/java/com/apify/client/KeyValueStoreClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/KeyValueStoreClient.java index 3afc2cf..16c8f80 100644 --- a/src/main/java/com/apify/client/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreClient.java @@ -161,8 +161,8 @@ public String createKeysPublicUrl(ListKeysOptions options, Long expiresInSecs) { } } } - // Public URL = resource path + signature only, matching the JS reference (see the last-run - // caveat documented in docs/storages.md). + // Public URL = resource path + the explicit key-listing options + signature, matching the JS + // reference (see the last-run caveat documented in docs/storages.md). return params.applyToUrl(ctx.publicUrl("keys")); } } From a67f66af630825c1c88c546ed603fa48d7ca3f55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:31:14 +0000 Subject: [PATCH 09/12] docs: add method tables + ActorVersion field list for version/env-var clients docs/actors.md gave the four version/env-var clients prose only; add Method/Description tables (matching every other client section) and an ActorVersion field list (IT6-1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- docs/actors.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/actors.md b/docs/actors.md index 72df502..c0586a2 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -85,17 +85,52 @@ unmodelled fields. `client.actor(id).versions()` lists/creates versions; `client.actor(id).version(v)` reads, updates and deletes a single version and exposes its environment variables. +### `ActorVersionCollectionClient` — `client.actor(id).versions()` + +| Method | Description | +|---|---| +| `list(ListOptions)` | List the Actor's versions. Returns `PaginationList`. | +| `create(Object version)` | Create a version. Returns `ActorVersion`. | + +### `ActorVersionClient` — `client.actor(id).version(v)` + +| Method | Description | +|---|---| +| `get()` | Fetch the version. Returns `Optional`. | +| `update(Object)` | Update the version. Returns `ActorVersion`. | +| `delete()` | Delete the version. | +| `envVars()` | An `ActorEnvVarCollectionClient` for the version's environment variables. | +| `envVar(String name)` | An `ActorEnvVarClient` for a single environment variable. | + ```java ActorVersion version = client.actor("me/my-actor").version("0.0").get().orElseThrow(); System.out.println(version.getSourceType()); ``` +`ActorVersion` fields: `getVersionNumber()`, `getSourceType()`, plus `getExtra()` for any unmodelled +fields. + ## `ActorEnvVarClient` and `ActorEnvVarCollectionClient` Attach environment variables to a version. `ActorEnvVar` has a `(name, value)` constructor plus fluent setters `setName`, `setValue`, `setIsSecret(Boolean)` (when secret, the value is stored encrypted), and matching getters `getName()`, `getValue()`, `getIsSecret()`. +### `ActorEnvVarCollectionClient` — `client.actor(id).version(v).envVars()` + +| Method | Description | +|---|---| +| `list()` | List the version's environment variables. Returns `PaginationList`. | +| `create(ActorEnvVar)` | Create an environment variable. Returns `ActorEnvVar`. | + +### `ActorEnvVarClient` — `client.actor(id).version(v).envVar(name)` + +| Method | Description | +|---|---| +| `get()` | Fetch the environment variable. Returns `Optional`. | +| `update(ActorEnvVar)` | Update the environment variable. Returns `ActorEnvVar`. | +| `delete()` | Delete the environment variable. | + ```java client.actor("me/my-actor").version("0.0").envVars() .create(new ActorEnvVar("API_KEY", "secret").setIsSecret(true)); From fd21d370f4761198d40f3e7b80180e1a1e636d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:13:47 +0000 Subject: [PATCH 10/12] docs: fill remaining return types (listItems typed overload, updateLimits) docs/storages.md listItems(..., Class) now states Returns PaginationList; docs/misc.md updateLimits(Object) now states No return value (IT7-1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- docs/misc.md | 2 +- docs/storages.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/misc.md b/docs/misc.md index b2aed38..2c19e7d 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -32,7 +32,7 @@ while (shown < 5 && it.hasNext()) { | `get()` | Fetch the user. Returns `Optional` (private details for `me()` via `getExtra()`). | | `monthlyUsage()` / `monthlyUsage(String date)` | Account monthly usage (`me()` only). Returns `JsonNode`. | | `limits()` | Account resource limits (`me()` only). Returns `JsonNode`. | -| `updateLimits(Object)` | Update account limits (`me()` only). | +| `updateLimits(Object)` | Update account limits (`me()` only). No return value. | The usage/limits methods are only available for `me()`; calling them on `user(id)` throws `IllegalStateException`. diff --git a/docs/storages.md b/docs/storages.md index 8330471..ab35bfc 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -35,7 +35,7 @@ getters `getId()`, `getName()`, `getUserId()`, `getCreatedAt()` (`Instant`), and |---|---| | `get()` / `update(Object)` / `delete()` | Metadata CRUD. | | `listItems(DatasetListItemsOptions)` | List items as `PaginationList`. | -| `listItems(DatasetListItemsOptions, Class)` | List items decoded into `T`. | +| `listItems(DatasetListItemsOptions, Class)` | List items decoded into `T`. Returns `PaginationList`. | | `downloadItems(DownloadItemsFormat, DatasetDownloadOptions)` | Serialized bytes (JSON/JSONL/CSV/XLSX/XML/RSS/HTML). | | `pushItems(Object)` | Push a single item or a list of items. | | `getStatistics()` | Dataset statistics. Returns `Optional`. | From 5182964c5f716c684b300a07e76cf2b86e3e3c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:17:45 +0000 Subject: [PATCH 11/12] docs: add return types to getWithWait rows (runs, builds) Complete the return-type documentation: runs.md/builds.md getWithWait rows now state Optional/Optional and describe the clamp instead of a stale (max 60). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017sJGjCxFP3bpbeF4LsVqK3 --- docs/builds.md | 2 +- docs/runs.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/builds.md b/docs/builds.md index 063ced5..f6fe317 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -14,7 +14,7 @@ builds) and a single build with `client.build(id)`. | Method | Description | |---|---| | `get()` | Fetch the build. Returns `Optional`. | -| `getWithWait(Long waitForFinishSecs)` | Fetch, waiting up to `waitForFinishSecs` (max 60) server-side. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the build to finish (clamped to the request timeout; the API caps server-side waiting at 60s). Returns `Optional`. | | `abort()` | Abort the build. Returns `Build`. | | `delete()` | Delete the build. | | `waitForFinish(Long waitSecs)` | Poll until the build finishes (`null` waits indefinitely). Returns `Build`. | diff --git a/docs/runs.md b/docs/runs.md index 19632b4..fd7ab21 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -23,7 +23,7 @@ PaginationList runs = client.runs().list( | Method | Description | |---|---| | `get()` | Fetch the run. Returns `Optional`. | -| `getWithWait(Long waitForFinishSecs)` | Fetch, waiting up to `waitForFinishSecs` (max 60) server-side. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the run to finish (clamped to the request timeout; the API caps server-side waiting at 60s). Returns `Optional`. | | `update(Object)` | Update the run. Returns `ActorRun`. | | `delete()` | Delete the run. | | `abort(Boolean gracefully)` | Abort the run (`null` = server default). Returns `ActorRun`. | From e024619f1cb558db74b6d99991024c391863fda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:31:59 +0000 Subject: [PATCH 12/12] fix: follow HTTP redirects in DefaultHttpBackend The default JDK HttpClient used Redirect.NEVER, so endpoints that answer with a 302 (e.g. a non-attachment key-value-store record served from external storage) surfaced as errors instead of being followed, diverging from the reference clients. Use Redirect.NORMAL (no HTTPS->HTTP downgrade; the JDK strips Authorization on cross-origin hops, so the token is not leaked). --- .../java/com/apify/client/DefaultHttpBackend.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/apify/client/DefaultHttpBackend.java b/src/main/java/com/apify/client/DefaultHttpBackend.java index cf17489..d8c219f 100644 --- a/src/main/java/com/apify/client/DefaultHttpBackend.java +++ b/src/main/java/com/apify/client/DefaultHttpBackend.java @@ -25,7 +25,17 @@ public final class DefaultHttpBackend implements HttpBackend { /** Creates a backend with a sensible default {@link java.net.http.HttpClient}. */ public DefaultHttpBackend() { - this(HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build()); + // Follow redirects (NORMAL, matching the reference clients): some endpoints — e.g. a + // non-attachment key-value-store record GET — answer with a 302 to external storage, which the + // JDK's default Redirect.NEVER would otherwise surface as an error. NORMAL does not follow an + // HTTPS->HTTP downgrade and the JDK strips the Authorization header on cross-origin hops, so + // the + // bearer token is not leaked to the redirect target. + this( + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); } /** Wraps a caller-provided {@link java.net.http.HttpClient} (share a pool, custom proxy/TLS). */