diff --git a/.github/workflows/java-integration-tests.yml b/.github/workflows/java-integration-tests.yml index 3257ab1..e094fa8 100644 --- a/.github/workflows/java-integration-tests.yml +++ b/.github/workflows/java-integration-tests.yml @@ -45,9 +45,12 @@ jobs: - 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. + # Offline unit tests (mock HTTP backend): prove the retry/error/signature/pagination logic + # without the API. Selected by pattern — every hermetic test in the base package runs, and the + # token-gated integration/example/doc-snippet suites are excluded — so new hermetic tests are + # covered here automatically without editing this list. - name: Unit tests - run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest,CompressionTest' -DfailIfNoTests=true + run: mvn -B test -Dtest='!*IntegrationTest,!ExamplesTest,!DocSnippetsTest' -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), @@ -65,8 +68,9 @@ jobs: 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=true + # Run only the live integration suites; the hermetic tests already ran in the offline step + # above and the example/doc-snippet harnesses run in the step below. + run: mvn -B test -Dtest='*IntegrationTest' -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 diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index 4ad8af1..e70e984 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -62,8 +62,11 @@ jobs: - name: Static analysis (SpotBugs) run: mvn -B -DskipTests compile spotbugs:check + # Same pattern-based selection as the integration workflow's offline gate: run every hermetic + # test and exclude the token-gated integration/example/doc-snippet suites, so new hermetic + # tests are gated on release automatically without editing this list. - name: Unit tests - run: mvn -B test -Dtest='UnitHttpTest,ReviewFixesTest,ClientMetaTest,SignatureTest,ConfigTest' -DfailIfNoTests=true + run: mvn -B test -Dtest='!*IntegrationTest,!ExamplesTest,!DocSnippetsTest' -DfailIfNoTests=true - name: Resolve version from pom.xml id: version diff --git a/.gitignore b/.gitignore index 07ad7f2..4f958b3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ replay_pid* # Maven build output target/ +# Local build-classpath dumps (e.g. from `mvn dependency:build-classpath`) +cp.txt + # IDE .idea/ *.iml diff --git a/CHANGELOG.md b/CHANGELOG.md index df5035b..624363d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ 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.3.0] - 2026-07-11 + +### Added + +- `StreamedLog` log-redirection helper (matching the reference client's `getStreamedLog`): + `RunClient.getStreamedLog()` / `getStreamedLog(StreamedLogOptions)` return a `StreamedLog` that + follows the run's live log in a background thread and redirects each complete, timestamped message + to a destination. `StreamedLog` is `AutoCloseable` with `start()`/`stop()` lifecycle. Options: + `toLog(Consumer)` (custom destination; default is a per-run prefixed `java.util.logging` + logger), `prefix(String)`, and `fromStart(boolean)` (skip pre-redirection log lines when false). + +### Changed + +- **Breaking:** `RunClient.getStreamedLog()` now returns a `StreamedLog` redirection helper instead + of a raw `InputStream`, aligning its public interface with the reference client. For raw stream + access use `run(id).log().stream(new LogOptions().raw(true))`. +- Standardized the "official, but experimental" disclaimer wording across the README and all + documentation pages. + +### Fixed + +- `StreamedLog`: the last complete log message is no longer dropped when stopping a live stream. + The final flush now runs in a `finally` block, so a stop that unblocks a blocked read with an + `IOException` still delivers the retained message. +- `StreamedLog`: `stop()`/`close()` can no longer hang. The log stream is now opened in `start()` + before the reader thread is launched, eliminating a startup race where `stop()` closed a + still-null stream and then waited forever on a read that never returned. +- Documentation: added `java.util.ArrayList` and `java.util.function.Consumer` to the stated + snippet import list in `docs/README.md` so the streamed-log example compiles as written. +- `StreamedLog.close()` is now fully idempotent: the running-check and stop happen atomically under + the monitor, so a double or concurrent `close()` can no longer throw `IllegalStateException`. +- Documentation: `docs/runs.md` streamed-log snippet now uses the `RUN_ID` placeholder, matching the + convention used across the other snippets. +- `StreamedLog`: a destination consumer that throws no longer escapes as an uncaught exception on the + background daemon thread. Matching the reference client, the failure is caught, redirection stops, + and a warning is logged. +- `StreamedLog`: the pending-message buffer is now local to each reader run instead of a shared + field, so a `start()` racing a still-draining reader can no longer corrupt shared parsing state. +- Documentation: `docs/README.md` now lists `dataset(id).getStatistics()` as returning + `Optional`, matching the method table in `docs/storages.md`. + +## [0.2.0] - 2026-07-10 + +### Added + +- Lazy iteration helpers over every paginated collection, matching the reference JS client's + iterable `list()`: `iterate(options, chunkSize)` on the Actor, build, run, dataset, + key-value-store, request-queue, task, schedule, webhook, and webhook-dispatch collection clients; + and `DatasetClient.iterateItems(...)` for dataset items and `KeyValueStoreClient.iterateKeys(...)` + for store keys. The options' `limit` caps the total number of items yielded and `chunkSize` sets + the per-request page size. The non-paginated collections — `ActorVersionCollectionClient.iterate(options)` + and `ActorEnvVarCollectionClient.iterate()` — return the full list in a single fetch (no page size + to tune). + +### Changed + +- Verified the client against OpenAPI specification version `v2-2026-07-10T105921Z` and bumped + `Version.API_SPEC_VERSION` accordingly. The spec delta is forward-compatible: new `401`/`402` + error responses on several endpoints (handled generically by `ApifyApiException`) and relaxed + nullability/optionality on some response fields — already tolerated because the models use + nullable boxed field types (a JSON `null` deserializes to `null`) and an optional field simply + stays unset. +- **Breaking:** `StoreCollectionClient.iterate` now takes `iterate(StoreListOptions, Long chunkSize)`, + where the options' `limit` is the total-items cap and `chunkSize` is the page size. Previously + `limit` was the per-page size. This aligns Store iteration with the reference client and the new + collection iterators. + ## [0.1.3] - 2026-07-10 ### Added diff --git a/README.md b/README.md index ef9d203..12aabfc 100644 --- a/README.md +++ b/README.md @@ -15,38 +15,56 @@ queues, tasks, schedules, webhooks, the store, users and logs). ## Installation -Maven: +The client is published to [Maven Central](https://central.sonatype.com/artifact/com.apify/apify-client). + +Maven (Maven Central is a default repository, so no extra configuration is needed): ```xml com.apify apify-client - 0.1.3 + 0.3.0 ``` -Gradle: +Gradle — ensure `mavenCentral()` is in your `repositories`, then add the dependency: ```groovy -implementation 'com.apify:apify-client:0.1.3' +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.apify:apify-client:0.3.0' +} ``` ## Quick start -A complete, copy-pasteable first program (save as `HelloApify.java`). Populate a `lib/` directory -with the client and its runtime dependencies (from a directory whose `pom.xml` declares the -dependency shown in [Installation](#installation) above), then compile and run against the JVM's -`lib/*` classpath wildcard — quote it so the shell does not expand it: +A complete, copy-pasteable first program (save as `HelloApify.java`). First scaffold a minimal +`pom.xml` next to it so Maven can resolve the client and its runtime dependencies: -```bash -# 1. Collect apify-client and its runtime dependencies (Jackson, brotli4j codecs, …) into lib/. -mvn dependency:copy-dependencies -DoutputDirectory=lib -DincludeScope=runtime - -# 2. Compile and run. '.' is for the compiled HelloApify.class; lib/* is the JVM classpath wildcard. -javac -cp 'lib/*' HelloApify.java -java -cp '.:lib/*' HelloApify # Windows: java -cp ".;lib/*" HelloApify +```xml + + 4.0.0 + com.example + hello-apify + 1.0.0 + + 17 + + + + com.apify + apify-client + 0.3.0 + + + ``` +Create `HelloApify.java`: + ```java import com.apify.client.ApifyClient; import com.apify.client.ActorRun; @@ -54,15 +72,31 @@ import com.apify.client.ActorStartOptions; class HelloApify { public static void main(String[] args) { - ApifyClient client = ApifyClient.create("my-api-token"); + // Your API token from https://console.apify.com/settings/integrations + 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()); } } ``` -The remaining snippets below are fragments that assume a configured `client` (see the imports note -after the next block): +Then populate a `lib/` directory with the client and its runtime dependencies, and compile and run +against the JVM's `lib/*` classpath wildcard — quote it so the shell does not expand it: + +```bash +# 1. Collect apify-client and its runtime dependencies (Jackson, brotli4j codecs, …) into lib/. +mvn dependency:copy-dependencies -DoutputDirectory=lib -DincludeScope=runtime + +# 2. Compile and run. '.' is for the compiled HelloApify.class; lib/* is the JVM classpath wildcard. +javac -cp '.:lib/*' HelloApify.java # Windows: javac -cp ".;lib/*" HelloApify.java +java -cp '.:lib/*' HelloApify # Windows: java -cp ".;lib/*" HelloApify +``` + +The remaining snippets below are fragments that assume a configured `client` and these imports: 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, `java.time.Duration` in the configuration examples, and standard JDK types such as +`java.util.Optional` and `java.util.Map` (`import java.util.*;`). ```java ApifyClient client = ApifyClient.create("my-api-token"); @@ -77,10 +111,9 @@ PaginationList items = System.out.println("Items in this page: " + 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, `java.time.Duration` in the configuration examples, and standard JDK types such as -`java.util.Optional` and `java.util.Map` (`import java.util.*;`). +The types used above — `PaginationList`, `DatasetListItemsOptions`, and the per-resource clients — +are documented on the [resource pages](docs/README.md); `ApifyApiException` is covered under +[Error handling](#error-handling) 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. @@ -153,9 +186,12 @@ try { ## Versioning -- `Version.CLIENT_VERSION` — the semantic version of this client (`0.1.3`). +The public `com.apify.client.Version` class (`import com.apify.client.Version;`) exposes two +constants: + +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.3.0`). - `Version.API_SPEC_VERSION` — the Apify OpenAPI specification version this client was verified - against (`v2-2026-07-08T143931Z`). + against (`v2-2026-07-10T105921Z`). Changes to the public interface other than additive ones are considered breaking changes and follow [Semantic Versioning](https://semver.org/). @@ -194,7 +230,7 @@ Full documentation is in the [`docs/`](docs/README.md) directory, organized by r - [Schedules](docs/schedules.md) - [Webhooks & dispatches](docs/webhooks.md) - [Store, users & logs](docs/misc.md) -- [Runnable examples](docs/examples.md) +- [Examples](docs/examples.md) ## Resources diff --git a/docs/README.md b/docs/README.md index 0f6cfbe..f2459be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ This directory documents the public API of the Apify Java client, organized by r lists the available methods with their parameters and short snippets. The snippets are code fragments that assume a configured `client` and the imports listed below, not standalone `main` programs; for complete, runnable programs see [examples.md](examples.md) and -[`src/test/java/com/apify/client/examples/`](../src/test/java/com/apify/client/examples). For an +[`src/test/java/com/apify/client/examples/`](https://github.com/apify/apify-client-java/tree/master/src/test/java/com/apify/client/examples). For an overview, configuration, error handling and the full resource table, see the [top-level README](../README.md). @@ -32,8 +32,9 @@ empty `Optional` rather than an exception. API failures are thrown as `ApifyApiE ## 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`). +`import com.apify.client.*;`) plus standard-library types (`java.util.List`, `java.util.ArrayList`, +`java.util.Map`, `java.util.Optional`, `java.util.Iterator`, `java.util.function.Consumer`, +`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. @@ -44,9 +45,9 @@ A few methods return data whose shape is not modelled by this client and is inst Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): - 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`). + `build(id).getOpenApiDefinition()`, `dataset(id).getStatistics()` (returned as + `Optional`), 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. @@ -84,8 +85,9 @@ 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. +Most `list` methods (builds, tasks, schedules, webhooks, Actor versions) take the shared +`ListOptions`, which carries the standard pagination/ordering controls. Runs additionally take a +`RunListOptions` status filter — `runs().list(ListOptions, RunListOptions)`; see [Runs](runs.md). | Method | Type | Meaning | |---|---|---| @@ -103,6 +105,21 @@ PaginationList builds = client.builds().list(new ListOptions().limit(50L) `getCount()`, `isDesc()` and `getItems()`. Within-storage listers (`listKeys`, `listHead`) return their own page/head containers instead. +## Iteration — `iterate` / `iterateItems` / `iterateKeys` + +Each paginated collection also offers a lazy `Iterator` that fetches pages on demand: `iterate(...)` +on the collection clients, `DatasetClient.iterateItems(...)`, and `KeyValueStoreClient.iterateKeys(...)` +(request-queue requests use `RequestQueueClient.paginateRequests(...)`). The options' `limit` caps the +**total** number of items yielded; `null`/unset — or a non-positive value such as `0` — means no cap, +so every item is yielded. (This differs from `list(...)`, which sends `limit=0` to the server +verbatim rather than treating it as unbounded — the iteration behavior matches the reference JS +client.) The per-request page size is an optional +trailing `chunkSize` argument: the per-resource tables below show the `chunkSize` form, and each +iterator also has an overload that omits it (using the server's default page size). The page size +does not change which items a collection iterator yields; note the one exception in +[Storages](storages.md) — `iterateItems` combined with server-side item filters, where the page size +can affect the result. + ## Resource pages - [Actors, versions & environment variables](actors.md) @@ -113,4 +130,4 @@ their own page/head containers instead. - [Schedules](schedules.md) - [Webhooks & dispatches](webhooks.md) - [Store, users & logs](misc.md) -- [Runnable examples](examples.md) +- [Examples](examples.md) diff --git a/docs/actors.md b/docs/actors.md index c0586a2..0c640c4 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -1,5 +1,9 @@ # Actors, versions & environment variables +> **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. + 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). @@ -8,6 +12,7 @@ where `id` is an Actor ID or `username~name` (a `/` in the id is accepted and no | Method | Description | |---|---| | `list(ActorListOptions)` | List the account's Actors. Returns `PaginationList`. | +| `iterate(ActorListOptions, Long chunkSize)` | Lazy `Iterator` over all matches; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the page size (`null` = server default). | | `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 @@ -52,12 +57,17 @@ Actor created = client.actors().create(Map.of( | `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). | +| `webhooks()` | Read-only nested webhook collection (`NestedWebhookCollectionClient`, `list` + `iterate`, no `create`). | | `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 +`ActorStartOptions` fields (all optional): `build` (the tag or number of the build to run, e.g. +`latest`, `0.1.2`), `memoryMbytes` (memory in megabytes allocated for the run), `timeoutSecs` (run +timeout in seconds; `0` means no timeout), `waitForFinish` (maximum seconds to wait server-side for +the run to finish, max 60), `maxItems` (maximum dataset items to charge, pay-per-result Actors), +`maxTotalChargeUsd` (maximum total charge in USD, pay-per-event Actors), `contentType` (content type +of the input body, defaults to `application/json`), `restartOnError` (restart the run if it fails), +`forcePermissionLevel` (override the Actor's permission level for this run: +`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. @@ -90,6 +100,7 @@ and deletes a single version and exposes its environment variables. | Method | Description | |---|---| | `list(ListOptions)` | List the Actor's versions. Returns `PaginationList`. | +| `iterate(ListOptions)` | Lazy `Iterator` over all versions; `limit` caps the total (`null`/unset or non-positive = all). The versions endpoint is not paginated (one fetch returns every version), so `offset` has no effect and there is no page size to tune. | | `create(Object version)` | Create a version. Returns `ActorVersion`. | ### `ActorVersionClient` — `client.actor(id).version(v)` @@ -121,6 +132,7 @@ encrypted), and matching getters `getName()`, `getValue()`, `getIsSecret()`. | Method | Description | |---|---| | `list()` | List the version's environment variables. Returns `PaginationList`. | +| `iterate()` | `Iterator` over the variables. The env-var collection is not paginated (all variables are returned at once), so this iterates a single fetched page; provided for API consistency. | | `create(ActorEnvVar)` | Create an environment variable. Returns `ActorEnvVar`. | ### `ActorEnvVarClient` — `client.actor(id).version(v).envVar(name)` diff --git a/docs/builds.md b/docs/builds.md index f6fe317..8854024 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -1,5 +1,9 @@ # Builds +> **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. + 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)`. @@ -8,6 +12,7 @@ builds) and a single build with `client.build(id)`. | Method | Description | |---|---| | `list(ListOptions)` | List builds. Returns `PaginationList`. | +| `iterate(ListOptions, Long chunkSize)` | Lazy `Iterator` over all builds; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | ## `BuildClient` diff --git a/docs/examples.md b/docs/examples.md index 0266997..a84a6ab 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,11 +1,16 @@ -# Runnable examples +# Examples -Each example below is a code fragment that assumes a configured `client` and the imports listed in -the [documentation index](README.md#imports-and-dependencies); it is not a standalone `main`. The -complete, runnable 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. +> **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. + +Each example below is a code fragment (not a standalone `main`) that assumes a configured `client` +and the imports listed in the [documentation index](README.md#imports-and-dependencies). The +complete, standalone programs (with imports and a `main`) live under +[`src/test/java/com/apify/client/examples/`](https://github.com/apify/apify-client-java/tree/master/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. The link points to the GitHub source so it resolves from the +rendered docs as well as the repository. ## Run a store Actor and read its default dataset @@ -20,10 +25,11 @@ System.out.println("Items in this page: " + items.getCount()); ```java // Named storages persist on your account; each block deletes its storage in a finally so the -// example does not leak them. +// example does not leak them. A unique suffix keeps the names from colliding across parallel runs. +String suffix = "-" + System.currentTimeMillis(); // Dataset: create, push items, read them back. -Dataset dataset = client.datasets().getOrCreate("example-ds"); +Dataset dataset = client.datasets().getOrCreate("example-ds" + suffix); try { client.dataset(dataset.getId()).pushItems(List.of(Map.of("hello", "world"))); PaginationList items = client.dataset(dataset.getId()).listItems(new DatasetListItemsOptions()); @@ -33,7 +39,7 @@ try { } // Key-value store: create, set a record, read it back. -KeyValueStore store = client.keyValueStores().getOrCreate("example-kvs"); +KeyValueStore store = client.keyValueStores().getOrCreate("example-kvs" + suffix); try { client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); Optional record = client.keyValueStore(store.getId()).getRecord("OUTPUT"); @@ -43,7 +49,7 @@ try { } // Request queue: create, add a request, read the head. -RequestQueue queue = client.requestQueues().getOrCreate("example-rq"); +RequestQueue queue = client.requestQueues().getOrCreate("example-rq" + suffix); try { client.requestQueue(queue.getId()).addRequest(new RequestQueueRequest("https://example.com", "example"), false); RequestQueueHead head = client.requestQueue(queue.getId()).listHead(10L); @@ -64,7 +70,8 @@ user.ifPresent(u -> System.out.println("Account " + u.getId() + " / " + u.getUse ```java Actor created = client.actors().create(Map.of( - "name", "my-example-actor", + // A unique name avoids collisions across parallel runs. + "name", "my-example-actor-" + System.currentTimeMillis(), "isPublic", false, "versions", List.of(Map.of( "versionNumber", "0.0", @@ -100,7 +107,7 @@ if (last.isPresent()) { ## Lazy iteration of Store Actors ```java -Iterator it = client.store().iterate(new StoreListOptions().limit(10L)); +Iterator it = client.store().iterate(new StoreListOptions().limit(10L), 10L); int shown = 0; while (shown < 5 && it.hasNext()) { System.out.println(it.next().getName()); @@ -110,9 +117,17 @@ while (shown < 5 && it.hasNext()) { ## Run an Actor with log redirection +`getStreamedLog()` returns a [`StreamedLog`](runs.md#streamed-log-redirection) that follows the +run's live log and redirects each message to a destination (by default a per-run prefixed logger). +It is `AutoCloseable`, so a try-with-resources block stops redirection when the run finishes. The +complete program lives in +[`LogRedirection.java`](https://github.com/apify/apify-client-java/blob/master/src/test/java/com/apify/client/examples/LogRedirection.java). + ```java ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); -try (InputStream stream = client.run(run.getId()).getStreamedLog()) { - stream.transferTo(System.out); +RunClient runClient = client.run(run.getId()); +try (StreamedLog streamedLog = runClient.getStreamedLog()) { + streamedLog.start(); + runClient.waitForFinish(120L); } ``` diff --git a/docs/misc.md b/docs/misc.md index 2c19e7d..dc5bf13 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -1,20 +1,28 @@ # Store, users & logs +> **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. + ## Apify Store — `client.store()` -Browse public Actors in the Apify Store. +Browse public Actors in the Apify Store. `client.store()` returns a `StoreCollectionClient`. | Method | Description | |---|---| | `list(StoreListOptions)` | A page of Store Actors. Returns `PaginationList`. | -| `iterate(StoreListOptions)` | A lazy `Iterator` fetching pages on demand. | +| `iterate(StoreListOptions, Long chunkSize)` | A lazy `Iterator` over all matches, fetching pages on demand. The options' `limit` caps the total number of Actors yielded (`null`/unset or non-positive = all); `chunkSize` is the per-request page size (`null` = server default). | -`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`). +`StoreListOptions` fields: `offset` (number of Actors to skip), `limit` (maximum number of Actors to +return), `search` (full-text search query), `sortBy` (the sort field, e.g. `popularity`, `newest`), +`category` (filter Actors by category), `username` (filter Actors by owner username), `pricingModel` +(filter by pricing model: `FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`), +`includeUnrunnableActors` (include Actors the current user cannot run), `allowsAgenticUsers` (only +Actors that allow agentic users), `responseFormat` (the response shape: `full` or `agent`). ```java -Iterator it = client.store().iterate(new StoreListOptions().search("crawler").limit(20L)); +Iterator it = + client.store().iterate(new StoreListOptions().search("crawler").limit(20L), 10L); int shown = 0; while (shown < 5 && it.hasNext()) { ActorStoreListItem item = it.next(); @@ -39,11 +47,18 @@ The usage/limits methods are only available for `me()`; calling them on `user(id ```java Optional me = client.me().get(); -me.ifPresent(u -> System.out.println("Account: " + u.getId())); +me.ifPresent( + u -> { + System.out.println("Account: " + u.getId()); + // Fields not modelled on User (email, plan, proxy, ...) are exposed through the untyped + // extras map, which getExtra() returns as a Map. + System.out.println("Email: " + u.getExtra().get("email")); + }); JsonNode usage = client.me().monthlyUsage(); ``` -`User` fields: `getId()`, `getUsername()`, plus `getExtra()`. +`User` fields: `getId()`, `getUsername()`, plus `getExtra()` — a `Map` carrying any +account fields not modelled directly (for `me()`, private details such as `email` and `plan`). ## Logs — `client.log(id)` diff --git a/docs/runs.md b/docs/runs.md index fd7ab21..e1c03a3 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -1,5 +1,9 @@ # Runs +> **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. + Access the run collection with `client.runs()` (or `client.actor(id).runs()` / `client.task(id).runs()`) and a single run with `client.run(id)`. @@ -8,6 +12,7 @@ Access the run collection with `client.runs()` (or `client.actor(id).runs()` / | Method | Description | |---|---| | `list(ListOptions, RunListOptions)` | List runs. Returns `PaginationList`. | +| `iterate(ListOptions, RunListOptions, Long chunkSize)` | Lazy `Iterator` over all matching runs; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | `RunListOptions` adds `status(List)` (e.g. `SUCCEEDED`, `RUNNING`; sent comma-separated) and, for Actor/task-scoped collections, `startedAfter(String)` / `startedBefore(String)` (ISO-8601). @@ -34,17 +39,55 @@ PaginationList runs = client.runs().list( | `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()` | A `StreamedLog` that redirects the live log to a default per-run logger. | +| `getStreamedLog(StreamedLogOptions)` | As above, with a custom destination / options. | + +### Streamed log redirection + +`getStreamedLog()` returns a `StreamedLog`: a helper that follows the run's live raw log in a +background thread and redirects each complete, timestamped message to a destination. It is +`AutoCloseable` — call `start()` to begin redirection and `stop()` (or `close()`, via +try-with-resources) to end it. -`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. +With no options, messages go to a `java.util.logging.Logger` at `INFO` level, prefixed with the +Actor name and run id (looked up automatically). `StreamedLogOptions` customizes it: + +- `toLog(Consumer)` — send each complete message to your own consumer instead of the + default logger. +- `prefix(String)` — override the auto-built prefix used by the default logger. +- `fromStart(boolean)` — when `false`, skip log lines produced before redirection started + (default `true`). + +```java +RunClient runClient = client.run("RUN_ID"); +List collected = new ArrayList<>(); +try (StreamedLog streamedLog = + runClient.getStreamedLog(new StreamedLogOptions().toLog(collected::add).fromStart(true))) { + streamedLog.start(); + runClient.waitForFinish(120L); +} +``` + +For raw stream access without redirection, use `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)). +> **`lastRun()` clients are for reads.** A `RunClient` obtained via `actor(id).lastRun(...)` / +> `task(id).lastRun(...)` targets the `runs/last` path and is intended for reading (`get`, +> `dataset()`/`keyValueStore()`/`requestQueue()`, `log()`). The mutating methods (`abort`, `reboot`, +> `resurrect`, `metamorph`, `charge`, `update`, `delete`) require a concrete run and have no +> `runs/last` endpoint — resolve the id first (`lastRun(...).get()` then `client.run(id)`). The type +> exposes them uniformly (matching the reference client's shared `RunClient`), but calling them on a +> last-run client will fail server-side. + `ActorRun` fields include `getId()`, `getActId()`, `getUserId()`, `getStatus()`, `getStatusMessage()`, `getStartedAt()`, `getFinishedAt()`, `getBuildId()`, `getDefaultDatasetId()`, `getDefaultKeyValueStoreId()`, `getDefaultRequestQueueId()`, `getContainerUrl()`, plus `isTerminal()`. +The status is one of `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, +`ABORTING`, `ABORTED`; `isTerminal()` is true for the finished states (`SUCCEEDED`, `FAILED`, +`TIMED-OUT`, `ABORTED`). `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 diff --git a/docs/schedules.md b/docs/schedules.md index fed9409..d820b6d 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -1,5 +1,9 @@ # Schedules +> **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. + Schedules automatically start Actor or task runs at specified times. Access the collection with `client.schedules()` and a single schedule with `client.schedule(id)`. @@ -8,6 +12,7 @@ Schedules automatically start Actor or task runs at specified times. Access the | Method | Description | |---|---| | `list(ListOptions)` | List schedules. Returns `PaginationList`. | +| `iterate(ListOptions, Long chunkSize)` | Lazy `Iterator` over all schedules; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | | `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): diff --git a/docs/storages.md b/docs/storages.md index ab35bfc..d118ba3 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -1,5 +1,9 @@ # Storages: datasets, key-value stores, request queues +> **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. + 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()` / @@ -24,6 +28,7 @@ getters `getId()`, `getName()`, `getUserId()`, `getCreatedAt()` (`Instant`), and | Method | Description | |---|---| | `list(StorageListOptions)` | List datasets. Returns `PaginationList`. | +| `iterate(StorageListOptions, Long chunkSize)` | Lazy `Iterator` over all datasets; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | | `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`. | @@ -36,11 +41,23 @@ 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`. 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. | +| `iterateItems(DatasetListItemsOptions)` / `iterateItems(DatasetListItemsOptions, Long chunkSize)` | Lazy `Iterator` over all items; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), the optional `chunkSize` sets the per-request page size (omitted/`null` = server default). | +| `iterateItems(DatasetListItemsOptions, Long chunkSize, Class)` | As above, decoded into `T`. Returns `Iterator`. For typed iteration at the server-default page size, pass a `null` chunk size: `iterateItems(opts, null, T.class)`. | +| `downloadItems(DownloadItemsFormat, DatasetDownloadOptions)` | Serialized bytes. `DownloadItemsFormat` is one of `JSON`, `JSONL`, `CSV`, `XLSX`, `XML`, `RSS`, `HTML`. | +| `pushItems(Object)` | Push a single item or a list of items. No return value. | | `getStatistics()` | Dataset statistics. Returns `Optional`. | | `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` | A public (optionally signed) items URL. | +> **Server-side item filters and iteration.** The dataset-items endpoint applies `offset`/`limit` to +> the raw items and then drops those removed by a server-side filter (`skipEmpty`, `skipHidden`, +> `clean`, `simplified`), so a page can contain fewer items than requested. Because `iterateItems` +> advances the offset by the number of items actually returned, combining it with those filters over a +> multi-page dataset has two failure modes: page windows can overlap and **repeat items**, and — more +> severely — if an entire offset window is filtered out the endpoint returns an empty page, which the +> iterator treats as the end, so iteration **stops early and silently skips the remaining data** (an +> all-filtered first page yields nothing at all). Prefer paging without server-side item filters when +> iterating, or fetch pages explicitly with `listItems` and filter client-side. + ```java Dataset ds = client.datasets().getOrCreate("my-dataset"); client.dataset(ds.getId()).pushItems(List.of(Map.of("url", "https://a.com"))); @@ -48,12 +65,18 @@ PaginationList page = client.dataset(ds.getId()).listItems(new Dataset 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 +`DatasetListItemsOptions` fields: `offset` (number of items to skip), `limit` (maximum number of +items to return), `desc` (return items newest-first), `fields` (restrict the output to these source +fields), `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`), `omit` +(exclude these fields from the output), `skipEmpty` (skip empty items), `skipHidden` (skip hidden +fields, i.e. those starting with `#`), `clean` (return only clean — non-empty, non-hidden — items), +`unwind` (expand these fields so each array element becomes a separate item), `flatten` (flatten +these nested fields into dot-notation keys), `view` (select a predefined dataset view for field +selection), `simplified` (return simplified — flattened and cleaned — items), `skipFailedPages` +(skip items that come from failed pages), `signature` (a pre-shared URL signature granting access +without an API token). `downloadItems(...)` returns `byte[]` (the serialized export). +`DatasetDownloadOptions` wraps a `DatasetListItemsOptions` (`items(...)`) and adds `attachment`, `bom`, `delimiter`, `skipHeaderRow`, `xmlRoot`, `xmlRow`, `feedTitle`, `feedDescription`. @@ -74,8 +97,9 @@ unsigned. ### `KeyValueStoreCollectionClient` — `client.keyValueStores()` -`list(StorageListOptions)`, `getOrCreate(String)`, and `getOrCreate(String, Object schema)` (the -latter sends a creation-time store `schema`), as for datasets. +`list(StorageListOptions)`, `iterate(StorageListOptions, Long chunkSize)`, `getOrCreate(String)`, and +`getOrCreate(String, Object schema)` (the latter sends a creation-time store `schema`), as for +datasets. ### `KeyValueStoreClient` — `client.keyValueStore(id)` @@ -83,12 +107,13 @@ latter sends a creation-time store `schema`), as for datasets. |---|---| | `get()` / `update(Object)` / `delete()` | Metadata CRUD. | | `listKeys(ListKeysOptions)` | List keys. Returns `KeyValueStoreKeysPage`. | +| `iterateKeys(ListKeysOptions)` / `iterateKeys(ListKeysOptions, Long chunkSize)` | Lazy `Iterator` over all keys, paging with the cursor (`exclusiveStartKey`). Note: here the options' `limit` caps the **total** number of keys yielded (`null`/unset or non-positive = all), whereas for `listKeys`/`createKeysPublicUrl` the same `ListKeysOptions.limit` is a single-request page size. `chunkSize` sets the per-request page size (`null` = server default). | | `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. | +| `setRecord(String key, byte[] value, String contentType)` | Store raw bytes. No return value. | +| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`timeoutSecs`, `doNotRetryTimeouts`). No return value. | +| `setRecordJson(String key, Object value)` | Store JSON. No return value. | +| `deleteRecord(String key)` | Delete a record. No return value. | | `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`). | @@ -117,7 +142,8 @@ expiry-aware storage-content signature — hence only `createKeysPublicUrl` take ### `RequestQueueCollectionClient` — `client.requestQueues()` -`list(StorageListOptions)` and `getOrCreate(String)`, as for datasets. +`list(StorageListOptions)`, `iterate(StorageListOptions, Long chunkSize)`, and `getOrCreate(String)`, +as for datasets. ### `RequestQueueClient` — `client.requestQueue(id)` @@ -138,7 +164,12 @@ expiry-aware storage-content signature — hence only `createKeysPublicUrl` take | `prolongRequestLock(String id, long lockSecs, boolean forefront)` | Extend a lock. Returns `JsonNode`. | | `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. | +| `paginateRequests(Long pageLimit)` | A lazy `Iterator` over all requests, paging with the queue's forward cursor. | + +> **Naming exception.** Request-queue *requests* are iterated with `paginateRequests(Long pageLimit)` +> — not an `iterate(...)` method — because the request-queue listing is cursor-based rather than +> offset/limit. Its single argument is the per-request page size; there is no total-items cap. Every +> other resource uses the `iterate`/`iterateItems`/`iterateKeys` family. ```java RequestQueue rq = client.requestQueues().getOrCreate("my-queue"); diff --git a/docs/tasks.md b/docs/tasks.md index 2db0d62..9fe6f19 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,5 +1,9 @@ # Tasks +> **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. + Tasks are pre-configured Actor runs with stored input. Access the task collection with `client.tasks()` and a single task with `client.task(id)`. @@ -8,6 +12,7 @@ Tasks are pre-configured Actor runs with stored input. Access the task collectio | Method | Description | |---|---| | `list(ListOptions)` | List tasks. Returns `PaginationList`. | +| `iterate(ListOptions, Long chunkSize)` | Lazy `Iterator` over all tasks; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | | `create(Object)` | Create a task from a JSON-serializable definition. Returns `Task`. | ```java @@ -22,14 +27,14 @@ Task task = client.tasks().create(Map.of( | 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. | +| `get()` / `update(Object)` / `delete()` | CRUD. Return `Optional` / `Task` / `void`. | +| `start(Object input, TaskStartOptions)` | Start a task run (input overrides stored input; `null` uses it). Returns `ActorRun`. | +| `call(Object input, TaskStartOptions, Long waitSecs)` | Start and poll until finished. Returns `ActorRun`. | | `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). | +| `webhooks()` | Read-only nested webhook collection (`NestedWebhookCollectionClient`, `list` + `iterate`, no `create`). | `TaskStartOptions` mirrors `ActorStartOptions` but omits the Actor-only `contentType` and `forcePermissionLevel`: `build`, `memoryMbytes`, `timeoutSecs`, `waitForFinish`, `maxItems`, diff --git a/docs/webhooks.md b/docs/webhooks.md index 59a8533..44236e3 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,5 +1,9 @@ # Webhooks & dispatches +> **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. + 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()` / @@ -12,16 +16,17 @@ The account-wide collection supports both listing and creation. | Method | Description | |---|---| | `list(ListOptions)` | List webhooks. Returns `PaginationList`. | +| `iterate(ListOptions, Long chunkSize)` | Lazy `Iterator` over all webhooks; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). Also available on the read-only nested collections. | | `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`. +`NestedWebhookCollectionClient`. The Apify API only supports **reading** webhooks on those nested +paths (`GET /v2/actors/{id}/webhooks`, `GET /v2/actor-tasks/{id}/webhooks`), so this read-only type +exposes `list(ListOptions)` and `iterate(ListOptions, Long chunkSize)` — 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`, @@ -56,6 +61,7 @@ System.out.println(dispatch.getId()); | Method | Description | |---|---| | `webhookDispatches().list(ListOptions)` | List dispatches. Returns `PaginationList`. | +| `webhookDispatches().iterate(ListOptions, Long chunkSize)` | Lazy `Iterator` over all dispatches; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | | `webhookDispatch(id).get()` | Fetch a dispatch. Returns `Optional`. | `WebhookDispatch` fields: `getId()`, `getWebhookId()`. diff --git a/pom.xml b/pom.xml index 69c1d5a..91e1476 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.1.3 + 0.3.0 jar Apify Java Client diff --git a/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java b/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java index 14750dd..0c88298 100644 --- a/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java +++ b/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** * 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 @@ -19,4 +21,20 @@ public PaginationList list(ListOptions options) { options.apply(params); return ctx.listResource("", params, Webhook.class); } + + /** + * Returns a lazy iterator over the webhooks. The options' {@code limit} caps the total number + * yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page size + * ({@code null} = server default). + */ + public Iterator iterate(ListOptions options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptions)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(ListOptions options, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, Webhook.class); + } } diff --git a/src/main/java/com/apify/client/ActorCollectionClient.java b/src/main/java/com/apify/client/ActorCollectionClient.java index ea631cd..799e2f2 100644 --- a/src/main/java/com/apify/client/ActorCollectionClient.java +++ b/src/main/java/com/apify/client/ActorCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the Actor collection ({@code GET/POST /v2/actors}). */ public final class ActorCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,24 @@ public PaginationList list(ActorListOptions options) { return ctx.listResource("", params, Actor.class); } + /** + * Returns a lazy iterator over the account's Actors, fetching pages on demand at the server's + * default page size. The options' {@code limit} caps the total number of Actors yielded ({@code + * null} or non-positive = all). + */ + public Iterator iterate(ActorListOptions options) { + return iterate(options, null); + } + + /** + * As {@link #iterate(ActorListOptions)}, but {@code chunkSize} sets the per-request page size. + */ + public Iterator iterate(ActorListOptions options, Long chunkSize) { + ActorListOptions opts = options != null ? options : new ActorListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, 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/ActorEnvVarCollectionClient.java b/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java index 1058867..4c31dea 100644 --- a/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java +++ b/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** * A client for an Actor version's environment variable collection ({@code GET/POST * /v2/actors/{actorId}/versions/{versionNumber}/env-vars}). @@ -16,6 +18,15 @@ public PaginationList list() { return ctx.listResource("", new QueryParams(), ActorEnvVar.class); } + /** + * Returns an iterator over the version's environment variables. The env-var collection is not + * paginated (the API returns every variable in one response), so this iterates a single fetched + * page; the method exists for API consistency with the other collection clients. + */ + public Iterator iterate() { + return list().getItems().iterator(); + } + /** 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 index efddf2b..cdf9c57 100644 --- a/src/main/java/com/apify/client/ActorListOptions.java +++ b/src/main/java/com/apify/client/ActorListOptions.java @@ -14,7 +14,10 @@ public ActorListOptions offset(Long offset) { return this; } - /** Maximum number of Actors to return. */ + /** + * Maximum number of Actors to return. Sent verbatim by {@code list(...)} (so {@code 0} returns + * zero Actors); in {@code iterate(...)} a non-positive/zero {@code limit} means "no cap" (all). + */ public ActorListOptions limit(Long limit) { this.limit = limit; return this; @@ -38,11 +41,23 @@ public ActorListOptions sortBy(String sortBy) { return this; } + Long offsetValue() { + return offset; + } + + Long limitValue() { + return limit; + } + void apply(QueryParams q) { - q.addLong("offset", offset) - .addLong("limit", limit) - .addBool("desc", desc) - .addBool("my", my) - .addString("sortBy", sortBy); + q.addLong("offset", offset).addLong("limit", limit); + applyFilters(q); + } + + /** + * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. + */ + void applyFilters(QueryParams q) { + q.addBool("desc", desc).addBool("my", my).addString("sortBy", sortBy); } } diff --git a/src/main/java/com/apify/client/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/ActorVersionCollectionClient.java index 22c7fdf..5c4b936 100644 --- a/src/main/java/com/apify/client/ActorVersionCollectionClient.java +++ b/src/main/java/com/apify/client/ActorVersionCollectionClient.java @@ -1,5 +1,8 @@ package com.apify.client; +import java.util.Iterator; +import java.util.List; + /** A client for an Actor's version collection ({@code GET/POST /v2/actors/{actorId}/versions}). */ public final class ActorVersionCollectionClient { private final ResourceContext ctx; @@ -15,6 +18,30 @@ public PaginationList list(ListOptions options) { return ctx.listResource("", params, ActorVersion.class); } + /** + * Returns an iterator over the Actor's versions. Unlike the paginated collection iterators, this + * fetches eagerly — the single request runs when {@code iterate} is called (see below). + * + *

{@code GET /v2/actors/{actorId}/versions} is not offset/limit paginated: it takes + * no pagination parameters and returns the full version list in a single {@code {total, items}} + * response (the server ignores {@code offset}). This iterates that one fetched page, so draining + * the iterator always terminates and never re-yields a version. (Routing it through the offset/ + * limit paging engine would loop forever, since the server returns the same non-empty page at + * every offset — this is why the sibling non-paginated {@code env-vars} collection is also a + * single-fetch iterator.) The options' {@code limit} still caps the number yielded ({@code null} + * or non-positive = all); {@code offset} has no effect (the server ignores it) and there is no + * page size to tune. + */ + public Iterator iterate(ListOptions options) { + ListOptions opts = options != null ? options : new ListOptions(); + List items = list(opts).getItems(); + Long limit = opts.limitValue(); + if (limit != null && limit > 0 && items.size() > limit) { + items = items.subList(0, (int) (long) limit); + } + return items.iterator(); + } + /** 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/BuildCollectionClient.java b/src/main/java/com/apify/client/BuildCollectionClient.java index 3552395..91d7656 100644 --- a/src/main/java/com/apify/client/BuildCollectionClient.java +++ b/src/main/java/com/apify/client/BuildCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** * 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}). @@ -17,4 +19,20 @@ public PaginationList list(ListOptions options) { options.apply(params); return ctx.listResource("", params, Build.class); } + + /** + * Returns a lazy iterator over the builds. The options' {@code limit} caps the total number + * yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page size + * ({@code null} = server default). + */ + public Iterator iterate(ListOptions options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptions)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(ListOptions options, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, Build.class); + } } diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/DatasetClient.java index 24d57b1..00f2132 100644 --- a/src/main/java/com/apify/client/DatasetClient.java +++ b/src/main/java/com/apify/client/DatasetClient.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; +import java.util.Iterator; import java.util.List; import java.util.Optional; @@ -70,6 +71,61 @@ public PaginationList listItems(DatasetListItemsOptions options) { public PaginationList listItems(DatasetListItemsOptions options, Class itemClass) { QueryParams params = new QueryParams(); options.apply(params); + return fetchItemsPage(params, options.descValue(), itemClass); + } + + /** + * Returns a lazy iterator over the dataset's items, decoding each into a generic {@link + * JsonNode}. For typed decoding use {@link #iterateItems(DatasetListItemsOptions, Long, Class)}. + */ + public Iterator iterateItems(DatasetListItemsOptions options, Long chunkSize) { + return iterateItems(options, chunkSize, JsonNode.class); + } + + /** As {@link #iterateItems(DatasetListItemsOptions, Long)} with the server-default page size. */ + public Iterator iterateItems(DatasetListItemsOptions options) { + return iterateItems(options, null, JsonNode.class); + } + + /** + * Returns a lazy iterator over the dataset's items, decoding each into {@code itemClass}, + * fetching pages on demand. The options' {@code limit} caps the total number of items yielded + * ({@code null} or non-positive = all); {@code chunkSize} is the per-request page size ({@code + * null} = server default). + * + *

Note: server-side item filters ({@code skipEmpty}, {@code skipHidden}, {@code clean}, {@code + * simplified}) are applied after {@code offset}/{@code limit}, so a page can return fewer items + * than requested. Combining those filters with iteration can repeat items (overlapping windows) + * and, if a whole offset window is filtered out, the endpoint returns an empty page which ends + * iteration early — silently skipping the remaining items. Iterate without server-side item + * filters, or page explicitly with {@link #listItems} and filter client-side. + */ + public Iterator iterateItems( + DatasetListItemsOptions options, Long chunkSize, Class itemClass) { + DatasetListItemsOptions opts = options != null ? options : new DatasetListItemsOptions(); + // Snapshot the filters/offset/limit/desc once so mutating the options mid-iteration cannot + // leak. + QueryParams filters = new QueryParams(); + opts.applyFilters(filters); + Boolean desc = opts.descValue(); + return new PaginatedIterator<>( + opts.limitValue(), + chunkSize, + opts.offsetValue(), + (offset, pageLimit) -> { + QueryParams p = new QueryParams().addLong("offset", offset).addLong("limit", pageLimit); + p.extend(filters); + return fetchItemsPage(p, desc, itemClass); + }); + } + + /** + * Fetches a single page of dataset items for the already-built query {@code params}. 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}. + */ + private PaginationList fetchItemsPage( + QueryParams params, Boolean desc, Class itemClass) { String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); ApiResponse resp = http.call("GET", url, null, "", http.baseRequestTimeout()); @@ -83,8 +139,8 @@ public PaginationList listItems(DatasetListItemsOptions options, Class 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()); + if (desc != null) { + result.setDesc(desc); } return result; } diff --git a/src/main/java/com/apify/client/DatasetCollectionClient.java b/src/main/java/com/apify/client/DatasetCollectionClient.java index 6c7972f..bdd4aaa 100644 --- a/src/main/java/com/apify/client/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/DatasetCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the dataset collection ({@code GET/POST /v2/datasets}). */ public final class DatasetCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,24 @@ public PaginationList list(StorageListOptions options) { return ctx.listResource("", params, Dataset.class); } + /** + * Returns a lazy iterator over the datasets. The options' {@code limit} caps the total number + * yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page size + * ({@code null} = server default). + */ + public Iterator iterate(StorageListOptions options) { + return iterate(options, null); + } + + /** + * As {@link #iterate(StorageListOptions)}, but {@code chunkSize} sets the per-request page size. + */ + public Iterator iterate(StorageListOptions options, Long chunkSize) { + StorageListOptions opts = options != null ? options : new StorageListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, 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. diff --git a/src/main/java/com/apify/client/DatasetListItemsOptions.java b/src/main/java/com/apify/client/DatasetListItemsOptions.java index a449924..94bc0ab 100644 --- a/src/main/java/com/apify/client/DatasetListItemsOptions.java +++ b/src/main/java/com/apify/client/DatasetListItemsOptions.java @@ -29,7 +29,11 @@ public DatasetListItemsOptions offset(Long offset) { return this; } - /** Maximum number of items to return. */ + /** + * Maximum number of items to return. Sent verbatim by {@code listItems(...)} (so {@code 0} + * returns zero items); in {@code iterateItems(...)} a non-positive/zero {@code limit} means "no + * cap" (all). + */ public DatasetListItemsOptions limit(Long limit) { this.limit = limit; return this; @@ -120,10 +124,24 @@ Boolean descValue() { return desc; } + Long offsetValue() { + return offset; + } + + Long limitValue() { + return limit; + } + void apply(QueryParams q) { - q.addLong("offset", offset) - .addLong("limit", limit) - .addBool("desc", desc) + q.addLong("offset", offset).addLong("limit", limit); + applyFilters(q); + } + + /** + * Applies every option except {@code offset}/{@code limit}, which the iterator drives per page. + */ + void applyFilters(QueryParams q) { + q.addBool("desc", desc) .addCsv("fields", fields) .addCsv("outputFields", outputFields) .addCsv("omit", omit) diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/KeyValueStoreClient.java index 16c8f80..85c22fa 100644 --- a/src/main/java/com/apify/client/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreClient.java @@ -1,5 +1,8 @@ package com.apify.client; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; import java.util.Optional; /** A client for a specific key-value store (and run-nested variants). */ @@ -53,6 +56,105 @@ public KeyValueStoreKeysPage listKeys(ListKeysOptions options) { return ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class); } + /** + * Returns a lazy iterator over this store's keys, fetching pages on demand via the cursor-based + * ({@code exclusiveStartKey}) listing endpoint. The options' {@code limit} caps the total number + * of keys yielded ({@code null} or non-positive = all); any {@code exclusiveStartKey} sets the + * starting point. The per-request page size is left to the server (bounded by any {@code limit} + * cap); use {@link #iterateKeys(ListKeysOptions, Long)} to set it explicitly. + */ + public Iterator iterateKeys(ListKeysOptions options) { + return iterateKeys(options, null); + } + + /** + * As {@link #iterateKeys(ListKeysOptions)}, but {@code chunkSize} sets the per-request page size + * ({@code null} = server default). Provided for consistency with the collection {@code iterate} + * helpers; key listing is cursor-based, so the options' {@code limit} remains a total-items cap. + */ + public Iterator iterateKeys(ListKeysOptions options, Long chunkSize) { + return new KeysIterator(options != null ? options : new ListKeysOptions(), chunkSize); + } + + /** + * Lazily iterates over a store's keys via the cursor-based ({@code exclusiveStartKey}) listing. + */ + private final class KeysIterator implements Iterator { + private final Long chunkSize; + private final QueryParams filters; + private List buffer = List.of(); + private int pos; + private String cursor; + private Long remaining; + private boolean exhausted; + + KeysIterator(ListKeysOptions options, Long chunkSize) { + this.chunkSize = chunkSize != null && chunkSize > 0 ? chunkSize : null; + this.cursor = options.exclusiveStartKeyValue(); + Long limit = options.limitValue(); + this.remaining = limit != null && limit > 0 ? limit : null; + // Snapshot the filters once so mutating the options mid-iteration cannot leak into later + // pages. + this.filters = new QueryParams(); + options.applyFilters(this.filters); + } + + @Override + public boolean hasNext() { + while (pos >= buffer.size()) { + if (exhausted) { + return false; + } + fetchPage(); + } + return true; + } + + @Override + public KeyValueStoreKey next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return buffer.get(pos++); + } + + private void fetchPage() { + QueryParams params = new QueryParams(); + // Request the smaller of the remaining cap and the page size, so the last page never + // overshoots the caller's total cap; a null limit lets the server choose its default page. + Long pageLimit = remaining; + if (chunkSize != null && (pageLimit == null || chunkSize < pageLimit)) { + pageLimit = chunkSize; + } + params.addLong("limit", pageLimit); + params.addString("exclusiveStartKey", cursor); + params.extend(filters); + KeyValueStoreKeysPage page = + ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class); + buffer = page.getItems(); + pos = 0; + cursor = page.getNextExclusiveStartKey(); + boolean truncated = page.isTruncated(); + if (remaining != null) { + // Defensively trim the last page to the cap in case the server returned more than + // requested. + if (buffer.size() > remaining) { + buffer = buffer.subList(0, remaining.intValue()); + } + remaining -= buffer.size(); + } + // Stop when the API reports the listing is not truncated (no more keys), there is no next + // cursor, the page is empty, or the total cap is reached. + if (!truncated + || cursor == null + || cursor.isEmpty() + || buffer.isEmpty() + || (remaining != null && remaining <= 0)) { + exhausted = true; + } + } + } + /** Reports whether a record with the given key exists. */ public boolean recordExists(String key) { return ctx.headExists("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 63e2b29..7066cb0 100644 --- a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the key-value store collection ({@code GET/POST /v2/key-value-stores}). */ public final class KeyValueStoreCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,29 @@ public PaginationList list(StorageListOptions options) { return ctx.listResource("", params, KeyValueStore.class); } + /** + * Returns a lazy iterator over the key-value stores. The options' {@code limit} caps the total + * number yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page + * size ({@code null} = server default). + */ + public Iterator iterate(StorageListOptions options) { + return iterate(options, null); + } + + /** + * As {@link #iterate(StorageListOptions)}, but {@code chunkSize} sets the per-request page size. + */ + public Iterator iterate(StorageListOptions options, Long chunkSize) { + StorageListOptions opts = options != null ? options : new StorageListOptions(); + return ctx.iterateResource( + "", + opts.limitValue(), + chunkSize, + opts.offsetValue(), + opts::applyFilters, + 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. diff --git a/src/main/java/com/apify/client/ListKeysOptions.java b/src/main/java/com/apify/client/ListKeysOptions.java index a9d3d70..ca2d1d7 100644 --- a/src/main/java/com/apify/client/ListKeysOptions.java +++ b/src/main/java/com/apify/client/ListKeysOptions.java @@ -8,7 +8,10 @@ public final class ListKeysOptions { private String collection; private String signature; - /** Maximum number of keys to return. */ + /** + * Maximum number of keys to return. Sent verbatim by {@code listKeys(...)} (so {@code 0} returns + * zero keys); in {@code iterateKeys(...)} a non-positive/zero {@code limit} means "no cap" (all). + */ public ListKeysOptions limit(Long limit) { this.limit = limit; return this; @@ -42,10 +45,25 @@ String signatureValue() { return signature; } + Long limitValue() { + return limit; + } + + String exclusiveStartKeyValue() { + return exclusiveStartKey; + } + void apply(QueryParams q) { - q.addLong("limit", limit) - .addString("exclusiveStartKey", exclusiveStartKey) - .addString("prefix", prefix) + q.addLong("limit", limit).addString("exclusiveStartKey", exclusiveStartKey); + applyFilters(q); + } + + /** + * Applies every filter except {@code limit}/{@code exclusiveStartKey}, which the key iterator + * drives per page. + */ + void applyFilters(QueryParams q) { + q.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 index f18a0dc..6e952c1 100644 --- a/src/main/java/com/apify/client/ListOptions.java +++ b/src/main/java/com/apify/client/ListOptions.java @@ -16,7 +16,12 @@ public ListOptions offset(Long offset) { return this; } - /** Maximum number of items to return. */ + /** + * Maximum number of items to return. In {@code list(...)} this is sent verbatim (so {@code 0} + * returns zero items); in {@code iterate(...)} a non-positive/zero {@code limit} means "no cap" — + * iteration yields every item (matching the reference client's falsy-limit-means-unbounded + * intent). + */ public ListOptions limit(Long limit) { this.limit = limit; return this; @@ -28,7 +33,23 @@ public ListOptions desc(Boolean desc) { return this; } + Long offsetValue() { + return offset; + } + + Long limitValue() { + return limit; + } + void apply(QueryParams q) { - q.addLong("offset", offset).addLong("limit", limit).addBool("desc", desc); + q.addLong("offset", offset).addLong("limit", limit); + applyFilters(q); + } + + /** + * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. + */ + void applyFilters(QueryParams q) { + q.addBool("desc", desc); } } diff --git a/src/main/java/com/apify/client/PaginatedIterator.java b/src/main/java/com/apify/client/PaginatedIterator.java new file mode 100644 index 0000000..dd990ba --- /dev/null +++ b/src/main/java/com/apify/client/PaginatedIterator.java @@ -0,0 +1,113 @@ +package com.apify.client; + +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * A lazy {@link Iterator} over an offset/limit-paginated list endpoint, fetching one page at a + * time. Internal reusable engine shared by every offset/limit-paginated collection's {@code + * iterate} method, so the paging arithmetic lives in one place (DRY). (The cursor-based {@code + * iterateKeys} and the single-fetch {@code versions}/{@code env-vars} iterators do not use it.) + * + *

Behaviour, mirroring the end-user contract of the reference JS client's iterable {@code + * list()}: + * + *

    + *
  • {@code totalLimit} caps the total number of items yielded across all pages ({@code + * null} = yield everything). + *
  • {@code chunkSize} is the per-request page size ({@code null} = let the API choose). Each + * page requests {@code min(remaining-under-cap, chunkSize)} items so the cap is never + * overshot. + *
+ * + *

Each page advances the offset by the number of items actually returned, and iteration stops + * only when the cap is reached or the API returns an empty page. It deliberately does not + * stop on a short page or a reported {@code total}: the API clamps an over-large requested page + * size to its own maximum (so a "short" page is common mid-collection), and some endpoints report a + * {@code total} of {@code 0} or a value that lags right after a write (e.g. dataset items) — either + * signal would truncate iteration. Terminating on an empty page costs one extra request at the end + * but yields the complete result in every case. + */ +final class PaginatedIterator implements Iterator { + + /** Fetches a single page starting at {@code offset}, requesting at most {@code limit} items. */ + @FunctionalInterface + interface PageFetcher { + PaginationList fetch(long offset, Long limit); + } + + private final PageFetcher fetcher; + private final Long totalLimit; + private final Long chunkSize; + + private List buffer = List.of(); + private int pos; + private long offset; + private long yielded; + private boolean exhausted; + + PaginatedIterator(Long totalLimit, Long chunkSize, Long startOffset, PageFetcher fetcher) { + this.totalLimit = totalLimit != null && totalLimit > 0 ? totalLimit : null; + this.chunkSize = chunkSize != null && chunkSize > 0 ? chunkSize : null; + this.offset = startOffset != null && startOffset > 0 ? startOffset : 0; + this.fetcher = fetcher; + } + + @Override + public boolean hasNext() { + while (pos >= buffer.size()) { + if (exhausted) { + return false; + } + fetchNextPage(); + } + return true; + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return buffer.get(pos++); + } + + private void fetchNextPage() { + Long capRemaining = totalLimit != null ? totalLimit - yielded : null; + Long pageLimit = minForLimit(capRemaining, chunkSize); + PaginationList page = fetcher.fetch(offset, pageLimit); + buffer = page.getItems(); + pos = 0; + int count = buffer.size(); + offset += count; + yielded += count; + // Defensively trim the last page to the cap in case the server returned more than requested, so + // the caller never sees more than `totalLimit` items. + if (totalLimit != null && yielded > totalLimit) { + buffer = buffer.subList(0, count - (int) (yielded - totalLimit)); + yielded = totalLimit; + } + // Stop on the caller's cap or an empty page; never on a short page (the API clamps large page + // sizes) or the reported total (unreliable on some endpoints — see class doc). + if (count == 0 || (totalLimit != null && yielded >= totalLimit)) { + exhausted = true; + } + } + + /** + * Returns the smaller of the two per-page bounds, or {@code null} (server default) when neither + * is set. Both inputs are already positive-or-{@code null}: {@code chunkSize} is normalized in + * the constructor and {@code capRemaining} is only ever {@code > 0} here (the {@code exhausted} + * guard blocks fetching once the cap is reached). + */ + private static Long minForLimit(Long a, Long b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return Math.min(a, b); + } +} diff --git a/src/main/java/com/apify/client/RequestQueueCollectionClient.java b/src/main/java/com/apify/client/RequestQueueCollectionClient.java index a713b32..b0ca489 100644 --- a/src/main/java/com/apify/client/RequestQueueCollectionClient.java +++ b/src/main/java/com/apify/client/RequestQueueCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the request queue collection ({@code GET/POST /v2/request-queues}). */ public final class RequestQueueCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,29 @@ public PaginationList list(StorageListOptions options) { return ctx.listResource("", params, RequestQueue.class); } + /** + * Returns a lazy iterator over the request queues. The options' {@code limit} caps the total + * number yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page + * size ({@code null} = server default). + */ + public Iterator iterate(StorageListOptions options) { + return iterate(options, null); + } + + /** + * As {@link #iterate(StorageListOptions)}, but {@code chunkSize} sets the per-request page size. + */ + public Iterator iterate(StorageListOptions options, Long chunkSize) { + StorageListOptions opts = options != null ? options : new StorageListOptions(); + return ctx.iterateResource( + "", + opts.limitValue(), + chunkSize, + opts.offsetValue(), + opts::applyFilters, + 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. diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/ResourceContext.java index e75cf4c..1c2e8d7 100644 --- a/src/main/java/com/apify/client/ResourceContext.java +++ b/src/main/java/com/apify/client/ResourceContext.java @@ -4,7 +4,9 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Iterator; import java.util.Optional; +import java.util.function.Consumer; import java.util.function.Predicate; /** @@ -167,6 +169,34 @@ PaginationList listResource(String subPath, QueryParams params, Class return getResourceRequired(subPath, params, listType); } + /** + * Builds a lazy iterator over an offset/limit-paginated endpoint. {@code applyFilters} adds the + * per-endpoint filter params (everything except {@code offset}/{@code limit}, which the iterator + * drives per page). {@code totalLimit} caps the total items yielded; {@code chunkSize} is the + * page size (both {@code null} meaning "unbounded" / "server default"). + */ + Iterator iterateResource( + String subPath, + Long totalLimit, + Long chunkSize, + Long startOffset, + Consumer applyFilters, + Class itemClass) { + // Snapshot the caller's filters once, so mutating the options object mid-iteration cannot leak + // into later pages (the iterator owns an independent copy of every filter, offset and limit). + QueryParams filters = new QueryParams(); + applyFilters.accept(filters); + return new PaginatedIterator<>( + totalLimit, + chunkSize, + startOffset, + (offset, pageLimit) -> { + QueryParams p = new QueryParams().addLong("offset", offset).addLong("limit", pageLimit); + p.extend(filters); + return listResource(subPath, p, itemClass); + }); + } + T createResource(QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl("")); ApiResponse resp = diff --git a/src/main/java/com/apify/client/RunClient.java b/src/main/java/com/apify/client/RunClient.java index 374e77f..68b3bd2 100644 --- a/src/main/java/com/apify/client/RunClient.java +++ b/src/main/java/com/apify/client/RunClient.java @@ -1,10 +1,11 @@ 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; +import java.util.function.Consumer; +import java.util.logging.Logger; /** * A client for a specific Actor run. @@ -182,15 +183,73 @@ public LogClient log() { return LogClient.nested(ctx.http, ctx.subUrl(""), ctx.baseParams); } + /** Logger name used by the default log-redirection destination. */ + private static final String REDIRECT_LOGGER_NAME = "com.apify.client.ActorRunLog"; + + /** + * Returns a {@link StreamedLog} that redirects this run's live log to a default per-run logger. + * + *

The default destination is a {@link java.util.logging.Logger} that receives each message at + * {@code INFO} level, prefixed with the Actor name and run id (built by fetching the run and its + * Actor). Call {@link StreamedLog#start()} to begin redirection and {@link StreamedLog#stop()} + * (or {@link StreamedLog#close()}) to end it. + * + *

For a custom destination or to skip historical log lines, use {@link + * #getStreamedLog(StreamedLogOptions)}. For raw stream access without redirection, use {@link + * #log()}{@code .stream(...)}. + */ + public StreamedLog getStreamedLog() { + return getStreamedLog(new StreamedLogOptions()); + } + /** - * Opens a live stream of this run's raw log, for convenient log redirection. The caller must - * close the returned stream. + * Returns a {@link StreamedLog} that redirects this run's live log according to {@code options}. * - *

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}. + *

If {@link StreamedLogOptions#toLog(Consumer)} is set, each complete log message is passed to + * that consumer; otherwise a default {@link java.util.logging.Logger} destination is used with a + * per-run prefix (an explicit {@link StreamedLogOptions#prefix(String)} overrides the auto-built + * one). {@link StreamedLogOptions#fromStart(boolean)} controls whether log lines produced before + * redirection started are included. */ - public InputStream getStreamedLog() { - return log().stream(new LogOptions().raw(true)); + public StreamedLog getStreamedLog(StreamedLogOptions options) { + Consumer destination = options.destination(); + if (destination == null) { + String prefix = + options.prefixValue() != null ? options.prefixValue() : buildDefaultLogPrefix(); + destination = defaultLogDestination(prefix); + } + return new StreamedLog(log(), destination, options.fromStartValue()); + } + + /** + * Builds the per-run prefix used by the default redirection destination, mirroring the reference + * client: the Actor name (looked up from the run) and {@code runId:{id}}, joined with a space and + * followed by {@code " -> "}. Falls back to just the run id when the Actor name is unavailable. + */ + private String buildDefaultLogPrefix() { + String runPart = "runId:" + id; + String actorName = ""; + try { + Optional run = get(); + if (run.isPresent() && run.get().getActId() != null && !run.get().getActId().isEmpty()) { + Optional actor = root.actor(run.get().getActId()).get(); + if (actor.isPresent() && actor.get().getName() != null) { + actorName = actor.get().getName(); + } + } + } catch (RuntimeException e) { + // The Actor-name lookup is cosmetic. The getters swallow 404, but an auth (401/403), + // transport, or 5xx-after-retries failure would otherwise throw out of getStreamedLog() and + // abort helper creation even though streaming itself might have worked. Fall back to the + // runId-only prefix (actorName stays "") so the helper is always created. + } + String name = actorName.isEmpty() ? runPart : actorName + " " + runPart; + return name + " -> "; + } + + /** A destination that logs each message at {@code INFO} level, prefixed with {@code prefix}. */ + private static Consumer defaultLogDestination(String prefix) { + Logger logger = Logger.getLogger(REDIRECT_LOGGER_NAME); + return message -> logger.info(prefix + message); } } diff --git a/src/main/java/com/apify/client/RunCollectionClient.java b/src/main/java/com/apify/client/RunCollectionClient.java index 1bcde19..101a5a2 100644 --- a/src/main/java/com/apify/client/RunCollectionClient.java +++ b/src/main/java/com/apify/client/RunCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** * 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 @@ -26,4 +28,34 @@ public PaginationList list(ListOptions options, RunListOptions filter) } return ctx.listResource("", params, ActorRun.class); } + + /** + * Returns a lazy iterator over the runs, applying the standard pagination and run-specific + * filters. The options' {@code limit} caps the total number yielded ({@code null} or non-positive + * = all); {@code chunkSize} is the per-request page size ({@code null} = server default). Both + * {@code options} and {@code filter} may be {@code null}. + */ + public Iterator iterate(ListOptions options, RunListOptions filter) { + return iterate(options, filter, null); + } + + /** + * As {@link #iterate(ListOptions, RunListOptions)}, but {@code chunkSize} sets the per-request + * page size. + */ + public Iterator iterate(ListOptions options, RunListOptions filter, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", + opts.limitValue(), + chunkSize, + opts.offsetValue(), + p -> { + opts.applyFilters(p); + if (filter != null) { + filter.apply(p); + } + }, + ActorRun.class); + } } diff --git a/src/main/java/com/apify/client/RunListOptions.java b/src/main/java/com/apify/client/RunListOptions.java index 3f21d70..1322548 100644 --- a/src/main/java/com/apify/client/RunListOptions.java +++ b/src/main/java/com/apify/client/RunListOptions.java @@ -4,8 +4,9 @@ /** * 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. + * {@code startedAfter}/{@code startedBefore} filters are honoured by the top-level ({@code + * /v2/actor-runs}) and Actor-scoped ({@code /v2/actors/{actorId}/runs}) run collections; the + * task-scoped run collection accepts only {@code status}. */ public final class RunListOptions { private List status; diff --git a/src/main/java/com/apify/client/ScheduleCollectionClient.java b/src/main/java/com/apify/client/ScheduleCollectionClient.java index b82d569..bcd3d61 100644 --- a/src/main/java/com/apify/client/ScheduleCollectionClient.java +++ b/src/main/java/com/apify/client/ScheduleCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the schedule collection ({@code GET/POST /v2/schedules}). */ public final class ScheduleCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,22 @@ public PaginationList list(ListOptions options) { return ctx.listResource("", params, Schedule.class); } + /** + * Returns a lazy iterator over the account's schedules. The options' {@code limit} caps the total + * number yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page + * size ({@code null} = server default). + */ + public Iterator iterate(ListOptions options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptions)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(ListOptions options, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, 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/StorageListOptions.java b/src/main/java/com/apify/client/StorageListOptions.java index 8993a81..f9b9e6e 100644 --- a/src/main/java/com/apify/client/StorageListOptions.java +++ b/src/main/java/com/apify/client/StorageListOptions.java @@ -18,7 +18,11 @@ public StorageListOptions offset(Long offset) { return this; } - /** Maximum number of items to return. */ + /** + * Maximum number of items to return. Sent verbatim by {@code list(...)} (so {@code 0} returns + * zero items); in {@code iterate(...)} a non-positive/zero {@code limit} means "no cap" (all + * items). + */ public StorageListOptions limit(Long limit) { this.limit = limit; return this; @@ -42,11 +46,23 @@ public StorageListOptions ownership(String ownership) { return this; } + Long offsetValue() { + return offset; + } + + Long limitValue() { + return limit; + } + void apply(QueryParams q) { - q.addLong("offset", offset) - .addLong("limit", limit) - .addBool("desc", desc) - .addBool("unnamed", unnamed) - .addString("ownership", ownership); + q.addLong("offset", offset).addLong("limit", limit); + applyFilters(q); + } + + /** + * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. + */ + void applyFilters(QueryParams q) { + q.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 index 2d43ca7..56cb5d5 100644 --- a/src/main/java/com/apify/client/StoreCollectionClient.java +++ b/src/main/java/com/apify/client/StoreCollectionClient.java @@ -1,8 +1,6 @@ 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 { @@ -20,63 +18,25 @@ public PaginationList list(StoreListOptions options) { } /** - * 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. + * Returns a lazy iterator over Store Actors matching the options, fetching pages on demand. The + * options' {@code limit} caps the total number of Actors yielded ({@code null} or non-positive = + * all); {@code chunkSize} is the per-request page size ({@code null} = server default). */ public Iterator iterate(StoreListOptions options) { - return new StoreIterator(options); + return iterate(options, null); } - /** 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 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; - offset += page.getItems().size(); - // 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; - } - } + /** + * As {@link #iterate(StoreListOptions)}, but {@code chunkSize} sets the per-request page size. + */ + public Iterator iterate(StoreListOptions options, Long chunkSize) { + StoreListOptions opts = options != null ? options : new StoreListOptions(); + return ctx.iterateResource( + "", + opts.limitValue(), + chunkSize, + opts.offsetValue(), + opts::applyFilters, + ActorStoreListItem.class); } } diff --git a/src/main/java/com/apify/client/StoreListOptions.java b/src/main/java/com/apify/client/StoreListOptions.java index 00b76a7..9187464 100644 --- a/src/main/java/com/apify/client/StoreListOptions.java +++ b/src/main/java/com/apify/client/StoreListOptions.java @@ -19,7 +19,10 @@ public StoreListOptions offset(Long offset) { return this; } - /** Maximum number of Actors to return. */ + /** + * Maximum number of Actors to return. Sent verbatim by {@code list(...)} (so {@code 0} returns + * zero Actors); in {@code iterate(...)} a non-positive/zero {@code limit} means "no cap" (all). + */ public StoreListOptions limit(Long limit) { this.limit = limit; return this; @@ -84,31 +87,16 @@ 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); + applyFilters(q); } - void apply(QueryParams q) { - q.addLong("offset", offset) - .addLong("limit", limit) - .addString("search", search) + /** + * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. + */ + void applyFilters(QueryParams q) { + q.addString("search", search) .addString("sortBy", sortBy) .addString("category", category) .addString("username", username) diff --git a/src/main/java/com/apify/client/StreamedLog.java b/src/main/java/com/apify/client/StreamedLog.java new file mode 100644 index 0000000..4e23fba --- /dev/null +++ b/src/main/java/com/apify/client/StreamedLog.java @@ -0,0 +1,319 @@ +package com.apify.client; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper for redirecting a run's (or build's) streamed log to another destination, mirroring the + * reference client's {@code StreamedLog}. + * + *

It follows the live raw log stream in the background and forwards each complete, timestamped + * log message to a destination {@link Consumer} (by default a per-run prefixed {@link Logger}). + * Messages are parsed on log-line boundaries (the Apify platform prefixes every line with an + * ISO-8601 timestamp), so multi-line messages are kept intact across stream chunks. + * + *

Lifecycle: call {@link #start()} to begin redirection and {@link #stop()} to end it. The class + * is {@link AutoCloseable}, so it can be used in a try-with-resources block; {@link #close()} stops + * redirection if it is still running. + * + *

Typical use: + * + *

{@code
+ * RunClient runClient = client.run(runId);
+ * try (StreamedLog streamedLog = runClient.getStreamedLog()) {
+ *   streamedLog.start();
+ *   runClient.waitForFinish(120L);
+ * }
+ * }
+ */ +public final class StreamedLog implements AutoCloseable { + + /** + * Marks the start of a log message: an ISO-8601 timestamp at the beginning of a line (or of the + * stream). The platform prefixes every log line with such a timestamp, so this reliably splits + * concatenated messages while keeping multi-line messages together. + */ + private static final Pattern MESSAGE_MARKER = + Pattern.compile("(?:\\n|^)(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)"); + + /** Read buffer size for pulling bytes off the live log stream. */ + private static final int READ_BUFFER_BYTES = 8192; + + private final LogClient logClient; + private final Consumer destination; + + /** + * Messages older than this instant are dropped ({@code null} means "redirect from the start", so + * nothing is dropped). Set to construction time when {@code fromStart} is {@code false}. + */ + private final Instant relevancyTimeLimit; + + private volatile boolean stopLogging; + + /** + * Set once a destination consumer throws, so redirection unwinds on the first failure and + * forwards nothing further (matching the reference client). Reset per {@link #start()}. + */ + private volatile boolean forwardingFailed; + + private volatile InputStream activeStream; + private Thread streamingThread; + + StreamedLog(LogClient logClient, Consumer destination, boolean fromStart) { + this.logClient = logClient; + this.destination = destination; + this.relevancyTimeLimit = fromStart ? null : Instant.now(); + } + + /** + * Starts redirecting the log in a background daemon thread. + * + *

The live log stream is opened synchronously before the thread is launched, so this call + * blocks briefly on the HTTP round-trip and surfaces a failure to open the stream to the caller + * rather than on the background thread. + * + * @throws IllegalStateException if redirection is already running + * @throws ApifyApiException if the log stream cannot be opened (the API returns a non-2xx + * status); other transport failures propagate as their own runtime exception + */ + public synchronized void start() { + if (streamingThread != null) { + throw new IllegalStateException("Streaming task already active"); + } + stopLogging = false; + forwardingFailed = false; + // Open the live stream here, before launching the reader thread, so activeStream is guaranteed + // to be set once the thread exists. If it were opened inside the thread, a stop() that ran + // during the HTTP round-trip would close a still-null stream, leaving the reader blocked on a + // live read() that never returns and join() hanging forever. Opening it up front also lets a + // failed connection surface to the caller of start() instead of a background-thread warning. + activeStream = logClient.stream(new LogOptions().raw(true)); + Thread thread = new Thread(this::streamLog, "apify-streamed-log"); + thread.setDaemon(true); + streamingThread = thread; + thread.start(); + } + + /** + * Stops log redirection, waiting for the background reader thread to finish. + * + *

Called from any other thread (the normal case) this joins the reader before returning. + * Called from inside the destination consumer - which runs on the reader thread - it + * cannot join itself, so it only signals the stop and returns immediately; redirection then + * unwinds as the reader returns from the consumer. For stopping from inside the consumer prefer + * {@link #close()}: the reader's final flush can re-invoke the consumer for the last buffered + * message, and a second {@code stop()} on the by-then-stopped helper throws, whereas {@code + * close()} is idempotent. + * + * @throws IllegalStateException if redirection is not running - including a repeat call from + * within the consumer after an initial self-stop has already cleared the running thread + */ + public synchronized void stop() { + if (streamingThread == null) { + throw new IllegalStateException("Streaming task is not active"); + } + stopStreaming(); + } + + /** + * Stops redirection if it is still running; a no-op otherwise. Fully idempotent and safe under a + * concurrent {@link #stop()} or a double {@code close()}: the running-check and the stop happen + * atomically under the monitor, so unlike {@link #stop()} this never throws. + */ + @Override + public synchronized void close() { + if (streamingThread != null) { + stopStreaming(); + } + } + + /** + * Signals the reader to stop, unblocks it by closing the live stream, and joins it (unless called + * from the reader thread itself, which cannot join itself). Callers must hold the monitor and + * must have already checked that a thread is running. + */ + private void stopStreaming() { + stopLogging = true; + // Close the live stream so a blocked read returns promptly instead of waiting for more bytes. + closeQuietly(activeStream); + // A thread must never join itself. The destination consumer runs on the streaming thread, so a + // user calling stop()/close() from inside that consumer (e.g. "stop redirecting once I see line + // X") would otherwise join() the current thread on itself and block forever - and because + // stop()/close() are synchronized, that hung thread keeps the monitor, deadlocking every later + // start/stop/close with no exception. Setting stopLogging + closing the stream already unwinds + // the reader loop, so in that self-stop case we skip only the join; the field is still cleared + // below so lifecycle state stays consistent. + if (Thread.currentThread() != streamingThread) { + try { + streamingThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + streamingThread = null; + } + + /** Reads the live raw log stream and forwards complete messages to the destination. */ + private void streamLog() { + // Bytes after the last newline: an incomplete trailing line kept for the next read so a message + // split across chunk boundaries (including a partial UTF-8 sequence) is not corrupted. Declared + // outside the try so the final flush in finally can still emit it (see below). + byte[] lineRemainder = new byte[0]; + // Decoded log text seen so far but not yet split into complete messages. Local to this reader + // (not a shared field) so that if a start() ever races a still-draining reader, the two readers + // never mutate the same non-thread-safe buffer. + StringBuilder pending = new StringBuilder(); + // The stream was opened in start() and stored in activeStream; own its lifecycle here via + // try-with-resources so the reader closes it when it exits (idempotent with stop()'s close, + // which may close it first to unblock a pending read). + try (InputStream stream = activeStream) { + byte[] readBuffer = new byte[READ_BUFFER_BYTES]; + int read; + // Process each chunk before honouring a stop request, so a stop issued right after start + // still redirects whatever the run has already produced (matching the reference client). + while ((read = stream.read(readBuffer)) != -1) { + byte[] combined = new byte[lineRemainder.length + read]; + System.arraycopy(lineRemainder, 0, combined, 0, lineRemainder.length); + System.arraycopy(readBuffer, 0, combined, lineRemainder.length, read); + + int lastNewline = lastIndexOf(combined, (byte) '\n'); + if (lastNewline >= 0) { + String completeText = new String(combined, 0, lastNewline + 1, StandardCharsets.UTF_8); + lineRemainder = new byte[combined.length - (lastNewline + 1)]; + System.arraycopy(combined, lastNewline + 1, lineRemainder, 0, lineRemainder.length); + emitMessages(pending, completeText, false); + } else { + lineRemainder = combined; + } + if (stopLogging) { + break; + } + } + } catch (IOException e) { + // A read error after an explicit stop is expected (the stream was closed under us). Surface + // only genuine, unsolicited failures, and do so without throwing from the background thread. + if (!stopLogging) { + Logger.getLogger(StreamedLog.class.getName()) + .log(Level.WARNING, "Log redirection stopped due to error", e); + } + } finally { + // Flush whatever is left when the stream ends OR when a stop closes the stream mid-read: the + // last complete message is held back in `pending` by emitMessages(..., false), and a stop + // unblocks the read via an IOException that skips the loop body. Running the final flush in + // finally guarantees that retained message (and any unterminated trailing line) is still + // delivered on stop, matching the reference client. Skipped once a consumer has thrown: there + // is nothing more to deliver and the flush must not re-invoke the failed consumer. + if (!forwardingFailed) { + emitMessages(pending, new String(lineRemainder, StandardCharsets.UTF_8), true); + } + } + } + + /** + * Appends {@code text} to the pending buffer, then forwards every complete message it contains. + * When {@code flush} is {@code false} the last message is held back (it may still be growing); + * when {@code true} everything remaining is emitted. + */ + private void emitMessages(StringBuilder pending, String text, boolean flush) { + pending.append(text); + String buffered = pending.toString(); + + Matcher matcher = MESSAGE_MARKER.matcher(buffered); + List messageStarts = new ArrayList<>(); + List timestamps = new ArrayList<>(); + while (matcher.find()) { + messageStarts.add(matcher.start(1)); + timestamps.add(matcher.group(1)); + } + + if (messageStarts.isEmpty()) { + // No complete message yet; keep buffering unless this is the final flush. + if (flush) { + emitIfRelevant(buffered.trim(), null); + pending.setLength(0); + } + return; + } + + int completeCount = flush ? messageStarts.size() : messageStarts.size() - 1; + for (int i = 0; i < completeCount; i++) { + int from = messageStarts.get(i); + int to = (i + 1 < messageStarts.size()) ? messageStarts.get(i + 1) : buffered.length(); + emitIfRelevant(buffered.substring(from, to).trim(), timestamps.get(i)); + if (forwardingFailed) { + // A consumer threw: unwind on the first failure without forwarding the rest of this batch. + return; + } + } + + if (flush) { + pending.setLength(0); + } else { + // Retain the last (possibly still-growing) message for the next round. + String rest = buffered.substring(messageStarts.get(messageStarts.size() - 1)); + pending.setLength(0); + pending.append(rest); + } + } + + /** + * Forwards a trimmed message to the destination, dropping it if it is empty or (when a relevancy + * limit is set) older than that limit. + */ + private void emitIfRelevant(String message, String timestamp) { + if (message.isEmpty()) { + return; + } + if (relevancyTimeLimit != null && timestamp != null) { + try { + if (Instant.parse(timestamp).isBefore(relevancyTimeLimit)) { + return; + } + } catch (DateTimeParseException ignored) { + // Unparseable timestamp: keep the message rather than silently dropping log output. + } + } + try { + destination.accept(message); + } catch (RuntimeException e) { + // The destination is a user-supplied consumer running on this background daemon thread; an + // uncaught throw would kill the thread. Match the reference client: stop redirecting on the + // first failure (forwardingFailed unwinds the emit loop and skips the final flush, so the + // consumer is not invoked again) and log a single warning instead of propagating. + forwardingFailed = true; + stopLogging = true; + Logger.getLogger(StreamedLog.class.getName()) + .log(Level.WARNING, "Log redirection stopped due to error", e); + } + } + + private static int lastIndexOf(byte[] bytes, byte target) { + for (int i = bytes.length - 1; i >= 0; i--) { + if (bytes[i] == target) { + return i; + } + } + return -1; + } + + private static void closeQuietly(InputStream stream) { + if (stream == null) { + return; + } + try { + stream.close(); + } catch (IOException ignored) { + // Best-effort close; nothing actionable if it fails. + } + } +} diff --git a/src/main/java/com/apify/client/StreamedLogOptions.java b/src/main/java/com/apify/client/StreamedLogOptions.java new file mode 100644 index 0000000..8d1c8d0 --- /dev/null +++ b/src/main/java/com/apify/client/StreamedLogOptions.java @@ -0,0 +1,58 @@ +package com.apify.client; + +import java.util.function.Consumer; + +/** + * Configures {@link RunClient#getStreamedLog(StreamedLogOptions)} log redirection. + * + *

Mirrors the reference client's {@code getStreamedLog} options: a destination log ({@code + * toLog}) and whether to include messages produced before redirection started ({@code fromStart}). + */ +public final class StreamedLogOptions { + + private Consumer toLog; + private String prefix; + private boolean fromStart = true; + + /** + * Destination for redirected log messages. Each complete log message is passed to the consumer. + * When left unset, messages go to a {@link java.util.logging.Logger} at {@code INFO} level with + * an auto-built per-run prefix. + */ + public StreamedLogOptions toLog(Consumer toLog) { + this.toLog = toLog; + return this; + } + + /** + * Prefix prepended to each message by the default destination logger. Ignored when a custom + * {@link #toLog(Consumer)} destination is set. When unset, a per-run prefix (Actor name and run + * id) is built automatically. + */ + public StreamedLogOptions prefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** + * Whether to redirect all of the run's logs, including those produced before redirection started + * ({@code true}, the default). When {@code false}, only messages timestamped at or after the + * moment redirection is created are redirected. + */ + public StreamedLogOptions fromStart(boolean fromStart) { + this.fromStart = fromStart; + return this; + } + + Consumer destination() { + return toLog; + } + + String prefixValue() { + return prefix; + } + + boolean fromStartValue() { + return fromStart; + } +} diff --git a/src/main/java/com/apify/client/TaskCollectionClient.java b/src/main/java/com/apify/client/TaskCollectionClient.java index 9ca5a0c..392b9c5 100644 --- a/src/main/java/com/apify/client/TaskCollectionClient.java +++ b/src/main/java/com/apify/client/TaskCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** A client for the Actor task collection ({@code GET/POST /v2/actor-tasks}). */ public final class TaskCollectionClient { private final ResourceContext ctx; @@ -15,6 +17,22 @@ public PaginationList list(ListOptions options) { return ctx.listResource("", params, Task.class); } + /** + * Returns a lazy iterator over the account's tasks. The options' {@code limit} caps the total + * number yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page + * size ({@code null} = server default). + */ + public Iterator iterate(ListOptions options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptions)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(ListOptions options, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters, 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/Version.java b/src/main/java/com/apify/client/Version.java index 290ebee..c3333a0 100644 --- a/src/main/java/com/apify/client/Version.java +++ b/src/main/java/com/apify/client/Version.java @@ -13,13 +13,13 @@ 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.3"; + public static final String CLIENT_VERSION = "0.3.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-08T143931Z"; + public static final String API_SPEC_VERSION = "v2-2026-07-10T105921Z"; private Version() {} } diff --git a/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java b/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java index 65063fb..2aa3ede 100644 --- a/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java +++ b/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java @@ -1,5 +1,7 @@ package com.apify.client; +import java.util.Iterator; + /** * A client for a webhook dispatch collection: the account-wide collection ({@code GET * /v2/webhook-dispatches}) or dispatches nested under a webhook. @@ -17,4 +19,25 @@ public PaginationList list(ListOptions options) { options.apply(params); return ctx.listResource("", params, WebhookDispatch.class); } + + /** + * Returns a lazy iterator over the webhook dispatches. The options' {@code limit} caps the total + * number yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request page + * size ({@code null} = server default). + */ + public Iterator iterate(ListOptions options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptions)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(ListOptions options, Long chunkSize) { + ListOptions opts = options != null ? options : new ListOptions(); + return ctx.iterateResource( + "", + opts.limitValue(), + chunkSize, + opts.offsetValue(), + opts::applyFilters, + WebhookDispatch.class); + } } diff --git a/src/test/java/com/apify/client/ReviewFixesTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java similarity index 76% rename from src/test/java/com/apify/client/ReviewFixesTest.java rename to src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 3c17be2..a8e5441 100644 --- a/src/test/java/com/apify/client/ReviewFixesTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -11,8 +11,12 @@ import java.util.Optional; import org.junit.jupiter.api.Test; -/** Offline tests pinning correctness behaviours surfaced in review (idempotency, chunking, ...). */ -class ReviewFixesTest { +/** + * Offline regression tests pinning client behaviours: idempotency-key derivation, filter + * propagation through nested clients, retry/timeout policy, request chunking, pagination/iteration + * termination, and wait/poll semantics. + */ +class ClientBehaviourRegressionTest { private static ApifyClient client(MockBackend backend) { return client(backend, 0); @@ -267,7 +271,7 @@ void storeIterateDoesNotMutateCallerOptionsAndHonorsOffset() { 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(); + client(backend).store().iterate(options, null).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"); @@ -283,16 +287,61 @@ void storeIterateWalksMultiplePages() { "{\"data\":{\"items\":[{},{}],\"total\":3,\"offset\":0,\"limit\":2,\"count\":2}}"), MockBackend.ok( 200, - "{\"data\":{\"items\":[{}],\"total\":3,\"offset\":2,\"limit\":2,\"count\":1}}"))); + "{\"data\":{\"items\":[{}],\"total\":3,\"offset\":2,\"limit\":2,\"count\":1}}"), + // Trailing empty page: the iterator stops on an empty page (it does not trust the + // reported total, which some endpoints under-report), so a final empty page is + // required to terminate an uncapped walk. + MockBackend.ok( + 200, + "{\"data\":{\"items\":[],\"total\":3,\"offset\":3,\"limit\":2,\"count\":0}}"))); + // No total cap; page size 2 drives paging until the empty page. java.util.Iterator it = - client(backend).store().iterate(new StoreListOptions().limit(2L)); + client(backend).store().iterate(new StoreListOptions(), 2L); + int count = 0; + while (it.hasNext()) { + it.next(); + count++; + } + assertEquals(3, count, "iteration should walk across both non-empty pages"); + assertEquals(3, backend.calls, "two data pages plus the terminating empty page"); + } + + @Test + void collectionIterateSingleArgDelegatesToServerDefaultPageSize() { + // The arg-less-chunkSize convenience overload must page correctly (delegates with null chunk). + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{},{}],\"total\":2,\"offset\":0,\"limit\":2,\"count\":2}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[],\"total\":2,\"offset\":2,\"limit\":2,\"count\":0}}"))); + java.util.Iterator it = client(backend).actors().iterate(new ActorListOptions()); 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"); + assertEquals(2, count, "single-arg iterate should yield every item"); + assertFalse( + backend.lastUrl.contains("limit="), "no chunkSize => no limit param (server default)"); + } + + @Test + void iterateSnapshotsOptionsSoLaterMutationsDoNotLeak() { + // The iterator must capture the options (offset/limit AND filters) at call time; mutating the + // caller's options object afterwards must not change subsequent page requests. + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"items\":[{}],\"total\":1,\"offset\":0,\"limit\":0,\"count\":1}}"); + ActorListOptions options = new ActorListOptions().sortBy("createdAt"); + java.util.Iterator it = client(backend).actors().iterate(options); + options.sortBy("modifiedAt"); // mutate after obtaining the iterator + it.hasNext(); // triggers the first page fetch + assertTrue(backend.lastUrl.contains("sortBy=createdAt"), backend.lastUrl); + assertFalse(backend.lastUrl.contains("modifiedAt"), backend.lastUrl); } @Test @@ -404,4 +453,50 @@ void logStreamThrowsOnNonSuccessStatus() { assertThrows(ApifyApiException.class, () -> client(backend).log("run1").stream()); assertEquals(403, ex.getStatusCode()); } + + @Test + void versionsIterateSingleFetchTerminatesAndDoesNotDuplicate() { + // GET /v2/actors/{actorId}/versions is NOT offset/limit paginated: the server ignores `offset` + // and returns the full {total, items} list on every request. `ofConstant` reproduces exactly + // that (same non-empty page for every call). Draining the iterator must terminate and yield + // each version once. Routing this endpoint through the offset/limit paging engine looped + // forever (empty-page termination never triggers), so this pins the single-fetch behaviour. + MockBackend backend = + MockBackend.ofConstant( + 200, + "{\"data\":{\"total\":3,\"items\":[" + + "{\"versionNumber\":\"0.1\"}," + + "{\"versionNumber\":\"0.2\"}," + + "{\"versionNumber\":\"0.3\"}]}}"); + java.util.Iterator it = + client(backend).actor("me/actor").versions().iterate(new ListOptions()); + List yielded = new ArrayList<>(); + int guard = 0; + while (it.hasNext()) { + // Guard converts a termination regression (infinite loop) into a clean failure, not a hang. + assertTrue( + ++guard <= 100, "versions iterator did not terminate (paged a non-paginated endpoint)"); + yielded.add(it.next().getVersionNumber()); + } + assertEquals(List.of("0.1", "0.2", "0.3"), yielded, "each version yielded once, in order"); + assertEquals(1, backend.calls, "non-paginated versions endpoint must be fetched exactly once"); + } + + @Test + void versionsIterateHonorsTotalLimitCap() { + MockBackend backend = + MockBackend.ofConstant( + 200, + "{\"data\":{\"total\":3,\"items\":[" + + "{\"versionNumber\":\"0.1\"}," + + "{\"versionNumber\":\"0.2\"}," + + "{\"versionNumber\":\"0.3\"}]}}"); + java.util.Iterator it = + client(backend).actor("me/actor").versions().iterate(new ListOptions().limit(2L)); + List yielded = new ArrayList<>(); + while (it.hasNext()) { + yielded.add(it.next().getVersionNumber()); + } + assertEquals(List.of("0.1", "0.2"), yielded, "limit caps the number yielded"); + } } diff --git a/src/test/java/com/apify/client/DatasetItemsIteratorTest.java b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java new file mode 100644 index 0000000..d51f1b7 --- /dev/null +++ b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java @@ -0,0 +1,103 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Hermetic (token-free) tests for {@link DatasetClient#iterateItems} and its {@code fetchItemsPage} + * helper — the distinct dataset-items path that parses a bare JSON array body (not a {@code data} + * envelope) and terminates on an empty page. Driven by a {@link MockBackend}, no {@code + * APIFY_TOKEN}. + */ +class DatasetItemsIteratorTest { + + private static ApifyClient client(MockBackend backend) { + return ApifyClient.builder() + .token("test-token") + .httpBackend(backend) + .maxRetries(0) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .build(); + } + + @Test + void pagesBareArrayBodyAndStopsOnEmptyPage() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok(200, "[{\"n\":1},{\"n\":2}]"), + MockBackend.ok(200, "[{\"n\":3}]"), + MockBackend.ok(200, "[]"))); + List seen = new ArrayList<>(); + Iterator it = + client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), 2L); + while (it.hasNext()) { + seen.add(it.next().get("n").asInt()); + } + assertEquals(List.of(1, 2, 3), seen, "iterateItems should page the bare-array endpoint"); + assertEquals(3, backend.calls, "two data pages plus the terminating empty page"); + assertTrue(backend.lastUrl.contains("datasets/d1/items"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("limit=2"), "chunkSize drives the per-request page size"); + } + + @Test + void typedIterateItemsAtDefaultPageSize() { + // Typed iteration at the server-default page size uses the 3-arg form with a null chunkSize. + // (No (options, Class) overload exists: it would make iterateItems(opts, null) ambiguous + // with (options, Long) — see commit 02c977e.) Decodes each item into the requested type. + MockBackend backend = + new MockBackend( + List.of(MockBackend.ok(200, "[{\"n\":1},{\"n\":2}]"), MockBackend.ok(200, "[]"))); + List seen = new ArrayList<>(); + Iterator it = + client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), null, Row.class); + while (it.hasNext()) { + seen.add(it.next().n); + } + assertEquals(List.of(1, 2), seen, "typed iteration at default page size yields decoded items"); + assertTrue(backend.lastUrl.contains("datasets/d1/items"), backend.lastUrl); + } + + /** Minimal typed row for the typed-iteration overload test. */ + static final class Row { + public int n; + } + + @Test + void totalCapTrimsDatasetItems() { + // The cap wins even though the server would return more; only the first page is requested. + MockBackend backend = MockBackend.ofConstant(200, "[{\"n\":1},{\"n\":2},{\"n\":3}]"); + List seen = new ArrayList<>(); + Iterator it = + client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions().limit(2L), 5L); + while (it.hasNext()) { + seen.add(it.next().get("n").asInt()); + } + assertEquals(List.of(1, 2), seen, "limit caps the total items yielded"); + assertEquals(1, backend.calls); + } + + /** A typed item for {@link #decodesIntoRequestedType()}. */ + public record Item(int n) {} + + @Test + void decodesIntoRequestedType() { + MockBackend backend = + new MockBackend(List.of(MockBackend.ok(200, "[{\"n\":7}]"), MockBackend.ok(200, "[]"))); + List seen = new ArrayList<>(); + Iterator it = + client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), 2L, Item.class); + while (it.hasNext()) { + seen.add(it.next()); + } + assertEquals(1, seen.size(), "typed iteration should decode and yield the item"); + assertEquals(7, seen.get(0).n()); + } +} diff --git a/src/test/java/com/apify/client/KeyIteratorTest.java b/src/test/java/com/apify/client/KeyIteratorTest.java new file mode 100644 index 0000000..c92b7bb --- /dev/null +++ b/src/test/java/com/apify/client/KeyIteratorTest.java @@ -0,0 +1,126 @@ +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.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Hermetic (token-free) tests for the cursor-based key iterator ({@link + * KeyValueStoreClient#iterateKeys}), driven by a {@link MockBackend}: cursor chaining across pages, + * the {@code limit} total-cap, and empty-page / empty-cursor termination. + */ +class KeyIteratorTest { + + private static ApifyClient client(MockBackend backend) { + return ApifyClient.builder() + .token("test-token") + .httpBackend(backend) + .maxRetries(0) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .build(); + } + + private static List keys(Iterator it) { + List out = new ArrayList<>(); + while (it.hasNext()) { + out.add(it.next().getKey()); + } + return out; + } + + @Test + void chainsAcrossPagesUsingCursor() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1}]," + + "\"nextExclusiveStartKey\":null,\"isTruncated\":false}}"))); + List got = keys(client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions())); + assertEquals(List.of("a", "b", "c"), got, "iterator should chain pages via the cursor"); + assertEquals(2, backend.calls, "stops once the next cursor is null"); + assertTrue(backend.lastUrl.contains("exclusiveStartKey=b"), backend.lastUrl); + } + + @Test + void limitCapsTotalKeysYielded() { + MockBackend backend = + MockBackend.ofConstant( + 200, + "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}," + + "{\"key\":\"c\",\"size\":1}],\"nextExclusiveStartKey\":\"c\",\"isTruncated\":true}}"); + List got = + keys(client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions().limit(2L))); + assertEquals(List.of("a", "b"), got, "limit caps the total keys yielded"); + assertEquals(1, backend.calls, "the cap is reached within the first page"); + assertTrue(backend.lastUrl.contains("limit=2"), backend.lastUrl); + } + + @Test + void chunkSizeSetsPerRequestPageSize() { + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1},{\"key\":\"d\",\"size\":1}]," + + "\"nextExclusiveStartKey\":\"d\",\"isTruncated\":true}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"e\",\"size\":1}]," + + "\"nextExclusiveStartKey\":null,\"isTruncated\":false}}"))); + List got = + keys(client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions(), 2L)); + assertEquals(List.of("a", "b", "c", "d", "e"), got, "chunkSize pages the full collection"); + assertEquals(3, backend.calls); + assertTrue(backend.lastUrl.contains("limit=2"), "chunkSize is sent as the page limit"); + } + + @Test + void chunkSizeAndLimitCombine() { + // limit=3 (total cap) with chunkSize=2: first page requests 2, second requests min(1,2)=1. + MockBackend backend = + new MockBackend( + List.of( + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), + MockBackend.ok( + 200, + "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1},{\"key\":\"d\",\"size\":1}]," + + "\"nextExclusiveStartKey\":\"d\",\"isTruncated\":true}}"))); + List got = + keys(client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions().limit(3L), 2L)); + assertEquals(List.of("a", "b", "c"), got, "the total cap trims across chunked pages"); + assertEquals(2, backend.calls); + assertTrue( + backend.lastUrl.contains("limit=1"), "the last page requests only the remaining cap"); + } + + @Test + void stopsOnEmptyPage() { + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"items\":[],\"nextExclusiveStartKey\":\"z\",\"isTruncated\":true}}"); + Iterator it = + client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions()); + assertFalse(it.hasNext(), "an empty page ends iteration even if a next cursor is reported"); + assertEquals(1, backend.calls); + } +} diff --git a/src/test/java/com/apify/client/MockBackend.java b/src/test/java/com/apify/client/MockBackend.java index 16dc0d9..1df7af6 100644 --- a/src/test/java/com/apify/client/MockBackend.java +++ b/src/test/java/com/apify/client/MockBackend.java @@ -46,6 +46,17 @@ static final class Scripted { byte[] lastBodyBytes; final List bodies = new ArrayList<>(); + /** + * Optional scripted response for {@link #sendStreaming}; defaults to the first {@code send} + * entry. + */ + private Scripted streamResponse; + + /** Optional custom stream body (e.g. a blocking stream that simulates a live, open log). */ + private InputStream streamBody; + + private int streamBodyStatus; + MockBackend(List responses) { this.responses = responses; } @@ -87,21 +98,31 @@ 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); + this.streamBody = null; + } + + /** + * Scripts the next {@link #sendStreaming} call to return the given status and an arbitrary, + * possibly blocking, {@link InputStream}. Unlike {@link #scriptStream(int, String)}, the body is + * not a finite in-memory buffer, so {@code read()} does not naturally reach end-of-stream; this + * models a live log that only ends when the stream is closed. + */ + void scriptStream(int status, InputStream body) { + this.streamBodyStatus = status; + this.streamBody = body; + this.streamResponse = null; } @Override public synchronized HttpResponse sendStreaming(HttpRequest request) { calls++; lastUrl = request.uri().toString(); + if (streamBody != null) { + return new FakeStreamResponse(request.uri(), streamBodyStatus, streamBody); + } Scripted r = streamResponse != null ? streamResponse : responses.get(0); return new FakeStreamResponse( request.uri(), r.status, new java.io.ByteArrayInputStream(r.body)); diff --git a/src/test/java/com/apify/client/PaginatedIteratorTest.java b/src/test/java/com/apify/client/PaginatedIteratorTest.java new file mode 100644 index 0000000..d77461d --- /dev/null +++ b/src/test/java/com/apify/client/PaginatedIteratorTest.java @@ -0,0 +1,146 @@ +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 java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Test; + +/** + * Hermetic (token-free) tests for the offset/limit iteration engine. They drive {@link + * PaginatedIterator} with a stub page fetcher over a synthetic collection, pinning the total-cap / + * chunk-size arithmetic, server-side page-size clamping, and termination — without any network or + * {@code APIFY_TOKEN}. + */ +class PaginatedIteratorTest { + + /** + * A fake paginated endpoint over integers {@code [0, available)}. Like the real API, it clamps a + * requested page size (or an unset one) to {@code maxPageSize} ({@code 0} = no clamp). + */ + private static final class StubFetcher implements PaginatedIterator.PageFetcher { + final long available; + final long maxPageSize; + final List requests = new ArrayList<>(); + + StubFetcher(long available) { + this(available, 0); + } + + StubFetcher(long available, long maxPageSize) { + this.available = available; + this.maxPageSize = maxPageSize; + } + + @Override + public PaginationList fetch(long offset, Long limit) { + requests.add(new long[] {offset, limit == null ? -1 : limit}); + long requested = limit == null ? Long.MAX_VALUE : limit; + long take = maxPageSize > 0 ? Math.min(requested, maxPageSize) : requested; + List items = new ArrayList<>(); + for (long i = offset; i < available && items.size() < take; i++) { + items.add((int) i); + } + PaginationList page = new PaginationList<>(); + page.setItems(items); + // Some endpoints (e.g. dataset items) report a total of 0 or a lagging value; the engine must + // not depend on it, so the stub deliberately reports an unhelpful total. + page.setTotal(0); + page.setOffset(offset); + page.setCount(items.size()); + return page; + } + } + + private static List drain(java.util.Iterator it) { + List out = new ArrayList<>(); + while (it.hasNext()) { + out.add(it.next()); + } + return out; + } + + @Test + void totalCapTrimsLastPage() { + StubFetcher f = new StubFetcher(10); + List got = drain(new PaginatedIterator<>(3L, 2L, null, f)); + assertEquals(List.of(0, 1, 2), got, "limit=3 caps the total yielded across pages"); + // First page requests min(cap,chunk)=2; second requests min(remaining=1,chunk=2)=1; the cap is + // reached exactly, so no trailing empty request is made. + assertEquals(2, f.requests.size()); + assertEquals(0L, f.requests.get(0)[0]); + assertEquals(2L, f.requests.get(0)[1]); + assertEquals(2L, f.requests.get(1)[0]); + assertEquals(1L, f.requests.get(1)[1]); + } + + @Test + void chunkSizePagesAllThenStopsOnEmptyPage() { + StubFetcher f = new StubFetcher(5); + List got = drain(new PaginatedIterator<>(null, 2L, null, f)); + assertEquals(List.of(0, 1, 2, 3, 4), got); + // 5 items / page size 2 => pages of 2,2,1 then a trailing empty page confirms the end. + assertEquals(4, f.requests.size()); + assertEquals(5L, f.requests.get(3)[0], "trailing request pages past the last item"); + assertEquals(2L, f.requests.get(3)[1], "each page still requests the chunk size"); + } + + @Test + void serverClampsLargePageSizeSoShortPageIsNotTheEnd() { + // Server caps every page at 2 items; the caller asks for far more. A page shorter than the + // request must NOT be treated as end-of-collection. + StubFetcher f = new StubFetcher(5, 2); + List got = drain(new PaginatedIterator<>(null, 100L, null, f)); + assertEquals(List.of(0, 1, 2, 3, 4), got, "clamped short pages must keep paging to the end"); + } + + @Test + void capIsHonoredEvenWhenServerClampsPages() { + StubFetcher f = new StubFetcher(100, 2); + List got = drain(new PaginatedIterator<>(5L, 100L, null, f)); + assertEquals(List.of(0, 1, 2, 3, 4), got, "the total cap holds despite server page clamping"); + } + + @Test + void noChunkUsesServerDefaultAndPagesToEmpty() { + StubFetcher f = new StubFetcher(4); + List got = drain(new PaginatedIterator<>(null, null, null, f)); + assertEquals(List.of(0, 1, 2, 3), got); + assertEquals(-1L, f.requests.get(0)[1], "no cap and no chunk => null limit (server default)"); + } + + @Test + void startOffsetIsHonored() { + StubFetcher f = new StubFetcher(10); + List got = drain(new PaginatedIterator<>(null, 5L, 7L, f)); + assertEquals(List.of(7, 8, 9), got, "iteration starts at the requested offset"); + assertEquals(7L, f.requests.get(0)[0]); + } + + @Test + void trimsOverReturnedPageToCap() { + // A misbehaving server that returns more items than requested must not make the iterator + // overshoot the caller's total cap. + PaginatedIterator.PageFetcher overReturning = + (offset, limit) -> { + PaginationList page = new PaginationList<>(); + page.setItems(List.of(1, 2, 3, 4, 5)); // ignores the requested limit + page.setTotal(5); + page.setCount(5); + return page; + }; + List got = drain(new PaginatedIterator<>(3L, 2L, null, overReturning)); + assertEquals(List.of(1, 2, 3), got, "the last page is trimmed to the cap"); + } + + @Test + void emptyCollectionYieldsNothing() { + StubFetcher f = new StubFetcher(0); + java.util.Iterator it = new PaginatedIterator<>(null, 5L, null, f); + assertFalse(it.hasNext()); + assertThrows(NoSuchElementException.class, it::next); + } +} diff --git a/src/test/java/com/apify/client/StreamedLogTest.java b/src/test/java/com/apify/client/StreamedLogTest.java new file mode 100644 index 0000000..efaca90 --- /dev/null +++ b/src/test/java/com/apify/client/StreamedLogTest.java @@ -0,0 +1,431 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Offline tests for the {@link StreamedLog} log-redirection helper: it must forward complete + * timestamped messages to the destination, drop messages older than the relevancy limit when {@code + * fromStart} is false, flush a final unterminated line, and enforce its start/stop lifecycle. + */ +class StreamedLogTest { + + private static ApifyClient client(MockBackend backend) { + return ApifyClient.builder().token("test-token").httpBackend(backend).maxRetries(0).build(); + } + + /** Drives a run's log stream from a scripted body and collects redirected messages. */ + private static List redirect(String streamBody, StreamedLogOptions options) + throws InterruptedException { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, streamBody); + List collected = new CopyOnWriteArrayList<>(); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(options.toLog(collected::add)); + streamedLog.start(); + // The scripted stream is finite, so redirection finishes on its own; stop() joins the thread. + streamedLog.stop(); + return collected; + } + + @Test + void redirectsCompleteTimestampedMessages() throws InterruptedException { + List messages = + redirect( + "2999-01-01T00:00:00.000Z line A\n2999-01-01T00:00:01.000Z line B\n", + new StreamedLogOptions()); + assertEquals( + List.of("2999-01-01T00:00:00.000Z line A", "2999-01-01T00:00:01.000Z line B"), messages); + } + + @Test + void keepsMultiLineMessagesTogether() throws InterruptedException { + // A message whose body spans multiple lines must not be split at the inner newline; only a + // leading timestamp starts a new message. + List messages = + redirect( + "2999-01-01T00:00:00.000Z first line\ncontinued\n2999-01-01T00:00:01.000Z second\n", + new StreamedLogOptions()); + assertEquals(2, messages.size()); + assertEquals("2999-01-01T00:00:00.000Z first line\ncontinued", messages.get(0)); + assertEquals("2999-01-01T00:00:01.000Z second", messages.get(1)); + } + + @Test + void flushesFinalUnterminatedMessage() throws InterruptedException { + List messages = + redirect("2999-01-01T00:00:00.000Z only line, no newline", new StreamedLogOptions()); + assertEquals(List.of("2999-01-01T00:00:00.000Z only line, no newline"), messages); + } + + @Test + void fromStartFalseDropsOldMessages() throws InterruptedException { + // relevancyTimeLimit is set to "now" at construction, so the year-2000 line is dropped and the + // year-2999 line is kept. + List messages = + redirect( + "2000-01-01T00:00:00.000Z old\n2999-01-01T00:00:00.000Z new\n", + new StreamedLogOptions().fromStart(false)); + assertEquals(List.of("2999-01-01T00:00:00.000Z new"), messages); + } + + @Test + void defaultDestinationDoesNotThrow() throws InterruptedException { + // With no toLog, getStreamedLog fetches the run + Actor to build the prefix, then logs to JUL. + MockBackend backend = + MockBackend.ofConstant( + 200, "{\"data\":{\"id\":\"run123\",\"actId\":\"act1\",\"name\":\"a\"}}"); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z hello\n"); + StreamedLog streamedLog = client(backend).run("run123").getStreamedLog(); + streamedLog.start(); + streamedLog.stop(); + // No assertion on output (goes to java.util.logging); the point is it runs without throwing. + } + + @Test + void throwingConsumerDoesNotKillDaemonThreadUncaught() throws InterruptedException { + // The destination consumer runs on the background daemon reader thread. A user consumer that + // throws must not escape as an uncaught exception that silently kills that thread (and, via the + // final flush, throws again). Matching the reference client, redirection must degrade to a + // logged warning. We capture any exception that reaches the reader thread's uncaught handler + // and + // assert none does, while confirming the consumer was actually exercised. + AtomicReference uncaught = new AtomicReference<>(); + Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler( + (t, e) -> { + if ("apify-streamed-log".equals(t.getName())) { + uncaught.set(e); + } else if (previous != null) { + previous.uncaughtException(t, e); + } + }); + try { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z boom\n2999-01-01T00:00:01.000Z after\n"); + AtomicInteger calls = new AtomicInteger(); + StreamedLog streamedLog = + client(backend) + .run("run123") + .getStreamedLog( + new StreamedLogOptions() + .toLog( + message -> { + calls.incrementAndGet(); + throw new RuntimeException("consumer failed"); + })); + streamedLog.start(); + streamedLog.stop(); // joins the reader; if it died uncaught, the handler above has fired + assertNull( + uncaught.get(), + "a throwing destination consumer must not escape as an uncaught daemon-thread exception"); + // Two complete lines are streamed, but redirection must unwind on the first throw and forward + // nothing further (no re-invocation for the remaining line or the final flush), matching the + // reference client's single warning. + assertEquals( + 1, calls.get(), "the destination consumer must not be invoked again after it throws"); + } finally { + Thread.setDefaultUncaughtExceptionHandler(previous); + } + } + + @Test + void startTwiceThrows() throws InterruptedException { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); + streamedLog.start(); + assertThrows(IllegalStateException.class, streamedLog::start); + streamedLog.stop(); + } + + @Test + void stopWithoutStartThrows() { + MockBackend backend = MockBackend.ofConstant(200, ""); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); + assertThrows(IllegalStateException.class, streamedLog::stop); + } + + @Test + void closeWithoutStartIsNoOp() { + MockBackend backend = MockBackend.ofConstant(200, ""); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); + // close() on a never-started helper must not throw (supports try-with-resources). + streamedLog.close(); + } + + @Test + void deliversLastMessageWhenStoppingLiveStream() throws InterruptedException { + // Regression test for a live stream that never reaches end-of-stream on its own. The reader + // buffers three complete lines (a, b, c): a and b are emitted immediately while c is retained + // as the possibly-still-growing last message. Then the read blocks. stop() closes the stream, + // which unblocks the read with an IOException; the retained last message (c) must still be + // flushed and delivered. A finite ByteArrayInputStream cannot exercise this because read() + // returns -1 by itself, so we drive a purpose-built blocking stream instead. + String body = + "2999-01-01T00:00:00.000Z a\n" + + "2999-01-01T00:00:01.000Z b\n" + + "2999-01-01T00:00:02.000Z c\n"; + BlockingStream stream = new BlockingStream(body); + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, stream); + List collected = new CopyOnWriteArrayList<>(); + StreamedLog streamedLog = + client(backend) + .run("run123") + .getStreamedLog(new StreamedLogOptions().toLog(collected::add)); + streamedLog.start(); + // Wait until a and b have been redirected, which proves the reader has consumed the body and is + // now blocked with c held back as the pending last message. + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (collected.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertEquals(2, collected.size(), "a and b should be redirected before stop()"); + streamedLog.stop(); + assertEquals( + List.of( + "2999-01-01T00:00:00.000Z a", + "2999-01-01T00:00:01.000Z b", + "2999-01-01T00:00:02.000Z c"), + collected, + "the retained last message must be delivered after stop()"); + } + + @Test + void closeAfterStopIsNoOp() throws InterruptedException { + // Sequential idempotency: after an explicit stop() (which nulls the running thread), a trailing + // close() - e.g. from try-with-resources - and any repeat close() must be no-ops, not throws, + // honouring the documented "no-op otherwise" contract. (The concurrent stop()/close() race is + // covered separately by concurrentCloseNeverThrowsWhileStopRaces.) + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); + streamedLog.start(); + streamedLog.stop(); + streamedLog.close(); // must be a no-op, not an IllegalStateException + streamedLog.close(); // idempotent on repeat + } + + @Test + void concurrentCloseNeverThrowsWhileStopRaces() throws InterruptedException { + // Reproduces the concurrent case the fix targets: a stop() and a close() fire simultaneously. + // Because close() does its running-check and stop atomically under the monitor, it must never + // throw regardless of interleaving. (stop() may legitimately throw when it loses the race - its + // explicit-misuse contract - so only close()'s outcome is asserted.) The pre-fix check-then-act + // close() could observe a non-null thread, release the lock, then call a stop() that had + // already nulled the field and threw. Run several rounds to make the interleaving likely. + for (int round = 0; round < 50; round++) { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); + StreamedLog streamedLog = + client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); + streamedLog.start(); + + CountDownLatch go = new CountDownLatch(1); + AtomicReference closeError = new AtomicReference<>(); + Thread stopper = + new Thread( + () -> { + awaitQuietly(go); + try { + streamedLog.stop(); + } catch (IllegalStateException ignored) { + // Allowed: stop() throws when it loses the race to close(). + } + }); + Thread closer = + new Thread( + () -> { + awaitQuietly(go); + try { + streamedLog.close(); + } catch (Throwable t) { + closeError.set(t); + } + }); + stopper.start(); + closer.start(); + go.countDown(); // release both together to maximise the overlap + stopper.join(); + closer.join(); + assertNull(closeError.get(), "close() must never throw, even when racing stop()"); + } + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Test + void closeStopsActiveRedirection() throws InterruptedException { + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); + List collected = new CopyOnWriteArrayList<>(); + try (StreamedLog streamedLog = + client(backend) + .run("run123") + .getStreamedLog(new StreamedLogOptions().toLog(collected::add))) { + streamedLog.start(); + // give the finite stream a moment to be consumed + Thread.sleep(Duration.ofMillis(50).toMillis()); + } + assertTrue(collected.contains("2999-01-01T00:00:00.000Z x")); + } + + @Test + void stopFromInsideConsumerDoesNotDeadlock() throws InterruptedException { + // The destination consumer runs on the background reader thread. A user may stop redirection + // from inside it ("stop once I see line X"). Because that calls stopStreaming() on the reader + // thread itself, an unguarded streamingThread.join() would join the thread on itself and hang + // forever - and, since stop() is synchronized, hold the monitor so every later start/stop/close + // deadlocks too. The self-join guard must let the call return so the reader breaks its loop and + // flushes the retained message. If it deadlocked, "b" would never be flushed and the poll below + // would time out with only "a" collected. A blocking stream is required: a finite one would end + // on its own and mask the hang. + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream( + 200, new BlockingStream("2999-01-01T00:00:00.000Z a\n2999-01-01T00:00:01.000Z b\n")); + List collected = new CopyOnWriteArrayList<>(); + AtomicReference ref = new AtomicReference<>(); + AtomicBoolean stopRequested = new AtomicBoolean(false); + StreamedLog streamedLog = + client(backend) + .run("run123") + .getStreamedLog( + new StreamedLogOptions() + .toLog( + message -> { + collected.add(message); + // Self-stop on the reader thread; fire once (stop() throws after the + // thread reference is cleared). + if (stopRequested.compareAndSet(false, true)) { + ref.get().stop(); + } + })); + ref.set(streamedLog); + streamedLog.start(); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (collected.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertEquals( + List.of("2999-01-01T00:00:00.000Z a", "2999-01-01T00:00:01.000Z b"), + collected, + "self-stop from inside the consumer must not deadlock and must flush the retained message"); + } + + @Test + void closeFromInsideConsumerDoesNotDeadlock() throws InterruptedException { + // Same self-join hazard as stopFromInsideConsumerDoesNotDeadlock, via close() (which routes + // through the same stopStreaming()). close() is idempotent, so the consumer can call it on + // every + // message without guarding. + MockBackend backend = MockBackend.ofConstant(200, ""); + backend.scriptStream( + 200, new BlockingStream("2999-01-01T00:00:00.000Z a\n2999-01-01T00:00:01.000Z b\n")); + List collected = new CopyOnWriteArrayList<>(); + AtomicReference ref = new AtomicReference<>(); + StreamedLog streamedLog = + client(backend) + .run("run123") + .getStreamedLog( + new StreamedLogOptions() + .toLog( + message -> { + collected.add(message); + ref.get().close(); // idempotent self-close on the reader thread + })); + ref.set(streamedLog); + streamedLog.start(); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (collected.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertEquals( + List.of("2999-01-01T00:00:00.000Z a", "2999-01-01T00:00:01.000Z b"), + collected, + "self-close from inside the consumer must not deadlock and must flush the retained message"); + } + + @Test + void defaultPrefixLookupFailureStillCreatesHelper() { + // The no-arg getStreamedLog() builds a cosmetic per-run prefix by GETting the run (and its + // Actor). Those getters swallow only 404; a 401/403/5xx-after-retries would otherwise throw out + // of getStreamedLog() and abort helper creation even though streaming might work. The lookup is + // wrapped so it falls back to the runId-only prefix instead of failing. + MockBackend backend = new MockBackend(List.of(MockBackend.ok(401, "{\"error\":{}}"))); + StreamedLog streamedLog = + assertDoesNotThrow(() -> client(backend).run("run123").getStreamedLog()); + assertNotNull(streamedLog); + } + + /** + * An {@link InputStream} that serves a fixed payload once and then blocks on {@code read()} until + * it is closed, throwing an {@link IOException} when unblocked by the close. This models a live + * log stream that produces some bytes and then stays open with no further data until the client + * stops redirection - the case a finite {@link java.io.ByteArrayInputStream} cannot reproduce. + */ + private static final class BlockingStream extends InputStream { + private final byte[] data; + private int pos; + private final CountDownLatch closed = new CountDownLatch(1); + + BlockingStream(String data) { + this.data = data.getBytes(StandardCharsets.UTF_8); + } + + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + if (pos < data.length) { + int n = Math.min(len, data.length - pos); + System.arraycopy(data, pos, b, off, n); + pos += n; + return n; + } + // Payload exhausted: block like a live stream awaiting more bytes, until close() unblocks us. + try { + closed.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("stream closed"); + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n == -1 ? -1 : one[0] & 0xff; + } + + @Override + public void close() { + closed.countDown(); + } + } +} diff --git a/src/test/java/com/apify/client/examples/GetAccount.java b/src/test/java/com/apify/client/examples/GetAccount.java index b5c7c33..c8091b5 100644 --- a/src/test/java/com/apify/client/examples/GetAccount.java +++ b/src/test/java/com/apify/client/examples/GetAccount.java @@ -20,5 +20,8 @@ public static void main(String[] args) { } System.out.println("Account ID: " + user.get().getId()); System.out.println("Username: " + user.get().getUsername()); + // Fields not modelled on User (email, plan, ...) live in the untyped extras map that + // getExtra() returns as a Map. + System.out.println("Email: " + user.get().getExtra().get("email")); } } diff --git a/src/test/java/com/apify/client/examples/IterateStore.java b/src/test/java/com/apify/client/examples/IterateStore.java index c1946c7..a733f02 100644 --- a/src/test/java/com/apify/client/examples/IterateStore.java +++ b/src/test/java/com/apify/client/examples/IterateStore.java @@ -15,7 +15,8 @@ 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)); + Iterator it = + client.store().iterate(new StoreListOptions().limit(10L), 10L); int count = 0; while (count < 5 && it.hasNext()) { ActorStoreListItem item = it.next(); diff --git a/src/test/java/com/apify/client/examples/LogRedirection.java b/src/test/java/com/apify/client/examples/LogRedirection.java index eff05ab..0acfd1e 100644 --- a/src/test/java/com/apify/client/examples/LogRedirection.java +++ b/src/test/java/com/apify/client/examples/LogRedirection.java @@ -3,24 +3,27 @@ import com.apify.client.ActorRun; import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import java.io.InputStream; +import com.apify.client.RunClient; +import com.apify.client.StreamedLog; /** - * Starts an Actor without waiting, then streams its log output to stdout in real time (log - * redirection). + * Starts an Actor without waiting, then redirects its live log to a logger in real time (log + * redirection) until the run finishes. */ public final class LogRedirection { private LogRedirection() {} - public static void main(String[] args) throws Exception { + public static void main(String[] args) { 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()); + RunClient runClient = client.run(run.getId()); - // 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); + // Redirect the run's live log to the default per-run logger while we wait for it to finish. + try (StreamedLog streamedLog = runClient.getStreamedLog()) { + streamedLog.start(); + runClient.waitForFinish(120L); } } } diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 6bcb9ce..b7a8f3d 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -53,4 +53,19 @@ void lastRunAccess() { assertTrue(byOrigin.isPresent()); assertEquals("SUCCEEDED", byOrigin.get().getStatus()); } + + @Test + void streamedLogRedirection() { + ApifyClient client = requireClient(); + ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); + com.apify.client.RunClient runClient = client.run(run.getId()); + + java.util.List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + try (com.apify.client.StreamedLog streamedLog = + runClient.getStreamedLog(new com.apify.client.StreamedLogOptions().toLog(collected::add))) { + streamedLog.start(); + runClient.waitForFinish(120L); + } + assertTrue(!collected.isEmpty(), "expected redirected log messages"); + } } diff --git a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java new file mode 100644 index 0000000..d84e349 --- /dev/null +++ b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java @@ -0,0 +1,332 @@ +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.ActorEnvVar; +import com.apify.client.ActorListOptions; +import com.apify.client.ApifyClient; +import com.apify.client.Build; +import com.apify.client.Dataset; +import com.apify.client.DatasetClient; +import com.apify.client.DatasetListItemsOptions; +import com.apify.client.KeyValueStore; +import com.apify.client.KeyValueStoreClient; +import com.apify.client.KeyValueStoreKey; +import com.apify.client.ListKeysOptions; +import com.apify.client.ListOptions; +import com.apify.client.RequestQueue; +import com.apify.client.RunListOptions; +import com.apify.client.Schedule; +import com.apify.client.StorageListOptions; +import com.apify.client.Task; +import com.apify.client.Webhook; +import com.apify.client.WebhookDispatch; +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.Map; +import java.util.Set; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +/** + * Iteration coverage for every paginated collection client that exposes an {@code iterate} helper, + * mirroring the reference client's async iterators. Where creation is cheap, tests create a few + * uniquely-named resources and assert iteration finds them (so paging is exercised across pages); + * for resources whose setup is expensive (builds, runs, dispatches) they iterate a bounded slice + * and assert the iterator behaves. All resources are uniquely named for parallel isolation. + */ +class IterationIntegrationTest extends IntegrationBase { + + /** Iterates until every target id is seen (lazily; stops early) or the iterator is exhausted. */ + private static boolean findsAll( + Iterator it, Function idOf, Set targets) { + Set remaining = new HashSet<>(targets); + int safety = 0; + while (it.hasNext() && !remaining.isEmpty() && safety++ < 10000) { + remaining.remove(idOf.apply(it.next())); + } + return remaining.isEmpty(); + } + + @Test + void iterateDatasetItems() { + ApifyClient client = requireClient(); + Dataset ds = client.datasets().getOrCreate(uniqueName("it-ds-items")); + try { + DatasetClient dataset = client.dataset(ds.getId()); + List> items = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + items.add(Map.of("n", i)); + } + dataset.pushItems(items); + + List seen = new ArrayList<>(); + Iterator it = dataset.iterateItems(new DatasetListItemsOptions(), 2L); + while (it.hasNext()) { + seen.add(it.next().get("n").asInt()); + } + assertEquals(List.of(1, 2, 3, 4, 5), seen, "iterateItems should yield all items in order"); + + // The total cap trims the tail: limit=3 over 5 items with page size 2 yields exactly 3. + List capped = new ArrayList<>(); + Iterator cappedIt = + dataset.iterateItems(new DatasetListItemsOptions().limit(3L), 2L); + while (cappedIt.hasNext()) { + capped.add(cappedIt.next().get("n").asInt()); + } + assertEquals(List.of(1, 2, 3), capped); + } finally { + client.dataset(ds.getId()).delete(); + } + } + + @Test + void iterateKeyValueStoreKeys() { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("it-kvs-keys")); + try { + KeyValueStoreClient kvs = client.keyValueStore(store.getId()); + Set keys = new HashSet<>(); + for (int i = 0; i < 5; i++) { + String key = "key-" + i; + kvs.setRecordJson(key, Map.of("i", i)); + keys.add(key); + } + + Set seen = new HashSet<>(); + Iterator it = kvs.iterateKeys(new ListKeysOptions()); + while (it.hasNext()) { + seen.add(it.next().getKey()); + } + assertTrue(seen.containsAll(keys), "iterateKeys should yield every key; saw " + seen); + + // The limit caps the total number of keys yielded. + int count = 0; + Iterator capped = kvs.iterateKeys(new ListKeysOptions().limit(3L)); + while (capped.hasNext()) { + capped.next(); + count++; + } + assertEquals(3, count, "limit should cap the keys yielded"); + } finally { + client.keyValueStore(store.getId()).delete(); + } + } + + @Test + void iterateDatasets() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 3; i++) { + ids.add(client.datasets().getOrCreate(uniqueName("it-ds-" + i)).getId()); + } + // Page size 1 forces the offset iterator across multiple pages; desc puts the fresh ones + // first. + Iterator it = client.datasets().iterate(new StorageListOptions().desc(true), 1L); + assertTrue(findsAll(it, Dataset::getId, ids), "iterate should find every created dataset"); + } finally { + for (String id : ids) { + client.dataset(id).delete(); + } + } + } + + @Test + void iterateKeyValueStores() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add(client.keyValueStores().getOrCreate(uniqueName("it-kvs-" + i)).getId()); + } + Iterator it = + client.keyValueStores().iterate(new StorageListOptions().desc(true), 1L); + assertTrue(findsAll(it, KeyValueStore::getId, ids)); + } finally { + for (String id : ids) { + client.keyValueStore(id).delete(); + } + } + } + + @Test + void iterateRequestQueues() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add(client.requestQueues().getOrCreate(uniqueName("it-rq-" + i)).getId()); + } + Iterator it = + client.requestQueues().iterate(new StorageListOptions().desc(true), 1L); + assertTrue(findsAll(it, RequestQueue::getId, ids)); + } finally { + for (String id : ids) { + client.requestQueue(id).delete(); + } + } + } + + @Test + void iterateTasks() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add( + client.tasks().create(TaskIntegrationTest.taskDef(uniqueName("it-task-" + i))).getId()); + } + Iterator it = client.tasks().iterate(new ListOptions().desc(true), 1L); + assertTrue(findsAll(it, Task::getId, ids)); + } finally { + for (String id : ids) { + client.task(id).delete(); + } + } + } + + @Test + void iterateSchedules() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add( + client + .schedules() + .create(ScheduleIntegrationTest.scheduleDef(uniqueName("it-sch-" + i))) + .getId()); + } + Iterator it = client.schedules().iterate(new ListOptions().desc(true), 1L); + assertTrue(findsAll(it, Schedule::getId, ids)); + } finally { + for (String id : ids) { + client.schedule(id).delete(); + } + } + } + + @Test + void iterateWebhooks() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add( + client + .webhooks() + .create(WebhookIntegrationTest.webhookDef("https://example.com/it-wh-" + i)) + .getId()); + } + Iterator it = client.webhooks().iterate(new ListOptions().desc(true), 1L); + assertTrue(findsAll(it, Webhook::getId, ids)); + } finally { + for (String id : ids) { + client.webhook(id).delete(); + } + } + } + + @Test + void iterateActors() { + ApifyClient client = requireClient(); + Set ids = new HashSet<>(); + try { + for (int i = 0; i < 2; i++) { + ids.add( + client + .actors() + .create(ActorIntegrationTest.minimalActor(uniqueName("it-act-" + i))) + .getId()); + } + // Restrict to the current user's Actors so iteration finds the freshly-created ones quickly. + Iterator it = client.actors().iterate(new ActorListOptions().my(true).desc(true), 1L); + assertTrue(findsAll(it, Actor::getId, ids)); + } finally { + for (String id : ids) { + client.actor(id).delete(); + } + } + } + + @Test + void iterateActorVersionsAndEnvVars() { + ApifyClient client = requireClient(); + Actor actor = client.actors().create(ActorIntegrationTest.minimalActor(uniqueName("it-ver"))); + try { + var actorClient = client.actor(actor.getId()); + // The versions endpoint is not paginated (one fetch returns every version); fully draining + // the iterator must terminate and must not re-yield a version. The minimal Actor ships with + // version 0.0, so iteration yields at least one version, each exactly once. + Set versionNumbers = new HashSet<>(); + int versionCount = 0; + Iterator versions = + actorClient.versions().iterate(new ListOptions()); + while (versions.hasNext()) { + versionCount++; + versionNumbers.add(versions.next().getVersionNumber()); + } + assertTrue(versionCount >= 1, "expected at least the initial version"); + assertEquals(versionCount, versionNumbers.size(), "versions iterator re-yielded a version"); + assertTrue( + versionNumbers.contains("0.0"), "expected initial version 0.0, saw " + versionNumbers); + + var envVars = actorClient.version("0.0").envVars(); + envVars.create(new ActorEnvVar("IT_VAR_A", "a")); + envVars.create(new ActorEnvVar("IT_VAR_B", "b")); + Set seen = new HashSet<>(); + Iterator it = envVars.iterate(); + while (it.hasNext()) { + seen.add(it.next().getName()); + } + assertTrue(seen.contains("IT_VAR_A") && seen.contains("IT_VAR_B"), "saw " + seen); + } finally { + client.actor(actor.getId()).delete(); + } + } + + @Test + void iterateBuildsBounded() { + ApifyClient client = requireClient(); + // Builds require building an Actor (expensive); assert a bounded slice iterates cleanly. + Iterator it = client.builds().iterate(new ListOptions().limit(5L), 2L); + int count = 0; + while (it.hasNext()) { + assertTrue(it.next().getId() != null); + count++; + } + assertTrue(count <= 5, "the total-cap limit must bound iteration; got " + count); + } + + @Test + void iterateRunsBounded() { + ApifyClient client = requireClient(); + Iterator it = + client.runs().iterate(new ListOptions().limit(5L), new RunListOptions(), 2L); + int count = 0; + while (it.hasNext()) { + assertTrue(it.next().getId() != null); + count++; + } + assertTrue(count <= 5); + } + + @Test + void iterateWebhookDispatchesBounded() { + ApifyClient client = requireClient(); + Iterator it = + client.webhookDispatches().iterate(new ListOptions().limit(5L), 2L); + int count = 0; + while (it.hasNext()) { + it.next(); + count++; + } + assertTrue(count <= 5); + } +} diff --git a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java index 08d6abd..66a19ce 100644 --- a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java @@ -20,7 +20,7 @@ void listStore() { @Test void iterateStore() { ApifyClient client = requireClient(); - Iterator it = client.store().iterate(new StoreListOptions().limit(5L)); + Iterator it = client.store().iterate(new StoreListOptions().limit(20L), 5L); int count = 0; while (count < 12 && it.hasNext()) { ActorStoreListItem item = it.next(); diff --git a/src/test/java/com/apify/client/integration/UserIntegrationTest.java b/src/test/java/com/apify/client/integration/UserIntegrationTest.java index 94c25b1..adb1a88 100644 --- a/src/test/java/com/apify/client/integration/UserIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/UserIntegrationTest.java @@ -42,5 +42,5 @@ void getLimits() { // 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. + // ClientBehaviourRegressionTest#updateLimitsSendsPutToMeLimits. }