From a8090341b5362a578526fffac567b055a9a13917 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 14:03:11 +0000 Subject: [PATCH 01/18] refactor!: package split + issue #8 review fixes; sync spec to v2-2026-07-20T094852Z Addresses the expert review at apify/apify-client-java#8: splits the single com.apify.client package into resource-scoped sub-packages, renames the replaceable HTTP transport (HttpBackend -> HttpTransport), reworks the exception hierarchy (new ApifyClientException base, public ApifyTransportException, backend-agnostic HttpTimeoutException), makes ResourceContext/DatasetClient/KeyValueStoreClient immutable, centralizes resource path segments into ApiPaths, removes setStatusMessage() and the public getUserAgent()/getApiBaseUrl(), makes brotli opt-in and adds slf4j logging. Several review suggestions are explicitly skipped where they would conflict with the reference-client naming/behavior parity requirement (see notes.md); documented with rationale. Bumps to 0.4.0 (breaking) and syncs Version.API_SPEC_VERSION to v2-2026-07-20T094852Z (response-only spec delta, no behavior change needed). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 45 +++++ README.md | 129 ++++++--------- docs/README.md | 6 - docs/runs.md | 9 +- pom.xml | 65 ++++---- spotbugs-exclude.xml | 39 +++-- src/main/java/com/apify/client/ApiPaths.java | 29 ++++ .../java/com/apify/client/ApifyClient.java | 80 +++++---- .../com/apify/client/ApifyClientBuilder.java | 12 +- .../java/com/apify/client/DataEnvelope.java | 2 +- .../com/apify/client/DefaultHttpBackend.java | 56 ------- .../java/com/apify/client/ListOptions.java | 8 +- .../com/apify/client/PaginatedIterator.java | 7 +- .../java/com/apify/client/PaginationList.java | 23 ++- .../java/com/apify/client/QueryParams.java | 20 +-- .../com/apify/client/ResourceContext.java | 122 ++++++++------ .../java/com/apify/client/Signatures.java | 6 +- src/main/java/com/apify/client/Statuses.java | 4 +- .../com/apify/client/StorageListOptions.java | 8 +- src/main/java/com/apify/client/Version.java | 4 +- .../com/apify/client/{ => actor}/Actor.java | 3 +- .../client/{ => actor}/ActorBuildOptions.java | 4 +- .../apify/client/{ => actor}/ActorClient.java | 20 ++- .../{ => actor}/ActorCollectionClient.java | 11 +- .../apify/client/{ => actor}/ActorEnvVar.java | 4 +- .../client/{ => actor}/ActorEnvVarClient.java | 5 +- .../ActorEnvVarCollectionClient.java | 6 +- .../client/{ => actor}/ActorListOptions.java | 4 +- .../client/{ => actor}/ActorStartOptions.java | 7 +- .../client/{ => actor}/ActorVersion.java | 4 +- .../{ => actor}/ActorVersionClient.java | 5 +- .../ActorVersionCollectionClient.java | 7 +- .../com/apify/client/{ => build}/Build.java | 4 +- .../apify/client/{ => build}/BuildClient.java | 13 +- .../{ => build}/BuildCollectionClient.java | 9 +- .../apify/client/{ => dataset}/Dataset.java | 3 +- .../client/{ => dataset}/DatasetClient.java | 43 +++-- .../DatasetCollectionClient.java | 14 +- .../{ => dataset}/DatasetDownloadOptions.java | 4 +- .../DatasetListItemsOptions.java | 3 +- .../{ => dataset}/DownloadItemsFormat.java | 2 +- .../apify/client/{ => http}/ApiResponse.java | 8 +- .../client/{ => http}/ApifyApiException.java | 2 +- .../client/http/ApifyClientException.java | 32 ++++ .../client/http/ApifyTransportException.java | 30 ++++ .../client/http/DefaultHttpTransport.java | 72 ++++++++ .../client/{ => http}/HttpClientCore.java | 148 +++++++++-------- .../client/http/HttpTimeoutException.java | 22 +++ .../HttpTransport.java} | 9 +- .../com/apify/client/{ => http}/Json.java | 37 +++-- .../apify/client/{ => http}/RetryConfig.java | 6 +- .../{ => keyvalue}/GetRecordOptions.java | 4 +- .../client/{ => keyvalue}/KeyValueStore.java | 3 +- .../{ => keyvalue}/KeyValueStoreClient.java | 36 ++-- .../KeyValueStoreCollectionClient.java | 12 +- .../{ => keyvalue}/KeyValueStoreKey.java | 4 +- .../{ => keyvalue}/KeyValueStoreKeysPage.java | 2 +- .../{ => keyvalue}/KeyValueStoreRecord.java | 2 +- .../{ => keyvalue}/ListKeysOptions.java | 4 +- .../{ => keyvalue}/SetRecordOptions.java | 2 +- .../com/apify/client/{ => log}/LogClient.java | 15 +- .../apify/client/{ => log}/LogOptions.java | 4 +- .../apify/client/{ => log}/StreamedLog.java | 24 +-- .../client/{ => log}/StreamedLogOptions.java | 13 +- .../BatchAddRequestsOptions.java | 2 +- .../{ => requestqueue}/BatchAddResult.java | 2 +- .../ListRequestsOptions.java | 3 +- .../{ => requestqueue}/RequestQueue.java | 3 +- .../RequestQueueClient.java | 24 ++- .../RequestQueueCollectionClient.java | 12 +- .../{ => requestqueue}/RequestQueueHead.java | 3 +- .../RequestQueueOperationInfo.java | 2 +- .../RequestQueueRequest.java | 3 +- .../com/apify/client/{ => run}/ActorRun.java | 4 +- .../client/{ => run}/LastRunOptions.java | 5 +- .../client/{ => run}/MetamorphOptions.java | 4 +- .../client/{ => run}/RunChargeOptions.java | 2 +- .../com/apify/client/{ => run}/RunClient.java | 45 +++-- .../client/{ => run}/RunCollectionClient.java | 9 +- .../client/{ => run}/RunListOptions.java | 4 +- .../client/{ => run}/RunResurrectOptions.java | 4 +- .../apify/client/{ => schedule}/Schedule.java | 4 +- .../client/{ => schedule}/ScheduleClient.java | 11 +- .../ScheduleCollectionClient.java | 12 +- .../{ => store}/ActorStoreListItem.java | 4 +- .../{ => store}/StoreCollectionClient.java | 11 +- .../client/{ => store}/StoreListOptions.java | 6 +- .../com/apify/client/{ => task}/Task.java | 3 +- .../apify/client/{ => task}/TaskClient.java | 15 +- .../{ => task}/TaskCollectionClient.java | 12 +- .../client/{ => task}/TaskStartOptions.java | 4 +- .../{ => task}/ValidateInputOptions.java | 10 +- .../com/apify/client/{ => user}/User.java | 4 +- .../apify/client/{ => user}/UserClient.java | 11 +- .../AbstractWebhookCollectionClient.java | 10 +- .../NestedWebhookCollectionClient.java | 6 +- .../apify/client/{ => webhook}/Webhook.java | 3 +- .../client/{ => webhook}/WebhookClient.java | 10 +- .../WebhookCollectionClient.java | 7 +- .../client/{ => webhook}/WebhookDispatch.java | 4 +- .../{ => webhook}/WebhookDispatchClient.java | 10 +- .../WebhookDispatchCollectionClient.java | 9 +- .../client/ClientBehaviourRegressionTest.java | 154 ++++++++++-------- .../com/apify/client/CompressionTest.java | 11 +- .../client/DatasetItemsIteratorTest.java | 31 ++-- .../com/apify/client/DocSnippetsTest.java | 13 ++ .../com/apify/client/KeyIteratorTest.java | 45 ++--- .../{MockBackend.java => MockTransport.java} | 32 ++-- .../com/apify/client/StreamedLogTest.java | 37 +++-- .../java/com/apify/client/UnitHttpTest.java | 48 +++--- .../client/examples/CreateBuildRunActor.java | 12 +- .../com/apify/client/examples/GetAccount.java | 2 +- .../apify/client/examples/IterateStore.java | 4 +- .../apify/client/examples/LogRedirection.java | 8 +- .../examples/RunAndLastRunStorages.java | 6 +- .../apify/client/examples/RunStoreActor.java | 6 +- .../com/apify/client/examples/Storages.java | 12 +- .../integration/ActorIntegrationTest.java | 10 +- .../integration/ActorRunIntegrationTest.java | 20 ++- .../integration/BuildIntegrationTest.java | 6 +- .../integration/DatasetIntegrationTest.java | 10 +- .../integration/IterationIntegrationTest.java | 41 ++--- .../KeyValueStoreIntegrationTest.java | 10 +- .../RequestQueueIntegrationTest.java | 12 +- .../integration/ScheduleIntegrationTest.java | 4 +- .../integration/StoreIntegrationTest.java | 4 +- .../integration/TaskIntegrationTest.java | 6 +- .../integration/WebhookIntegrationTest.java | 6 +- 128 files changed, 1359 insertions(+), 828 deletions(-) create mode 100644 src/main/java/com/apify/client/ApiPaths.java delete mode 100644 src/main/java/com/apify/client/DefaultHttpBackend.java rename src/main/java/com/apify/client/{ => actor}/Actor.java (94%) rename src/main/java/com/apify/client/{ => actor}/ActorBuildOptions.java (94%) rename src/main/java/com/apify/client/{ => actor}/ActorClient.java (88%) rename src/main/java/com/apify/client/{ => actor}/ActorCollectionClient.java (78%) rename src/main/java/com/apify/client/{ => actor}/ActorEnvVar.java (92%) rename src/main/java/com/apify/client/{ => actor}/ActorEnvVarClient.java (85%) rename src/main/java/com/apify/client/{ => actor}/ActorEnvVarCollectionClient.java (85%) rename src/main/java/com/apify/client/{ => actor}/ActorListOptions.java (95%) rename src/main/java/com/apify/client/{ => actor}/ActorStartOptions.java (94%) rename src/main/java/com/apify/client/{ => actor}/ActorVersion.java (85%) rename src/main/java/com/apify/client/{ => actor}/ActorVersionClient.java (89%) rename src/main/java/com/apify/client/{ => actor}/ActorVersionCollectionClient.java (90%) rename src/main/java/com/apify/client/{ => build}/Build.java (92%) rename src/main/java/com/apify/client/{ => build}/BuildClient.java (82%) rename src/main/java/com/apify/client/{ => build}/BuildCollectionClient.java (79%) rename src/main/java/com/apify/client/{ => dataset}/Dataset.java (92%) rename src/main/java/com/apify/client/{ => dataset}/DatasetClient.java (87%) rename src/main/java/com/apify/client/{ => dataset}/DatasetCollectionClient.java (81%) rename src/main/java/com/apify/client/{ => dataset}/DatasetDownloadOptions.java (97%) rename src/main/java/com/apify/client/{ => dataset}/DatasetListItemsOptions.java (98%) rename src/main/java/com/apify/client/{ => dataset}/DownloadItemsFormat.java (94%) rename src/main/java/com/apify/client/{ => http}/ApiResponse.java (73%) rename src/main/java/com/apify/client/{ => http}/ApifyApiException.java (98%) create mode 100644 src/main/java/com/apify/client/http/ApifyClientException.java create mode 100644 src/main/java/com/apify/client/http/ApifyTransportException.java create mode 100644 src/main/java/com/apify/client/http/DefaultHttpTransport.java rename src/main/java/com/apify/client/{ => http}/HttpClientCore.java (78%) create mode 100644 src/main/java/com/apify/client/http/HttpTimeoutException.java rename src/main/java/com/apify/client/{HttpBackend.java => http/HttpTransport.java} (85%) rename src/main/java/com/apify/client/{ => http}/Json.java (74%) rename src/main/java/com/apify/client/{ => http}/RetryConfig.java (92%) rename src/main/java/com/apify/client/{ => keyvalue}/GetRecordOptions.java (90%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStore.java (91%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStoreClient.java (89%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStoreCollectionClient.java (82%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStoreKey.java (80%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStoreKeysPage.java (96%) rename src/main/java/com/apify/client/{ => keyvalue}/KeyValueStoreRecord.java (96%) rename src/main/java/com/apify/client/{ => keyvalue}/ListKeysOptions.java (96%) rename src/main/java/com/apify/client/{ => keyvalue}/SetRecordOptions.java (96%) rename src/main/java/com/apify/client/{ => log}/LogClient.java (83%) rename src/main/java/com/apify/client/{ => log}/LogOptions.java (89%) rename src/main/java/com/apify/client/{ => log}/StreamedLog.java (94%) rename src/main/java/com/apify/client/{ => log}/StreamedLogOptions.java (82%) rename src/main/java/com/apify/client/{ => requestqueue}/BatchAddRequestsOptions.java (98%) rename src/main/java/com/apify/client/{ => requestqueue}/BatchAddResult.java (97%) rename src/main/java/com/apify/client/{ => requestqueue}/ListRequestsOptions.java (96%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueue.java (92%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueueClient.java (95%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueueCollectionClient.java (77%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueueHead.java (89%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueueOperationInfo.java (95%) rename src/main/java/com/apify/client/{ => requestqueue}/RequestQueueRequest.java (95%) rename src/main/java/com/apify/client/{ => run}/ActorRun.java (95%) rename src/main/java/com/apify/client/{ => run}/LastRunOptions.java (90%) rename src/main/java/com/apify/client/{ => run}/MetamorphOptions.java (90%) rename src/main/java/com/apify/client/{ => run}/RunChargeOptions.java (97%) rename src/main/java/com/apify/client/{ => run}/RunClient.java (86%) rename src/main/java/com/apify/client/{ => run}/RunCollectionClient.java (86%) rename src/main/java/com/apify/client/{ => run}/RunListOptions.java (92%) rename src/main/java/com/apify/client/{ => run}/RunResurrectOptions.java (96%) rename src/main/java/com/apify/client/{ => schedule}/Schedule.java (91%) rename src/main/java/com/apify/client/{ => schedule}/ScheduleClient.java (72%) rename src/main/java/com/apify/client/{ => schedule}/ScheduleCollectionClient.java (76%) rename src/main/java/com/apify/client/{ => store}/ActorStoreListItem.java (88%) rename src/main/java/com/apify/client/{ => store}/StoreCollectionClient.java (78%) rename src/main/java/com/apify/client/{ => store}/StoreListOptions.java (96%) rename src/main/java/com/apify/client/{ => task}/Task.java (93%) rename src/main/java/com/apify/client/{ => task}/TaskClient.java (86%) rename src/main/java/com/apify/client/{ => task}/TaskCollectionClient.java (76%) rename src/main/java/com/apify/client/{ => task}/TaskStartOptions.java (95%) rename src/main/java/com/apify/client/{ => task}/ValidateInputOptions.java (76%) rename src/main/java/com/apify/client/{ => user}/User.java (84%) rename src/main/java/com/apify/client/{ => user}/UserClient.java (87%) rename src/main/java/com/apify/client/{ => webhook}/AbstractWebhookCollectionClient.java (80%) rename src/main/java/com/apify/client/{ => webhook}/NestedWebhookCollectionClient.java (77%) rename src/main/java/com/apify/client/{ => webhook}/Webhook.java (90%) rename src/main/java/com/apify/client/{ => webhook}/WebhookClient.java (76%) rename src/main/java/com/apify/client/{ => webhook}/WebhookCollectionClient.java (75%) rename src/main/java/com/apify/client/{ => webhook}/WebhookDispatch.java (82%) rename src/main/java/com/apify/client/{ => webhook}/WebhookDispatchClient.java (52%) rename src/main/java/com/apify/client/{ => webhook}/WebhookDispatchCollectionClient.java (80%) rename src/test/java/com/apify/client/{MockBackend.java => MockTransport.java} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d3ff0..23b0119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ 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.4.0] - 2026-07-20 + +### Changed + +- **Breaking:** split the single `com.apify.client` package into resource-scoped sub-packages + (`com.apify.client.actor`, `.build`, `.run`, `.dataset`, `.keyvalue`, `.requestqueue`, `.task`, + `.schedule`, `.webhook`, `.user`, `.store`, `.log`, `.http`); `ApifyClient`, its builder, shared + value types and the exception hierarchy stay in the root package. +- **Breaking:** renamed the replaceable transport: `HttpBackend` -> `HttpTransport`, + `DefaultHttpBackend` -> `DefaultHttpTransport`, `HttpBackend.sendStreaming` -> + `HttpTransport.sendStreamingResponse`, `ApifyClientBuilder.httpBackend` -> + `ApifyClientBuilder.httpTransport`. +- **Breaking:** reworked the exception hierarchy: added a common `ApifyClientException` base; + the previously internal, package-private transport-failure exception is now a public + `ApifyTransportException`; timeout detection is backend-agnostic via a new + `HttpTimeoutException` that any `HttpTransport` implementation can throw. +- **Breaking:** `ApifyClient.getUserAgent()`/`getApiBaseUrl()` are no longer public. +- **Breaking:** removed `ApifyClient.setStatusMessage(...)`; use + `client.run(runId).update(Map.of("statusMessage", ..., "isStatusMessageTerminal", ...))`. +- `DatasetClient`/`KeyValueStoreClient` are now fully immutable: the public-base-URL is set at + construction instead of through a self-mutating `withPublicBase` step. +- Lowered the default HTTP connection-establishment timeout from 30s to 10s and made it + configurable (`DefaultHttpTransport(Duration)`). +- Consolidated the internal `call`/`callWithHeaders` overloads under a single `call` name. +- Error-response body parsing now maps to typed Jackson classes instead of navigating a raw + `JsonNode` tree; error-reporting path extraction now parses the URL instead of using string + offsets. +- Brotli request-body compression is now opt-in: the client depends only on the brotli4j core API + (`optional`, no native codec bundled); a consumer that wants brotli over the always-available + gzip fallback adds brotli4j's native artifact for their platform themselves. +- The default log-redirection destination now logs through SLF4J instead of `java.util.logging`. +- Centralized the API's resource-collection path segments (e.g. `datasets`, `actor-builds`) into a + single `ApiPaths` constants class, referenced by each dedicated resource client instead of being + passed in from `ApifyClient`. +- Verified the client against OpenAPI specification version `v2-2026-07-20T094852Z` and bumped + `Version.API_SPEC_VERSION`. The spec change only added already-handled error responses (`402`, + `404`, `409` on the build/task endpoints), an `ErrorType` enum value, and allowed `null` for a + tagged build's `buildNumber`; all are already covered by the client's generic status-code error + handling and untyped-extras model deserialization, so no interface or behavior change was needed. + +### Fixed + +- `PaginationList.setItems` now defensively copies its input, closing a representation-exposure + gap surfaced by making the setter part of the client's cross-package public surface. + ## [0.3.1] - 2026-07-14 ### Added diff --git a/README.md b/README.md index 4a08490..ea336b4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Maven (Maven Central is a default repository, so no extra configuration is neede com.apify apify-client - 0.3.1 + 0.4.0 ``` @@ -35,40 +35,18 @@ repositories { } dependencies { - implementation 'com.apify:apify-client:0.3.1' + implementation 'com.apify:apify-client:0.4.0' } ``` ## Quick start -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: - -```xml - - 4.0.0 - com.example - hello-apify - 1.0.0 - - 17 - - - - com.apify - apify-client - 0.3.1 - - - -``` - -Create `HelloApify.java`: +After adding the dependency (above), create an `ApifyClient`, then drill down into a resource: ```java import com.apify.client.ApifyClient; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; +import com.apify.client.run.ActorRun; +import com.apify.client.actor.ActorStartOptions; class HelloApify { public static void main(String[] args) { @@ -80,32 +58,18 @@ class HelloApify { } ``` -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 -``` +`ApifyClient.create` takes the token as an explicit argument — it does **not** read `APIFY_TOKEN` (or +any other environment variable) automatically. Read it yourself if you want that, e.g. +`ApifyClient.create(System.getenv("APIFY_TOKEN"))`. -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.*;`). +All public client types live under `com.apify.client`, split by resource into sub-packages (e.g. +`com.apify.client.run.ActorRun`, `com.apify.client.dataset.DatasetListItemsOptions`) — see +[Resources](#resources) below for the full list. The remaining snippets in this file are fragments +that assume a configured `client`; the [resource pages](docs/README.md) show each fragment as a +complete, correctly-imported program. Reading items from a run's default dataset: ```java -ApifyClient client = ApifyClient.create("my-api-token"); - -// Start an Actor and wait for it to finish. The last argument is the wait budget in seconds; -// pass a value (e.g. 120L) to bound the wait, or null to wait indefinitely. ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); - -// Read items from the run's default dataset. PaginationList items = client.dataset(run.getDefaultDatasetId()).listItems(new DatasetListItemsOptions()); System.out.println("Items in this page: " + items.getCount()); @@ -113,11 +77,8 @@ System.out.println("Items in this page: " + items.getCount()); 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. -`ApifyClient.create(System.getenv("APIFY_TOKEN"))`. +[Error handling](#error-handling) below. [`docs/examples.md`](docs/examples.md) has complete, +runnable programs (build-and-run, storages, log redirection, and more). ## Configuration @@ -137,13 +98,13 @@ ApifyClient configured = ### Replaceable HTTP transport -The transport is a replaceable component. The default is `DefaultHttpBackend` (backed by the JDK's -`java.net.http.HttpClient`); provide your own `HttpBackend` to share a connection pool or customize +The transport is a replaceable component. The default is `DefaultHttpTransport` (backed by the JDK's +`java.net.http.HttpClient`); provide your own `HttpTransport` to share a connection pool or customize proxy/TLS: ```java -HttpBackend backend = new DefaultHttpBackend(java.net.http.HttpClient.newHttpClient()); -ApifyClient withBackend = ApifyClient.builder().token("t").httpBackend(backend).build(); +HttpTransport backend = new DefaultHttpTransport(java.net.http.HttpClient.newHttpClient()); +ApifyClient withBackend = ApifyClient.builder().token("t").httpTransport(backend).build(); ``` Cross-cutting behaviour applied to every request lives in the client, not the backend: @@ -156,10 +117,7 @@ Methods that fetch a single resource return an `Optional`: a missing resource empty `Optional` rather than an exception. ```java -Optional maybeActor = client.actor("apify/hello-world").get(); -if (maybeActor.isPresent()) { - System.out.println(maybeActor.get().getTitle()); -} +client.actor("apify/hello-world").get().ifPresent(actor -> System.out.println(actor.getTitle())); ``` ## Error handling @@ -189,9 +147,12 @@ try { 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.1`). -- `Version.API_SPEC_VERSION` — the Apify OpenAPI specification version this client was verified - against (`v2-2026-07-13T092445Z`). +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.4.0`). +- `Version.API_SPEC_VERSION` — the version of the [Apify OpenAPI specification](https://docs.apify.com/api/openapi.json) + (its `info.version` field) that this client's endpoints, parameters and models were last generated + and checked against (`v2-2026-07-20T094852Z`). It is a snapshot, not a live compatibility + guarantee: the client keeps working against newer, backward-compatible spec revisions, but a + feature added to the API after this snapshot has no corresponding method here yet. Changes to the public interface other than additive ones are considered breaking changes and follow [Semantic Versioning](https://semver.org/). @@ -234,21 +195,29 @@ Full documentation is in the [`docs/`](docs/README.md) directory, organized by r ## Resources -| Accessor | Client | Description | -|---|---|---| -| `actors()` / `actor(id)` | `ActorCollectionClient` / `ActorClient` | Actors | -| `builds()` / `build(id)` | `BuildCollectionClient` / `BuildClient` | Actor builds | -| `runs()` / `run(id)` | `RunCollectionClient` / `RunClient` | Actor runs | -| `datasets()` / `dataset(id)` | `DatasetCollectionClient` / `DatasetClient` | Datasets | -| `keyValueStores()` / `keyValueStore(id)` | `KeyValueStoreCollectionClient` / `KeyValueStoreClient` | Key-value stores | -| `requestQueues()` / `requestQueue(id)` | `RequestQueueCollectionClient` / `RequestQueueClient` | Request queues | -| `tasks()` / `task(id)` | `TaskCollectionClient` / `TaskClient` | Actor tasks | -| `schedules()` / `schedule(id)` | `ScheduleCollectionClient` / `ScheduleClient` | Schedules | -| `webhooks()` / `webhook(id)` | `WebhookCollectionClient` / `WebhookClient` | Webhooks | -| `webhookDispatches()` / `webhookDispatch(id)` | `WebhookDispatchCollectionClient` / `WebhookDispatchClient` | Webhook dispatches | -| `store()` | `StoreCollectionClient` | Apify Store | -| `me()` / `user(id)` | `UserClient` | Users | -| `log(id)` | `LogClient` | Build/run logs | +Every resource client lives in its own sub-package of `com.apify.client`, named after the resource. +`ApifyClient` itself, its builder, the exception types, and shared value types (`PaginationList`, +`Version`, ...) stay in the root `com.apify.client` package. + +| Accessor | Client | Package | Description | +|---|---|---|---| +| `actors()` / `actor(id)` | `ActorCollectionClient` / `ActorClient` | `com.apify.client.actor` | Actors | +| `builds()` / `build(id)` | `BuildCollectionClient` / `BuildClient` | `com.apify.client.build` | Actor builds | +| `runs()` / `run(id)` | `RunCollectionClient` / `RunClient` | `com.apify.client.run` | Actor runs | +| `datasets()` / `dataset(id)` | `DatasetCollectionClient` / `DatasetClient` | `com.apify.client.dataset` | Datasets | +| `keyValueStores()` / `keyValueStore(id)` | `KeyValueStoreCollectionClient` / `KeyValueStoreClient` | `com.apify.client.keyvalue` | Key-value stores | +| `requestQueues()` / `requestQueue(id)` | `RequestQueueCollectionClient` / `RequestQueueClient` | `com.apify.client.requestqueue` | Request queues | +| `tasks()` / `task(id)` | `TaskCollectionClient` / `TaskClient` | `com.apify.client.task` | Actor tasks | +| `schedules()` / `schedule(id)` | `ScheduleCollectionClient` / `ScheduleClient` | `com.apify.client.schedule` | Schedules | +| `webhooks()` / `webhook(id)` | `WebhookCollectionClient` / `WebhookClient` | `com.apify.client.webhook` | Webhooks | +| `webhookDispatches()` / `webhookDispatch(id)` | `WebhookDispatchCollectionClient` / `WebhookDispatchClient` | `com.apify.client.webhook` | Webhook dispatches | +| `store()` | `StoreCollectionClient` | `com.apify.client.store` | Apify Store | +| `me()` / `user(id)` | `UserClient` | `com.apify.client.user` | Users | +| `log(id)` | `LogClient` | `com.apify.client.log` | Build/run logs | + +The HTTP transport contract (`HttpTransport`, `DefaultHttpTransport`) and the exceptions thrown for +transport-level failures (`ApifyTransportException`, `HttpTimeoutException`) live in +`com.apify.client.http`, alongside `ApifyApiException`. ## License diff --git a/docs/README.md b/docs/README.md index f2459be..c6222a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,12 +67,6 @@ Schedule schedule = client.schedule("SCHEDULE_ID").get().orElseThrow(); Object actions = schedule.getExtra().get("actions"); ``` -## Setting single-resource status - -`client.setStatusMessage(String message, boolean isTerminal)` updates the status message of the -current Actor run (identified by the `ACTOR_RUN_ID` environment variable); it only works from inside -a run and throws `IllegalStateException` otherwise. Returns the updated `ActorRun`. - ## Optional option fields Option objects use fluent setters and nullable (boxed) fields; an unset field means "use the API diff --git a/docs/runs.md b/docs/runs.md index e1c03a3..fbab358 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -71,8 +71,13 @@ try (StreamedLog streamedLog = 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)). +To set the current run's status message from inside an Actor, resolve the run id (e.g. from the +`ACTOR_RUN_ID` environment variable) and `update()` it directly: + +```java +String runId = System.getenv("ACTOR_RUN_ID"); +client.run(runId).update(Map.of("statusMessage", "half way there", "isStatusMessageTerminal", false)); +``` > **`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`, diff --git a/pom.xml b/pom.xml index 7d62df7..1402cf9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.3.1 + 0.4.0 jar Apify Java Client @@ -42,6 +42,7 @@ 2.17.2 5.10.2 1.23.0 + 2.0.13 @@ -56,50 +57,52 @@ ${jackson.version} - + - com.aayushatharva.brotli4j - brotli4j - ${brotli4j.version} - - - com.aayushatharva.brotli4j - native-linux-x86_64 - ${brotli4j.version} - runtime - - - com.aayushatharva.brotli4j - native-linux-aarch64 - ${brotli4j.version} - runtime + org.slf4j + slf4j-api + ${slf4j.version} + + com.aayushatharva.brotli4j - native-osx-x86_64 + brotli4j ${brotli4j.version} - runtime + true + - com.aayushatharva.brotli4j - native-osx-aarch64 - ${brotli4j.version} - runtime + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + com.aayushatharva.brotli4j - native-windows-x86_64 + native-linux-x86_64 ${brotli4j.version} - runtime + test + - org.junit.jupiter - junit-jupiter - ${junit.version} + org.slf4j + slf4j-simple + ${slf4j.version} test diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 252e702..3fd5836 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -1,26 +1,45 @@ - + - + + + + + + + + + + + + + diff --git a/src/main/java/com/apify/client/ApiPaths.java b/src/main/java/com/apify/client/ApiPaths.java new file mode 100644 index 0000000..e174131 --- /dev/null +++ b/src/main/java/com/apify/client/ApiPaths.java @@ -0,0 +1,29 @@ +package com.apify.client; + +/** + * The Apify API's top-level resource-collection path segments (e.g. {@code datasets} in {@code + * /v2/datasets/{id}}), named and collected in one place so every resource client that needs its own + * collection path references the same constant instead of repeating the literal. + * + *

Nested, resource-relative sub-paths (e.g. an Actor's {@code builds}, a run's {@code + * key-value-store}) are not listed here: they are specific to their parent resource client (e.g. + * {@code ActorClient}, {@code RunClient}) and are defined right where they are used. + */ +public final class ApiPaths { + + public static final String ACTORS = "actors"; + public static final String ACTOR_BUILDS = "actor-builds"; + public static final String ACTOR_RUNS = "actor-runs"; + public static final String ACTOR_TASKS = "actor-tasks"; + public static final String DATASETS = "datasets"; + public static final String KEY_VALUE_STORES = "key-value-stores"; + public static final String REQUEST_QUEUES = "request-queues"; + public static final String SCHEDULES = "schedules"; + public static final String WEBHOOKS = "webhooks"; + public static final String WEBHOOK_DISPATCHES = "webhook-dispatches"; + public static final String STORE = "store"; + public static final String USERS = "users"; + public static final String LOGS = "logs"; + + private ApiPaths() {} +} diff --git a/src/main/java/com/apify/client/ApifyClient.java b/src/main/java/com/apify/client/ApifyClient.java index bc59f35..648c5fa 100644 --- a/src/main/java/com/apify/client/ApifyClient.java +++ b/src/main/java/com/apify/client/ApifyClient.java @@ -1,7 +1,31 @@ package com.apify.client; -import java.util.LinkedHashMap; -import java.util.Map; +import com.apify.client.actor.ActorClient; +import com.apify.client.actor.ActorCollectionClient; +import com.apify.client.build.BuildClient; +import com.apify.client.build.BuildCollectionClient; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetCollectionClient; +import com.apify.client.http.DefaultHttpTransport; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.HttpTransport; +import com.apify.client.keyvalue.KeyValueStoreClient; +import com.apify.client.keyvalue.KeyValueStoreCollectionClient; +import com.apify.client.log.LogClient; +import com.apify.client.requestqueue.RequestQueueClient; +import com.apify.client.requestqueue.RequestQueueCollectionClient; +import com.apify.client.run.RunClient; +import com.apify.client.run.RunCollectionClient; +import com.apify.client.schedule.ScheduleClient; +import com.apify.client.schedule.ScheduleCollectionClient; +import com.apify.client.store.StoreCollectionClient; +import com.apify.client.task.TaskClient; +import com.apify.client.task.TaskCollectionClient; +import com.apify.client.user.UserClient; +import com.apify.client.webhook.WebhookClient; +import com.apify.client.webhook.WebhookCollectionClient; +import com.apify.client.webhook.WebhookDispatchClient; +import com.apify.client.webhook.WebhookDispatchCollectionClient; /** * The entry point for interacting with the Apify API. @@ -27,8 +51,8 @@ * *

    *
  • Public interface: {@link ApifyClient} and the resource clients it returns. - *
  • Replaceable transport: the {@link HttpBackend} interface, with a default {@link - * DefaultHttpBackend}; swap it via {@link ApifyClientBuilder#httpBackend(HttpBackend)}. + *
  • Replaceable transport: the {@link HttpTransport} interface, with a default {@link + * DefaultHttpTransport}; swap it via {@link ApifyClientBuilder#httpTransport(HttpTransport)}. *
  • Cross-cutting behaviour (auth, User-Agent, retries with exponential backoff, timeouts) * lives in the internal HTTP client and is applied to every request. *
@@ -58,16 +82,20 @@ public static ApifyClientBuilder builder() { return new ApifyClientBuilder(); } - /** Returns the {@code User-Agent} header value this client sends. */ - public String getUserAgent() { + /** + * Returns the {@code User-Agent} header value this client sends. Not part of the public API + * (there is no supported use for an end user to read it back); package-private only so the test + * suite can assert on it. + */ + String getUserAgent() { return http.userAgent(); } /** * Returns the fully-qualified API base URL this client targets (including the {@code /v2} - * suffix). + * suffix). Not part of the public API, for the same reason as {@link #getUserAgent()}. */ - public String getApiBaseUrl() { + String getApiBaseUrl() { return baseUrl; } @@ -87,7 +115,7 @@ public ActorClient actor(String id) { /** A client for the Actor build collection (list builds). */ public BuildCollectionClient builds() { - return new BuildCollectionClient(http, baseUrl, "actor-builds"); + return new BuildCollectionClient(http, baseUrl, ApiPaths.ACTOR_BUILDS); } /** A client for a specific Actor build. */ @@ -99,12 +127,12 @@ public BuildClient build(String id) { /** A client for the Actor run collection (list runs). */ public RunCollectionClient runs() { - return new RunCollectionClient(http, baseUrl, "actor-runs"); + return new RunCollectionClient(http, baseUrl, ApiPaths.ACTOR_RUNS); } /** A client for a specific Actor run. */ public RunClient run(String id) { - return new RunClient(this, http, baseUrl, "actor-runs", id); + return new RunClient(this, http, baseUrl, ApiPaths.ACTOR_RUNS, id); } // ----- Dataset accessors --------------------------------------------------- @@ -116,7 +144,7 @@ public DatasetCollectionClient datasets() { /** A client for a specific dataset, addressed by ID or name. */ public DatasetClient dataset(String id) { - return new DatasetClient(http, baseUrl, "datasets", id).withPublicBase(publicBaseUrl); + return new DatasetClient(http, baseUrl, id, publicBaseUrl); } // ----- Key-value store accessors ------------------------------------------- @@ -128,8 +156,7 @@ public KeyValueStoreCollectionClient keyValueStores() { /** A client for a specific key-value store, addressed by ID or name. */ public KeyValueStoreClient keyValueStore(String id) { - return new KeyValueStoreClient(http, baseUrl, "key-value-stores", id) - .withPublicBase(publicBaseUrl); + return new KeyValueStoreClient(http, baseUrl, id, publicBaseUrl); } // ----- Request queue accessors --------------------------------------------- @@ -141,7 +168,7 @@ public RequestQueueCollectionClient requestQueues() { /** A client for a specific request queue, addressed by ID or name. */ public RequestQueueClient requestQueue(String id) { - return new RequestQueueClient(http, baseUrl, "request-queues", id); + return new RequestQueueClient(http, baseUrl, id); } // ----- Task accessors ------------------------------------------------------ @@ -182,7 +209,7 @@ public WebhookClient webhook(String id) { /** A client for the webhook dispatch collection. */ public WebhookDispatchCollectionClient webhookDispatches() { - return new WebhookDispatchCollectionClient(http, baseUrl, "webhook-dispatches"); + return new WebhookDispatchCollectionClient(http, baseUrl, ApiPaths.WEBHOOK_DISPATCHES); } /** A client for a specific webhook dispatch. */ @@ -199,7 +226,7 @@ public StoreCollectionClient store() { /** A client for accessing a build's or run's log. */ public LogClient log(String buildOrRunId) { - return new LogClient(http, baseUrl, "logs", buildOrRunId); + return new LogClient(http, baseUrl, buildOrRunId); } /** A client for the current user ({@code /users/me}). */ @@ -211,23 +238,4 @@ public UserClient me() { public UserClient user(String id) { return new UserClient(http, baseUrl, id); } - - /** - * Sets the status message of the current Actor run. - * - *

This convenience method updates the run identified by the {@code ACTOR_RUN_ID} environment - * variable, so it only works when called from inside an Actor run. If {@code isTerminal} is true, - * the message becomes final and won't be overwritten. Throws {@link IllegalStateException} if - * {@code ACTOR_RUN_ID} is not set. - */ - public ActorRun setStatusMessage(String message, boolean isTerminal) { - String runId = System.getenv("ACTOR_RUN_ID"); - if (runId == null || runId.isEmpty()) { - throw new IllegalStateException("ACTOR_RUN_ID environment variable is not set"); - } - Map body = new LinkedHashMap<>(); - body.put("statusMessage", message); - body.put("isStatusMessageTerminal", isTerminal); - return run(runId).update(body); - } } diff --git a/src/main/java/com/apify/client/ApifyClientBuilder.java b/src/main/java/com/apify/client/ApifyClientBuilder.java index ef4a377..c59efcb 100644 --- a/src/main/java/com/apify/client/ApifyClientBuilder.java +++ b/src/main/java/com/apify/client/ApifyClientBuilder.java @@ -1,5 +1,9 @@ package com.apify.client; +import com.apify.client.http.DefaultHttpTransport; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.HttpTransport; +import com.apify.client.http.RetryConfig; import java.time.Duration; import java.util.function.BooleanSupplier; @@ -27,7 +31,7 @@ public final class ApifyClientBuilder { private Duration maxDelayBetweenRetries = DEFAULT_TIMEOUT; private Duration timeout = DEFAULT_TIMEOUT; private String userAgentSuffix; - private HttpBackend httpBackend; + private HttpTransport httpTransport; private BooleanSupplier isAtHomeFn = ApifyClientBuilder::defaultIsAtHome; ApifyClientBuilder() {} @@ -85,8 +89,8 @@ public ApifyClientBuilder userAgentSuffix(String userAgentSuffix) { } /** Replaces the default HTTP backend with a custom implementation (the replaceable transport). */ - public ApifyClientBuilder httpBackend(HttpBackend httpBackend) { - this.httpBackend = httpBackend; + public ApifyClientBuilder httpTransport(HttpTransport httpTransport) { + this.httpTransport = httpTransport; return this; } @@ -98,7 +102,7 @@ ApifyClientBuilder isAtHomeFn(BooleanSupplier isAtHomeFn) { /** Builds the configured {@link ApifyClient}. */ public ApifyClient build() { - HttpBackend backend = httpBackend != null ? httpBackend : new DefaultHttpBackend(); + HttpTransport backend = httpTransport != null ? httpTransport : new DefaultHttpTransport(); String userAgent = buildUserAgent(userAgentSuffix, isAtHomeFn); RetryConfig retry = new RetryConfig(maxRetries, minDelayBetweenRetries, maxDelayBetweenRetries, timeout); diff --git a/src/main/java/com/apify/client/DataEnvelope.java b/src/main/java/com/apify/client/DataEnvelope.java index 73bd7b2..34200fd 100644 --- a/src/main/java/com/apify/client/DataEnvelope.java +++ b/src/main/java/com/apify/client/DataEnvelope.java @@ -5,6 +5,6 @@ * * @param the type of the wrapped payload */ -final class DataEnvelope { +public final class DataEnvelope { public T data; } diff --git a/src/main/java/com/apify/client/DefaultHttpBackend.java b/src/main/java/com/apify/client/DefaultHttpBackend.java deleted file mode 100644 index d8c219f..0000000 --- a/src/main/java/com/apify/client/DefaultHttpBackend.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.apify.client; - -import java.io.IOException; -import java.io.InputStream; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; - -/** - * The default {@link HttpBackend}, backed by the JDK's {@link java.net.http.HttpClient}. - * - *

The per-attempt timeout is applied to each {@link HttpRequest} by the orchestrating client, so - * this backend sets only a connection timeout of its own. It follows normal redirects and reuses - * connections from the underlying client's pool. - */ -public final class DefaultHttpBackend implements HttpBackend { - - /** - * Connection-establishment timeout (distinct from the per-request timeout the client applies). - */ - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(30); - - private final HttpClient client; - - /** Creates a backend with a sensible default {@link java.net.http.HttpClient}. */ - public DefaultHttpBackend() { - // Follow redirects (NORMAL, matching the reference clients): some endpoints — e.g. a - // non-attachment key-value-store record GET — answer with a 302 to external storage, which the - // JDK's default Redirect.NEVER would otherwise surface as an error. NORMAL does not follow an - // HTTPS->HTTP downgrade and the JDK strips the Authorization header on cross-origin hops, so - // the - // bearer token is not leaked to the redirect target. - this( - HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .followRedirects(HttpClient.Redirect.NORMAL) - .build()); - } - - /** Wraps a caller-provided {@link java.net.http.HttpClient} (share a pool, custom proxy/TLS). */ - public DefaultHttpBackend(HttpClient client) { - this.client = client; - } - - @Override - public HttpResponse send(HttpRequest request) throws IOException, InterruptedException { - return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); - } - - @Override - public HttpResponse sendStreaming(HttpRequest request) - throws IOException, InterruptedException { - return client.send(request, HttpResponse.BodyHandlers.ofInputStream()); - } -} diff --git a/src/main/java/com/apify/client/ListOptions.java b/src/main/java/com/apify/client/ListOptions.java index 6e952c1..8d06209 100644 --- a/src/main/java/com/apify/client/ListOptions.java +++ b/src/main/java/com/apify/client/ListOptions.java @@ -33,15 +33,15 @@ public ListOptions desc(Boolean desc) { return this; } - Long offsetValue() { + public Long offsetValue() { return offset; } - Long limitValue() { + public Long limitValue() { return limit; } - void apply(QueryParams q) { + public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); } @@ -49,7 +49,7 @@ void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ - void applyFilters(QueryParams q) { + public 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 index dd990ba..b04b229 100644 --- a/src/main/java/com/apify/client/PaginatedIterator.java +++ b/src/main/java/com/apify/client/PaginatedIterator.java @@ -29,11 +29,11 @@ * 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 { +public final class PaginatedIterator implements Iterator { /** Fetches a single page starting at {@code offset}, requesting at most {@code limit} items. */ @FunctionalInterface - interface PageFetcher { + public interface PageFetcher { PaginationList fetch(long offset, Long limit); } @@ -47,7 +47,8 @@ interface PageFetcher { private long yielded; private boolean exhausted; - PaginatedIterator(Long totalLimit, Long chunkSize, Long startOffset, PageFetcher fetcher) { + public 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; diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java index 01f3a1b..1cdd4c2 100644 --- a/src/main/java/com/apify/client/PaginationList.java +++ b/src/main/java/com/apify/client/PaginationList.java @@ -52,31 +52,38 @@ public boolean isDesc() { /** The items of this page (never {@code null}; unmodifiable). */ public List getItems() { + // items is already an immutable List.copyOf() copy (see setItems); wrapping it again is a + // no-op at runtime but satisfies static analysis that the getter cannot expose a mutable + // reference to the backing list, without weakening the real defense (the copy in setItems). return Collections.unmodifiableList(items); } - // Package-private setters used by the dataset-items path, which builds pages from headers. - void setTotal(long total) { + // Setters used by the collection-listing and dataset-items paths, which build a page from a + // parsed response (or, for dataset items, from response headers) one field at a time. + public void setTotal(long total) { this.total = total; } - void setOffset(long offset) { + public void setOffset(long offset) { this.offset = offset; } - void setLimit(long limit) { + public void setLimit(long limit) { this.limit = limit; } - void setCount(long count) { + public void setCount(long count) { this.count = count; } - void setDesc(boolean desc) { + public void setDesc(boolean desc) { this.desc = desc; } - void setItems(List items) { - this.items = items; + public void setItems(List items) { + // Copy defensively: items is set once by the client from a freshly-parsed/collected list, but + // taking an immutable copy means a caller can never mutate this page's items afterwards by + // mutating the list it passed in, and getItems() needs no wrapping to stay unmodifiable. + this.items = List.copyOf(items); } } diff --git a/src/main/java/com/apify/client/QueryParams.java b/src/main/java/com/apify/client/QueryParams.java index 5abcafe..e2c5226 100644 --- a/src/main/java/com/apify/client/QueryParams.java +++ b/src/main/java/com/apify/client/QueryParams.java @@ -9,14 +9,14 @@ * An ordered collection of query parameters that omits absent ({@code null}) values and encodes * booleans as {@code 1}/{@code 0}, matching the Apify API conventions. Internal to the client. */ -final class QueryParams { +public final class QueryParams { private final List pairs = new ArrayList<>(); - QueryParams() {} + public QueryParams() {} /** Adds a string parameter if the value is non-null. */ - QueryParams addString(String key, String value) { + public QueryParams addString(String key, String value) { if (value != null) { pairs.add(new String[] {key, value}); } @@ -24,7 +24,7 @@ QueryParams addString(String key, String value) { } /** Adds an integer parameter if the value is non-null. */ - QueryParams addLong(String key, Long value) { + public QueryParams addLong(String key, Long value) { if (value != null) { pairs.add(new String[] {key, Long.toString(value)}); } @@ -32,7 +32,7 @@ QueryParams addLong(String key, Long value) { } /** Adds a floating-point parameter if the value is non-null. */ - QueryParams addDouble(String key, Double value) { + public QueryParams addDouble(String key, Double value) { if (value != null) { pairs.add(new String[] {key, Double.toString(value)}); } @@ -40,7 +40,7 @@ QueryParams addDouble(String key, Double value) { } /** Adds a boolean parameter, encoded as {@code 1}/{@code 0}, if the value is non-null. */ - QueryParams addBool(String key, Boolean value) { + public QueryParams addBool(String key, Boolean value) { if (value != null) { pairs.add(new String[] {key, value ? "1" : "0"}); } @@ -48,7 +48,7 @@ QueryParams addBool(String key, Boolean value) { } /** Adds a comma-joined list parameter if the list is non-null and non-empty. */ - QueryParams addCsv(String key, List values) { + public QueryParams addCsv(String key, List values) { if (values != null && !values.isEmpty()) { pairs.add(new String[] {key, String.join(",", values)}); } @@ -56,7 +56,7 @@ QueryParams addCsv(String key, List values) { } /** Appends an already-stringified key/value pair unconditionally. */ - QueryParams addRaw(String key, String value) { + public QueryParams addRaw(String key, String value) { pairs.add(new String[] {key, value}); return this; } @@ -73,7 +73,7 @@ QueryParams copy() { } /** Appends all pairs from {@code other} to this instance. */ - QueryParams extend(QueryParams other) { + public QueryParams extend(QueryParams other) { if (other != null) { pairs.addAll(other.pairs); } @@ -81,7 +81,7 @@ QueryParams extend(QueryParams other) { } /** Appends the parameters to {@code rawUrl} as a URL-encoded query string. */ - String applyToUrl(String rawUrl) { + public String applyToUrl(String rawUrl) { if (pairs.isEmpty()) { return rawUrl; } diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/ResourceContext.java index 1c2e8d7..95fc901 100644 --- a/src/main/java/com/apify/client/ResourceContext.java +++ b/src/main/java/com/apify/client/ResourceContext.java @@ -1,5 +1,10 @@ package com.apify.client; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; import com.fasterxml.jackson.databind.JavaType; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -14,10 +19,10 @@ * here implement the CRUD primitives once, so each resource client stays small and consistent * (DRY). Internal to the client. */ -final class ResourceContext { +public final class ResourceContext { - static final String CONTENT_TYPE_JSON = "application/json"; - static final String CONTENT_TYPE_JSON_CHARSET = "application/json; charset=utf-8"; + public static final String CONTENT_TYPE_JSON = "application/json"; + public static final String CONTENT_TYPE_JSON_CHARSET = "application/json; charset=utf-8"; /** How long to wait between polls while waiting for a run/build to finish. */ private static final Duration WAIT_POLL_INTERVAL = Duration.ofMillis(250); @@ -42,53 +47,67 @@ final class ResourceContext { private static final int NOT_FOUND = 404; - final HttpClientCore http; + public final HttpClientCore http; /** Fully-qualified base URL of the resource, e.g. {@code https://api.apify.com/v2/actors/ID}. */ final String url; - final QueryParams baseParams; + public final QueryParams baseParams; /** Origin (scheme + host) the API is reached through. */ final String apiOrigin; /** Origin used to build public, shareable URLs (defaults to {@link #apiOrigin}). */ - String publicOrigin; + final String publicOrigin; - private ResourceContext(HttpClientCore http, String url, String baseUrl) { + /** + * Immutable: every field is set once here. {@link #withPublicOrigin} and {@link #seedParams} + * return a new instance rather than mutating this one, so a {@code ResourceContext} (and the + * resource client that holds one) is safe to share across threads once built. + */ + private ResourceContext( + HttpClientCore http, + String url, + QueryParams baseParams, + String apiOrigin, + String publicOrigin) { this.http = http; this.url = url; - this.baseParams = new QueryParams(); - this.apiOrigin = originOf(baseUrl); - this.publicOrigin = this.apiOrigin; + this.baseParams = baseParams; + this.apiOrigin = apiOrigin; + this.publicOrigin = publicOrigin; + } + + private ResourceContext(HttpClientCore http, String url, String baseUrl) { + this(http, url, new QueryParams(), originOf(baseUrl), originOf(baseUrl)); } /** Creates a context for a collection endpoint: {@code {base}/{resourcePath}}. */ - static ResourceContext collection(HttpClientCore http, String baseUrl, String resourcePath) { + public static ResourceContext collection( + HttpClientCore http, String baseUrl, String resourcePath) { return new ResourceContext(http, baseUrl + "/" + resourcePath, baseUrl); } /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ - static ResourceContext single( + public static ResourceContext single( HttpClientCore http, String baseUrl, String resourcePath, String id) { return new ResourceContext(http, baseUrl + "/" + resourcePath + "/" + toSafeId(id), baseUrl); } - /** Overrides the origin used when building public URLs. */ - ResourceContext withPublicOrigin(String publicBaseUrl) { - this.publicOrigin = originOf(publicBaseUrl); - return this; + /** A copy of this context with the origin used to build public URLs overridden. */ + public ResourceContext withPublicOrigin(String publicBaseUrl) { + return new ResourceContext(http, url, baseParams, apiOrigin, originOf(publicBaseUrl)); } /** This resource's URL with an optional extra path segment appended. */ - String subUrl(String subPath) { + public String subUrl(String subPath) { return (subPath == null || subPath.isEmpty()) ? url : url + "/" + subPath; } /** * The public (shareable) form of this resource's URL, swapping the API origin for the public one. */ - String publicUrl(String subPath) { + public String publicUrl(String subPath) { String apiUrl = subUrl(subPath); if (publicOrigin.equals(apiOrigin)) { return apiUrl; @@ -100,25 +119,27 @@ String publicUrl(String subPath) { } /** Merges the inherited base params with per-call params. */ - QueryParams mergedParams(QueryParams params) { + public QueryParams mergedParams(QueryParams params) { return baseParams.copy().extend(params); } /** - * Seeds this context's inherited params from a parent context (e.g. so a last-run client's pinned - * {@code status}/{@code origin} filters carry into its nested storage/log accessors). No-op when - * {@code inherited} is null or empty, so ordinary nested clients are unaffected. + * A copy of this context with {@code inherited} merged into its base params (e.g. so a last-run + * client's pinned {@code status}/{@code origin} filters carry into its nested storage/log + * accessors). Returns {@code this} unchanged when {@code inherited} is null or empty, so ordinary + * nested clients are unaffected. */ - ResourceContext seedParams(QueryParams inherited) { - if (inherited != null && !inherited.isEmpty()) { - baseParams.extend(inherited); + public ResourceContext seedParams(QueryParams inherited) { + if (inherited == null || inherited.isEmpty()) { + return this; } - return this; + return new ResourceContext( + http, url, baseParams.copy().extend(inherited), apiOrigin, publicOrigin); } // ---- CRUD primitives ------------------------------------------------------ - Optional getResource(String subPath, QueryParams params, JavaType dataType) { + public Optional getResource(String subPath, QueryParams params, JavaType dataType) { try { // ofNullable, not of: an HTTP 200 with body {"data": null} unwraps to null, which is a valid // "no resource" answer rather than a programming error — never surface it as a raw NPE. @@ -131,21 +152,21 @@ Optional getResource(String subPath, QueryParams params, JavaType dataTyp } } - Optional getResource(String subPath, QueryParams params, Class dataClass) { + public Optional getResource(String subPath, QueryParams params, Class dataClass) { return getResource(subPath, params, Json.type(dataClass)); } - T getResourceRequired(String subPath, QueryParams params, JavaType dataType) { + public T getResourceRequired(String subPath, QueryParams params, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("GET", u, null, "", http.baseRequestTimeout()); return Json.parseData(resp.body, dataType); } - T getResourceRequired(String subPath, QueryParams params, Class dataClass) { + public T getResourceRequired(String subPath, QueryParams params, Class dataClass) { return getResourceRequired(subPath, params, Json.type(dataClass)); } - T updateResource(String subPath, Object body, Class dataClass) { + public T updateResource(String subPath, Object body, Class dataClass) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); @@ -153,7 +174,7 @@ T updateResource(String subPath, Object body, Class dataClass) { } /** Performs a DELETE; a not-found is treated as a successful no-op. */ - void deleteResource(String subPath) { + public void deleteResource(String subPath) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); try { http.call("DELETE", u, null, "", http.baseRequestTimeout()); @@ -164,7 +185,8 @@ void deleteResource(String subPath) { } } - PaginationList listResource(String subPath, QueryParams params, Class itemClass) { + public PaginationList listResource( + String subPath, QueryParams params, Class itemClass) { JavaType listType = Json.parametric(PaginationList.class, Json.type(itemClass)); return getResourceRequired(subPath, params, listType); } @@ -175,7 +197,7 @@ PaginationList listResource(String subPath, QueryParams params, Class * 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( + public Iterator iterateResource( String subPath, Long totalLimit, Long chunkSize, @@ -197,7 +219,7 @@ Iterator iterateResource( }); } - T createResource(QueryParams params, Object body, Class dataClass) { + public T createResource(QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl("")); ApiResponse resp = http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); @@ -205,7 +227,7 @@ T createResource(QueryParams params, Object body, Class dataClass) { } /** POST that gets-or-creates a named resource ({@code POST {collection}?name=...}). */ - T getOrCreateNamed(String name, Class dataClass) { + public T getOrCreateNamed(String name, Class dataClass) { return getOrCreateNamed(name, null, dataClass); } @@ -213,7 +235,7 @@ T getOrCreateNamed(String name, Class dataClass) { * POST that gets-or-creates a named resource, optionally sending a JSON request body (e.g. a * storage {@code schema}). A {@code null} body sends no body, matching the plain get-or-create. */ - T getOrCreateNamed(String name, Object body, Class dataClass) { + public T getOrCreateNamed(String name, Object body, Class dataClass) { QueryParams params = new QueryParams(); if (name != null && !name.isEmpty()) { params.addString("name", name); @@ -227,12 +249,12 @@ T getOrCreateNamed(String name, Object body, Class dataClass) { } /** POST with a raw body (optional) and content type, unwrapping the data envelope. */ - T postWithBody( + public T postWithBody( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { return postWithBody(subPath, params, body, contentType, Json.type(dataClass)); } - T postWithBody( + public T postWithBody( String subPath, QueryParams params, byte[] body, String contentType, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); @@ -244,7 +266,7 @@ T postWithBody( * {"data": ...}} envelope. Used by endpoints (e.g. actor input validation) whose response is a * plain object rather than the standard data envelope. */ - T postWithBodyNoEnvelope( + public T postWithBodyNoEnvelope( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); @@ -252,7 +274,7 @@ T postWithBodyNoEnvelope( } /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ - T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { + public T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); @@ -260,7 +282,7 @@ T deleteWithBody(String subPath, QueryParams params, Object body, Class d } /** GET returning the raw response (no data envelope). Returns {@code null} on not-found. */ - ApiResponse getRaw(String subPath, QueryParams params) { + public ApiResponse getRaw(String subPath, QueryParams params) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); try { return http.call("GET", u, null, "", http.baseRequestTimeout()); @@ -273,7 +295,7 @@ ApiResponse getRaw(String subPath, QueryParams params) { } /** HEAD request; returns whether the resource exists. */ - boolean headExists(String subPath, QueryParams params) { + public boolean headExists(String subPath, QueryParams params) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); try { http.call("HEAD", u, null, "", http.baseRequestTimeout()); @@ -287,7 +309,7 @@ boolean headExists(String subPath, QueryParams params) { } /** PUT with raw bytes and a content type (used for key-value-store record uploads). */ - void putRaw(String subPath, QueryParams params, byte[] body, String contentType) { + public void putRaw(String subPath, QueryParams params, byte[] body, String contentType) { putRaw(subPath, params, body, contentType, http.baseRequestTimeout(), false); } @@ -296,7 +318,7 @@ void putRaw(String subPath, QueryParams params, byte[] body, String contentType) * over whether transport timeouts are retried. Used by key-value-store record uploads that expose * the reference client's {@code timeoutSecs}/{@code doNotRetryTimeouts} write options. */ - void putRaw( + public void putRaw( String subPath, QueryParams params, byte[] body, @@ -325,7 +347,7 @@ private long serverWaitCapSecs() { * connection longer than the client's own per-request timeout. Returns {@code null} for a {@code * null} input (no server-side wait requested). */ - Long clampServerWait(Long waitForFinishSecs) { + public Long clampServerWait(Long waitForFinishSecs) { if (waitForFinishSecs == null) { return null; } @@ -341,7 +363,7 @@ Long clampServerWait(Long waitForFinishSecs) { * currently present: a just-started run/build can transiently return 404 (database-replica lag), * which is treated as "not yet available". */ - T waitForFinish( + public T waitForFinish( Long waitSecs, String resourceName, JavaType dataType, Predicate isTerminal) { // Clamp to MAX_WAIT_FOR_FINISH_SECS so a pathological waitSecs near Long.MAX_VALUE cannot // overflow budgetMillis into a negative value (which would degrade the wait into a single @@ -402,14 +424,14 @@ private static void sleep(Duration d) { Thread.sleep(d.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new HttpClientCore.TransportException(e); + throw new ApifyTransportException(e); } } // ---- URL / id helpers ----------------------------------------------------- /** Reports whether an exception represents a "resource not found" API error. */ - static boolean isNotFound(ApifyApiException e) { + public static boolean isNotFound(ApifyApiException e) { if (e.getStatusCode() != NOT_FOUND) { return false; } @@ -432,7 +454,7 @@ static String toSafeId(String id) { * Percent-encodes a single URL path segment, so that values interpolated into the path (record * keys, request IDs) cannot break out of the segment. */ - static String encodePathSegment(String input) { + public static String encodePathSegment(String input) { return URLEncoder.encode(input, StandardCharsets.UTF_8).replace("+", "%20"); } diff --git a/src/main/java/com/apify/client/Signatures.java b/src/main/java/com/apify/client/Signatures.java index 1c45766..409f5c2 100644 --- a/src/main/java/com/apify/client/Signatures.java +++ b/src/main/java/com/apify/client/Signatures.java @@ -11,7 +11,7 @@ * {@code @apify/utilities} implementation that the reference clients rely on. Internal to the * client. */ -final class Signatures { +public final class Signatures { /** Version tag embedded in storage-content signatures (upstream default). */ private static final String STORAGE_CONTENT_SIGNATURE_VERSION = "0"; @@ -32,7 +32,7 @@ private Signatures() {} * createHmacSignature}: HMAC-SHA256(secret, message) as lowercase hex, take the first 30 hex * characters, interpret them as a big integer, then base62-encode (alphabet {@code 0-9a-zA-Z}). */ - static String createHmacSignature(String secretKey, String message) { + public static String createHmacSignature(String secretKey, String message) { byte[] digest = hmacSha256(secretKey, message); StringBuilder hex = new StringBuilder(digest.length * 2); for (byte b : digest) { @@ -69,7 +69,7 @@ static String toBase62(BigInteger value) { * * @param expiresInSecs optional expiry in seconds ({@code null} for a non-expiring URL) */ - static String signStorageContent(String secretKey, String resourceId, Long expiresInSecs) { + public static String signStorageContent(String secretKey, String resourceId, Long expiresInSecs) { long expiresAtMillis = expiresInSecs != null ? System.currentTimeMillis() + expiresInSecs * 1000L : 0L; String version = STORAGE_CONTENT_SIGNATURE_VERSION; diff --git a/src/main/java/com/apify/client/Statuses.java b/src/main/java/com/apify/client/Statuses.java index 71f74f3..d961647 100644 --- a/src/main/java/com/apify/client/Statuses.java +++ b/src/main/java/com/apify/client/Statuses.java @@ -3,7 +3,7 @@ import java.util.Set; /** Run/build status helpers. Internal to the client. */ -final class Statuses { +public final class Statuses { /** Terminal run/build statuses: a resource in any of these is finished and will not change. */ private static final Set TERMINAL = Set.of("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"); @@ -11,7 +11,7 @@ final class Statuses { private Statuses() {} /** Reports whether the status is a terminal (finished) run/build status. */ - static boolean isTerminal(String status) { + public static boolean isTerminal(String status) { return status != null && TERMINAL.contains(status); } } diff --git a/src/main/java/com/apify/client/StorageListOptions.java b/src/main/java/com/apify/client/StorageListOptions.java index f9b9e6e..bfe892b 100644 --- a/src/main/java/com/apify/client/StorageListOptions.java +++ b/src/main/java/com/apify/client/StorageListOptions.java @@ -46,15 +46,15 @@ public StorageListOptions ownership(String ownership) { return this; } - Long offsetValue() { + public Long offsetValue() { return offset; } - Long limitValue() { + public Long limitValue() { return limit; } - void apply(QueryParams q) { + public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); } @@ -62,7 +62,7 @@ void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ - void applyFilters(QueryParams q) { + public void applyFilters(QueryParams q) { q.addBool("desc", desc).addBool("unnamed", unnamed).addString("ownership", ownership); } } diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index e4d344f..5ef75df 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.3.1"; + public static final String CLIENT_VERSION = "0.4.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-13T092445Z"; + public static final String API_SPEC_VERSION = "v2-2026-07-20T094852Z"; private Version() {} } diff --git a/src/main/java/com/apify/client/Actor.java b/src/main/java/com/apify/client/actor/Actor.java similarity index 94% rename from src/main/java/com/apify/client/Actor.java rename to src/main/java/com/apify/client/actor/Actor.java index 135fa92..23bb4db 100644 --- a/src/main/java/com/apify/client/Actor.java +++ b/src/main/java/com/apify/client/actor/Actor.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.ApifyResource; import java.time.Instant; /** An Actor on the Apify platform. */ diff --git a/src/main/java/com/apify/client/ActorBuildOptions.java b/src/main/java/com/apify/client/actor/ActorBuildOptions.java similarity index 94% rename from src/main/java/com/apify/client/ActorBuildOptions.java rename to src/main/java/com/apify/client/actor/ActorBuildOptions.java index 990ee5a..8bb1b4c 100644 --- a/src/main/java/com/apify/client/ActorBuildOptions.java +++ b/src/main/java/com/apify/client/actor/ActorBuildOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.actor; + +import com.apify.client.QueryParams; /** Configures {@link ActorClient#build(String, ActorBuildOptions)}. */ public final class ActorBuildOptions { diff --git a/src/main/java/com/apify/client/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java similarity index 88% rename from src/main/java/com/apify/client/ActorClient.java rename to src/main/java/com/apify/client/actor/ActorClient.java index ad05ddd..6d117ba 100644 --- a/src/main/java/com/apify/client/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -1,5 +1,19 @@ -package com.apify.client; - +package com.apify.client.actor; + +import com.apify.client.ApifyClient; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.build.Build; +import com.apify.client.build.BuildClient; +import com.apify.client.build.BuildCollectionClient; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; +import com.apify.client.run.ActorRun; +import com.apify.client.run.LastRunOptions; +import com.apify.client.run.RunClient; +import com.apify.client.run.RunCollectionClient; +import com.apify.client.task.ValidateInputOptions; +import com.apify.client.webhook.NestedWebhookCollectionClient; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; @@ -16,7 +30,7 @@ public final class ActorClient { private final String baseUrl; private final String id; - ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { + public ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { this.root = root; this.http = http; this.ctx = ResourceContext.single(http, baseUrl, "actors", id); diff --git a/src/main/java/com/apify/client/ActorCollectionClient.java b/src/main/java/com/apify/client/actor/ActorCollectionClient.java similarity index 78% rename from src/main/java/com/apify/client/ActorCollectionClient.java rename to src/main/java/com/apify/client/actor/ActorCollectionClient.java index 799e2f2..5979310 100644 --- a/src/main/java/com/apify/client/ActorCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorCollectionClient.java @@ -1,13 +1,18 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.ApiPaths; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** A client for the Actor collection ({@code GET/POST /v2/actors}). */ public final class ActorCollectionClient { private final ResourceContext ctx; - ActorCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "actors"); + public ActorCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.ACTORS); } /** Lists the account's Actors. */ diff --git a/src/main/java/com/apify/client/ActorEnvVar.java b/src/main/java/com/apify/client/actor/ActorEnvVar.java similarity index 92% rename from src/main/java/com/apify/client/ActorEnvVar.java rename to src/main/java/com/apify/client/actor/ActorEnvVar.java index 7a0f99f..69ce243 100644 --- a/src/main/java/com/apify/client/ActorEnvVar.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVar.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.actor; + +import com.apify.client.ApifyResource; /** An environment variable attached to an Actor version. */ public final class ActorEnvVar extends ApifyResource { diff --git a/src/main/java/com/apify/client/ActorEnvVarClient.java b/src/main/java/com/apify/client/actor/ActorEnvVarClient.java similarity index 85% rename from src/main/java/com/apify/client/ActorEnvVarClient.java rename to src/main/java/com/apify/client/actor/ActorEnvVarClient.java index 7e6760c..398e551 100644 --- a/src/main/java/com/apify/client/ActorEnvVarClient.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVarClient.java @@ -1,5 +1,8 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Optional; /** diff --git a/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java similarity index 85% rename from src/main/java/com/apify/client/ActorEnvVarCollectionClient.java rename to src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java index 4c31dea..44a33e1 100644 --- a/src/main/java/com/apify/client/ActorEnvVarCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java @@ -1,5 +1,9 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** diff --git a/src/main/java/com/apify/client/ActorListOptions.java b/src/main/java/com/apify/client/actor/ActorListOptions.java similarity index 95% rename from src/main/java/com/apify/client/ActorListOptions.java rename to src/main/java/com/apify/client/actor/ActorListOptions.java index cdf9c57..a111cc8 100644 --- a/src/main/java/com/apify/client/ActorListOptions.java +++ b/src/main/java/com/apify/client/actor/ActorListOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.actor; + +import com.apify.client.QueryParams; /** Options for {@link ActorCollectionClient#list(ActorListOptions)}. */ public final class ActorListOptions { diff --git a/src/main/java/com/apify/client/ActorStartOptions.java b/src/main/java/com/apify/client/actor/ActorStartOptions.java similarity index 94% rename from src/main/java/com/apify/client/ActorStartOptions.java rename to src/main/java/com/apify/client/actor/ActorStartOptions.java index 3d5c111..0e26f11 100644 --- a/src/main/java/com/apify/client/ActorStartOptions.java +++ b/src/main/java/com/apify/client/actor/ActorStartOptions.java @@ -1,5 +1,8 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.Json; import java.util.Base64; import java.util.List; @@ -108,7 +111,7 @@ void apply(QueryParams q) { * parameter requires. Returns {@code null} for a {@code null} list. Shared by Actor and task * start options (DRY). */ - static String encodeWebhooks(List webhooks) { + public static String encodeWebhooks(List webhooks) { if (webhooks == null) { return null; } diff --git a/src/main/java/com/apify/client/ActorVersion.java b/src/main/java/com/apify/client/actor/ActorVersion.java similarity index 85% rename from src/main/java/com/apify/client/ActorVersion.java rename to src/main/java/com/apify/client/actor/ActorVersion.java index 53f088f..e114b4e 100644 --- a/src/main/java/com/apify/client/ActorVersion.java +++ b/src/main/java/com/apify/client/actor/ActorVersion.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.actor; + +import com.apify.client.ApifyResource; /** A single version of an Actor. */ public final class ActorVersion extends ApifyResource { diff --git a/src/main/java/com/apify/client/ActorVersionClient.java b/src/main/java/com/apify/client/actor/ActorVersionClient.java similarity index 89% rename from src/main/java/com/apify/client/ActorVersionClient.java rename to src/main/java/com/apify/client/actor/ActorVersionClient.java index 9f9c86d..7a4ffca 100644 --- a/src/main/java/com/apify/client/ActorVersionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionClient.java @@ -1,5 +1,8 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Optional; /** diff --git a/src/main/java/com/apify/client/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java similarity index 90% rename from src/main/java/com/apify/client/ActorVersionCollectionClient.java rename to src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java index 5c4b936..fa59143 100644 --- a/src/main/java/com/apify/client/ActorVersionCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.actor; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; import java.util.List; diff --git a/src/main/java/com/apify/client/Build.java b/src/main/java/com/apify/client/build/Build.java similarity index 92% rename from src/main/java/com/apify/client/Build.java rename to src/main/java/com/apify/client/build/Build.java index ebbc867..e35419b 100644 --- a/src/main/java/com/apify/client/Build.java +++ b/src/main/java/com/apify/client/build/Build.java @@ -1,5 +1,7 @@ -package com.apify.client; +package com.apify.client.build; +import com.apify.client.ApifyResource; +import com.apify.client.Statuses; import java.time.Instant; /** A single build of an Actor. */ diff --git a/src/main/java/com/apify/client/BuildClient.java b/src/main/java/com/apify/client/build/BuildClient.java similarity index 82% rename from src/main/java/com/apify/client/BuildClient.java rename to src/main/java/com/apify/client/build/BuildClient.java index 77d7ce9..a1c700b 100644 --- a/src/main/java/com/apify/client/BuildClient.java +++ b/src/main/java/com/apify/client/build/BuildClient.java @@ -1,5 +1,12 @@ -package com.apify.client; +package com.apify.client.build; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; +import com.apify.client.log.LogClient; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; @@ -8,9 +15,9 @@ public final class BuildClient { private final HttpClientCore http; private final ResourceContext ctx; - BuildClient(HttpClientCore http, String baseUrl, String id) { + public BuildClient(HttpClientCore http, String baseUrl, String id) { this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, "actor-builds", id); + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.ACTOR_BUILDS, id); } /** Fetches the build object, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/BuildCollectionClient.java b/src/main/java/com/apify/client/build/BuildCollectionClient.java similarity index 79% rename from src/main/java/com/apify/client/BuildCollectionClient.java rename to src/main/java/com/apify/client/build/BuildCollectionClient.java index 91d7656..a82bb26 100644 --- a/src/main/java/com/apify/client/BuildCollectionClient.java +++ b/src/main/java/com/apify/client/build/BuildCollectionClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.build; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** @@ -9,7 +14,7 @@ public final class BuildCollectionClient { private final ResourceContext ctx; - BuildCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + public BuildCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); } diff --git a/src/main/java/com/apify/client/Dataset.java b/src/main/java/com/apify/client/dataset/Dataset.java similarity index 92% rename from src/main/java/com/apify/client/Dataset.java rename to src/main/java/com/apify/client/dataset/Dataset.java index 7efb24d..06c941d 100644 --- a/src/main/java/com/apify/client/Dataset.java +++ b/src/main/java/com/apify/client/dataset/Dataset.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.dataset; +import com.apify.client.ApifyResource; import java.time.Instant; /** A dataset stores structured results from Actor runs. */ diff --git a/src/main/java/com/apify/client/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java similarity index 87% rename from src/main/java/com/apify/client/DatasetClient.java rename to src/main/java/com/apify/client/dataset/DatasetClient.java index 00f2132..4b0bd19 100644 --- a/src/main/java/com/apify/client/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -1,5 +1,14 @@ -package com.apify.client; - +package com.apify.client.dataset; + +import com.apify.client.ApiPaths; +import com.apify.client.PaginatedIterator; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.Signatures; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; import java.util.Iterator; @@ -9,11 +18,22 @@ /** A client for a specific dataset (and run-nested variants). */ public final class DatasetClient { private final HttpClientCore http; - private ResourceContext ctx; + private final ResourceContext ctx; - DatasetClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { - this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + /** Creates a client for a dataset addressed by ID or name, reached only through the API host. */ + public DatasetClient(HttpClientCore http, String baseUrl, String id) { + this(http, ResourceContext.single(http, baseUrl, ApiPaths.DATASETS, id)); + } + + /** + * Creates a client for a dataset addressed by ID or name, whose {@code createItemsPublicUrl} + * builds URLs against {@code publicBaseUrl} instead of {@code baseUrl}. + */ + public DatasetClient(HttpClientCore http, String baseUrl, String id, String publicBaseUrl) { + this( + http, + ResourceContext.single(http, baseUrl, ApiPaths.DATASETS, id) + .withPublicOrigin(publicBaseUrl)); } private DatasetClient(HttpClientCore http, ResourceContext ctx) { @@ -22,22 +42,17 @@ private DatasetClient(HttpClientCore http, ResourceContext ctx) { } /** Creates a dataset client for a run's default dataset (nested path only, no ID). */ - static DatasetClient nested(HttpClientCore http, String base, String subPath) { + public static DatasetClient nested(HttpClientCore http, String base, String subPath) { return nested(http, base, subPath, null); } /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ - static DatasetClient nested( + public static DatasetClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new DatasetClient( http, ResourceContext.collection(http, base, subPath).seedParams(inherited)); } - DatasetClient withPublicBase(String publicBaseUrl) { - this.ctx = ctx.withPublicOrigin(publicBaseUrl); - return this; - } - /** Fetches the dataset metadata, or empty if it does not exist. */ public Optional get() { return ctx.getResource("", new QueryParams(), Dataset.class); @@ -215,7 +230,7 @@ private static long headerLong(ApiResponse resp, String name, long fallback) { } /** Reads a string field from an extra map, returning {@code null} if absent or not a string. */ - static String extractString(java.util.Map extra, String key) { + public static String extractString(java.util.Map extra, String key) { Object v = extra.get(key); return (v instanceof String) ? (String) v : null; } diff --git a/src/main/java/com/apify/client/DatasetCollectionClient.java b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java similarity index 81% rename from src/main/java/com/apify/client/DatasetCollectionClient.java rename to src/main/java/com/apify/client/dataset/DatasetCollectionClient.java index bdd4aaa..4a203dc 100644 --- a/src/main/java/com/apify/client/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java @@ -1,13 +1,19 @@ -package com.apify.client; - +package com.apify.client.dataset; + +import com.apify.client.ApiPaths; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.StorageListOptions; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** A client for the dataset collection ({@code GET/POST /v2/datasets}). */ public final class DatasetCollectionClient { private final ResourceContext ctx; - DatasetCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "datasets"); + public DatasetCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.DATASETS); } /** Lists datasets. */ diff --git a/src/main/java/com/apify/client/DatasetDownloadOptions.java b/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java similarity index 97% rename from src/main/java/com/apify/client/DatasetDownloadOptions.java rename to src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java index 529ae6f..0dcede9 100644 --- a/src/main/java/com/apify/client/DatasetDownloadOptions.java +++ b/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.dataset; + +import com.apify.client.QueryParams; /** * Adds format-specific options for {@link DatasetClient#downloadItems} on top of the shared item diff --git a/src/main/java/com/apify/client/DatasetListItemsOptions.java b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java similarity index 98% rename from src/main/java/com/apify/client/DatasetListItemsOptions.java rename to src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java index 94bc0ab..41ae2b4 100644 --- a/src/main/java/com/apify/client/DatasetListItemsOptions.java +++ b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.dataset; +import com.apify.client.QueryParams; import java.util.List; /** diff --git a/src/main/java/com/apify/client/DownloadItemsFormat.java b/src/main/java/com/apify/client/dataset/DownloadItemsFormat.java similarity index 94% rename from src/main/java/com/apify/client/DownloadItemsFormat.java rename to src/main/java/com/apify/client/dataset/DownloadItemsFormat.java index a165746..88d0fd2 100644 --- a/src/main/java/com/apify/client/DownloadItemsFormat.java +++ b/src/main/java/com/apify/client/dataset/DownloadItemsFormat.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.dataset; /** An output format for {@link DatasetClient#downloadItems}. */ public enum DownloadItemsFormat { diff --git a/src/main/java/com/apify/client/ApiResponse.java b/src/main/java/com/apify/client/http/ApiResponse.java similarity index 73% rename from src/main/java/com/apify/client/ApiResponse.java rename to src/main/java/com/apify/client/http/ApiResponse.java index a888f12..acec7bc 100644 --- a/src/main/java/com/apify/client/ApiResponse.java +++ b/src/main/java/com/apify/client/http/ApiResponse.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.http; import java.net.http.HttpHeaders; @@ -6,10 +6,10 @@ * The parsed result of a single API call: the status code, headers and the fully-buffered response * body. Internal to the client. */ -final class ApiResponse { +public final class ApiResponse { final int statusCode; - final HttpHeaders headers; - final byte[] body; + public final HttpHeaders headers; + public final byte[] body; ApiResponse(int statusCode, HttpHeaders headers, byte[] body) { this.statusCode = statusCode; diff --git a/src/main/java/com/apify/client/ApifyApiException.java b/src/main/java/com/apify/client/http/ApifyApiException.java similarity index 98% rename from src/main/java/com/apify/client/ApifyApiException.java rename to src/main/java/com/apify/client/http/ApifyApiException.java index ced3cbd..d4e5d61 100644 --- a/src/main/java/com/apify/client/ApifyApiException.java +++ b/src/main/java/com/apify/client/http/ApifyApiException.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.http; import java.util.Collections; import java.util.Map; diff --git a/src/main/java/com/apify/client/http/ApifyClientException.java b/src/main/java/com/apify/client/http/ApifyClientException.java new file mode 100644 index 0000000..0fa1d9b --- /dev/null +++ b/src/main/java/com/apify/client/http/ApifyClientException.java @@ -0,0 +1,32 @@ +package com.apify.client.http; + +/** + * Common base of every exception this client throws for a failed API call. + * + *

It is unchecked (a {@link RuntimeException}), matching the reference JavaScript client's + * {@code Error}-based, unchecked error model, so callers are not forced to wrap every call in a + * {@code try}/{@code catch}. Two concrete subtypes cover the two ways a call can fail: + * + *

    + *
  • {@link ApifyApiException} — the request reached the API, which answered with a non-success + * status code. + *
  • {@link ApifyTransportException} — the request never produced an API response at all + * (connection failure, DNS, timeout, or a local failure while preparing the request/response, + * e.g. compression). + *
+ * + * Catch this common type to handle both failure modes uniformly, or catch a specific subtype to + * handle one of them differently. + */ +public abstract class ApifyClientException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + ApifyClientException(String message) { + super(message); + } + + ApifyClientException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/apify/client/http/ApifyTransportException.java b/src/main/java/com/apify/client/http/ApifyTransportException.java new file mode 100644 index 0000000..a0d6e3e --- /dev/null +++ b/src/main/java/com/apify/client/http/ApifyTransportException.java @@ -0,0 +1,30 @@ +package com.apify.client.http; + +/** + * Thrown when a call never produced an API response: a network-level failure (connection refused, + * DNS, timeout) reported by the {@link HttpTransport}, or a local failure while preparing the + * request or consuming the response (e.g. request-body compression) that prevented the exchange + * from happening at all. + * + *

Retried the same way as a retryable HTTP status (see {@link RetryConfig}), up to the + * configured number of attempts, unless the caller opted out of retrying timeouts for that call. + * + * @see ApifyApiException for failures where the API did respond, with a non-success status. + */ +public final class ApifyTransportException extends ApifyClientException { + + private static final long serialVersionUID = 1L; + + public ApifyTransportException(Throwable cause) { + super(cause); + } + + /** + * Reports whether this transport failure was caused by the request timing out, as opposed to some + * other network failure. Backend-agnostic: it recognizes {@link HttpTimeoutException}, the {@link + * HttpTransport} contract's own timeout signal, not any specific backend's exception type. + */ + public boolean isTimeout() { + return getCause() instanceof HttpTimeoutException; + } +} diff --git a/src/main/java/com/apify/client/http/DefaultHttpTransport.java b/src/main/java/com/apify/client/http/DefaultHttpTransport.java new file mode 100644 index 0000000..7bf7eff --- /dev/null +++ b/src/main/java/com/apify/client/http/DefaultHttpTransport.java @@ -0,0 +1,72 @@ +package com.apify.client.http; + +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * The default {@link HttpTransport}, backed by the JDK's {@link java.net.http.HttpClient}. + * + *

The per-attempt timeout is applied to each {@link HttpRequest} by the orchestrating client, so + * this backend sets only a connection timeout of its own. It follows normal redirects and reuses + * connections from the underlying client's pool. + */ +public final class DefaultHttpTransport implements HttpTransport { + + /** + * Default connection-establishment timeout (distinct from the per-request timeout the client + * applies), used when no explicit value is given to {@link #DefaultHttpTransport(Duration)}. + * Generous enough for a slow TLS handshake over a high-latency link without leaving a hung DNS + * lookup or a filtered port stuck for the full per-request timeout. + */ + public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + + private final HttpClient client; + + /** Creates a backend with the {@link #DEFAULT_CONNECT_TIMEOUT}. */ + public DefaultHttpTransport() { + this(DEFAULT_CONNECT_TIMEOUT); + } + + /** Creates a backend with an explicit connection-establishment timeout. */ + public DefaultHttpTransport(Duration connectTimeout) { + // Follow redirects (NORMAL, matching the reference clients): some endpoints — e.g. a + // non-attachment key-value-store record GET — answer with a 302 to external storage, which the + // JDK's default Redirect.NEVER would otherwise surface as an error. NORMAL does not follow an + // HTTPS->HTTP downgrade and the JDK strips the Authorization header on cross-origin hops, so + // the + // bearer token is not leaked to the redirect target. + this( + HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); + } + + /** Wraps a caller-provided {@link java.net.http.HttpClient} (share a pool, custom proxy/TLS). */ + public DefaultHttpTransport(HttpClient client) { + this.client = client; + } + + @Override + public HttpResponse send(HttpRequest request) throws IOException, InterruptedException { + try { + return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } catch (java.net.http.HttpTimeoutException e) { + throw new HttpTimeoutException(e.getMessage(), e); + } + } + + @Override + public HttpResponse sendStreamingResponse(HttpRequest request) + throws IOException, InterruptedException { + try { + return client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (java.net.http.HttpTimeoutException e) { + throw new HttpTimeoutException(e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/apify/client/HttpClientCore.java b/src/main/java/com/apify/client/http/HttpClientCore.java similarity index 78% rename from src/main/java/com/apify/client/HttpClientCore.java rename to src/main/java/com/apify/client/http/HttpClientCore.java index 24d04b7..f64aba7 100644 --- a/src/main/java/com/apify/client/HttpClientCore.java +++ b/src/main/java/com/apify/client/http/HttpClientCore.java @@ -1,12 +1,12 @@ -package com.apify.client; +package com.apify.client.http; import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.encoder.Encoder; -import com.fasterxml.jackson.databind.JsonNode; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; @@ -20,7 +20,7 @@ * API token, the {@code User-Agent}, and the retry/timeout policy, and applies them to every * request. Internal to the client; safe for concurrent use. */ -final class HttpClientCore { +public final class HttpClientCore { /** Status returned when the per-resource rate limit is hit. */ private static final int RATE_LIMIT_EXCEEDED = 429; @@ -29,7 +29,7 @@ final class HttpClientCore { private static final int MIN_SERVER_ERROR = 500; /** Responses with status below this value are treated as success. */ - static final int MAX_SUCCESS_STATUS = 300; + public static final int MAX_SUCCESS_STATUS = 300; /** Exponential-backoff multiplier applied to the inter-retry delay after each attempt. */ private static final int BACKOFF_FACTOR = 2; @@ -71,32 +71,32 @@ private static boolean detectBrotli() { } /** Reports whether the brotli path is active on this platform (package-private for tests). */ - static boolean brotliAvailable() { + public static boolean brotliAvailable() { return BROTLI_AVAILABLE; } - private final HttpBackend backend; + private final HttpTransport backend; private final String token; private final String userAgent; private final RetryConfig retry; - HttpClientCore(HttpBackend backend, String token, String userAgent, RetryConfig retry) { + public HttpClientCore(HttpTransport backend, String token, String userAgent, RetryConfig retry) { this.backend = backend; this.token = token; this.userAgent = userAgent; this.retry = retry; } - String userAgent() { + public String userAgent() { return userAgent; } - HttpBackend backend() { + HttpTransport backend() { return backend; } /** The configured maximum per-attempt request timeout, in whole seconds. */ - long requestTimeoutSeconds() { + public long requestTimeoutSeconds() { return retry.timeout.getSeconds(); } @@ -106,43 +106,48 @@ long requestTimeoutSeconds() { * the first attempt too. Individual calls may pass a smaller base (e.g. key-value-store uploads), * which then grows back up toward this value on retries. */ - Duration baseRequestTimeout() { + public Duration baseRequestTimeout() { return retry.timeout; } /** Sends a request with auth, User-Agent and the retry policy applied. */ - ApiResponse call( + public ApiResponse call( String method, String url, byte[] body, String contentType, Duration baseTimeout) { - return callWithHeaders(method, url, body, contentType, null, baseTimeout, false); + return call(method, url, body, contentType, null, baseTimeout, false); } /** - * Like {@link #call} but, when {@code doNotRetryTimeouts} is {@code true}, a transport timeout is - * treated as a terminal failure rather than being retried (other transport/network errors and - * retryable statuses are still retried). + * Like {@link #call(String, String, byte[], String, Duration)} but, when {@code + * doNotRetryTimeouts} is {@code true}, a transport timeout is treated as a terminal failure + * rather than being retried (other transport/network errors and retryable statuses are still + * retried). */ - ApiResponse call( + public ApiResponse call( String method, String url, byte[] body, String contentType, Duration baseTimeout, boolean doNotRetryTimeouts) { - return callWithHeaders(method, url, body, contentType, null, baseTimeout, doNotRetryTimeouts); + return call(method, url, body, contentType, null, baseTimeout, doNotRetryTimeouts); } - /** Like {@link #call} but additionally sets the given extra headers on every attempt. */ - ApiResponse callWithHeaders( + /** + * Like {@link #call(String, String, byte[], String, Duration)} but additionally sets the given + * extra headers on every attempt. + */ + public ApiResponse call( String method, String url, byte[] body, String contentType, Map extraHeaders, Duration baseTimeout) { - return callWithHeaders(method, url, body, contentType, extraHeaders, baseTimeout, false); + return call(method, url, body, contentType, extraHeaders, baseTimeout, false); } - ApiResponse callWithHeaders( + /** The canonical overload every other {@code call} convenience overload delegates to. */ + public ApiResponse call( String method, String url, byte[] body, @@ -182,10 +187,10 @@ ApiResponse callWithHeaders( } lastError = buildApiError(resp.statusCode, resp.body, attempt, method, path); retryable = isStatusRetryable(resp.statusCode); - } catch (TransportException e) { + } catch (ApifyTransportException e) { lastError = e; // Network/timeout failures are retryable, unless the caller opted out of retrying timeouts. - retryable = !(doNotRetryTimeouts && isTimeout(e)); + retryable = !(doNotRetryTimeouts && e.isTimeout()); } if (!retryable || attempt == maxAttempts) { @@ -239,10 +244,10 @@ private ApiResponse doAttempt( HttpResponse resp = backend.send(request); return new ApiResponse(resp.statusCode(), resp.headers(), resp.body()); } catch (IOException e) { - throw new TransportException(e); + throw new ApifyTransportException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new TransportException(e); + throw new ApifyTransportException(e); } } @@ -287,9 +292,9 @@ private static boolean shouldCompress(byte[] body, Map extraHead /** * A compressed request body together with the {@code Content-Encoding} token that describes it. */ - static final class Compressed { - final byte[] body; - final String encoding; + public static final class Compressed { + public final byte[] body; + public final String encoding; Compressed(byte[] body, String encoding) { this.body = body; @@ -304,7 +309,7 @@ static final class Compressed { * pass {@link #BROTLI_AVAILABLE}; making the coding an explicit parameter keeps this a pure * function of its inputs rather than of hidden static state. Package-private. */ - static Compressed compress(byte[] data, boolean preferBrotli) { + public static Compressed compress(byte[] data, boolean preferBrotli) { return preferBrotli ? new Compressed(brotli(data), ENCODING_BROTLI) : new Compressed(gzip(data), ENCODING_GZIP); @@ -316,7 +321,7 @@ private static byte[] brotli(byte[] data) { return Encoder.compress(data); } catch (IOException e) { // Encoding an in-memory byte[] performs no real I/O, so this is unreachable in practice. - throw new TransportException(e); + throw new ApifyTransportException(e); } } @@ -332,7 +337,7 @@ private static byte[] gzip(byte[] data) { } catch (IOException e) { // Compressing an in-memory byte[] cannot perform real I/O, so this is unreachable in // practice. - throw new TransportException(e); + throw new ApifyTransportException(e); } return out.toByteArray(); } @@ -341,11 +346,6 @@ private static boolean isStatusRetryable(int status) { return status == RATE_LIMIT_EXCEEDED || status >= MIN_SERVER_ERROR; } - /** Reports whether a transport failure was caused by a request timeout. */ - private static boolean isTimeout(TransportException e) { - return e.getCause() instanceof java.net.http.HttpTimeoutException; - } - /** * Returns a delay chosen randomly from {@code [delay, 2*delay)} (exponential backoff + jitter). */ @@ -362,7 +362,7 @@ private static void sleep(Duration d) { Thread.sleep(Math.max(0, d.toMillis())); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new TransportException(e); + throw new ApifyTransportException(e); } } @@ -370,28 +370,32 @@ private static Duration minDuration(Duration a, Duration b) { return a.compareTo(b) < 0 ? a : b; } + /** + * The {@code {"error": {...}}} envelope the API sends on a non-success response, mapped directly + * by Jackson rather than navigated field-by-field as a raw {@link + * com.fasterxml.jackson.databind.JsonNode} tree. + */ + private static final class ErrorEnvelope { + public ErrorBody error; + } + + private static final class ErrorBody { + public String type; + public String message; + public Map data; + } + /** Builds an {@link ApifyApiException} from an API error response body. */ - static ApifyApiException buildApiError( + public static ApifyApiException buildApiError( int status, byte[] body, int attempt, String method, String path) { + ErrorEnvelope envelope = Json.tryParse(body, ErrorEnvelope.class); String type = null; String message = null; Map data = null; - try { - JsonNode root = Json.MAPPER.readTree(body); - JsonNode error = root.get("error"); - if (error != null && error.hasNonNull("message")) { - type = error.path("type").asText(null); - message = error.path("message").asText(null); - JsonNode dataNode = error.get("data"); - if (dataNode != null && dataNode.isObject()) { - data = - Json.MAPPER.convertValue( - dataNode, - new com.fasterxml.jackson.core.type.TypeReference>() {}); - } - } - } catch (IOException ignored) { - // Fall through to the generic message below. + if (envelope != null && envelope.error != null && envelope.error.message != null) { + type = envelope.error.type; + message = envelope.error.message; + data = envelope.error.data; } if (message == null) { message = @@ -403,35 +407,29 @@ static ApifyApiException buildApiError( } /** Returns the path+query portion of a URL, for error reporting. */ - static String extractPath(String url) { - String rest = url; - int scheme = rest.indexOf("://"); - if (scheme >= 0) { - rest = rest.substring(scheme + 3); - } - int slash = rest.indexOf('/'); - return slash >= 0 ? rest.substring(slash) : ""; - } - - /** Internal marker for transport-level (network/timeout) failures, which are retryable. */ - static final class TransportException extends RuntimeException { - private static final long serialVersionUID = 1L; - - TransportException(Throwable cause) { - super(cause); + public static String extractPath(String url) { + try { + URI uri = new URI(url); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + return uri.getRawQuery() == null ? path : path + "?" + uri.getRawQuery(); + } catch (URISyntaxException e) { + // The client itself only ever builds well-formed URLs; a malformed one here would mean a + // caller-supplied base URL is broken, which already surfaced earlier when the request was + // sent. Fall back to an empty path rather than letting error reporting itself throw. + return ""; } } /** Opens a live streaming response (single attempt, no retry). Used by log streaming. */ - HttpResponse stream(String url) { + public HttpResponse stream(String url) { HttpRequest request = buildRequest("GET", url, null, null, null, retry.timeout); try { - return backend.sendStreaming(request); + return backend.sendStreamingResponse(request); } catch (IOException e) { - throw new TransportException(e); + throw new ApifyTransportException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new TransportException(e); + throw new ApifyTransportException(e); } } } diff --git a/src/main/java/com/apify/client/http/HttpTimeoutException.java b/src/main/java/com/apify/client/http/HttpTimeoutException.java new file mode 100644 index 0000000..715d5fb --- /dev/null +++ b/src/main/java/com/apify/client/http/HttpTimeoutException.java @@ -0,0 +1,22 @@ +package com.apify.client.http; + +import java.io.IOException; + +/** + * Signals that an {@link HttpTransport} gave up on a request because it exceeded its timeout, as + * opposed to some other transport failure (connection refused, DNS, ...). + * + *

Part of the {@link HttpTransport} contract, not tied to any specific backend implementation: + * an implementation that can distinguish "timed out" from other I/O failures should throw this + * (wrapping or in place of the underlying exception) so the client can apply {@code + * doNotRetryTimeouts} correctly. A backend that cannot tell the two apart may throw a plain {@link + * IOException} instead; the call is then simply always eligible for a retry. + */ +public class HttpTimeoutException extends IOException { + + private static final long serialVersionUID = 1L; + + public HttpTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/apify/client/HttpBackend.java b/src/main/java/com/apify/client/http/HttpTransport.java similarity index 85% rename from src/main/java/com/apify/client/HttpBackend.java rename to src/main/java/com/apify/client/http/HttpTransport.java index f75202a..fa29b6a 100644 --- a/src/main/java/com/apify/client/HttpBackend.java +++ b/src/main/java/com/apify/client/http/HttpTransport.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.http; +import com.apify.client.ApifyClientBuilder; import java.io.IOException; import java.io.InputStream; import java.net.http.HttpRequest; @@ -16,10 +17,10 @@ *

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

Swap the default implementation via {@link ApifyClientBuilder#httpBackend(HttpBackend)} to + *

Swap the default implementation via {@link ApifyClientBuilder#httpTransport(HttpTransport)} to * share a connection pool, customize TLS/proxy settings, or inject a mock in tests. */ -public interface HttpBackend { +public interface HttpTransport { /** Sends a single request and buffers the whole response body as bytes. */ HttpResponse send(HttpRequest request) throws IOException, InterruptedException; @@ -28,6 +29,6 @@ public interface HttpBackend { * Sends a single request and returns the response body as a live {@link InputStream}, for * incremental consumption (used by log streaming). The caller closes the stream. */ - HttpResponse sendStreaming(HttpRequest request) + HttpResponse sendStreamingResponse(HttpRequest request) throws IOException, InterruptedException; } diff --git a/src/main/java/com/apify/client/Json.java b/src/main/java/com/apify/client/http/Json.java similarity index 74% rename from src/main/java/com/apify/client/Json.java rename to src/main/java/com/apify/client/http/Json.java index 5d6382d..0c1c4ef 100644 --- a/src/main/java/com/apify/client/Json.java +++ b/src/main/java/com/apify/client/http/Json.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.http; +import com.apify.client.DataEnvelope; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.PropertyAccessor; @@ -20,7 +21,7 @@ * forward compatibility), renders dates as ISO-8601 strings, and omits {@code null} fields when * serializing request bodies. */ -final class Json { +public final class Json { static final ObjectMapper MAPPER = new ObjectMapper() @@ -37,7 +38,7 @@ final class Json { private Json() {} /** Serializes a value to JSON bytes. */ - static byte[] toBytes(Object value) { + public static byte[] toBytes(Object value) { try { return MAPPER.writeValueAsBytes(value); } catch (IOException e) { @@ -46,7 +47,7 @@ static byte[] toBytes(Object value) { } /** Parses JSON bytes into the given type. */ - static T parse(byte[] body, JavaType type) { + public static T parse(byte[] body, JavaType type) { try { return MAPPER.readValue(body, type); } catch (IOException e) { @@ -55,7 +56,7 @@ static T parse(byte[] body, JavaType type) { } /** Parses JSON bytes into the given class. */ - static T parse(byte[] body, Class type) { + public static T parse(byte[] body, Class type) { try { return MAPPER.readValue(body, type); } catch (IOException e) { @@ -63,8 +64,24 @@ static T parse(byte[] body, Class type) { } } + /** + * Parses JSON bytes into the given class, returning {@code null} instead of throwing if the body + * is missing, empty, or not valid JSON for that shape. For call sites (like error-body parsing) + * where malformed input is an expected, non-fatal possibility rather than a programming error. + */ + static T tryParse(byte[] body, Class type) { + if (body == null || body.length == 0) { + return null; + } + try { + return MAPPER.readValue(body, type); + } catch (IOException e) { + return null; + } + } + /** Parses JSON bytes into the given {@link TypeReference} (for generic types). */ - static T parse(byte[] body, TypeReference type) { + public static T parse(byte[] body, TypeReference type) { try { return MAPPER.readValue(body, type); } catch (IOException e) { @@ -73,12 +90,12 @@ static T parse(byte[] body, TypeReference type) { } /** Constructs a {@link JavaType} for a raw class. */ - static JavaType type(Class raw) { + public static JavaType type(Class raw) { return MAPPER.getTypeFactory().constructType(raw); } /** Constructs a parametric {@link JavaType}, e.g. {@code PaginationList}. */ - static JavaType parametric(Class raw, JavaType... params) { + public static JavaType parametric(Class raw, JavaType... params) { return MAPPER.getTypeFactory().constructParametricType(raw, params); } @@ -86,14 +103,14 @@ static JavaType parametric(Class raw, JavaType... params) { * Parses a JSON response body wrapped in a {@code {"data": ...}} envelope, returning the * unwrapped {@code data} value of the given type. */ - static T parseData(byte[] body, JavaType dataType) { + public static T parseData(byte[] body, JavaType dataType) { JavaType envelopeType = parametric(DataEnvelope.class, dataType); DataEnvelope envelope = parse(body, envelopeType); return envelope.data; } /** Parses a data-envelope whose {@code data} is of the given class. */ - static T parseData(byte[] body, Class dataClass) { + public static T parseData(byte[] body, Class dataClass) { return parseData(body, type(dataClass)); } } diff --git a/src/main/java/com/apify/client/RetryConfig.java b/src/main/java/com/apify/client/http/RetryConfig.java similarity index 92% rename from src/main/java/com/apify/client/RetryConfig.java rename to src/main/java/com/apify/client/http/RetryConfig.java index 31c5ae2..bf350fb 100644 --- a/src/main/java/com/apify/client/RetryConfig.java +++ b/src/main/java/com/apify/client/http/RetryConfig.java @@ -1,9 +1,9 @@ -package com.apify.client; +package com.apify.client.http; import java.time.Duration; /** Retry/timeout policy for the orchestrating HTTP client. Internal to the client. */ -final class RetryConfig { +public final class RetryConfig { /** Maximum number of retries (the request is attempted up to {@code maxRetries + 1} times). */ final int maxRetries; @@ -20,7 +20,7 @@ final class RetryConfig { */ final Duration timeout; - RetryConfig( + public RetryConfig( int maxRetries, Duration minDelayBetweenRetries, Duration maxDelayBetweenRetries, diff --git a/src/main/java/com/apify/client/GetRecordOptions.java b/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java similarity index 90% rename from src/main/java/com/apify/client/GetRecordOptions.java rename to src/main/java/com/apify/client/keyvalue/GetRecordOptions.java index 70e40fe..ab37954 100644 --- a/src/main/java/com/apify/client/GetRecordOptions.java +++ b/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.keyvalue; + +import com.apify.client.QueryParams; /** Configures {@link KeyValueStoreClient#getRecord(String, GetRecordOptions)}. */ public final class GetRecordOptions { diff --git a/src/main/java/com/apify/client/KeyValueStore.java b/src/main/java/com/apify/client/keyvalue/KeyValueStore.java similarity index 91% rename from src/main/java/com/apify/client/KeyValueStore.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStore.java index d7ce8d4..e3202ea 100644 --- a/src/main/java/com/apify/client/KeyValueStore.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStore.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.keyvalue; +import com.apify.client.ApifyResource; import java.time.Instant; /** A key-value store holds arbitrary data records. */ diff --git a/src/main/java/com/apify/client/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java similarity index 89% rename from src/main/java/com/apify/client/KeyValueStoreClient.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index 85c22fa..8a80462 100644 --- a/src/main/java/com/apify/client/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -1,5 +1,13 @@ -package com.apify.client; +package com.apify.client.keyvalue; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.Signatures; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; @@ -7,10 +15,21 @@ /** A client for a specific key-value store (and run-nested variants). */ public final class KeyValueStoreClient { - private ResourceContext ctx; + private final ResourceContext ctx; - KeyValueStoreClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { - this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + /** Creates a client for a store addressed by ID or name, reached only through the API host. */ + public KeyValueStoreClient(HttpClientCore http, String baseUrl, String id) { + this(ResourceContext.single(http, baseUrl, ApiPaths.KEY_VALUE_STORES, id)); + } + + /** + * Creates a client for a store addressed by ID or name, whose public record/list URLs are built + * against {@code publicBaseUrl} instead of {@code baseUrl}. + */ + public KeyValueStoreClient(HttpClientCore http, String baseUrl, String id, String publicBaseUrl) { + this( + ResourceContext.single(http, baseUrl, ApiPaths.KEY_VALUE_STORES, id) + .withPublicOrigin(publicBaseUrl)); } private KeyValueStoreClient(ResourceContext ctx) { @@ -18,22 +37,17 @@ private KeyValueStoreClient(ResourceContext ctx) { } /** Creates a client for a run's default key-value store (nested path only, no ID). */ - static KeyValueStoreClient nested(HttpClientCore http, String base, String subPath) { + public static KeyValueStoreClient nested(HttpClientCore http, String base, String subPath) { return nested(http, base, subPath, null); } /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ - static KeyValueStoreClient nested( + public static KeyValueStoreClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new KeyValueStoreClient( ResourceContext.collection(http, base, subPath).seedParams(inherited)); } - KeyValueStoreClient withPublicBase(String publicBaseUrl) { - this.ctx = ctx.withPublicOrigin(publicBaseUrl); - return this; - } - /** Fetches the store metadata, or empty if it does not exist. */ public Optional get() { return ctx.getResource("", new QueryParams(), KeyValueStore.class); diff --git a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java similarity index 82% rename from src/main/java/com/apify/client/KeyValueStoreCollectionClient.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java index 7066cb0..747a6a1 100644 --- a/src/main/java/com/apify/client/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java @@ -1,13 +1,19 @@ -package com.apify.client; +package com.apify.client.keyvalue; +import com.apify.client.ApiPaths; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.StorageListOptions; +import com.apify.client.http.HttpClientCore; 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; - KeyValueStoreCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "key-value-stores"); + public KeyValueStoreCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.KEY_VALUE_STORES); } /** Lists key-value stores. */ diff --git a/src/main/java/com/apify/client/KeyValueStoreKey.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java similarity index 80% rename from src/main/java/com/apify/client/KeyValueStoreKey.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java index cf0aa7f..6b57461 100644 --- a/src/main/java/com/apify/client/KeyValueStoreKey.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.keyvalue; + +import com.apify.client.ApifyResource; /** A single key listed from a key-value store. */ public final class KeyValueStoreKey extends ApifyResource { diff --git a/src/main/java/com/apify/client/KeyValueStoreKeysPage.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java similarity index 96% rename from src/main/java/com/apify/client/KeyValueStoreKeysPage.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java index 3a59e8f..7ed9ab1 100644 --- a/src/main/java/com/apify/client/KeyValueStoreKeysPage.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.keyvalue; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.Collections; diff --git a/src/main/java/com/apify/client/KeyValueStoreRecord.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreRecord.java similarity index 96% rename from src/main/java/com/apify/client/KeyValueStoreRecord.java rename to src/main/java/com/apify/client/keyvalue/KeyValueStoreRecord.java index a477227..f291f51 100644 --- a/src/main/java/com/apify/client/KeyValueStoreRecord.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreRecord.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.keyvalue; /** * A single record retrieved from a key-value store. Its {@link #getValue() value} holds the raw diff --git a/src/main/java/com/apify/client/ListKeysOptions.java b/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java similarity index 96% rename from src/main/java/com/apify/client/ListKeysOptions.java rename to src/main/java/com/apify/client/keyvalue/ListKeysOptions.java index ca2d1d7..18d2dfe 100644 --- a/src/main/java/com/apify/client/ListKeysOptions.java +++ b/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.keyvalue; + +import com.apify.client.QueryParams; /** Configures {@link KeyValueStoreClient#listKeys(ListKeysOptions)}. */ public final class ListKeysOptions { diff --git a/src/main/java/com/apify/client/SetRecordOptions.java b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java similarity index 96% rename from src/main/java/com/apify/client/SetRecordOptions.java rename to src/main/java/com/apify/client/keyvalue/SetRecordOptions.java index 3820d43..8f3b493 100644 --- a/src/main/java/com/apify/client/SetRecordOptions.java +++ b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.keyvalue; /** * Write options for {@link KeyValueStoreClient#setRecord(String, byte[], String, diff --git a/src/main/java/com/apify/client/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java similarity index 83% rename from src/main/java/com/apify/client/LogClient.java rename to src/main/java/com/apify/client/log/LogClient.java index 2b2624f..a5448d1 100644 --- a/src/main/java/com/apify/client/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.log; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; import java.io.IOException; import java.io.InputStream; import java.net.http.HttpResponse; @@ -13,8 +18,8 @@ public final class LogClient { private final ResourceContext ctx; - LogClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { - this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + public LogClient(HttpClientCore http, String baseUrl, String id) { + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.LOGS, id); } private LogClient(ResourceContext ctx) { @@ -22,12 +27,12 @@ private LogClient(ResourceContext ctx) { } /** Creates a log client for a run's or build's nested log endpoint (e.g. {@code .../log}). */ - static LogClient nested(HttpClientCore http, String base) { + public static LogClient nested(HttpClientCore http, String base) { return nested(http, base, null); } /** As {@link #nested(HttpClientCore, String)} but inheriting parent query params. */ - static LogClient nested(HttpClientCore http, String base, QueryParams inherited) { + public static LogClient nested(HttpClientCore http, String base, QueryParams inherited) { return new LogClient(ResourceContext.collection(http, base, "log").seedParams(inherited)); } diff --git a/src/main/java/com/apify/client/LogOptions.java b/src/main/java/com/apify/client/log/LogOptions.java similarity index 89% rename from src/main/java/com/apify/client/LogOptions.java rename to src/main/java/com/apify/client/log/LogOptions.java index f8762ff..08e1572 100644 --- a/src/main/java/com/apify/client/LogOptions.java +++ b/src/main/java/com/apify/client/log/LogOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.log; + +import com.apify.client.QueryParams; /** Configures log retrieval/streaming. */ public final class LogOptions { diff --git a/src/main/java/com/apify/client/StreamedLog.java b/src/main/java/com/apify/client/log/StreamedLog.java similarity index 94% rename from src/main/java/com/apify/client/StreamedLog.java rename to src/main/java/com/apify/client/log/StreamedLog.java index 4e23fba..960274b 100644 --- a/src/main/java/com/apify/client/StreamedLog.java +++ b/src/main/java/com/apify/client/log/StreamedLog.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.log; +import com.apify.client.http.ApifyApiException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -8,19 +9,19 @@ 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * 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. + * log message to a destination {@link Consumer} (by default a per-run prefixed SLF4J {@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 @@ -38,6 +39,9 @@ */ public final class StreamedLog implements AutoCloseable { + /** SLF4J logger for this class's own diagnostics (distinct from the redirected run/build log). */ + private static final Logger LOG = LoggerFactory.getLogger(StreamedLog.class); + /** * 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 @@ -69,7 +73,7 @@ public final class StreamedLog implements AutoCloseable { private volatile InputStream activeStream; private Thread streamingThread; - StreamedLog(LogClient logClient, Consumer destination, boolean fromStart) { + public StreamedLog(LogClient logClient, Consumer destination, boolean fromStart) { this.logClient = logClient; this.destination = destination; this.relevancyTimeLimit = fromStart ? null : Instant.now(); @@ -203,8 +207,7 @@ private void streamLog() { // 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); + LOG.warn("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 @@ -292,8 +295,7 @@ private void emitIfRelevant(String message, String timestamp) { // 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); + LOG.warn("Log redirection stopped due to error", e); } } diff --git a/src/main/java/com/apify/client/StreamedLogOptions.java b/src/main/java/com/apify/client/log/StreamedLogOptions.java similarity index 82% rename from src/main/java/com/apify/client/StreamedLogOptions.java rename to src/main/java/com/apify/client/log/StreamedLogOptions.java index 8d1c8d0..b346815 100644 --- a/src/main/java/com/apify/client/StreamedLogOptions.java +++ b/src/main/java/com/apify/client/log/StreamedLogOptions.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.log; +import com.apify.client.run.RunClient; import java.util.function.Consumer; /** @@ -16,8 +17,8 @@ public final class StreamedLogOptions { /** * 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. + * When left unset, messages go to an SLF4J {@code Logger} at {@code INFO} level with an + * auto-built per-run prefix. */ public StreamedLogOptions toLog(Consumer toLog) { this.toLog = toLog; @@ -44,15 +45,15 @@ public StreamedLogOptions fromStart(boolean fromStart) { return this; } - Consumer destination() { + public Consumer destination() { return toLog; } - String prefixValue() { + public String prefixValue() { return prefix; } - boolean fromStartValue() { + public boolean fromStartValue() { return fromStart; } } diff --git a/src/main/java/com/apify/client/BatchAddRequestsOptions.java b/src/main/java/com/apify/client/requestqueue/BatchAddRequestsOptions.java similarity index 98% rename from src/main/java/com/apify/client/BatchAddRequestsOptions.java rename to src/main/java/com/apify/client/requestqueue/BatchAddRequestsOptions.java index f11c2a2..354ca33 100644 --- a/src/main/java/com/apify/client/BatchAddRequestsOptions.java +++ b/src/main/java/com/apify/client/requestqueue/BatchAddRequestsOptions.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.requestqueue; /** * Tuning options for {@link RequestQueueClient#batchAddRequests(java.util.List, boolean, diff --git a/src/main/java/com/apify/client/BatchAddResult.java b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java similarity index 97% rename from src/main/java/com/apify/client/BatchAddResult.java rename to src/main/java/com/apify/client/requestqueue/BatchAddResult.java index 2e3de5b..98eafd4 100644 --- a/src/main/java/com/apify/client/BatchAddResult.java +++ b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.requestqueue; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.ArrayList; diff --git a/src/main/java/com/apify/client/ListRequestsOptions.java b/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java similarity index 96% rename from src/main/java/com/apify/client/ListRequestsOptions.java rename to src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java index 131d9fd..eed9499 100644 --- a/src/main/java/com/apify/client/ListRequestsOptions.java +++ b/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.requestqueue; +import com.apify.client.QueryParams; import java.util.List; /** Configures {@link RequestQueueClient#listRequests(ListRequestsOptions)}. */ diff --git a/src/main/java/com/apify/client/RequestQueue.java b/src/main/java/com/apify/client/requestqueue/RequestQueue.java similarity index 92% rename from src/main/java/com/apify/client/RequestQueue.java rename to src/main/java/com/apify/client/requestqueue/RequestQueue.java index 6d5a3f5..d52c1eb 100644 --- a/src/main/java/com/apify/client/RequestQueue.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueue.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.requestqueue; +import com.apify.client.ApifyResource; import java.time.Instant; /** A request queue stores URLs to be crawled. */ diff --git a/src/main/java/com/apify/client/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java similarity index 95% rename from src/main/java/com/apify/client/RequestQueueClient.java rename to src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index c2d4f83..7b65781 100644 --- a/src/main/java/com/apify/client/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -1,5 +1,13 @@ -package com.apify.client; - +package com.apify.client.requestqueue; + +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; @@ -39,8 +47,8 @@ public final class RequestQueueClient { private final ResourceContext ctx; private final String clientKey; - RequestQueueClient(HttpClientCore http, String baseUrl, String resourcePath, String id) { - this(http, ResourceContext.single(http, baseUrl, resourcePath, id), null); + public RequestQueueClient(HttpClientCore http, String baseUrl, String id) { + this(http, ResourceContext.single(http, baseUrl, ApiPaths.REQUEST_QUEUES, id), null); } private RequestQueueClient(HttpClientCore http, ResourceContext ctx, String clientKey) { @@ -50,12 +58,12 @@ private RequestQueueClient(HttpClientCore http, ResourceContext ctx, String clie } /** Creates a client for a run's default request queue (nested path only, no ID). */ - static RequestQueueClient nested(HttpClientCore http, String base, String subPath) { + public static RequestQueueClient nested(HttpClientCore http, String base, String subPath) { return nested(http, base, subPath, null); } /** As {@link #nested(HttpClientCore, String, String)} but inheriting parent query params. */ - static RequestQueueClient nested( + public static RequestQueueClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new RequestQueueClient( http, ResourceContext.collection(http, base, subPath).seedParams(inherited), null); @@ -243,7 +251,7 @@ private static BatchAddResult awaitResult(Future future) { throw new IllegalStateException("batch add request failed", cause); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new HttpClientCore.TransportException(e); + throw new ApifyTransportException(e); } } @@ -335,7 +343,7 @@ private static void sleepBackoff(int attempt, long minDelayMillis) { Thread.sleep(delayMillis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new HttpClientCore.TransportException(e); + throw new ApifyTransportException(e); } } diff --git a/src/main/java/com/apify/client/RequestQueueCollectionClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java similarity index 77% rename from src/main/java/com/apify/client/RequestQueueCollectionClient.java rename to src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java index b0ca489..95c585a 100644 --- a/src/main/java/com/apify/client/RequestQueueCollectionClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java @@ -1,13 +1,19 @@ -package com.apify.client; +package com.apify.client.requestqueue; +import com.apify.client.ApiPaths; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.StorageListOptions; +import com.apify.client.http.HttpClientCore; 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; - RequestQueueCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "request-queues"); + public RequestQueueCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.REQUEST_QUEUES); } /** Lists request queues. */ diff --git a/src/main/java/com/apify/client/RequestQueueHead.java b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java similarity index 89% rename from src/main/java/com/apify/client/RequestQueueHead.java rename to src/main/java/com/apify/client/requestqueue/RequestQueueHead.java index 80cb015..21e1977 100644 --- a/src/main/java/com/apify/client/RequestQueueHead.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.requestqueue; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; diff --git a/src/main/java/com/apify/client/RequestQueueOperationInfo.java b/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java similarity index 95% rename from src/main/java/com/apify/client/RequestQueueOperationInfo.java rename to src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java index dc56c22..8901eab 100644 --- a/src/main/java/com/apify/client/RequestQueueOperationInfo.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.requestqueue; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/apify/client/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java similarity index 95% rename from src/main/java/com/apify/client/RequestQueueRequest.java rename to src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index 6379bf2..b944272 100644 --- a/src/main/java/com/apify/client/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.requestqueue; +import com.apify.client.ApifyResource; import com.fasterxml.jackson.databind.JsonNode; /** diff --git a/src/main/java/com/apify/client/ActorRun.java b/src/main/java/com/apify/client/run/ActorRun.java similarity index 95% rename from src/main/java/com/apify/client/ActorRun.java rename to src/main/java/com/apify/client/run/ActorRun.java index 31e635b..ad25326 100644 --- a/src/main/java/com/apify/client/ActorRun.java +++ b/src/main/java/com/apify/client/run/ActorRun.java @@ -1,5 +1,7 @@ -package com.apify.client; +package com.apify.client.run; +import com.apify.client.ApifyResource; +import com.apify.client.Statuses; import java.time.Instant; /** A single execution of an Actor. */ diff --git a/src/main/java/com/apify/client/LastRunOptions.java b/src/main/java/com/apify/client/run/LastRunOptions.java similarity index 90% rename from src/main/java/com/apify/client/LastRunOptions.java rename to src/main/java/com/apify/client/run/LastRunOptions.java index 0a582c1..2a1829e 100644 --- a/src/main/java/com/apify/client/LastRunOptions.java +++ b/src/main/java/com/apify/client/run/LastRunOptions.java @@ -1,4 +1,7 @@ -package com.apify.client; +package com.apify.client.run; + +import com.apify.client.actor.ActorClient; +import com.apify.client.task.TaskClient; /** * Filters which "last" run the {@link ActorClient#lastRun}/{@link TaskClient#lastRun} accessors diff --git a/src/main/java/com/apify/client/MetamorphOptions.java b/src/main/java/com/apify/client/run/MetamorphOptions.java similarity index 90% rename from src/main/java/com/apify/client/MetamorphOptions.java rename to src/main/java/com/apify/client/run/MetamorphOptions.java index 5e2230b..39e6b11 100644 --- a/src/main/java/com/apify/client/MetamorphOptions.java +++ b/src/main/java/com/apify/client/run/MetamorphOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.run; + +import com.apify.client.ResourceContext; /** Configures {@link RunClient#metamorph}. */ public final class MetamorphOptions { diff --git a/src/main/java/com/apify/client/RunChargeOptions.java b/src/main/java/com/apify/client/run/RunChargeOptions.java similarity index 97% rename from src/main/java/com/apify/client/RunChargeOptions.java rename to src/main/java/com/apify/client/run/RunChargeOptions.java index f22a198..7a3f64a 100644 --- a/src/main/java/com/apify/client/RunChargeOptions.java +++ b/src/main/java/com/apify/client/run/RunChargeOptions.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.run; /** Configures {@link RunClient#charge(RunChargeOptions)}. */ public final class RunChargeOptions { diff --git a/src/main/java/com/apify/client/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java similarity index 86% rename from src/main/java/com/apify/client/RunClient.java rename to src/main/java/com/apify/client/run/RunClient.java index 68b3bd2..0a8a362 100644 --- a/src/main/java/com/apify/client/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -1,11 +1,24 @@ -package com.apify.client; +package com.apify.client.run; +import com.apify.client.ApifyClient; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.actor.Actor; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; +import com.apify.client.keyvalue.KeyValueStoreClient; +import com.apify.client.log.LogClient; +import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; +import com.apify.client.requestqueue.RequestQueueClient; 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A client for a specific Actor run. @@ -22,7 +35,8 @@ public final class RunClient { private final ResourceContext ctx; private final String id; - RunClient(ApifyClient root, HttpClientCore http, String baseUrl, String resourcePath, String id) { + public RunClient( + ApifyClient root, HttpClientCore http, String baseUrl, String resourcePath, String id) { this.root = root; this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); this.id = id; @@ -32,7 +46,7 @@ public final class RunClient { * Pins the {@code status} and/or {@code origin} query parameters inherited by all calls on this * client (used by the last-run accessors). Empty values are skipped. */ - void setLastRunParams(LastRunOptions options) { + public void setLastRunParams(LastRunOptions options) { if (options.statusValue() != null && !options.statusValue().isEmpty()) { ctx.baseParams.addRaw("status", options.statusValue()); } @@ -131,7 +145,7 @@ public void charge(RunChargeOptions options) { // Route through mergedParams like the run's other actions, so a last-run-seeded context charges // the same run its read methods resolve to (its pinned status/origin filters are preserved). String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("charge")); - ctx.http.callWithHeaders( + ctx.http.call( "POST", url, Json.toBytes(body), @@ -187,12 +201,13 @@ public LogClient log() { 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. + * Returns a {@link StreamedLog} that redirects this run's live log to a default per-run SLF4J + * 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. + *

The default destination is a {@link 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 @@ -206,10 +221,10 @@ public StreamedLog getStreamedLog() { * Returns a {@link StreamedLog} that redirects this run's live log according to {@code options}. * *

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. + * that consumer; otherwise a default {@link 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 StreamedLog getStreamedLog(StreamedLogOptions options) { Consumer destination = options.destination(); @@ -249,7 +264,7 @@ private String buildDefaultLogPrefix() { /** 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); + Logger logger = LoggerFactory.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/run/RunCollectionClient.java similarity index 86% rename from src/main/java/com/apify/client/RunCollectionClient.java rename to src/main/java/com/apify/client/run/RunCollectionClient.java index 101a5a2..3b42a1f 100644 --- a/src/main/java/com/apify/client/RunCollectionClient.java +++ b/src/main/java/com/apify/client/run/RunCollectionClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.run; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** @@ -10,7 +15,7 @@ public final class RunCollectionClient { private final ResourceContext ctx; - RunCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + public RunCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); } diff --git a/src/main/java/com/apify/client/RunListOptions.java b/src/main/java/com/apify/client/run/RunListOptions.java similarity index 92% rename from src/main/java/com/apify/client/RunListOptions.java rename to src/main/java/com/apify/client/run/RunListOptions.java index 1322548..d1326dc 100644 --- a/src/main/java/com/apify/client/RunListOptions.java +++ b/src/main/java/com/apify/client/run/RunListOptions.java @@ -1,5 +1,7 @@ -package com.apify.client; +package com.apify.client.run; +import com.apify.client.ListOptions; +import com.apify.client.QueryParams; import java.util.List; /** diff --git a/src/main/java/com/apify/client/RunResurrectOptions.java b/src/main/java/com/apify/client/run/RunResurrectOptions.java similarity index 96% rename from src/main/java/com/apify/client/RunResurrectOptions.java rename to src/main/java/com/apify/client/run/RunResurrectOptions.java index a26555f..f819984 100644 --- a/src/main/java/com/apify/client/RunResurrectOptions.java +++ b/src/main/java/com/apify/client/run/RunResurrectOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.run; + +import com.apify.client.QueryParams; /** Configures {@link RunClient#resurrect(RunResurrectOptions)}. */ public final class RunResurrectOptions { diff --git a/src/main/java/com/apify/client/Schedule.java b/src/main/java/com/apify/client/schedule/Schedule.java similarity index 91% rename from src/main/java/com/apify/client/Schedule.java rename to src/main/java/com/apify/client/schedule/Schedule.java index c65f5e3..355ac02 100644 --- a/src/main/java/com/apify/client/Schedule.java +++ b/src/main/java/com/apify/client/schedule/Schedule.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.schedule; + +import com.apify.client.ApifyResource; /** A schedule automatically starts Actor or task runs at specified times. */ public final class Schedule extends ApifyResource { diff --git a/src/main/java/com/apify/client/ScheduleClient.java b/src/main/java/com/apify/client/schedule/ScheduleClient.java similarity index 72% rename from src/main/java/com/apify/client/ScheduleClient.java rename to src/main/java/com/apify/client/schedule/ScheduleClient.java index 38984f9..88ac0ff 100644 --- a/src/main/java/com/apify/client/ScheduleClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.schedule; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -7,8 +12,8 @@ public final class ScheduleClient { private final ResourceContext ctx; - ScheduleClient(HttpClientCore http, String baseUrl, String id) { - this.ctx = ResourceContext.single(http, baseUrl, "schedules", id); + public ScheduleClient(HttpClientCore http, String baseUrl, String id) { + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.SCHEDULES, id); } /** Fetches the schedule, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/ScheduleCollectionClient.java b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java similarity index 76% rename from src/main/java/com/apify/client/ScheduleCollectionClient.java rename to src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java index bcd3d61..9ea8e8c 100644 --- a/src/main/java/com/apify/client/ScheduleCollectionClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java @@ -1,13 +1,19 @@ -package com.apify.client; +package com.apify.client.schedule; +import com.apify.client.ApiPaths; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** A client for the schedule collection ({@code GET/POST /v2/schedules}). */ public final class ScheduleCollectionClient { private final ResourceContext ctx; - ScheduleCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "schedules"); + public ScheduleCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.SCHEDULES); } /** Lists the account's schedules. */ diff --git a/src/main/java/com/apify/client/ActorStoreListItem.java b/src/main/java/com/apify/client/store/ActorStoreListItem.java similarity index 88% rename from src/main/java/com/apify/client/ActorStoreListItem.java rename to src/main/java/com/apify/client/store/ActorStoreListItem.java index d0891f0..8057c01 100644 --- a/src/main/java/com/apify/client/ActorStoreListItem.java +++ b/src/main/java/com/apify/client/store/ActorStoreListItem.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.store; + +import com.apify.client.ApifyResource; /** An Actor as listed in the Apify Store. */ public final class ActorStoreListItem extends ApifyResource { diff --git a/src/main/java/com/apify/client/StoreCollectionClient.java b/src/main/java/com/apify/client/store/StoreCollectionClient.java similarity index 78% rename from src/main/java/com/apify/client/StoreCollectionClient.java rename to src/main/java/com/apify/client/store/StoreCollectionClient.java index 56cb5d5..f4ec998 100644 --- a/src/main/java/com/apify/client/StoreCollectionClient.java +++ b/src/main/java/com/apify/client/store/StoreCollectionClient.java @@ -1,13 +1,18 @@ -package com.apify.client; +package com.apify.client.store; +import com.apify.client.ApiPaths; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** A client for browsing the Apify Store ({@code GET /v2/store}). */ public final class StoreCollectionClient { private final ResourceContext ctx; - StoreCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "store"); + public StoreCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.STORE); } /** Returns a single page of Store Actors matching the options. */ diff --git a/src/main/java/com/apify/client/StoreListOptions.java b/src/main/java/com/apify/client/store/StoreListOptions.java similarity index 96% rename from src/main/java/com/apify/client/StoreListOptions.java rename to src/main/java/com/apify/client/store/StoreListOptions.java index 9187464..83379a3 100644 --- a/src/main/java/com/apify/client/StoreListOptions.java +++ b/src/main/java/com/apify/client/store/StoreListOptions.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.store; + +import com.apify.client.QueryParams; /** Options for listing/iterating the Apify Store ({@code GET /v2/store}). */ public final class StoreListOptions { @@ -83,7 +85,7 @@ Long limitValue() { return limit; } - Long offsetValue() { + public Long offsetValue() { return offset; } diff --git a/src/main/java/com/apify/client/Task.java b/src/main/java/com/apify/client/task/Task.java similarity index 93% rename from src/main/java/com/apify/client/Task.java rename to src/main/java/com/apify/client/task/Task.java index a8618ee..d3d7e4b 100644 --- a/src/main/java/com/apify/client/Task.java +++ b/src/main/java/com/apify/client/task/Task.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.task; +import com.apify.client.ApifyResource; import java.time.Instant; /** A pre-configured Actor run (an Actor task). */ diff --git a/src/main/java/com/apify/client/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java similarity index 86% rename from src/main/java/com/apify/client/TaskClient.java rename to src/main/java/com/apify/client/task/TaskClient.java index 5849be0..0526efa 100644 --- a/src/main/java/com/apify/client/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -1,5 +1,16 @@ -package com.apify.client; +package com.apify.client.task; +import com.apify.client.ApifyClient; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; +import com.apify.client.run.ActorRun; +import com.apify.client.run.LastRunOptions; +import com.apify.client.run.RunClient; +import com.apify.client.run.RunCollectionClient; +import com.apify.client.webhook.NestedWebhookCollectionClient; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; @@ -14,7 +25,7 @@ public final class TaskClient { private final HttpClientCore http; private final ResourceContext ctx; - TaskClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { + public TaskClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { this.root = root; this.http = http; this.ctx = ResourceContext.single(http, baseUrl, "actor-tasks", id); diff --git a/src/main/java/com/apify/client/TaskCollectionClient.java b/src/main/java/com/apify/client/task/TaskCollectionClient.java similarity index 76% rename from src/main/java/com/apify/client/TaskCollectionClient.java rename to src/main/java/com/apify/client/task/TaskCollectionClient.java index 392b9c5..a1c73ab 100644 --- a/src/main/java/com/apify/client/TaskCollectionClient.java +++ b/src/main/java/com/apify/client/task/TaskCollectionClient.java @@ -1,13 +1,19 @@ -package com.apify.client; +package com.apify.client.task; +import com.apify.client.ApiPaths; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; 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; - TaskCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "actor-tasks"); + public TaskCollectionClient(HttpClientCore http, String baseUrl) { + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.ACTOR_TASKS); } /** Lists the account's tasks. */ diff --git a/src/main/java/com/apify/client/TaskStartOptions.java b/src/main/java/com/apify/client/task/TaskStartOptions.java similarity index 95% rename from src/main/java/com/apify/client/TaskStartOptions.java rename to src/main/java/com/apify/client/task/TaskStartOptions.java index acc12d2..7218a79 100644 --- a/src/main/java/com/apify/client/TaskStartOptions.java +++ b/src/main/java/com/apify/client/task/TaskStartOptions.java @@ -1,5 +1,7 @@ -package com.apify.client; +package com.apify.client.task; +import com.apify.client.QueryParams; +import com.apify.client.actor.ActorStartOptions; import java.util.List; /** diff --git a/src/main/java/com/apify/client/ValidateInputOptions.java b/src/main/java/com/apify/client/task/ValidateInputOptions.java similarity index 76% rename from src/main/java/com/apify/client/ValidateInputOptions.java rename to src/main/java/com/apify/client/task/ValidateInputOptions.java index f0ead2a..50d1dca 100644 --- a/src/main/java/com/apify/client/ValidateInputOptions.java +++ b/src/main/java/com/apify/client/task/ValidateInputOptions.java @@ -1,4 +1,8 @@ -package com.apify.client; +package com.apify.client.task; + +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.actor.ActorClient; /** * Configures {@link ActorClient#validateInput(Object, ValidateInputOptions)}. All fields are @@ -20,13 +24,13 @@ public ValidateInputOptions contentType(String contentType) { return this; } - String contentTypeOrDefault() { + public String contentTypeOrDefault() { return (contentType != null && !contentType.isEmpty()) ? contentType : ResourceContext.CONTENT_TYPE_JSON; } - void apply(QueryParams q) { + public void apply(QueryParams q) { q.addString("build", build); } } diff --git a/src/main/java/com/apify/client/User.java b/src/main/java/com/apify/client/user/User.java similarity index 84% rename from src/main/java/com/apify/client/User.java rename to src/main/java/com/apify/client/user/User.java index d5eb8ca..c429283 100644 --- a/src/main/java/com/apify/client/User.java +++ b/src/main/java/com/apify/client/user/User.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.user; + +import com.apify.client.ApifyResource; /** * An Apify user account. Private account details for {@code me} are available via {@link diff --git a/src/main/java/com/apify/client/UserClient.java b/src/main/java/com/apify/client/user/UserClient.java similarity index 87% rename from src/main/java/com/apify/client/UserClient.java rename to src/main/java/com/apify/client/user/UserClient.java index 9d5b090..e3060ff 100644 --- a/src/main/java/com/apify/client/UserClient.java +++ b/src/main/java/com/apify/client/user/UserClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.user; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; +import com.apify.client.http.Json; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; @@ -17,9 +22,9 @@ public final class UserClient { private final ResourceContext ctx; private final boolean isMe; - UserClient(HttpClientCore http, String baseUrl, String id) { + public UserClient(HttpClientCore http, String baseUrl, String id) { this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, "users", id); + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.USERS, id); this.isMe = ME.equals(id); } diff --git a/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java similarity index 80% rename from src/main/java/com/apify/client/AbstractWebhookCollectionClient.java rename to src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java index 0c88298..1fb3605 100644 --- a/src/main/java/com/apify/client/AbstractWebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java @@ -1,5 +1,11 @@ -package com.apify.client; +package com.apify.client.webhook; +import com.apify.client.ApiPaths; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** @@ -12,7 +18,7 @@ abstract class AbstractWebhookCollectionClient { final ResourceContext ctx; AbstractWebhookCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, "webhooks"); + this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.WEBHOOKS); } /** Lists webhooks. */ diff --git a/src/main/java/com/apify/client/NestedWebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java similarity index 77% rename from src/main/java/com/apify/client/NestedWebhookCollectionClient.java rename to src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java index e3f4100..bcb1a94 100644 --- a/src/main/java/com/apify/client/NestedWebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.webhook; + +import com.apify.client.http.HttpClientCore; /** * A read-only client for the webhooks nested under an Actor ({@code GET /v2/actors/{id}/webhooks}) @@ -8,7 +10,7 @@ * exposed here. */ public final class NestedWebhookCollectionClient extends AbstractWebhookCollectionClient { - NestedWebhookCollectionClient(HttpClientCore http, String baseUrl) { + public NestedWebhookCollectionClient(HttpClientCore http, String baseUrl) { super(http, baseUrl); } } diff --git a/src/main/java/com/apify/client/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java similarity index 90% rename from src/main/java/com/apify/client/Webhook.java rename to src/main/java/com/apify/client/webhook/Webhook.java index 0222c3e..fc61dd7 100644 --- a/src/main/java/com/apify/client/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.webhook; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; diff --git a/src/main/java/com/apify/client/WebhookClient.java b/src/main/java/com/apify/client/webhook/WebhookClient.java similarity index 76% rename from src/main/java/com/apify/client/WebhookClient.java rename to src/main/java/com/apify/client/webhook/WebhookClient.java index 1d5a37c..3dedef9 100644 --- a/src/main/java/com/apify/client/WebhookClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookClient.java @@ -1,5 +1,9 @@ -package com.apify.client; +package com.apify.client.webhook; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Optional; /** A client for a specific webhook ({@code /v2/webhooks/{webhookId}}). */ @@ -7,9 +11,9 @@ public final class WebhookClient { private final HttpClientCore http; private final ResourceContext ctx; - WebhookClient(HttpClientCore http, String baseUrl, String id) { + public WebhookClient(HttpClientCore http, String baseUrl, String id) { this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, "webhooks", id); + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.WEBHOOKS, id); } /** Fetches the webhook, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/WebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java similarity index 75% rename from src/main/java/com/apify/client/WebhookCollectionClient.java rename to src/main/java/com/apify/client/webhook/WebhookCollectionClient.java index c5669e9..55ab0b1 100644 --- a/src/main/java/com/apify/client/WebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java @@ -1,4 +1,7 @@ -package com.apify.client; +package com.apify.client.webhook; + +import com.apify.client.QueryParams; +import com.apify.client.http.HttpClientCore; /** * A client for the account-wide webhook collection ({@code GET/POST /v2/webhooks}), supporting both @@ -6,7 +9,7 @@ * NestedWebhookCollectionClient} instead. */ public final class WebhookCollectionClient extends AbstractWebhookCollectionClient { - WebhookCollectionClient(HttpClientCore http, String baseUrl) { + public WebhookCollectionClient(HttpClientCore http, String baseUrl) { super(http, baseUrl); } diff --git a/src/main/java/com/apify/client/WebhookDispatch.java b/src/main/java/com/apify/client/webhook/WebhookDispatch.java similarity index 82% rename from src/main/java/com/apify/client/WebhookDispatch.java rename to src/main/java/com/apify/client/webhook/WebhookDispatch.java index 76ed263..e8bdf3b 100644 --- a/src/main/java/com/apify/client/WebhookDispatch.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatch.java @@ -1,4 +1,6 @@ -package com.apify.client; +package com.apify.client.webhook; + +import com.apify.client.ApifyResource; /** A single invocation of a webhook. */ public final class WebhookDispatch extends ApifyResource { diff --git a/src/main/java/com/apify/client/WebhookDispatchClient.java b/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java similarity index 52% rename from src/main/java/com/apify/client/WebhookDispatchClient.java rename to src/main/java/com/apify/client/webhook/WebhookDispatchClient.java index 7234a21..1d9808a 100644 --- a/src/main/java/com/apify/client/WebhookDispatchClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java @@ -1,13 +1,17 @@ -package com.apify.client; +package com.apify.client.webhook; +import com.apify.client.ApiPaths; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Optional; /** A client for a specific webhook dispatch ({@code /v2/webhook-dispatches/{dispatchId}}). */ public final class WebhookDispatchClient { private final ResourceContext ctx; - WebhookDispatchClient(HttpClientCore http, String baseUrl, String id) { - this.ctx = ResourceContext.single(http, baseUrl, "webhook-dispatches", id); + public WebhookDispatchClient(HttpClientCore http, String baseUrl, String id) { + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.WEBHOOK_DISPATCHES, id); } /** Fetches the dispatch, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java similarity index 80% rename from src/main/java/com/apify/client/WebhookDispatchCollectionClient.java rename to src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java index 2aa3ede..b4c4a68 100644 --- a/src/main/java/com/apify/client/WebhookDispatchCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java @@ -1,5 +1,10 @@ -package com.apify.client; +package com.apify.client.webhook; +import com.apify.client.ListOptions; +import com.apify.client.PaginationList; +import com.apify.client.QueryParams; +import com.apify.client.ResourceContext; +import com.apify.client.http.HttpClientCore; import java.util.Iterator; /** @@ -9,7 +14,7 @@ public final class WebhookDispatchCollectionClient { private final ResourceContext ctx; - WebhookDispatchCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { + public WebhookDispatchCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); } diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index a8e5441..132f9db 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -5,6 +5,28 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.apify.client.actor.Actor; +import com.apify.client.actor.ActorListOptions; +import com.apify.client.actor.ActorVersion; +import com.apify.client.dataset.DatasetDownloadOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.dataset.DownloadItemsFormat; +import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.keyvalue.KeyValueStore; +import com.apify.client.keyvalue.KeyValueStoreRecord; +import com.apify.client.keyvalue.ListKeysOptions; +import com.apify.client.keyvalue.SetRecordOptions; +import com.apify.client.requestqueue.BatchAddRequestsOptions; +import com.apify.client.requestqueue.BatchAddResult; +import com.apify.client.requestqueue.RequestQueueRequest; +import com.apify.client.run.ActorRun; +import com.apify.client.run.LastRunOptions; +import com.apify.client.run.RunChargeOptions; +import com.apify.client.store.ActorStoreListItem; +import com.apify.client.store.StoreListOptions; +import com.apify.client.webhook.NestedWebhookCollectionClient; +import com.apify.client.webhook.Webhook; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -18,14 +40,14 @@ */ class ClientBehaviourRegressionTest { - private static ApifyClient client(MockBackend backend) { + private static ApifyClient client(MockTransport backend) { return client(backend, 0); } - private static ApifyClient client(MockBackend backend, int maxRetries) { + private static ApifyClient client(MockTransport backend, int maxRetries) { return ApifyClient.builder() .token("test-token") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(maxRetries) .minDelayBetweenRetries(Duration.ofMillis(1)) .build(); @@ -33,7 +55,7 @@ private static ApifyClient client(MockBackend backend, int maxRetries) { @Test void chargeSendsIdempotencyKey() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); client(backend).run("run123").charge(new RunChargeOptions("my-event")); String key = backend.lastHeaders.firstValue("idempotency-key").orElse(""); assertTrue( @@ -45,7 +67,7 @@ void chargeSendsIdempotencyKey() { void lastRunForwardsStatusFilterToNestedStorages() { // A status/origin-filtered last-run client must forward those filters to its nested // dataset/key-value-store/request-queue/log accessors, so they resolve the same run. - MockBackend ds = MockBackend.ofConstant(200, "[]"); + MockTransport ds = MockTransport.ofConstant(200, "[]"); client(ds) .actor("me/x") .lastRun("SUCCEEDED") @@ -54,7 +76,7 @@ void lastRunForwardsStatusFilterToNestedStorages() { assertTrue(ds.lastUrl.contains("runs/last/dataset/items"), ds.lastUrl); assertTrue(ds.lastUrl.contains("status=SUCCEEDED"), ds.lastUrl); - MockBackend log = MockBackend.ofConstant(200, "log-text"); + MockTransport log = MockTransport.ofConstant(200, "log-text"); client(log) .actor("me/x") .lastRun(new LastRunOptions().status("SUCCEEDED").origin("API")) @@ -68,7 +90,7 @@ void lastRunForwardsStatusFilterToNestedStorages() { @Test void plainRunNestedStoragesCarryNoInheritedFilter() { // A non-last-run client has no pinned params, so nested accessors stay filter-free. - MockBackend ds = MockBackend.ofConstant(200, "[]"); + MockTransport ds = MockTransport.ofConstant(200, "[]"); client(ds).run("run123").dataset().listItems(new DatasetListItemsOptions()); assertTrue(ds.lastUrl.contains("actor-runs/run123/dataset/items"), ds.lastUrl); assertFalse(ds.lastUrl.contains("status="), ds.lastUrl); @@ -76,14 +98,14 @@ void plainRunNestedStoragesCarryNoInheritedFilter() { @Test void chargeHonorsExplicitIdempotencyKey() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); client(backend).run("run123").charge(new RunChargeOptions("e").idempotencyKey("fixed-key")); assertEquals("fixed-key", backend.lastHeaders.firstValue("idempotency-key").orElse("")); } @Test void chargeRejectsMissingEventName() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{}}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); ApifyClient client = client(backend); assertThrows( IllegalArgumentException.class, () -> client.run("r").charge(new RunChargeOptions(null))); @@ -94,14 +116,14 @@ void chargeRejectsMissingEventName() { @Test void getRecordDefaultsAttachment() { - MockBackend backend = MockBackend.ofConstant(200, "raw-bytes"); + MockTransport backend = MockTransport.ofConstant(200, "raw-bytes"); client(backend).keyValueStore("store1").getRecord("OUTPUT"); assertTrue(backend.lastUrl.contains("attachment=1"), backend.lastUrl); } @Test void keyValueStoreRecordDefensivelyCopiesBytes() { - MockBackend backend = MockBackend.ofConstant(200, "raw-bytes"); + MockTransport backend = MockTransport.ofConstant(200, "raw-bytes"); KeyValueStoreRecord record = client(backend).keyValueStore("s").getRecord("OUTPUT").orElseThrow(); byte[] first = record.getValue(); @@ -113,9 +135,9 @@ void keyValueStoreRecordDefensivelyCopiesBytes() { @Test void setRecordDoesNotRetryTimeoutsWhenOptedOut() { - MockBackend timeouts = new MockBackend(List.of(MockBackend.timeoutError())); + MockTransport timeouts = new MockTransport(List.of(MockTransport.timeoutError())); assertThrows( - HttpClientCore.TransportException.class, + ApifyTransportException.class, () -> client(timeouts, 3) .keyValueStore("s") @@ -129,16 +151,16 @@ void setRecordDoesNotRetryTimeoutsWhenOptedOut() { @Test void setRecordRetriesTimeoutsByDefault() { - MockBackend timeouts = new MockBackend(List.of(MockBackend.timeoutError())); + MockTransport timeouts = new MockTransport(List.of(MockTransport.timeoutError())); assertThrows( - HttpClientCore.TransportException.class, + ApifyTransportException.class, () -> client(timeouts, 3).keyValueStore("s").setRecord("k", new byte[] {1}, "text/plain")); assertEquals(4, timeouts.calls, "timeouts should be retried (maxRetries + 1 attempts)"); } @Test void createKeysPublicUrlForwardsFilterOptions() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{\"id\":\"store1\"}}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"store1\"}}"); String url = client(backend) .keyValueStore("store1") @@ -150,7 +172,7 @@ void createKeysPublicUrlForwardsFilterOptions() { @Test void downloadItemsForwardsItemSelectionParams() { - MockBackend backend = MockBackend.ofConstant(200, "col1,col2\n"); + MockTransport backend = MockTransport.ofConstant(200, "col1,col2\n"); client(backend) .dataset("d1") .downloadItems( @@ -167,8 +189,8 @@ void downloadItemsForwardsItemSelectionParams() { @Test void batchAddRequestsChunks() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"processedRequests\":[],\"unprocessedRequests\":[]}}"); List requests = new ArrayList<>(); for (int i = 0; i < 60; i++) { @@ -185,14 +207,14 @@ void batchAddRequestsChunks() { @Test void batchAddRequestsRetriesUnprocessed() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"processedRequests\":[{\"uniqueKey\":\"k0\",\"requestId\":\"r0\"}]," + "\"unprocessedRequests\":[{\"uniqueKey\":\"k1\",\"url\":\"https://example.com\"}]}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"processedRequests\":[{\"uniqueKey\":\"k1\",\"requestId\":\"r1\"}]," + "\"unprocessedRequests\":[]}}"))); @@ -214,8 +236,8 @@ void batchAddRequestsRetriesUnprocessed() { @Test void getWithWaitForwardsWaitForFinish() { - MockBackend backend = - MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); Optional run = client(backend).run("r1").getWithWait(30L); assertTrue(run.isPresent(), "run should be present"); assertEquals("r1", run.get().getId()); @@ -227,12 +249,12 @@ void getWithWaitClampsServerWaitToConfiguredTimeout() { // With a 10s per-request timeout, a caller asking for waitForFinish=60 must be clamped below // the // timeout (10 - 5s margin = 5) so the synchronous get can't abort itself on the socket timeout. - MockBackend backend = - MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); ApifyClient client = ApifyClient.builder() .token("t") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(0) .timeout(Duration.ofSeconds(10)) .build(); @@ -243,12 +265,12 @@ void getWithWaitClampsServerWaitToConfiguredTimeout() { @Test void waitForFinishClampsServerWaitToConfiguredTimeout() { - MockBackend backend = - MockBackend.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); ApifyClient client = ApifyClient.builder() .token("t") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(0) .minDelayBetweenRetries(Duration.ofMillis(1)) .timeout(Duration.ofSeconds(20)) @@ -260,15 +282,15 @@ void waitForFinishClampsServerWaitToConfiguredTimeout() { @Test void getResourceReturnsEmptyOnNullData() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":null}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":null}"); Optional store = client(backend).keyValueStore("s").get(); assertTrue(store.isEmpty(), "a 200 with null data must map to an empty Optional, not throw"); } @Test void storeIterateDoesNotMutateCallerOptionsAndHonorsOffset() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.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, null).hasNext(); @@ -279,19 +301,19 @@ void storeIterateDoesNotMutateCallerOptionsAndHonorsOffset() { @Test void storeIterateWalksMultiplePages() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{},{}],\"total\":3,\"offset\":0,\"limit\":2,\"count\":2}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"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( + MockTransport.ok( 200, "{\"data\":{\"items\":[],\"total\":3,\"offset\":3,\"limit\":2,\"count\":0}}"))); // No total cap; page size 2 drives paging until the empty page. @@ -309,13 +331,13 @@ void storeIterateWalksMultiplePages() { @Test void collectionIterateSingleArgDelegatesToServerDefaultPageSize() { // The arg-less-chunkSize convenience overload must page correctly (delegates with null chunk). - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{},{}],\"total\":2,\"offset\":0,\"limit\":2,\"count\":2}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[],\"total\":2,\"offset\":2,\"limit\":2,\"count\":0}}"))); java.util.Iterator it = client(backend).actors().iterate(new ActorListOptions()); @@ -333,8 +355,8 @@ void collectionIterateSingleArgDelegatesToServerDefaultPageSize() { 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( + MockTransport backend = + MockTransport.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); @@ -346,8 +368,8 @@ void iterateSnapshotsOptionsSoLaterMutationsDoNotLeak() { @Test void runCollectionListToleratesNullOptionsAndFilter() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"items\":[],\"total\":0,\"offset\":0,\"limit\":0,\"count\":0}}"); PaginationList runs = client(backend).runs().list(null, null); assertEquals(0, runs.getItems().size()); @@ -355,8 +377,8 @@ void runCollectionListToleratesNullOptionsAndFilter() { @Test void nestedWebhookCollectionListsWithoutCreate() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"items\":[],\"total\":0,\"offset\":0,\"limit\":0,\"count\":0}}"); // Compile-time guarantee: the nested collection type has no create(); it only lists. NestedWebhookCollectionClient nested = client(backend).task("t1").webhooks(); @@ -365,7 +387,7 @@ void nestedWebhookCollectionListsWithoutCreate() { @Test void accountWebhookCollectionCanCreate() { - MockBackend backend = MockBackend.ofConstant(200, "{\"data\":{\"id\":\"wh1\"}}"); + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"wh1\"}}"); Webhook created = client(backend).webhooks().create(java.util.Map.of("eventTypes", List.of())); assertEquals("wh1", created.getId()); assertTrue(backend.lastUrl.endsWith("/webhooks"), backend.lastUrl); @@ -373,7 +395,7 @@ void accountWebhookCollectionCanCreate() { @Test void updateLimitsSendsPutToMeLimits() { - MockBackend backend = MockBackend.ofConstant(200, "{}"); + MockTransport backend = MockTransport.ofConstant(200, "{}"); client(backend).me().updateLimits(java.util.Map.of("maxMonthlyUsageUsd", 100)); assertTrue(backend.lastUrl.endsWith("/users/me/limits"), backend.lastUrl); assertTrue(backend.lastBody.contains("\"maxMonthlyUsageUsd\":100"), backend.lastBody); @@ -384,8 +406,8 @@ void updateLimitsSendsPutToMeLimits() { void batchAddRequestsThrowsOnNonRetryableClientError() { // A hard 4xx (e.g. bad token / insufficient permissions) must surface, not be masked as // "unprocessed" — otherwise a caller cannot tell it apart from ordinary rate-limiting. - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 403, "{\"error\":{\"type\":\"insufficient-permissions\",\"message\":\"no\"}}"); ApifyApiException ex = assertThrows( @@ -402,8 +424,8 @@ void batchAddRequestsThrowsOnNonRetryableClientError() { @Test void batchAddRequestsRunsChunksInParallel() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"processedRequests\":[],\"unprocessedRequests\":[]}}"); List requests = new ArrayList<>(); for (int i = 0; i < 60; i++) { @@ -422,12 +444,12 @@ void batchAddRequestsRunsChunksInParallel() { void waitForFinishReturnsWhenResourceAppearsAfter404() { // A just-started run can transiently 404 (replica lag); the wait must keep polling until it // appears and reaches a terminal state rather than giving up on the first 404. - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"), - MockBackend.ok(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"))); + MockTransport.ok(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"))); ActorRun run = client(backend).run("r1").waitForFinish(5L); assertEquals("r1", run.getId()); assertEquals("SUCCEEDED", run.getStatus()); @@ -438,15 +460,15 @@ void waitForFinishReturnsWhenResourceAppearsAfter404() { void waitForFinishThrowsWhenResourceNeverAppears() { // If the resource is never fetchable within the budget, the wait must fail loudly rather than // return a phantom result. - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"); assertThrows(IllegalStateException.class, () -> client(backend).run("r1").waitForFinish(0L)); } @Test void logStreamThrowsOnNonSuccessStatus() { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream( 403, "{\"error\":{\"type\":\"insufficient-permissions\",\"message\":\"no\"}}"); ApifyApiException ex = @@ -461,8 +483,8 @@ void versionsIterateSingleFetchTerminatesAndDoesNotDuplicate() { // 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( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"total\":3,\"items\":[" + "{\"versionNumber\":\"0.1\"}," @@ -484,8 +506,8 @@ void versionsIterateSingleFetchTerminatesAndDoesNotDuplicate() { @Test void versionsIterateHonorsTotalLimitCap() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"total\":3,\"items\":[" + "{\"versionNumber\":\"0.1\"}," diff --git a/src/test/java/com/apify/client/CompressionTest.java b/src/test/java/com/apify/client/CompressionTest.java index f7c6c27..f26c9a8 100644 --- a/src/test/java/com/apify/client/CompressionTest.java +++ b/src/test/java/com/apify/client/CompressionTest.java @@ -9,6 +9,7 @@ import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.decoder.Decoder; import com.aayushatharva.brotli4j.decoder.DirectDecompress; +import com.apify.client.http.HttpClientCore; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; @@ -31,8 +32,8 @@ class CompressionTest { private static final String RECORD_KEY = "record"; private static final String CONTENT_TYPE = "application/octet-stream"; - private static ApifyClient client(MockBackend backend) { - return ApifyClient.builder().token("t").httpBackend(backend).maxRetries(0).build(); + private static ApifyClient client(MockTransport backend) { + return ApifyClient.builder().token("t").httpTransport(backend).maxRetries(0).build(); } private static byte[] gunzip(byte[] data) throws IOException { @@ -113,7 +114,7 @@ void brotliNativeCodecLoadsWhereBundled() { @Test void largeBodyIsCompressedWithPreferredEncoding() throws IOException { - MockBackend backend = MockBackend.ofConstant(201, ""); + MockTransport backend = MockTransport.ofConstant(201, ""); byte[] payload = payload(4096, (byte) 'a'); client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, CONTENT_TYPE); @@ -131,7 +132,7 @@ void largeBodyIsCompressedWithPreferredEncoding() throws IOException { @Test void smallBodyIsNotCompressed() { - MockBackend backend = MockBackend.ofConstant(201, ""); + MockTransport backend = MockTransport.ofConstant(201, ""); byte[] payload = payload(16, (byte) 'b'); client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, CONTENT_TYPE); @@ -144,7 +145,7 @@ void smallBodyIsNotCompressed() { @Test void bodyExactlyAtThresholdIsCompressed() throws IOException { - MockBackend backend = MockBackend.ofConstant(201, ""); + MockTransport backend = MockTransport.ofConstant(201, ""); byte[] payload = payload(1024, (byte) 'c'); client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, CONTENT_TYPE); diff --git a/src/test/java/com/apify/client/DatasetItemsIteratorTest.java b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java index d51f1b7..7977d50 100644 --- a/src/test/java/com/apify/client/DatasetItemsIteratorTest.java +++ b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetListItemsOptions; import com.fasterxml.jackson.databind.JsonNode; import java.time.Duration; import java.util.ArrayList; @@ -13,15 +15,15 @@ /** * 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 + * envelope) and terminates on an empty page. Driven by a {@link MockTransport}, no {@code * APIFY_TOKEN}. */ class DatasetItemsIteratorTest { - private static ApifyClient client(MockBackend backend) { + private static ApifyClient client(MockTransport backend) { return ApifyClient.builder() .token("test-token") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(0) .minDelayBetweenRetries(Duration.ofMillis(1)) .build(); @@ -29,12 +31,12 @@ private static ApifyClient client(MockBackend backend) { @Test void pagesBareArrayBodyAndStopsOnEmptyPage() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok(200, "[{\"n\":1},{\"n\":2}]"), - MockBackend.ok(200, "[{\"n\":3}]"), - MockBackend.ok(200, "[]"))); + MockTransport.ok(200, "[{\"n\":1},{\"n\":2}]"), + MockTransport.ok(200, "[{\"n\":3}]"), + MockTransport.ok(200, "[]"))); List seen = new ArrayList<>(); Iterator it = client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), 2L); @@ -52,9 +54,9 @@ 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, "[]"))); + MockTransport backend = + new MockTransport( + List.of(MockTransport.ok(200, "[{\"n\":1},{\"n\":2}]"), MockTransport.ok(200, "[]"))); List seen = new ArrayList<>(); Iterator it = client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), null, Row.class); @@ -73,7 +75,7 @@ static final class Row { @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}]"); + MockTransport backend = MockTransport.ofConstant(200, "[{\"n\":1},{\"n\":2},{\"n\":3}]"); List seen = new ArrayList<>(); Iterator it = client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions().limit(2L), 5L); @@ -89,8 +91,9 @@ public record Item(int n) {} @Test void decodesIntoRequestedType() { - MockBackend backend = - new MockBackend(List.of(MockBackend.ok(200, "[{\"n\":7}]"), MockBackend.ok(200, "[]"))); + MockTransport backend = + new MockTransport( + List.of(MockTransport.ok(200, "[{\"n\":7}]"), MockTransport.ok(200, "[]"))); List seen = new ArrayList<>(); Iterator it = client(backend).dataset("d1").iterateItems(new DatasetListItemsOptions(), 2L, Item.class); diff --git a/src/test/java/com/apify/client/DocSnippetsTest.java b/src/test/java/com/apify/client/DocSnippetsTest.java index 2db55d5..7e4e525 100644 --- a/src/test/java/com/apify/client/DocSnippetsTest.java +++ b/src/test/java/com/apify/client/DocSnippetsTest.java @@ -90,6 +90,19 @@ private static String wrap(String className, String snippet) { return snippet; } return "import com.apify.client.*;\n" + + "import com.apify.client.actor.*;\n" + + "import com.apify.client.build.*;\n" + + "import com.apify.client.dataset.*;\n" + + "import com.apify.client.http.*;\n" + + "import com.apify.client.keyvalue.*;\n" + + "import com.apify.client.log.*;\n" + + "import com.apify.client.requestqueue.*;\n" + + "import com.apify.client.run.*;\n" + + "import com.apify.client.schedule.*;\n" + + "import com.apify.client.store.*;\n" + + "import com.apify.client.task.*;\n" + + "import com.apify.client.user.*;\n" + + "import com.apify.client.webhook.*;\n" + "import com.fasterxml.jackson.databind.*;\n" + "import java.util.*;\n" + "import java.time.*;\n" diff --git a/src/test/java/com/apify/client/KeyIteratorTest.java b/src/test/java/com/apify/client/KeyIteratorTest.java index c92b7bb..570c5d2 100644 --- a/src/test/java/com/apify/client/KeyIteratorTest.java +++ b/src/test/java/com/apify/client/KeyIteratorTest.java @@ -4,6 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.apify.client.keyvalue.KeyValueStoreClient; +import com.apify.client.keyvalue.KeyValueStoreKey; +import com.apify.client.keyvalue.ListKeysOptions; import java.time.Duration; import java.util.ArrayList; import java.util.Iterator; @@ -12,15 +15,15 @@ /** * 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. + * KeyValueStoreClient#iterateKeys}), driven by a {@link MockTransport}: cursor chaining across + * pages, the {@code limit} total-cap, and empty-page / empty-cursor termination. */ class KeyIteratorTest { - private static ApifyClient client(MockBackend backend) { + private static ApifyClient client(MockTransport backend) { return ApifyClient.builder() .token("test-token") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(0) .minDelayBetweenRetries(Duration.ofMillis(1)) .build(); @@ -36,14 +39,14 @@ private static List keys(Iterator it) { @Test void chainsAcrossPagesUsingCursor() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1}]," + "\"nextExclusiveStartKey\":null,\"isTruncated\":false}}"))); @@ -55,8 +58,8 @@ void chainsAcrossPagesUsingCursor() { @Test void limitCapsTotalKeysYielded() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}," + "{\"key\":\"c\",\"size\":1}],\"nextExclusiveStartKey\":\"c\",\"isTruncated\":true}}"); @@ -69,18 +72,18 @@ void limitCapsTotalKeysYielded() { @Test void chunkSizeSetsPerRequestPageSize() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1},{\"key\":\"d\",\"size\":1}]," + "\"nextExclusiveStartKey\":\"d\",\"isTruncated\":true}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"e\",\"size\":1}]," + "\"nextExclusiveStartKey\":null,\"isTruncated\":false}}"))); @@ -94,14 +97,14 @@ void chunkSizeSetsPerRequestPageSize() { @Test void chunkSizeAndLimitCombine() { // limit=3 (total cap) with chunkSize=2: first page requests 2, second requests min(1,2)=1. - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"a\",\"size\":1},{\"key\":\"b\",\"size\":1}]," + "\"nextExclusiveStartKey\":\"b\",\"isTruncated\":true}}"), - MockBackend.ok( + MockTransport.ok( 200, "{\"data\":{\"items\":[{\"key\":\"c\",\"size\":1},{\"key\":\"d\",\"size\":1}]," + "\"nextExclusiveStartKey\":\"d\",\"isTruncated\":true}}"))); @@ -115,8 +118,8 @@ void chunkSizeAndLimitCombine() { @Test void stopsOnEmptyPage() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 200, "{\"data\":{\"items\":[],\"nextExclusiveStartKey\":\"z\",\"isTruncated\":true}}"); Iterator it = client(backend).keyValueStore("s").iterateKeys(new ListKeysOptions()); diff --git a/src/test/java/com/apify/client/MockBackend.java b/src/test/java/com/apify/client/MockTransport.java similarity index 83% rename from src/test/java/com/apify/client/MockBackend.java rename to src/test/java/com/apify/client/MockTransport.java index 1df7af6..2bb4793 100644 --- a/src/test/java/com/apify/client/MockBackend.java +++ b/src/test/java/com/apify/client/MockTransport.java @@ -1,5 +1,7 @@ package com.apify.client; +import com.apify.client.http.HttpTimeoutException; +import com.apify.client.http.HttpTransport; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -19,11 +21,11 @@ import javax.net.ssl.SSLSession; /** - * A deterministic {@link HttpBackend} for offline unit tests. It serves a queue of scripted + * A deterministic {@link HttpTransport} for offline unit tests. It serves a queue of scripted * responses/errors, records how many times it was called, and captures the last request's headers, * URL and body. */ -final class MockBackend implements HttpBackend { +final class MockTransport implements HttpTransport { /** One scripted response: an HTTP status + body, or a transport error. */ static final class Scripted { @@ -47,8 +49,8 @@ static final class Scripted { final List bodies = new ArrayList<>(); /** - * Optional scripted response for {@link #sendStreaming}; defaults to the first {@code send} - * entry. + * Optional scripted response for {@link #sendStreamingResponse}; defaults to the first {@code + * send} entry. */ private Scripted streamResponse; @@ -57,12 +59,12 @@ static final class Scripted { private int streamBodyStatus; - MockBackend(List responses) { + MockTransport(List responses) { this.responses = responses; } - static MockBackend ofConstant(int status, String body) { - return new MockBackend(List.of(new Scripted(status, body, null))); + static MockTransport ofConstant(int status, String body) { + return new MockTransport(List.of(new Scripted(status, body, null))); } static Scripted ok(int status, String body) { @@ -74,7 +76,9 @@ static Scripted networkError() { } static Scripted timeoutError() { - return new Scripted(0, null, new java.net.http.HttpTimeoutException("request timed out")); + // Throws the HttpTransport contract's own backend-agnostic timeout signal (not the JDK + // HttpClient's), matching what any real backend implementation is expected to do. + return new Scripted(0, null, new HttpTimeoutException("request timed out", null)); } // synchronized: batchAddRequests may drive this backend from several threads at once. @@ -98,17 +102,17 @@ public synchronized HttpResponse send(HttpRequest request) throws IOExce return new FakeResponse(request.uri(), r.status, r.body); } - /** Scripts the next {@link #sendStreaming} call to return the given status and body. */ + /** Scripts the next {@link #sendStreamingResponse} 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. + * Scripts the next {@link #sendStreamingResponse} 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; @@ -117,7 +121,7 @@ void scriptStream(int status, InputStream body) { } @Override - public synchronized HttpResponse sendStreaming(HttpRequest request) { + public synchronized HttpResponse sendStreamingResponse(HttpRequest request) { calls++; lastUrl = request.uri().toString(); if (streamBody != null) { diff --git a/src/test/java/com/apify/client/StreamedLogTest.java b/src/test/java/com/apify/client/StreamedLogTest.java index efaca90..dd972d9 100644 --- a/src/test/java/com/apify/client/StreamedLogTest.java +++ b/src/test/java/com/apify/client/StreamedLogTest.java @@ -7,6 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -26,14 +28,14 @@ */ class StreamedLogTest { - private static ApifyClient client(MockBackend backend) { - return ApifyClient.builder().token("test-token").httpBackend(backend).maxRetries(0).build(); + private static ApifyClient client(MockTransport backend) { + return ApifyClient.builder().token("test-token").httpTransport(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, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, streamBody); List collected = new CopyOnWriteArrayList<>(); StreamedLog streamedLog = @@ -88,14 +90,15 @@ void fromStartFalseDropsOldMessages() throws InterruptedException { @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( + MockTransport backend = + MockTransport.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. + // No assertion on output (goes to the default SLF4J logger); the point is it runs without + // throwing. } @Test @@ -117,7 +120,7 @@ void throwingConsumerDoesNotKillDaemonThreadUncaught() throws InterruptedExcepti } }); try { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.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 = @@ -147,7 +150,7 @@ void throwingConsumerDoesNotKillDaemonThreadUncaught() throws InterruptedExcepti @Test void startTwiceThrows() throws InterruptedException { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); StreamedLog streamedLog = client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); @@ -158,7 +161,7 @@ void startTwiceThrows() throws InterruptedException { @Test void stopWithoutStartThrows() { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); StreamedLog streamedLog = client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); assertThrows(IllegalStateException.class, streamedLog::stop); @@ -166,7 +169,7 @@ void stopWithoutStartThrows() { @Test void closeWithoutStartIsNoOp() { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.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). @@ -186,7 +189,7 @@ void deliversLastMessageWhenStoppingLiveStream() throws InterruptedException { + "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, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, stream); List collected = new CopyOnWriteArrayList<>(); StreamedLog streamedLog = @@ -217,7 +220,7 @@ void closeAfterStopIsNoOp() throws InterruptedException { // 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, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); StreamedLog streamedLog = client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); @@ -236,7 +239,7 @@ void concurrentCloseNeverThrowsWhileStopRaces() throws InterruptedException { // 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, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); StreamedLog streamedLog = client(backend).run("run123").getStreamedLog(new StreamedLogOptions().toLog(m -> {})); @@ -283,7 +286,7 @@ private static void awaitQuietly(CountDownLatch latch) { @Test void closeStopsActiveRedirection() throws InterruptedException { - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.ofConstant(200, ""); backend.scriptStream(200, "2999-01-01T00:00:00.000Z x\n"); List collected = new CopyOnWriteArrayList<>(); try (StreamedLog streamedLog = @@ -307,7 +310,7 @@ void stopFromInsideConsumerDoesNotDeadlock() throws InterruptedException { // 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, ""); + MockTransport backend = MockTransport.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<>(); @@ -345,7 +348,7 @@ void closeFromInsideConsumerDoesNotDeadlock() throws InterruptedException { // through the same stopStreaming()). close() is idempotent, so the consumer can call it on // every // message without guarding. - MockBackend backend = MockBackend.ofConstant(200, ""); + MockTransport backend = MockTransport.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<>(); @@ -378,7 +381,7 @@ void defaultPrefixLookupFailureStillCreatesHelper() { // 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\":{}}"))); + MockTransport backend = new MockTransport(List.of(MockTransport.ok(401, "{\"error\":{}}"))); StreamedLog streamedLog = assertDoesNotThrow(() -> client(backend).run("run123").getStreamedLog()); assertNotNull(streamedLog); diff --git a/src/test/java/com/apify/client/UnitHttpTest.java b/src/test/java/com/apify/client/UnitHttpTest.java index a6d2334..102038e 100644 --- a/src/test/java/com/apify/client/UnitHttpTest.java +++ b/src/test/java/com/apify/client/UnitHttpTest.java @@ -5,6 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.apify.client.actor.Actor; +import com.apify.client.http.ApifyApiException; +import com.apify.client.user.User; import java.time.Duration; import java.util.List; import java.util.Optional; @@ -13,10 +16,10 @@ /** Offline unit tests for the retry/error/404 logic, using a mock HTTP backend. */ class UnitHttpTest { - private static ApifyClient client(MockBackend backend, int maxRetries) { + private static ApifyClient client(MockTransport backend, int maxRetries) { return ApifyClient.builder() .token("test-token") - .httpBackend(backend) + .httpTransport(backend) .maxRetries(maxRetries) .minDelayBetweenRetries(Duration.ofMillis(1)) .build(); @@ -24,8 +27,8 @@ private static ApifyClient client(MockBackend backend, int maxRetries) { @Test void successSingleCall() { - MockBackend backend = - MockBackend.ofConstant(200, "{\"data\":{\"id\":\"u1\",\"username\":\"bob\"}}"); + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"u1\",\"username\":\"bob\"}}"); Optional user = client(backend, 8).me().get(); assertTrue(user.isPresent()); assertEquals("u1", user.get().getId()); @@ -35,8 +38,8 @@ void successSingleCall() { @Test void rateLimitIsRetried() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 429, "{\"error\":{\"type\":\"rate-limit-exceeded\",\"message\":\"slow down\"}}"); ApifyApiException ex = assertThrows(ApifyApiException.class, () -> client(backend, 2).me().get()); @@ -47,35 +50,36 @@ void rateLimitIsRetried() { @Test void serverErrorIsRetried() { - MockBackend backend = - MockBackend.ofConstant(503, "{\"error\":{\"type\":\"internal\",\"message\":\"boom\"}}"); + MockTransport backend = + MockTransport.ofConstant(503, "{\"error\":{\"type\":\"internal\",\"message\":\"boom\"}}"); assertThrows(ApifyApiException.class, () -> client(backend, 1).me().get()); assertEquals(2, backend.calls); } @Test void clientErrorNotRetried() { - MockBackend backend = - MockBackend.ofConstant(400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"nope\"}}"); + MockTransport backend = + MockTransport.ofConstant( + 400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"nope\"}}"); assertThrows(ApifyApiException.class, () -> client(backend, 5).me().get()); assertEquals(1, backend.calls); } @Test void networkErrorIsRetried() { - MockBackend backend = new MockBackend(List.of(MockBackend.networkError())); + MockTransport backend = new MockTransport(List.of(MockTransport.networkError())); assertThrows(RuntimeException.class, () -> client(backend, 3).me().get()); assertEquals(4, backend.calls); } @Test void retryThenSuccess() { - MockBackend backend = - new MockBackend( + MockTransport backend = + new MockTransport( List.of( - MockBackend.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), - MockBackend.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), - MockBackend.ok(200, "{\"data\":{\"id\":\"ok\"}}"))); + MockTransport.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), + MockTransport.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), + MockTransport.ok(200, "{\"data\":{\"id\":\"ok\"}}"))); Optional user = client(backend, 5).me().get(); assertTrue(user.isPresent()); assertEquals("ok", user.get().getId()); @@ -84,8 +88,8 @@ void retryThenSuccess() { @Test void notFoundMapsToEmpty() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"); Optional actor = client(backend, 5).actor("nope").get(); assertFalse(actor.isPresent()); @@ -94,8 +98,8 @@ void notFoundMapsToEmpty() { @Test void errorBodyIsParsed() { - MockBackend backend = - MockBackend.ofConstant( + MockTransport backend = + MockTransport.ofConstant( 400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"invalid input\",\"data\":{\"field\":\"name\"}}}"); ApifyApiException ex = @@ -110,8 +114,8 @@ void errorBodyIsParsed() { @Test void zeroRetriesSingleAttempt() { - MockBackend backend = - MockBackend.ofConstant(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"); + MockTransport backend = + MockTransport.ofConstant(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"); assertThrows(ApifyApiException.class, () -> client(backend, 0).me().get()); assertEquals(1, backend.calls); } diff --git a/src/test/java/com/apify/client/examples/CreateBuildRunActor.java b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java index a3b4ed4..45dcf94 100644 --- a/src/test/java/com/apify/client/examples/CreateBuildRunActor.java +++ b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java @@ -1,12 +1,12 @@ package com.apify.client.examples; -import com.apify.client.Actor; -import com.apify.client.ActorBuildOptions; -import com.apify.client.ActorClient; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import com.apify.client.Build; +import com.apify.client.actor.Actor; +import com.apify.client.actor.ActorBuildOptions; +import com.apify.client.actor.ActorClient; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.build.Build; +import com.apify.client.run.ActorRun; import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/src/test/java/com/apify/client/examples/GetAccount.java b/src/test/java/com/apify/client/examples/GetAccount.java index c8091b5..cf13e52 100644 --- a/src/test/java/com/apify/client/examples/GetAccount.java +++ b/src/test/java/com/apify/client/examples/GetAccount.java @@ -1,7 +1,7 @@ package com.apify.client.examples; import com.apify.client.ApifyClient; -import com.apify.client.User; +import com.apify.client.user.User; import java.util.Optional; /** diff --git a/src/test/java/com/apify/client/examples/IterateStore.java b/src/test/java/com/apify/client/examples/IterateStore.java index a733f02..5179179 100644 --- a/src/test/java/com/apify/client/examples/IterateStore.java +++ b/src/test/java/com/apify/client/examples/IterateStore.java @@ -1,8 +1,8 @@ package com.apify.client.examples; -import com.apify.client.ActorStoreListItem; import com.apify.client.ApifyClient; -import com.apify.client.StoreListOptions; +import com.apify.client.store.ActorStoreListItem; +import com.apify.client.store.StoreListOptions; import java.util.Iterator; /** diff --git a/src/test/java/com/apify/client/examples/LogRedirection.java b/src/test/java/com/apify/client/examples/LogRedirection.java index 0acfd1e..825f9b1 100644 --- a/src/test/java/com/apify/client/examples/LogRedirection.java +++ b/src/test/java/com/apify/client/examples/LogRedirection.java @@ -1,10 +1,10 @@ package com.apify.client.examples; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import com.apify.client.RunClient; -import com.apify.client.StreamedLog; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.log.StreamedLog; +import com.apify.client.run.ActorRun; +import com.apify.client.run.RunClient; /** * Starts an Actor without waiting, then redirects its live log to a logger in real time (log diff --git a/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java b/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java index bed3213..28e6500 100644 --- a/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java +++ b/src/test/java/com/apify/client/examples/RunAndLastRunStorages.java @@ -1,9 +1,9 @@ package com.apify.client.examples; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import com.apify.client.DatasetListItemsOptions; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.run.ActorRun; import java.util.Optional; /** diff --git a/src/test/java/com/apify/client/examples/RunStoreActor.java b/src/test/java/com/apify/client/examples/RunStoreActor.java index 561e255..12ba9a2 100644 --- a/src/test/java/com/apify/client/examples/RunStoreActor.java +++ b/src/test/java/com/apify/client/examples/RunStoreActor.java @@ -1,9 +1,9 @@ package com.apify.client.examples; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import com.apify.client.DatasetListItemsOptions; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.run.ActorRun; /** * Runs an existing store Actor ({@code apify/hello-world}), waits for it to finish, and reads its diff --git a/src/test/java/com/apify/client/examples/Storages.java b/src/test/java/com/apify/client/examples/Storages.java index f1bdec9..8195abc 100644 --- a/src/test/java/com/apify/client/examples/Storages.java +++ b/src/test/java/com/apify/client/examples/Storages.java @@ -1,12 +1,12 @@ package com.apify.client.examples; import com.apify.client.ApifyClient; -import com.apify.client.Dataset; -import com.apify.client.DatasetListItemsOptions; -import com.apify.client.KeyValueStore; -import com.apify.client.KeyValueStoreRecord; -import com.apify.client.RequestQueue; -import com.apify.client.RequestQueueRequest; +import com.apify.client.dataset.Dataset; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.keyvalue.KeyValueStore; +import com.apify.client.keyvalue.KeyValueStoreRecord; +import com.apify.client.requestqueue.RequestQueue; +import com.apify.client.requestqueue.RequestQueueRequest; import java.util.List; import java.util.Map; import java.util.Optional; diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java index a6f93af..bc52b9f 100644 --- a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -3,14 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.apify.client.Actor; -import com.apify.client.ActorClient; -import com.apify.client.ActorEnvVar; -import com.apify.client.ActorListOptions; -import com.apify.client.ActorVersion; import com.apify.client.ApifyClient; import com.apify.client.ListOptions; import com.apify.client.PaginationList; +import com.apify.client.actor.Actor; +import com.apify.client.actor.ActorClient; +import com.apify.client.actor.ActorEnvVar; +import com.apify.client.actor.ActorListOptions; +import com.apify.client.actor.ActorVersion; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index b7a8f3d..30c72cb 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -3,12 +3,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.apify.client.ActorRun; -import com.apify.client.ActorStartOptions; import com.apify.client.ApifyClient; -import com.apify.client.LastRunOptions; import com.apify.client.ListOptions; -import com.apify.client.RunListOptions; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; +import com.apify.client.run.ActorRun; +import com.apify.client.run.LastRunOptions; +import com.apify.client.run.RunClient; +import com.apify.client.run.RunListOptions; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -32,7 +36,7 @@ void runActorAndReadOutputs() { Optional log = client.run(run.getId()).log().get(); assertTrue(log.isPresent() && !log.get().isEmpty()); - client.run(run.getId()).dataset().listItems(new com.apify.client.DatasetListItemsOptions()); + client.run(run.getId()).dataset().listItems(new DatasetListItemsOptions()); client.run(run.getId()).keyValueStore().getRecord("OUTPUT"); } @@ -58,11 +62,11 @@ void lastRunAccess() { 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()); + 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))) { + try (StreamedLog streamedLog = + runClient.getStreamedLog(new StreamedLogOptions().toLog(collected::add))) { streamedLog.start(); runClient.waitForFinish(120L); } diff --git a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java index 1642a64..c01d2d6 100644 --- a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java @@ -3,11 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.apify.client.Actor; -import com.apify.client.ActorBuildOptions; import com.apify.client.ApifyClient; -import com.apify.client.Build; import com.apify.client.ListOptions; +import com.apify.client.actor.Actor; +import com.apify.client.actor.ActorBuildOptions; +import com.apify.client.build.Build; import org.junit.jupiter.api.Test; class BuildIntegrationTest extends IntegrationBase { diff --git a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java index 7cde9ca..ebea377 100644 --- a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java @@ -4,13 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.ApifyClient; -import com.apify.client.Dataset; -import com.apify.client.DatasetClient; -import com.apify.client.DatasetDownloadOptions; -import com.apify.client.DatasetListItemsOptions; -import com.apify.client.DownloadItemsFormat; import com.apify.client.PaginationList; import com.apify.client.StorageListOptions; +import com.apify.client.dataset.Dataset; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetDownloadOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.dataset.DownloadItemsFormat; import com.fasterxml.jackson.databind.JsonNode; import java.nio.charset.StandardCharsets; import java.util.List; diff --git a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java index 1772e2d..eb8c186 100644 --- a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java @@ -3,26 +3,28 @@ 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.apify.client.actor.Actor; +import com.apify.client.actor.ActorEnvVar; +import com.apify.client.actor.ActorListOptions; +import com.apify.client.actor.ActorVersion; +import com.apify.client.build.Build; +import com.apify.client.dataset.Dataset; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.keyvalue.KeyValueStore; +import com.apify.client.keyvalue.KeyValueStoreClient; +import com.apify.client.keyvalue.KeyValueStoreKey; +import com.apify.client.keyvalue.ListKeysOptions; +import com.apify.client.requestqueue.RequestQueue; +import com.apify.client.run.ActorRun; +import com.apify.client.run.RunListOptions; +import com.apify.client.schedule.Schedule; +import com.apify.client.task.Task; +import com.apify.client.webhook.Webhook; +import com.apify.client.webhook.WebhookDispatch; import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; import java.util.HashSet; @@ -322,8 +324,7 @@ void iterateActorVersionsAndEnvVars() { // 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()); + Iterator versions = actorClient.versions().iterate(new ListOptions()); while (versions.hasNext()) { versionCount++; versionNumbers.add(versions.next().getVersionNumber()); @@ -363,7 +364,7 @@ void iterateBuildsBounded() { @Test void iterateRunsBounded() { ApifyClient client = requireClient(); - Iterator it = + Iterator it = client.runs().iterate(new ListOptions().limit(5L), new RunListOptions(), 2L); int count = 0; while (it.hasNext()) { diff --git a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java index f01bf7f..1f9a423 100644 --- a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java @@ -4,12 +4,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.ApifyClient; -import com.apify.client.GetRecordOptions; -import com.apify.client.KeyValueStore; -import com.apify.client.KeyValueStoreClient; -import com.apify.client.KeyValueStoreRecord; -import com.apify.client.ListKeysOptions; import com.apify.client.StorageListOptions; +import com.apify.client.keyvalue.GetRecordOptions; +import com.apify.client.keyvalue.KeyValueStore; +import com.apify.client.keyvalue.KeyValueStoreClient; +import com.apify.client.keyvalue.KeyValueStoreRecord; +import com.apify.client.keyvalue.ListKeysOptions; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; diff --git a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java index 69277f2..27d9590 100644 --- a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java @@ -4,13 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.ApifyClient; -import com.apify.client.BatchAddResult; -import com.apify.client.ListRequestsOptions; -import com.apify.client.RequestQueue; -import com.apify.client.RequestQueueClient; -import com.apify.client.RequestQueueOperationInfo; -import com.apify.client.RequestQueueRequest; import com.apify.client.StorageListOptions; +import com.apify.client.requestqueue.BatchAddResult; +import com.apify.client.requestqueue.ListRequestsOptions; +import com.apify.client.requestqueue.RequestQueue; +import com.apify.client.requestqueue.RequestQueueClient; +import com.apify.client.requestqueue.RequestQueueOperationInfo; +import com.apify.client.requestqueue.RequestQueueRequest; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; diff --git a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java index fb5e09c..0455c48 100644 --- a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java @@ -5,8 +5,8 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; -import com.apify.client.Schedule; -import com.apify.client.ScheduleClient; +import com.apify.client.schedule.Schedule; +import com.apify.client.schedule.ScheduleClient; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java index 66a19ce..ea0863a 100644 --- a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java @@ -2,9 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -import com.apify.client.ActorStoreListItem; import com.apify.client.ApifyClient; -import com.apify.client.StoreListOptions; +import com.apify.client.store.ActorStoreListItem; +import com.apify.client.store.StoreListOptions; import java.util.Iterator; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index f1ea420..4c16d77 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -5,9 +5,9 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; -import com.apify.client.RunListOptions; -import com.apify.client.Task; -import com.apify.client.TaskClient; +import com.apify.client.run.RunListOptions; +import com.apify.client.task.Task; +import com.apify.client.task.TaskClient; import java.util.Map; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java index eb7e9b9..58f0de4 100644 --- a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java @@ -5,9 +5,9 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; -import com.apify.client.Webhook; -import com.apify.client.WebhookClient; -import com.apify.client.WebhookDispatch; +import com.apify.client.webhook.Webhook; +import com.apify.client.webhook.WebhookClient; +import com.apify.client.webhook.WebhookDispatch; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; From 2a1c03156b05f7bd5469cbb2d4d47ccd6ed6b3c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:13:38 +0000 Subject: [PATCH 02/18] =?UTF-8?q?fix:=20address=20loop-1=20review=20?= =?UTF-8?q?=E2=80=94=20exception=20hierarchy,=20internal=20package,=20rest?= =?UTF-8?q?ore=20setStatusMessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the headline correctness bug (ApifyApiException now extends ApifyClientException) and a PaginationList NPE on "items": null. Hides implementation plumbing behind a non-exported com.apify.client.internal package with a module-info.java, restores ApifyClient.setStatusMessage (the prior removal was based on a false claim about the reference client), and removes the duplicated AI-disclaimer from the 10 docs pages. Also covers the accumulated code-quality, docs, changelog, test-coverage, parity-gap and issue #8 follow-ups from the review; see notes.md "Implementer — loop 2" for the itemized list and the explicit skip-with-justification entries. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- CHANGELOG.md | 59 ++++++------- README.md | 39 ++++++--- docs/README.md | 74 ++++++++++++---- docs/actors.md | 8 +- docs/builds.md | 4 - docs/examples.md | 13 ++- docs/misc.md | 6 +- docs/runs.md | 15 ++-- docs/schedules.md | 4 - docs/storages.md | 26 +++--- docs/tasks.md | 4 - docs/webhooks.md | 4 - pom.xml | 5 ++ spotbugs-exclude.xml | 2 +- .../java/com/apify/client/ApifyClient.java | 34 +++++++- .../com/apify/client/ApifyClientBuilder.java | 77 +++++++++++++---- .../java/com/apify/client/ListOptions.java | 2 + .../java/com/apify/client/PaginationList.java | 9 +- .../com/apify/client/StorageListOptions.java | 2 + .../apify/client/actor/ActorBuildOptions.java | 2 +- .../com/apify/client/actor/ActorClient.java | 15 ++-- .../client/actor/ActorCollectionClient.java | 8 +- .../apify/client/actor/ActorEnvVarClient.java | 6 +- .../actor/ActorEnvVarCollectionClient.java | 6 +- .../apify/client/actor/ActorListOptions.java | 2 +- .../apify/client/actor/ActorStartOptions.java | 6 +- .../client/actor/ActorVersionClient.java | 6 +- .../actor/ActorVersionCollectionClient.java | 23 +++-- .../java/com/apify/client/build/Build.java | 2 +- .../com/apify/client/build/BuildClient.java | 10 +-- .../client/build/BuildCollectionClient.java | 6 +- .../apify/client/dataset/DatasetClient.java | 23 ++--- .../dataset/DatasetCollectionClient.java | 8 +- .../dataset/DatasetDownloadOptions.java | 2 +- .../dataset/DatasetListItemsOptions.java | 2 +- .../com/apify/client/http/ApiResponse.java | 8 +- .../apify/client/http/ApifyApiException.java | 15 +++- .../client/http/ApifyTransportException.java | 5 +- .../client/http/DefaultHttpTransport.java | 11 ++- .../client/http/HttpTimeoutException.java | 6 +- .../com/apify/client/http/HttpTransport.java | 4 +- .../com/apify/client/http/RetryConfig.java | 14 +-- .../apify/client/{ => internal}/ApiPaths.java | 2 +- .../client/{ => internal}/DataEnvelope.java | 9 +- .../com/apify/client/internal/Extras.java | 19 +++++ .../{http => internal}/HttpClientCore.java | 20 +++-- .../apify/client/{http => internal}/Json.java | 5 +- .../{ => internal}/PaginatedIterator.java | 3 +- .../client/{ => internal}/QueryParams.java | 2 +- .../{ => internal}/ResourceContext.java | 25 +++++- .../client/{ => internal}/Signatures.java | 2 +- .../apify/client/{ => internal}/Statuses.java | 2 +- .../client/keyvalue/GetRecordOptions.java | 2 +- .../client/keyvalue/KeyValueStoreClient.java | 23 ++--- .../KeyValueStoreCollectionClient.java | 8 +- .../client/keyvalue/ListKeysOptions.java | 2 +- .../java/com/apify/client/log/LogClient.java | 8 +- .../java/com/apify/client/log/LogOptions.java | 2 +- .../requestqueue/ListRequestsOptions.java | 2 +- .../requestqueue/RequestQueueClient.java | 84 ++++++++++++------ .../RequestQueueCollectionClient.java | 8 +- .../requestqueue/RequestQueueRequest.java | 85 +++++++++++++++++++ .../java/com/apify/client/run/ActorRun.java | 2 +- .../apify/client/run/MetamorphOptions.java | 2 +- .../java/com/apify/client/run/RunClient.java | 41 ++++++--- .../apify/client/run/RunCollectionClient.java | 6 +- .../com/apify/client/run/RunListOptions.java | 2 +- .../apify/client/run/RunResurrectOptions.java | 2 +- .../client/run/SetStatusMessageOptions.java | 24 ++++++ .../apify/client/schedule/ScheduleClient.java | 8 +- .../schedule/ScheduleCollectionClient.java | 8 +- .../client/store/StoreCollectionClient.java | 8 +- .../apify/client/store/StoreListOptions.java | 2 +- .../com/apify/client/task/TaskClient.java | 32 +++---- .../client/task/TaskCollectionClient.java | 8 +- .../apify/client/task/TaskStartOptions.java | 2 +- .../client/task/ValidateInputOptions.java | 4 +- .../com/apify/client/user/UserClient.java | 30 +++---- .../AbstractWebhookCollectionClient.java | 8 +- .../NestedWebhookCollectionClient.java | 2 +- .../apify/client/webhook/WebhookClient.java | 8 +- .../webhook/WebhookCollectionClient.java | 4 +- .../client/webhook/WebhookDispatchClient.java | 8 +- .../WebhookDispatchCollectionClient.java | 6 +- src/main/java/module-info.java | 70 +++++++++++++++ .../client/ClientBehaviourRegressionTest.java | 4 +- .../com/apify/client/CompressionTest.java | 2 +- .../client/DatasetItemsIteratorTest.java | 5 +- .../apify/client/ExceptionHierarchyTest.java | 44 ++++++++++ .../java/com/apify/client/MockTransport.java | 7 +- .../apify/client/PaginatedIteratorTest.java | 1 + .../java/com/apify/client/UnitHttpTest.java | 58 ++++++------- .../client/examples/CreateBuildRunActor.java | 6 +- .../com/apify/client/examples/Storages.java | 6 +- .../integration/ActorIntegrationTest.java | 24 ++++++ .../integration/ActorRunIntegrationTest.java | 57 ++++++++++++- .../integration/BuildIntegrationTest.java | 18 ++++ .../RequestQueueIntegrationTest.java | 24 ++++++ .../integration/TaskIntegrationTest.java | 24 ++++++ .../integration/UserIntegrationTest.java | 12 +++ .../client/{ => internal}/SignatureTest.java | 2 +- 101 files changed, 1036 insertions(+), 435 deletions(-) rename src/main/java/com/apify/client/{ => internal}/ApiPaths.java (97%) rename src/main/java/com/apify/client/{ => internal}/DataEnvelope.java (57%) create mode 100644 src/main/java/com/apify/client/internal/Extras.java rename src/main/java/com/apify/client/{http => internal}/HttpClientCore.java (96%) rename src/main/java/com/apify/client/{http => internal}/Json.java (97%) rename src/main/java/com/apify/client/{ => internal}/PaginatedIterator.java (98%) rename src/main/java/com/apify/client/{ => internal}/QueryParams.java (98%) rename src/main/java/com/apify/client/{ => internal}/ResourceContext.java (94%) rename src/main/java/com/apify/client/{ => internal}/Signatures.java (99%) rename src/main/java/com/apify/client/{ => internal}/Statuses.java (93%) create mode 100644 src/main/java/com/apify/client/run/SetStatusMessageOptions.java create mode 100644 src/main/java/module-info.java create mode 100644 src/test/java/com/apify/client/ExceptionHierarchyTest.java rename src/test/java/com/apify/client/{ => internal}/SignatureTest.java (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b0119..ad5d267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,20 +12,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Breaking:** split the single `com.apify.client` package into resource-scoped sub-packages (`com.apify.client.actor`, `.build`, `.run`, `.dataset`, `.keyvalue`, `.requestqueue`, `.task`, `.schedule`, `.webhook`, `.user`, `.store`, `.log`, `.http`); `ApifyClient`, its builder, shared - value types and the exception hierarchy stay in the root package. + value types and the exception hierarchy stay in the root package. Pure implementation plumbing + (`ResourceContext`, `QueryParams`, `HttpClientCore`, `Json`, `Signatures`, `Statuses`, + `DataEnvelope`, `ApiPaths`, `PaginatedIterator`, ...) moved to a new, non-exported + `com.apify.client.internal` package; a `module-info.java` exports only the resource-scoped + packages above. - **Breaking:** renamed the replaceable transport: `HttpBackend` -> `HttpTransport`, `DefaultHttpBackend` -> `DefaultHttpTransport`, `HttpBackend.sendStreaming` -> `HttpTransport.sendStreamingResponse`, `ApifyClientBuilder.httpBackend` -> `ApifyClientBuilder.httpTransport`. -- **Breaking:** reworked the exception hierarchy: added a common `ApifyClientException` base; - the previously internal, package-private transport-failure exception is now a public - `ApifyTransportException`; timeout detection is backend-agnostic via a new - `HttpTimeoutException` that any `HttpTransport` implementation can throw. +- **Breaking:** reworked the exception hierarchy: added a common `ApifyClientException` base that + `ApifyApiException` and the new public `ApifyTransportException` both extend; timeout detection + is transport-agnostic via a new `HttpTimeoutException` that any `HttpTransport` implementation + can throw. - **Breaking:** `ApifyClient.getUserAgent()`/`getApiBaseUrl()` are no longer public. -- **Breaking:** removed `ApifyClient.setStatusMessage(...)`; use - `client.run(runId).update(Map.of("statusMessage", ..., "isStatusMessageTerminal", ...))`. +- **Breaking:** `RunClient.setLastRunParams` is no longer public; last-run construction is now + internal to `RunClient.lastRun(...)`, used by `ActorClient.lastRun`/`TaskClient.lastRun`. - `DatasetClient`/`KeyValueStoreClient` are now fully immutable: the public-base-URL is set at - construction instead of through a self-mutating `withPublicBase` step. + construction instead of through a self-mutating `withPublicBase` step. `ResourceContext` no + longer has any in-place-mutating call site. - Lowered the default HTTP connection-establishment timeout from 30s to 10s and made it configurable (`DefaultHttpTransport(Duration)`). - Consolidated the internal `call`/`callWithHeaders` overloads under a single `call` name. @@ -39,16 +44,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Centralized the API's resource-collection path segments (e.g. `datasets`, `actor-builds`) into a single `ApiPaths` constants class, referenced by each dedicated resource client instead of being passed in from `ApifyClient`. -- Verified the client against OpenAPI specification version `v2-2026-07-20T094852Z` and bumped - `Version.API_SPEC_VERSION`. The spec change only added already-handled error responses (`402`, - `404`, `409` on the build/task endpoints), an `ErrorType` enum value, and allowed `null` for a - tagged build's `buildNumber`; all are already covered by the client's generic status-code error - handling and untyped-extras model deserialization, so no interface or behavior change was needed. +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-20T094852Z`. + +### Added + +- `ApifyClient.setStatusMessage(String, SetStatusMessageOptions)`, matching the reference client's + top-level `setStatusMessage`. ### Fixed -- `PaginationList.setItems` now defensively copies its input, closing a representation-exposure - gap surfaced by making the setter part of the client's cross-package public surface. +- `ApifyApiException` now extends `ApifyClientException` (previously `RuntimeException` directly), + so `catch (ApifyClientException)` catches API errors as documented. +- `PaginationList.getItems()` no longer throws `NullPointerException` when the API response + contains an explicit `"items": null`. +- `PaginationList.setItems` now defensively copies its input. ## [0.3.1] - 2026-07-14 @@ -59,12 +68,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Verified the client against OpenAPI specification version `v2-2026-07-13T092445Z` and bumped - `Version.API_SPEC_VERSION`. The spec change only added already-handled error responses (`402` - Payment Required on actor and run-resurrect run-sync endpoints, `408` Request Timeout on the - task run-sync endpoint) and relaxed `required` constraints on run/build/key-value-store/webhook - stats counters; both are already covered by the client's generic status-code error handling and - its forward-compatible model deserialization, so no interface or behavior change was needed. +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-13T092445Z`. ### Fixed @@ -130,12 +134,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-10T105921Z`. - **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 @@ -159,8 +158,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Verified the client against OpenAPI specification version `v2-2026-07-08T143931Z` and bumped - `Version.API_SPEC_VERSION` accordingly. +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-08T143931Z`. - Aligned the `User-Agent` OS token with the reference JS client's `os.platform()` token: it now uses the short, lowercase platform identifier (`linux`, `darwin`, `win32`, `android`, …) instead of the human-readable `os.name` value. @@ -174,8 +172,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Verified the client against OpenAPI specification version `v2-2026-07-07T132551Z` and bumped - `Version.API_SPEC_VERSION` accordingly. +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-07T132551Z`. - Corrected the `LastRunOptions` documentation: `origin` is now a spec-declared query parameter on the `runs/last` endpoints (behaviour unchanged). diff --git a/README.md b/README.md index ea336b4..88310f2 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,11 @@ any other environment variable) automatically. Read it yourself if you want that All public client types live under `com.apify.client`, split by resource into sub-packages (e.g. `com.apify.client.run.ActorRun`, `com.apify.client.dataset.DatasetListItemsOptions`) — see -[Resources](#resources) below for the full list. The remaining snippets in this file are fragments -that assume a configured `client`; the [resource pages](docs/README.md) show each fragment as a -complete, correctly-imported program. Reading items from a run's default dataset: +[Resources](#resources) below for the full list; [`docs/README.md`](docs/README.md#imports-and-dependencies) +enumerates the model/option-type packages. The remaining snippets in this file (like the Quick +start one above) are fragments that assume a configured `client` and the correct imports for the +types they use — they are not, by themselves, complete programs; the [resource pages](docs/README.md) +show the same kind of fragment per method. Reading items from a run's default dataset: ```java ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); @@ -75,8 +77,9 @@ PaginationList items = System.out.println("Items in this page: " + items.getCount()); ``` -The types used above — `PaginationList`, `DatasetListItemsOptions`, and the per-resource clients — -are documented on the [resource pages](docs/README.md); `ApifyApiException` is covered under +The types used above — `PaginationList` (root package), `DatasetListItemsOptions` +(`com.apify.client.dataset`), and the per-resource clients — are documented on the +[resource pages](docs/README.md); `ApifyApiException` (`com.apify.client.http`) is covered under [Error handling](#error-handling) below. [`docs/examples.md`](docs/examples.md) has complete, runnable programs (build-and-run, storages, log redirection, and more). @@ -103,13 +106,13 @@ The transport is a replaceable component. The default is `DefaultHttpTransport` proxy/TLS: ```java -HttpTransport backend = new DefaultHttpTransport(java.net.http.HttpClient.newHttpClient()); -ApifyClient withBackend = ApifyClient.builder().token("t").httpTransport(backend).build(); +HttpTransport transport = new DefaultHttpTransport(java.net.http.HttpClient.newHttpClient()); +ApifyClient withTransport = ApifyClient.builder().token("t").httpTransport(transport).build(); ``` -Cross-cutting behaviour applied to every request lives in the client, not the backend: -bearer-token authentication, the mandated `User-Agent` header, and retries with exponential -backoff and jitter on `429`, `5xx` and network errors. +Cross-cutting behaviour applied to every request lives in the client, not the transport +implementation: bearer-token authentication, the mandated `User-Agent` header, and retries with +exponential backoff and jitter on `429`, `5xx` and network errors. ## Fetching single resources @@ -122,8 +125,18 @@ client.actor("apify/hello-world").get().ifPresent(actor -> System.out.println(ac ## Error handling -API failures (a request that reaches the API but returns a non-success status) are thrown as -`ApifyApiException`, an unchecked exception exposing the parsed error details: +Every exception this client throws is an unchecked `com.apify.client.http.ApifyClientException`. +It has two concrete subtypes, both also in `com.apify.client.http`: + +- `ApifyApiException` — the request reached the API, which answered with a non-success status. +- `ApifyTransportException` — the request never produced an API response at all (connection + failure, DNS, timeout, or a local failure preparing the request/response, e.g. compression). + `isTimeout()` reports whether the underlying cause was specifically a timeout (backed by + `HttpTimeoutException`, part of the `HttpTransport` contract, not any specific transport + implementation's own exception type). + +Catch `ApifyClientException` to handle both failure modes uniformly, or catch a specific subtype to +handle one of them differently. `ApifyApiException` is imported from `com.apify.client.http`: ```java try { @@ -133,6 +146,8 @@ try { } ``` +`ApifyApiException` exposes the parsed error details: + | Accessor | Meaning | |---|---| | `getStatusCode()` | HTTP status code of the error response. | diff --git a/docs/README.md b/docs/README.md index c6222a5..7712e43 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,5 @@ # Apify Java client documentation -> **Official, but experimental — AI-generated and AI-maintained.** This is an official Apify client, -> but it is experimental: it is generated and maintained by AI. Review the code before relying on it -> in production and report issues on the repository. - This directory documents the public API of the Apify Java client, organized by resource. Each page lists the available methods with their parameters and short snippets. The snippets are code fragments that assume a configured `client` and the imports listed below, not standalone `main` @@ -31,10 +27,38 @@ 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.ArrayList`, -`java.util.Map`, `java.util.Optional`, `java.util.Iterator`, `java.util.function.Consumer`, -`java.time.Duration`, `java.io.InputStream`). +Client types are **not** all in one package: `import com.apify.client.*;` only resolves +`ApifyClient`, its builder, and the shared kernel types below — a wildcard import does not reach +into sub-packages in Java. Every resource has its own sub-package for its client(s), model(s) and +option types, so import each resource you use from its own package: + +| Package | Contains | +|---|---| +| `com.apify.client` (root) | `ApifyClient`, `ApifyClientBuilder`, `Version`, `PaginationList`, `ListOptions`, `StorageListOptions`, `ApifyResource` | +| `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException` | +| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorListOptions`, `ActorStartOptions`, `ActorBuildOptions`, `ActorVersion`, `ActorEnvVar`, ... | +| `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient` | +| `com.apify.client.run` | `ActorRun`, `RunClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | +| `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions` | +| `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreRecord`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | +| `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueRequest`, `ListRequestsOptions`, `BatchAddRequestsOptions` | +| `com.apify.client.task` | `Task`, `TaskClient`, `TaskStartOptions`, `ValidateInputOptions` | +| `com.apify.client.schedule` | `Schedule`, `ScheduleClient` | +| `com.apify.client.webhook` | `Webhook`, `WebhookClient`, `WebhookDispatch`, `WebhookDispatchClient` | +| `com.apify.client.user` | `User`, `UserClient` | +| `com.apify.client.store` | `ActorStoreListItem`, `StoreCollectionClient`, `StoreListOptions` | +| `com.apify.client.log` | `LogClient`, `LogOptions`, `StreamedLog`, `StreamedLogOptions` | + +For example, the [top-level README's dataset snippet](../README.md#quick-start) needs +`com.apify.client.ApifyClient`, `com.apify.client.PaginationList`, +`com.apify.client.dataset.DatasetListItemsOptions`, `com.apify.client.run.ActorRun`, +`com.apify.client.actor.ActorStartOptions` and `com.fasterxml.jackson.databind.JsonNode` — five +different packages for one six-line snippet. + +Snippets in these docs also assume the standard-library types they use are imported +(`java.util.List`, `java.util.ArrayList`, `java.util.Map`, `java.util.Optional`, +`java.util.Iterator`, `java.util.UUID`, `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,13 +68,14 @@ transitive dependency of this client, so it is already on your classpath. A few methods return data whose shape is not modelled by this client and is instead exposed as a Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): -- Read: `me().monthlyUsage(...)`, `me().limits()`, `task(id).getInput()`, - `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. +- Read, returning a required `JsonNode` (never absent): `me().monthlyUsage(...)`, `me().limits()`, + and the raw request-queue operations `listRequests`, `listAndLockHead`, `prolongRequestLock`, + `unlockRequests`, `batchDeleteRequests`. +- Read, returning `Optional` (empty when the underlying resource has none): `dataset(id).getStatistics()`, + `task(id).getInput()`, `build(id).getOpenApiDefinition()`. +- Write: `task(id).updateInput(...)` (itself returning a required `JsonNode`, the updated input) + and `me().updateLimits(...)` accept an arbitrary JSON-serializable value, as do + definition/`update`/`create` arguments generally — a `Map`, a `JsonNode`, or your own POJO. Navigate a `JsonNode` with `node.get("field")`, `node.path("a").asText()`, etc. @@ -67,6 +92,25 @@ Schedule schedule = client.schedule("SCHEDULE_ID").get().orElseThrow(); Object actions = schedule.getExtra().get("actions"); ``` +Some models expose only their most commonly used fields as typed getters and leave more of the +API response in `getExtra()` than others — `Schedule`, `Webhook`, `Task` and `ActorRun` in +particular currently model a subset of what the API returns (e.g. a `Schedule`'s `title`, +`timezone`, `notifications`; a `Webhook`'s `condition`, `payloadTemplate`, `stats`; an `ActorRun`'s +`usage`, `options`, `pricingInfo`). Read those fields via `getExtra()` until a typed getter is +added; the raw JSON key is unchanged either way. + +## Setting the current run's status message + +`client.setStatusMessage(String message, SetStatusMessageOptions)` (import from +`com.apify.client.run`) updates the status message of the current Actor run (identified by the +`ACTOR_RUN_ID` environment variable); it only works from inside a run and throws +`IllegalStateException` otherwise. `SetStatusMessageOptions.isStatusMessageTerminal(boolean)` marks +the message as final so it won't be overwritten. Returns the updated `ActorRun`. + +```java +client.setStatusMessage("half way there", new SetStatusMessageOptions().isStatusMessageTerminal(false)); +``` + ## Optional option fields Option objects use fluent setters and nullable (boxed) fields; an unset field means "use the API diff --git a/docs/actors.md b/docs/actors.md index 0c640c4..9c7cc57 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -1,9 +1,5 @@ # 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). @@ -99,8 +95,8 @@ 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. | +| `list(ListOptions)` | List the Actor's versions. Returns `PaginationList`. The endpoint takes no query parameters; `options` (`offset`/`limit`/`desc`) is accepted only for signature consistency with every other `list(ListOptions)` and has no effect — pass `null`. | +| `iterate(ListOptions)` | Lazy `Iterator` over all versions; `limit` caps the total (`null`/unset or non-positive = all), applied client-side after the single fetch. `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)` diff --git a/docs/builds.md b/docs/builds.md index 8854024..bf2d821 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -1,9 +1,5 @@ # 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)`. diff --git a/docs/examples.md b/docs/examples.md index 4743610..7b1f1d9 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,9 +1,5 @@ # Examples -> **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 @@ -25,8 +21,9 @@ 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. A unique suffix keeps the names from colliding across parallel runs. -String suffix = Long.toString(System.currentTimeMillis()); +// example does not leak them. A random (not time-based) suffix keeps the names from colliding +// across parallel runs, including two runs starting in the same millisecond. +String suffix = UUID.randomUUID().toString().substring(0, 8); // Dataset: create, push items, read them back. Dataset dataset = client.datasets().getOrCreate("java-example-ds-" + suffix); @@ -75,8 +72,8 @@ user.ifPresent(u -> { ```java Actor created = client.actors().create(Map.of( - // A unique name avoids collisions across parallel runs. - "name", "my-example-actor-" + System.currentTimeMillis(), + // A random (not time-based) suffix avoids collisions across parallel runs. + "name", "my-example-actor-" + UUID.randomUUID().toString().substring(0, 8), "isPublic", false, "versions", List.of(Map.of( "versionNumber", "0.0", diff --git a/docs/misc.md b/docs/misc.md index dc5bf13..f4a3074 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -1,9 +1,5 @@ # 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. `client.store()` returns a `StoreCollectionClient`. @@ -38,7 +34,7 @@ while (shown < 5 && it.hasNext()) { | Method | Description | |---|---| | `get()` | Fetch the user. Returns `Optional` (private details for `me()` via `getExtra()`). | -| `monthlyUsage()` / `monthlyUsage(String date)` | Account monthly usage (`me()` only). Returns `JsonNode`. | +| `monthlyUsage()` / `monthlyUsage(String date)` | Account monthly usage (`me()` only). `date` (any day within the target month, formatted `YYYY-MM-DD`) reports that month; `null`/empty reports the current month. Returns `JsonNode`. | | `limits()` | Account resource limits (`me()` only). Returns `JsonNode`. | | `updateLimits(Object)` | Update account limits (`me()` only). No return value. | diff --git a/docs/runs.md b/docs/runs.md index fbab358..735409d 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -1,9 +1,5 @@ # 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)`. @@ -49,7 +45,7 @@ background thread and redirects each complete, timestamped message to a destinat `AutoCloseable` — call `start()` to begin redirection and `stop()` (or `close()`, via try-with-resources) to end it. -With no options, messages go to a `java.util.logging.Logger` at `INFO` level, prefixed with the +With no options, messages go to an SLF4J `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 @@ -71,12 +67,13 @@ try (StreamedLog streamedLog = 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, resolve the run id (e.g. from the -`ACTOR_RUN_ID` environment variable) and `update()` it directly: +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-the-current-runs-status-message)). +To update the status message of a run other than the current one, call `update()` on its +`RunClient` directly: ```java -String runId = System.getenv("ACTOR_RUN_ID"); -client.run(runId).update(Map.of("statusMessage", "half way there", "isStatusMessageTerminal", false)); +client.run("RUN_ID").update(Map.of("statusMessage", "half way there", "isStatusMessageTerminal", false)); ``` > **`lastRun()` clients are for reads.** A `RunClient` obtained via `actor(id).lastRun(...)` / diff --git a/docs/schedules.md b/docs/schedules.md index d820b6d..97a64cb 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -1,9 +1,5 @@ # 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)`. diff --git a/docs/storages.md b/docs/storages.md index d118ba3..3f1d13c 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -1,9 +1,5 @@ # 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()` / @@ -164,12 +160,15 @@ as for datasets. | `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, paging with the queue's forward cursor. | +| `paginateRequests(Long pageLimit)` | A lazy `Iterator` over all requests, paging with the queue's forward cursor. Equivalent to `paginateRequests(null, pageLimit, null)`. | +| `paginateRequests(Long totalLimit, Long chunkSize, List filter)` | As above, with a cap on the total number yielded (`null`/non-positive = unbounded) and an optional state `filter` (`ListRequestsOptions.FILTER_LOCKED`/`FILTER_PENDING`), matching `listRequests`'s filter. | -> **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. +> **Naming exception.** Request-queue *requests* are iterated with `paginateRequests(...)` — not an +> `iterate(...)` method — because the request-queue listing is cursor-based rather than +> offset/limit; it always starts from the beginning of the queue (resuming from an explicit +> `exclusiveStartId`/`cursor` is not supported by the iterator — use `listRequests(ListRequestsOptions)` +> directly for that single-page use case). Every other resource uses the +> `iterate`/`iterateItems`/`iterateKeys` family. ```java RequestQueue rq = client.requestQueues().getOrCreate("my-queue"); @@ -186,9 +185,12 @@ while (it.hasNext()) { `FILTER_PENDING`. `RequestQueueRequest` models a request. Its `(url, uniqueKey)` constructor covers the common case -(`uniqueKey` is the deduplication key); fluent setters `setId`, `setUrl`, `setUniqueKey`, -`setMethod`, `setUserData(JsonNode)` and matching getters cover the rest (unset fields are omitted on -the wire). +(`uniqueKey` is the deduplication key); fluent setters and matching getters cover the rest (unset +fields are omitted on the wire): `setId`, `setUrl`, `setUniqueKey`, `setMethod`, +`setUserData(JsonNode)`, `setPayload(String)` (the HTTP request body), `setHeaders(Map)`, `setNoRetry(Boolean)`, `setHandledAt(Instant)`, `setRetryCount(Integer)`, +`setLoadedUrl(String)` (the URL actually loaded, after redirects), and +`setErrorMessages(List)`. Return types: - `RequestQueueOperationInfo` (from `addRequest`/`updateRequest`): `getRequestId()`, diff --git a/docs/tasks.md b/docs/tasks.md index 9fe6f19..56aa635 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,9 +1,5 @@ # 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)`. diff --git a/docs/webhooks.md b/docs/webhooks.md index 44236e3..598746b 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,9 +1,5 @@ # 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()` / diff --git a/pom.xml b/pom.xml index 1402cf9..bd3f1d3 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,11 @@ 1 true + + false diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 3fd5836..d1c727a 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -39,7 +39,7 @@ - + diff --git a/src/main/java/com/apify/client/ApifyClient.java b/src/main/java/com/apify/client/ApifyClient.java index 648c5fa..e788682 100644 --- a/src/main/java/com/apify/client/ApifyClient.java +++ b/src/main/java/com/apify/client/ApifyClient.java @@ -7,15 +7,18 @@ import com.apify.client.dataset.DatasetClient; import com.apify.client.dataset.DatasetCollectionClient; import com.apify.client.http.DefaultHttpTransport; -import com.apify.client.http.HttpClientCore; import com.apify.client.http.HttpTransport; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; import com.apify.client.keyvalue.KeyValueStoreClient; import com.apify.client.keyvalue.KeyValueStoreCollectionClient; import com.apify.client.log.LogClient; import com.apify.client.requestqueue.RequestQueueClient; import com.apify.client.requestqueue.RequestQueueCollectionClient; +import com.apify.client.run.ActorRun; import com.apify.client.run.RunClient; import com.apify.client.run.RunCollectionClient; +import com.apify.client.run.SetStatusMessageOptions; import com.apify.client.schedule.ScheduleClient; import com.apify.client.schedule.ScheduleCollectionClient; import com.apify.client.store.StoreCollectionClient; @@ -26,6 +29,8 @@ import com.apify.client.webhook.WebhookCollectionClient; import com.apify.client.webhook.WebhookDispatchClient; import com.apify.client.webhook.WebhookDispatchCollectionClient; +import java.util.LinkedHashMap; +import java.util.Map; /** * The entry point for interacting with the Apify API. @@ -59,8 +64,8 @@ */ public final class ApifyClient { - /** Addresses the current user ({@code /users/me}). */ - private static final String ME_USER_PLACEHOLDER = "me"; + /** Environment variable holding the run id of the Actor run the client is executing in. */ + private static final String ACTOR_RUN_ID_ENV_VAR = "ACTOR_RUN_ID"; private final HttpClientCore http; private final String baseUrl; @@ -231,11 +236,32 @@ public LogClient log(String buildOrRunId) { /** A client for the current user ({@code /users/me}). */ public UserClient me() { - return new UserClient(http, baseUrl, ME_USER_PLACEHOLDER); + return new UserClient(http, baseUrl, UserClient.ME); } /** A client for a specific user by ID or username. */ public UserClient user(String id) { return new UserClient(http, baseUrl, id); } + + /** + * Sets the status message of the current Actor run. + * + *

This convenience method updates the run identified by the {@code ACTOR_RUN_ID} environment + * variable, so it only works when called from inside an Actor run. Throws {@link + * IllegalStateException} if {@code ACTOR_RUN_ID} is not set. Matches the reference client's + * top-level {@code setStatusMessage}. + */ + public ActorRun setStatusMessage(String message, SetStatusMessageOptions options) { + String runId = System.getenv(ACTOR_RUN_ID_ENV_VAR); + if (runId == null || runId.isEmpty()) { + throw new IllegalStateException(ACTOR_RUN_ID_ENV_VAR + " environment variable is not set"); + } + Map body = new LinkedHashMap<>(); + body.put("statusMessage", message); + if (options != null && options.isStatusMessageTerminal() != null) { + body.put("isStatusMessageTerminal", options.isStatusMessageTerminal()); + } + return run(runId).update(body); + } } diff --git a/src/main/java/com/apify/client/ApifyClientBuilder.java b/src/main/java/com/apify/client/ApifyClientBuilder.java index c59efcb..96e5cc8 100644 --- a/src/main/java/com/apify/client/ApifyClientBuilder.java +++ b/src/main/java/com/apify/client/ApifyClientBuilder.java @@ -1,9 +1,9 @@ package com.apify.client; import com.apify.client.http.DefaultHttpTransport; -import com.apify.client.http.HttpClientCore; import com.apify.client.http.HttpTransport; import com.apify.client.http.RetryConfig; +import com.apify.client.internal.HttpClientCore; import java.time.Duration; import java.util.function.BooleanSupplier; @@ -44,7 +44,7 @@ public ApifyClientBuilder token(String token) { /** Overrides the base URL of the API. The {@code /v2} suffix is appended automatically. */ public ApifyClientBuilder baseUrl(String baseUrl) { - this.baseUrl = baseUrl; + this.baseUrl = requireNonBlank(baseUrl, "baseUrl"); return this; } @@ -54,31 +54,38 @@ public ApifyClientBuilder baseUrl(String baseUrl) { * automatically. */ public ApifyClientBuilder publicBaseUrl(String publicBaseUrl) { - this.publicBaseUrl = publicBaseUrl; + this.publicBaseUrl = requireNonBlank(publicBaseUrl, "publicBaseUrl"); return this; } /** Sets the maximum number of retries for failed requests (default 8). */ public ApifyClientBuilder maxRetries(int maxRetries) { - this.maxRetries = maxRetries; + this.maxRetries = requireNonNegative(maxRetries, "maxRetries"); return this; } /** Sets the minimum delay between retries (default 500ms). */ public ApifyClientBuilder minDelayBetweenRetries(Duration minDelayBetweenRetries) { - this.minDelayBetweenRetries = minDelayBetweenRetries; + this.minDelayBetweenRetries = + requireNonNegative(minDelayBetweenRetries, "minDelayBetweenRetries"); return this; } /** Sets the maximum (exponentially-grown) delay between retries (default equals the timeout). */ public ApifyClientBuilder maxDelayBetweenRetries(Duration maxDelayBetweenRetries) { - this.maxDelayBetweenRetries = maxDelayBetweenRetries; + this.maxDelayBetweenRetries = + requireNonNegative(maxDelayBetweenRetries, "maxDelayBetweenRetries"); return this; } - /** Sets the overall per-request timeout (default 360s). */ + /** + * Sets the maximum per-attempt request (socket) timeout (default 360s): the ceiling each retry + * attempt's timeout grows toward, not a wall-clock bound on the cumulative time across retries. + * The connection-establishment timeout is separate and configured on the {@link HttpTransport} + * (see {@link DefaultHttpTransport#DefaultHttpTransport(Duration)}). + */ public ApifyClientBuilder timeout(Duration timeout) { - this.timeout = timeout; + this.timeout = requireNonNegative(timeout, "timeout"); return this; } @@ -88,38 +95,72 @@ public ApifyClientBuilder userAgentSuffix(String userAgentSuffix) { return this; } - /** Replaces the default HTTP backend with a custom implementation (the replaceable transport). */ + /** Replaces the default HTTP transport with a custom implementation. */ public ApifyClientBuilder httpTransport(HttpTransport httpTransport) { + if (httpTransport == null) { + throw new IllegalArgumentException("httpTransport must not be null"); + } this.httpTransport = httpTransport; return this; } + /** Validates a required, non-blank string argument, returning it unchanged. */ + private static String requireNonBlank(String value, String argName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(argName + " must not be null or blank"); + } + return value; + } + + /** Validates a non-negative {@code int} argument, returning it unchanged. */ + private static int requireNonNegative(int value, String argName) { + if (value < 0) { + throw new IllegalArgumentException(argName + " must not be negative"); + } + return value; + } + + /** Validates a non-null, non-negative {@link Duration} argument, returning it unchanged. */ + private static Duration requireNonNegative(Duration value, String argName) { + if (value == null || value.isNegative()) { + throw new IllegalArgumentException(argName + " must not be null or negative"); + } + return value; + } + /** Test seam: overrides how the client decides the {@code isAtHome} User-Agent flag. */ ApifyClientBuilder isAtHomeFn(BooleanSupplier isAtHomeFn) { this.isAtHomeFn = isAtHomeFn; return this; } + /** The API path suffix every base URL is normalized to end with. */ + private static final String API_VERSION_PATH = "/v2"; + /** Builds the configured {@link ApifyClient}. */ public ApifyClient build() { - HttpTransport backend = httpTransport != null ? httpTransport : new DefaultHttpTransport(); + HttpTransport transport = httpTransport != null ? httpTransport : new DefaultHttpTransport(); String userAgent = buildUserAgent(userAgentSuffix, isAtHomeFn); RetryConfig retry = new RetryConfig(maxRetries, minDelayBetweenRetries, maxDelayBetweenRetries, timeout); - HttpClientCore http = new HttpClientCore(backend, token, userAgent, retry); + HttpClientCore http = new HttpClientCore(transport, token, userAgent, retry); - String apiBase = trimTrailingSlash(baseUrl) + "/v2"; - String publicSource = publicBaseUrl != null ? publicBaseUrl : baseUrl; - String publicBase = trimTrailingSlash(publicSource) + "/v2"; + String apiBase = normalizeApiUrl(baseUrl); + String publicBase = normalizeApiUrl(publicBaseUrl != null ? publicBaseUrl : baseUrl); return new ApifyClient(http, apiBase, publicBase); } + /** Strips any trailing slashes and appends {@link #API_VERSION_PATH}. */ + private static String normalizeApiUrl(String url) { + return trimTrailingSlash(url) + API_VERSION_PATH; + } + private static String trimTrailingSlash(String s) { - int end = s.length(); - while (end > 0 && s.charAt(end - 1) == '/') { - end--; + String result = s; + while (result.endsWith("/")) { + result = result.substring(0, result.length() - 1); } - return s.substring(0, end); + return result; } /** diff --git a/src/main/java/com/apify/client/ListOptions.java b/src/main/java/com/apify/client/ListOptions.java index 8d06209..74d185a 100644 --- a/src/main/java/com/apify/client/ListOptions.java +++ b/src/main/java/com/apify/client/ListOptions.java @@ -1,5 +1,7 @@ package com.apify.client; +import com.apify.client.internal.QueryParams; + /** * The standard offset/limit pagination shared by most {@code list} endpoints (builds, runs, tasks, * schedules, webhooks, Actor versions). All fields are optional; leave one unset to use the API diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java index 1cdd4c2..a5440d3 100644 --- a/src/main/java/com/apify/client/PaginationList.java +++ b/src/main/java/com/apify/client/PaginationList.java @@ -52,10 +52,11 @@ public boolean isDesc() { /** The items of this page (never {@code null}; unmodifiable). */ public List getItems() { - // items is already an immutable List.copyOf() copy (see setItems); wrapping it again is a - // no-op at runtime but satisfies static analysis that the getter cannot expose a mutable - // reference to the backing list, without weakening the real defense (the copy in setItems). - return Collections.unmodifiableList(items); + // Null-coalesce: Jackson binds directly to the (private) `items` field for deserialization + // (see Json's FIELD/ANY visibility config), which bypasses setItems()'s List.copyOf() default + // whenever the API response contains an explicit `"items": null`. Falling back to List.of() + // here keeps the "never null" contract regardless of how the field was populated. + return items == null ? List.of() : Collections.unmodifiableList(items); } // Setters used by the collection-listing and dataset-items paths, which build a page from a diff --git a/src/main/java/com/apify/client/StorageListOptions.java b/src/main/java/com/apify/client/StorageListOptions.java index bfe892b..09112e8 100644 --- a/src/main/java/com/apify/client/StorageListOptions.java +++ b/src/main/java/com/apify/client/StorageListOptions.java @@ -1,5 +1,7 @@ package com.apify.client; +import com.apify.client.internal.QueryParams; + /** * Options for the storage collection list endpoints ({@code GET /v2/datasets}, {@code * /v2/key-value-stores}, {@code /v2/request-queues}), which add {@code unnamed} and {@code diff --git a/src/main/java/com/apify/client/actor/ActorBuildOptions.java b/src/main/java/com/apify/client/actor/ActorBuildOptions.java index 8bb1b4c..1121425 100644 --- a/src/main/java/com/apify/client/actor/ActorBuildOptions.java +++ b/src/main/java/com/apify/client/actor/ActorBuildOptions.java @@ -1,6 +1,6 @@ package com.apify.client.actor; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Configures {@link ActorClient#build(String, ActorBuildOptions)}. */ public final class ActorBuildOptions { diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index 6d117ba..1c8f9b9 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -1,13 +1,14 @@ package com.apify.client.actor; import com.apify.client.ApifyClient; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.build.Build; import com.apify.client.build.BuildClient; import com.apify.client.build.BuildCollectionClient; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -33,7 +34,7 @@ public final class ActorClient { public ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { this.root = root; this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, "actors", id); + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.ACTORS, id); this.baseUrl = baseUrl; this.id = id; } @@ -137,9 +138,7 @@ public RunClient lastRun(String status) { * Returns a client for the last run of this Actor, optionally filtered by status and/or origin. */ public RunClient lastRun(LastRunOptions options) { - RunClient client = new RunClient(root, http, ctx.subUrl(""), "runs", "last"); - client.setLastRunParams(options); - return client; + return RunClient.lastRun(root, http, ctx.subUrl(""), options); } /** A client for this Actor's build collection. */ diff --git a/src/main/java/com/apify/client/actor/ActorCollectionClient.java b/src/main/java/com/apify/client/actor/ActorCollectionClient.java index 5979310..a0fbda2 100644 --- a/src/main/java/com/apify/client/actor/ActorCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorCollectionClient.java @@ -1,10 +1,10 @@ package com.apify.client.actor; -import com.apify.client.ApiPaths; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the Actor collection ({@code GET/POST /v2/actors}). */ diff --git a/src/main/java/com/apify/client/actor/ActorEnvVarClient.java b/src/main/java/com/apify/client/actor/ActorEnvVarClient.java index 398e551..745661d 100644 --- a/src/main/java/com/apify/client/actor/ActorEnvVarClient.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVarClient.java @@ -1,8 +1,8 @@ package com.apify.client.actor; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Optional; /** diff --git a/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java index 44a33e1..871cf19 100644 --- a/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java @@ -1,9 +1,9 @@ package com.apify.client.actor; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** diff --git a/src/main/java/com/apify/client/actor/ActorListOptions.java b/src/main/java/com/apify/client/actor/ActorListOptions.java index a111cc8..86b0e38 100644 --- a/src/main/java/com/apify/client/actor/ActorListOptions.java +++ b/src/main/java/com/apify/client/actor/ActorListOptions.java @@ -1,6 +1,6 @@ package com.apify.client.actor; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Options for {@link ActorCollectionClient#list(ActorListOptions)}. */ public final class ActorListOptions { diff --git a/src/main/java/com/apify/client/actor/ActorStartOptions.java b/src/main/java/com/apify/client/actor/ActorStartOptions.java index 0e26f11..0692f42 100644 --- a/src/main/java/com/apify/client/actor/ActorStartOptions.java +++ b/src/main/java/com/apify/client/actor/ActorStartOptions.java @@ -1,8 +1,8 @@ package com.apify.client.actor; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.Json; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Base64; import java.util.List; diff --git a/src/main/java/com/apify/client/actor/ActorVersionClient.java b/src/main/java/com/apify/client/actor/ActorVersionClient.java index 7a4ffca..0d0c60b 100644 --- a/src/main/java/com/apify/client/actor/ActorVersionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionClient.java @@ -1,8 +1,8 @@ package com.apify.client.actor; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Optional; /** diff --git a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java index fa59143..6154fb5 100644 --- a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java @@ -2,9 +2,9 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; import java.util.List; @@ -16,16 +16,23 @@ public final class ActorVersionCollectionClient { this.ctx = ResourceContext.collection(http, actorUrl, "versions"); } - /** Lists the Actor's versions. */ + /** + * Lists the Actor's versions. {@code options} is accepted only for signature consistency with + * every other collection's {@code list(ListOptions)} (matching the reference client, whose {@code + * list()} likewise accepts but never forwards an options argument here) — {@code GET + * .../versions} takes no query parameters at all, so nothing in {@code options} (including {@code + * offset}/{@code limit}/{@code desc}) is sent to the server. Pass {@code null} for the common + * case; use {@link #iterate(ListOptions)}'s {@code limit} to cap the number returned client-side. + */ public PaginationList list(ListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, ActorVersion.class); + return ctx.listResource("", new QueryParams(), 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). + * fetches eagerly: the single request runs when {@code iterate} is called, not on first {@code + * next()}. The full explanation is why {@code GET .../versions} cannot use the offset/limit + * paging engine at all (next paragraph). * *

{@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}} diff --git a/src/main/java/com/apify/client/build/Build.java b/src/main/java/com/apify/client/build/Build.java index e35419b..484ff24 100644 --- a/src/main/java/com/apify/client/build/Build.java +++ b/src/main/java/com/apify/client/build/Build.java @@ -1,7 +1,7 @@ package com.apify.client.build; import com.apify.client.ApifyResource; -import com.apify.client.Statuses; +import com.apify.client.internal.Statuses; import java.time.Instant; /** A single build of an Actor. */ diff --git a/src/main/java/com/apify/client/build/BuildClient.java b/src/main/java/com/apify/client/build/BuildClient.java index a1c700b..915f719 100644 --- a/src/main/java/com/apify/client/build/BuildClient.java +++ b/src/main/java/com/apify/client/build/BuildClient.java @@ -1,11 +1,11 @@ package com.apify.client.build; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.apify.client.log.LogClient; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; diff --git a/src/main/java/com/apify/client/build/BuildCollectionClient.java b/src/main/java/com/apify/client/build/BuildCollectionClient.java index a82bb26..96b164c 100644 --- a/src/main/java/com/apify/client/build/BuildCollectionClient.java +++ b/src/main/java/com/apify/client/build/BuildCollectionClient.java @@ -2,9 +2,9 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index 4b0bd19..c1606b2 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -1,14 +1,15 @@ package com.apify.client.dataset; -import com.apify.client.ApiPaths; -import com.apify.client.PaginatedIterator; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.Signatures; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.Extras; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.PaginatedIterator; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; +import com.apify.client.internal.Signatures; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; import java.util.Iterator; @@ -212,7 +213,7 @@ public String createItemsPublicUrl(DatasetListItemsOptions options, Long expires options.apply(params); Optional dataset = get(); if (dataset.isPresent()) { - String secret = extractString(dataset.get().getExtra(), "urlSigningSecretKey"); + String secret = Extras.extractString(dataset.get().getExtra(), "urlSigningSecretKey"); if (secret != null) { String sig = Signatures.signStorageContent(secret, dataset.get().getId(), expiresInSecs); params.addString("signature", sig); @@ -228,10 +229,4 @@ public String createItemsPublicUrl(DatasetListItemsOptions options, Long expires private static long headerLong(ApiResponse resp, String name, long fallback) { return resp.headers.firstValueAsLong(name).orElse(fallback); } - - /** Reads a string field from an extra map, returning {@code null} if absent or not a string. */ - public static String extractString(java.util.Map extra, String key) { - Object v = extra.get(key); - return (v instanceof String) ? (String) v : null; - } } diff --git a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java index 4a203dc..000b47b 100644 --- a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.dataset; -import com.apify.client.ApiPaths; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.StorageListOptions; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the dataset collection ({@code GET/POST /v2/datasets}). */ diff --git a/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java b/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java index 0dcede9..a4c0512 100644 --- a/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java +++ b/src/main/java/com/apify/client/dataset/DatasetDownloadOptions.java @@ -1,6 +1,6 @@ package com.apify.client.dataset; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** * Adds format-specific options for {@link DatasetClient#downloadItems} on top of the shared item diff --git a/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java index 41ae2b4..6960086 100644 --- a/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java +++ b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java @@ -1,6 +1,6 @@ package com.apify.client.dataset; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; import java.util.List; /** diff --git a/src/main/java/com/apify/client/http/ApiResponse.java b/src/main/java/com/apify/client/http/ApiResponse.java index acec7bc..7cdc706 100644 --- a/src/main/java/com/apify/client/http/ApiResponse.java +++ b/src/main/java/com/apify/client/http/ApiResponse.java @@ -7,11 +7,15 @@ * body. Internal to the client. */ public final class ApiResponse { - final int statusCode; + public final int statusCode; public final HttpHeaders headers; public final byte[] body; - ApiResponse(int statusCode, HttpHeaders headers, byte[] body) { + /** + * Public so the internal HTTP client (in a separate, non-exported package) can build one; end + * users are not expected to construct this directly. + */ + public ApiResponse(int statusCode, HttpHeaders headers, byte[] body) { this.statusCode = statusCode; this.headers = headers; this.body = body; diff --git a/src/main/java/com/apify/client/http/ApifyApiException.java b/src/main/java/com/apify/client/http/ApifyApiException.java index d4e5d61..0efafbd 100644 --- a/src/main/java/com/apify/client/http/ApifyApiException.java +++ b/src/main/java/com/apify/client/http/ApifyApiException.java @@ -1,6 +1,7 @@ package com.apify.client.http; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -14,7 +15,7 @@ *

It is an unchecked exception so callers are not forced to wrap every call; recover from it * with a normal {@code try}/{@code catch} where relevant. */ -public class ApifyApiException extends RuntimeException { +public class ApifyApiException extends ApifyClientException { private static final long serialVersionUID = 1L; @@ -25,7 +26,12 @@ public class ApifyApiException extends RuntimeException { private final String path; private final transient Map data; - ApifyApiException( + /** + * Constructs an exception describing an API error response. Public so the internal HTTP client + * (in a separate, non-exported package) can build one; end users are not expected to construct + * this directly. + */ + public ApifyApiException( int statusCode, String type, String message, @@ -39,7 +45,10 @@ public class ApifyApiException extends RuntimeException { this.attempt = attempt; this.httpMethod = httpMethod; this.path = path; - this.data = data; + // Defensively copy: this constructor is public (built by the internal HTTP client, in a + // separate package, but nothing stops an external caller from building one too), so the + // caller's map must not remain a live, externally-mutable alias of this exception's state. + this.data = data == null ? null : new LinkedHashMap<>(data); } /** The HTTP status code of the error response. */ diff --git a/src/main/java/com/apify/client/http/ApifyTransportException.java b/src/main/java/com/apify/client/http/ApifyTransportException.java index a0d6e3e..c5fca10 100644 --- a/src/main/java/com/apify/client/http/ApifyTransportException.java +++ b/src/main/java/com/apify/client/http/ApifyTransportException.java @@ -21,8 +21,9 @@ public ApifyTransportException(Throwable cause) { /** * Reports whether this transport failure was caused by the request timing out, as opposed to some - * other network failure. Backend-agnostic: it recognizes {@link HttpTimeoutException}, the {@link - * HttpTransport} contract's own timeout signal, not any specific backend's exception type. + * other network failure. Transport-implementation-agnostic: it recognizes {@link + * HttpTimeoutException}, the {@link HttpTransport} contract's own timeout signal, not any + * specific transport implementation's exception type. */ public boolean isTimeout() { return getCause() instanceof HttpTimeoutException; diff --git a/src/main/java/com/apify/client/http/DefaultHttpTransport.java b/src/main/java/com/apify/client/http/DefaultHttpTransport.java index 7bf7eff..ff076a5 100644 --- a/src/main/java/com/apify/client/http/DefaultHttpTransport.java +++ b/src/main/java/com/apify/client/http/DefaultHttpTransport.java @@ -11,7 +11,7 @@ * The default {@link HttpTransport}, backed by the JDK's {@link java.net.http.HttpClient}. * *

The per-attempt timeout is applied to each {@link HttpRequest} by the orchestrating client, so - * this backend sets only a connection timeout of its own. It follows normal redirects and reuses + * this transport sets only a connection timeout of its own. It follows normal redirects and reuses * connections from the underlying client's pool. */ public final class DefaultHttpTransport implements HttpTransport { @@ -26,19 +26,18 @@ public final class DefaultHttpTransport implements HttpTransport { private final HttpClient client; - /** Creates a backend with the {@link #DEFAULT_CONNECT_TIMEOUT}. */ + /** Creates a transport with the {@link #DEFAULT_CONNECT_TIMEOUT}. */ public DefaultHttpTransport() { this(DEFAULT_CONNECT_TIMEOUT); } - /** Creates a backend with an explicit connection-establishment timeout. */ + /** Creates a transport with an explicit connection-establishment timeout. */ public DefaultHttpTransport(Duration connectTimeout) { // Follow redirects (NORMAL, matching the reference clients): some endpoints — e.g. a // non-attachment key-value-store record GET — answer with a 302 to external storage, which the // JDK's default Redirect.NEVER would otherwise surface as an error. NORMAL does not follow an - // HTTPS->HTTP downgrade and the JDK strips the Authorization header on cross-origin hops, so - // the - // bearer token is not leaked to the redirect target. + // HTTPS->HTTP downgrade, and the JDK strips the Authorization header on cross-origin hops, so + // the bearer token is not leaked to the redirect target. this( HttpClient.newBuilder() .connectTimeout(connectTimeout) diff --git a/src/main/java/com/apify/client/http/HttpTimeoutException.java b/src/main/java/com/apify/client/http/HttpTimeoutException.java index 715d5fb..e446d79 100644 --- a/src/main/java/com/apify/client/http/HttpTimeoutException.java +++ b/src/main/java/com/apify/client/http/HttpTimeoutException.java @@ -6,11 +6,11 @@ * Signals that an {@link HttpTransport} gave up on a request because it exceeded its timeout, as * opposed to some other transport failure (connection refused, DNS, ...). * - *

Part of the {@link HttpTransport} contract, not tied to any specific backend implementation: + *

Part of the {@link HttpTransport} contract, not tied to any specific transport implementation: * an implementation that can distinguish "timed out" from other I/O failures should throw this * (wrapping or in place of the underlying exception) so the client can apply {@code - * doNotRetryTimeouts} correctly. A backend that cannot tell the two apart may throw a plain {@link - * IOException} instead; the call is then simply always eligible for a retry. + * doNotRetryTimeouts} correctly. A transport implementation that cannot tell the two apart may + * throw a plain {@link IOException} instead; the call is then simply always eligible for a retry. */ public class HttpTimeoutException extends IOException { diff --git a/src/main/java/com/apify/client/http/HttpTransport.java b/src/main/java/com/apify/client/http/HttpTransport.java index fa29b6a..2ece3d3 100644 --- a/src/main/java/com/apify/client/http/HttpTransport.java +++ b/src/main/java/com/apify/client/http/HttpTransport.java @@ -11,8 +11,8 @@ * *

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

A non-2xx HTTP status is not an error at this layer — return it as a normal {@link * HttpResponse}. Only transport-level failures (connection refused, DNS, timeout) should be thrown. diff --git a/src/main/java/com/apify/client/http/RetryConfig.java b/src/main/java/com/apify/client/http/RetryConfig.java index bf350fb..4472593 100644 --- a/src/main/java/com/apify/client/http/RetryConfig.java +++ b/src/main/java/com/apify/client/http/RetryConfig.java @@ -4,21 +4,25 @@ /** Retry/timeout policy for the orchestrating HTTP client. Internal to the client. */ public final class RetryConfig { - /** Maximum number of retries (the request is attempted up to {@code maxRetries + 1} times). */ - final int maxRetries; + /** + * Maximum number of retries (the request is attempted up to {@code maxRetries + 1} times). Public + * (not just within this package) so the internal HTTP client, which lives in a separate, + * non-exported package, can read the policy it applies. + */ + public final int maxRetries; /** Minimum delay between retries; doubled on each subsequent retry (exponential backoff). */ - final Duration minDelayBetweenRetries; + public final Duration minDelayBetweenRetries; /** Upper bound on the (exponentially growing) inter-retry delay. */ - final Duration maxDelayBetweenRetries; + public final Duration maxDelayBetweenRetries; /** * Maximum per-attempt request (socket) timeout, and the default base timeout for a call. Each * attempt's timeout may grow from a smaller per-call base but is capped at this value. This * bounds a single attempt, not the cumulative time across all retries. */ - final Duration timeout; + public final Duration timeout; public RetryConfig( int maxRetries, diff --git a/src/main/java/com/apify/client/ApiPaths.java b/src/main/java/com/apify/client/internal/ApiPaths.java similarity index 97% rename from src/main/java/com/apify/client/ApiPaths.java rename to src/main/java/com/apify/client/internal/ApiPaths.java index e174131..fbdbcba 100644 --- a/src/main/java/com/apify/client/ApiPaths.java +++ b/src/main/java/com/apify/client/internal/ApiPaths.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; /** * The Apify API's top-level resource-collection path segments (e.g. {@code datasets} in {@code diff --git a/src/main/java/com/apify/client/DataEnvelope.java b/src/main/java/com/apify/client/internal/DataEnvelope.java similarity index 57% rename from src/main/java/com/apify/client/DataEnvelope.java rename to src/main/java/com/apify/client/internal/DataEnvelope.java index 34200fd..71a2f19 100644 --- a/src/main/java/com/apify/client/DataEnvelope.java +++ b/src/main/java/com/apify/client/internal/DataEnvelope.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; /** * Unwraps the top-level {@code {"data": ...}} wrapper used by most Apify endpoints. Internal. @@ -6,5 +6,10 @@ * @param the type of the wrapped payload */ public final class DataEnvelope { - public T data; + private T data; + + /** The unwrapped {@code data} payload. */ + public T getData() { + return data; + } } diff --git a/src/main/java/com/apify/client/internal/Extras.java b/src/main/java/com/apify/client/internal/Extras.java new file mode 100644 index 0000000..949c9b2 --- /dev/null +++ b/src/main/java/com/apify/client/internal/Extras.java @@ -0,0 +1,19 @@ +package com.apify.client.internal; + +import java.util.Map; + +/** + * Helpers for reading typed values out of a model's {@code extra} map ({@link + * com.apify.client.ApifyResource#getExtra()}). Shared by every resource client that needs a field + * the model does not (yet) expose as a typed getter (DRY). + */ +public final class Extras { + + private Extras() {} + + /** Reads a string field from an extra map, returning {@code null} if absent or not a string. */ + public static String extractString(Map extra, String key) { + Object v = extra.get(key); + return (v instanceof String) ? (String) v : null; + } +} diff --git a/src/main/java/com/apify/client/http/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java similarity index 96% rename from src/main/java/com/apify/client/http/HttpClientCore.java rename to src/main/java/com/apify/client/internal/HttpClientCore.java index f64aba7..6b0a9b8 100644 --- a/src/main/java/com/apify/client/http/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -1,7 +1,12 @@ -package com.apify.client.http; +package com.apify.client.internal; import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.encoder.Encoder; +import com.apify.client.http.ApiResponse; +import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.HttpTransport; +import com.apify.client.http.RetryConfig; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -70,7 +75,11 @@ private static boolean detectBrotli() { } } - /** Reports whether the brotli path is active on this platform (package-private for tests). */ + /** + * Reports whether the brotli path is active on this platform. Public so tests outside this + * package (which lives in the non-exported {@code com.apify.client.internal} package) can assert + * on it. + */ public static boolean brotliAvailable() { return BROTLI_AVAILABLE; } @@ -91,10 +100,6 @@ public String userAgent() { return userAgent; } - HttpTransport backend() { - return backend; - } - /** The configured maximum per-attempt request timeout, in whole seconds. */ public long requestTimeoutSeconds() { return retry.timeout.getSeconds(); @@ -307,7 +312,8 @@ public static final class Compressed { * JS client. When {@code preferBrotli} is {@code true} the body is brotli-encoded ({@code * Content-Encoding: br}); otherwise it is gzip-encoded ({@code Content-Encoding: gzip}). Callers * pass {@link #BROTLI_AVAILABLE}; making the coding an explicit parameter keeps this a pure - * function of its inputs rather than of hidden static state. Package-private. + * function of its inputs rather than of hidden static state. Public so {@code CompressionTest} + * (outside this non-exported package) can exercise it directly. */ public static Compressed compress(byte[] data, boolean preferBrotli) { return preferBrotli diff --git a/src/main/java/com/apify/client/http/Json.java b/src/main/java/com/apify/client/internal/Json.java similarity index 97% rename from src/main/java/com/apify/client/http/Json.java rename to src/main/java/com/apify/client/internal/Json.java index 0c1c4ef..bf0aecf 100644 --- a/src/main/java/com/apify/client/http/Json.java +++ b/src/main/java/com/apify/client/internal/Json.java @@ -1,6 +1,5 @@ -package com.apify.client.http; +package com.apify.client.internal; -import com.apify.client.DataEnvelope; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.PropertyAccessor; @@ -106,7 +105,7 @@ public static JavaType parametric(Class raw, JavaType... params) { public static T parseData(byte[] body, JavaType dataType) { JavaType envelopeType = parametric(DataEnvelope.class, dataType); DataEnvelope envelope = parse(body, envelopeType); - return envelope.data; + return envelope.getData(); } /** Parses a data-envelope whose {@code data} is of the given class. */ diff --git a/src/main/java/com/apify/client/PaginatedIterator.java b/src/main/java/com/apify/client/internal/PaginatedIterator.java similarity index 98% rename from src/main/java/com/apify/client/PaginatedIterator.java rename to src/main/java/com/apify/client/internal/PaginatedIterator.java index b04b229..f9af796 100644 --- a/src/main/java/com/apify/client/PaginatedIterator.java +++ b/src/main/java/com/apify/client/internal/PaginatedIterator.java @@ -1,5 +1,6 @@ -package com.apify.client; +package com.apify.client.internal; +import com.apify.client.PaginationList; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; diff --git a/src/main/java/com/apify/client/QueryParams.java b/src/main/java/com/apify/client/internal/QueryParams.java similarity index 98% rename from src/main/java/com/apify/client/QueryParams.java rename to src/main/java/com/apify/client/internal/QueryParams.java index e2c5226..9e78c5f 100644 --- a/src/main/java/com/apify/client/QueryParams.java +++ b/src/main/java/com/apify/client/internal/QueryParams.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; diff --git a/src/main/java/com/apify/client/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java similarity index 94% rename from src/main/java/com/apify/client/ResourceContext.java rename to src/main/java/com/apify/client/internal/ResourceContext.java index 95fc901..7767780 100644 --- a/src/main/java/com/apify/client/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -1,10 +1,9 @@ -package com.apify.client; +package com.apify.client.internal; +import com.apify.client.PaginationList; import com.apify.client.http.ApiResponse; import com.apify.client.http.ApifyApiException; import com.apify.client.http.ApifyTransportException; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; import com.fasterxml.jackson.databind.JavaType; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -273,6 +272,26 @@ public T postWithBodyNoEnvelope( return Json.parse(resp.body, dataClass); } + /** PUT with a raw body (optional) and content type, unwrapping the data envelope. */ + public T putWithBody( + String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = http.call("PUT", u, body, contentType, http.baseRequestTimeout()); + return Json.parseData(resp.body, dataClass); + } + + /** + * PUT with a raw body and content type, parsing the response body directly without + * unwrapping a {@code {"data": ...}} envelope. Used by endpoints (e.g. actor task input) whose + * response is a plain object rather than the standard data envelope. + */ + public T putWithBodyNoEnvelope( + String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String u = mergedParams(params).applyToUrl(subUrl(subPath)); + ApiResponse resp = http.call("PUT", u, body, contentType, http.baseRequestTimeout()); + return Json.parse(resp.body, dataClass); + } + /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ public T deleteWithBody(String subPath, QueryParams params, Object body, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); diff --git a/src/main/java/com/apify/client/Signatures.java b/src/main/java/com/apify/client/internal/Signatures.java similarity index 99% rename from src/main/java/com/apify/client/Signatures.java rename to src/main/java/com/apify/client/internal/Signatures.java index 409f5c2..2d294fc 100644 --- a/src/main/java/com/apify/client/Signatures.java +++ b/src/main/java/com/apify/client/internal/Signatures.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; import java.math.BigInteger; import java.nio.charset.StandardCharsets; diff --git a/src/main/java/com/apify/client/Statuses.java b/src/main/java/com/apify/client/internal/Statuses.java similarity index 93% rename from src/main/java/com/apify/client/Statuses.java rename to src/main/java/com/apify/client/internal/Statuses.java index d961647..cded735 100644 --- a/src/main/java/com/apify/client/Statuses.java +++ b/src/main/java/com/apify/client/internal/Statuses.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; import java.util.Set; diff --git a/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java b/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java index ab37954..7e9586b 100644 --- a/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java +++ b/src/main/java/com/apify/client/keyvalue/GetRecordOptions.java @@ -1,6 +1,6 @@ package com.apify.client.keyvalue; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Configures {@link KeyValueStoreClient#getRecord(String, GetRecordOptions)}. */ public final class GetRecordOptions { diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index 8a80462..cc410cf 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -1,13 +1,14 @@ package com.apify.client.keyvalue; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.Signatures; -import com.apify.client.dataset.DatasetClient; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.Extras; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; +import com.apify.client.internal.Signatures; +import java.time.Duration; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; @@ -204,9 +205,9 @@ public void setRecord(String key, byte[] value, String contentType) { * ({@code timeoutSecs}, {@code doNotRetryTimeouts}). */ public void setRecord(String key, byte[] value, String contentType, SetRecordOptions options) { - java.time.Duration timeout = + Duration timeout = options.timeoutSecsValue() != null - ? java.time.Duration.ofSeconds(options.timeoutSecsValue()) + ? Duration.ofSeconds(options.timeoutSecsValue()) : ctx.http.baseRequestTimeout(); ctx.putRaw( "records/" + ResourceContext.encodePathSegment(key), @@ -236,7 +237,7 @@ public String getRecordPublicUrl(String key) { QueryParams params = new QueryParams(); Optional store = get(); if (store.isPresent()) { - String secret = DatasetClient.extractString(store.get().getExtra(), "urlSigningSecretKey"); + String secret = Extras.extractString(store.get().getExtra(), "urlSigningSecretKey"); if (secret != null) { params.addString("signature", Signatures.createHmacSignature(secret, key)); } @@ -269,7 +270,7 @@ public String createKeysPublicUrl(ListKeysOptions options, Long expiresInSecs) { if (options.signatureValue() == null) { Optional store = get(); if (store.isPresent()) { - String secret = DatasetClient.extractString(store.get().getExtra(), "urlSigningSecretKey"); + String secret = Extras.extractString(store.get().getExtra(), "urlSigningSecretKey"); if (secret != null) { params.addString( "signature", diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java index 747a6a1..55a28c1 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.keyvalue; -import com.apify.client.ApiPaths; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.StorageListOptions; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the key-value store collection ({@code GET/POST /v2/key-value-stores}). */ diff --git a/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java b/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java index 18d2dfe..550f64f 100644 --- a/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java +++ b/src/main/java/com/apify/client/keyvalue/ListKeysOptions.java @@ -1,6 +1,6 @@ package com.apify.client.keyvalue; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Configures {@link KeyValueStoreClient#listKeys(ListKeysOptions)}. */ public final class ListKeysOptions { diff --git a/src/main/java/com/apify/client/log/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java index a5448d1..7ceb812 100644 --- a/src/main/java/com/apify/client/log/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -1,10 +1,10 @@ package com.apify.client.log; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.io.IOException; import java.io.InputStream; import java.net.http.HttpResponse; diff --git a/src/main/java/com/apify/client/log/LogOptions.java b/src/main/java/com/apify/client/log/LogOptions.java index 08e1572..1d4a94d 100644 --- a/src/main/java/com/apify/client/log/LogOptions.java +++ b/src/main/java/com/apify/client/log/LogOptions.java @@ -1,6 +1,6 @@ package com.apify.client.log; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Configures log retrieval/streaming. */ public final class LogOptions { diff --git a/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java b/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java index eed9499..fa7dad8 100644 --- a/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java +++ b/src/main/java/com/apify/client/requestqueue/ListRequestsOptions.java @@ -1,6 +1,6 @@ package com.apify.client.requestqueue; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; import java.util.List; /** Configures {@link RequestQueueClient#listRequests(ListRequestsOptions)}. */ diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index 7b65781..9f473eb 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -1,13 +1,12 @@ package com.apify.client.requestqueue; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.ApiResponse; import com.apify.client.http.ApifyApiException; import com.apify.client.http.ApifyTransportException; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; @@ -140,18 +139,12 @@ public RequestQueueOperationInfo updateRequest(RequestQueueRequest request, bool QueryParams params = new QueryParams(); params.addBool("forefront", forefront); withClientKey(params); - String url = - ctx.mergedParams(params) - .applyToUrl( - ctx.subUrl("requests/" + ResourceContext.encodePathSegment(request.getId()))); - ApiResponse resp = - http.call( - "PUT", - url, - Json.toBytes(request), - ResourceContext.CONTENT_TYPE_JSON, - http.baseRequestTimeout()); - return Json.parseData(resp.body, RequestQueueOperationInfo.class); + return ctx.putWithBody( + "requests/" + ResourceContext.encodePathSegment(request.getId()), + params, + Json.toBytes(request), + ResourceContext.CONTENT_TYPE_JSON, + RequestQueueOperationInfo.class); } /** Deletes a request by ID. */ @@ -385,11 +378,12 @@ public JsonNode prolongRequestLock(String id, long lockSecs, boolean forefront) QueryParams params = new QueryParams(); params.addLong("lockSecs", lockSecs).addBool("forefront", forefront); withClientKey(params); - String url = - ctx.mergedParams(params) - .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); - ApiResponse resp = http.call("PUT", url, null, "", http.baseRequestTimeout()); - return Json.parseData(resp.body, Json.type(JsonNode.class)); + return ctx.putWithBody( + "requests/" + ResourceContext.encodePathSegment(id) + "/lock", + params, + null, + "", + JsonNode.class); } /** @@ -420,10 +414,29 @@ public JsonNode unlockRequests() { /** * Returns a lazy iterator over all requests in the queue, fetching pages of up to {@code - * pageLimit} requests at a time ({@code null} for the server default). + * pageLimit} requests at a time ({@code null} for the server default). Equivalent to {@link + * #paginateRequests(Long, Long, List) paginateRequests(null, pageLimit, null)} (no total cap, no + * state filter). */ public Iterator paginateRequests(Long pageLimit) { - return new RequestsIterator(pageLimit); + return paginateRequests(null, pageLimit, null); + } + + /** + * Returns a lazy iterator over the queue's requests via the cursor-based listing endpoint. {@code + * totalLimit} caps the total number of requests yielded across all pages ({@code + * null}/non-positive = unbounded); {@code chunkSize} is the per-request page size ({@code null} = + * server default); {@code filter} restricts to requests in the given states ({@link + * ListRequestsOptions#FILTER_LOCKED}/{@link ListRequestsOptions#FILTER_PENDING}, {@code null} = + * no filter), matching {@link #listRequests(ListRequestsOptions)}'s filter. + * + *

Always starts from the beginning of the queue; resuming from an explicit {@code + * exclusiveStartId}/{@code cursor} is not supported here (use {@link + * #listRequests(ListRequestsOptions)} directly for that single-page use case). + */ + public Iterator paginateRequests( + Long totalLimit, Long chunkSize, List filter) { + return new RequestsIterator(totalLimit, chunkSize, filter); } /** Shape of a paginated requests listing. */ @@ -435,15 +448,20 @@ private static final class RequestsPage { /** Lazily iterates over a request queue's requests via the cursor-based listing endpoint. */ private final class RequestsIterator implements Iterator { - private final Long pageLimit; + private final Long totalLimit; + private final Long chunkSize; + private final List filter; private List buffer = List.of(); private int pos; private String nextCursor; + private long yielded; private boolean started; private boolean exhausted; - RequestsIterator(Long pageLimit) { - this.pageLimit = pageLimit; + RequestsIterator(Long totalLimit, Long chunkSize, List filter) { + this.totalLimit = totalLimit != null && totalLimit > 0 ? totalLimit : null; + this.chunkSize = chunkSize; + this.filter = filter; } @Override @@ -452,6 +470,9 @@ public boolean hasNext() { if (exhausted || (started && (nextCursor == null || nextCursor.isEmpty()))) { return false; } + if (totalLimit != null && yielded >= totalLimit) { + return false; + } fetchPage(); } return true; @@ -462,15 +483,22 @@ public RequestQueueRequest next() { if (!hasNext()) { throw new NoSuchElementException(); } + yielded++; return buffer.get(pos++); } private void fetchPage() { QueryParams params = new QueryParams(); + Long capRemaining = totalLimit != null ? totalLimit - yielded : null; + Long pageLimit = + capRemaining == null + ? chunkSize + : (chunkSize == null ? capRemaining : Math.min(capRemaining, chunkSize)); params.addLong("limit", pageLimit); if (nextCursor != null && !nextCursor.isEmpty()) { params.addString("cursor", nextCursor); } + params.addCsv("filter", filter); withClientKey(params); RequestsPage page = ctx.getResourceRequired("requests", params, RequestsPage.class); started = true; diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java index 95c585a..854e860 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.requestqueue; -import com.apify.client.ApiPaths; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.StorageListOptions; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the request queue collection ({@code GET/POST /v2/request-queues}). */ diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index b944272..39564b2 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -2,6 +2,11 @@ import com.apify.client.ApifyResource; import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** * A single request stored in a request queue. Fields left {@code null} are omitted when the request @@ -13,6 +18,13 @@ public final class RequestQueueRequest extends ApifyResource { private String uniqueKey; private String method; private JsonNode userData; + private String payload; + private Map headers; + private Boolean noRetry; + private Instant handledAt; + private Integer retryCount; + private String loadedUrl; + private List errorMessages; public RequestQueueRequest() {} @@ -71,4 +83,77 @@ public RequestQueueRequest setUserData(JsonNode userData) { this.userData = userData; return this; } + + /** The HTTP request body, if any. */ + public String getPayload() { + return payload; + } + + public RequestQueueRequest setPayload(String payload) { + this.payload = payload; + return this; + } + + /** Custom HTTP headers to send with the request (unmodifiable; {@code null} if unset). */ + public Map getHeaders() { + return headers == null ? null : Collections.unmodifiableMap(headers); + } + + public RequestQueueRequest setHeaders(Map headers) { + this.headers = headers == null ? null : new LinkedHashMap<>(headers); + return this; + } + + /** If {@code true}, the request is not retried on failure. */ + public Boolean getNoRetry() { + return noRetry; + } + + public RequestQueueRequest setNoRetry(Boolean noRetry) { + this.noRetry = noRetry; + return this; + } + + /** When the request was marked as handled, or {@code null} if it has not been. */ + public Instant getHandledAt() { + return handledAt; + } + + public RequestQueueRequest setHandledAt(Instant handledAt) { + this.handledAt = handledAt; + return this; + } + + /** Number of times processing this request has already been retried. */ + public Integer getRetryCount() { + return retryCount; + } + + public RequestQueueRequest setRetryCount(Integer retryCount) { + this.retryCount = retryCount; + return this; + } + + /** The URL actually loaded, after following redirects (may differ from {@link #getUrl()}). */ + public String getLoadedUrl() { + return loadedUrl; + } + + public RequestQueueRequest setLoadedUrl(String loadedUrl) { + this.loadedUrl = loadedUrl; + return this; + } + + /** + * Error messages recorded from previous failed processing attempts, oldest first (unmodifiable; + * {@code null} if unset). + */ + public List getErrorMessages() { + return errorMessages == null ? null : Collections.unmodifiableList(errorMessages); + } + + public RequestQueueRequest setErrorMessages(List errorMessages) { + this.errorMessages = errorMessages == null ? null : List.copyOf(errorMessages); + return this; + } } diff --git a/src/main/java/com/apify/client/run/ActorRun.java b/src/main/java/com/apify/client/run/ActorRun.java index ad25326..434ffb1 100644 --- a/src/main/java/com/apify/client/run/ActorRun.java +++ b/src/main/java/com/apify/client/run/ActorRun.java @@ -1,7 +1,7 @@ package com.apify.client.run; import com.apify.client.ApifyResource; -import com.apify.client.Statuses; +import com.apify.client.internal.Statuses; import java.time.Instant; /** A single execution of an Actor. */ diff --git a/src/main/java/com/apify/client/run/MetamorphOptions.java b/src/main/java/com/apify/client/run/MetamorphOptions.java index 39e6b11..7b18bab 100644 --- a/src/main/java/com/apify/client/run/MetamorphOptions.java +++ b/src/main/java/com/apify/client/run/MetamorphOptions.java @@ -1,6 +1,6 @@ package com.apify.client.run; -import com.apify.client.ResourceContext; +import com.apify.client.internal.ResourceContext; /** Configures {@link RunClient#metamorph}. */ public final class MetamorphOptions { diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index 0a8a362..82485f6 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -1,12 +1,12 @@ package com.apify.client.run; import com.apify.client.ApifyClient; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.actor.Actor; import com.apify.client.dataset.DatasetClient; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.apify.client.keyvalue.KeyValueStoreClient; import com.apify.client.log.LogClient; import com.apify.client.log.StreamedLog; @@ -31,28 +31,47 @@ public final class RunClient { /** Header the API uses to deduplicate charge requests. */ private static final String CHARGE_IDEMPOTENCY_HEADER = "idempotency-key"; + /** + * Exclusive upper bound of the random suffix appended to an auto-generated idempotency key: it + * only needs to be unique enough to avoid collisions within a single request, not + * cryptographically secure, so six decimal digits' worth of entropy is ample. + */ + private static final long IDEMPOTENCY_KEY_RANDOM_BOUND = 1_000_000; + private final ApifyClient root; private final ResourceContext ctx; private final String id; public RunClient( ApifyClient root, HttpClientCore http, String baseUrl, String resourcePath, String id) { + this(root, ResourceContext.single(http, baseUrl, resourcePath, id), id); + } + + private RunClient(ApifyClient root, ResourceContext ctx, String id) { this.root = root; - this.ctx = ResourceContext.single(http, baseUrl, resourcePath, id); + this.ctx = ctx; this.id = id; } /** - * Pins the {@code status} and/or {@code origin} query parameters inherited by all calls on this - * client (used by the last-run accessors). Empty values are skipped. + * Builds a {@code RunClient} for a "last run" accessor ({@code runs/last}, nested under an Actor + * or task), with the {@code status}/{@code origin} filters from {@code options} pinned as query + * parameters inherited by every call on the returned client. Shared by {@code + * ActorClient#lastRun}/{@code TaskClient#lastRun} so the construction logic (and the once-off use + * of {@link ResourceContext#seedParams}, which returns a new, still-immutable context rather than + * mutating one in place) lives in a single place (DRY). */ - public void setLastRunParams(LastRunOptions options) { + public static RunClient lastRun( + ApifyClient root, HttpClientCore http, String parentUrl, LastRunOptions options) { + ResourceContext ctx = ResourceContext.single(http, parentUrl, "runs", "last"); + QueryParams filter = new QueryParams(); if (options.statusValue() != null && !options.statusValue().isEmpty()) { - ctx.baseParams.addRaw("status", options.statusValue()); + filter.addRaw("status", options.statusValue()); } if (options.originValue() != null && !options.originValue().isEmpty()) { - ctx.baseParams.addRaw("origin", options.originValue()); + filter.addRaw("origin", options.originValue()); } + return new RunClient(root, ctx.seedParams(filter), "last"); } /** Fetches the run object, or empty if it does not exist. */ @@ -166,7 +185,7 @@ private String generateIdempotencyKey(String eventName) { + "-" + System.currentTimeMillis() + "-" - + ThreadLocalRandom.current().nextLong(1_000_000); + + ThreadLocalRandom.current().nextLong(IDEMPOTENCY_KEY_RANDOM_BOUND); } /** diff --git a/src/main/java/com/apify/client/run/RunCollectionClient.java b/src/main/java/com/apify/client/run/RunCollectionClient.java index 3b42a1f..0093d4f 100644 --- a/src/main/java/com/apify/client/run/RunCollectionClient.java +++ b/src/main/java/com/apify/client/run/RunCollectionClient.java @@ -2,9 +2,9 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** diff --git a/src/main/java/com/apify/client/run/RunListOptions.java b/src/main/java/com/apify/client/run/RunListOptions.java index d1326dc..f77dee4 100644 --- a/src/main/java/com/apify/client/run/RunListOptions.java +++ b/src/main/java/com/apify/client/run/RunListOptions.java @@ -1,7 +1,7 @@ package com.apify.client.run; import com.apify.client.ListOptions; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; import java.util.List; /** diff --git a/src/main/java/com/apify/client/run/RunResurrectOptions.java b/src/main/java/com/apify/client/run/RunResurrectOptions.java index f819984..7138746 100644 --- a/src/main/java/com/apify/client/run/RunResurrectOptions.java +++ b/src/main/java/com/apify/client/run/RunResurrectOptions.java @@ -1,6 +1,6 @@ package com.apify.client.run; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Configures {@link RunClient#resurrect(RunResurrectOptions)}. */ public final class RunResurrectOptions { diff --git a/src/main/java/com/apify/client/run/SetStatusMessageOptions.java b/src/main/java/com/apify/client/run/SetStatusMessageOptions.java new file mode 100644 index 0000000..be8910a --- /dev/null +++ b/src/main/java/com/apify/client/run/SetStatusMessageOptions.java @@ -0,0 +1,24 @@ +package com.apify.client.run; + +/** + * Configures {@code com.apify.client.ApifyClient#setStatusMessage(String, + * SetStatusMessageOptions)}. + */ +public final class SetStatusMessageOptions { + + private Boolean statusMessageTerminal; + + /** + * If {@code true}, marks the message as final so it won't be overwritten by a subsequent + * non-terminal status message. Leave unset to let the server apply its default. + */ + public SetStatusMessageOptions isStatusMessageTerminal(boolean isStatusMessageTerminal) { + this.statusMessageTerminal = isStatusMessageTerminal; + return this; + } + + /** Whether the message is marked terminal, or {@code null} if unset. */ + public Boolean isStatusMessageTerminal() { + return statusMessageTerminal; + } +} diff --git a/src/main/java/com/apify/client/schedule/ScheduleClient.java b/src/main/java/com/apify/client/schedule/ScheduleClient.java index 88ac0ff..1192721 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleClient.java @@ -1,10 +1,10 @@ package com.apify.client.schedule; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.nio.charset.StandardCharsets; import java.util.Optional; diff --git a/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java index 9ea8e8c..9d2650d 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.schedule; -import com.apify.client.ApiPaths; import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the schedule collection ({@code GET/POST /v2/schedules}). */ diff --git a/src/main/java/com/apify/client/store/StoreCollectionClient.java b/src/main/java/com/apify/client/store/StoreCollectionClient.java index f4ec998..f7e6b0c 100644 --- a/src/main/java/com/apify/client/store/StoreCollectionClient.java +++ b/src/main/java/com/apify/client/store/StoreCollectionClient.java @@ -1,10 +1,10 @@ package com.apify.client.store; -import com.apify.client.ApiPaths; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for browsing the Apify Store ({@code GET /v2/store}). */ diff --git a/src/main/java/com/apify/client/store/StoreListOptions.java b/src/main/java/com/apify/client/store/StoreListOptions.java index 83379a3..bb9d5f4 100644 --- a/src/main/java/com/apify/client/store/StoreListOptions.java +++ b/src/main/java/com/apify/client/store/StoreListOptions.java @@ -1,6 +1,6 @@ package com.apify.client.store; -import com.apify.client.QueryParams; +import com.apify.client.internal.QueryParams; /** Options for listing/iterating the Apify Store ({@code GET /v2/store}). */ public final class StoreListOptions { diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 0526efa..e4b854b 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -1,11 +1,12 @@ package com.apify.client.task; import com.apify.client.ApifyClient; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.http.ApiResponse; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -28,7 +29,7 @@ public final class TaskClient { public TaskClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) { this.root = root; this.http = http; - this.ctx = ResourceContext.single(http, baseUrl, "actor-tasks", id); + this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.ACTOR_TASKS, id); } /** Fetches the task object, or empty if it does not exist. */ @@ -78,17 +79,12 @@ public Optional getInput() { /** Replaces the task's stored input and returns the updated input. */ public JsonNode updateInput(Object input) { - // Route through mergedParams like the task's other calls, so any inherited params are - // preserved. - String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("input")); - ApiResponse resp = - http.call( - "PUT", - url, - Json.toBytes(input), - ResourceContext.CONTENT_TYPE_JSON, - http.baseRequestTimeout()); - return Json.parse(resp.body, JsonNode.class); + return ctx.putWithBodyNoEnvelope( + "input", + new QueryParams(), + Json.toBytes(input), + ResourceContext.CONTENT_TYPE_JSON, + JsonNode.class); } /** @@ -103,9 +99,7 @@ public RunClient lastRun(String status) { * Returns a client for the last run of this task, optionally filtered by status and/or origin. */ public RunClient lastRun(LastRunOptions options) { - RunClient client = new RunClient(root, http, ctx.subUrl(""), "runs", "last"); - client.setLastRunParams(options); - return client; + return RunClient.lastRun(root, http, ctx.subUrl(""), options); } /** A client for this task's run collection. */ diff --git a/src/main/java/com/apify/client/task/TaskCollectionClient.java b/src/main/java/com/apify/client/task/TaskCollectionClient.java index a1c73ab..9daa43a 100644 --- a/src/main/java/com/apify/client/task/TaskCollectionClient.java +++ b/src/main/java/com/apify/client/task/TaskCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.task; -import com.apify.client.ApiPaths; import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** A client for the Actor task collection ({@code GET/POST /v2/actor-tasks}). */ diff --git a/src/main/java/com/apify/client/task/TaskStartOptions.java b/src/main/java/com/apify/client/task/TaskStartOptions.java index 7218a79..665f7e6 100644 --- a/src/main/java/com/apify/client/task/TaskStartOptions.java +++ b/src/main/java/com/apify/client/task/TaskStartOptions.java @@ -1,7 +1,7 @@ package com.apify.client.task; -import com.apify.client.QueryParams; import com.apify.client.actor.ActorStartOptions; +import com.apify.client.internal.QueryParams; import java.util.List; /** diff --git a/src/main/java/com/apify/client/task/ValidateInputOptions.java b/src/main/java/com/apify/client/task/ValidateInputOptions.java index 50d1dca..9926b51 100644 --- a/src/main/java/com/apify/client/task/ValidateInputOptions.java +++ b/src/main/java/com/apify/client/task/ValidateInputOptions.java @@ -1,8 +1,8 @@ package com.apify.client.task; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; import com.apify.client.actor.ActorClient; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; /** * Configures {@link ActorClient#validateInput(Object, ValidateInputOptions)}. All fields are diff --git a/src/main/java/com/apify/client/user/UserClient.java b/src/main/java/com/apify/client/user/UserClient.java index e3060ff..f0042c8 100644 --- a/src/main/java/com/apify/client/user/UserClient.java +++ b/src/main/java/com/apify/client/user/UserClient.java @@ -1,10 +1,10 @@ package com.apify.client.user; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; -import com.apify.client.http.Json; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.Json; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; @@ -16,14 +16,14 @@ * client. */ public final class UserClient { - private static final String ME = "me"; - private final HttpClientCore http; + /** The special user id addressing the currently-authenticated account ({@code /users/me}). */ + public static final String ME = "me"; + private final ResourceContext ctx; private final boolean isMe; public UserClient(HttpClientCore http, String baseUrl, String id) { - this.http = http; this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.USERS, id); this.isMe = ME.equals(id); } @@ -67,15 +67,11 @@ public JsonNode limits() { /** Updates the current account's resource limits. Only available for {@code me}. */ public void updateLimits(Object newLimits) { requireMe(); - // Route through mergedParams for consistency with the sibling limits() read (a no-op here since - // UserClient is never seeded with inherited params, but keeps every call site uniform). - String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("limits")); - http.call( - "PUT", - url, - Json.toBytes(newLimits), - ResourceContext.CONTENT_TYPE_JSON, - http.baseRequestTimeout()); + // Route through the shared PUT primitive for consistency with every other resource client's + // update-style call; the response body (the updated limits) is intentionally not parsed, + // matching the reference client's void-returning updateLimits. + ctx.putRaw( + "limits", new QueryParams(), Json.toBytes(newLimits), ResourceContext.CONTENT_TYPE_JSON); } private void requireMe() { diff --git a/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java index 1fb3605..7a7ea8c 100644 --- a/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/AbstractWebhookCollectionClient.java @@ -1,11 +1,11 @@ package com.apify.client.webhook; -import com.apify.client.ApiPaths; import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** diff --git a/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java index bcb1a94..19b699a 100644 --- a/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/NestedWebhookCollectionClient.java @@ -1,6 +1,6 @@ package com.apify.client.webhook; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; /** * A read-only client for the webhooks nested under an Actor ({@code GET /v2/actors/{id}/webhooks}) diff --git a/src/main/java/com/apify/client/webhook/WebhookClient.java b/src/main/java/com/apify/client/webhook/WebhookClient.java index 3dedef9..d98be76 100644 --- a/src/main/java/com/apify/client/webhook/WebhookClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookClient.java @@ -1,9 +1,9 @@ package com.apify.client.webhook; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Optional; /** A client for a specific webhook ({@code /v2/webhooks/{webhookId}}). */ diff --git a/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java b/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java index 55ab0b1..1d9f043 100644 --- a/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookCollectionClient.java @@ -1,7 +1,7 @@ package com.apify.client.webhook; -import com.apify.client.QueryParams; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; /** * A client for the account-wide webhook collection ({@code GET/POST /v2/webhooks}), supporting both diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java b/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java index 1d9808a..d922a5d 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchClient.java @@ -1,9 +1,9 @@ package com.apify.client.webhook; -import com.apify.client.ApiPaths; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.ApiPaths; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Optional; /** A client for a specific webhook dispatch ({@code /v2/webhook-dispatches/{dispatchId}}). */ diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java index b4c4a68..c81e9b0 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java @@ -2,9 +2,9 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; -import com.apify.client.QueryParams; -import com.apify.client.ResourceContext; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; import java.util.Iterator; /** diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..a2608c4 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,70 @@ +/** + * Module descriptor for the Apify Java client. + * + *

Only the resource-scoped, user-facing packages are exported; {@code com.apify.client.internal} + * holds pure implementation plumbing (the HTTP orchestration core, JSON mapping, query-string and + * resource-context helpers, URL-signature math, ...) shared across resource clients and is + * deliberately not exported, so a consumer running on the module path sees only the public API + * surface. (A consumer on the classpath, the common case for a Maven/Gradle dependency, is + * unaffected by module boundaries either way.) + */ +module com.apify.client { + requires transitive java.net.http; + // Not used by the client itself; DocSnippetsTest (compiled against this module, since Maven + // resolves test sources on the module path when main sources declare one) uses the in-process + // Java compiler API to compile documentation code snippets as part of the test suite. + requires java.compiler; + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.databind; + requires com.fasterxml.jackson.datatype.jsr310; + requires org.slf4j; + requires static com.aayushatharva.brotli4j; + + exports com.apify.client; + exports com.apify.client.actor; + exports com.apify.client.build; + exports com.apify.client.run; + exports com.apify.client.dataset; + exports com.apify.client.keyvalue; + exports com.apify.client.requestqueue; + exports com.apify.client.task; + exports com.apify.client.schedule; + exports com.apify.client.webhook; + exports com.apify.client.user; + exports com.apify.client.store; + exports com.apify.client.log; + exports com.apify.client.http; + + // Jackson deserializes directly into (private) fields (see Json's FIELD/ANY visibility config), + // which requires reflective access opened to jackson-databind for every package holding a model + // or request/response DTO, including the non-exported internal package. + opens com.apify.client to + com.fasterxml.jackson.databind; + opens com.apify.client.actor to + com.fasterxml.jackson.databind; + opens com.apify.client.build to + com.fasterxml.jackson.databind; + opens com.apify.client.run to + com.fasterxml.jackson.databind; + opens com.apify.client.dataset to + com.fasterxml.jackson.databind; + opens com.apify.client.keyvalue to + com.fasterxml.jackson.databind; + opens com.apify.client.requestqueue to + com.fasterxml.jackson.databind; + opens com.apify.client.task to + com.fasterxml.jackson.databind; + opens com.apify.client.schedule to + com.fasterxml.jackson.databind; + opens com.apify.client.webhook to + com.fasterxml.jackson.databind; + opens com.apify.client.user to + com.fasterxml.jackson.databind; + opens com.apify.client.store to + com.fasterxml.jackson.databind; + opens com.apify.client.http to + com.fasterxml.jackson.databind; + opens com.apify.client.internal to + com.fasterxml.jackson.databind; +} diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 132f9db..0b6a0cd 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -247,8 +247,8 @@ void getWithWaitForwardsWaitForFinish() { @Test void getWithWaitClampsServerWaitToConfiguredTimeout() { // With a 10s per-request timeout, a caller asking for waitForFinish=60 must be clamped below - // the - // timeout (10 - 5s margin = 5) so the synchronous get can't abort itself on the socket timeout. + // the timeout (10 - 5s margin = 5) so the synchronous get can't abort itself on the socket + // timeout. MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); ApifyClient client = diff --git a/src/test/java/com/apify/client/CompressionTest.java b/src/test/java/com/apify/client/CompressionTest.java index f26c9a8..7eea71a 100644 --- a/src/test/java/com/apify/client/CompressionTest.java +++ b/src/test/java/com/apify/client/CompressionTest.java @@ -9,7 +9,7 @@ import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.decoder.Decoder; import com.aayushatharva.brotli4j.decoder.DirectDecompress; -import com.apify.client.http.HttpClientCore; +import com.apify.client.internal.HttpClientCore; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; diff --git a/src/test/java/com/apify/client/DatasetItemsIteratorTest.java b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java index 7977d50..5e775df 100644 --- a/src/test/java/com/apify/client/DatasetItemsIteratorTest.java +++ b/src/test/java/com/apify/client/DatasetItemsIteratorTest.java @@ -52,8 +52,9 @@ void pagesBareArrayBodyAndStopsOnEmptyPage() { @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. + // (No (options, Class) overload exists: with a null second argument, the compiler could not + // tell it apart from the (options, Long chunkSize) overload — ambiguous overload resolution.) + // Decodes each item into the requested type. MockTransport backend = new MockTransport( List.of(MockTransport.ok(200, "[{\"n\":1},{\"n\":2}]"), MockTransport.ok(200, "[]"))); diff --git a/src/test/java/com/apify/client/ExceptionHierarchyTest.java b/src/test/java/com/apify/client/ExceptionHierarchyTest.java new file mode 100644 index 0000000..a49b1f4 --- /dev/null +++ b/src/test/java/com/apify/client/ExceptionHierarchyTest.java @@ -0,0 +1,44 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyClientException; +import com.apify.client.http.ApifyTransportException; +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Guards the exception hierarchy's headline contract: {@code catch (ApifyClientException)} must + * catch every failure this client throws, both the "API responded with an error" case ({@link + * ApifyApiException}) and the "no response was produced at all" case ({@link + * ApifyTransportException}). A regression here (e.g. one subtype quietly stops extending the common + * base) would silently break every caller relying on the documented single catch clause. + */ +class ExceptionHierarchyTest { + + @Test + void apiExceptionIsCatchableAsClientException() { + ApifyClientException caught = + assertThrows( + ApifyClientException.class, + () -> { + throw new ApifyApiException( + 404, "record-not-found", "not found", 1, "GET", "/x", Map.of()); + }); + assertInstanceOf(ApifyApiException.class, caught); + } + + @Test + void transportExceptionIsCatchableAsClientException() { + ApifyClientException caught = + assertThrows( + ApifyClientException.class, + () -> { + throw new ApifyTransportException(new IOException("connection refused")); + }); + assertInstanceOf(ApifyTransportException.class, caught); + } +} diff --git a/src/test/java/com/apify/client/MockTransport.java b/src/test/java/com/apify/client/MockTransport.java index 2bb4793..ef3dd93 100644 --- a/src/test/java/com/apify/client/MockTransport.java +++ b/src/test/java/com/apify/client/MockTransport.java @@ -76,12 +76,13 @@ static Scripted networkError() { } static Scripted timeoutError() { - // Throws the HttpTransport contract's own backend-agnostic timeout signal (not the JDK - // HttpClient's), matching what any real backend implementation is expected to do. + // Throws the HttpTransport contract's own transport-implementation-agnostic timeout signal + // (not the JDK HttpClient's), matching what any real transport implementation is expected to + // do. return new Scripted(0, null, new HttpTimeoutException("request timed out", null)); } - // synchronized: batchAddRequests may drive this backend from several threads at once. + // synchronized: batchAddRequests may drive this transport from several threads at once. @Override public synchronized HttpResponse send(HttpRequest request) throws IOException { int idx = calls++; diff --git a/src/test/java/com/apify/client/PaginatedIteratorTest.java b/src/test/java/com/apify/client/PaginatedIteratorTest.java index d77461d..e704b26 100644 --- a/src/test/java/com/apify/client/PaginatedIteratorTest.java +++ b/src/test/java/com/apify/client/PaginatedIteratorTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.apify.client.internal.PaginatedIterator; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; diff --git a/src/test/java/com/apify/client/UnitHttpTest.java b/src/test/java/com/apify/client/UnitHttpTest.java index 102038e..e9fda51 100644 --- a/src/test/java/com/apify/client/UnitHttpTest.java +++ b/src/test/java/com/apify/client/UnitHttpTest.java @@ -13,13 +13,13 @@ import java.util.Optional; import org.junit.jupiter.api.Test; -/** Offline unit tests for the retry/error/404 logic, using a mock HTTP backend. */ +/** Offline unit tests for the retry/error/404 logic, using a mock HTTP transport. */ class UnitHttpTest { - private static ApifyClient client(MockTransport backend, int maxRetries) { + private static ApifyClient client(MockTransport transport, int maxRetries) { return ApifyClient.builder() .token("test-token") - .httpTransport(backend) + .httpTransport(transport) .maxRetries(maxRetries) .minDelayBetweenRetries(Duration.ofMillis(1)) .build(); @@ -27,83 +27,83 @@ private static ApifyClient client(MockTransport backend, int maxRetries) { @Test void successSingleCall() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"u1\",\"username\":\"bob\"}}"); - Optional user = client(backend, 8).me().get(); + Optional user = client(transport, 8).me().get(); assertTrue(user.isPresent()); assertEquals("u1", user.get().getId()); assertEquals("bob", user.get().getUsername()); - assertEquals(1, backend.calls); + assertEquals(1, transport.calls); } @Test void rateLimitIsRetried() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant( 429, "{\"error\":{\"type\":\"rate-limit-exceeded\",\"message\":\"slow down\"}}"); ApifyApiException ex = - assertThrows(ApifyApiException.class, () -> client(backend, 2).me().get()); + assertThrows(ApifyApiException.class, () -> client(transport, 2).me().get()); assertEquals(429, ex.getStatusCode()); - assertEquals(3, backend.calls); // 1 initial + 2 retries + assertEquals(3, transport.calls); // 1 initial + 2 retries assertEquals(3, ex.getAttempt()); } @Test void serverErrorIsRetried() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant(503, "{\"error\":{\"type\":\"internal\",\"message\":\"boom\"}}"); - assertThrows(ApifyApiException.class, () -> client(backend, 1).me().get()); - assertEquals(2, backend.calls); + assertThrows(ApifyApiException.class, () -> client(transport, 1).me().get()); + assertEquals(2, transport.calls); } @Test void clientErrorNotRetried() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant( 400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"nope\"}}"); - assertThrows(ApifyApiException.class, () -> client(backend, 5).me().get()); - assertEquals(1, backend.calls); + assertThrows(ApifyApiException.class, () -> client(transport, 5).me().get()); + assertEquals(1, transport.calls); } @Test void networkErrorIsRetried() { - MockTransport backend = new MockTransport(List.of(MockTransport.networkError())); - assertThrows(RuntimeException.class, () -> client(backend, 3).me().get()); - assertEquals(4, backend.calls); + MockTransport transport = new MockTransport(List.of(MockTransport.networkError())); + assertThrows(RuntimeException.class, () -> client(transport, 3).me().get()); + assertEquals(4, transport.calls); } @Test void retryThenSuccess() { - MockTransport backend = + MockTransport transport = new MockTransport( List.of( MockTransport.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), MockTransport.ok(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"), MockTransport.ok(200, "{\"data\":{\"id\":\"ok\"}}"))); - Optional user = client(backend, 5).me().get(); + Optional user = client(transport, 5).me().get(); assertTrue(user.isPresent()); assertEquals("ok", user.get().getId()); - assertEquals(3, backend.calls); + assertEquals(3, transport.calls); } @Test void notFoundMapsToEmpty() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant( 404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"missing\"}}"); - Optional actor = client(backend, 5).actor("nope").get(); + Optional actor = client(transport, 5).actor("nope").get(); assertFalse(actor.isPresent()); - assertEquals(1, backend.calls); // no retry on 404 + assertEquals(1, transport.calls); // no retry on 404 } @Test void errorBodyIsParsed() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant( 400, "{\"error\":{\"type\":\"bad-request\",\"message\":\"invalid input\",\"data\":{\"field\":\"name\"}}}"); ApifyApiException ex = - assertThrows(ApifyApiException.class, () -> client(backend, 0).me().get()); + assertThrows(ApifyApiException.class, () -> client(transport, 0).me().get()); assertEquals(400, ex.getStatusCode()); assertEquals("bad-request", ex.getType()); assertTrue(ex.getMessage().contains("invalid input")); @@ -114,9 +114,9 @@ void errorBodyIsParsed() { @Test void zeroRetriesSingleAttempt() { - MockTransport backend = + MockTransport transport = MockTransport.ofConstant(500, "{\"error\":{\"type\":\"internal\",\"message\":\"x\"}}"); - assertThrows(ApifyApiException.class, () -> client(backend, 0).me().get()); - assertEquals(1, backend.calls); + assertThrows(ApifyApiException.class, () -> client(transport, 0).me().get()); + assertEquals(1, transport.calls); } } diff --git a/src/test/java/com/apify/client/examples/CreateBuildRunActor.java b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java index 45dcf94..a568de0 100644 --- a/src/test/java/com/apify/client/examples/CreateBuildRunActor.java +++ b/src/test/java/com/apify/client/examples/CreateBuildRunActor.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; /** * Demonstrates the full Actor lifecycle: create a new Actor from source files, build it, run it, @@ -20,7 +21,10 @@ private CreateBuildRunActor() {} public static void main(String[] args) { ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); - String name = "java-example-" + System.currentTimeMillis(); + // Random, not time-based: this example (like the integration tests) may run concurrently + // against the same account from several processes/languages at once, and two runs starting in + // the same millisecond would otherwise collide on the same Actor name. + String name = "java-example-" + UUID.randomUUID().toString().substring(0, 8); Map actorDef = Map.of( diff --git a/src/test/java/com/apify/client/examples/Storages.java b/src/test/java/com/apify/client/examples/Storages.java index 8195abc..93733ad 100644 --- a/src/test/java/com/apify/client/examples/Storages.java +++ b/src/test/java/com/apify/client/examples/Storages.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; /** * Demonstrates each storage type: create the storage, push data to it, then read the data back. @@ -21,7 +22,10 @@ private Storages() {} public static void main(String[] args) { ApifyClient client = ApifyClient.create(System.getenv("APIFY_TOKEN")); - String suffix = Long.toString(System.currentTimeMillis()); + // Random, not time-based: this example (like the integration tests) may run concurrently + // against the same account from several processes/languages at once, and two runs starting in + // the same millisecond would otherwise collide on the same storage name. + String suffix = UUID.randomUUID().toString().substring(0, 8); // Dataset: create, push items, read them back. Dataset dataset = client.datasets().getOrCreate("java-example-ds-" + suffix); diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java index bc52b9f..6d0a80a 100644 --- a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -7,10 +7,13 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; import com.apify.client.actor.Actor; +import com.apify.client.actor.ActorBuildOptions; import com.apify.client.actor.ActorClient; import com.apify.client.actor.ActorEnvVar; import com.apify.client.actor.ActorListOptions; import com.apify.client.actor.ActorVersion; +import com.apify.client.build.Build; +import com.apify.client.build.BuildClient; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -117,6 +120,27 @@ void validateInput() { assertTrue(valid); } + @Test + void actorDefaultBuildAndWebhooks() { + ApifyClient client = requireClient(); + Actor created = client.actors().create(minimalActor(uniqueName("default-build"))); + try { + ActorClient actor = client.actor(created.getId()); + Build build = actor.build("0.0", new ActorBuildOptions()); + client.build(build.getId()).waitForFinish(300L); + + BuildClient defaultBuild = actor.defaultBuild(60L); + assertTrue(defaultBuild.get().isPresent()); + + // Read-only nested webhook collection (GET + iterate); the account has none registered for + // this fresh Actor, so this just exercises both calls succeeding against an empty result. + assertTrue(actor.webhooks().list(new ListOptions()).getTotal() >= 0); + assertTrue(!actor.webhooks().iterate(new ListOptions()).hasNext()); + } finally { + client.actor(created.getId()).delete(); + } + } + @Test void actorEnvVarCrudFlow() { ApifyClient client = requireClient(); diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 30c72cb..bc488c1 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -13,6 +13,8 @@ import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; import com.apify.client.run.RunListOptions; +import com.apify.client.run.RunResurrectOptions; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -38,16 +40,46 @@ void runActorAndReadOutputs() { client.run(run.getId()).dataset().listItems(new DatasetListItemsOptions()); client.run(run.getId()).keyValueStore().getRecord("OUTPUT"); + client.run(run.getId()).requestQueue().listHead(null); + } + + @Test + void runAbortUpdateResurrectDelete() { + ApifyClient client = requireClient(); + ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); + RunClient runClient = client.run(run.getId()); + + ActorRun aborted = runClient.abort(false); + assertTrue(!"READY".equals(aborted.getStatus()), aborted.getStatus()); + ActorRun finished = runClient.waitForFinish(60L); + assertTrue( + finished.isTerminal(), "run did not reach a terminal state: " + finished.getStatus()); + + ActorRun updated = runClient.update(Map.of("statusMessage", "integration-test-update")); + assertEquals("integration-test-update", updated.getStatusMessage()); + + ActorRun resurrected = runClient.resurrect(new RunResurrectOptions()); + assertTrue( + !resurrected.isTerminal(), + "resurrected run should not already be terminal: " + resurrected.getStatus()); + + // Clean up the resurrected run so it doesn't linger on the shared test account. + RunClient resurrectedClient = client.run(resurrected.getId()); + resurrectedClient.abort(false); + resurrectedClient.delete(); } @Test void lastRunAccess() { ApifyClient client = requireClient(); - client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); var lastRun = client.actor("apify/hello-world").lastRun("SUCCEEDED").get(); assertTrue(lastRun.isPresent()); assertEquals("SUCCEEDED", lastRun.get().getStatus()); + // Assert identity, not just status: the "last" run resolved above must actually be the run + // just created above, not merely some other SUCCEEDED run on the account. + assertEquals(run.getId(), lastRun.get().getId()); var byOrigin = client @@ -56,6 +88,7 @@ void lastRunAccess() { .get(); assertTrue(byOrigin.isPresent()); assertEquals("SUCCEEDED", byOrigin.get().getStatus()); + assertEquals(run.getId(), byOrigin.get().getId()); } @Test @@ -69,7 +102,29 @@ void streamedLogRedirection() { runClient.getStreamedLog(new StreamedLogOptions().toLog(collected::add))) { streamedLog.start(); runClient.waitForFinish(120L); + // A fast Actor (hello-world routinely finishes in a couple of seconds) can complete before + // the background reader has pulled any bytes off the live log stream yet, even though the + // log content itself is already fully available server-side once the run is done. Rather + // than asserting immediately (a race with that background thread) or closing the stream + // right away (which would cut the reader off before its first read), give it a bounded + // window to catch up and flush; the log is static at this point, so waiting longer never + // helps once it is genuinely empty. + long deadline = System.currentTimeMillis() + STREAM_CATCH_UP_TIMEOUT_MILLIS; + while (collected.isEmpty() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(STREAM_CATCH_UP_POLL_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } } assertTrue(!collected.isEmpty(), "expected redirected log messages"); } + + /** Bounded window given to {@link #streamedLogRedirection} for the log to catch up. */ + private static final long STREAM_CATCH_UP_TIMEOUT_MILLIS = 15_000; + + /** Poll interval while waiting for the log to catch up in {@link #streamedLogRedirection}. */ + private static final long STREAM_CATCH_UP_POLL_MILLIS = 250; } diff --git a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java index c01d2d6..4a0aa58 100644 --- a/src/test/java/com/apify/client/integration/BuildIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/BuildIntegrationTest.java @@ -8,6 +8,7 @@ import com.apify.client.actor.Actor; import com.apify.client.actor.ActorBuildOptions; import com.apify.client.build.Build; +import com.apify.client.build.BuildClient; import org.junit.jupiter.api.Test; class BuildIntegrationTest extends IntegrationBase { @@ -38,4 +39,21 @@ void buildActorFlow() { client.actor(created.getId()).delete(); } } + + @Test + void buildAbortAndDelete() { + ApifyClient client = requireClient(); + Actor created = + client.actors().create(ActorIntegrationTest.minimalActor(uniqueName("build-abort"))); + try { + Build build = client.actor(created.getId()).build("0.0", new ActorBuildOptions()); + BuildClient buildClient = client.build(build.getId()); + Build aborted = buildClient.abort(); + assertNotNull(aborted.getStatus()); + buildClient.waitForFinish(60L); + buildClient.delete(); + } finally { + client.actor(created.getId()).delete(); + } + } } diff --git a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java index 27d9590..fff5759 100644 --- a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java @@ -87,6 +87,30 @@ void requestQueuePaginateMultiplePages() { } } + @Test + void requestQueuePaginateWithTotalLimit() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-page-limit")); + try { + RequestQueueClient queue = client.requestQueue(rq.getId()); + for (int i = 0; i < 5; i++) { + String url = "https://example.com/" + i; + queue.addRequest(new RequestQueueRequest(url, url), false); + } + // totalLimit caps the number yielded across all pages, independent of the per-page chunk + // size (chunkSize=2 forces at least two page fetches to satisfy a totalLimit of 3). + Iterator it = queue.paginateRequests(3L, 2L, null); + int count = 0; + while (it.hasNext()) { + it.next(); + count++; + } + assertEquals(3, count); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + @Test void requestQueueBatchAddRequests() { ApifyClient client = requireClient(); diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index 4c16d77..bb1426c 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -5,9 +5,11 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; +import com.apify.client.run.ActorRun; import com.apify.client.run.RunListOptions; import com.apify.client.task.Task; import com.apify.client.task.TaskClient; +import com.apify.client.task.TaskStartOptions; import java.util.Map; import org.junit.jupiter.api.Test; @@ -59,4 +61,26 @@ void taskCrudFlow() { client.task(task.getId()).delete(); } } + + @Test + void taskLastRunAndWebhooks() { + ApifyClient client = requireClient(); + Task task = client.tasks().create(taskDef(uniqueName("task-lastrun"))); + try { + TaskClient tc = client.task(task.getId()); + ActorRun run = tc.call(null, new TaskStartOptions(), 120L); + assertEquals("SUCCEEDED", run.getStatus()); + + var lastRun = tc.lastRun("SUCCEEDED").get(); + assertTrue(lastRun.isPresent()); + assertEquals(run.getId(), lastRun.get().getId()); + + // Read-only nested webhook collection (GET + iterate); no webhooks are registered for this + // fresh task, so this just exercises both calls succeeding against an empty result. + assertTrue(tc.webhooks().list(new ListOptions()).getTotal() >= 0); + assertTrue(!tc.webhooks().iterate(new ListOptions()).hasNext()); + } finally { + client.task(task.getId()).delete(); + } + } } diff --git a/src/test/java/com/apify/client/integration/UserIntegrationTest.java b/src/test/java/com/apify/client/integration/UserIntegrationTest.java index adb1a88..6065aaa 100644 --- a/src/test/java/com/apify/client/integration/UserIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/UserIntegrationTest.java @@ -16,6 +16,18 @@ void getOwnAccount() { assertTrue(user.get().getId() != null && !user.get().getId().isEmpty()); } + @Test + void getUserById() { + ApifyClient client = requireClient(); + var me = client.me().get(); + assertTrue(me.isPresent()); + // The account's own id is always a valid target for the by-id accessor (public profile view), + // covering `ApifyClient.user(id)` in addition to the `me()` convenience already exercised + // above. + var byId = client.user(me.get().getId()).get(); + assertTrue(byId.isPresent()); + } + @Test void getMonthlyUsage() { ApifyClient client = requireClient(); diff --git a/src/test/java/com/apify/client/SignatureTest.java b/src/test/java/com/apify/client/internal/SignatureTest.java similarity index 97% rename from src/test/java/com/apify/client/SignatureTest.java rename to src/test/java/com/apify/client/internal/SignatureTest.java index e7ace13..e351441 100644 --- a/src/test/java/com/apify/client/SignatureTest.java +++ b/src/test/java/com/apify/client/internal/SignatureTest.java @@ -1,4 +1,4 @@ -package com.apify.client; +package com.apify.client.internal; import static org.junit.jupiter.api.Assertions.assertEquals; From a95c3e851aec6d7a1d8bffa8f391c948a7f33c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 16:19:08 +0000 Subject: [PATCH 03/18] =?UTF-8?q?fix:=20loop-3=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20payload-size=20chunking,=20typed=20RQ=20returns,=20?= =?UTF-8?q?log-streaming=20call(),=20DRY=20collection=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes every item from the loop-2 review's remaining-actionable list: batchAddRequests now also splits by payload byte size (not just count) to avoid 413s; RequestQueueClient's lock/list operations return typed models instead of raw JsonNode; Schedule/Webhook/Task/ActorRun gained typed getters for fields previously reachable only via getExtra(); new ActorCallOptions/ TaskCallOptions call() overloads stream the run's log by default, matching the reference client; extracted AbstractCollectionClient to de-duplicate the 8 collection clients' list/iterate boilerplate; plus the requested docs completions and style nits. See notes.md "Implementer — loop 3" in apify-client-orchestration for the itemized list and test evidence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- CHANGELOG.md | 24 +++ docs/README.md | 57 ++++--- docs/actors.md | 10 +- docs/builds.md | 27 +++- docs/runs.md | 10 ++ docs/schedules.md | 7 +- docs/storages.md | 47 ++++-- docs/tasks.md | 20 ++- docs/webhooks.md | 8 +- spotbugs-exclude.xml | 14 +- .../java/com/apify/client/ListOptions.java | 7 +- .../com/apify/client/StorageListOptions.java | 7 +- .../apify/client/actor/ActorCallOptions.java | 115 ++++++++++++++ .../com/apify/client/actor/ActorClient.java | 51 ++++++ .../client/actor/ActorCollectionClient.java | 36 +---- .../apify/client/actor/ActorListOptions.java | 15 +- .../com/apify/client/actor/ActorStandby.java | 50 ++++++ .../actor/ActorVersionCollectionClient.java | 19 +-- .../client/build/BuildCollectionClient.java | 32 +--- .../apify/client/dataset/DatasetClient.java | 16 +- .../dataset/DatasetCollectionClient.java | 38 +---- .../internal/AbstractCollectionClient.java | 85 ++++++++++ .../apify/client/internal/HttpClientCore.java | 26 ++-- .../apify/client/internal/HttpHeaders.java | 20 +++ .../client/internal/ListOptionsLike.java | 25 +++ .../apify/client/internal/QueryParams.java | 21 +-- .../client/internal/ResourceContext.java | 14 +- .../client/keyvalue/KeyValueStoreClient.java | 3 +- .../KeyValueStoreCollectionClient.java | 43 +---- .../requestqueue/BatchDeleteResult.java | 22 +++ .../requestqueue/DeletedRequestInfo.java | 24 +++ .../requestqueue/LockedRequestQueueHead.java | 56 +++++++ .../client/requestqueue/RequestLockInfo.java | 15 ++ .../requestqueue/RequestQueueClient.java | 147 +++++++++++++----- .../RequestQueueCollectionClient.java | 43 +---- .../client/requestqueue/RequestQueueHead.java | 7 + .../requestqueue/RequestQueueRequest.java | 9 ++ .../client/requestqueue/RequestsList.java | 53 +++++++ .../requestqueue/UnlockRequestsResult.java | 14 ++ .../java/com/apify/client/run/ActorRun.java | 60 +++++++ .../com/apify/client/run/ActorRunMeta.java | 26 ++++ .../com/apify/client/run/ActorRunOptions.java | 54 +++++++ .../com/apify/client/run/ActorRunStats.java | 98 ++++++++++++ .../com/apify/client/run/ActorRunUsage.java | 111 +++++++++++++ .../apify/client/run/RunCollectionClient.java | 20 ++- .../client/run/SetStatusMessageOptions.java | 7 +- .../com/apify/client/schedule/Schedule.java | 69 ++++++++ .../schedule/ScheduleCollectionClient.java | 35 +---- .../schedule/ScheduleNotifications.java | 14 ++ src/main/java/com/apify/client/task/Task.java | 36 +++++ .../apify/client/task/TaskCallOptions.java | 100 ++++++++++++ .../com/apify/client/task/TaskClient.java | 50 ++++++ .../client/task/TaskCollectionClient.java | 34 +--- .../com/apify/client/task/TaskOptions.java | 32 ++++ .../java/com/apify/client/task/TaskStats.java | 14 ++ .../com/apify/client/webhook/Webhook.java | 76 ++++++++- .../apify/client/webhook/WebhookStats.java | 14 ++ .../client/ClientBehaviourRegressionTest.java | 33 ++++ .../integration/ActorRunIntegrationTest.java | 80 ++++++++++ .../RequestQueueIntegrationTest.java | 47 +++++- .../integration/ScheduleIntegrationTest.java | 9 ++ .../integration/TaskIntegrationTest.java | 44 ++++++ .../integration/WebhookIntegrationTest.java | 8 + 63 files changed, 1940 insertions(+), 368 deletions(-) create mode 100644 src/main/java/com/apify/client/actor/ActorCallOptions.java create mode 100644 src/main/java/com/apify/client/actor/ActorStandby.java create mode 100644 src/main/java/com/apify/client/internal/AbstractCollectionClient.java create mode 100644 src/main/java/com/apify/client/internal/HttpHeaders.java create mode 100644 src/main/java/com/apify/client/internal/ListOptionsLike.java create mode 100644 src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java create mode 100644 src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java create mode 100644 src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java create mode 100644 src/main/java/com/apify/client/requestqueue/RequestLockInfo.java create mode 100644 src/main/java/com/apify/client/requestqueue/RequestsList.java create mode 100644 src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java create mode 100644 src/main/java/com/apify/client/run/ActorRunMeta.java create mode 100644 src/main/java/com/apify/client/run/ActorRunOptions.java create mode 100644 src/main/java/com/apify/client/run/ActorRunStats.java create mode 100644 src/main/java/com/apify/client/run/ActorRunUsage.java create mode 100644 src/main/java/com/apify/client/schedule/ScheduleNotifications.java create mode 100644 src/main/java/com/apify/client/task/TaskCallOptions.java create mode 100644 src/main/java/com/apify/client/task/TaskOptions.java create mode 100644 src/main/java/com/apify/client/task/TaskStats.java create mode 100644 src/main/java/com/apify/client/webhook/WebhookStats.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5d267..80d2283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,14 +45,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). single `ApiPaths` constants class, referenced by each dedicated resource client instead of being passed in from `ApifyClient`. - Bumped `Version.API_SPEC_VERSION` to `v2-2026-07-20T094852Z`. +- Extracted a shared `AbstractCollectionClient` (internal) for the repetitive + offset/limit `list`/`iterate` boilerplate on `DatasetCollectionClient`, + `KeyValueStoreCollectionClient`, `RequestQueueCollectionClient`, `ActorCollectionClient`, + `ScheduleCollectionClient`, `TaskCollectionClient`, `BuildCollectionClient` and + `RunCollectionClient`; behavior is unchanged. ### Added - `ApifyClient.setStatusMessage(String, SetStatusMessageOptions)`, matching the reference client's top-level `setStatusMessage`. +- `ActorClient.call(Object, ActorCallOptions, Long)` / `TaskClient.call(Object, TaskCallOptions, + Long)`: a log-streaming `call` overload that streams the run's log for the duration of the wait + by default, matching the reference client's `call` defaulting `options.log` to `'default'`. + `ActorCallOptions`/`TaskCallOptions` mirror `ActorStartOptions`/`TaskStartOptions` (minus + `waitForFinish`) and add `disableLogStreaming()` / `logOptions(StreamedLogOptions)`. +- Typed return values for `RequestQueueClient.listAndLockHead` (`LockedRequestQueueHead`), + `prolongRequestLock` (`RequestLockInfo`), `unlockRequests` (`UnlockRequestsResult`), + `batchDeleteRequests` (`BatchDeleteResult`) and `listRequests` (`RequestsList`), replacing raw + `JsonNode`. `RequestQueueHead`/`RequestQueueRequest` gained `queueModifiedAt`/`lockExpiresAt`. +- Typed getters replacing several `getExtra()`-only fields: `Schedule` (`title`, `timezone`, + `isExclusive`, `description`, `createdAt`, `modifiedAt`, `nextRunAt`, `lastRunAt`, `actions`, + `notifications`), `Webhook` (`condition`, `ignoreSslErrors`, `doNotRetry`, `payloadTemplate`, + `headersTemplate`, `isAdHoc`, `stats`, `description`, `createdAt`, `modifiedAt`), `Task` + (`description`, `stats`, `options`, `input`, `actorStandby`), `ActorRun` (`generalAccess`, + `chargedEventCounts`, `pricingInfo`, `usage`, `usageUsd`, `stats`, `options`, `meta`). ### Fixed +- `RequestQueueClient.batchAddRequests` now additionally splits chunks by cumulative JSON-encoded + byte size (matching the reference client's `MAX_PAYLOAD_SIZE_BYTES`), not just the 25-request + count limit, so a batch of individually large requests (e.g. sizeable `userData`) can no longer + 413. - `ApifyApiException` now extends `ApifyClientException` (previously `RuntimeException` directly), so `catch (ApifyClientException)` catches API errors as documented. - `PaginationList.getItems()` no longer throws `NullPointerException` when the API response diff --git a/docs/README.md b/docs/README.md index 7712e43..d79d193 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,15 +36,15 @@ option types, so import each resource you use from its own package: |---|---| | `com.apify.client` (root) | `ApifyClient`, `ApifyClientBuilder`, `Version`, `PaginationList`, `ListOptions`, `StorageListOptions`, `ApifyResource` | | `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException` | -| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorListOptions`, `ActorStartOptions`, `ActorBuildOptions`, `ActorVersion`, `ActorEnvVar`, ... | +| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient` | | `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient` | -| `com.apify.client.run` | `ActorRun`, `RunClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | -| `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions` | -| `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreRecord`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | -| `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueRequest`, `ListRequestsOptions`, `BatchAddRequestsOptions` | -| `com.apify.client.task` | `Task`, `TaskClient`, `TaskStartOptions`, `ValidateInputOptions` | -| `com.apify.client.schedule` | `Schedule`, `ScheduleClient` | -| `com.apify.client.webhook` | `Webhook`, `WebhookClient`, `WebhookDispatch`, `WebhookDispatchClient` | +| `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | +| `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions` | +| `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreCollectionClient`, `KeyValueStoreRecord`, `KeyValueStoreKeysPage`, `KeyValueStoreKey`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | +| `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | +| `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions`, `ValidateInputOptions` | +| `com.apify.client.schedule` | `Schedule`, `ScheduleNotifications`, `ScheduleClient`, `ScheduleCollectionClient` | +| `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient` | | `com.apify.client.user` | `User`, `UserClient` | | `com.apify.client.store` | `ActorStoreListItem`, `StoreCollectionClient`, `StoreListOptions` | | `com.apify.client.log` | `LogClient`, `LogOptions`, `StreamedLog`, `StreamedLogOptions` | @@ -53,7 +53,7 @@ For example, the [top-level README's dataset snippet](../README.md#quick-start) `com.apify.client.ApifyClient`, `com.apify.client.PaginationList`, `com.apify.client.dataset.DatasetListItemsOptions`, `com.apify.client.run.ActorRun`, `com.apify.client.actor.ActorStartOptions` and `com.fasterxml.jackson.databind.JsonNode` — five -different packages for one six-line snippet. +different packages for one four-line snippet. Snippets in these docs also assume the standard-library types they use are imported (`java.util.List`, `java.util.ArrayList`, `java.util.Map`, `java.util.Optional`, @@ -68,37 +68,44 @@ transitive dependency of this client, so it is already on your classpath. A few methods return data whose shape is not modelled by this client and is instead exposed as a Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): -- Read, returning a required `JsonNode` (never absent): `me().monthlyUsage(...)`, `me().limits()`, - and the raw request-queue operations `listRequests`, `listAndLockHead`, `prolongRequestLock`, - `unlockRequests`, `batchDeleteRequests`. +- Read, returning a required `JsonNode` (never absent): `me().monthlyUsage(...)`, `me().limits()`. - Read, returning `Optional` (empty when the underlying resource has none): `dataset(id).getStatistics()`, `task(id).getInput()`, `build(id).getOpenApiDefinition()`. - Write: `task(id).updateInput(...)` (itself returning a required `JsonNode`, the updated input) and `me().updateLimits(...)` accept an arbitrary JSON-serializable value, as do definition/`update`/`create` arguments generally — a `Map`, a `JsonNode`, or your own POJO. +- A few typed models still carry one raw-JSON field where the shape is a discriminated union not + worth fully modelling: `RequestQueueRequest.getUserData()`, `Webhook.getCondition()`, + `ActorRun.getPricingInfo()`, `Schedule.getActions()` (a `List`). Navigate a `JsonNode` with `node.get("field")`, `node.path("a").asText()`, etc. +The request-queue lock/list operations (`listRequests`, `listAndLockHead`, `prolongRequestLock`, +`unlockRequests`, `batchDeleteRequests`) return typed models (`RequestsList`, +`LockedRequestQueueHead`, `RequestLockInfo`, `UnlockRequestsResult`, `BatchDeleteResult`) rather +than raw `JsonNode` — see [Storages](storages.md#request-queues). + +## What `ApifyResource` is + +`ApifyResource` (root package) is the base class every response model extends (`Actor`, `Dataset`, +`Schedule`, `ActorRun`, and so on). It has no fields of its own beyond the `extra` map described +below; it exists purely so every model shares one place to capture unmodelled API fields, and so +code that only needs the common capability (rare — most call sites use a concrete model type +directly) can accept `ApifyResource` rather than a specific model. + ## Model fields and unmodeled data (`getExtra`) Response models expose the commonly-used fields as typed getters. The API returns more fields than -are modelled; every model also carries a `getExtra()` map (`Map`) holding any field -not mapped to a typed getter, so nothing the API returns is lost. For example a `Schedule`'s -`actions`/`isExclusive`, or a `me()` `User`'s private account details (email, plan, proxy settings, -…), are available via `getExtra()`. +are modelled; every model also carries a `getExtra()` map (`Map`, inherited from +`ApifyResource` above) holding any field not mapped to a typed getter, so nothing the API returns is +lost. For example a `me()` `User`'s private account details (email, plan, proxy settings, …) are +available via `getExtra()`, since `User` models only its most commonly used fields. ```java -Schedule schedule = client.schedule("SCHEDULE_ID").get().orElseThrow(); -Object actions = schedule.getExtra().get("actions"); +User me = client.me().get().orElseThrow(); +Object plan = me.getExtra().get("plan"); ``` -Some models expose only their most commonly used fields as typed getters and leave more of the -API response in `getExtra()` than others — `Schedule`, `Webhook`, `Task` and `ActorRun` in -particular currently model a subset of what the API returns (e.g. a `Schedule`'s `title`, -`timezone`, `notifications`; a `Webhook`'s `condition`, `payloadTemplate`, `stats`; an `ActorRun`'s -`usage`, `options`, `pricingInfo`). Read those fields via `getExtra()` until a typed getter is -added; the raw JSON key is unchanged either way. - ## Setting the current run's status message `client.setStatusMessage(String message, SetStatusMessageOptions)` (import from diff --git a/docs/actors.md b/docs/actors.md index 9c7cc57..c0570b7 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -47,7 +47,8 @@ Actor created = client.actors().create(Map.of( | `update(Object)` | Update the Actor with the given fields. Returns `Actor`. | | `delete()` | Delete the Actor. | | `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Returns `ActorRun`. | -| `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely). Returns `ActorRun`. | +| `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely); does **not** stream the run's log. Returns `ActorRun`. | +| `call(Object input, ActorCallOptions, Long waitSecs)` | As above, additionally streaming the run's log for the duration of the wait by default (matching the reference client's `call` defaulting `options.log` to `'default'`). Use `ActorCallOptions.disableLogStreaming()` to opt out, or `logOptions(StreamedLogOptions)` for a custom destination. | | `validateInput(Object input)` / `validateInput(Object input, ValidateInputOptions)` | Validate an input against the Actor's input schema. Returns `boolean`. `ValidateInputOptions` fields (optional): `build`, `contentType`. | | `build(String versionNumber, ActorBuildOptions)` | Build a version. Returns `Build`. | | `defaultBuild(Long waitForFinish)` | Resolve the default build. Returns `BuildClient`. | @@ -67,6 +68,13 @@ of the input body, defaults to `application/json`), `restartOnError` (restart th (each a JSON-serializable `Map`, as in [Webhooks](webhooks.md)) that the client base64-encodes on the wire. +`ActorCallOptions` (for the log-streaming `call` overload) mirrors `ActorStartOptions` field for +field, but omits `waitForFinish`: that field asks the API to hold the HTTP response open +server-side while the run finishes, which is redundant with (and wastes a request slot next to) +`call`'s own client-side `waitSecs` polling. It adds `disableLogStreaming()` (matching the +reference client's `log: null`) and `logOptions(StreamedLogOptions)` (a custom destination/prefix, +matching a custom `Log` instance) — see [Streamed log redirection](runs.md#streamed-log-redirection). + `lastRun(String status)` filters only by status; `lastRun(LastRunOptions)` also accepts an origin filter. `LastRunOptions` has fluent setters `status(String)` (e.g. `SUCCEEDED`, `RUNNING`) and `origin(String)` (e.g. `API`, `WEB`, `SCHEDULER`); leave a setter uncalled to omit that filter. diff --git a/docs/builds.md b/docs/builds.md index bf2d821..ae1101d 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -34,4 +34,29 @@ if (finished.isTerminal()) { } ``` -`ActorBuildOptions` fields (all optional): `betaPackages`, `tag`, `useCache`, `waitForFinish`. +`build(versionNumber, options)`'s `versionNumber` (e.g. `"0.0"`) selects *which Actor version's +source* to build, matching one of the version numbers under `client.actor(id).versions()`. +`ActorBuildOptions.tag(...)` is unrelated to that: it is the *build tag* (e.g. `"latest"`, `"beta"`) +stamped onto the resulting build, which is what `ActorStartOptions.build(...)` / +`Actor.defaultBuild(...)` later resolve by name — a version can be rebuilt many times under the +same tag, with each new build replacing which build that tag currently points to. + +`ActorBuildOptions` fields (all optional): `betaPackages` (`Boolean`; if `true`, use beta versions +of Apify packages instead of the latest stable ones), `tag` (`String`; the build tag to apply, e.g. +`"latest"` — see above), `useCache` (`Boolean`; whether to reuse the Docker build cache, default +`true`), `waitForFinish` (`Long`; maximum seconds to wait server-side for the build to finish +before the API responds, max 60 — see `defaultBuild` below for the same wait model). + +`ActorClient.defaultBuild(Long waitForFinish)` resolves the Actor's currently-tagged default build +(the build behind the `"latest"`/`"default"` tag, i.e. what a plain `start`/`call` with no explicit +`build` would run) and returns a `BuildClient` handle for it — it does not itself return the `Build` +object. `waitForFinish` only bounds how long the *resolving GET* waits server-side for that build to +finish before responding (`null`/`0` returns immediately with whatever state the build is +currently in); it does not make `defaultBuild` block until the build is done. To actually observe +the finished build, call `.get()` (or `.waitForFinish(...)` for client-side polling) on the returned +handle: + +```java +BuildClient defaultBuild = client.actor("me/my-actor").defaultBuild(30L); +Build finished = defaultBuild.waitForFinish(300L); +``` diff --git a/docs/runs.md b/docs/runs.md index 735409d..7b586c3 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -91,6 +91,16 @@ The status is one of `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `T `ABORTING`, `ABORTED`; `isTerminal()` is true for the finished states (`SUCCEEDED`, `FAILED`, `TIMED-OUT`, `ABORTED`). +`ActorRun` additionally exposes: `getGeneralAccess()` (`String`; who can access the run without +owning it, e.g. `"ANYONE_WITH_ID_CAN_READ"`, `"RESTRICTED"`, or `null` to follow the account/Actor +default), `getChargedEventCounts()` (`Map`; per-event-type charge counts for +pay-per-event Actors, `null` otherwise), `getPricingInfo()` (`JsonNode`; shape depends on the +Actor's pricing model), `getUsage()` / `getUsageUsd()` (`ActorRunUsage`; per-billable-unit resource +consumption, the latter as its USD cost), `getStats()` (`ActorRunStats`; runtime metrics like +`getDurationMillis()`, `getMemAvgBytes()`, `getCpuAvgUsage()`), `getOptions()` (`ActorRunOptions`; +the run configuration actually applied, which may differ from what was requested), and `getMeta()` +(`ActorRunMeta`; `getOrigin()`, `getClientIp()`, `getUserAgent()` — how the run was initiated). + `RunChargeOptions` (constructed with the required event name) uses plain values: `count(Long)` and `idempotencyKey(String)` — the latter is auto-generated when unset so a transport-retried charge is applied at most once. diff --git a/docs/schedules.md b/docs/schedules.md index 97a64cb..c998fba 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -36,4 +36,9 @@ Optional s = client.schedule("SCHEDULE_ID").get(); s.ifPresent(sched -> System.out.println(sched.getCronExpression())); ``` -`Schedule` fields: `getId()`, `getUserId()`, `getName()`, `getCronExpression()`, `isEnabled()`. +`Schedule` fields: `getId()`, `getUserId()`, `getName()`, `getTitle()`, `getDescription()`, +`getCronExpression()`, `getTimezone()` (IANA name, e.g. `UTC`), `isEnabled()`, `isExclusive()` +(skip a new run while a previous one is still active), `getCreatedAt()` / `getModifiedAt()` +(`Instant`), `getNextRunAt()` / `getLastRunAt()` (`Instant`, may be `null`), `getActions()` +(`List`, each entry's shape depends on its `type`), and `getNotifications()` +(`ScheduleNotifications`, exposing `isEmail()`). diff --git a/docs/storages.md b/docs/storages.md index 3f1d13c..a55dbd9 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -152,17 +152,31 @@ as for datasets. | `getRequest(String id)` | Fetch a request. Returns `Optional`. | | `updateRequest(RequestQueueRequest, boolean forefront)` | Update a request. Returns `RequestQueueOperationInfo`. | | `deleteRequest(String id)` | Delete a request. No return value. | -| `batchAddRequests(List, boolean forefront)` | Add many (auto-chunked at 25, unprocessed requests retried). Returns `BatchAddResult`. | +| `batchAddRequests(List, boolean forefront)` | Add many (auto-chunked at 25 requests *and* by the API's ~9 MiB payload-size limit; unprocessed requests retried). Returns `BatchAddResult`. | | `batchAddRequests(List, boolean forefront, BatchAddRequestsOptions)` | As above, tuning `maxUnprocessedRequestsRetries`, `maxParallel` and `minDelayBetweenUnprocessedRequestsRetriesMillis`. | -| `batchDeleteRequests(Object)` | Delete many. Returns `JsonNode`. | -| `listAndLockHead(long lockSecs, Long limit)` | Atomically lock the head. Returns `JsonNode`. | -| `listRequests(ListRequestsOptions)` | List requests. Returns `JsonNode`. | -| `prolongRequestLock(String id, long lockSecs, boolean forefront)` | Extend a lock. Returns `JsonNode`. | +| `batchDeleteRequests(Object)` | Delete many. Returns `BatchDeleteResult`. | +| `listAndLockHead(long lockSecs, Long limit)` | Atomically lock the head. Returns `LockedRequestQueueHead`. | +| `listRequests(ListRequestsOptions)` | List requests. Returns `RequestsList`. | +| `prolongRequestLock(String id, long lockSecs, boolean forefront)` | Extend a lock. Returns `RequestLockInfo`. | | `deleteRequestLock(String id, boolean forefront)` | Release a lock. No return value. | -| `unlockRequests()` | Release all the client's locks. Returns `JsonNode`. | +| `unlockRequests()` | Release all the client's locks. Returns `UnlockRequestsResult`. | | `paginateRequests(Long pageLimit)` | A lazy `Iterator` over all requests, paging with the queue's forward cursor. Equivalent to `paginateRequests(null, pageLimit, null)`. | | `paginateRequests(Long totalLimit, Long chunkSize, List filter)` | As above, with a cap on the total number yielded (`null`/non-positive = unbounded) and an optional state `filter` (`ListRequestsOptions.FILTER_LOCKED`/`FILTER_PENDING`), matching `listRequests`'s filter. | +> **The `forefront` parameter.** `addRequest`, `updateRequest`, `batchAddRequests`, +> `prolongRequestLock` and `deleteRequestLock` all take a `boolean forefront`. It controls queue +> priority: `false` (the common case) processes the request in normal FIFO order; `true` inserts +> or moves the request to the *front* of the queue, so it is returned before anything already +> queued — use it for urgent/priority work that should jump the line, e.g. re-queuing a failed +> request for immediate retry or seeding a crawl's very first URLs. + +```java +// Retry a failed request ahead of everything else already queued. +RequestQueueClient queue = client.requestQueue("QUEUE_ID"); +RequestQueueRequest failed = queue.getRequest("REQUEST_ID").orElseThrow(); +queue.updateRequest(failed.setNoRetry(false), true); +``` + > **Naming exception.** Request-queue *requests* are iterated with `paginateRequests(...)` — not an > `iterate(...)` method — because the request-queue listing is cursor-based rather than > offset/limit; it always starts from the beginning of the queue (resuming from an explicit @@ -190,16 +204,25 @@ fields are omitted on the wire): `setId`, `setUrl`, `setUniqueKey`, `setMethod`, `setUserData(JsonNode)`, `setPayload(String)` (the HTTP request body), `setHeaders(Map)`, `setNoRetry(Boolean)`, `setHandledAt(Instant)`, `setRetryCount(Integer)`, `setLoadedUrl(String)` (the URL actually loaded, after redirects), and -`setErrorMessages(List)`. +`setErrorMessages(List)`. `getLockExpiresAt()` (read-only, no setter) is populated only on +items returned from `listAndLockHead`. Return types: - `RequestQueueOperationInfo` (from `addRequest`/`updateRequest`): `getRequestId()`, `isWasAlreadyPresent()`, `isWasAlreadyHandled()`. - `RequestQueueHead` (from `listHead`): `getItems()` (a list of `RequestQueueRequest`), - `getLimit()`, `isHadMultipleClients()`. + `getLimit()`, `getQueueModifiedAt()` (`Instant`), `isHadMultipleClients()`. - `BatchAddResult` (from `batchAddRequests`): `getProcessedRequests()` (a list of `RequestQueueOperationInfo`) and `getUnprocessedRequests()` (a list of `RequestQueueRequest`). - -The remaining lock/list operations return raw `JsonNode` (see -[Raw JSON values](README.md#raw-json-values)); `batchDeleteRequests(Object)` accepts a -JSON-serializable list of request identifiers (each with an `id` or `uniqueKey`). +- `BatchDeleteResult` (from `batchDeleteRequests`): `getProcessedRequests()` (a list of + `DeletedRequestInfo`, exposing `getId()`/`getUniqueKey()`) and `getUnprocessedRequests()` (a list + of `RequestQueueRequest`). +- `LockedRequestQueueHead` (from `listAndLockHead`): as `RequestQueueHead` plus `getLockSecs()`, + `isQueueHasLockedRequests()` and `getClientKey()`; each item's `getLockExpiresAt()` is populated. +- `RequestsList` (from `listRequests`): `getItems()`, `getLimit()`, `getCursor()`, + `getNextCursor()` (pass to a further `listRequests` call to continue paging). +- `RequestLockInfo` (from `prolongRequestLock`): `getLockExpiresAt()` (`Instant`). +- `UnlockRequestsResult` (from `unlockRequests`): `getUnlockedCount()`. + +`batchDeleteRequests(Object)` accepts a JSON-serializable list of request identifiers (each with an +`id` or `uniqueKey`). diff --git a/docs/tasks.md b/docs/tasks.md index 56aa635..696f4ee 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -25,7 +25,8 @@ Task task = client.tasks().create(Map.of( |---|---| | `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`. | +| `call(Object input, TaskStartOptions, Long waitSecs)` | Start and poll until finished; does **not** stream the run's log. Returns `ActorRun`. | +| `call(Object input, TaskCallOptions, Long waitSecs)` | As above, additionally streaming the run's log for the duration of the wait by default (matching the reference client's `call` defaulting `options.log` to `'default'`). Use `TaskCallOptions.disableLogStreaming()` to opt out, or `logOptions(StreamedLogOptions)` for a custom destination. | | `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)). | @@ -34,12 +35,23 @@ Task task = client.tasks().create(Map.of( `TaskStartOptions` mirrors `ActorStartOptions` but omits the Actor-only `contentType` and `forcePermissionLevel`: `build`, `memoryMbytes`, `timeoutSecs`, `waitForFinish`, `maxItems`, -`maxTotalChargeUsd`, `restartOnError`, `webhooks`. +`maxTotalChargeUsd`, `restartOnError`, `webhooks`. `TaskCallOptions` mirrors `TaskStartOptions` in +turn, but additionally omits `waitForFinish`: that field asks the API to hold the HTTP response +open server-side while the run finishes, which is redundant with (and wastes a request slot next +to) `call`'s own client-side `waitSecs` polling. ```java ActorRun run = client.task("TASK_ID").call(null, new TaskStartOptions().memoryMbytes(512L), 120L); System.out.println(run.getStatus()); + +// Streams the run's log to a default per-run logger for the duration of the wait. +ActorRun streamed = client.task("TASK_ID").call(null, new TaskCallOptions().memoryMbytes(512L), 120L); ``` -`Task` fields: `getId()`, `getActId()`, `getUserId()`, `getName()`, `getTitle()`, `getCreatedAt()`, -`getModifiedAt()`. +`Task` fields: `getId()`, `getActId()`, `getUserId()`, `getName()`, `getTitle()`, +`getDescription()`, `getCreatedAt()`, `getModifiedAt()`, `getStats()` (`TaskStats`, exposing +`getTotalRuns()`), `getOptions()` (`TaskOptions`: the task's stored default run configuration — +`getBuild()`, `getTimeoutSecs()`, `getMemoryMbytes()`, `getRestartOnError()`), `getInput()` (a +`JsonNode` snapshot of the stored input, from whichever response last returned this `Task` object; +prefer `TaskClient.getInput()` above to fetch it fresh on-demand), and `getActorStandby()` +(`ActorStandby`, standby-mode configuration overrides for this task, if any). diff --git a/docs/webhooks.md b/docs/webhooks.md index 598746b..daa64b6 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -50,7 +50,13 @@ WebhookDispatch dispatch = client.webhook("WEBHOOK_ID").test(); System.out.println(dispatch.getId()); ``` -`Webhook` fields: `getId()`, `getUserId()`, `getRequestUrl()`, `getEventTypes()`. +`Webhook` fields: `getId()`, `getUserId()`, `getRequestUrl()`, `getEventTypes()`, `getCreatedAt()` +/ `getModifiedAt()` (`Instant`), `isAdHoc()` (a one-off webhook attached to a single run, e.g. via +`ActorStartOptions.webhooks(...)`, rather than a persistent account-level webhook), +`getCondition()` (`JsonNode`; one of an Actor ID, a task ID or a specific run ID, depending on how +the webhook was configured), `isIgnoreSslErrors()`, `isDoNotRetry()`, `getPayloadTemplate()`, +`getHeadersTemplate()`, `getDescription()`, and `getStats()` (`WebhookStats`, exposing +`getTotalDispatches()`). ## `WebhookDispatchCollectionClient` and `WebhookDispatchClient` diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index d1c727a..f56085a 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -17,9 +17,10 @@ guards against; there is nothing to defensively copy for a service object. 3. UWF_UNWRITTEN_FIELD / UWF_UNWRITTEN_PUBLIC_OR_PROTECTED_FIELD on Jackson-populated DTOs/response - shapes ({@code KeyValueStoreKey}, the internal error-response mapping in {@code - HttpClientCore}): their fields are written by the deserializer via reflection, which SpotBugs - cannot see - a known false positive for data-binding models. + shapes ({@code KeyValueStoreKey}, {@code DeletedRequestInfo}, {@code ActorRunMeta}, {@code + TaskOptions}, the internal error-response mapping in {@code HttpClientCore}): their fields are + written by the deserializer via reflection, which SpotBugs cannot see - a known false positive + for data-binding models. --> @@ -35,7 +36,12 @@ - + + + + + + diff --git a/src/main/java/com/apify/client/ListOptions.java b/src/main/java/com/apify/client/ListOptions.java index 74d185a..d48bf66 100644 --- a/src/main/java/com/apify/client/ListOptions.java +++ b/src/main/java/com/apify/client/ListOptions.java @@ -1,5 +1,6 @@ package com.apify.client; +import com.apify.client.internal.ListOptionsLike; import com.apify.client.internal.QueryParams; /** @@ -7,7 +8,7 @@ * schedules, webhooks, Actor versions). All fields are optional; leave one unset to use the API * default. */ -public final class ListOptions { +public final class ListOptions implements ListOptionsLike { private Long offset; private Long limit; private Boolean desc; @@ -35,14 +36,17 @@ public ListOptions desc(Boolean desc) { return this; } + @Override public Long offsetValue() { return offset; } + @Override public Long limitValue() { return limit; } + @Override public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); @@ -51,6 +55,7 @@ public void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ + @Override public void applyFilters(QueryParams q) { q.addBool("desc", desc); } diff --git a/src/main/java/com/apify/client/StorageListOptions.java b/src/main/java/com/apify/client/StorageListOptions.java index 09112e8..0afd09c 100644 --- a/src/main/java/com/apify/client/StorageListOptions.java +++ b/src/main/java/com/apify/client/StorageListOptions.java @@ -1,5 +1,6 @@ package com.apify.client; +import com.apify.client.internal.ListOptionsLike; import com.apify.client.internal.QueryParams; /** @@ -7,7 +8,7 @@ * /v2/key-value-stores}, {@code /v2/request-queues}), which add {@code unnamed} and {@code * ownership} filters on top of the standard pagination. */ -public final class StorageListOptions { +public final class StorageListOptions implements ListOptionsLike { private Long offset; private Long limit; private Boolean desc; @@ -48,14 +49,17 @@ public StorageListOptions ownership(String ownership) { return this; } + @Override public Long offsetValue() { return offset; } + @Override public Long limitValue() { return limit; } + @Override public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); @@ -64,6 +68,7 @@ public void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ + @Override public void applyFilters(QueryParams q) { q.addBool("desc", desc).addBool("unnamed", unnamed).addString("ownership", ownership); } diff --git a/src/main/java/com/apify/client/actor/ActorCallOptions.java b/src/main/java/com/apify/client/actor/ActorCallOptions.java new file mode 100644 index 0000000..38da85f --- /dev/null +++ b/src/main/java/com/apify/client/actor/ActorCallOptions.java @@ -0,0 +1,115 @@ +package com.apify.client.actor; + +import com.apify.client.log.StreamedLogOptions; +import java.util.List; + +/** + * Configures {@link ActorClient#call(Object, ActorCallOptions, Long)}. + * + *

Mirrors {@link ActorStartOptions} (delegating every field to an internal instance) but omits + * {@code waitForFinish}: that field asks the API to hold the HTTP response open server-side while + * the run finishes, which is redundant with (and wastes a request slot next to) {@code call}'s own + * client-side {@code waitSecs} polling. This matches the reference client, whose dedicated {@code + * ActorCallOptions} type is {@code Omit}. + * + *

By default, {@code call} also streams the run's log to a default per-run logger for the + * duration of the wait (via {@link com.apify.client.run.RunClient#getStreamedLog()}), matching the + * reference client's {@code options.log} defaulting to {@code 'default'}. Use {@link + * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom + * destination. + */ +public final class ActorCallOptions { + + private final ActorStartOptions startOptions = new ActorStartOptions(); + private boolean logStreamingEnabled = true; + private StreamedLogOptions logOptions; + + /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ + public ActorCallOptions build(String build) { + startOptions.build(build); + return this; + } + + /** Memory in megabytes allocated for the run. */ + public ActorCallOptions memoryMbytes(Long memoryMbytes) { + startOptions.memoryMbytes(memoryMbytes); + return this; + } + + /** Timeout for the run in seconds (0 means no timeout). */ + public ActorCallOptions timeoutSecs(Long timeoutSecs) { + startOptions.timeoutSecs(timeoutSecs); + return this; + } + + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public ActorCallOptions maxItems(Long maxItems) { + startOptions.maxItems(maxItems); + return this; + } + + /** Maximum total charge in USD (pay-per-event Actors). */ + public ActorCallOptions maxTotalChargeUsd(Double maxTotalChargeUsd) { + startOptions.maxTotalChargeUsd(maxTotalChargeUsd); + return this; + } + + /** The content type of the input body. Defaults to {@code application/json}. */ + public ActorCallOptions contentType(String contentType) { + startOptions.contentType(contentType); + return this; + } + + /** If {@code true}, restart the run if it fails. */ + public ActorCallOptions restartOnError(Boolean restartOnError) { + startOptions.restartOnError(restartOnError); + return this; + } + + /** + * Override the Actor's permission level for this run ({@code LIMITED_PERMISSIONS}/{@code + * FULL_PERMISSIONS}). + */ + public ActorCallOptions forcePermissionLevel(String forcePermissionLevel) { + startOptions.forcePermissionLevel(forcePermissionLevel); + return this; + } + + /** Ad-hoc webhooks to attach to this run (serialized to base64-encoded JSON). */ + public ActorCallOptions webhooks(List webhooks) { + startOptions.webhooks(webhooks); + return this; + } + + /** + * Disables the default log streaming, matching the reference client's {@code log: null}. {@code + * call} then only starts the run and polls for completion, as if built from {@link + * ActorStartOptions} directly. + */ + public ActorCallOptions disableLogStreaming() { + this.logStreamingEnabled = false; + return this; + } + + /** + * Customizes log streaming (destination, prefix, whether to include pre-existing log lines) + * instead of using the default per-run logger. Implies streaming is enabled. + */ + public ActorCallOptions logOptions(StreamedLogOptions logOptions) { + this.logOptions = logOptions; + this.logStreamingEnabled = true; + return this; + } + + ActorStartOptions toStartOptions() { + return startOptions; + } + + boolean logStreamingEnabledValue() { + return logStreamingEnabled; + } + + StreamedLogOptions logOptionsValue() { + return logOptions; + } +} diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index 1c8f9b9..bfc941c 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -9,6 +9,7 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; +import com.apify.client.log.StreamedLog; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -74,12 +75,62 @@ public ActorRun start(Object input, ActorStartOptions options) { * Starts the Actor and waits (client-side polling) for it to finish. {@code waitSecs} bounds the * wait; {@code null} waits indefinitely. Returns the finished run (or the still-running run if * the wait budget was exhausted). + * + *

This overload does not stream the run's log; use {@link #call(Object, ActorCallOptions, + * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { ActorRun run = start(input, options); return root.run(run.getId()).waitForFinish(waitSecs); } + /** + * Starts the Actor and waits (client-side polling) for it to finish, additionally streaming the + * run's log for the duration of the wait — matching the reference client's {@code call}, whose + * {@code options.log} defaults to {@code 'default'}. {@code waitSecs} bounds the wait; {@code + * null} waits indefinitely. Returns the finished run (or the still-running run if the wait budget + * was exhausted). + * + *

Log streaming is best-effort: if starting it fails (e.g. the log is not yet available), the + * run still starts and is still waited for, just without redirected log output. Use {@link + * ActorCallOptions#disableLogStreaming()} to opt out entirely, or {@link + * ActorCallOptions#logOptions(com.apify.client.log.StreamedLogOptions)} for a custom destination. + */ + public ActorRun call(Object input, ActorCallOptions options, Long waitSecs) { + ActorCallOptions opts = options != null ? options : new ActorCallOptions(); + ActorRun run = start(input, opts.toStartOptions()); + RunClient runClient = root.run(run.getId()); + + StreamedLog streamedLog = null; + if (opts.logStreamingEnabledValue()) { + streamedLog = startStreamedLogQuietly(runClient, opts); + } + try { + return runClient.waitForFinish(waitSecs); + } finally { + if (streamedLog != null) { + streamedLog.close(); + } + } + } + + /** + * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure + * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. + */ + private static StreamedLog startStreamedLogQuietly(RunClient runClient, ActorCallOptions opts) { + try { + StreamedLog streamedLog = + opts.logOptionsValue() != null + ? runClient.getStreamedLog(opts.logOptionsValue()) + : runClient.getStreamedLog(); + streamedLog.start(); + return streamedLog; + } catch (RuntimeException e) { + return null; + } + } + /** Validates the given input against the Actor's default-build input schema. */ public boolean validateInput(Object input) { return validateInput(input, new ValidateInputOptions()); diff --git a/src/main/java/com/apify/client/actor/ActorCollectionClient.java b/src/main/java/com/apify/client/actor/ActorCollectionClient.java index a0fbda2..a1735e2 100644 --- a/src/main/java/com/apify/client/actor/ActorCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorCollectionClient.java @@ -1,43 +1,19 @@ package com.apify.client.actor; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import java.util.Iterator; /** A client for the Actor collection ({@code GET/POST /v2/actors}). */ -public final class ActorCollectionClient { - private final ResourceContext ctx; +public final class ActorCollectionClient extends AbstractCollectionClient { public ActorCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.ACTORS); - } - - /** Lists the account's Actors. */ - public PaginationList list(ActorListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.ACTORS), + Actor.class, + ActorListOptions::new); } /** Creates a new Actor. {@code actor} is any JSON-serializable Actor definition. */ diff --git a/src/main/java/com/apify/client/actor/ActorListOptions.java b/src/main/java/com/apify/client/actor/ActorListOptions.java index 86b0e38..37fa112 100644 --- a/src/main/java/com/apify/client/actor/ActorListOptions.java +++ b/src/main/java/com/apify/client/actor/ActorListOptions.java @@ -1,9 +1,10 @@ package com.apify.client.actor; +import com.apify.client.internal.ListOptionsLike; import com.apify.client.internal.QueryParams; /** Options for {@link ActorCollectionClient#list(ActorListOptions)}. */ -public final class ActorListOptions { +public final class ActorListOptions implements ListOptionsLike { private Long offset; private Long limit; private Boolean desc; @@ -43,15 +44,18 @@ public ActorListOptions sortBy(String sortBy) { return this; } - Long offsetValue() { + @Override + public Long offsetValue() { return offset; } - Long limitValue() { + @Override + public Long limitValue() { return limit; } - void apply(QueryParams q) { + @Override + public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); } @@ -59,7 +63,8 @@ void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ - void applyFilters(QueryParams q) { + @Override + public void applyFilters(QueryParams q) { q.addBool("desc", desc).addBool("my", my).addString("sortBy", sortBy); } } diff --git a/src/main/java/com/apify/client/actor/ActorStandby.java b/src/main/java/com/apify/client/actor/ActorStandby.java new file mode 100644 index 0000000..33e874a --- /dev/null +++ b/src/main/java/com/apify/client/actor/ActorStandby.java @@ -0,0 +1,50 @@ +package com.apify.client.actor; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Standby-mode configuration for an Actor, or for a task that overrides its Actor's defaults. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorStandby { + private String build; + private Long desiredRequestsPerActorRun; + private Boolean disableStandbyFieldsOverride; + private Long idleTimeoutSecs; + private Long maxRequestsPerActorRun; + private Long memoryMbytes; + private Boolean shouldPassActorInput; + + /** The tag or number of the build used to serve standby requests. */ + public String getBuild() { + return build; + } + + /** The desired number of concurrent requests handled by a single standby run. */ + public Long getDesiredRequestsPerActorRun() { + return desiredRequestsPerActorRun; + } + + /** Whether a task-level override of these standby fields is disabled. */ + public Boolean getDisableStandbyFieldsOverride() { + return disableStandbyFieldsOverride; + } + + /** Seconds of inactivity after which an idle standby run is shut down. */ + public Long getIdleTimeoutSecs() { + return idleTimeoutSecs; + } + + /** The maximum number of concurrent requests a single standby run handles. */ + public Long getMaxRequestsPerActorRun() { + return maxRequestsPerActorRun; + } + + /** Memory in megabytes allocated to each standby run. */ + public Long getMemoryMbytes() { + return memoryMbytes; + } + + /** Whether the incoming HTTP request is passed as the standby run's input. */ + public Boolean getShouldPassActorInput() { + return shouldPassActorInput; + } +} diff --git a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java index 6154fb5..0ea9ce9 100644 --- a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java @@ -31,18 +31,13 @@ public PaginationList list(ListOptions options) { /** * 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, not on first {@code - * next()}. The full explanation is why {@code GET .../versions} cannot use the offset/limit - * paging engine at all (next paragraph). - * - *

{@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. + * next()}. That's because {@code GET /v2/actors/{actorId}/versions} is not offset/limit paginated + * at all — it takes no pagination parameters and always returns the full version list in one + * {@code {total, items}} response, so routing it through the offset/limit paging engine would + * loop forever (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 and there is no page size to tune. */ public Iterator iterate(ListOptions options) { ListOptions opts = options != null ? options : new ListOptions(); diff --git a/src/main/java/com/apify/client/build/BuildCollectionClient.java b/src/main/java/com/apify/client/build/BuildCollectionClient.java index 96b164c..9a29bb5 100644 --- a/src/main/java/com/apify/client/build/BuildCollectionClient.java +++ b/src/main/java/com/apify/client/build/BuildCollectionClient.java @@ -1,43 +1,17 @@ package com.apify.client.build; import com.apify.client.ListOptions; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -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}). */ -public final class BuildCollectionClient { - private final ResourceContext ctx; +public final class BuildCollectionClient extends AbstractCollectionClient { public BuildCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { - this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); - } - - /** Lists builds. */ - public PaginationList list(ListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, Build.class); - } - - /** - * 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); + super(ResourceContext.collection(http, baseUrl, resourcePath), Build.class, ListOptions::new); } } diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index c1606b2..e74178e 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -18,6 +18,16 @@ /** A client for a specific dataset (and run-nested variants). */ public final class DatasetClient { + + /** Header reporting the total number of items available (not just in this page). */ + private static final String HEADER_PAGINATION_TOTAL = "X-Apify-Pagination-Total"; + + /** Header reporting the offset this page started at. */ + private static final String HEADER_PAGINATION_OFFSET = "X-Apify-Pagination-Offset"; + + /** Header reporting the effective page size limit applied to this page. */ + private static final String HEADER_PAGINATION_LIMIT = "X-Apify-Pagination-Limit"; + private final HttpClientCore http; private final ResourceContext ctx; @@ -152,9 +162,9 @@ private PaginationList fetchItemsPage( PaginationList result = new PaginationList<>(); result.setItems(items); result.setCount(count); - result.setTotal(headerLong(resp, "X-Apify-Pagination-Total", count)); - result.setOffset(headerLong(resp, "X-Apify-Pagination-Offset", 0)); - result.setLimit(headerLong(resp, "X-Apify-Pagination-Limit", count)); + result.setTotal(headerLong(resp, HEADER_PAGINATION_TOTAL, count)); + result.setOffset(headerLong(resp, HEADER_PAGINATION_OFFSET, 0)); + result.setLimit(headerLong(resp, HEADER_PAGINATION_LIMIT, count)); if (desc != null) { result.setDesc(desc); } diff --git a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java index 000b47b..0ac8433 100644 --- a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java @@ -1,44 +1,20 @@ package com.apify.client.dataset; -import com.apify.client.PaginationList; import com.apify.client.StorageListOptions; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import java.util.Iterator; /** A client for the dataset collection ({@code GET/POST /v2/datasets}). */ -public final class DatasetCollectionClient { - private final ResourceContext ctx; +public final class DatasetCollectionClient + extends AbstractCollectionClient { public DatasetCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.DATASETS); - } - - /** Lists datasets. */ - public PaginationList list(StorageListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.DATASETS), + Dataset.class, + StorageListOptions::new); } /** diff --git a/src/main/java/com/apify/client/internal/AbstractCollectionClient.java b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java new file mode 100644 index 0000000..e737d12 --- /dev/null +++ b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java @@ -0,0 +1,85 @@ +package com.apify.client.internal; + +import com.apify.client.PaginationList; +import java.util.Iterator; +import java.util.function.Supplier; + +/** + * Shared {@code list}/{@code iterate} behavior for an offset/limit-paginated collection endpoint, + * generic over the item type {@code T} and its options type {@code O}. Follows the same shape as + * {@code com.apify.client.webhook.AbstractWebhookCollectionClient} (which predates the generic + * {@code Class} witness {@link ResourceContext#listResource}/{@link + * ResourceContext#iterateResource} take, and stays a separate, non-generic base since only one + * concrete subclass pair needs it) but factors the item-class witness and the "no options -> + * default options" fallback out so every other offset/limit-paginated collection client (Dataset, + * KeyValueStore, RequestQueue, Actor, Schedule, Task, Build) can extend this one instead of + * repeating the same three methods. + * + *

{@code RunCollectionClient} additionally takes a run-specific status filter alongside {@code + * O}, so it does not extend this class directly; it still avoids duplicating the {@code + * ctx}/item-class plumbing by building on the protected helpers here. + */ +public abstract class AbstractCollectionClient { + + protected final ResourceContext ctx; + private final Class itemClass; + private final Supplier defaultOptions; + + protected AbstractCollectionClient( + ResourceContext ctx, Class itemClass, Supplier defaultOptions) { + this.ctx = ctx; + this.itemClass = itemClass; + this.defaultOptions = defaultOptions; + } + + /** Lists the collection's items for one page. */ + public PaginationList list(O options) { + QueryParams params = new QueryParams(); + options.apply(params); + return listWithParams(params); + } + + /** + * Returns a lazy iterator over the collection. 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(O options) { + return iterate(options, null); + } + + /** As {@link #iterate(ListOptionsLike)}, but {@code chunkSize} sets the per-request page size. */ + public Iterator iterate(O options, Long chunkSize) { + O opts = options != null ? options : defaultOptions.get(); + return iterateWithFilters(opts.limitValue(), chunkSize, opts.offsetValue(), opts::applyFilters); + } + + /** + * Fetches one page using an already-built {@code params} (offset/limit plus filters). Exposed for + * subclasses (e.g. {@code RunCollectionClient}) that need to merge in an extra filter ({@code + * options} alone cannot express) before delegating to the same {@code ctx}/item-class. + */ + protected final PaginationList listWithParams(QueryParams params) { + return ctx.listResource("", params, itemClass); + } + + /** + * As {@link #listWithParams}, but for lazy iteration: {@code applyFilters} supplies every filter + * except {@code offset}/{@code limit}, which the iterator drives per page. + */ + protected final Iterator iterateWithFilters( + Long limit, + Long chunkSize, + Long offset, + java.util.function.Consumer applyFilters) { + return ctx.iterateResource("", limit, chunkSize, offset, applyFilters, itemClass); + } + + /** + * The default options instance used by {@link #iterate(ListOptionsLike)} when passed {@code + * null}. + */ + protected final O defaultOptions() { + return defaultOptions.get(); + } +} diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index 6b0a9b8..7dc4071 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -21,9 +21,9 @@ import java.util.zip.GZIPOutputStream; /** - * The orchestrating HTTP client shared by every resource client. It owns the backend, the optional - * API token, the {@code User-Agent}, and the retry/timeout policy, and applies them to every - * request. Internal to the client; safe for concurrent use. + * The orchestrating HTTP client shared by every resource client. It owns the transport, the + * optional API token, the {@code User-Agent}, and the retry/timeout policy, and applies them to + * every request. Internal to the client; safe for concurrent use. */ public final class HttpClientCore { @@ -55,6 +55,9 @@ public final class HttpClientCore { /** {@code Content-Encoding} value for gzip-compressed bodies (the fallback coding). */ private static final String ENCODING_GZIP = "gzip"; + /** Prefix required before the token value in the {@code Authorization} header. */ + private static final String AUTH_BEARER_PREFIX = "Bearer "; + /** * Whether a brotli native codec loaded for the running platform. Resolved once at class load: * brotli4j needs a platform-specific native library, so on platforms without one (or if loading @@ -84,13 +87,14 @@ public static boolean brotliAvailable() { return BROTLI_AVAILABLE; } - private final HttpTransport backend; + private final HttpTransport transport; private final String token; private final String userAgent; private final RetryConfig retry; - public HttpClientCore(HttpTransport backend, String token, String userAgent, RetryConfig retry) { - this.backend = backend; + public HttpClientCore( + HttpTransport transport, String token, String userAgent, RetryConfig retry) { + this.transport = transport; this.token = token; this.userAgent = userAgent; this.retry = retry; @@ -224,12 +228,12 @@ HttpRequest buildRequest( : HttpRequest.BodyPublishers.noBody(); HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method(method, publisher).timeout(timeout); - b.header("User-Agent", userAgent); + b.header(HttpHeaders.USER_AGENT, userAgent); if (token != null && !token.isEmpty()) { - b.header("Authorization", "Bearer " + token); + b.header(HttpHeaders.AUTHORIZATION, AUTH_BEARER_PREFIX + token); } if (contentType != null && !contentType.isEmpty()) { - b.header("Content-Type", contentType); + b.header(HttpHeaders.CONTENT_TYPE, contentType); } if (extraHeaders != null) { extraHeaders.forEach(b::header); @@ -246,7 +250,7 @@ private ApiResponse doAttempt( Duration timeout) { HttpRequest request = buildRequest(method, url, body, contentType, extraHeaders, timeout); try { - HttpResponse resp = backend.send(request); + HttpResponse resp = transport.send(request); return new ApiResponse(resp.statusCode(), resp.headers(), resp.body()); } catch (IOException e) { throw new ApifyTransportException(e); @@ -430,7 +434,7 @@ public static String extractPath(String url) { public HttpResponse stream(String url) { HttpRequest request = buildRequest("GET", url, null, null, null, retry.timeout); try { - return backend.sendStreamingResponse(request); + return transport.sendStreamingResponse(request); } catch (IOException e) { throw new ApifyTransportException(e); } catch (InterruptedException e) { diff --git a/src/main/java/com/apify/client/internal/HttpHeaders.java b/src/main/java/com/apify/client/internal/HttpHeaders.java new file mode 100644 index 0000000..e07c5b9 --- /dev/null +++ b/src/main/java/com/apify/client/internal/HttpHeaders.java @@ -0,0 +1,20 @@ +package com.apify.client.internal; + +/** + * Names of the standard HTTP headers this client sets or reads on more than one call site, named + * and collected in one place so every reader/writer references the same constant instead of + * repeating the literal. + */ +public final class HttpHeaders { + + /** The mandated User-Agent request header name. */ + public static final String USER_AGENT = "User-Agent"; + + /** The bearer-token authentication header name. */ + public static final String AUTHORIZATION = "Authorization"; + + /** The request/response body media-type header name. */ + public static final String CONTENT_TYPE = "Content-Type"; + + private HttpHeaders() {} +} diff --git a/src/main/java/com/apify/client/internal/ListOptionsLike.java b/src/main/java/com/apify/client/internal/ListOptionsLike.java new file mode 100644 index 0000000..56ba25d --- /dev/null +++ b/src/main/java/com/apify/client/internal/ListOptionsLike.java @@ -0,0 +1,25 @@ +package com.apify.client.internal; + +/** + * The common shape every offset/limit-paginated {@code list(...)} options type ({@code + * com.apify.client.ListOptions}, {@code StorageListOptions}, {@code ActorListOptions}, ...) + * implements, so {@link AbstractCollectionClient} can build query params and drive iteration + * generically across all of them. + */ +public interface ListOptionsLike { + + /** Number of items to skip from the beginning of the list, or {@code null} for the default. */ + Long offsetValue(); + + /** Maximum number of items to return, or {@code null} for the default/unbounded. */ + Long limitValue(); + + /** Applies every query parameter, including {@code offset}/{@code limit}. */ + void apply(QueryParams q); + + /** + * Applies every filter except {@code offset}/{@code limit}, which a paging iterator drives per + * page rather than taking from the options object. + */ + void applyFilters(QueryParams q); +} diff --git a/src/main/java/com/apify/client/internal/QueryParams.java b/src/main/java/com/apify/client/internal/QueryParams.java index 9e78c5f..a0ca627 100644 --- a/src/main/java/com/apify/client/internal/QueryParams.java +++ b/src/main/java/com/apify/client/internal/QueryParams.java @@ -11,14 +11,17 @@ */ public final class QueryParams { - private final List pairs = new ArrayList<>(); + private final List pairs = new ArrayList<>(); public QueryParams() {} + /** A single already-encoded (not yet URL-escaped) query parameter key/value pair. */ + private record Pair(String key, String value) {} + /** Adds a string parameter if the value is non-null. */ public QueryParams addString(String key, String value) { if (value != null) { - pairs.add(new String[] {key, value}); + pairs.add(new Pair(key, value)); } return this; } @@ -26,7 +29,7 @@ public QueryParams addString(String key, String value) { /** Adds an integer parameter if the value is non-null. */ public QueryParams addLong(String key, Long value) { if (value != null) { - pairs.add(new String[] {key, Long.toString(value)}); + pairs.add(new Pair(key, Long.toString(value))); } return this; } @@ -34,7 +37,7 @@ public QueryParams addLong(String key, Long value) { /** Adds a floating-point parameter if the value is non-null. */ public QueryParams addDouble(String key, Double value) { if (value != null) { - pairs.add(new String[] {key, Double.toString(value)}); + pairs.add(new Pair(key, Double.toString(value))); } return this; } @@ -42,7 +45,7 @@ public QueryParams addDouble(String key, Double value) { /** Adds a boolean parameter, encoded as {@code 1}/{@code 0}, if the value is non-null. */ public QueryParams addBool(String key, Boolean value) { if (value != null) { - pairs.add(new String[] {key, value ? "1" : "0"}); + pairs.add(new Pair(key, value ? "1" : "0")); } return this; } @@ -50,14 +53,14 @@ public QueryParams addBool(String key, Boolean value) { /** Adds a comma-joined list parameter if the list is non-null and non-empty. */ public QueryParams addCsv(String key, List values) { if (values != null && !values.isEmpty()) { - pairs.add(new String[] {key, String.join(",", values)}); + pairs.add(new Pair(key, String.join(",", values))); } return this; } /** Appends an already-stringified key/value pair unconditionally. */ public QueryParams addRaw(String key, String value) { - pairs.add(new String[] {key, value}); + pairs.add(new Pair(key, value)); return this; } @@ -90,8 +93,8 @@ public String applyToUrl(String rawUrl) { if (i > 0) { b.append('&'); } - String[] p = pairs.get(i); - b.append(encode(p[0])).append('=').append(encode(p[1])); + Pair p = pairs.get(i); + b.append(encode(p.key())).append('=').append(encode(p.value())); } String sep = rawUrl.contains("?") ? "&" : "?"; return rawUrl + sep + b; diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index 7767780..7485bb5 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -46,6 +46,16 @@ public final class ResourceContext { private static final int NOT_FOUND = 404; + /** API error type for a resource that does not exist. */ + private static final String ERROR_TYPE_RECORD_NOT_FOUND = "record-not-found"; + + /** + * API error type for a resource that does not exist, or exists but the caller's token cannot see + * it (the API deliberately does not distinguish the two, to avoid leaking existence to an + * unauthorized caller). + */ + private static final String ERROR_TYPE_RECORD_OR_TOKEN_NOT_FOUND = "record-or-token-not-found"; + public final HttpClientCore http; /** Fully-qualified base URL of the resource, e.g. {@code https://api.apify.com/v2/actors/ID}. */ @@ -455,8 +465,8 @@ public static boolean isNotFound(ApifyApiException e) { return false; } String type = e.getType(); - return "record-not-found".equals(type) - || "record-or-token-not-found".equals(type) + return ERROR_TYPE_RECORD_NOT_FOUND.equals(type) + || ERROR_TYPE_RECORD_OR_TOKEN_NOT_FOUND.equals(type) || "HEAD".equals(e.getHttpMethod()); } diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index cc410cf..ab8c4bf 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -4,6 +4,7 @@ import com.apify.client.internal.ApiPaths; import com.apify.client.internal.Extras; import com.apify.client.internal.HttpClientCore; +import com.apify.client.internal.HttpHeaders; import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; @@ -191,7 +192,7 @@ public Optional getRecord(String key, GetRecordOptions opti if (resp == null) { return Optional.empty(); } - String contentType = resp.headers.firstValue("Content-Type").orElse(null); + String contentType = resp.headers.firstValue(HttpHeaders.CONTENT_TYPE).orElse(null); return Optional.of(new KeyValueStoreRecord(key, resp.body, contentType)); } diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java index 55a28c1..8e821d0 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java @@ -1,49 +1,20 @@ package com.apify.client.keyvalue; -import com.apify.client.PaginationList; import com.apify.client.StorageListOptions; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -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; +public final class KeyValueStoreCollectionClient + extends AbstractCollectionClient { public KeyValueStoreCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.KEY_VALUE_STORES); - } - - /** Lists key-value stores. */ - public PaginationList list(StorageListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, KeyValueStore.class); - } - - /** - * 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.KEY_VALUE_STORES), + KeyValueStore.class, + StorageListOptions::new); } /** diff --git a/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java new file mode 100644 index 0000000..8349161 --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java @@ -0,0 +1,22 @@ +package com.apify.client.requestqueue; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Collections; +import java.util.List; + +/** The result of {@link RequestQueueClient#batchDeleteRequests}: deleted and failed requests. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BatchDeleteResult { + private List processedRequests = List.of(); + private List unprocessedRequests = List.of(); + + /** The requests the API successfully deleted (unmodifiable). */ + public List getProcessedRequests() { + return Collections.unmodifiableList(processedRequests); + } + + /** The requests the API did not delete, e.g. due to rate limiting (unmodifiable). */ + public List getUnprocessedRequests() { + return Collections.unmodifiableList(unprocessedRequests); + } +} diff --git a/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java b/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java new file mode 100644 index 0000000..be9e2d2 --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java @@ -0,0 +1,24 @@ +package com.apify.client.requestqueue; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Confirmation that a request was successfully deleted by {@link + * RequestQueueClient#batchDeleteRequests}, identified by whichever of {@code id}/{@code uniqueKey} + * the API returned. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class DeletedRequestInfo { + private String id; + private String uniqueKey; + + /** The ID of the deleted request. */ + public String getId() { + return id; + } + + /** The unique key of the deleted request. */ + public String getUniqueKey() { + return uniqueKey; + } +} diff --git a/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java b/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java new file mode 100644 index 0000000..782b171 --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java @@ -0,0 +1,56 @@ +package com.apify.client.requestqueue; + +import com.apify.client.ApifyResource; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +/** + * The head (front) of a request queue, atomically locked for processing by {@link + * RequestQueueClient#listAndLockHead}. Each item's {@link RequestQueueRequest#getLockExpiresAt()} + * reports its individual lock expiry. + */ +public final class LockedRequestQueueHead extends ApifyResource { + private long limit; + private Instant queueModifiedAt; + private boolean hadMultipleClients; + private boolean queueHasLockedRequests; + private String clientKey; + private long lockSecs; + private List items = List.of(); + + /** The maximum number of requests requested. */ + public long getLimit() { + return limit; + } + + /** When the queue was last modified. */ + public Instant getQueueModifiedAt() { + return queueModifiedAt; + } + + /** Whether multiple clients have accessed the queue. */ + public boolean isHadMultipleClients() { + return hadMultipleClients; + } + + /** Whether the queue has requests locked by any client (this one or a different one). */ + public boolean isQueueHasLockedRequests() { + return queueHasLockedRequests; + } + + /** The client key used for locking the returned requests. */ + public String getClientKey() { + return clientKey; + } + + /** The number of seconds the locks are held for. */ + public long getLockSecs() { + return lockSecs; + } + + /** The locked requests at the head of the queue. */ + public List getItems() { + return Collections.unmodifiableList(items); + } +} diff --git a/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java b/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java new file mode 100644 index 0000000..0a6e011 --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java @@ -0,0 +1,15 @@ +package com.apify.client.requestqueue; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; + +/** Returned by {@link RequestQueueClient#prolongRequestLock}: the request's new lock expiry. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class RequestLockInfo { + private Instant lockExpiresAt; + + /** When the (extended) lock on the request expires. */ + public Instant getLockExpiresAt() { + return lockExpiresAt; + } +} diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index 9f473eb..d52ee7b 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -7,8 +7,6 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; @@ -28,6 +26,26 @@ public final class RequestQueueClient { /** The API limit on requests per batch call; larger inputs are split into chunks of this size. */ private static final int MAX_REQUESTS_PER_BATCH = 25; + /** + * The API's request-body payload-size limit for a batch-add call, in bytes (9 MiB), matching the + * reference JS client's {@code MAX_PAYLOAD_SIZE_BYTES}. A batch of up to {@link + * #MAX_REQUESTS_PER_BATCH} requests can still exceed this if the requests are individually large + * (e.g. sizeable {@code userData}), so {@link #batchAddRequests(List, boolean, + * BatchAddRequestsOptions)} additionally splits chunks by cumulative JSON-encoded byte size to + * avoid a 413 (Payload Too Large). + */ + private static final long MAX_PAYLOAD_SIZE_BYTES = 9L * 1024 * 1024; + + /** + * Safety margin subtracted from {@link #MAX_PAYLOAD_SIZE_BYTES} before slicing by byte size, so a + * chunk sized right at the boundary is not rejected due to minor framing overhead. Matches the + * reference client's {@code SAFETY_BUFFER_PERCENT}. + */ + private static final double PAYLOAD_SAFETY_BUFFER_PERCENT = 0.01 / 100; + + /** The two bytes of an empty JSON array ({@code []}), the baseline for a byte-size chunk. */ + private static final int EMPTY_JSON_ARRAY_BYTES = 2; + /** * Upper bound on the unprocessed-requests backoff exponent, so {@code 2^attempt} cannot blow up. */ @@ -164,13 +182,15 @@ public void deleteRequest(String id) { /** * Atomically returns and locks up to {@code limit} requests from the head of the queue for {@code - * lockSecs} seconds. Returns the raw locked-head object. + * lockSecs} seconds, so other clients cannot retrieve them until the lock expires or is released + * via {@link #deleteRequestLock}. This is the primary method used by distributed crawlers to + * coordinate work across multiple workers. */ - public JsonNode listAndLockHead(long lockSecs, Long limit) { + public LockedRequestQueueHead listAndLockHead(long lockSecs, Long limit) { QueryParams params = new QueryParams(); params.addLong("lockSecs", lockSecs).addLong("limit", limit); withClientKey(params); - return ctx.postWithBody("head/lock", params, null, "", Json.type(JsonNode.class)); + return ctx.postWithBody("head/lock", params, null, "", LockedRequestQueueHead.class); } /** @@ -184,25 +204,38 @@ public BatchAddResult batchAddRequests(List requests, boole /** * Adds multiple requests to the queue. If {@code forefront} is true, they are added to the front. * - *

The input is automatically split into chunks of at most 25 requests (the API limit). Chunks - * are sent using up to {@link BatchAddRequestsOptions#maxParallel} parallel API calls, and any - * requests the API leaves unprocessed (typically rate-limited) are retried with exponential - * backoff up to {@link BatchAddRequestsOptions#maxUnprocessedRequestsRetries} times. The - * per-chunk results are merged into a single {@link BatchAddResult}. + *

The input is automatically split into chunks of at most 25 requests (the API count limit) + * that additionally respect the API's ~9 MiB request-body payload-size limit ({@link + * #MAX_PAYLOAD_SIZE_BYTES}): a chunk of up to 25 requests is further split by cumulative + * JSON-encoded byte size, so a batch of individually large requests (e.g. sizeable {@code + * userData}) cannot 413. Chunks are sent using up to {@link BatchAddRequestsOptions#maxParallel} + * parallel API calls, and any requests the API leaves unprocessed (typically rate-limited) are + * retried with exponential backoff up to {@link + * BatchAddRequestsOptions#maxUnprocessedRequestsRetries} times. The per-chunk results are merged + * into a single {@link BatchAddResult}. * *

Requests that remain unprocessed after all retries (typically due to persistent * rate-limiting or server errors) are returned in {@link BatchAddResult#getUnprocessedRequests()} * rather than raising an exception. A non-retryable client error (a 4xx other than 429, such as * an invalid token or insufficient permissions) is instead thrown as an {@link * ApifyApiException}, since it will not succeed on retry and should not be silently hidden as - * "unprocessed". + * "unprocessed". A single request whose own JSON encoding already exceeds the payload-size limit + * is rejected up front with {@link IllegalArgumentException}, since no chunk size could ever fit + * it. */ public BatchAddResult batchAddRequests( List requests, boolean forefront, BatchAddRequestsOptions options) { + long payloadSizeLimitBytes = + MAX_PAYLOAD_SIZE_BYTES + - (long) Math.ceil(MAX_PAYLOAD_SIZE_BYTES * PAYLOAD_SAFETY_BUFFER_PERCENT); + List> chunks = new ArrayList<>(); - for (int start = 0; start < requests.size(); start += MAX_REQUESTS_PER_BATCH) { + for (int start = 0; start < requests.size(); ) { int end = Math.min(start + MAX_REQUESTS_PER_BATCH, requests.size()); - chunks.add(new ArrayList<>(requests.subList(start, end))); + List countSlice = requests.subList(start, end); + List chunk = sliceByByteLength(countSlice, payloadSizeLimitBytes, start); + chunks.add(new ArrayList<>(chunk)); + start += chunk.size(); } BatchAddResult merged = new BatchAddResult(); @@ -233,6 +266,55 @@ public BatchAddResult batchAddRequests( return merged; } + /** + * Returns the longest leading run of {@code requests} whose combined JSON payload stays under + * {@code maxByteLength}, always keeping at least one request so the caller's chunking loop makes + * forward progress. Ports the reference client's {@code sliceArrayByByteLength}. {@code + * startIndex} is the position of {@code requests.get(0)} in the caller's full input list, used + * only to report which request is oversized. + * + * @throws IllegalArgumentException if any individual request's own JSON encoding already exceeds + * {@code maxByteLength} (no chunk size could ever fit it) + */ + private static List sliceByByteLength( + List requests, long maxByteLength, int startIndex) { + if (jsonByteLength(requests) < maxByteLength) { + return requests; + } + + List sliced = new ArrayList<>(); + long byteLength = EMPTY_JSON_ARRAY_BYTES; + for (int i = 0; i < requests.size(); i++) { + RequestQueueRequest request = requests.get(i); + long itemBytes = jsonByteLength(request); + if (itemBytes > maxByteLength) { + throw new IllegalArgumentException( + "RequestQueueClient.batchAddRequests: the request at index " + + (startIndex + i) + + " exceeds the maximum allowed payload size (" + + maxByteLength + + " bytes)"); + } + if (byteLength + itemBytes >= maxByteLength) { + break; + } + byteLength += itemBytes; + sliced.add(request); + } + + // Guarantee forward progress: keep at least the first request. It cannot be individually + // oversized (that would already have thrown above), so this never produces an over-limit chunk. + if (sliced.isEmpty()) { + sliced.add(requests.get(0)); + } + return sliced; + } + + /** The byte length of {@code value}'s JSON encoding, as sent on the wire. */ + private static long jsonByteLength(Object value) { + return Json.toBytes(value).length; + } + private static BatchAddResult awaitResult(Future future) { try { return future.get(); @@ -354,27 +436,27 @@ private BatchAddResult batchAddChunk(List requests, boolean /** * Deletes multiple requests in a single call. Each entry identifies a request (e.g. by id or - * uniqueKey). Returns the raw batch result. + * uniqueKey). */ - public JsonNode batchDeleteRequests(Object requests) { + public BatchDeleteResult batchDeleteRequests(Object requests) { QueryParams params = withClientKey(new QueryParams()); - return ctx.deleteWithBody("requests/batch", params, requests, JsonNode.class); + return ctx.deleteWithBody("requests/batch", params, requests, BatchDeleteResult.class); } - /** Lists the queue's requests with pagination. Returns the raw response. */ - public JsonNode listRequests(ListRequestsOptions options) { + /** Lists the queue's requests with pagination. */ + public RequestsList listRequests(ListRequestsOptions options) { options.validate(); QueryParams params = new QueryParams(); options.apply(params); withClientKey(params); - return ctx.getResourceRequired("requests", params, Json.type(JsonNode.class)); + return ctx.getResourceRequired("requests", params, RequestsList.class); } /** * Extends the lock on a request by {@code lockSecs} seconds. If {@code forefront} is true, the - * request is moved to the front when its lock expires. Returns the raw response. + * request is moved to the front when its lock expires. */ - public JsonNode prolongRequestLock(String id, long lockSecs, boolean forefront) { + public RequestLockInfo prolongRequestLock(String id, long lockSecs, boolean forefront) { QueryParams params = new QueryParams(); params.addLong("lockSecs", lockSecs).addBool("forefront", forefront); withClientKey(params); @@ -383,7 +465,7 @@ public JsonNode prolongRequestLock(String id, long lockSecs, boolean forefront) params, null, "", - JsonNode.class); + RequestLockInfo.class); } /** @@ -406,10 +488,10 @@ public void deleteRequestLock(String id, boolean forefront) { } } - /** Releases all locks the client holds on this queue's requests. Returns the raw response. */ - public JsonNode unlockRequests() { + /** Releases all locks the client holds on this queue's requests. */ + public UnlockRequestsResult unlockRequests() { QueryParams params = withClientKey(new QueryParams()); - return ctx.postWithBody("requests/unlock", params, null, "", Json.type(JsonNode.class)); + return ctx.postWithBody("requests/unlock", params, null, "", UnlockRequestsResult.class); } /** @@ -439,13 +521,6 @@ public Iterator paginateRequests( return new RequestsIterator(totalLimit, chunkSize, filter); } - /** Shape of a paginated requests listing. */ - @JsonIgnoreProperties(ignoreUnknown = true) - private static final class RequestsPage { - public List items = List.of(); - public String nextCursor; - } - /** Lazily iterates over a request queue's requests via the cursor-based listing endpoint. */ private final class RequestsIterator implements Iterator { private final Long totalLimit; @@ -500,12 +575,12 @@ private void fetchPage() { } params.addCsv("filter", filter); withClientKey(params); - RequestsPage page = ctx.getResourceRequired("requests", params, RequestsPage.class); + RequestsList page = ctx.getResourceRequired("requests", params, RequestsList.class); started = true; - buffer = page.items; + buffer = page.getItems(); pos = 0; - nextCursor = page.nextCursor; - if (page.items.isEmpty() && (nextCursor == null || nextCursor.isEmpty())) { + nextCursor = page.getNextCursor(); + if (buffer.isEmpty() && (nextCursor == null || nextCursor.isEmpty())) { exhausted = true; } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java index 854e860..93fa6ab 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueCollectionClient.java @@ -1,49 +1,20 @@ package com.apify.client.requestqueue; -import com.apify.client.PaginationList; import com.apify.client.StorageListOptions; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -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; +public final class RequestQueueCollectionClient + extends AbstractCollectionClient { public RequestQueueCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.REQUEST_QUEUES); - } - - /** Lists request queues. */ - public PaginationList list(StorageListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.REQUEST_QUEUES), + RequestQueue.class, + StorageListOptions::new); } /** diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java index 21e1977..caa6d12 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java @@ -1,12 +1,14 @@ package com.apify.client.requestqueue; import com.apify.client.ApifyResource; +import java.time.Instant; import java.util.Collections; import java.util.List; /** The head (front) of a request queue. */ public final class RequestQueueHead extends ApifyResource { private long limit; + private Instant queueModifiedAt; private boolean hadMultipleClients; private List items = List.of(); @@ -15,6 +17,11 @@ public long getLimit() { return limit; } + /** When the queue was last modified (add/update/remove/lock/unlock of any request). */ + public Instant getQueueModifiedAt() { + return queueModifiedAt; + } + /** Whether multiple clients have accessed the queue. */ public boolean isHadMultipleClients() { return hadMultipleClients; diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index 39564b2..24bb0d1 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -25,6 +25,7 @@ public final class RequestQueueRequest extends ApifyResource { private Integer retryCount; private String loadedUrl; private List errorMessages; + private Instant lockExpiresAt; public RequestQueueRequest() {} @@ -156,4 +157,12 @@ public RequestQueueRequest setErrorMessages(List errorMessages) { this.errorMessages = errorMessages == null ? null : List.copyOf(errorMessages); return this; } + + /** + * When this request's lock (if any) expires. Only populated on items returned from {@link + * RequestQueueClient#listAndLockHead}; {@code null} everywhere else, including on write. + */ + public Instant getLockExpiresAt() { + return lockExpiresAt; + } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestsList.java b/src/main/java/com/apify/client/requestqueue/RequestsList.java new file mode 100644 index 0000000..4e3409f --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/RequestsList.java @@ -0,0 +1,53 @@ +package com.apify.client.requestqueue; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Collections; +import java.util.List; + +/** + * A page of requests returned by {@link RequestQueueClient#listRequests}, with cursor-based + * pagination information. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class RequestsList { + private List items = List.of(); + private long limit; + + /** + * @deprecated Use {@link #getCursor()}/{@link #getNextCursor()} for pagination instead. + */ + @Deprecated private String exclusiveStartId; + + private String cursor; + private String nextCursor; + + /** The requests in this page (unmodifiable). */ + public List getItems() { + return Collections.unmodifiableList(items); + } + + /** The maximum number of requests requested for this page. */ + public long getLimit() { + return limit; + } + + /** + * The ID of the last request from the previous page, if this page was requested that way. + * + * @deprecated Use {@link #getCursor()}/{@link #getNextCursor()} for pagination instead. + */ + @Deprecated + public String getExclusiveStartId() { + return exclusiveStartId; + } + + /** The cursor identifying this page, if this page was requested by cursor. */ + public String getCursor() { + return cursor; + } + + /** The cursor to pass to fetch the next page, or {@code null}/empty if this is the last page. */ + public String getNextCursor() { + return nextCursor; + } +} diff --git a/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java b/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java new file mode 100644 index 0000000..f0318e9 --- /dev/null +++ b/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java @@ -0,0 +1,14 @@ +package com.apify.client.requestqueue; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Returned by {@link RequestQueueClient#unlockRequests}: how many requests were unlocked. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class UnlockRequestsResult { + private long unlockedCount; + + /** The number of requests that were successfully unlocked. */ + public long getUnlockedCount() { + return unlockedCount; + } +} diff --git a/src/main/java/com/apify/client/run/ActorRun.java b/src/main/java/com/apify/client/run/ActorRun.java index 434ffb1..a01322a 100644 --- a/src/main/java/com/apify/client/run/ActorRun.java +++ b/src/main/java/com/apify/client/run/ActorRun.java @@ -2,7 +2,10 @@ import com.apify.client.ApifyResource; import com.apify.client.internal.Statuses; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; +import java.util.Collections; +import java.util.Map; /** A single execution of an Actor. */ public final class ActorRun extends ApifyResource { @@ -19,6 +22,14 @@ public final class ActorRun extends ApifyResource { private String defaultKeyValueStoreId; private String defaultRequestQueueId; private String containerUrl; + private String generalAccess; + private Map chargedEventCounts; + private JsonNode pricingInfo; + private ActorRunUsage usage; + private ActorRunUsage usageUsd; + private ActorRunStats stats; + private ActorRunOptions options; + private ActorRunMeta meta; /** The unique run ID. */ public String getId() { @@ -89,6 +100,55 @@ public String getContainerUrl() { return containerUrl; } + /** + * Who can access this run without owning it (e.g. {@code "ANYONE_WITH_ID_CAN_READ"}, {@code + * "RESTRICTED"}), or {@code null} to follow the account/Actor default. + */ + public String getGeneralAccess() { + return generalAccess; + } + + /** + * For a pay-per-event Actor, how many times each event type was charged during this run (event + * type -> count); {@code null} for Actors that are not pay-per-event. + */ + public Map getChargedEventCounts() { + return chargedEventCounts == null ? null : Collections.unmodifiableMap(chargedEventCounts); + } + + /** + * Pricing information for the Actor this run belongs to, as raw JSON (its shape depends on the + * Actor's pricing model: pay-per-event, pay-per-result, flat monthly, or free). + */ + public JsonNode getPricingInfo() { + return pricingInfo; + } + + /** Resource usage consumed by the run so far, broken down by billable unit. */ + public ActorRunUsage getUsage() { + return usage; + } + + /** As {@link #getUsage()}, converted to USD; only present where {@link #getUsage()} is. */ + public ActorRunUsage getUsageUsd() { + return usageUsd; + } + + /** Runtime resource-consumption and performance statistics for the run. */ + public ActorRunStats getStats() { + return stats; + } + + /** The run configuration actually applied (may differ from what the caller requested). */ + public ActorRunOptions getOptions() { + return options; + } + + /** Metadata about how the run was initiated. */ + public ActorRunMeta getMeta() { + return meta; + } + /** Whether the run has reached a terminal (finished) status. */ public boolean isTerminal() { return Statuses.isTerminal(status); diff --git a/src/main/java/com/apify/client/run/ActorRunMeta.java b/src/main/java/com/apify/client/run/ActorRunMeta.java new file mode 100644 index 0000000..c211ee1 --- /dev/null +++ b/src/main/java/com/apify/client/run/ActorRunMeta.java @@ -0,0 +1,26 @@ +package com.apify.client.run; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Metadata about how an {@link ActorRun} was initiated. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorRunMeta { + private String origin; + private String clientIp; + private String userAgent; + + /** What triggered the run (e.g. {@code "WEB"}, {@code "API"}, {@code "SCHEDULER"}). */ + public String getOrigin() { + return origin; + } + + /** The IP address of the client that started the run, if known. */ + public String getClientIp() { + return clientIp; + } + + /** The {@code User-Agent} of the client that started the run. */ + public String getUserAgent() { + return userAgent; + } +} diff --git a/src/main/java/com/apify/client/run/ActorRunOptions.java b/src/main/java/com/apify/client/run/ActorRunOptions.java new file mode 100644 index 0000000..59a452e --- /dev/null +++ b/src/main/java/com/apify/client/run/ActorRunOptions.java @@ -0,0 +1,54 @@ +package com.apify.client.run; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * The configuration options actually applied to an {@link ActorRun} (as opposed to {@link + * com.apify.client.actor.ActorStartOptions}, which is what a caller requested and may differ, e.g. + * when a requested value falls back to a task/Actor default). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorRunOptions { + private String build; + private long timeoutSecs; + private long memoryMbytes; + private long diskMbytes; + private Long maxItems; + private Double maxTotalChargeUsd; + private Boolean restartOnError; + + /** The tag or number of the build that was run. */ + public String getBuild() { + return build; + } + + /** The applied run timeout, in seconds. */ + public long getTimeoutSecs() { + return timeoutSecs; + } + + /** The applied memory allocation, in megabytes. */ + public long getMemoryMbytes() { + return memoryMbytes; + } + + /** The applied disk allocation, in megabytes. */ + public long getDiskMbytes() { + return diskMbytes; + } + + /** The applied cap on dataset items charged (pay-per-result Actors), if any. */ + public Long getMaxItems() { + return maxItems; + } + + /** The applied cap on total charge in USD (pay-per-event Actors), if any. */ + public Double getMaxTotalChargeUsd() { + return maxTotalChargeUsd; + } + + /** Whether the run is restarted if it fails. */ + public Boolean getRestartOnError() { + return restartOnError; + } +} diff --git a/src/main/java/com/apify/client/run/ActorRunStats.java b/src/main/java/com/apify/client/run/ActorRunStats.java new file mode 100644 index 0000000..a6c0221 --- /dev/null +++ b/src/main/java/com/apify/client/run/ActorRunStats.java @@ -0,0 +1,98 @@ +package com.apify.client.run; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Runtime resource-consumption and performance metrics for an {@link ActorRun}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorRunStats { + private long inputBodyLen; + private long restartCount; + private long resurrectCount; + private double memAvgBytes; + private double memMaxBytes; + private double memCurrentBytes; + private double cpuAvgUsage; + private double cpuMaxUsage; + private double cpuCurrentUsage; + private long netRxBytes; + private long netTxBytes; + private long durationMillis; + private double runTimeSecs; + private long metamorph; + private double computeUnits; + + /** The byte length of the run's input body. */ + public long getInputBodyLen() { + return inputBodyLen; + } + + /** How many times the run was automatically restarted. */ + public long getRestartCount() { + return restartCount; + } + + /** How many times the run was resurrected. */ + public long getResurrectCount() { + return resurrectCount; + } + + /** Average memory usage, in bytes. */ + public double getMemAvgBytes() { + return memAvgBytes; + } + + /** Peak memory usage, in bytes. */ + public double getMemMaxBytes() { + return memMaxBytes; + } + + /** Memory usage at the time of the response, in bytes. */ + public double getMemCurrentBytes() { + return memCurrentBytes; + } + + /** Average CPU usage percentage. */ + public double getCpuAvgUsage() { + return cpuAvgUsage; + } + + /** Peak CPU usage percentage. */ + public double getCpuMaxUsage() { + return cpuMaxUsage; + } + + /** CPU usage percentage at the time of the response. */ + public double getCpuCurrentUsage() { + return cpuCurrentUsage; + } + + /** Bytes received over the network. */ + public long getNetRxBytes() { + return netRxBytes; + } + + /** Bytes sent over the network. */ + public long getNetTxBytes() { + return netTxBytes; + } + + /** The run's duration, in milliseconds. */ + public long getDurationMillis() { + return durationMillis; + } + + /** The run's duration, in seconds. */ + public double getRunTimeSecs() { + return runTimeSecs; + } + + /** How many times the run was metamorphed into another Actor. */ + public long getMetamorph() { + return metamorph; + } + + /** Compute units consumed by the run. */ + public double getComputeUnits() { + return computeUnits; + } +} diff --git a/src/main/java/com/apify/client/run/ActorRunUsage.java b/src/main/java/com/apify/client/run/ActorRunUsage.java new file mode 100644 index 0000000..041aad1 --- /dev/null +++ b/src/main/java/com/apify/client/run/ActorRunUsage.java @@ -0,0 +1,111 @@ +package com.apify.client.run; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Resource-usage metrics for an {@link ActorRun}, broken down by billable unit. All fields are the + * total consumption during the run's lifetime and {@code null} when not applicable to the run. + * + *

The API reports these keys in {@code SCREAMING_SNAKE_CASE} (e.g. {@code + * "ACTOR_COMPUTE_UNITS"}); each field is mapped to its idiomatic camelCase Java name via + * {@code @JsonProperty}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorRunUsage { + @JsonProperty("ACTOR_COMPUTE_UNITS") + private Double actorComputeUnits; + + @JsonProperty("DATASET_READS") + private Double datasetReads; + + @JsonProperty("DATASET_WRITES") + private Double datasetWrites; + + @JsonProperty("KEY_VALUE_STORE_READS") + private Double keyValueStoreReads; + + @JsonProperty("KEY_VALUE_STORE_WRITES") + private Double keyValueStoreWrites; + + @JsonProperty("KEY_VALUE_STORE_LISTS") + private Double keyValueStoreLists; + + @JsonProperty("REQUEST_QUEUE_READS") + private Double requestQueueReads; + + @JsonProperty("REQUEST_QUEUE_WRITES") + private Double requestQueueWrites; + + @JsonProperty("DATA_TRANSFER_INTERNAL_GBYTES") + private Double dataTransferInternalGbytes; + + @JsonProperty("DATA_TRANSFER_EXTERNAL_GBYTES") + private Double dataTransferExternalGbytes; + + @JsonProperty("PROXY_RESIDENTIAL_TRANSFER_GBYTES") + private Double proxyResidentialTransferGbytes; + + @JsonProperty("PROXY_SERPS") + private Double proxySerps; + + /** Compute units consumed (combines CPU and memory usage over time). */ + public Double getActorComputeUnits() { + return actorComputeUnits; + } + + /** Number of dataset read operations. */ + public Double getDatasetReads() { + return datasetReads; + } + + /** Number of dataset write operations. */ + public Double getDatasetWrites() { + return datasetWrites; + } + + /** Number of key-value store read operations. */ + public Double getKeyValueStoreReads() { + return keyValueStoreReads; + } + + /** Number of key-value store write operations. */ + public Double getKeyValueStoreWrites() { + return keyValueStoreWrites; + } + + /** Number of key-value store list operations. */ + public Double getKeyValueStoreLists() { + return keyValueStoreLists; + } + + /** Number of request queue read operations. */ + public Double getRequestQueueReads() { + return requestQueueReads; + } + + /** Number of request queue write operations. */ + public Double getRequestQueueWrites() { + return requestQueueWrites; + } + + /** Internal data transfer within the Apify platform, in gigabytes. */ + public Double getDataTransferInternalGbytes() { + return dataTransferInternalGbytes; + } + + /** External data transfer to/from the internet, in gigabytes. */ + public Double getDataTransferExternalGbytes() { + return dataTransferExternalGbytes; + } + + /** Residential proxy data transfer, in gigabytes. */ + public Double getProxyResidentialTransferGbytes() { + return proxyResidentialTransferGbytes; + } + + /** Number of SERP (search engine results page) proxy requests. */ + public Double getProxySerps() { + return proxySerps; + } +} diff --git a/src/main/java/com/apify/client/run/RunCollectionClient.java b/src/main/java/com/apify/client/run/RunCollectionClient.java index 0093d4f..0c15984 100644 --- a/src/main/java/com/apify/client/run/RunCollectionClient.java +++ b/src/main/java/com/apify/client/run/RunCollectionClient.java @@ -2,6 +2,7 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; @@ -11,12 +12,17 @@ * A client for a run collection: the account-wide collection ({@code GET /v2/actor-runs}), an * Actor's runs ({@code GET /v2/actors/{id}/runs}), or a task's runs ({@code GET * /v2/actor-tasks/{id}/runs}). + * + *

Extends {@link AbstractCollectionClient} for the shared {@code ctx}/item-class plumbing, but + * its {@code list}/{@code iterate} take an extra run-specific {@link RunListOptions} filter that + * the shared {@code list(ListOptions)}/{@code iterate(ListOptions, Long)} (still inherited, and + * usable when no run filter is needed) cannot express. */ -public final class RunCollectionClient { - private final ResourceContext ctx; +public final class RunCollectionClient extends AbstractCollectionClient { public RunCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { - this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); + super( + ResourceContext.collection(http, baseUrl, resourcePath), ActorRun.class, ListOptions::new); } /** @@ -31,7 +37,7 @@ public PaginationList list(ListOptions options, RunListOptions filter) if (filter != null) { filter.apply(params); } - return ctx.listResource("", params, ActorRun.class); + return listWithParams(params); } /** @@ -50,8 +56,7 @@ public Iterator iterate(ListOptions options, RunListOptions filter) { */ public Iterator iterate(ListOptions options, RunListOptions filter, Long chunkSize) { ListOptions opts = options != null ? options : new ListOptions(); - return ctx.iterateResource( - "", + return iterateWithFilters( opts.limitValue(), chunkSize, opts.offsetValue(), @@ -60,7 +65,6 @@ public Iterator iterate(ListOptions options, RunListOptions filter, Lo if (filter != null) { filter.apply(p); } - }, - ActorRun.class); + }); } } diff --git a/src/main/java/com/apify/client/run/SetStatusMessageOptions.java b/src/main/java/com/apify/client/run/SetStatusMessageOptions.java index be8910a..d8300dc 100644 --- a/src/main/java/com/apify/client/run/SetStatusMessageOptions.java +++ b/src/main/java/com/apify/client/run/SetStatusMessageOptions.java @@ -1,9 +1,8 @@ package com.apify.client.run; -/** - * Configures {@code com.apify.client.ApifyClient#setStatusMessage(String, - * SetStatusMessageOptions)}. - */ +import com.apify.client.ApifyClient; + +/** Configures {@link ApifyClient#setStatusMessage(String, SetStatusMessageOptions)}. */ public final class SetStatusMessageOptions { private Boolean statusMessageTerminal; diff --git a/src/main/java/com/apify/client/schedule/Schedule.java b/src/main/java/com/apify/client/schedule/Schedule.java index 355ac02..59270c1 100644 --- a/src/main/java/com/apify/client/schedule/Schedule.java +++ b/src/main/java/com/apify/client/schedule/Schedule.java @@ -1,14 +1,28 @@ package com.apify.client.schedule; import com.apify.client.ApifyResource; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; +import java.util.Collections; +import java.util.List; /** A schedule automatically starts Actor or task runs at specified times. */ public final class Schedule extends ApifyResource { private String id; private String userId; private String name; + private String title; + private String description; private String cronExpression; + private String timezone; private boolean isEnabled; + private boolean isExclusive; + private Instant createdAt; + private Instant modifiedAt; + private Instant nextRunAt; + private Instant lastRunAt; + private List actions = List.of(); + private ScheduleNotifications notifications; /** The unique schedule ID. */ public String getId() { @@ -25,13 +39,68 @@ public String getName() { return name; } + /** The human-readable title shown in the UI. */ + public String getTitle() { + return title; + } + + /** A description of what the schedule does. */ + public String getDescription() { + return description; + } + /** The cron expression governing when the schedule fires. */ public String getCronExpression() { return cronExpression; } + /** The IANA timezone name (e.g. {@code "UTC"}, {@code "America/New_York"}) the cron runs in. */ + public String getTimezone() { + return timezone; + } + /** Whether the schedule is currently active. */ public boolean isEnabled() { return isEnabled; } + + /** + * Whether the schedule is exclusive: a new run is skipped while a previous one is still active. + */ + public boolean isExclusive() { + return isExclusive; + } + + /** When the schedule was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the schedule was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } + + /** When the schedule is next due to fire, or {@code null} if it is disabled. */ + public Instant getNextRunAt() { + return nextRunAt; + } + + /** When the schedule last fired, or {@code null} if it never has. */ + public Instant getLastRunAt() { + return lastRunAt; + } + + /** + * The Actor/task-run actions this schedule triggers, as raw JSON (each action's shape depends on + * its {@code type}, {@code RUN_ACTOR} or {@code RUN_ACTOR_TASK}; unmodifiable). + */ + public List getActions() { + return Collections.unmodifiableList(actions); + } + + /** Notification settings for this schedule. */ + public ScheduleNotifications getNotifications() { + return notifications; + } } diff --git a/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java index 9d2650d..a19904e 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleCollectionClient.java @@ -1,42 +1,21 @@ package com.apify.client.schedule; import com.apify.client.ListOptions; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import java.util.Iterator; /** A client for the schedule collection ({@code GET/POST /v2/schedules}). */ -public final class ScheduleCollectionClient { - private final ResourceContext ctx; +public final class ScheduleCollectionClient + extends AbstractCollectionClient { public ScheduleCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.SCHEDULES); - } - - /** Lists the account's schedules. */ - public PaginationList list(ListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.SCHEDULES), + Schedule.class, + ListOptions::new); } /** Creates a new schedule. {@code schedule} is any JSON-serializable schedule definition. */ diff --git a/src/main/java/com/apify/client/schedule/ScheduleNotifications.java b/src/main/java/com/apify/client/schedule/ScheduleNotifications.java new file mode 100644 index 0000000..d0b247e --- /dev/null +++ b/src/main/java/com/apify/client/schedule/ScheduleNotifications.java @@ -0,0 +1,14 @@ +package com.apify.client.schedule; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Notification settings for a {@link Schedule}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ScheduleNotifications { + private boolean email; + + /** Whether the schedule owner is notified by email of run failures. */ + public boolean isEmail() { + return email; + } +} diff --git a/src/main/java/com/apify/client/task/Task.java b/src/main/java/com/apify/client/task/Task.java index d3d7e4b..531af1f 100644 --- a/src/main/java/com/apify/client/task/Task.java +++ b/src/main/java/com/apify/client/task/Task.java @@ -1,6 +1,8 @@ package com.apify.client.task; import com.apify.client.ApifyResource; +import com.apify.client.actor.ActorStandby; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; /** A pre-configured Actor run (an Actor task). */ @@ -10,8 +12,13 @@ public final class Task extends ApifyResource { private String userId; private String name; private String title; + private String description; private Instant createdAt; private Instant modifiedAt; + private TaskStats stats; + private TaskOptions options; + private JsonNode input; + private ActorStandby actorStandby; /** The unique task ID. */ public String getId() { @@ -38,6 +45,11 @@ public String getTitle() { return title; } + /** A description of what the task does. */ + public String getDescription() { + return description; + } + /** When the task was created. */ public Instant getCreatedAt() { return createdAt; @@ -47,4 +59,28 @@ public Instant getCreatedAt() { public Instant getModifiedAt() { return modifiedAt; } + + /** Usage statistics for this task. */ + public TaskStats getStats() { + return stats; + } + + /** The task's stored default run configuration. */ + public TaskOptions getOptions() { + return options; + } + + /** + * The task's stored input, as raw JSON (an object or array of objects, depending on the Actor's + * input schema); {@code null} if none is set. Use {@link TaskClient#getInput()} to fetch it + * on-demand instead of relying on this snapshot. + */ + public JsonNode getInput() { + return input; + } + + /** Standby-mode configuration overrides for this task, if any. */ + public ActorStandby getActorStandby() { + return actorStandby; + } } diff --git a/src/main/java/com/apify/client/task/TaskCallOptions.java b/src/main/java/com/apify/client/task/TaskCallOptions.java new file mode 100644 index 0000000..928627e --- /dev/null +++ b/src/main/java/com/apify/client/task/TaskCallOptions.java @@ -0,0 +1,100 @@ +package com.apify.client.task; + +import com.apify.client.log.StreamedLogOptions; +import java.util.List; + +/** + * Configures {@link TaskClient#call(Object, TaskCallOptions, Long)}. + * + *

Mirrors {@link TaskStartOptions} (delegating every field to an internal instance) but omits + * {@code waitForFinish}: that field asks the API to hold the HTTP response open server-side while + * the run finishes, which is redundant with (and wastes a request slot next to) {@code call}'s own + * client-side {@code waitSecs} polling. This matches the reference client, whose dedicated {@code + * ActorCallOptions} type (shared by tasks) is {@code Omit}. + * + *

By default, {@code call} also streams the run's log to a default per-run logger for the + * duration of the wait (via {@link com.apify.client.run.RunClient#getStreamedLog()}), matching the + * reference client's {@code options.log} defaulting to {@code 'default'}. Use {@link + * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom + * destination. + */ +public final class TaskCallOptions { + + private final TaskStartOptions startOptions = new TaskStartOptions(); + private boolean logStreamingEnabled = true; + private StreamedLogOptions logOptions; + + /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ + public TaskCallOptions build(String build) { + startOptions.build(build); + return this; + } + + /** Memory in megabytes allocated for the run. */ + public TaskCallOptions memoryMbytes(Long memoryMbytes) { + startOptions.memoryMbytes(memoryMbytes); + return this; + } + + /** Timeout for the run in seconds (0 means no timeout). */ + public TaskCallOptions timeoutSecs(Long timeoutSecs) { + startOptions.timeoutSecs(timeoutSecs); + return this; + } + + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public TaskCallOptions maxItems(Long maxItems) { + startOptions.maxItems(maxItems); + return this; + } + + /** Maximum total charge in USD (pay-per-event Actors). */ + public TaskCallOptions maxTotalChargeUsd(Double maxTotalChargeUsd) { + startOptions.maxTotalChargeUsd(maxTotalChargeUsd); + return this; + } + + /** If {@code true}, restart the run if it fails. */ + public TaskCallOptions restartOnError(Boolean restartOnError) { + startOptions.restartOnError(restartOnError); + return this; + } + + /** Ad-hoc webhooks to attach to this run (serialized to base64-encoded JSON). */ + public TaskCallOptions webhooks(List webhooks) { + startOptions.webhooks(webhooks); + return this; + } + + /** + * Disables the default log streaming, matching the reference client's {@code log: null}. {@code + * call} then only starts the run and polls for completion, as if built from {@link + * TaskStartOptions} directly. + */ + public TaskCallOptions disableLogStreaming() { + this.logStreamingEnabled = false; + return this; + } + + /** + * Customizes log streaming (destination, prefix, whether to include pre-existing log lines) + * instead of using the default per-run logger. Implies streaming is enabled. + */ + public TaskCallOptions logOptions(StreamedLogOptions logOptions) { + this.logOptions = logOptions; + this.logStreamingEnabled = true; + return this; + } + + TaskStartOptions toStartOptions() { + return startOptions; + } + + boolean logStreamingEnabledValue() { + return logStreamingEnabled; + } + + StreamedLogOptions logOptionsValue() { + return logOptions; + } +} diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index e4b854b..1e963a9 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -7,6 +7,7 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; +import com.apify.client.log.StreamedLog; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -62,12 +63,61 @@ public ActorRun start(Object input, TaskStartOptions options) { /** * Starts the task and waits (client-side polling) for it to finish. {@code waitSecs} bounds the * wait; {@code null} waits indefinitely. + * + *

This overload does not stream the run's log; use {@link #call(Object, TaskCallOptions, + * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { ActorRun run = start(input, options); return root.run(run.getId()).waitForFinish(waitSecs); } + /** + * Starts the task and waits (client-side polling) for it to finish, additionally streaming the + * run's log for the duration of the wait — matching the reference client's {@code call}, whose + * {@code options.log} defaults to {@code 'default'}. {@code waitSecs} bounds the wait; {@code + * null} waits indefinitely. + * + *

Log streaming is best-effort: if starting it fails (e.g. the log is not yet available), the + * run still starts and is still waited for, just without redirected log output. Use {@link + * TaskCallOptions#disableLogStreaming()} to opt out entirely, or {@link + * TaskCallOptions#logOptions(com.apify.client.log.StreamedLogOptions)} for a custom destination. + */ + public ActorRun call(Object input, TaskCallOptions options, Long waitSecs) { + TaskCallOptions opts = options != null ? options : new TaskCallOptions(); + ActorRun run = start(input, opts.toStartOptions()); + RunClient runClient = root.run(run.getId()); + + StreamedLog streamedLog = null; + if (opts.logStreamingEnabledValue()) { + streamedLog = startStreamedLogQuietly(runClient, opts); + } + try { + return runClient.waitForFinish(waitSecs); + } finally { + if (streamedLog != null) { + streamedLog.close(); + } + } + } + + /** + * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure + * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. + */ + private static StreamedLog startStreamedLogQuietly(RunClient runClient, TaskCallOptions opts) { + try { + StreamedLog streamedLog = + opts.logOptionsValue() != null + ? runClient.getStreamedLog(opts.logOptionsValue()) + : runClient.getStreamedLog(); + streamedLog.start(); + return streamedLog; + } catch (RuntimeException e) { + return null; + } + } + /** Fetches the task's stored input, or empty if none is set. */ public Optional getInput() { ApiResponse resp = ctx.getRaw("input", new QueryParams()); diff --git a/src/main/java/com/apify/client/task/TaskCollectionClient.java b/src/main/java/com/apify/client/task/TaskCollectionClient.java index 9daa43a..ef99d94 100644 --- a/src/main/java/com/apify/client/task/TaskCollectionClient.java +++ b/src/main/java/com/apify/client/task/TaskCollectionClient.java @@ -1,42 +1,20 @@ package com.apify.client.task; import com.apify.client.ListOptions; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -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; +public final class TaskCollectionClient extends AbstractCollectionClient { public TaskCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.ACTOR_TASKS); - } - - /** Lists the account's tasks. */ - public PaginationList list(ListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, Task.class); - } - - /** - * 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.ACTOR_TASKS), + Task.class, + ListOptions::new); } /** Creates a new task. {@code task} is any JSON-serializable task definition. */ diff --git a/src/main/java/com/apify/client/task/TaskOptions.java b/src/main/java/com/apify/client/task/TaskOptions.java new file mode 100644 index 0000000..168e118 --- /dev/null +++ b/src/main/java/com/apify/client/task/TaskOptions.java @@ -0,0 +1,32 @@ +package com.apify.client.task; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** The stored run configuration of a {@link Task} (the defaults applied when it is started). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TaskOptions { + private String build; + private Long timeoutSecs; + private Long memoryMbytes; + private Boolean restartOnError; + + /** The tag or number of the build the task runs by default. */ + public String getBuild() { + return build; + } + + /** The default run timeout in seconds. */ + public Long getTimeoutSecs() { + return timeoutSecs; + } + + /** The default memory in megabytes allocated for the run. */ + public Long getMemoryMbytes() { + return memoryMbytes; + } + + /** Whether the run is restarted by default if it fails. */ + public Boolean getRestartOnError() { + return restartOnError; + } +} diff --git a/src/main/java/com/apify/client/task/TaskStats.java b/src/main/java/com/apify/client/task/TaskStats.java new file mode 100644 index 0000000..d73bd6e --- /dev/null +++ b/src/main/java/com/apify/client/task/TaskStats.java @@ -0,0 +1,14 @@ +package com.apify.client.task; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Usage statistics for a {@link Task}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TaskStats { + private long totalRuns; + + /** The total number of runs started from this task. */ + public long getTotalRuns() { + return totalRuns; + } +} diff --git a/src/main/java/com/apify/client/webhook/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java index fc61dd7..3ca1f3a 100644 --- a/src/main/java/com/apify/client/webhook/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -1,6 +1,8 @@ package com.apify.client.webhook; import com.apify.client.ApifyResource; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; import java.util.Collections; import java.util.List; @@ -8,8 +10,18 @@ public final class Webhook extends ApifyResource { private String id; private String userId; - private String requestUrl; + private Instant createdAt; + private Instant modifiedAt; + private boolean isAdHoc; private List eventTypes = List.of(); + private JsonNode condition; + private boolean ignoreSslErrors; + private boolean doNotRetry; + private String requestUrl; + private String payloadTemplate; + private String headersTemplate; + private String description; + private WebhookStats stats; /** The unique webhook ID. */ public String getId() { @@ -21,13 +33,69 @@ public String getUserId() { return userId; } - /** The URL the webhook posts to. */ - public String getRequestUrl() { - return requestUrl; + /** When the webhook was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** When the webhook was last modified. */ + public Instant getModifiedAt() { + return modifiedAt; + } + + /** + * Whether this is a one-off webhook attached to a single run (e.g. via {@code + * ActorStartOptions#webhooks}) rather than a persistent, account-level webhook. + */ + public boolean isAdHoc() { + return isAdHoc; } /** The events that trigger the webhook. */ public List getEventTypes() { return Collections.unmodifiableList(eventTypes); } + + /** + * The condition that must be met for the webhook to fire, as raw JSON (one of an Actor ID, a task + * ID, or a specific run ID, depending on how the webhook was configured). + */ + public JsonNode getCondition() { + return condition; + } + + /** Whether the webhook skips TLS certificate validation when calling {@link #getRequestUrl()}. */ + public boolean isIgnoreSslErrors() { + return ignoreSslErrors; + } + + /** Whether a failed dispatch is retried. */ + public boolean isDoNotRetry() { + return doNotRetry; + } + + /** The URL the webhook posts to. */ + public String getRequestUrl() { + return requestUrl; + } + + /** A template controlling the payload sent to {@link #getRequestUrl()}. */ + public String getPayloadTemplate() { + return payloadTemplate; + } + + /** A template controlling the extra HTTP headers sent with the dispatch, if any. */ + public String getHeadersTemplate() { + return headersTemplate; + } + + /** A description of what the webhook does. */ + public String getDescription() { + return description; + } + + /** Usage statistics for this webhook. */ + public WebhookStats getStats() { + return stats; + } } diff --git a/src/main/java/com/apify/client/webhook/WebhookStats.java b/src/main/java/com/apify/client/webhook/WebhookStats.java new file mode 100644 index 0000000..3f6aba7 --- /dev/null +++ b/src/main/java/com/apify/client/webhook/WebhookStats.java @@ -0,0 +1,14 @@ +package com.apify.client.webhook; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Usage statistics for a {@link Webhook}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class WebhookStats { + private long totalDispatches; + + /** The total number of times this webhook has been dispatched. */ + public long getTotalDispatches() { + return totalDispatches; + } +} diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 0b6a0cd..f01966b 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -205,6 +205,39 @@ void batchAddRequestsChunks() { assertEquals(3, backend.calls); // 25 + 25 + 10 } + @Test + void batchAddRequestsSplitsChunksByPayloadSize() { + MockTransport backend = + MockTransport.ofConstant( + 200, "{\"data\":{\"processedRequests\":[],\"unprocessedRequests\":[]}}"); + // Five requests, each carrying a ~4 MiB payload: three of them already exceed the ~9 MiB + // per-call limit, so byte-size chunking must split this single 5-request batch (well under + // the 25-request count limit alone) into more than one HTTP call. + String bigPayload = "x".repeat(4 * 1024 * 1024); + List requests = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + requests.add(new RequestQueueRequest("https://example.com", "k" + i).setPayload(bigPayload)); + } + client(backend) + .requestQueue("q1") + .batchAddRequests( + requests, + false, + new BatchAddRequestsOptions().maxParallel(1).maxUnprocessedRequestsRetries(0)); + assertTrue(backend.calls > 1, "large requests should be split across multiple batch calls"); + } + + @Test + void batchAddRequestsThrowsWhenSingleRequestExceedsPayloadLimit() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); + String hugePayload = "x".repeat(10 * 1024 * 1024); // exceeds the ~9 MiB limit on its own + List requests = + List.of(new RequestQueueRequest("https://example.com", "k0").setPayload(hugePayload)); + assertThrows( + IllegalArgumentException.class, + () -> client(backend).requestQueue("q1").batchAddRequests(requests, false)); + } + @Test void batchAddRequestsRetriesUnprocessed() { MockTransport backend = diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index bc488c1..fd25e5f 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -5,6 +5,8 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; +import com.apify.client.actor.ActorCallOptions; +import com.apify.client.actor.ActorClient; import com.apify.client.actor.ActorStartOptions; import com.apify.client.dataset.DatasetListItemsOptions; import com.apify.client.log.StreamedLog; @@ -14,12 +16,22 @@ import com.apify.client.run.RunClient; import com.apify.client.run.RunListOptions; import com.apify.client.run.RunResurrectOptions; +import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; class ActorRunIntegrationTest extends IntegrationBase { + // Note: RunClient.metamorph/reboot/charge are intentionally NOT exercised by a live integration + // test here, matching the project's existing convention for UserIntegrationTest#updateLimits. + // `metamorph` requires a second target Actor under the same account with a compatible input + // schema. `reboot` requires a genuinely *running* container; the store Actor exercised in this + // suite (hello-world) finishes in ~1-2s, so a live reboot test would be either flaky-by- + // construction or need a bespoke slow-running test Actor this suite doesn't otherwise need. + // `charge` only applies to pay-per-event Actors, which none of this suite's fixtures are. All + // three remain covered offline by ClientBehaviourRegressionTest. + @Test void listRuns() { ApifyClient client = requireClient(); @@ -41,6 +53,74 @@ void runActorAndReadOutputs() { client.run(run.getId()).dataset().listItems(new DatasetListItemsOptions()); client.run(run.getId()).keyValueStore().getRecord("OUTPUT"); client.run(run.getId()).requestQueue().listHead(null); + + // Nested run-storage metadata GETs (previously untested): confirm the run's default storages + // are reachable as full metadata resources, not just via the item/record/head-level calls + // above. + assertTrue(client.run(run.getId()).dataset().get().isPresent()); + assertTrue(client.run(run.getId()).keyValueStore().get().isPresent()); + assertTrue(client.run(run.getId()).requestQueue().get().isPresent()); + + // Typed getters (previously only reachable via getExtra()): verify the API's response + // actually deserializes into them, not just that the code compiles. + assertTrue(run.getGeneralAccess() != null); + assertTrue(run.getStats() != null); + assertTrue(run.getOptions() != null && run.getOptions().getBuild() != null); + assertTrue(run.getMeta() != null && run.getMeta().getOrigin() != null); + assertTrue(run.getUsage() != null); + } + + @Test + void actorRunsNestedCollection() throws InterruptedException { + ApifyClient client = requireClient(); + ActorClient actor = client.actor("apify/hello-world"); + ActorRun run = actor.call(null, new ActorStartOptions(), 120L); + + // Runs are sorted ascending by startedAt by default, and "apify/hello-world" is a heavily + // used public Actor, so the just-started run would never surface within a small default-order + // page; request newest-first. The LIST endpoint's index can also lag a just-finished run by a + // moment (eventual consistency, same class of race as + // IterationIntegrationTest#findsAllEventually), so poll with a bounded retry too. + boolean found = false; + for (int attempt = 0; !found && attempt < 10; attempt++) { + var page = actor.runs().list(new ListOptions().limit(5L).desc(true), new RunListOptions()); + assertTrue(page.getTotal() >= 0); + found = page.getItems().stream().anyMatch(r -> run.getId().equals(r.getId())); + if (!found) { + Thread.sleep(1000); + } + } + assertTrue(found, "expected the just-started run to appear in the Actor's run collection"); + + var iterated = + actor.runs().iterate(new ListOptions().limit(5L).desc(true), new RunListOptions()); + assertTrue(iterated.hasNext()); + } + + @Test + void callWithActorCallOptionsStreamsLogByDefault() { + ApifyClient client = requireClient(); + ActorClient actor = client.actor("apify/hello-world"); + List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + // The log-streaming call() overload (ActorCallOptions), matching the reference client's + // default call(options.log='default') behavior: the run's log is streamed for the duration of + // the wait without any explicit opt-in. runActorAndReadOutputs above exercises the plain + // (non-streaming) ActorStartOptions overload. + ActorRun run = + actor.call( + null, + new ActorCallOptions().logOptions(new StreamedLogOptions().toLog(collected::add)), + 120L); + assertEquals("SUCCEEDED", run.getStatus()); + assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); + } + + @Test + void callWithActorCallOptionsCanDisableLogStreaming() { + ApifyClient client = requireClient(); + ActorClient actor = client.actor("apify/hello-world"); + ActorRun run = actor.call(null, new ActorCallOptions().disableLogStreaming(), 120L); + assertEquals("SUCCEEDED", run.getStatus()); } @Test diff --git a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java index fff5759..1cc62e0 100644 --- a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java @@ -6,11 +6,16 @@ import com.apify.client.ApifyClient; import com.apify.client.StorageListOptions; import com.apify.client.requestqueue.BatchAddResult; +import com.apify.client.requestqueue.BatchDeleteResult; import com.apify.client.requestqueue.ListRequestsOptions; +import com.apify.client.requestqueue.LockedRequestQueueHead; +import com.apify.client.requestqueue.RequestLockInfo; import com.apify.client.requestqueue.RequestQueue; import com.apify.client.requestqueue.RequestQueueClient; import com.apify.client.requestqueue.RequestQueueOperationInfo; import com.apify.client.requestqueue.RequestQueueRequest; +import com.apify.client.requestqueue.RequestsList; +import com.apify.client.requestqueue.UnlockRequestsResult; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; @@ -139,15 +144,49 @@ void requestQueueLockLifecycle() { client.requestQueue(rq.getId()).withClientKey("java-test-client-key"); RequestQueueOperationInfo info = queue.addRequest(new RequestQueueRequest("https://lock.example.com", "lock"), false); - queue.listRequests(new ListRequestsOptions()); + + RequestsList listed = queue.listRequests(new ListRequestsOptions()); + assertTrue(listed.getLimit() > 0); + assertTrue(!listed.getItems().isEmpty()); queue.listRequests( new ListRequestsOptions() .filter( List.of(ListRequestsOptions.FILTER_LOCKED, ListRequestsOptions.FILTER_PENDING))); - queue.listAndLockHead(60, 10L); - queue.prolongRequestLock(info.getRequestId(), 30, false); + + LockedRequestQueueHead locked = queue.listAndLockHead(60, 10L); + assertEquals(60, locked.getLockSecs()); + assertTrue(!locked.getItems().isEmpty()); + assertTrue(locked.getItems().get(0).getLockExpiresAt() != null); + + RequestLockInfo prolonged = queue.prolongRequestLock(info.getRequestId(), 30, false); + assertTrue(prolonged.getLockExpiresAt() != null); + queue.deleteRequestLock(info.getRequestId(), false); - queue.unlockRequests(); + UnlockRequestsResult unlocked = queue.unlockRequests(); + assertTrue(unlocked.getUnlockedCount() >= 0); + } finally { + client.requestQueue(rq.getId()).delete(); + } + } + + @Test + void requestQueueBatchDeleteRequests() { + ApifyClient client = requireClient(); + RequestQueue rq = client.requestQueues().getOrCreate(uniqueName("rq-batch-delete")); + try { + RequestQueueClient queue = client.requestQueue(rq.getId()); + RequestQueueOperationInfo first = + queue.addRequest( + new RequestQueueRequest("https://batch-delete.example.com/1", "bd1"), false); + RequestQueueOperationInfo second = + queue.addRequest( + new RequestQueueRequest("https://batch-delete.example.com/2", "bd2"), false); + + BatchDeleteResult result = + queue.batchDeleteRequests( + List.of(Map.of("id", first.getRequestId()), Map.of("uniqueKey", "bd2"))); + assertEquals(2, result.getProcessedRequests().size()); + assertTrue(result.getUnprocessedRequests().isEmpty()); } finally { client.requestQueue(rq.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java index 0455c48..c683799 100644 --- a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java @@ -69,6 +69,15 @@ void scheduleCrudFlow() { Schedule updated = schedule.update(Map.of("cronExpression", "0 12 * * *")); assertEquals("0 12 * * *", updated.getCronExpression()); schedule.getLog(); + + // Typed getters (previously only reachable via getExtra()): verify the API's response + // actually deserializes into them, not just that the code compiles. + assertTrue(sch.isExclusive()); + assertTrue(sch.getTimezone() != null && !sch.getTimezone().isEmpty()); + assertTrue(sch.getCreatedAt() != null); + assertTrue(sch.getModifiedAt() != null); + assertTrue(sch.getNotifications() != null); + assertTrue(sch.getActions() != null && sch.getActions().isEmpty()); } finally { client.schedule(sch.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index bb1426c..f6fe055 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -8,6 +8,7 @@ import com.apify.client.run.ActorRun; import com.apify.client.run.RunListOptions; import com.apify.client.task.Task; +import com.apify.client.task.TaskCallOptions; import com.apify.client.task.TaskClient; import com.apify.client.task.TaskStartOptions; import java.util.Map; @@ -21,6 +22,8 @@ static Map taskDef(String name) { "apify/hello-world", "name", name, + "description", + "Integration test task", "options", Map.of("build", "latest", "memoryMbytes", 256, "timeoutSecs", 60), "input", @@ -57,6 +60,13 @@ void taskCrudFlow() { assertTrue(tc.getInput().isPresent()); tc.update(Map.of("name", uniqueName("task-renamed"))); tc.runs().list(new ListOptions(), new RunListOptions()); + + // Typed getters (previously only reachable via getExtra()): verify the API's response + // actually deserializes into them, not just that the code compiles. + assertEquals("Integration test task", task.getDescription()); + assertTrue(task.getStats() != null); + assertTrue(task.getOptions() != null && "latest".equals(task.getOptions().getBuild())); + assertTrue(task.getInput() != null); } finally { client.task(task.getId()).delete(); } @@ -70,6 +80,9 @@ void taskLastRunAndWebhooks() { TaskClient tc = client.task(task.getId()); ActorRun run = tc.call(null, new TaskStartOptions(), 120L); assertEquals("SUCCEEDED", run.getStatus()); + assertTrue(run.getStats() != null); + assertTrue(run.getOptions() != null); + assertTrue(run.getMeta() != null && run.getMeta().getOrigin() != null); var lastRun = tc.lastRun("SUCCEEDED").get(); assertTrue(lastRun.isPresent()); @@ -83,4 +96,35 @@ void taskLastRunAndWebhooks() { client.task(task.getId()).delete(); } } + + @Test + void taskCallStreamsLogByDefault() throws InterruptedException { + ApifyClient client = requireClient(); + Task task = client.tasks().create(taskDef(uniqueName("task-call-log"))); + try { + TaskClient tc = client.task(task.getId()); + java.util.List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + // The log-streaming call() overload (TaskCallOptions), matching the reference client's + // default call(options.log='default') behavior: the run's log is streamed for the duration + // of the wait without any explicit opt-in. + ActorRun run = + tc.call( + null, + new TaskCallOptions() + .logOptions(new com.apify.client.log.StreamedLogOptions().toLog(collected::add)), + 120L); + assertEquals("SUCCEEDED", run.getStatus()); + // As in ActorRunIntegrationTest#streamedLogRedirection: a fast run can finish (and call()'s + // finally-block close the stream) before the background reader's first read completes, even + // though the log content is already fully available server-side. Give it a bounded window to + // catch up rather than asserting immediately. + long deadline = System.currentTimeMillis() + 15_000; + while (collected.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(250); + } + assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); + } finally { + client.task(task.getId()).delete(); + } + } } diff --git a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java index 58f0de4..0b1a5f3 100644 --- a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java @@ -76,6 +76,14 @@ void webhookCrudFlow() { assertEquals("https://example.com/updated", updated.getRequestUrl()); webhook.dispatches().list(new ListOptions()); webhook.test(); + + // Typed getters (previously only reachable via getExtra()): verify the API's response + // actually deserializes into them, not just that the code compiles. + assertTrue(wh.isAdHoc()); + assertTrue(wh.getCondition() != null); + assertTrue(wh.getCreatedAt() != null); + assertTrue(wh.getModifiedAt() != null); + assertTrue(wh.getStats() != null); } finally { client.webhook(wh.getId()).delete(); } From c78676082cc4eeac0ccd13055f6b443c62c5c1fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 06:50:35 +0000 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20loop-4=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20disclaimer=20dedup,=20batchAddRequests=20never-thro?= =?UTF-8?q?ws,=20parity=20getters,=20call()=20DRY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes every item from the loop-3 review's residual list: removed the duplicated AI/experimental disclaimer from ApifyClient's class javadoc (README-only now); RequestQueueClient.batchAddRequests no longer throws on a non-retryable 4xx, matching the reference client's never-throws contract (unprocessed requests reported via BatchAddResult instead); added offline mock-backend coverage for RunClient.metamorph/reboot and corrected the stale ActorRunIntegrationTest comment; added the remaining typed getters (ActorRun.usageTotalUsd/buildNumber/exitCode/isContainerServerReady/ gitBranchName/storageIds, Webhook.shouldInterpolateStrings/lastDispatch); extracted StartOptionsLike/CallOptionsLike + a RunStartSupport helper so ActorClient/TaskClient's start/call machinery stops duplicating ~185 lines; ApifyClientBuilder.timeout now rejects zero/negative durations; RequestQueueRequest.userData is defensively deep-copied; RequestQueueClient's cursor iterator now trims an overshot page to totalLimit; tightened spotbugs-exclude.xml to entries that actually reproduce a finding; plus the requested docs completions and CI permissions block. See notes.md "Implementer — loop 4" in apify-client-orchestration for the itemized list and test evidence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- .github/workflows/java-integration-tests.yml | 5 ++ CHANGELOG.md | 41 +++++++-- README.md | 34 ++++--- docs/README.md | 2 +- docs/runs.md | 36 ++++++-- docs/schedules.md | 4 +- docs/storages.md | 33 +++++++ docs/tasks.md | 4 +- docs/webhooks.md | 9 +- spotbugs-exclude.xml | 42 ++++----- .../java/com/apify/client/ApifyClient.java | 12 ++- .../com/apify/client/ApifyClientBuilder.java | 22 ++++- .../apify/client/actor/ActorCallOptions.java | 12 ++- .../com/apify/client/actor/ActorClient.java | 42 +-------- .../com/apify/client/actor/ActorEnvVar.java | 3 + .../apify/client/actor/ActorStartOptions.java | 9 +- .../dataset/DatasetCollectionClient.java | 3 +- .../internal/AbstractCollectionClient.java | 6 +- .../client/internal/CallOptionsLike.java | 23 +++++ .../client/internal/ResourceContext.java | 13 +++ .../client/internal/RunStartSupport.java | 90 +++++++++++++++++++ .../client/internal/StartOptionsLike.java | 16 ++++ .../KeyValueStoreCollectionClient.java | 3 +- .../requestqueue/RequestQueueClient.java | 58 ++++-------- .../requestqueue/RequestQueueRequest.java | 23 ++++- .../java/com/apify/client/run/ActorRun.java | 46 ++++++++++ .../apify/client/task/TaskCallOptions.java | 12 ++- .../com/apify/client/task/TaskClient.java | 43 +-------- .../apify/client/task/TaskStartOptions.java | 17 +++- .../com/apify/client/webhook/Webhook.java | 16 ++++ .../client/webhook/WebhookLastDispatch.java | 30 +++++++ .../client/ClientBehaviourRegressionTest.java | 90 ++++++++++++++++--- .../java/com/apify/client/MockTransport.java | 3 + .../integration/ActorRunIntegrationTest.java | 5 +- 34 files changed, 592 insertions(+), 215 deletions(-) create mode 100644 src/main/java/com/apify/client/internal/CallOptionsLike.java create mode 100644 src/main/java/com/apify/client/internal/RunStartSupport.java create mode 100644 src/main/java/com/apify/client/internal/StartOptionsLike.java create mode 100644 src/main/java/com/apify/client/webhook/WebhookLastDispatch.java diff --git a/.github/workflows/java-integration-tests.yml b/.github/workflows/java-integration-tests.yml index e094fa8..8a23ae1 100644 --- a/.github/workflows/java-integration-tests.yml +++ b/.github/workflows/java-integration-tests.yml @@ -20,6 +20,11 @@ concurrency: group: java-integration-${{ github.ref }} cancel-in-progress: true +# Least-privilege default: this workflow only checks out code and runs tests, it never writes to +# the repository or opens PRs/issues, so the GITHUB_TOKEN needs no more than read access. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d2283..d621dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `KeyValueStoreCollectionClient`, `RequestQueueCollectionClient`, `ActorCollectionClient`, `ScheduleCollectionClient`, `TaskCollectionClient`, `BuildCollectionClient` and `RunCollectionClient`; behavior is unchanged. +- Extracted shared `StartOptionsLike`/`CallOptionsLike` interfaces (internal) and a + `RunStartSupport` helper backing `ActorClient`/`TaskClient`'s `start`/`call`, removing the + duplicated implementation between the two; behavior is unchanged. +- Tightened `spotbugs-exclude.xml` to only the entries that currently reproduce a real finding. +- `java-integration-tests.yml` now declares an explicit, least-privilege `permissions: contents: + read` block. ### Added @@ -67,9 +73,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Typed getters replacing several `getExtra()`-only fields: `Schedule` (`title`, `timezone`, `isExclusive`, `description`, `createdAt`, `modifiedAt`, `nextRunAt`, `lastRunAt`, `actions`, `notifications`), `Webhook` (`condition`, `ignoreSslErrors`, `doNotRetry`, `payloadTemplate`, - `headersTemplate`, `isAdHoc`, `stats`, `description`, `createdAt`, `modifiedAt`), `Task` - (`description`, `stats`, `options`, `input`, `actorStandby`), `ActorRun` (`generalAccess`, - `chargedEventCounts`, `pricingInfo`, `usage`, `usageUsd`, `stats`, `options`, `meta`). + `headersTemplate`, `isAdHoc`, `stats`, `description`, `createdAt`, `modifiedAt`, + `shouldInterpolateStrings`, `lastDispatch`), `Task` (`description`, `stats`, `options`, `input`, + `actorStandby`), `ActorRun` (`generalAccess`, `chargedEventCounts`, `pricingInfo`, `usage`, + `usageUsd`, `stats`, `options`, `meta`, `usageTotalUsd`, `buildNumber`, `exitCode`, + `isContainerServerReady`, `gitBranchName`, `storageIds`). ### Fixed @@ -77,11 +85,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). byte size (matching the reference client's `MAX_PAYLOAD_SIZE_BYTES`), not just the 25-request count limit, so a batch of individually large requests (e.g. sizeable `userData`) can no longer 413. +- `RequestQueueClient.batchAddRequests` no longer throws on a non-retryable 4xx; the affected + requests are now reported via `BatchAddResult.getUnprocessedRequests()`, matching the reference + client's never-throws contract. - `ApifyApiException` now extends `ApifyClientException` (previously `RuntimeException` directly), so `catch (ApifyClientException)` catches API errors as documented. - `PaginationList.getItems()` no longer throws `NullPointerException` when the API response contains an explicit `"items": null`. - `PaginationList.setItems` now defensively copies its input. +- `ApifyClient`'s class javadoc no longer duplicates the "official, but experimental" disclaimer; + it appears only once, in the top-level README, as required. +- `ApifyClientBuilder.timeout(Duration)` now rejects `Duration.ZERO`/a negative duration at build + time instead of building a client whose first request fails deep inside the transport. +- `RequestQueueRequest.getUserData()`/`setUserData()` now defensively deep-copy the `JsonNode`, + matching `getHeaders()`/`getErrorMessages()`, so external mutation of the passed-in/returned value + can no longer corrupt the request's stored state. +- `RequestQueueClient.paginateRequests(...)` no longer yields more than the requested `totalLimit` + when a single page overshoots it, matching `PaginatedIterator`'s equivalent guard. + +### Documentation + +- Documented the newly added `ActorRun`/`Webhook` fields above, `ActorRunOptions`/`ActorRunUsage`'s + getters, and the per-scope support for `RunListOptions.startedAfter`/`startedBefore`; added a + request-queue lock/unlock worked example (`withClientKey`/`listAndLockHead`/ + `prolongRequestLock`/`deleteRequestLock`/`unlockRequests`); added the missing + `DownloadItemsFormat` import to the package table and fixed the Quick Start "fragment" + contradiction; documented `ApifyApiException`'s accessor return types and the + `HttpTimeoutException` naming collision with `java.net.http.HttpTimeoutException`; added + `setStatusMessage` to the README resources table; noted `getExtra()` on every thin response model. ## [0.3.1] - 2026-07-14 @@ -202,8 +233,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.0] - 2026-07-03 -Initial release of the official (experimental, AI-generated and AI-maintained) Java client for the -Apify API, verified against OpenAPI specification version `v2-2026-07-02T131926Z`. +Initial release of the Java client for the Apify API, targeting OpenAPI specification version +`v2-2026-07-02T131926Z`. ### Added diff --git a/README.md b/README.md index 88310f2..eeb017b 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,11 @@ any other environment variable) automatically. Read it yourself if you want that All public client types live under `com.apify.client`, split by resource into sub-packages (e.g. `com.apify.client.run.ActorRun`, `com.apify.client.dataset.DatasetListItemsOptions`) — see [Resources](#resources) below for the full list; [`docs/README.md`](docs/README.md#imports-and-dependencies) -enumerates the model/option-type packages. The remaining snippets in this file (like the Quick -start one above) are fragments that assume a configured `client` and the correct imports for the -types they use — they are not, by themselves, complete programs; the [resource pages](docs/README.md) -show the same kind of fragment per method. Reading items from a run's default dataset: +enumerates the model/option-type packages. The Quick start example above is a complete, runnable +program (imports, class, `main`); every other snippet in this file, from here on, is a fragment +that assumes a configured `client` and the correct imports for the types it uses — not, by itself, +a complete program — the [resource pages](docs/README.md) show the same kind of fragment per +method. Reading items from a run's default dataset: ```java ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); @@ -132,8 +133,11 @@ It has two concrete subtypes, both also in `com.apify.client.http`: - `ApifyTransportException` — the request never produced an API response at all (connection failure, DNS, timeout, or a local failure preparing the request/response, e.g. compression). `isTimeout()` reports whether the underlying cause was specifically a timeout (backed by - `HttpTimeoutException`, part of the `HttpTransport` contract, not any specific transport - implementation's own exception type). + `com.apify.client.http.HttpTimeoutException`, part of the `HttpTransport` contract, not any + specific transport implementation's own exception type). Note the name collision: this is a + distinct type from the JDK's own `java.net.http.HttpTimeoutException` (which + `DefaultHttpTransport` catches internally and translates into this one) — use an explicit, + fully-qualified import or a clear alias if a file needs both. Catch `ApifyClientException` to handle both failure modes uniformly, or catch a specific subtype to handle one of them differently. `ApifyApiException` is imported from `com.apify.client.http`: @@ -148,14 +152,15 @@ try { `ApifyApiException` exposes the parsed error details: -| Accessor | Meaning | -|---|---| -| `getStatusCode()` | HTTP status code of the error response. | -| `getType()` | Machine-readable error type (e.g. `record-not-found`). | -| `getMessage()` | Human-readable description. | -| `getAttempt()` | The (1-based) attempt number that produced the error. | -| `getHttpMethod()` / `getPath()` | The request method and path. | -| `getData()` | Additional structured error data, if any. | +| Accessor | Type | Meaning | +|---|---|---| +| `getStatusCode()` | `int` | HTTP status code of the error response. | +| `getType()` | `String` (nullable) | Machine-readable error type (e.g. `record-not-found`). | +| `getMessage()` | `String` | Human-readable description (also `Throwable.getMessage()`). | +| `getAttempt()` | `int` | The (1-based) attempt number that produced the error. | +| `getHttpMethod()` | `String` | The request's HTTP method. | +| `getPath()` | `String` | The request's URL path. | +| `getData()` | `Map` (nullable, unmodifiable) | Additional structured error data, if any. | ## Versioning @@ -229,6 +234,7 @@ Every resource client lives in its own sub-package of `com.apify.client`, named | `store()` | `StoreCollectionClient` | `com.apify.client.store` | Apify Store | | `me()` / `user(id)` | `UserClient` | `com.apify.client.user` | Users | | `log(id)` | `LogClient` | `com.apify.client.log` | Build/run logs | +| `setStatusMessage(message, options)` | — (direct `ApifyClient` method, no resource client) | `com.apify.client.run` (for `SetStatusMessageOptions`) | Sets the status message of the current Actor run (see [docs/README.md](docs/README.md#setting-the-current-runs-status-message)) | The HTTP transport contract (`HttpTransport`, `DefaultHttpTransport`) and the exceptions thrown for transport-level failures (`ApifyTransportException`, `HttpTimeoutException`) live in diff --git a/docs/README.md b/docs/README.md index d79d193..df23451 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ option types, so import each resource you use from its own package: | `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient` | | `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient` | | `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | -| `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions` | +| `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions`, `DownloadItemsFormat` | | `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreCollectionClient`, `KeyValueStoreRecord`, `KeyValueStoreKeysPage`, `KeyValueStoreKey`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | | `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | | `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions`, `ValidateInputOptions` | diff --git a/docs/runs.md b/docs/runs.md index 7b586c3..99b8888 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -10,8 +10,13 @@ Access the run collection with `client.runs()` (or `client.actor(id).runs()` / | `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). +`RunListOptions` adds `status(List)` (e.g. `SUCCEEDED`, `RUNNING`; sent comma-separated), +declared by every scope's endpoint (`client.runs()`, `client.actor(id).runs()`, +`client.task(id).runs()`), and `startedAfter(String)` / `startedBefore(String)` (ISO-8601), declared +only by the account-wide `client.runs()` and the Actor-scoped `client.actor(id).runs()` endpoints — +the task-scoped `client.task(id).runs()` endpoint's spec does not declare these two parameters at +all, so set them only when listing at one of the other two scopes. All parameters a given scope +declares compose (e.g. a status filter combined with a start-time window on `client.runs()`). ```java PaginationList runs = client.runs().list( @@ -96,10 +101,29 @@ owning it, e.g. `"ANYONE_WITH_ID_CAN_READ"`, `"RESTRICTED"`, or `null` to follow default), `getChargedEventCounts()` (`Map`; per-event-type charge counts for pay-per-event Actors, `null` otherwise), `getPricingInfo()` (`JsonNode`; shape depends on the Actor's pricing model), `getUsage()` / `getUsageUsd()` (`ActorRunUsage`; per-billable-unit resource -consumption, the latter as its USD cost), `getStats()` (`ActorRunStats`; runtime metrics like -`getDurationMillis()`, `getMemAvgBytes()`, `getCpuAvgUsage()`), `getOptions()` (`ActorRunOptions`; -the run configuration actually applied, which may differ from what was requested), and `getMeta()` -(`ActorRunMeta`; `getOrigin()`, `getClientIp()`, `getUserAgent()` — how the run was initiated). +consumption, the latter as its USD cost — see below), `getUsageTotalUsd()` (`Double`; the run's +total cost in USD, combining platform usage and/or event costs depending on pricing model), +`getStats()` (`ActorRunStats`; runtime metrics like `getDurationMillis()`, `getMemAvgBytes()`, +`getCpuAvgUsage()`), `getOptions()` (`ActorRunOptions`; the run configuration actually applied, +which may differ from what was requested — see below), `getMeta()` (`ActorRunMeta`; `getOrigin()`, +`getClientIp()`, `getUserAgent()` — how the run was initiated), `getBuildNumber()` (`String`; the +build's semver-like number, e.g. `"0.0.36"`), `getExitCode()` (`Integer`; the process exit code once +finished), `isContainerServerReady()` (`Boolean`; whether the container's HTTP server can accept +requests), `getGitBranchName()` (`String`; the git branch the build was built from, if any), and +`getStorageIds()` (`JsonNode`; a `{datasets, keyValueStores, requestQueues}` map of aliased storage +IDs for the run, each with at least a `"default"` entry). Any field not covered by a typed getter is +still available via the inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). + +`ActorRunUsage` (`getUsage()`/`getUsageUsd()`) exposes one nullable `Double` getter per billable +unit: `getActorComputeUnits()`, `getDatasetReads()`, `getDatasetWrites()`, +`getKeyValueStoreReads()`, `getKeyValueStoreWrites()`, `getKeyValueStoreLists()`, +`getRequestQueueReads()`, `getRequestQueueWrites()`, `getDataTransferInternalGbytes()`, +`getDataTransferExternalGbytes()`, `getProxyResidentialTransferGbytes()`, `getProxySerps()`. Each is +`null` when the run's Actor did not incur that kind of usage. + +`ActorRunOptions` (`getOptions()`) exposes: `getBuild()` (`String`), `getTimeoutSecs()` (`long`), +`getMemoryMbytes()` (`long`), `getDiskMbytes()` (`long`), `getMaxItems()` (`Long`, nullable), +`getMaxTotalChargeUsd()` (`Double`, nullable), `getRestartOnError()` (`Boolean`, nullable). `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 c998fba..5e8372b 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -41,4 +41,6 @@ s.ifPresent(sched -> System.out.println(sched.getCronExpression())); (skip a new run while a previous one is still active), `getCreatedAt()` / `getModifiedAt()` (`Instant`), `getNextRunAt()` / `getLastRunAt()` (`Instant`, may be `null`), `getActions()` (`List`, each entry's shape depends on its `type`), and `getNotifications()` -(`ScheduleNotifications`, exposing `isEmail()`). +(`ScheduleNotifications`, exposing `isEmail()`). Any field not covered by a typed getter is still +available via the inherited `getExtra()` (see +[the docs index](README.md#model-fields-and-unmodeled-data-getextra)). diff --git a/docs/storages.md b/docs/storages.md index a55dbd9..17cccbd 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -17,6 +17,10 @@ getters `getId()`, `getName()`, `getUserId()`, `getCreatedAt()` (`Instant`), and | `KeyValueStore` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | — | | `RequestQueue` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | `getTotalRequestCount()` (`long`) | +`Dataset`, `KeyValueStore`, `RequestQueue` and `RequestQueueRequest` all extend `ApifyResource`, so +any field not covered by a typed getter above is still available via the inherited `getExtra()` +(see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). + ## Datasets ### `DatasetCollectionClient` — `client.datasets()` @@ -194,6 +198,35 @@ while (it.hasNext()) { } ``` +> **Lock lifecycle for distributed crawling.** `withClientKey(String)` returns a copy of the client +> that stamps every request with a stable identifier, so the queue can tell which client holds which +> lock; use the *same* `RequestQueueClient` instance (or another built with the same client key) for +> the whole lock/unlock cycle below. `listAndLockHead` atomically reserves requests so other workers +> using a different client key cannot receive them until the lock expires or is explicitly released. + +```java +RequestQueueClient queue = client.requestQueue("QUEUE_ID").withClientKey("worker-1"); + +// Atomically reserve up to 10 requests from the head, locked for 60s. +LockedRequestQueueHead locked = queue.listAndLockHead(60L, 10L); +for (RequestQueueRequest request : locked.getItems()) { + boolean handledSuccessfully = true; // replace with your own processing logic/result + if (handledSuccessfully) { + queue.deleteRequest(request.getId()); + } else { + // Give up on this one: release its lock so another worker can pick it up instead. + queue.deleteRequestLock(request.getId(), false); + } +} + +// Still working on a request past its lock's expiry? Extend it instead of losing it mid-process. +queue.prolongRequestLock(locked.getItems().get(0).getId(), 60L, false); + +// On shutdown, release every lock this client still holds so other workers are not blocked +// waiting for locks this worker will never finish processing. +queue.unlockRequests(); +``` + `ListRequestsOptions` fields: `limit`, `exclusiveStartId`, `cursor` (mutually exclusive with `exclusiveStartId`), and `filter(List)` restricted to `ListRequestsOptions.FILTER_LOCKED` / `FILTER_PENDING`. diff --git a/docs/tasks.md b/docs/tasks.md index 696f4ee..8a082f4 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -54,4 +54,6 @@ ActorRun streamed = client.task("TASK_ID").call(null, new TaskCallOptions().memo `getBuild()`, `getTimeoutSecs()`, `getMemoryMbytes()`, `getRestartOnError()`), `getInput()` (a `JsonNode` snapshot of the stored input, from whichever response last returned this `Task` object; prefer `TaskClient.getInput()` above to fetch it fresh on-demand), and `getActorStandby()` -(`ActorStandby`, standby-mode configuration overrides for this task, if any). +(`ActorStandby`, standby-mode configuration overrides for this task, if any). Any field not covered +by a typed getter is still available via the inherited `getExtra()` (see +[the docs index](README.md#model-fields-and-unmodeled-data-getextra)). diff --git a/docs/webhooks.md b/docs/webhooks.md index daa64b6..455d9f0 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -53,10 +53,15 @@ System.out.println(dispatch.getId()); `Webhook` fields: `getId()`, `getUserId()`, `getRequestUrl()`, `getEventTypes()`, `getCreatedAt()` / `getModifiedAt()` (`Instant`), `isAdHoc()` (a one-off webhook attached to a single run, e.g. via `ActorStartOptions.webhooks(...)`, rather than a persistent account-level webhook), +`isShouldInterpolateStrings()` (`Boolean`; whether `{{...}}` placeholders in `getPayloadTemplate()`/ +`getHeadersTemplate()` are interpolated with values from the triggering event before dispatch), `getCondition()` (`JsonNode`; one of an Actor ID, a task ID or a specific run ID, depending on how the webhook was configured), `isIgnoreSslErrors()`, `isDoNotRetry()`, `getPayloadTemplate()`, -`getHeadersTemplate()`, `getDescription()`, and `getStats()` (`WebhookStats`, exposing -`getTotalDispatches()`). +`getHeadersTemplate()`, `getDescription()`, `getLastDispatch()` (`WebhookLastDispatch`, nullable — +a summary of the most recent dispatch: `getStatus()` one of `"ACTIVE"`/`"SUCCEEDED"`/`"FAILED"`, +`getFinishedAt()`/`getRemovedAt()` as `Instant`), and `getStats()` (`WebhookStats`, exposing +`getTotalDispatches()`). Any field not covered by a typed getter is still available via the +inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). ## `WebhookDispatchCollectionClient` and `WebhookDispatchClient` diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index f56085a..d2d8b9a 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -1,36 +1,31 @@ - - - - - @@ -41,6 +36,7 @@ + diff --git a/src/main/java/com/apify/client/ApifyClient.java b/src/main/java/com/apify/client/ApifyClient.java index e788682..ea349a2 100644 --- a/src/main/java/com/apify/client/ApifyClient.java +++ b/src/main/java/com/apify/client/ApifyClient.java @@ -35,10 +35,6 @@ /** * The entry point for interacting with the Apify API. * - *

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

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

  • Cross-cutting behaviour (auth, User-Agent, retries with exponential backoff, timeouts) * lives in the internal HTTP client and is applied to every request. * + * + *

    Every resource client's constructor is public, but that is an artifact of each living in its + * own resource-scoped package (e.g. {@link ActorClient} in {@code com.apify.client.actor}) rather + * than an invitation to call it directly: they are not meant to be constructed by user code. Always + * obtain one from {@link ApifyClient} or from another resource client's own accessor methods (e.g. + * {@link ActorClient#lastRun}, {@link ActorClient#builds}), which supply the internal wiring (the + * shared HTTP client, base URL, inherited query filters) a direct constructor call would otherwise + * have to reconstruct by hand. */ public final class ApifyClient { diff --git a/src/main/java/com/apify/client/ApifyClientBuilder.java b/src/main/java/com/apify/client/ApifyClientBuilder.java index 96e5cc8..1938f97 100644 --- a/src/main/java/com/apify/client/ApifyClientBuilder.java +++ b/src/main/java/com/apify/client/ApifyClientBuilder.java @@ -5,6 +5,7 @@ import com.apify.client.http.RetryConfig; import com.apify.client.internal.HttpClientCore; import java.time.Duration; +import java.util.Locale; import java.util.function.BooleanSupplier; /** @@ -85,7 +86,7 @@ public ApifyClientBuilder maxDelayBetweenRetries(Duration maxDelayBetweenRetries * (see {@link DefaultHttpTransport#DefaultHttpTransport(Duration)}). */ public ApifyClientBuilder timeout(Duration timeout) { - this.timeout = requireNonNegative(timeout, "timeout"); + this.timeout = requirePositive(timeout, "timeout"); return this; } @@ -128,6 +129,21 @@ private static Duration requireNonNegative(Duration value, String argName) { return value; } + /** + * Validates a non-null, strictly-positive {@link Duration} argument, returning it unchanged. + * Unlike the delay parameters (where zero legitimately means "retry immediately"), a zero {@code + * timeout} is not tolerated: it is passed straight through to {@code HttpRequest.Builder#timeout} + * deep inside {@link DefaultHttpTransport}, which rejects a zero/negative duration at + * request-send time — so a zero value here would build a client that fails on its very first call + * rather than at construction time. + */ + private static Duration requirePositive(Duration value, String argName) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(argName + " must be strictly positive"); + } + return value; + } + /** Test seam: overrides how the client decides the {@code isAtHome} User-Agent flag. */ ApifyClientBuilder isAtHomeFn(BooleanSupplier isAtHomeFn) { this.isAtHomeFn = isAtHomeFn; @@ -214,11 +230,11 @@ static String platformToken() { */ static String platformToken(String osName, String vmName) { // Android runs on a Linux kernel (os.name == "Linux"); detect it via its VM name ("Dalvik"). - String vm = vmName.toLowerCase(java.util.Locale.ROOT); + String vm = vmName.toLowerCase(Locale.ROOT); if (vm.contains("dalvik")) { return "android"; } - String os = osName.toLowerCase(java.util.Locale.ROOT); + String os = osName.toLowerCase(Locale.ROOT); // macOS/Darwin is checked before Windows because the literal "darwin" contains "win"; the // reverse order would misclassify a "Darwin" os.name as "win32". if (os.contains("mac") || os.contains("darwin")) { diff --git a/src/main/java/com/apify/client/actor/ActorCallOptions.java b/src/main/java/com/apify/client/actor/ActorCallOptions.java index 38da85f..d12e017 100644 --- a/src/main/java/com/apify/client/actor/ActorCallOptions.java +++ b/src/main/java/com/apify/client/actor/ActorCallOptions.java @@ -1,5 +1,6 @@ package com.apify.client.actor; +import com.apify.client.internal.CallOptionsLike; import com.apify.client.log.StreamedLogOptions; import java.util.List; @@ -18,7 +19,7 @@ * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom * destination. */ -public final class ActorCallOptions { +public final class ActorCallOptions implements CallOptionsLike { private final ActorStartOptions startOptions = new ActorStartOptions(); private boolean logStreamingEnabled = true; @@ -101,15 +102,18 @@ public ActorCallOptions logOptions(StreamedLogOptions logOptions) { return this; } - ActorStartOptions toStartOptions() { + @Override + public ActorStartOptions toStartOptions() { return startOptions; } - boolean logStreamingEnabledValue() { + @Override + public boolean logStreamingEnabledValue() { return logStreamingEnabled; } - StreamedLogOptions logOptionsValue() { + @Override + public StreamedLogOptions logOptionsValue() { return logOptions; } } diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index bfc941c..ef06d7d 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -9,7 +9,7 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import com.apify.client.log.StreamedLog; +import com.apify.client.internal.RunStartSupport; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -65,10 +65,7 @@ public void delete() { * JSON-serializable value (or {@code null} for no input). */ public ActorRun start(Object input, ActorStartOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - byte[] body = input == null ? null : Json.toBytes(input); - return ctx.postWithBody("runs", params, body, options.contentTypeOrDefault(), ActorRun.class); + return RunStartSupport.start(ctx, input, options); } /** @@ -80,8 +77,7 @@ public ActorRun start(Object input, ActorStartOptions options) { * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { - ActorRun run = start(input, options); - return root.run(run.getId()).waitForFinish(waitSecs); + return RunStartSupport.call(root, ctx, input, options, waitSecs); } /** @@ -98,37 +94,7 @@ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { */ public ActorRun call(Object input, ActorCallOptions options, Long waitSecs) { ActorCallOptions opts = options != null ? options : new ActorCallOptions(); - ActorRun run = start(input, opts.toStartOptions()); - RunClient runClient = root.run(run.getId()); - - StreamedLog streamedLog = null; - if (opts.logStreamingEnabledValue()) { - streamedLog = startStreamedLogQuietly(runClient, opts); - } - try { - return runClient.waitForFinish(waitSecs); - } finally { - if (streamedLog != null) { - streamedLog.close(); - } - } - } - - /** - * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure - * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. - */ - private static StreamedLog startStreamedLogQuietly(RunClient runClient, ActorCallOptions opts) { - try { - StreamedLog streamedLog = - opts.logOptionsValue() != null - ? runClient.getStreamedLog(opts.logOptionsValue()) - : runClient.getStreamedLog(); - streamedLog.start(); - return streamedLog; - } catch (RuntimeException e) { - return null; - } + return RunStartSupport.callWithLogStreaming(root, ctx, input, opts, waitSecs); } /** Validates the given input against the Actor's default-build input schema. */ diff --git a/src/main/java/com/apify/client/actor/ActorEnvVar.java b/src/main/java/com/apify/client/actor/ActorEnvVar.java index 69ce243..d118105 100644 --- a/src/main/java/com/apify/client/actor/ActorEnvVar.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVar.java @@ -20,6 +20,7 @@ public String getName() { return name; } + /** Sets the environment variable name. */ public ActorEnvVar setName(String name) { this.name = name; return this; @@ -30,6 +31,7 @@ public String getValue() { return value; } + /** Sets the environment variable value. */ public ActorEnvVar setValue(String value) { this.value = value; return this; @@ -40,6 +42,7 @@ public Boolean getIsSecret() { return isSecret; } + /** Sets whether the value is stored as a secret. */ public ActorEnvVar setIsSecret(Boolean isSecret) { this.isSecret = isSecret; return this; diff --git a/src/main/java/com/apify/client/actor/ActorStartOptions.java b/src/main/java/com/apify/client/actor/ActorStartOptions.java index 0692f42..fbf6f06 100644 --- a/src/main/java/com/apify/client/actor/ActorStartOptions.java +++ b/src/main/java/com/apify/client/actor/ActorStartOptions.java @@ -3,6 +3,7 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; +import com.apify.client.internal.StartOptionsLike; import java.util.Base64; import java.util.List; @@ -10,7 +11,7 @@ * Configures starting an Actor run ({@link ActorClient#start}/{@link ActorClient#call}). All fields * are optional. */ -public final class ActorStartOptions { +public final class ActorStartOptions implements StartOptionsLike { private String build; private Long memoryMbytes; private Long timeoutSecs; @@ -88,13 +89,15 @@ public ActorStartOptions webhooks(List webhooks) { return this; } - String contentTypeOrDefault() { + @Override + public String contentTypeOrDefault() { return (contentType != null && !contentType.isEmpty()) ? contentType : ResourceContext.CONTENT_TYPE_JSON; } - void apply(QueryParams q) { + @Override + public void apply(QueryParams q) { q.addString("build", build) .addLong("memory", memoryMbytes) .addLong("timeout", timeoutSecs) diff --git a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java index 0ac8433..d2392bd 100644 --- a/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetCollectionClient.java @@ -31,7 +31,6 @@ public Dataset getOrCreate(String name) { * a {@code null} schema behaves like {@link #getOrCreate(String)}. */ public Dataset getOrCreate(String name, Object schema) { - Object body = schema == null ? null : java.util.Collections.singletonMap("schema", schema); - return ctx.getOrCreateNamed(name, body, Dataset.class); + return ctx.getOrCreateNamedWithSchema(name, schema, Dataset.class); } } diff --git a/src/main/java/com/apify/client/internal/AbstractCollectionClient.java b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java index e737d12..053a381 100644 --- a/src/main/java/com/apify/client/internal/AbstractCollectionClient.java +++ b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java @@ -2,6 +2,7 @@ import com.apify.client.PaginationList; import java.util.Iterator; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -68,10 +69,7 @@ protected final PaginationList listWithParams(QueryParams params) { * except {@code offset}/{@code limit}, which the iterator drives per page. */ protected final Iterator iterateWithFilters( - Long limit, - Long chunkSize, - Long offset, - java.util.function.Consumer applyFilters) { + Long limit, Long chunkSize, Long offset, Consumer applyFilters) { return ctx.iterateResource("", limit, chunkSize, offset, applyFilters, itemClass); } diff --git a/src/main/java/com/apify/client/internal/CallOptionsLike.java b/src/main/java/com/apify/client/internal/CallOptionsLike.java new file mode 100644 index 0000000..1757c0f --- /dev/null +++ b/src/main/java/com/apify/client/internal/CallOptionsLike.java @@ -0,0 +1,23 @@ +package com.apify.client.internal; + +import com.apify.client.log.StreamedLogOptions; + +/** + * The common shape every {@code call(...)} options type ({@code + * com.apify.client.actor.ActorCallOptions}, {@code com.apify.client.task.TaskCallOptions}) + * implements, so {@link RunStartSupport} can drive {@code call}'s "start, then wait, optionally + * streaming the log" behavior generically across both instead of each resource client duplicating + * it. {@code S} is the paired start-options type ({@code call} options always mirror "start options + * minus {@code waitForFinish}"). + */ +public interface CallOptionsLike { + + /** The equivalent start options (every field the two share). */ + S toStartOptions(); + + /** Whether {@code call} should stream the run's log for the duration of the wait. */ + boolean logStreamingEnabledValue(); + + /** A custom log-streaming destination, or {@code null} to use the default per-run logger. */ + StreamedLogOptions logOptionsValue(); +} diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index 7485bb5..e23841c 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Iterator; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Predicate; @@ -240,6 +241,18 @@ public T getOrCreateNamed(String name, Class dataClass) { return getOrCreateNamed(name, null, dataClass); } + /** + * As {@link #getOrCreateNamed(String, Object, Class)}, wrapping a nullable {@code schema} value + * into the {@code {"schema": ...}} request body shape every storage collection's {@code + * getOrCreate(name, schema)} sends (a {@code null} schema sends no body, same as {@link + * #getOrCreateNamed(String, Class)}). Shared by {@code DatasetCollectionClient}/{@code + * KeyValueStoreCollectionClient} so neither duplicates the wrapping (DRY). + */ + public T getOrCreateNamedWithSchema(String name, Object schema, Class dataClass) { + Object body = schema == null ? null : Map.of("schema", schema); + return getOrCreateNamed(name, body, dataClass); + } + /** * POST that gets-or-creates a named resource, optionally sending a JSON request body (e.g. a * storage {@code schema}). A {@code null} body sends no body, matching the plain get-or-create. diff --git a/src/main/java/com/apify/client/internal/RunStartSupport.java b/src/main/java/com/apify/client/internal/RunStartSupport.java new file mode 100644 index 0000000..037b0dc --- /dev/null +++ b/src/main/java/com/apify/client/internal/RunStartSupport.java @@ -0,0 +1,90 @@ +package com.apify.client.internal; + +import com.apify.client.ApifyClient; +import com.apify.client.log.StreamedLog; +import com.apify.client.run.ActorRun; +import com.apify.client.run.RunClient; + +/** + * Shared implementation of "start a run" and "call: start, then wait, optionally streaming the log" + * for every client that can start a run from a runnable resource (Actor, Task). Both resources' + * start-options and call-options types opt in via {@link StartOptionsLike}/{@link CallOptionsLike}, + * so this single implementation backs {@code ActorClient}/{@code TaskClient} instead of each + * duplicating the same ~90 lines. + */ +public final class RunStartSupport { + + private RunStartSupport() {} + + /** + * Starts a run on {@code ctx}'s {@code runs} sub-resource and returns immediately with the + * created run. {@code input} is any JSON-serializable value, or {@code null} for no input. + */ + public static ActorRun start(ResourceContext ctx, Object input, StartOptionsLike options) { + QueryParams params = new QueryParams(); + options.apply(params); + byte[] body = input == null ? null : Json.toBytes(input); + return ctx.postWithBody("runs", params, body, options.contentTypeOrDefault(), ActorRun.class); + } + + /** + * Starts a run and waits (client-side polling) for it to finish, without log streaming. {@code + * waitSecs} bounds the wait; {@code null} waits indefinitely. + */ + public static ActorRun call( + ApifyClient root, + ResourceContext ctx, + Object input, + StartOptionsLike options, + Long waitSecs) { + ActorRun run = start(ctx, input, options); + return root.run(run.getId()).waitForFinish(waitSecs); + } + + /** + * Starts a run and waits for it to finish, additionally streaming the run's log for the duration + * of the wait by default, matching the reference client's {@code call} defaulting {@code + * options.log} to {@code 'default'}. Log streaming is best-effort: if starting it fails (e.g. the + * log is not yet available), the run still starts and is still waited for, just without + * redirected log output. + */ + public static ActorRun callWithLogStreaming( + ApifyClient root, + ResourceContext ctx, + Object input, + CallOptionsLike options, + Long waitSecs) { + ActorRun run = start(ctx, input, options.toStartOptions()); + RunClient runClient = root.run(run.getId()); + + StreamedLog streamedLog = null; + if (options.logStreamingEnabledValue()) { + streamedLog = startStreamedLogQuietly(runClient, options); + } + try { + return runClient.waitForFinish(waitSecs); + } finally { + if (streamedLog != null) { + streamedLog.close(); + } + } + } + + /** + * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure + * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. + */ + private static StreamedLog startStreamedLogQuietly( + RunClient runClient, CallOptionsLike options) { + try { + StreamedLog streamedLog = + options.logOptionsValue() != null + ? runClient.getStreamedLog(options.logOptionsValue()) + : runClient.getStreamedLog(); + streamedLog.start(); + return streamedLog; + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/src/main/java/com/apify/client/internal/StartOptionsLike.java b/src/main/java/com/apify/client/internal/StartOptionsLike.java new file mode 100644 index 0000000..0197141 --- /dev/null +++ b/src/main/java/com/apify/client/internal/StartOptionsLike.java @@ -0,0 +1,16 @@ +package com.apify.client.internal; + +/** + * The common shape every "start a run" options type ({@code + * com.apify.client.actor.ActorStartOptions}, {@code com.apify.client.task.TaskStartOptions}) + * implements, so {@link RunStartSupport} can build the start request generically across both + * instead of each resource client duplicating the same {@code start}/{@code call} logic. + */ +public interface StartOptionsLike { + + /** Applies every query parameter these options configure (build, memory, timeout, ...). */ + void apply(QueryParams q); + + /** The content type of the input body to send, defaulting to JSON when unset. */ + String contentTypeOrDefault(); +} diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java index 8e821d0..7c421ac 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreCollectionClient.java @@ -31,7 +31,6 @@ public KeyValueStore getOrCreate(String name) { * {@code null} schema behaves like {@link #getOrCreate(String)}. */ public KeyValueStore getOrCreate(String name, Object schema) { - Object body = schema == null ? null : java.util.Collections.singletonMap("schema", schema); - return ctx.getOrCreateNamed(name, body, KeyValueStore.class); + return ctx.getOrCreateNamedWithSchema(name, schema, KeyValueStore.class); } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index d52ee7b..2325eb9 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -51,15 +51,6 @@ public final class RequestQueueClient { */ private static final int MAX_BACKOFF_EXPONENT = 10; - /** Lowest client-error status; statuses in {@code [400, 500)} are client errors. */ - private static final int MIN_CLIENT_ERROR_STATUS = 400; - - /** Lowest server-error status; statuses at or above this are server errors. */ - private static final int MIN_SERVER_ERROR_STATUS = 500; - - /** Rate-limit status; retryable, so it is not treated as a hard client error. */ - private static final int RATE_LIMIT_STATUS = 429; - private final HttpClientCore http; private final ResourceContext ctx; private final String clientKey; @@ -214,14 +205,12 @@ public BatchAddResult batchAddRequests(List requests, boole * BatchAddRequestsOptions#maxUnprocessedRequestsRetries} times. The per-chunk results are merged * into a single {@link BatchAddResult}. * - *

    Requests that remain unprocessed after all retries (typically due to persistent - * rate-limiting or server errors) are returned in {@link BatchAddResult#getUnprocessedRequests()} - * rather than raising an exception. A non-retryable client error (a 4xx other than 429, such as - * an invalid token or insufficient permissions) is instead thrown as an {@link - * ApifyApiException}, since it will not succeed on retry and should not be silently hidden as - * "unprocessed". A single request whose own JSON encoding already exceeds the payload-size limit - * is rejected up front with {@link IllegalArgumentException}, since no chunk size could ever fit - * it. + *

    This method never throws for an API error, matching the reference client's contract: any + * request that could not be confirmed processed — whether due to persistent rate-limiting, server + * errors, or a non-retryable client error (e.g. an invalid token or insufficient permissions) — + * is returned in {@link BatchAddResult#getUnprocessedRequests()} instead. A single request whose + * own JSON encoding already exceeds the payload-size limit is rejected up front with {@link + * IllegalArgumentException}, since no chunk size could ever fit it. */ public BatchAddResult batchAddRequests( List requests, boolean forefront, BatchAddRequestsOptions options) { @@ -332,8 +321,10 @@ private static BatchAddResult awaitResult(Future future) { /** * Adds one chunk (already sized to the API limit), retrying requests the API leaves unprocessed - * with exponential backoff. Never throws for an API error: on a non-retryable failure the - * remaining requests are returned as unprocessed, matching the reference client's contract. + * with exponential backoff. Never throws for an API error, matching the reference client's {@code + * _batchAddRequestsWithRetries}: on any failure (including a non-retryable 4xx) the remaining + * requests in the chunk are simply returned as unprocessed rather than surfaced as an exception, + * so the method keeps a single, uniform never-throws contract regardless of the failure cause. */ private BatchAddResult batchAddChunkWithRetries( List chunk, boolean forefront, BatchAddRequestsOptions options) { @@ -352,14 +343,9 @@ private BatchAddResult batchAddChunkWithRetries( break; } } catch (ApifyApiException e) { - // A non-retryable client error (bad token, insufficient permissions, invalid request) is a - // hard failure, not a transient one — surface it rather than hiding it as "unprocessed", - // where a caller could not tell it apart from ordinary rate-limiting. Rate-limit (429) and - // server (5xx) errors have already exhausted the transport's retries by this point, so for - // those we keep the non-throwing contract and report the remainder as unprocessed. - if (isNonRetryableClientError(e)) { - throw e; - } + // Any API error (rate-limit, server error, or a hard client error such as a bad token) + // stops retrying this chunk immediately; whatever has not been confirmed processed is + // reported as unprocessed below, never thrown — matching the reference client's contract. break; } if (attempt < maxRetries) { @@ -375,18 +361,6 @@ private BatchAddResult batchAddChunkWithRetries( return result; } - /** - * Reports whether an API error is a non-retryable client error (a 4xx other than 429). Such - * errors (e.g. bad token, insufficient permissions, invalid request) will not succeed on retry, - * so the batch helper surfaces them instead of masking them as unprocessed requests. - */ - private static boolean isNonRetryableClientError(ApifyApiException e) { - int status = e.getStatusCode(); - return status >= MIN_CLIENT_ERROR_STATUS - && status < MIN_SERVER_ERROR_STATUS - && status != RATE_LIMIT_STATUS; - } - private static List requestsNotYetProcessed( List chunk, List processed) { Set processedKeys = new HashSet<>(); @@ -580,6 +554,12 @@ private void fetchPage() { buffer = page.getItems(); pos = 0; nextCursor = page.getNextCursor(); + // Defensively trim to the cap in case the server returned more than requested, matching + // PaginatedIterator's same guard, so a caller never sees more than `totalLimit` items even + // if the server ignores (or overshoots) the requested per-page `limit`. + if (capRemaining != null && buffer.size() > capRemaining) { + buffer = buffer.subList(0, capRemaining.intValue()); + } if (buffer.isEmpty() && (nextCursor == null || nextCursor.isEmpty())) { exhausted = true; } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index 24bb0d1..12f7c7b 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -40,6 +40,7 @@ public String getId() { return id; } + /** Sets the unique request ID. */ public RequestQueueRequest setId(String id) { this.id = id; return this; @@ -50,6 +51,7 @@ public String getUrl() { return url; } + /** Sets the request URL. */ public RequestQueueRequest setUrl(String url) { this.url = url; return this; @@ -60,6 +62,7 @@ public String getUniqueKey() { return uniqueKey; } + /** Sets the deduplication key for the request. */ public RequestQueueRequest setUniqueKey(String uniqueKey) { this.uniqueKey = uniqueKey; return this; @@ -70,18 +73,25 @@ public String getMethod() { return method; } + /** Sets the HTTP method. */ public RequestQueueRequest setMethod(String method) { this.method = method; return this; } - /** Arbitrary user-attached metadata. */ + /** + * Arbitrary user-attached metadata ({@code null} if unset). Returns a defensive deep copy, so + * mutating the returned {@link JsonNode} (if it is a container type such as {@code ObjectNode}) + * cannot affect this request's own state, matching {@link #getHeaders()}/{@link + * #getErrorMessages()}. + */ public JsonNode getUserData() { - return userData; + return userData == null ? null : userData.deepCopy(); } + /** Sets the user-attached metadata (defensively deep-copied; see {@link #getUserData()}). */ public RequestQueueRequest setUserData(JsonNode userData) { - this.userData = userData; + this.userData = userData == null ? null : userData.deepCopy(); return this; } @@ -90,6 +100,7 @@ public String getPayload() { return payload; } + /** Sets the HTTP request body. */ public RequestQueueRequest setPayload(String payload) { this.payload = payload; return this; @@ -100,6 +111,7 @@ public Map getHeaders() { return headers == null ? null : Collections.unmodifiableMap(headers); } + /** Sets the custom HTTP headers to send with the request (defensively copied). */ public RequestQueueRequest setHeaders(Map headers) { this.headers = headers == null ? null : new LinkedHashMap<>(headers); return this; @@ -110,6 +122,7 @@ public Boolean getNoRetry() { return noRetry; } + /** Sets whether the request is retried on failure. */ public RequestQueueRequest setNoRetry(Boolean noRetry) { this.noRetry = noRetry; return this; @@ -120,6 +133,7 @@ public Instant getHandledAt() { return handledAt; } + /** Sets when the request was marked as handled. */ public RequestQueueRequest setHandledAt(Instant handledAt) { this.handledAt = handledAt; return this; @@ -130,6 +144,7 @@ public Integer getRetryCount() { return retryCount; } + /** Sets the number of times processing this request has already been retried. */ public RequestQueueRequest setRetryCount(Integer retryCount) { this.retryCount = retryCount; return this; @@ -140,6 +155,7 @@ public String getLoadedUrl() { return loadedUrl; } + /** Sets the URL actually loaded, after following redirects. */ public RequestQueueRequest setLoadedUrl(String loadedUrl) { this.loadedUrl = loadedUrl; return this; @@ -153,6 +169,7 @@ public List getErrorMessages() { return errorMessages == null ? null : Collections.unmodifiableList(errorMessages); } + /** Sets the error messages recorded from previous failed processing attempts. */ public RequestQueueRequest setErrorMessages(List errorMessages) { this.errorMessages = errorMessages == null ? null : List.copyOf(errorMessages); return this; diff --git a/src/main/java/com/apify/client/run/ActorRun.java b/src/main/java/com/apify/client/run/ActorRun.java index a01322a..a1dc609 100644 --- a/src/main/java/com/apify/client/run/ActorRun.java +++ b/src/main/java/com/apify/client/run/ActorRun.java @@ -27,9 +27,15 @@ public final class ActorRun extends ApifyResource { private JsonNode pricingInfo; private ActorRunUsage usage; private ActorRunUsage usageUsd; + private Double usageTotalUsd; private ActorRunStats stats; private ActorRunOptions options; private ActorRunMeta meta; + private String buildNumber; + private Integer exitCode; + private Boolean isContainerServerReady; + private String gitBranchName; + private JsonNode storageIds; /** The unique run ID. */ public String getId() { @@ -134,6 +140,15 @@ public ActorRunUsage getUsageUsd() { return usageUsd; } + /** + * Total cost in USD for this run (what the caller actually pays): platform usage and/or event + * costs, depending on the Actor's pricing model. For a run the caller does not own, only + * available (non-{@code null}) for pay-per-event Actors, and only covers event costs. + */ + public Double getUsageTotalUsd() { + return usageTotalUsd; + } + /** Runtime resource-consumption and performance statistics for the run. */ public ActorRunStats getStats() { return stats; @@ -149,6 +164,37 @@ public ActorRunMeta getMeta() { return meta; } + /** The build number (semver-like, e.g. {@code "0.0.36"}) of the build used for this run. */ + public String getBuildNumber() { + return buildNumber; + } + + /** The exit code of the run's process, once it has finished; {@code null} while running. */ + public Integer getExitCode() { + return exitCode; + } + + /** Whether the run container's HTTP server is ready to accept requests. */ + public Boolean isContainerServerReady() { + return isContainerServerReady; + } + + /** The name of the git branch the Actor build was built from, if applicable. */ + public String getGitBranchName() { + return gitBranchName; + } + + /** + * A map of aliased storage IDs associated with this run, grouped by storage type ({@code + * datasets}/{@code keyValueStores}/{@code requestQueues}), each with at least a {@code "default"} + * entry matching {@link #getDefaultDatasetId()}/{@link #getDefaultKeyValueStoreId()}/{@link + * #getDefaultRequestQueueId()}. Returned as raw JSON since the set of aliases beyond {@code + * "default"} is open-ended. + */ + public JsonNode getStorageIds() { + return storageIds; + } + /** Whether the run has reached a terminal (finished) status. */ public boolean isTerminal() { return Statuses.isTerminal(status); diff --git a/src/main/java/com/apify/client/task/TaskCallOptions.java b/src/main/java/com/apify/client/task/TaskCallOptions.java index 928627e..14ab48a 100644 --- a/src/main/java/com/apify/client/task/TaskCallOptions.java +++ b/src/main/java/com/apify/client/task/TaskCallOptions.java @@ -1,5 +1,6 @@ package com.apify.client.task; +import com.apify.client.internal.CallOptionsLike; import com.apify.client.log.StreamedLogOptions; import java.util.List; @@ -18,7 +19,7 @@ * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom * destination. */ -public final class TaskCallOptions { +public final class TaskCallOptions implements CallOptionsLike { private final TaskStartOptions startOptions = new TaskStartOptions(); private boolean logStreamingEnabled = true; @@ -86,15 +87,18 @@ public TaskCallOptions logOptions(StreamedLogOptions logOptions) { return this; } - TaskStartOptions toStartOptions() { + @Override + public TaskStartOptions toStartOptions() { return startOptions; } - boolean logStreamingEnabledValue() { + @Override + public boolean logStreamingEnabledValue() { return logStreamingEnabled; } - StreamedLogOptions logOptionsValue() { + @Override + public StreamedLogOptions logOptionsValue() { return logOptions; } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 1e963a9..29c61be 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -7,7 +7,7 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import com.apify.client.log.StreamedLog; +import com.apify.client.internal.RunStartSupport; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -53,11 +53,7 @@ public void delete() { * overrides the task's stored input ({@code null} to use the stored input). */ public ActorRun start(Object input, TaskStartOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - byte[] body = input == null ? null : Json.toBytes(input); - return ctx.postWithBody( - "runs", params, body, ResourceContext.CONTENT_TYPE_JSON, ActorRun.class); + return RunStartSupport.start(ctx, input, options); } /** @@ -68,8 +64,7 @@ public ActorRun start(Object input, TaskStartOptions options) { * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { - ActorRun run = start(input, options); - return root.run(run.getId()).waitForFinish(waitSecs); + return RunStartSupport.call(root, ctx, input, options, waitSecs); } /** @@ -85,37 +80,7 @@ public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { */ public ActorRun call(Object input, TaskCallOptions options, Long waitSecs) { TaskCallOptions opts = options != null ? options : new TaskCallOptions(); - ActorRun run = start(input, opts.toStartOptions()); - RunClient runClient = root.run(run.getId()); - - StreamedLog streamedLog = null; - if (opts.logStreamingEnabledValue()) { - streamedLog = startStreamedLogQuietly(runClient, opts); - } - try { - return runClient.waitForFinish(waitSecs); - } finally { - if (streamedLog != null) { - streamedLog.close(); - } - } - } - - /** - * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure - * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. - */ - private static StreamedLog startStreamedLogQuietly(RunClient runClient, TaskCallOptions opts) { - try { - StreamedLog streamedLog = - opts.logOptionsValue() != null - ? runClient.getStreamedLog(opts.logOptionsValue()) - : runClient.getStreamedLog(); - streamedLog.start(); - return streamedLog; - } catch (RuntimeException e) { - return null; - } + return RunStartSupport.callWithLogStreaming(root, ctx, input, opts, waitSecs); } /** Fetches the task's stored input, or empty if none is set. */ diff --git a/src/main/java/com/apify/client/task/TaskStartOptions.java b/src/main/java/com/apify/client/task/TaskStartOptions.java index 665f7e6..48b2583 100644 --- a/src/main/java/com/apify/client/task/TaskStartOptions.java +++ b/src/main/java/com/apify/client/task/TaskStartOptions.java @@ -2,6 +2,8 @@ import com.apify.client.actor.ActorStartOptions; import com.apify.client.internal.QueryParams; +import com.apify.client.internal.ResourceContext; +import com.apify.client.internal.StartOptionsLike; import java.util.List; /** @@ -11,7 +13,7 @@ * accept (the Actor-only {@code contentType} and {@code forcePermissionLevel}), matching the * reference client. */ -public final class TaskStartOptions { +public final class TaskStartOptions implements StartOptionsLike { private String build; private Long memoryMbytes; private Long timeoutSecs; @@ -69,7 +71,8 @@ public TaskStartOptions webhooks(List webhooks) { return this; } - void apply(QueryParams q) { + @Override + public void apply(QueryParams q) { q.addString("build", build) .addLong("memory", memoryMbytes) .addLong("timeout", timeoutSecs) @@ -79,4 +82,14 @@ void apply(QueryParams q) { .addBool("restartOnError", restartOnError); q.addString("webhooks", ActorStartOptions.encodeWebhooks(webhooks)); } + + /** + * The task run endpoint does not accept a content-type override (unlike the Actor run endpoint), + * so this always returns the JSON default; present only to satisfy {@link StartOptionsLike} so + * {@code start}/{@code call} can be driven generically for both Actor and task runs. + */ + @Override + public String contentTypeOrDefault() { + return ResourceContext.CONTENT_TYPE_JSON; + } } diff --git a/src/main/java/com/apify/client/webhook/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java index 3ca1f3a..ec9993f 100644 --- a/src/main/java/com/apify/client/webhook/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -13,6 +13,7 @@ public final class Webhook extends ApifyResource { private Instant createdAt; private Instant modifiedAt; private boolean isAdHoc; + private Boolean shouldInterpolateStrings; private List eventTypes = List.of(); private JsonNode condition; private boolean ignoreSslErrors; @@ -21,6 +22,7 @@ public final class Webhook extends ApifyResource { private String payloadTemplate; private String headersTemplate; private String description; + private WebhookLastDispatch lastDispatch; private WebhookStats stats; /** The unique webhook ID. */ @@ -51,6 +53,15 @@ public boolean isAdHoc() { return isAdHoc; } + /** + * Whether {@code {{...}}} placeholders in {@link #getPayloadTemplate()}/{@link + * #getHeadersTemplate()} are interpolated with values from the triggering event before the + * webhook is dispatched. + */ + public Boolean isShouldInterpolateStrings() { + return shouldInterpolateStrings; + } + /** The events that trigger the webhook. */ public List getEventTypes() { return Collections.unmodifiableList(eventTypes); @@ -94,6 +105,11 @@ public String getDescription() { return description; } + /** A summary of this webhook's most recent dispatch, if it has ever fired. */ + public WebhookLastDispatch getLastDispatch() { + return lastDispatch; + } + /** Usage statistics for this webhook. */ public WebhookStats getStats() { return stats; diff --git a/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java b/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java new file mode 100644 index 0000000..f40d829 --- /dev/null +++ b/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java @@ -0,0 +1,30 @@ +package com.apify.client.webhook; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; + +/** A summary of the most recent dispatch of a {@link Webhook}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class WebhookLastDispatch { + private String status; + private Instant finishedAt; + private Instant removedAt; + + /** + * The dispatch's status: one of {@code "ACTIVE"} (still retrying), {@code "SUCCEEDED"}, or {@code + * "FAILED"}. + */ + public String getStatus() { + return status; + } + + /** When the dispatch reached a terminal state; {@code null} while still {@code "ACTIVE"}. */ + public Instant getFinishedAt() { + return finishedAt; + } + + /** When the dispatch record was removed, if applicable. */ + public Instant getRemovedAt() { + return removedAt; + } +} diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index f01966b..819da52 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -114,6 +114,31 @@ void chargeRejectsMissingEventName() { assertEquals(0, backend.calls, "no request should be sent for an invalid charge"); } + @Test + void metamorphSendsTargetActorIdBuildAndInputBody() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); + client(backend) + .run("run123") + .metamorph( + "apify/other-actor", + java.util.Map.of("foo", "bar"), + new com.apify.client.run.MetamorphOptions().build("1.2.3")); + assertTrue(backend.lastUrl.contains("actor-runs/run123/metamorph"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("targetActorId=apify%2Fother-actor"), backend.lastUrl); + assertTrue(backend.lastUrl.contains("build=1.2.3"), backend.lastUrl); + assertTrue(backend.lastBody.contains("\"foo\":\"bar\""), backend.lastBody); + assertEquals("POST", backend.lastMethod); + } + + @Test + void rebootSendsPostToRebootWithNoBody() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); + ActorRun run = client(backend).run("run123").reboot(); + assertEquals("run123", run.getId()); + assertTrue(backend.lastUrl.contains("actor-runs/run123/reboot"), backend.lastUrl); + assertEquals("POST", backend.lastMethod); + } + @Test void getRecordDefaultsAttachment() { MockTransport backend = MockTransport.ofConstant(200, "raw-bytes"); @@ -238,6 +263,30 @@ void batchAddRequestsThrowsWhenSingleRequestExceedsPayloadLimit() { () -> client(backend).requestQueue("q1").batchAddRequests(requests, false)); } + @Test + void paginateRequestsTrimsPageToTotalLimitWhenServerOvershoots() { + // If the server ignores (or overshoots) the requested per-page `limit` and returns more items + // than the caller's totalLimit cap allows, the iterator must still yield exactly totalLimit + // items, mirroring PaginatedIterator's own defensive trim. + MockTransport backend = + MockTransport.ofConstant( + 200, + "{\"data\":{\"items\":[" + + "{\"id\":\"1\",\"url\":\"https://example.com/1\",\"uniqueKey\":\"k1\"}," + + "{\"id\":\"2\",\"url\":\"https://example.com/2\",\"uniqueKey\":\"k2\"}," + + "{\"id\":\"3\",\"url\":\"https://example.com/3\",\"uniqueKey\":\"k3\"}," + + "{\"id\":\"4\",\"url\":\"https://example.com/4\",\"uniqueKey\":\"k4\"}," + + "{\"id\":\"5\",\"url\":\"https://example.com/5\",\"uniqueKey\":\"k5\"}" + + "],\"limit\":3,\"nextCursor\":null}}"); + java.util.Iterator it = + client(backend).requestQueue("q1").paginateRequests(3L, null, null); + List ids = new ArrayList<>(); + while (it.hasNext()) { + ids.add(it.next().getId()); + } + assertEquals(List.of("1", "2", "3"), ids); + } + @Test void batchAddRequestsRetriesUnprocessed() { MockTransport backend = @@ -313,6 +362,18 @@ void waitForFinishClampsServerWaitToConfiguredTimeout() { assertTrue(backend.lastUrl.contains("waitForFinish=15"), backend.lastUrl); } + @Test + void builderRejectsZeroOrNegativeTimeout() { + // Unlike the delay params (where zero legitimately means "no delay"), a zero/negative timeout + // must be rejected at build time: it would otherwise build a client whose first request fails + // deep inside the transport (HttpRequest.Builder#timeout rejects a non-positive duration). + assertThrows( + IllegalArgumentException.class, () -> ApifyClient.builder().timeout(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> ApifyClient.builder().timeout(Duration.ofSeconds(-1))); + } + @Test void getResourceReturnsEmptyOnNullData() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":null}"); @@ -436,23 +497,24 @@ void updateLimitsSendsPutToMeLimits() { } @Test - void batchAddRequestsThrowsOnNonRetryableClientError() { - // A hard 4xx (e.g. bad token / insufficient permissions) must surface, not be masked as - // "unprocessed" — otherwise a caller cannot tell it apart from ordinary rate-limiting. + void batchAddRequestsNeverThrowsOnNonRetryableClientError() { + // Matches the reference client's `_batchAddRequestsWithRetries`: even a hard 4xx (e.g. bad + // token / insufficient permissions) must NOT be thrown. It is reported as unprocessed instead, + // keeping batchAddRequests' never-throws contract regardless of the failure cause. MockTransport backend = MockTransport.ofConstant( 403, "{\"error\":{\"type\":\"insufficient-permissions\",\"message\":\"no\"}}"); - ApifyApiException ex = - assertThrows( - ApifyApiException.class, - () -> - client(backend) - .requestQueue("q1") - .batchAddRequests( - List.of(new RequestQueueRequest("https://example.com", "k0")), - false, - new BatchAddRequestsOptions().maxUnprocessedRequestsRetries(0))); - assertEquals(403, ex.getStatusCode()); + RequestQueueRequest request = new RequestQueueRequest("https://example.com", "k0"); + BatchAddResult result = + client(backend) + .requestQueue("q1") + .batchAddRequests( + List.of(request), + false, + new BatchAddRequestsOptions().maxUnprocessedRequestsRetries(0)); + assertEquals(0, result.getProcessedRequests().size()); + assertEquals(1, result.getUnprocessedRequests().size()); + assertEquals("k0", result.getUnprocessedRequests().get(0).getUniqueKey()); } @Test diff --git a/src/test/java/com/apify/client/MockTransport.java b/src/test/java/com/apify/client/MockTransport.java index ef3dd93..974c5fd 100644 --- a/src/test/java/com/apify/client/MockTransport.java +++ b/src/test/java/com/apify/client/MockTransport.java @@ -44,6 +44,7 @@ static final class Scripted { int calls = 0; HttpHeaders lastHeaders; String lastUrl; + String lastMethod; String lastBody; byte[] lastBodyBytes; final List bodies = new ArrayList<>(); @@ -88,6 +89,7 @@ public synchronized HttpResponse send(HttpRequest request) throws IOExce int idx = calls++; lastHeaders = request.headers(); lastUrl = request.uri().toString(); + lastMethod = request.method(); lastBodyBytes = readBody(request); lastBody = lastBodyBytes == null ? null : new String(lastBodyBytes, StandardCharsets.UTF_8); if (lastBody != null) { @@ -125,6 +127,7 @@ void scriptStream(int status, InputStream body) { public synchronized HttpResponse sendStreamingResponse(HttpRequest request) { calls++; lastUrl = request.uri().toString(); + lastMethod = request.method(); if (streamBody != null) { return new FakeStreamResponse(request.uri(), streamBodyStatus, streamBody); } diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index fd25e5f..a471ca1 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -30,7 +30,10 @@ class ActorRunIntegrationTest extends IntegrationBase { // suite (hello-world) finishes in ~1-2s, so a live reboot test would be either flaky-by- // construction or need a bespoke slow-running test Actor this suite doesn't otherwise need. // `charge` only applies to pay-per-event Actors, which none of this suite's fixtures are. All - // three remain covered offline by ClientBehaviourRegressionTest. + // three are covered offline instead, against a mock backend, by + // ClientBehaviourRegressionTest#metamorphSendsTargetActorIdBuildAndInputBody, + // #rebootSendsPostToRebootWithNoBody, and #chargeSendsIdempotencyKey (+ its sibling charge* + // tests) respectively. @Test void listRuns() { From 344736b09a467c4fe7e687c560d528464b2b8c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 07:23:31 +0000 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20loop-5=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20package-private=20call/start=20options,=20docs=20co?= =?UTF-8?q?ntradictions,=20uniqueKey=20caveat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop-4 review's residual MAJORs and nits: - N1: RunStartSupport now takes Consumer/plain values instead of the public CallOptionsLike/StartOptionsLike marker interfaces, so ActorClient/TaskClient can keep apply()/contentTypeOrDefault()/toStartOptions()/logStreamingEnabledValue()/logOptionsValue() package-private on ActorStartOptions/TaskStartOptions/ActorCallOptions/TaskCallOptions instead of leaking them (and the internal QueryParams type) into the options builders' public API. - N2: reconciled the examples.md contradiction across README.md/docs/README.md/docs/examples.md — all three now agree that the fragments are fragments and the runnable programs live under src/test/java/com/apify/client/examples/. - N3: added the missing WebhookLastDispatch import to docs/README.md's package table. - N4: added an offline mock-backend test for ApifyClient.setStatusMessage's ACTOR_RUN_ID-unset IllegalStateException path (previously zero coverage); documented why the ACTOR_RUN_ID-set path isn't offline- or live-testable, mirroring the metamorph/reboot/charge convention. - N5: added a javadoc caveat on batchAddRequests/RequestQueueRequest.getUniqueKey() about uniqueKey-based retry reconciliation falsely reporting a succeeded request as unprocessed. - Nits: isShouldInterpolateStrings() -> getShouldInterpolateStrings() (field name unchanged, so no wire-format effect); named the unused RequestQueueClient catch binding `ignored`; fixed the "one raw-JSON field" framing in docs/README.md; clarified the request-queue getOrCreate overload gap in docs/storages.md; documented SetStatusMessageOptions's primitive boolean; trimmed boilerplate javadoc on trivial RequestQueueRequest setters; documented DefaultHttpTransport(Duration) and evened out import disclosure in README fragments; strengthened the nested-webhook test comment to point at the real multi-item iteration coverage. mvn spotless:check/compile/spotbugs:check/test all green: 183/183 tests passing (unit + live integration + ExamplesTest + DocSnippetsTest), 0 failures, 0 errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- CHANGELOG.md | 30 +++++++++-- README.md | 18 ++++++- docs/README.md | 20 +++++-- docs/storages.md | 3 +- docs/webhooks.md | 2 +- .../apify/client/actor/ActorCallOptions.java | 17 +++--- .../com/apify/client/actor/ActorClient.java | 16 ++++-- .../apify/client/actor/ActorStartOptions.java | 14 ++--- .../client/internal/CallOptionsLike.java | 23 -------- .../client/internal/RunStartSupport.java | 54 ++++++++++++------- .../client/internal/StartOptionsLike.java | 16 ------ .../requestqueue/RequestQueueClient.java | 9 +++- .../requestqueue/RequestQueueRequest.java | 13 +++-- .../apify/client/task/TaskCallOptions.java | 17 +++--- .../com/apify/client/task/TaskClient.java | 16 ++++-- .../apify/client/task/TaskStartOptions.java | 18 ++++--- .../com/apify/client/webhook/Webhook.java | 2 +- .../client/ClientBehaviourRegressionTest.java | 22 ++++++++ .../integration/ActorIntegrationTest.java | 6 ++- 19 files changed, 201 insertions(+), 115 deletions(-) delete mode 100644 src/main/java/com/apify/client/internal/CallOptionsLike.java delete mode 100644 src/main/java/com/apify/client/internal/StartOptionsLike.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d621dfa..67e015e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,9 +50,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `KeyValueStoreCollectionClient`, `RequestQueueCollectionClient`, `ActorCollectionClient`, `ScheduleCollectionClient`, `TaskCollectionClient`, `BuildCollectionClient` and `RunCollectionClient`; behavior is unchanged. -- Extracted shared `StartOptionsLike`/`CallOptionsLike` interfaces (internal) and a - `RunStartSupport` helper backing `ActorClient`/`TaskClient`'s `start`/`call`, removing the - duplicated implementation between the two; behavior is unchanged. +- Extracted a shared `RunStartSupport` helper (internal) backing `ActorClient`/`TaskClient`'s + `start`/`call`, removing the duplicated implementation between the two. `ActorClient`/ + `TaskClient` pass their options' behavior in as plain values and method references (e.g. + `options::apply`), so `ActorStartOptions`/`TaskStartOptions`/`ActorCallOptions`/`TaskCallOptions` + stay package-private with no public marker interface; behavior is unchanged. - Tightened `spotbugs-exclude.xml` to only the entries that currently reproduce a real finding. - `java-integration-tests.yml` now declares an explicit, least-privilege `permissions: contents: read` block. @@ -102,6 +104,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). can no longer corrupt the request's stored state. - `RequestQueueClient.paginateRequests(...)` no longer yields more than the requested `totalLimit` when a single page overshoots it, matching `PaginatedIterator`'s equivalent guard. +- `Webhook.isShouldInterpolateStrings()` renamed to `getShouldInterpolateStrings()`, following the + JavaBeans convention for a boxed `Boolean` accessor (`is`-prefixed getters are for primitive + `boolean`). The underlying field name, `shouldInterpolateStrings`, is unchanged, so this has no + effect on the JSON wire format. ### Documentation @@ -113,6 +119,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). contradiction; documented `ApifyApiException`'s accessor return types and the `HttpTimeoutException` naming collision with `java.net.http.HttpTimeoutException`; added `setStatusMessage` to the README resources table; noted `getExtra()` on every thin response model. +- Fixed a contradiction in `README.md`/`docs/README.md`: both described `docs/examples.md` as + "complete, runnable programs," but its own snippets are fragments in the same style as the + resource pages — the complete, runnable programs live under + `src/test/java/com/apify/client/examples/`. Reworded both to match. + Added the missing `WebhookLastDispatch` import to the `com.apify.client.webhook` package-table row. + Corrected the "one raw-JSON field" framing in `docs/README.md`'s "Raw JSON values" section: + `ActorRun` actually carries two (`getPricingInfo()`, `getStorageIds()`), and `Task.getInput()` (the + model field) is a third, distinct from the live-fetching `task(id).getInput()` client call. + Clarified in `docs/storages.md` that request queues, unlike datasets/key-value stores, have no + `getOrCreate(String, Object schema)` overload. Noted that + `SetStatusMessageOptions.isStatusMessageTerminal(boolean)` intentionally takes a primitive + `boolean`, not boxed `Boolean` like this client's other optional option fields. Documented the + `DefaultHttpTransport(Duration)` constructor in the README's transport section, and disclosed + `Duration`'s package (`java.time.Duration`) next to the Configuration snippet for consistency with + how other fragments disclose the types they use. Added a javadoc caveat on + `RequestQueueClient.batchAddRequests`/`RequestQueueRequest.getUniqueKey()`: retry reconciliation + matches by `uniqueKey`, so a request that omits it can be falsely reported unprocessed after + retries even though it succeeded server-side. ## [0.3.1] - 2026-07-14 diff --git a/README.md b/README.md index eeb017b..c4c2325 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,10 @@ System.out.println("Items in this page: " + items.getCount()); The types used above — `PaginationList` (root package), `DatasetListItemsOptions` (`com.apify.client.dataset`), and the per-resource clients — are documented on the [resource pages](docs/README.md); `ApifyApiException` (`com.apify.client.http`) is covered under -[Error handling](#error-handling) below. [`docs/examples.md`](docs/examples.md) has complete, -runnable programs (build-and-run, storages, log redirection, and more). +[Error handling](#error-handling) below. [`docs/examples.md`](docs/examples.md) has more fragments +in the same style (build-and-run, storages, log redirection, and more); the complete, runnable +programs 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). ## Configuration @@ -100,6 +102,8 @@ ApifyClient configured = .build(); ``` +`Duration` above is `java.time.Duration`. + ### Replaceable HTTP transport The transport is a replaceable component. The default is `DefaultHttpTransport` (backed by the JDK's @@ -111,6 +115,16 @@ HttpTransport transport = new DefaultHttpTransport(java.net.http.HttpClient.newH ApifyClient withTransport = ApifyClient.builder().token("t").httpTransport(transport).build(); ``` +`DefaultHttpTransport` also has a `DefaultHttpTransport(Duration connectTimeout)` constructor that +builds its own JDK `HttpClient` with a custom connection-establishment timeout (default 10s, +`DefaultHttpTransport.DEFAULT_CONNECT_TIMEOUT`) without requiring you to construct the `HttpClient` +yourself — use it when you only want to change the connect timeout and don't otherwise need to +customize the JDK client: + +```java +HttpTransport transport = new DefaultHttpTransport(Duration.ofSeconds(5)); +``` + Cross-cutting behaviour applied to every request lives in the client, not the transport implementation: bearer-token authentication, the mandated `User-Agent` header, and retries with exponential backoff and jitter on `429`, `5xx` and network errors. diff --git a/docs/README.md b/docs/README.md index df23451..a59ab14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,8 @@ This directory documents the public API of the Apify Java client, organized by resource. Each page lists the available methods with their parameters and short 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 +programs; [examples.md](examples.md) has more fragments in the same style, and the complete, +runnable programs 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). For an overview, configuration, error handling and the full resource table, see the [top-level README](../README.md). @@ -44,7 +45,7 @@ option types, so import each resource you use from its own package: | `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | | `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions`, `ValidateInputOptions` | | `com.apify.client.schedule` | `Schedule`, `ScheduleNotifications`, `ScheduleClient`, `ScheduleCollectionClient` | -| `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient` | +| `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookLastDispatch`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient` | | `com.apify.client.user` | `User`, `UserClient` | | `com.apify.client.store` | `ActorStoreListItem`, `StoreCollectionClient`, `StoreListOptions` | | `com.apify.client.log` | `LogClient`, `LogOptions`, `StreamedLog`, `StreamedLogOptions` | @@ -74,9 +75,12 @@ Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): - Write: `task(id).updateInput(...)` (itself returning a required `JsonNode`, the updated input) and `me().updateLimits(...)` accept an arbitrary JSON-serializable value, as do definition/`update`/`create` arguments generally — a `Map`, a `JsonNode`, or your own POJO. -- A few typed models still carry one raw-JSON field where the shape is a discriminated union not - worth fully modelling: `RequestQueueRequest.getUserData()`, `Webhook.getCondition()`, - `ActorRun.getPricingInfo()`, `Schedule.getActions()` (a `List`). +- A few typed models still carry one or more raw-JSON fields where the shape is a discriminated + union (or otherwise not worth fully modelling): `RequestQueueRequest.getUserData()`, + `Webhook.getCondition()`, `Schedule.getActions()` (a `List`), `Task.getInput()` (the + task's stored input, on the `Task` model itself — distinct from the live-fetching + `task(id).getInput()` client call listed above), and `ActorRun`, which carries two: + `getPricingInfo()` and `getStorageIds()`. Navigate a `JsonNode` with `node.get("field")`, `node.path("a").asText()`, etc. @@ -114,6 +118,12 @@ Object plan = me.getExtra().get("plan"); `IllegalStateException` otherwise. `SetStatusMessageOptions.isStatusMessageTerminal(boolean)` marks the message as final so it won't be overwritten. Returns the updated `ActorRun`. +Note: `isStatusMessageTerminal(boolean)` takes a primitive `boolean`, not a boxed `Boolean` — an +intentional exception to the "optional fields are boxed so they can stay unset" convention used +elsewhere in this client's options classes, since this field always has a concrete on/off meaning +with no useful "unset" state (the field is simply omitted from the request body when the setter is +never called). + ```java client.setStatusMessage("half way there", new SetStatusMessageOptions().isStatusMessageTerminal(false)); ``` diff --git a/docs/storages.md b/docs/storages.md index 17cccbd..b343029 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -143,7 +143,8 @@ expiry-aware storage-content signature — hence only `createKeysPublicUrl` take ### `RequestQueueCollectionClient` — `client.requestQueues()` `list(StorageListOptions)`, `iterate(StorageListOptions, Long chunkSize)`, and `getOrCreate(String)`, -as for datasets. +as for datasets. Unlike datasets/key-value stores, there is no `getOrCreate(String, Object schema)` +overload here — the request-queue creation endpoint does not accept a creation-time schema. ### `RequestQueueClient` — `client.requestQueue(id)` diff --git a/docs/webhooks.md b/docs/webhooks.md index 455d9f0..ce01ba6 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -53,7 +53,7 @@ System.out.println(dispatch.getId()); `Webhook` fields: `getId()`, `getUserId()`, `getRequestUrl()`, `getEventTypes()`, `getCreatedAt()` / `getModifiedAt()` (`Instant`), `isAdHoc()` (a one-off webhook attached to a single run, e.g. via `ActorStartOptions.webhooks(...)`, rather than a persistent account-level webhook), -`isShouldInterpolateStrings()` (`Boolean`; whether `{{...}}` placeholders in `getPayloadTemplate()`/ +`getShouldInterpolateStrings()` (`Boolean`; whether `{{...}}` placeholders in `getPayloadTemplate()`/ `getHeadersTemplate()` are interpolated with values from the triggering event before dispatch), `getCondition()` (`JsonNode`; one of an Actor ID, a task ID or a specific run ID, depending on how the webhook was configured), `isIgnoreSslErrors()`, `isDoNotRetry()`, `getPayloadTemplate()`, diff --git a/src/main/java/com/apify/client/actor/ActorCallOptions.java b/src/main/java/com/apify/client/actor/ActorCallOptions.java index d12e017..fcac830 100644 --- a/src/main/java/com/apify/client/actor/ActorCallOptions.java +++ b/src/main/java/com/apify/client/actor/ActorCallOptions.java @@ -1,6 +1,5 @@ package com.apify.client.actor; -import com.apify.client.internal.CallOptionsLike; import com.apify.client.log.StreamedLogOptions; import java.util.List; @@ -18,8 +17,13 @@ * reference client's {@code options.log} defaulting to {@code 'default'}. Use {@link * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom * destination. + * + *

    {@link #toStartOptions}/{@link #logStreamingEnabledValue}/{@link #logOptionsValue} are + * package-private: {@link ActorClient} (same package) reads them directly and hands the results to + * {@code com.apify.client.internal.RunStartSupport} as plain values/method references, so this + * internal shape never needs to be public API. */ -public final class ActorCallOptions implements CallOptionsLike { +public final class ActorCallOptions { private final ActorStartOptions startOptions = new ActorStartOptions(); private boolean logStreamingEnabled = true; @@ -102,18 +106,15 @@ public ActorCallOptions logOptions(StreamedLogOptions logOptions) { return this; } - @Override - public ActorStartOptions toStartOptions() { + ActorStartOptions toStartOptions() { return startOptions; } - @Override - public boolean logStreamingEnabledValue() { + boolean logStreamingEnabledValue() { return logStreamingEnabled; } - @Override - public StreamedLogOptions logOptionsValue() { + StreamedLogOptions logOptionsValue() { return logOptions; } } diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index ef06d7d..ecbbfb9 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -65,7 +65,7 @@ public void delete() { * JSON-serializable value (or {@code null} for no input). */ public ActorRun start(Object input, ActorStartOptions options) { - return RunStartSupport.start(ctx, input, options); + return RunStartSupport.start(ctx, input, options::apply, options.contentTypeOrDefault()); } /** @@ -77,7 +77,8 @@ public ActorRun start(Object input, ActorStartOptions options) { * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { - return RunStartSupport.call(root, ctx, input, options, waitSecs); + return RunStartSupport.call( + root, ctx, input, options::apply, options.contentTypeOrDefault(), waitSecs); } /** @@ -94,7 +95,16 @@ public ActorRun call(Object input, ActorStartOptions options, Long waitSecs) { */ public ActorRun call(Object input, ActorCallOptions options, Long waitSecs) { ActorCallOptions opts = options != null ? options : new ActorCallOptions(); - return RunStartSupport.callWithLogStreaming(root, ctx, input, opts, waitSecs); + ActorStartOptions startOptions = opts.toStartOptions(); + return RunStartSupport.callWithLogStreaming( + root, + ctx, + input, + startOptions::apply, + startOptions.contentTypeOrDefault(), + opts.logStreamingEnabledValue(), + opts.logOptionsValue(), + waitSecs); } /** Validates the given input against the Actor's default-build input schema. */ diff --git a/src/main/java/com/apify/client/actor/ActorStartOptions.java b/src/main/java/com/apify/client/actor/ActorStartOptions.java index fbf6f06..428ad1d 100644 --- a/src/main/java/com/apify/client/actor/ActorStartOptions.java +++ b/src/main/java/com/apify/client/actor/ActorStartOptions.java @@ -3,15 +3,19 @@ import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import com.apify.client.internal.StartOptionsLike; import java.util.Base64; import java.util.List; /** * Configures starting an Actor run ({@link ActorClient#start}/{@link ActorClient#call}). All fields * are optional. + * + *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private: {@link ActorClient}/{@link + * ActorCallOptions} (both in this package) read them directly and hand the results to {@code + * com.apify.client.internal.RunStartSupport} as plain values/method references, so this internal + * shape never needs to be public API. */ -public final class ActorStartOptions implements StartOptionsLike { +public final class ActorStartOptions { private String build; private Long memoryMbytes; private Long timeoutSecs; @@ -89,15 +93,13 @@ public ActorStartOptions webhooks(List webhooks) { return this; } - @Override - public String contentTypeOrDefault() { + String contentTypeOrDefault() { return (contentType != null && !contentType.isEmpty()) ? contentType : ResourceContext.CONTENT_TYPE_JSON; } - @Override - public void apply(QueryParams q) { + void apply(QueryParams q) { q.addString("build", build) .addLong("memory", memoryMbytes) .addLong("timeout", timeoutSecs) diff --git a/src/main/java/com/apify/client/internal/CallOptionsLike.java b/src/main/java/com/apify/client/internal/CallOptionsLike.java deleted file mode 100644 index 1757c0f..0000000 --- a/src/main/java/com/apify/client/internal/CallOptionsLike.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.apify.client.internal; - -import com.apify.client.log.StreamedLogOptions; - -/** - * The common shape every {@code call(...)} options type ({@code - * com.apify.client.actor.ActorCallOptions}, {@code com.apify.client.task.TaskCallOptions}) - * implements, so {@link RunStartSupport} can drive {@code call}'s "start, then wait, optionally - * streaming the log" behavior generically across both instead of each resource client duplicating - * it. {@code S} is the paired start-options type ({@code call} options always mirror "start options - * minus {@code waitForFinish}"). - */ -public interface CallOptionsLike { - - /** The equivalent start options (every field the two share). */ - S toStartOptions(); - - /** Whether {@code call} should stream the run's log for the duration of the wait. */ - boolean logStreamingEnabledValue(); - - /** A custom log-streaming destination, or {@code null} to use the default per-run logger. */ - StreamedLogOptions logOptionsValue(); -} diff --git a/src/main/java/com/apify/client/internal/RunStartSupport.java b/src/main/java/com/apify/client/internal/RunStartSupport.java index 037b0dc..78bf012 100644 --- a/src/main/java/com/apify/client/internal/RunStartSupport.java +++ b/src/main/java/com/apify/client/internal/RunStartSupport.java @@ -2,15 +2,24 @@ import com.apify.client.ApifyClient; import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; import com.apify.client.run.ActorRun; import com.apify.client.run.RunClient; +import java.util.function.Consumer; /** * Shared implementation of "start a run" and "call: start, then wait, optionally streaming the log" - * for every client that can start a run from a runnable resource (Actor, Task). Both resources' - * start-options and call-options types opt in via {@link StartOptionsLike}/{@link CallOptionsLike}, - * so this single implementation backs {@code ActorClient}/{@code TaskClient} instead of each - * duplicating the same ~90 lines. + * for every client that can start a run from a runnable resource (Actor, Task). + * + *

    {@code ActorStartOptions}/{@code TaskStartOptions}/{@code ActorCallOptions}/{@code + * TaskCallOptions} stay package-private in their own packages ({@code com.apify.client.actor}/ + * {@code com.apify.client.task}) with no public interface or shared supertype: {@code + * ActorClient}/{@code TaskClient} (same package as their respective options types) read the option + * values directly and pass them here as plain values and method references (e.g. {@code + * options::apply} bound as a {@code Consumer}). That keeps this one implementation + * backing both Actor and task calls (instead of each duplicating the same ~90 lines) without + * forcing any of the options builders' internal plumbing — including the internal {@link + * QueryParams} type — into their public API. */ public final class RunStartSupport { @@ -18,13 +27,16 @@ private RunStartSupport() {} /** * Starts a run on {@code ctx}'s {@code runs} sub-resource and returns immediately with the - * created run. {@code input} is any JSON-serializable value, or {@code null} for no input. + * created run. {@code input} is any JSON-serializable value, or {@code null} for no input. {@code + * applyParams} applies every start-option query parameter (build, memory, timeout, ...) the + * caller's options type configures; {@code contentType} is the input body's content type. */ - public static ActorRun start(ResourceContext ctx, Object input, StartOptionsLike options) { + public static ActorRun start( + ResourceContext ctx, Object input, Consumer applyParams, String contentType) { QueryParams params = new QueryParams(); - options.apply(params); + applyParams.accept(params); byte[] body = input == null ? null : Json.toBytes(input); - return ctx.postWithBody("runs", params, body, options.contentTypeOrDefault(), ActorRun.class); + return ctx.postWithBody("runs", params, body, contentType, ActorRun.class); } /** @@ -35,9 +47,10 @@ public static ActorRun call( ApifyClient root, ResourceContext ctx, Object input, - StartOptionsLike options, + Consumer applyParams, + String contentType, Long waitSecs) { - ActorRun run = start(ctx, input, options); + ActorRun run = start(ctx, input, applyParams, contentType); return root.run(run.getId()).waitForFinish(waitSecs); } @@ -48,18 +61,21 @@ public static ActorRun call( * log is not yet available), the run still starts and is still waited for, just without * redirected log output. */ - public static ActorRun callWithLogStreaming( + public static ActorRun callWithLogStreaming( ApifyClient root, ResourceContext ctx, Object input, - CallOptionsLike options, + Consumer applyParams, + String contentType, + boolean logStreamingEnabled, + StreamedLogOptions logOptions, Long waitSecs) { - ActorRun run = start(ctx, input, options.toStartOptions()); + ActorRun run = start(ctx, input, applyParams, contentType); RunClient runClient = root.run(run.getId()); StreamedLog streamedLog = null; - if (options.logStreamingEnabledValue()) { - streamedLog = startStreamedLogQuietly(runClient, options); + if (logStreamingEnabled) { + streamedLog = startStreamedLogQuietly(runClient, logOptions); } try { return runClient.waitForFinish(waitSecs); @@ -74,13 +90,11 @@ public static ActorRun callWithLogStreaming( * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. */ - private static StreamedLog startStreamedLogQuietly( - RunClient runClient, CallOptionsLike options) { + private static StreamedLog startStreamedLogQuietly( + RunClient runClient, StreamedLogOptions logOptions) { try { StreamedLog streamedLog = - options.logOptionsValue() != null - ? runClient.getStreamedLog(options.logOptionsValue()) - : runClient.getStreamedLog(); + logOptions != null ? runClient.getStreamedLog(logOptions) : runClient.getStreamedLog(); streamedLog.start(); return streamedLog; } catch (RuntimeException e) { diff --git a/src/main/java/com/apify/client/internal/StartOptionsLike.java b/src/main/java/com/apify/client/internal/StartOptionsLike.java deleted file mode 100644 index 0197141..0000000 --- a/src/main/java/com/apify/client/internal/StartOptionsLike.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.apify.client.internal; - -/** - * The common shape every "start a run" options type ({@code - * com.apify.client.actor.ActorStartOptions}, {@code com.apify.client.task.TaskStartOptions}) - * implements, so {@link RunStartSupport} can build the start request generically across both - * instead of each resource client duplicating the same {@code start}/{@code call} logic. - */ -public interface StartOptionsLike { - - /** Applies every query parameter these options configure (build, memory, timeout, ...). */ - void apply(QueryParams q); - - /** The content type of the input body to send, defaulting to JSON when unset. */ - String contentTypeOrDefault(); -} diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index 2325eb9..b06381d 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -211,6 +211,13 @@ public BatchAddResult batchAddRequests(List requests, boole * is returned in {@link BatchAddResult#getUnprocessedRequests()} instead. A single request whose * own JSON encoding already exceeds the payload-size limit is rejected up front with {@link * IllegalArgumentException}, since no chunk size could ever fit it. + * + *

    Caveat: processed-vs-unprocessed reconciliation after a retry matches requests by + * {@link RequestQueueRequest#getUniqueKey()}. If a request omits {@code uniqueKey} (it is + * nullable), a request that actually succeeded server-side on an earlier attempt can still be + * reported in {@link BatchAddResult#getUnprocessedRequests()} after retries, because there is no + * caller-supplied key to match it back against the API's per-attempt response. Callers that rely + * on {@code batchAddRequests}' return value should set {@code uniqueKey} explicitly. */ public BatchAddResult batchAddRequests( List requests, boolean forefront, BatchAddRequestsOptions options) { @@ -342,7 +349,7 @@ private BatchAddResult batchAddChunkWithRetries( if (remaining.isEmpty()) { break; } - } catch (ApifyApiException e) { + } catch (ApifyApiException ignored) { // Any API error (rate-limit, server error, or a hard client error such as a bad token) // stops retrying this chunk immediately; whatever has not been confirmed processed is // reported as unprocessed below, never thrown — matching the reference client's contract. diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index 12f7c7b..d97e47b 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -40,7 +40,6 @@ public String getId() { return id; } - /** Sets the unique request ID. */ public RequestQueueRequest setId(String id) { this.id = id; return this; @@ -51,13 +50,19 @@ public String getUrl() { return url; } - /** Sets the request URL. */ public RequestQueueRequest setUrl(String url) { this.url = url; return this; } - /** The deduplication key for the request. */ + /** + * The deduplication key for the request. Nullable — if unset, the API derives one from the + * request (typically the URL + method). Note that {@link + * RequestQueueClient#batchAddRequests(List, boolean, BatchAddRequestsOptions) batchAddRequests}' + * retry reconciliation matches requests back to the API's response by this field, so a request + * without an explicit {@code uniqueKey} can be falsely reported unprocessed after a retry even + * though it succeeded server-side; set it explicitly if you rely on that return value. + */ public String getUniqueKey() { return uniqueKey; } @@ -73,7 +78,6 @@ public String getMethod() { return method; } - /** Sets the HTTP method. */ public RequestQueueRequest setMethod(String method) { this.method = method; return this; @@ -100,7 +104,6 @@ public String getPayload() { return payload; } - /** Sets the HTTP request body. */ public RequestQueueRequest setPayload(String payload) { this.payload = payload; return this; diff --git a/src/main/java/com/apify/client/task/TaskCallOptions.java b/src/main/java/com/apify/client/task/TaskCallOptions.java index 14ab48a..5dd3e19 100644 --- a/src/main/java/com/apify/client/task/TaskCallOptions.java +++ b/src/main/java/com/apify/client/task/TaskCallOptions.java @@ -1,6 +1,5 @@ package com.apify.client.task; -import com.apify.client.internal.CallOptionsLike; import com.apify.client.log.StreamedLogOptions; import java.util.List; @@ -18,8 +17,13 @@ * reference client's {@code options.log} defaulting to {@code 'default'}. Use {@link * #disableLogStreaming()} to opt out, or {@link #logOptions(StreamedLogOptions)} for a custom * destination. + * + *

    {@link #toStartOptions}/{@link #logStreamingEnabledValue}/{@link #logOptionsValue} are + * package-private: {@link TaskClient} (same package) reads them directly and hands the results to + * {@code com.apify.client.internal.RunStartSupport} as plain values/method references, so this + * internal shape never needs to be public API. */ -public final class TaskCallOptions implements CallOptionsLike { +public final class TaskCallOptions { private final TaskStartOptions startOptions = new TaskStartOptions(); private boolean logStreamingEnabled = true; @@ -87,18 +91,15 @@ public TaskCallOptions logOptions(StreamedLogOptions logOptions) { return this; } - @Override - public TaskStartOptions toStartOptions() { + TaskStartOptions toStartOptions() { return startOptions; } - @Override - public boolean logStreamingEnabledValue() { + boolean logStreamingEnabledValue() { return logStreamingEnabled; } - @Override - public StreamedLogOptions logOptionsValue() { + StreamedLogOptions logOptionsValue() { return logOptions; } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 29c61be..5b34c87 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -53,7 +53,7 @@ public void delete() { * overrides the task's stored input ({@code null} to use the stored input). */ public ActorRun start(Object input, TaskStartOptions options) { - return RunStartSupport.start(ctx, input, options); + return RunStartSupport.start(ctx, input, options::apply, options.contentTypeOrDefault()); } /** @@ -64,7 +64,8 @@ public ActorRun start(Object input, TaskStartOptions options) { * Long)} for that (matching the reference client's default {@code call} behavior). */ public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { - return RunStartSupport.call(root, ctx, input, options, waitSecs); + return RunStartSupport.call( + root, ctx, input, options::apply, options.contentTypeOrDefault(), waitSecs); } /** @@ -80,7 +81,16 @@ public ActorRun call(Object input, TaskStartOptions options, Long waitSecs) { */ public ActorRun call(Object input, TaskCallOptions options, Long waitSecs) { TaskCallOptions opts = options != null ? options : new TaskCallOptions(); - return RunStartSupport.callWithLogStreaming(root, ctx, input, opts, waitSecs); + TaskStartOptions startOptions = opts.toStartOptions(); + return RunStartSupport.callWithLogStreaming( + root, + ctx, + input, + startOptions::apply, + startOptions.contentTypeOrDefault(), + opts.logStreamingEnabledValue(), + opts.logOptionsValue(), + waitSecs); } /** Fetches the task's stored input, or empty if none is set. */ diff --git a/src/main/java/com/apify/client/task/TaskStartOptions.java b/src/main/java/com/apify/client/task/TaskStartOptions.java index 48b2583..ef5f86c 100644 --- a/src/main/java/com/apify/client/task/TaskStartOptions.java +++ b/src/main/java/com/apify/client/task/TaskStartOptions.java @@ -3,7 +3,6 @@ import com.apify.client.actor.ActorStartOptions; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import com.apify.client.internal.StartOptionsLike; import java.util.List; /** @@ -12,8 +11,13 @@ *

    It mirrors {@link ActorStartOptions} but omits the fields the task run endpoint does not * accept (the Actor-only {@code contentType} and {@code forcePermissionLevel}), matching the * reference client. + * + *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private: {@link TaskClient}/{@link + * TaskCallOptions} (both in this package) read them directly and hand the results to {@code + * com.apify.client.internal.RunStartSupport} as plain values/method references, so this internal + * shape never needs to be public API. */ -public final class TaskStartOptions implements StartOptionsLike { +public final class TaskStartOptions { private String build; private Long memoryMbytes; private Long timeoutSecs; @@ -71,8 +75,7 @@ public TaskStartOptions webhooks(List webhooks) { return this; } - @Override - public void apply(QueryParams q) { + void apply(QueryParams q) { q.addString("build", build) .addLong("memory", memoryMbytes) .addLong("timeout", timeoutSecs) @@ -85,11 +88,10 @@ public void apply(QueryParams q) { /** * The task run endpoint does not accept a content-type override (unlike the Actor run endpoint), - * so this always returns the JSON default; present only to satisfy {@link StartOptionsLike} so - * {@code start}/{@code call} can be driven generically for both Actor and task runs. + * so this always returns the JSON default; present only so {@code start}/{@code call} can be + * driven generically for both Actor and task runs (both call sites read this the same way). */ - @Override - public String contentTypeOrDefault() { + String contentTypeOrDefault() { return ResourceContext.CONTENT_TYPE_JSON; } } diff --git a/src/main/java/com/apify/client/webhook/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java index ec9993f..42b51d7 100644 --- a/src/main/java/com/apify/client/webhook/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -58,7 +58,7 @@ public boolean isAdHoc() { * #getHeadersTemplate()} are interpolated with values from the triggering event before the * webhook is dispatched. */ - public Boolean isShouldInterpolateStrings() { + public Boolean getShouldInterpolateStrings() { return shouldInterpolateStrings; } diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 819da52..0af4f35 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -23,6 +23,7 @@ import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunChargeOptions; +import com.apify.client.run.SetStatusMessageOptions; import com.apify.client.store.ActorStoreListItem; import com.apify.client.store.StoreListOptions; import com.apify.client.webhook.NestedWebhookCollectionClient; @@ -114,6 +115,27 @@ void chargeRejectsMissingEventName() { assertEquals(0, backend.calls, "no request should be sent for an invalid charge"); } + @Test + void setStatusMessageThrowsWhenActorRunIdUnset() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); + ApifyClient client = client(backend); + assertThrows( + IllegalStateException.class, + () -> client.setStatusMessage("hello", new SetStatusMessageOptions())); + assertEquals(0, backend.calls, "no request should be sent when ACTOR_RUN_ID is unset"); + } + + // Documented skip, mirroring this project's existing metamorph/reboot/charge live-skip + // convention (see ActorRunIntegrationTest's class-level comment): the ACTOR_RUN_ID-set success + // path (PUTs statusMessage/isStatusMessageTerminal to the run identified by that env var) is not + // covered here because the JVM's environment map is effectively immutable in-process without + // reflecting into JDK internals (fragile, and guarded by the module system since Java 9), and it + // is not live-testable either (a live integration test isn't itself an Actor run, so + // ACTOR_RUN_ID is never set there). The PUT-body construction it exercises — statusMessage + // always set, isStatusMessageTerminal only when non-null — is otherwise a straight-line, 3-line + // method body with no branch this test suite doesn't already cover elsewhere (RunClient.update's + // PUT is exercised by TaskIntegrationTest#taskCrudFlow and friends). + @Test void metamorphSendsTargetActorIdBuildAndInputBody() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java index 6d0a80a..f7e295b 100644 --- a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -133,7 +133,11 @@ void actorDefaultBuildAndWebhooks() { assertTrue(defaultBuild.get().isPresent()); // Read-only nested webhook collection (GET + iterate); the account has none registered for - // this fresh Actor, so this just exercises both calls succeeding against an empty result. + // this fresh Actor, so this just exercises both calls succeeding against an empty result — + // not a multi-item case. `NestedWebhookCollectionClient` shares its `list`/`iterate` + // implementation with `AbstractWebhookCollectionClient`, whose paging/iteration behavior is + // already exercised with real multi-item data (2 created webhooks, paged one at a time) by + // `IterationIntegrationTest#iterateWebhooks` through the sibling `WebhookCollectionClient`. assertTrue(actor.webhooks().list(new ListOptions()).getTotal() >= 0); assertTrue(!actor.webhooks().iterate(new ListOptions()).hasNext()); } finally { From f705ae68a8bc47e843cfe740f6f0805084df42db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 08:28:30 +0000 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20loop-5=20review=20polish=20?= =?UTF-8?q?=E2=80=94=20dedupe=20options-class=20javadoc,=20consistent=20se?= =?UTF-8?q?tter=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two concrete findings from this loop's coding-style review: - The near-identical "package-private on purpose" paragraph was copy-pasted across ActorStartOptions/ActorCallOptions/TaskStartOptions/TaskCallOptions's class javadoc. Condensed each to a one-line pointer at RunStartSupport, whose own javadoc (now with @param docs on call/callWithLogStreaming) carries the full rationale once. - RequestQueueRequest's setter-javadoc trim was inconsistent: setId/setUrl/setMethod/setPayload had their boilerplate one-liner removed, but setHandledAt/setRetryCount/setLoadedUrl (equally trivial restatements of their getters) did not. Trimmed those three too for consistency; setUserData/ setHeaders/setNoRetry/setUniqueKey/setErrorMessages are untouched (still the mandated keep-as-informative set). mvn spotless:check/compile/spotbugs:check/test all green: 183/183 tests passing, 0 failures, 0 errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- .../com/apify/client/actor/ActorCallOptions.java | 5 ++--- .../com/apify/client/actor/ActorStartOptions.java | 6 ++---- .../com/apify/client/internal/RunStartSupport.java | 12 ++++++++++-- .../client/requestqueue/RequestQueueRequest.java | 3 --- .../java/com/apify/client/task/TaskCallOptions.java | 5 ++--- .../java/com/apify/client/task/TaskStartOptions.java | 6 ++---- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/apify/client/actor/ActorCallOptions.java b/src/main/java/com/apify/client/actor/ActorCallOptions.java index fcac830..91b7839 100644 --- a/src/main/java/com/apify/client/actor/ActorCallOptions.java +++ b/src/main/java/com/apify/client/actor/ActorCallOptions.java @@ -19,9 +19,8 @@ * destination. * *

    {@link #toStartOptions}/{@link #logStreamingEnabledValue}/{@link #logOptionsValue} are - * package-private: {@link ActorClient} (same package) reads them directly and hands the results to - * {@code com.apify.client.internal.RunStartSupport} as plain values/method references, so this - * internal shape never needs to be public API. + * package-private on purpose — see {@link com.apify.client.internal.RunStartSupport}'s class + * javadoc for why. */ public final class ActorCallOptions { diff --git a/src/main/java/com/apify/client/actor/ActorStartOptions.java b/src/main/java/com/apify/client/actor/ActorStartOptions.java index 428ad1d..2724028 100644 --- a/src/main/java/com/apify/client/actor/ActorStartOptions.java +++ b/src/main/java/com/apify/client/actor/ActorStartOptions.java @@ -10,10 +10,8 @@ * Configures starting an Actor run ({@link ActorClient#start}/{@link ActorClient#call}). All fields * are optional. * - *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private: {@link ActorClient}/{@link - * ActorCallOptions} (both in this package) read them directly and hand the results to {@code - * com.apify.client.internal.RunStartSupport} as plain values/method references, so this internal - * shape never needs to be public API. + *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private on purpose — see {@link + * com.apify.client.internal.RunStartSupport}'s class javadoc for why. */ public final class ActorStartOptions { private String build; diff --git a/src/main/java/com/apify/client/internal/RunStartSupport.java b/src/main/java/com/apify/client/internal/RunStartSupport.java index 78bf012..f3685c4 100644 --- a/src/main/java/com/apify/client/internal/RunStartSupport.java +++ b/src/main/java/com/apify/client/internal/RunStartSupport.java @@ -40,8 +40,9 @@ public static ActorRun start( } /** - * Starts a run and waits (client-side polling) for it to finish, without log streaming. {@code - * waitSecs} bounds the wait; {@code null} waits indefinitely. + * Starts a run and waits (client-side polling) for it to finish, without log streaming. + * + * @param waitSecs bounds the wait; {@code null} waits indefinitely */ public static ActorRun call( ApifyClient root, @@ -60,6 +61,13 @@ public static ActorRun call( * options.log} to {@code 'default'}. Log streaming is best-effort: if starting it fails (e.g. the * log is not yet available), the run still starts and is still waited for, just without * redirected log output. + * + * @param applyParams applies every start-option query parameter the caller's call-options type + * configures (delegated from its paired start-options type) + * @param contentType the input body's content type + * @param logStreamingEnabled whether to stream the log for the duration of the wait + * @param logOptions a custom log-streaming destination, or {@code null} for the default + * @param waitSecs bounds the wait; {@code null} waits indefinitely */ public static ActorRun callWithLogStreaming( ApifyClient root, diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index d97e47b..6e9a1e3 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -136,7 +136,6 @@ public Instant getHandledAt() { return handledAt; } - /** Sets when the request was marked as handled. */ public RequestQueueRequest setHandledAt(Instant handledAt) { this.handledAt = handledAt; return this; @@ -147,7 +146,6 @@ public Integer getRetryCount() { return retryCount; } - /** Sets the number of times processing this request has already been retried. */ public RequestQueueRequest setRetryCount(Integer retryCount) { this.retryCount = retryCount; return this; @@ -158,7 +156,6 @@ public String getLoadedUrl() { return loadedUrl; } - /** Sets the URL actually loaded, after following redirects. */ public RequestQueueRequest setLoadedUrl(String loadedUrl) { this.loadedUrl = loadedUrl; return this; diff --git a/src/main/java/com/apify/client/task/TaskCallOptions.java b/src/main/java/com/apify/client/task/TaskCallOptions.java index 5dd3e19..670ffb5 100644 --- a/src/main/java/com/apify/client/task/TaskCallOptions.java +++ b/src/main/java/com/apify/client/task/TaskCallOptions.java @@ -19,9 +19,8 @@ * destination. * *

    {@link #toStartOptions}/{@link #logStreamingEnabledValue}/{@link #logOptionsValue} are - * package-private: {@link TaskClient} (same package) reads them directly and hands the results to - * {@code com.apify.client.internal.RunStartSupport} as plain values/method references, so this - * internal shape never needs to be public API. + * package-private on purpose — see {@link com.apify.client.internal.RunStartSupport}'s class + * javadoc for why. */ public final class TaskCallOptions { diff --git a/src/main/java/com/apify/client/task/TaskStartOptions.java b/src/main/java/com/apify/client/task/TaskStartOptions.java index ef5f86c..6d06253 100644 --- a/src/main/java/com/apify/client/task/TaskStartOptions.java +++ b/src/main/java/com/apify/client/task/TaskStartOptions.java @@ -12,10 +12,8 @@ * accept (the Actor-only {@code contentType} and {@code forcePermissionLevel}), matching the * reference client. * - *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private: {@link TaskClient}/{@link - * TaskCallOptions} (both in this package) read them directly and hand the results to {@code - * com.apify.client.internal.RunStartSupport} as plain values/method references, so this internal - * shape never needs to be public API. + *

    {@link #apply}/{@link #contentTypeOrDefault} are package-private on purpose — see {@link + * com.apify.client.internal.RunStartSupport}'s class javadoc for why. */ public final class TaskStartOptions { private String build; From 35c02cc710e9ce43e1713441af1e8b5f9fbe6b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 08:44:06 +0000 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20loop-5=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20CI=20flaky-test=20fix,=20P5/P6/P7=20correctness+doc?= =?UTF-8?q?=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second independent /review-client pass's residual findings on top of f705ae6: - P1 (CI, merge-blocking): ActorRunIntegrationTest.streamedLogRedirection failed in CI on f705ae6 (the same live race as loop 1/2 — a live-tail stream opened before the run finishes can reach EOF a hair before the final log bytes are flushed to that connection). Fix: after the bounded catch-up window, if the run produced a log but the first stream still came up empty, retry once with a brand-new StreamedLog opened strictly after the run is already finished — a GET against a static, fully-persisted log rather than a live tail, so it cannot race the run's own writer. Verified green 3x locally against the live API. - P2: fixed a factually wrong RunStartSupport javadoc claim that ActorStartOptions/TaskStartOptions/ ActorCallOptions/TaskCallOptions "stay package-private" — those classes are public (they must be, as public method parameter/return types); only 5 of their accessor methods are package-private. - P5: AbstractCollectionClient.list(options) NPE'd on null options while iterate(options) tolerated it — now consistent. BatchAddResult.getProcessedRequests()/getUnprocessedRequests()/merge() now null-coalesce, matching PaginationList's existing pattern, so an explicit "processedRequests": null / "unprocessedRequests": null API response can no longer NPE. - P7 (docs): documented that ValidateInputOptions lives in com.apify.client.task despite configuring an ActorClient method; added java.time.Instant to the standard-import list; noted that setStatusMessage/UserClient limits methods/ApifyClientBuilder setters throw plain JDK exceptions outside the ApifyClientException hierarchy; clarified waitForFinish/call's behavior when the wait budget expires before the run reaches a terminal state. mvn spotless:check/compile/spotbugs:check/test all green: 183/183 tests passing, 0 failures, 0 errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- README.md | 16 +++++- docs/README.md | 3 +- docs/actors.md | 4 ++ docs/runs.md | 8 +++ .../java/com/apify/client/PaginationList.java | 10 +++- .../internal/AbstractCollectionClient.java | 5 +- .../client/internal/RunStartSupport.java | 10 ++-- .../client/requestqueue/BatchAddResult.java | 21 ++++++-- .../requestqueue/RequestQueueRequest.java | 7 +++ .../java/com/apify/client/run/RunClient.java | 5 +- .../integration/ActorRunIntegrationTest.java | 51 ++++++++++++++++++- 11 files changed, 123 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c4c2325..6d9ec89 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,9 @@ client.actor("apify/hello-world").get().ifPresent(actor -> System.out.println(ac ## Error handling -Every exception this client throws is an unchecked `com.apify.client.http.ApifyClientException`. -It has two concrete subtypes, both also in `com.apify.client.http`: +Every exception this client throws for a request/transport failure is an unchecked +`com.apify.client.http.ApifyClientException`. It has two concrete subtypes, both also in +`com.apify.client.http`: - `ApifyApiException` — the request reached the API, which answered with a non-success status. - `ApifyTransportException` — the request never produced an API response at all (connection @@ -156,6 +157,17 @@ It has two concrete subtypes, both also in `com.apify.client.http`: Catch `ApifyClientException` to handle both failure modes uniformly, or catch a specific subtype to handle one of them differently. `ApifyApiException` is imported from `com.apify.client.http`: +A few methods validate their own preconditions before making any request and throw a plain JDK +exception instead, not an `ApifyClientException` subtype: `ApifyClient.setStatusMessage(message, +options)` throws `IllegalStateException` if the `ACTOR_RUN_ID` environment variable is not set, +`ApifyClient.me()`'s limits methods (`limits()`, `updateLimits(...)`, `monthlyUsage(...)`) throw the +same `IllegalStateException` if called on a `UserClient` that is not `me()`, and +`ApifyClientBuilder`'s setters throw `IllegalArgumentException` on an invalid configuration value +(e.g. a negative retry count). These are local, no-request-sent failures, not something the API +responded with or the transport failed to deliver, which is why they stay outside the +request/transport exception hierarchy above — catch `IllegalStateException`/`IllegalArgumentException` +separately if you call any of them. + ```java try { client.actor("does/not-exist").update(Map.of("title", "x")); diff --git a/docs/README.md b/docs/README.md index a59ab14..db7b419 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,8 @@ different packages for one four-line snippet. Snippets in these docs also assume the standard-library types they use are imported (`java.util.List`, `java.util.ArrayList`, `java.util.Map`, `java.util.Optional`, `java.util.Iterator`, `java.util.UUID`, `java.util.function.Consumer`, `java.time.Duration`, -`java.io.InputStream`). +`java.time.Instant`, `java.io.InputStream`). `java.time.Instant` is returned by many model getters +(e.g. `createdAt`, `modifiedAt`, `nextRunAt`, `lockExpiresAt`). 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. diff --git a/docs/actors.md b/docs/actors.md index c0570b7..f681dc5 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -75,6 +75,10 @@ server-side while the run finishes, which is redundant with (and wastes a reques reference client's `log: null`) and `logOptions(StreamedLogOptions)` (a custom destination/prefix, matching a custom `Log` instance) — see [Streamed log redirection](runs.md#streamed-log-redirection). +`ValidateInputOptions` is a `com.apify.client.task` type (see the package table in +[`README.md`](README.md)), even though `validateInput` is an `ActorClient` method — import it from +`com.apify.client.task`, not `com.apify.client.actor`. + `lastRun(String status)` filters only by status; `lastRun(LastRunOptions)` also accepts an origin filter. `LastRunOptions` has fluent setters `status(String)` (e.g. `SUCCEEDED`, `RUNNING`) and `origin(String)` (e.g. `API`, `WEB`, `SCHEDULER`); leave a setter uncalled to omit that filter. diff --git a/docs/runs.md b/docs/runs.md index 99b8888..fc86590 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -43,6 +43,14 @@ PaginationList runs = client.runs().list( | `getStreamedLog()` | A `StreamedLog` that redirects the live log to a default per-run logger. | | `getStreamedLog(StreamedLogOptions)` | As above, with a custom destination / options. | +If `waitSecs` elapses before the run reaches a terminal state, `waitForFinish` returns the run's +current (non-terminal) status rather than throwing or blocking further — check `isTerminal()` on +the result and call `waitForFinish` again (or poll) if you need to keep waiting. `ActorClient`/ +`TaskClient`'s `call(...)` overloads (see [Actors](actors.md#actorclient)/[Tasks](tasks.md)) return +this same possibly-non-terminal `ActorRun` for the same reason: `call` is `start` followed by +`waitForFinish`, so a `call` with a short `waitSecs` on a long-running Actor can return before the +run finishes. + ### Streamed log redirection `getStreamedLog()` returns a `StreamedLog`: a helper that follows the run's live raw log in a diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java index a5440d3..827c474 100644 --- a/src/main/java/com/apify/client/PaginationList.java +++ b/src/main/java/com/apify/client/PaginationList.java @@ -60,7 +60,15 @@ public List getItems() { } // Setters used by the collection-listing and dataset-items paths, which build a page from a - // parsed response (or, for dataset items, from response headers) one field at a time. + // parsed response (or, for dataset items, from response headers) one field at a time. These stay + // public rather than package-private: DatasetClient (com.apify.client.dataset, the only caller + // outside Jackson's own field-based deserialization) builds a page's metadata one header at a + // time in listItems/downloadItems, and Java has no cross-package "friend" visibility - narrowing + // this without a public setter would mean either moving that header-parsing logic into this + // (root) package, which is a bigger surgery than this getter surface warrants, or introducing a + // module-info split purely for this one class. Left as-is; a caller holding a page can mutate it + // after the fact, which is a real but minor and pre-existing encapsulation gap, not a correctness + // bug (this client never re-reads a page after constructing it). public void setTotal(long total) { this.total = total; } diff --git a/src/main/java/com/apify/client/internal/AbstractCollectionClient.java b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java index 053a381..650c7ee 100644 --- a/src/main/java/com/apify/client/internal/AbstractCollectionClient.java +++ b/src/main/java/com/apify/client/internal/AbstractCollectionClient.java @@ -33,10 +33,11 @@ protected AbstractCollectionClient( this.defaultOptions = defaultOptions; } - /** Lists the collection's items for one page. */ + /** Lists the collection's items for one page. {@code options} may be {@code null} (defaults). */ public PaginationList list(O options) { + O opts = options != null ? options : defaultOptions.get(); QueryParams params = new QueryParams(); - options.apply(params); + opts.apply(params); return listWithParams(params); } diff --git a/src/main/java/com/apify/client/internal/RunStartSupport.java b/src/main/java/com/apify/client/internal/RunStartSupport.java index f3685c4..2264de3 100644 --- a/src/main/java/com/apify/client/internal/RunStartSupport.java +++ b/src/main/java/com/apify/client/internal/RunStartSupport.java @@ -12,10 +12,12 @@ * for every client that can start a run from a runnable resource (Actor, Task). * *

    {@code ActorStartOptions}/{@code TaskStartOptions}/{@code ActorCallOptions}/{@code - * TaskCallOptions} stay package-private in their own packages ({@code com.apify.client.actor}/ - * {@code com.apify.client.task}) with no public interface or shared supertype: {@code - * ActorClient}/{@code TaskClient} (same package as their respective options types) read the option - * values directly and pass them here as plain values and method references (e.g. {@code + * TaskCallOptions} (in {@code com.apify.client.actor}/{@code com.apify.client.task}) are themselves + * public classes — only the handful of accessor methods this class needs from them ({@code apply}, + * {@code contentTypeOrDefault}, {@code toStartOptions}, {@code logStreamingEnabledValue}, {@code + * logOptionsValue}) are package-private, with no public interface or shared supertype exposing + * them. {@code ActorClient}/{@code TaskClient} (same package as their respective options types) + * read those values directly and pass them here as plain values and method references (e.g. {@code * options::apply} bound as a {@code Consumer}). That keeps this one implementation * backing both Actor and task calls (instead of each duplicating the same ~90 lines) without * forcing any of the options builders' internal plumbing — including the internal {@link diff --git a/src/main/java/com/apify/client/requestqueue/BatchAddResult.java b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java index 98eafd4..92e222f 100644 --- a/src/main/java/com/apify/client/requestqueue/BatchAddResult.java +++ b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java @@ -13,18 +13,29 @@ public final class BatchAddResult { /** The requests the API successfully added (unmodifiable). */ public List getProcessedRequests() { - return Collections.unmodifiableList(processedRequests); + return Collections.unmodifiableList(processedRequests == null ? List.of() : processedRequests); } /** The requests the API did not process (unmodifiable). */ public List getUnprocessedRequests() { - return Collections.unmodifiableList(unprocessedRequests); + return Collections.unmodifiableList( + unprocessedRequests == null ? List.of() : unprocessedRequests); } - /** Appends another result's requests into this one (used to merge per-chunk batch results). */ + /** + * Appends another result's requests into this one (used to merge per-chunk batch results). + * Tolerates {@code other}'s lists being {@code null} (Jackson assigns the field directly on an + * explicit {@code "processedRequests": null}/{@code "unprocessedRequests": null} response, + * bypassing the constructor's empty-list default), mirroring {@link #getProcessedRequests()}/ + * {@link #getUnprocessedRequests()}'s own null-coalescing. + */ void merge(BatchAddResult other) { - this.processedRequests.addAll(other.processedRequests); - this.unprocessedRequests.addAll(other.unprocessedRequests); + if (other.processedRequests != null) { + this.processedRequests.addAll(other.processedRequests); + } + if (other.unprocessedRequests != null) { + this.unprocessedRequests.addAll(other.unprocessedRequests); + } } void setProcessedRequests(List processedRequests) { diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index 6e9a1e3..b3c68b7 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -40,6 +40,7 @@ public String getId() { return id; } + /** Sets the unique request ID. */ public RequestQueueRequest setId(String id) { this.id = id; return this; @@ -50,6 +51,7 @@ public String getUrl() { return url; } + /** Sets the request URL. */ public RequestQueueRequest setUrl(String url) { this.url = url; return this; @@ -78,6 +80,7 @@ public String getMethod() { return method; } + /** Sets the HTTP method. */ public RequestQueueRequest setMethod(String method) { this.method = method; return this; @@ -104,6 +107,7 @@ public String getPayload() { return payload; } + /** Sets the HTTP request body. */ public RequestQueueRequest setPayload(String payload) { this.payload = payload; return this; @@ -136,6 +140,7 @@ public Instant getHandledAt() { return handledAt; } + /** Sets when the request was marked as handled. */ public RequestQueueRequest setHandledAt(Instant handledAt) { this.handledAt = handledAt; return this; @@ -146,6 +151,7 @@ public Integer getRetryCount() { return retryCount; } + /** Sets the number of times processing this request has already been retried. */ public RequestQueueRequest setRetryCount(Integer retryCount) { this.retryCount = retryCount; return this; @@ -156,6 +162,7 @@ public String getLoadedUrl() { return loadedUrl; } + /** Sets the URL actually loaded, after following redirects. */ public RequestQueueRequest setLoadedUrl(String loadedUrl) { this.loadedUrl = loadedUrl; return this; diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index 82485f6..e043cb8 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -190,7 +190,10 @@ private String generateIdempotencyKey(String eventName) { /** * Polls until the run reaches a terminal state or {@code waitSecs} elapses ({@code null} waits - * indefinitely). Returns the latest run. + * indefinitely). Returns the latest run fetched, whether or not it is terminal: if the wait + * budget runs out first, this returns the still-running run as of the last poll rather than + * throwing or blocking further — check {@link ActorRun#isTerminal()} on the result if the + * distinction matters to the caller. */ public ActorRun waitForFinish(Long waitSecs) { return ctx.waitForFinish(waitSecs, "run", Json.type(ActorRun.class), ActorRun::isTerminal); diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index a471ca1..31ac8bc 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -202,7 +202,56 @@ void streamedLogRedirection() { } } } - assertTrue(!collected.isEmpty(), "expected redirected log messages"); + + // The catch-up window above closes an eventual-consistency race, but not a genuine one: the + // *first* stream was opened before the run finished, following the live log as the run was + // still writing to it, so the underlying HTTP stream can reach EOF (the container/log-follow + // connection closing) a hair before the final bytes are flushed to that same connection -- no + // amount of client-side waiting after that EOF recovers bytes that were never delivered on it. + // Ask the authoritative source - the run's persisted log via the plain (non-streaming) + // log().get() call - whether the run produced any log output at all, and if the first stream + // still came up empty despite that, retry once with a brand-new stream opened strictly after + // the run is already finished: that is no longer a live tail, just a GET against a static, + // fully-persisted log, so it cannot race the run's own writer the way the first stream could. + Optional authoritativeLog = runClient.log().get(); + boolean runProducedLog = authoritativeLog.isPresent() && !authoritativeLog.get().isEmpty(); + if (runProducedLog && collected.isEmpty()) { + collected.addAll(collectFinishedRunLog(runClient)); + } + if (runProducedLog) { + assertTrue( + !collected.isEmpty(), + "run produced a non-empty log (" + + authoritativeLog.get().length() + + " chars) but the" + + " streamed collector observed none - redirection did not work"); + } + } + + /** + * Opens a fresh {@link StreamedLog} against an already-finished run's static log (with {@link + * StreamedLogOptions#fromStart(boolean)} so the run's already-past-relative-to-construction + * messages are not filtered out) and waits up to {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} for it + * to deliver the (now non-racing) content, so {@link #streamedLogRedirection} can retry once + * after the first, live-tail stream comes up empty despite the run having produced a log. + */ + private static java.util.List collectFinishedRunLog(RunClient runClient) { + java.util.List retryCollected = new java.util.concurrent.CopyOnWriteArrayList<>(); + try (StreamedLog retryStream = + runClient.getStreamedLog( + new StreamedLogOptions().toLog(retryCollected::add).fromStart(true))) { + retryStream.start(); + long deadline = System.currentTimeMillis() + STREAM_CATCH_UP_TIMEOUT_MILLIS; + while (retryCollected.isEmpty() && System.currentTimeMillis() < deadline) { + try { + Thread.sleep(STREAM_CATCH_UP_POLL_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + return retryCollected; } /** Bounded window given to {@link #streamedLogRedirection} for the log to catch up. */ From d74cd92b2accdd61766a7e2a2cbf79845f3df461 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:34:28 +0000 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20loop-6=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20transport-failure=20never-throws,=20shared-Actor=20?= =?UTF-8?q?race,=20DTO=20null-guards,=20RunClient.metamorph=20validation,?= =?UTF-8?q?=20package=20move,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses every item from the loop-6 /review-client verdict: RequestQueueClient.batchAddChunkWithRetries now catches ApifyClientException (not just ApifyApiException) so transport failures also stay within the never-throws contract; ActorRunIntegrationTest#lastRunAccess no longer asserts run identity against the shared apify/hello-world Actor (only presence/status/origin); added last-run- and run-scoped nested storage GET coverage; null-coalesced every flagged list-returning DTO getter; moved ValidateInputOptions to com.apify.client.actor next to its only consumer; RunClient.metamorph validates targetActorId; various docs completeness, comment, and DRY nits. See notes.md "Implementer — loop 7" in apify-client-orchestration for the itemized list and test evidence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- CHANGELOG.md | 30 ++++++ docs/README.md | 4 +- docs/actors.md | 4 - docs/misc.md | 4 +- docs/runs.md | 10 +- docs/storages.md | 5 + docs/tasks.md | 5 + docs/webhooks.md | 4 +- spotbugs-exclude.xml | 10 ++ .../com/apify/client/actor/ActorClient.java | 1 - .../{task => actor}/ValidateInputOptions.java | 7 +- .../apify/client/dataset/DatasetClient.java | 2 +- .../com/apify/client/http/ApiResponse.java | 9 +- .../client/internal/ResourceContext.java | 11 +++ .../com/apify/client/internal/Signatures.java | 3 +- .../client/keyvalue/KeyValueStoreClient.java | 2 +- .../keyvalue/KeyValueStoreKeysPage.java | 7 +- .../java/com/apify/client/log/LogClient.java | 2 +- .../requestqueue/LockedRequestQueueHead.java | 7 +- .../requestqueue/RequestQueueClient.java | 32 ++++--- .../client/requestqueue/RequestQueueHead.java | 7 +- .../client/requestqueue/RequestsList.java | 7 +- .../java/com/apify/client/run/RunClient.java | 3 + .../com/apify/client/schedule/Schedule.java | 8 +- .../com/apify/client/webhook/Webhook.java | 7 +- .../client/ClientBehaviourRegressionTest.java | 62 ++++++++++++- .../integration/ActorRunIntegrationTest.java | 93 ++++++++++++++----- .../client/integration/IntegrationBase.java | 35 +++++++ .../integration/IterationIntegrationTest.java | 28 ++---- .../integration/ScheduleIntegrationTest.java | 2 + .../integration/TaskIntegrationTest.java | 20 +++- 31 files changed, 339 insertions(+), 92 deletions(-) rename src/main/java/com/apify/client/{task => actor}/ValidateInputOptions.java (85%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e015e..2e1c695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Tightened `spotbugs-exclude.xml` to only the entries that currently reproduce a real finding. - `java-integration-tests.yml` now declares an explicit, least-privilege `permissions: contents: read` block. +- **Breaking:** moved `ValidateInputOptions` from `com.apify.client.task` to + `com.apify.client.actor` (its only consumer is `ActorClient.validateInput`); its internal + `apply`/`contentTypeOrDefault` are package-private again now that it's same-package with that + consumer. ### Added @@ -108,6 +112,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). JavaBeans convention for a boxed `Boolean` accessor (`is`-prefixed getters are for primitive `boolean`). The underlying field name, `shouldInterpolateStrings`, is unchanged, so this has no effect on the JSON wire format. +- `AbstractCollectionClient.list(options)` no longer throws `NullPointerException` on a `null` + `options` argument (now defaults it, matching `iterate(options)`'s existing tolerance). +- `BatchAddResult.getProcessedRequests()`/`getUnprocessedRequests()` no longer throw + `NullPointerException` on an explicit JSON `null` for either field. +- `RequestQueueClient.batchAddChunkWithRetries` now also catches `ApifyTransportException` (not + just `ApifyApiException`), so a persistent transport failure (connection error, timeout) is + reported via `BatchAddResult.getUnprocessedRequests()` instead of escaping as an exception, + matching `batchAddRequests`' documented never-throws contract. +- `RunClient.metamorph` now rejects a `null`/empty `targetActorId` with + `IllegalArgumentException`, matching `charge`'s `eventName` validation. +- `RequestsList.getItems()`, `RequestQueueHead.getItems()`, `LockedRequestQueueHead.getItems()`, + `KeyValueStoreKeysPage.getItems()`, `Schedule.getActions()` and `Webhook.getEventTypes()` no + longer throw `NullPointerException` when the API response contains an explicit `null` for that + field. +- `RequestQueueClient`'s chunk-by-byte-size slicing now accounts for the inter-element commas in + its incremental size estimate, matching the exact full-array measurement. ### Documentation @@ -137,6 +157,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `RequestQueueClient.batchAddRequests`/`RequestQueueRequest.getUniqueKey()`: retry reconciliation matches by `uniqueKey`, so a request that omits it can be falsely reported unprocessed after retries even though it succeeded server-side. +- Removed a stale `docs/actors.md` cross-package import note for `ValidateInputOptions`, now moot + since it moved to `com.apify.client.actor`; updated `docs/README.md`'s package table to match. + Documented `ActorStandby`'s fields in `docs/tasks.md`, added the standard `getExtra()` note to + `WebhookDispatch` (`docs/webhooks.md`) and `ActorStoreListItem` (`docs/misc.md`), added a + `ListKeysOptions` field list to `docs/storages.md`, and made `ActorRunStats`'s field list in + `docs/runs.md` exhaustive while noting it has no `getExtra()` fallback. Reworded `ApiResponse`'s + javadoc (it is a plain public data carrier used across resource clients, not internal plumbing) + and corrected `spotbugs-exclude.xml`'s header to accurately explain why its former `ApiResponse` + EI_EXPOSE exclusion stopped reproducing (the no-copy contract never changed; the tool simply + stopped flagging it) rather than implying the exposure was defensively fixed. ## [0.3.1] - 2026-07-14 diff --git a/docs/README.md b/docs/README.md index db7b419..be24454 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,13 +37,13 @@ option types, so import each resource you use from its own package: |---|---| | `com.apify.client` (root) | `ApifyClient`, `ApifyClientBuilder`, `Version`, `PaginationList`, `ListOptions`, `StorageListOptions`, `ApifyResource` | | `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException` | -| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient` | +| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient`, `ValidateInputOptions` | | `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient` | | `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | | `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions`, `DownloadItemsFormat` | | `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreCollectionClient`, `KeyValueStoreRecord`, `KeyValueStoreKeysPage`, `KeyValueStoreKey`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | | `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | -| `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions`, `ValidateInputOptions` | +| `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions` | | `com.apify.client.schedule` | `Schedule`, `ScheduleNotifications`, `ScheduleClient`, `ScheduleCollectionClient` | | `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookLastDispatch`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient` | | `com.apify.client.user` | `User`, `UserClient` | diff --git a/docs/actors.md b/docs/actors.md index f681dc5..c0570b7 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -75,10 +75,6 @@ server-side while the run finishes, which is redundant with (and wastes a reques reference client's `log: null`) and `logOptions(StreamedLogOptions)` (a custom destination/prefix, matching a custom `Log` instance) — see [Streamed log redirection](runs.md#streamed-log-redirection). -`ValidateInputOptions` is a `com.apify.client.task` type (see the package table in -[`README.md`](README.md)), even though `validateInput` is an `ActorClient` method — import it from -`com.apify.client.task`, not `com.apify.client.actor`. - `lastRun(String status)` filters only by status; `lastRun(LastRunOptions)` also accepts an origin filter. `LastRunOptions` has fluent setters `status(String)` (e.g. `SUCCEEDED`, `RUNNING`) and `origin(String)` (e.g. `API`, `WEB`, `SCHEDULER`); leave a setter uncalled to omit that filter. diff --git a/docs/misc.md b/docs/misc.md index f4a3074..5b215ff 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -27,7 +27,9 @@ while (shown < 5 && it.hasNext()) { } ``` -`ActorStoreListItem` fields: `getId()`, `getName()`, `getUsername()`, `getTitle()`. +`ActorStoreListItem` fields: `getId()`, `getName()`, `getUsername()`, `getTitle()`. Any field not +covered by a typed getter is still available via the inherited `getExtra()` (see +[the docs index](README.md#model-fields-and-unmodeled-data-getextra)). ## Users — `client.me()` / `client.user(id)` diff --git a/docs/runs.md b/docs/runs.md index fc86590..374a06d 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -111,8 +111,14 @@ pay-per-event Actors, `null` otherwise), `getPricingInfo()` (`JsonNode`; shape d Actor's pricing model), `getUsage()` / `getUsageUsd()` (`ActorRunUsage`; per-billable-unit resource consumption, the latter as its USD cost — see below), `getUsageTotalUsd()` (`Double`; the run's total cost in USD, combining platform usage and/or event costs depending on pricing model), -`getStats()` (`ActorRunStats`; runtime metrics like `getDurationMillis()`, `getMemAvgBytes()`, -`getCpuAvgUsage()`), `getOptions()` (`ActorRunOptions`; the run configuration actually applied, +`getStats()` (`ActorRunStats`; runtime metrics — `getInputBodyLen()`, `getRestartCount()`, +`getResurrectCount()`, `getMemAvgBytes()`/`getMemMaxBytes()`/`getMemCurrentBytes()`, +`getCpuAvgUsage()`/`getCpuMaxUsage()`/`getCpuCurrentUsage()`, `getNetRxBytes()`/`getNetTxBytes()`, +`getDurationMillis()`, `getRunTimeSecs()`, `getMetamorph()`, `getComputeUnits()`; this list is +exhaustive for the currently modelled fields, but `ActorRunStats` does not extend the common +`ApifyResource` base and so has no `getExtra()` - an API field added later without a matching +getter added here is silently dropped rather than surfaced), `getOptions()` (`ActorRunOptions`; the +run configuration actually applied, which may differ from what was requested — see below), `getMeta()` (`ActorRunMeta`; `getOrigin()`, `getClientIp()`, `getUserAgent()` — how the run was initiated), `getBuildNumber()` (`String`; the build's semver-like number, e.g. `"0.0.36"`), `getExitCode()` (`Integer`; the process exit code once diff --git a/docs/storages.md b/docs/storages.md index b343029..f7266f9 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -118,6 +118,11 @@ datasets. | `createKeysPublicUrl(Long expiresInSecs)` | A public (optionally signed) key-list URL. | | `createKeysPublicUrl(ListKeysOptions, Long expiresInSecs)` | As above, forwarding key-listing filters (`limit`, `prefix`, `collection`, `exclusiveStartKey`). | +`ListKeysOptions` fields (all optional): `limit(Long)`, `exclusiveStartKey(String)`, +`prefix(String)` (restrict to keys with this prefix), `collection(String)` (a named collection of +keys), `signature(String)` (a pre-shared URL signature granting access without an API token, used +by `createKeysPublicUrl`). + ```java KeyValueStore store = client.keyValueStores().getOrCreate("my-store"); client.keyValueStore(store.getId()).setRecordJson("OUTPUT", Map.of("answer", 42)); diff --git a/docs/tasks.md b/docs/tasks.md index 8a082f4..9ab416f 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -57,3 +57,8 @@ prefer `TaskClient.getInput()` above to fetch it fresh on-demand), and `getActor (`ActorStandby`, standby-mode configuration overrides for this task, if any). Any field not covered by a typed getter is still available via the inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). + +`ActorStandby` fields (all optional; `null` when unset): `getBuild()` (tag/number of the build +serving standby requests), `getDesiredRequestsPerActorRun()`, `getDisableStandbyFieldsOverride()`, +`getIdleTimeoutSecs()`, `getMaxRequestsPerActorRun()`, `getMemoryMbytes()`, +`getShouldPassActorInput()`. diff --git a/docs/webhooks.md b/docs/webhooks.md index ce01ba6..3ef625d 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -71,4 +71,6 @@ inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodele | `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()`. +`WebhookDispatch` fields: `getId()`, `getWebhookId()`. Any field not covered by a typed getter is +still available via the inherited `getExtra()` (see +[the docs index](README.md#model-fields-and-unmodeled-data-getextra)). diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index d2d8b9a..4b8a74f 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -21,6 +21,16 @@ TaskOptions}, {@code WebhookLastDispatch}, the internal error-response mapping in {@code HttpClientCore}): their fields are written by the deserializer via reflection, which SpotBugs cannot see - a known false positive for data-binding models. + + Historical note: an EI_EXPOSE_REP/EI_EXPOSE_REP2 exclusion for {@code ApiResponse} (its {@code + body} field is the raw response bytes, intentionally stored/returned without a defensive copy - + the payload can be large, e.g. dataset exports, and copying it on every access would be wasteful) + was carried here through loop 3 and dropped in loop 4 once it stopped reproducing. To be precise + about why, since "no longer reproduces" could otherwise be misread as "the exposure was fixed": + the no-copy contract itself never changed; SpotBugs simply stopped flagging that pattern on this + class. {@code ApiResponse} is still a plain public-field carrier with no copying anywhere in its + constructor or fields - the finding's disappearance reflects the tool's static-analysis coverage + of the pattern, not a code change that defensively fixed anything. --> diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index ecbbfb9..9bc2126 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -14,7 +14,6 @@ import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; import com.apify.client.run.RunCollectionClient; -import com.apify.client.task.ValidateInputOptions; import com.apify.client.webhook.NestedWebhookCollectionClient; import com.fasterxml.jackson.databind.JsonNode; import java.util.Optional; diff --git a/src/main/java/com/apify/client/task/ValidateInputOptions.java b/src/main/java/com/apify/client/actor/ValidateInputOptions.java similarity index 85% rename from src/main/java/com/apify/client/task/ValidateInputOptions.java rename to src/main/java/com/apify/client/actor/ValidateInputOptions.java index 9926b51..54af781 100644 --- a/src/main/java/com/apify/client/task/ValidateInputOptions.java +++ b/src/main/java/com/apify/client/actor/ValidateInputOptions.java @@ -1,6 +1,5 @@ -package com.apify.client.task; +package com.apify.client.actor; -import com.apify.client.actor.ActorClient; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; @@ -24,13 +23,13 @@ public ValidateInputOptions contentType(String contentType) { return this; } - public String contentTypeOrDefault() { + String contentTypeOrDefault() { return (contentType != null && !contentType.isEmpty()) ? contentType : ResourceContext.CONTENT_TYPE_JSON; } - public void apply(QueryParams q) { + void apply(QueryParams q) { q.addString("build", build); } } diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index e74178e..a2536de 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -61,7 +61,7 @@ public static DatasetClient nested(HttpClientCore http, String base, String subP public static DatasetClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new DatasetClient( - http, ResourceContext.collection(http, base, subPath).seedParams(inherited)); + http, ResourceContext.nestedCollection(http, base, subPath, inherited)); } /** Fetches the dataset metadata, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/http/ApiResponse.java b/src/main/java/com/apify/client/http/ApiResponse.java index 7cdc706..8b98c7c 100644 --- a/src/main/java/com/apify/client/http/ApiResponse.java +++ b/src/main/java/com/apify/client/http/ApiResponse.java @@ -4,7 +4,14 @@ /** * The parsed result of a single API call: the status code, headers and the fully-buffered response - * body. Internal to the client. + * body. + * + *

    This is a plain, public data carrier - it is legitimately exported, not internal plumbing: + * {@code com.apify.client.internal}'s HTTP core builds it as the result of every call it makes, and + * it is used as the local unwrapped-response type across the resource clients in this module. The + * fields are exposed raw (no defensive copy of {@code body}) by design; see the {@code + * spotbugs-exclude.xml} header for why that is a deliberate no-copy contract rather than an + * oversight. */ public final class ApiResponse { public final int statusCode; diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index e23841c..f00c2dc 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -98,6 +98,17 @@ public static ResourceContext collection( return new ResourceContext(http, baseUrl + "/" + resourcePath, baseUrl); } + /** + * {@link #collection} plus {@link #seedParams}, in one call. Every resource client's {@code + * nested(...)} factory (dataset/key-value-store/request-queue/log, each reached at a run's or + * task's {@code .../{resourcePath}} path with no ID of its own) builds its context this same way + * - sharing it here keeps that one line in exactly one place instead of duplicated four times. + */ + public static ResourceContext nestedCollection( + HttpClientCore http, String baseUrl, String resourcePath, QueryParams inherited) { + return collection(http, baseUrl, resourcePath).seedParams(inherited); + } + /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ public static ResourceContext single( HttpClientCore http, String baseUrl, String resourcePath, String id) { diff --git a/src/main/java/com/apify/client/internal/Signatures.java b/src/main/java/com/apify/client/internal/Signatures.java index 2d294fc..3d4d25d 100644 --- a/src/main/java/com/apify/client/internal/Signatures.java +++ b/src/main/java/com/apify/client/internal/Signatures.java @@ -2,6 +2,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.util.Base64; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -86,7 +87,7 @@ private static byte[] hmacSha256(String secretKey, String message) { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); - } catch (java.security.GeneralSecurityException e) { + } catch (GeneralSecurityException e) { throw new IllegalStateException("HMAC-SHA256 is unavailable", e); } } diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index ab8c4bf..78731f4 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -47,7 +47,7 @@ public static KeyValueStoreClient nested(HttpClientCore http, String base, Strin public static KeyValueStoreClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new KeyValueStoreClient( - ResourceContext.collection(http, base, subPath).seedParams(inherited)); + ResourceContext.nestedCollection(http, base, subPath, inherited)); } /** Fetches the store metadata, or empty if it does not exist. */ diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java index 7ed9ab1..702654b 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java @@ -33,8 +33,11 @@ public String getNextExclusiveStartKey() { return nextExclusiveStartKey; } - /** The listed keys. */ + /** The listed keys (never {@code null}; unmodifiable). */ public List getItems() { - return Collections.unmodifiableList(items); + // Null-coalesce: Jackson binds directly to the (private) `items` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"items": null`. + return items == null ? List.of() : Collections.unmodifiableList(items); } } diff --git a/src/main/java/com/apify/client/log/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java index 7ceb812..b23967e 100644 --- a/src/main/java/com/apify/client/log/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -33,7 +33,7 @@ public static LogClient nested(HttpClientCore http, String base) { /** As {@link #nested(HttpClientCore, String)} but inheriting parent query params. */ public static LogClient nested(HttpClientCore http, String base, QueryParams inherited) { - return new LogClient(ResourceContext.collection(http, base, "log").seedParams(inherited)); + return new LogClient(ResourceContext.nestedCollection(http, base, "log", inherited)); } /** Fetches the entire log as text, or empty if the log does not exist. */ diff --git a/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java b/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java index 782b171..c4b2100 100644 --- a/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java +++ b/src/main/java/com/apify/client/requestqueue/LockedRequestQueueHead.java @@ -49,8 +49,11 @@ public long getLockSecs() { return lockSecs; } - /** The locked requests at the head of the queue. */ + /** The locked requests at the head of the queue (never {@code null}; unmodifiable). */ public List getItems() { - return Collections.unmodifiableList(items); + // Null-coalesce: Jackson binds directly to the (private) `items` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"items": null`. + return items == null ? List.of() : Collections.unmodifiableList(items); } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index b06381d..5d0b9d3 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -1,6 +1,7 @@ package com.apify.client.requestqueue; import com.apify.client.http.ApifyApiException; +import com.apify.client.http.ApifyClientException; import com.apify.client.http.ApifyTransportException; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; @@ -74,7 +75,7 @@ public static RequestQueueClient nested(HttpClientCore http, String base, String public static RequestQueueClient nested( HttpClientCore http, String base, String subPath, QueryParams inherited) { return new RequestQueueClient( - http, ResourceContext.collection(http, base, subPath).seedParams(inherited), null); + http, ResourceContext.nestedCollection(http, base, subPath, inherited), null); } /** @@ -291,10 +292,15 @@ private static List sliceByByteLength( + maxByteLength + " bytes)"); } - if (byteLength + itemBytes >= maxByteLength) { + // Every element but the first is preceded by a comma in the serialized array (`[a,b,c]` has + // count-1 commas); account for it here so this incremental estimate agrees exactly with + // jsonByteLength(requests)'s full-array measurement above, rather than relying on being + // slightly under it. + long commaBytes = sliced.isEmpty() ? 0 : 1; + if (byteLength + commaBytes + itemBytes >= maxByteLength) { break; } - byteLength += itemBytes; + byteLength += commaBytes + itemBytes; sliced.add(request); } @@ -328,10 +334,12 @@ private static BatchAddResult awaitResult(Future future) { /** * Adds one chunk (already sized to the API limit), retrying requests the API leaves unprocessed - * with exponential backoff. Never throws for an API error, matching the reference client's {@code - * _batchAddRequestsWithRetries}: on any failure (including a non-retryable 4xx) the remaining - * requests in the chunk are simply returned as unprocessed rather than surfaced as an exception, - * so the method keeps a single, uniform never-throws contract regardless of the failure cause. + * with exponential backoff. Never throws regardless of the failure cause, matching the reference + * client's {@code _batchAddRequestsWithRetries}: on any failure — a non-retryable 4xx/5xx API + * response, or a transport-level failure (connection error, timeout) that never produced a + * response at all — the remaining requests in the chunk are simply returned as unprocessed rather + * than surfaced as an exception, so the method keeps a single, uniform never-throws contract + * across both {@link ApifyApiException} and {@link ApifyTransportException}. */ private BatchAddResult batchAddChunkWithRetries( List chunk, boolean forefront, BatchAddRequestsOptions options) { @@ -349,10 +357,12 @@ private BatchAddResult batchAddChunkWithRetries( if (remaining.isEmpty()) { break; } - } catch (ApifyApiException ignored) { - // Any API error (rate-limit, server error, or a hard client error such as a bad token) - // stops retrying this chunk immediately; whatever has not been confirmed processed is - // reported as unprocessed below, never thrown — matching the reference client's contract. + } catch (ApifyClientException ignored) { + // Any client-level failure — an API error response (rate-limit, server error, or a hard + // client error such as a bad token) or a transport failure that never reached the API at + // all (timeout, connection reset) — stops retrying this chunk immediately; whatever has + // not been confirmed processed is reported as unprocessed below, never thrown — matching + // the reference client's contract. break; } if (attempt < maxRetries) { diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java index caa6d12..8d44005 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueHead.java @@ -27,8 +27,11 @@ public boolean isHadMultipleClients() { return hadMultipleClients; } - /** The requests at the head of the queue. */ + /** The requests at the head of the queue (never {@code null}; unmodifiable). */ public List getItems() { - return Collections.unmodifiableList(items); + // Null-coalesce: Jackson binds directly to the (private) `items` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"items": null`. + return items == null ? List.of() : Collections.unmodifiableList(items); } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestsList.java b/src/main/java/com/apify/client/requestqueue/RequestsList.java index 4e3409f..502d8e9 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestsList.java +++ b/src/main/java/com/apify/client/requestqueue/RequestsList.java @@ -21,9 +21,12 @@ public final class RequestsList { private String cursor; private String nextCursor; - /** The requests in this page (unmodifiable). */ + /** The requests in this page (never {@code null}; unmodifiable). */ public List getItems() { - return Collections.unmodifiableList(items); + // Null-coalesce: Jackson binds directly to the (private) `items` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"items": null`. + return items == null ? List.of() : Collections.unmodifiableList(items); } /** The maximum number of requests requested for this page. */ diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index e043cb8..b968633 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -118,6 +118,9 @@ public ActorRun abort(Boolean gracefully) { * Actor to metamorph into; {@code input} is the new input ({@code null} for none). */ public ActorRun metamorph(String targetActorId, Object input, MetamorphOptions options) { + if (targetActorId == null || targetActorId.isEmpty()) { + throw new IllegalArgumentException("targetActorId is required and must not be empty"); + } QueryParams params = new QueryParams(); params.addString("targetActorId", targetActorId); if (options.buildValue() != null && !options.buildValue().isEmpty()) { diff --git a/src/main/java/com/apify/client/schedule/Schedule.java b/src/main/java/com/apify/client/schedule/Schedule.java index 59270c1..f2856ef 100644 --- a/src/main/java/com/apify/client/schedule/Schedule.java +++ b/src/main/java/com/apify/client/schedule/Schedule.java @@ -93,10 +93,14 @@ public Instant getLastRunAt() { /** * The Actor/task-run actions this schedule triggers, as raw JSON (each action's shape depends on - * its {@code type}, {@code RUN_ACTOR} or {@code RUN_ACTOR_TASK}; unmodifiable). + * its {@code type}, {@code RUN_ACTOR} or {@code RUN_ACTOR_TASK}; never {@code null}; + * unmodifiable). */ public List getActions() { - return Collections.unmodifiableList(actions); + // Null-coalesce: Jackson binds directly to the (private) `actions` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"actions": null`. + return actions == null ? List.of() : Collections.unmodifiableList(actions); } /** Notification settings for this schedule. */ diff --git a/src/main/java/com/apify/client/webhook/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java index 42b51d7..3a9807a 100644 --- a/src/main/java/com/apify/client/webhook/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -62,9 +62,12 @@ public Boolean getShouldInterpolateStrings() { return shouldInterpolateStrings; } - /** The events that trigger the webhook. */ + /** The events that trigger the webhook (never {@code null}; unmodifiable). */ public List getEventTypes() { - return Collections.unmodifiableList(eventTypes); + // Null-coalesce: Jackson binds directly to the (private) `eventTypes` field for + // deserialization, which bypasses the `= List.of()` field initializer whenever the API + // response contains an explicit `"eventTypes": null`. + return eventTypes == null ? List.of() : Collections.unmodifiableList(eventTypes); } /** diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 0af4f35..2d6a088 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -22,6 +22,7 @@ import com.apify.client.requestqueue.RequestQueueRequest; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; +import com.apify.client.run.MetamorphOptions; import com.apify.client.run.RunChargeOptions; import com.apify.client.run.SetStatusMessageOptions; import com.apify.client.store.ActorStoreListItem; @@ -144,7 +145,7 @@ void metamorphSendsTargetActorIdBuildAndInputBody() { .metamorph( "apify/other-actor", java.util.Map.of("foo", "bar"), - new com.apify.client.run.MetamorphOptions().build("1.2.3")); + new MetamorphOptions().build("1.2.3")); assertTrue(backend.lastUrl.contains("actor-runs/run123/metamorph"), backend.lastUrl); assertTrue(backend.lastUrl.contains("targetActorId=apify%2Fother-actor"), backend.lastUrl); assertTrue(backend.lastUrl.contains("build=1.2.3"), backend.lastUrl); @@ -152,6 +153,20 @@ void metamorphSendsTargetActorIdBuildAndInputBody() { assertEquals("POST", backend.lastMethod); } + @Test + void metamorphRejectsMissingTargetActorId() { + // Mirrors chargeRejectsMissingEventName's validation of RunChargeOptions.eventName. + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); + ApifyClient client = client(backend); + assertThrows( + IllegalArgumentException.class, + () -> client.run("run123").metamorph(null, null, new MetamorphOptions())); + assertThrows( + IllegalArgumentException.class, + () -> client.run("run123").metamorph("", null, new MetamorphOptions())); + assertEquals(0, backend.calls, "no request should be sent for an invalid metamorph"); + } + @Test void rebootSendsPostToRebootWithNoBody() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); @@ -557,6 +572,51 @@ void batchAddRequestsRunsChunksInParallel() { assertEquals(3, backend.calls, "60 requests must be sent as 3 parallel chunks of 25/25/10"); } + @Test + void batchAddRequestsNeverThrowsOnPersistentTransportFailure() { + // Sibling of `batchAddRequestsNeverThrowsOnNonRetryableClientError`: a transport-level failure + // (ApifyTransportException, e.g. connection refused/timeout — the request never reached the + // API at all) must be treated the same as an API-level failure (ApifyApiException). Both are + // ApifyClientException subtypes, and batchAddRequests' never-throws contract must hold across + // both, matching the reference client's `_batchAddRequestsWithRetries`. Sequential path + // (single chunk, default maxParallel). + MockTransport backend = new MockTransport(List.of(MockTransport.networkError())); + RequestQueueRequest request = new RequestQueueRequest("https://example.com", "k0"); + BatchAddResult result = + client(backend) + .requestQueue("q1") + .batchAddRequests( + List.of(request), + false, + new BatchAddRequestsOptions().maxUnprocessedRequestsRetries(0)); + assertEquals(0, result.getProcessedRequests().size()); + assertEquals(1, result.getUnprocessedRequests().size()); + assertEquals("k0", result.getUnprocessedRequests().get(0).getUniqueKey()); + } + + @Test + void batchAddRequestsNeverThrowsOnPersistentTransportFailureWithParallelChunks() { + // Same as above but exercising the `maxParallel > 1` path, where each chunk fails on its own + // executor thread — the never-throws contract must hold there too, and every request across + // every chunk must come back via getUnprocessedRequests(). + MockTransport backend = new MockTransport(List.of(MockTransport.networkError())); + List requests = new ArrayList<>(); + for (int i = 0; i < 60; i++) { + requests.add(new RequestQueueRequest("https://example.com", "k" + i)); + } + BatchAddResult result = + client(backend) + .requestQueue("q1") + .batchAddRequests( + requests, + false, + new BatchAddRequestsOptions().maxParallel(3).maxUnprocessedRequestsRetries(0)); + assertEquals(0, result.getProcessedRequests().size()); + assertEquals(60, result.getUnprocessedRequests().size()); + assertEquals( + 3, backend.calls, "60 requests must be sent as 3 parallel chunks, each failing once"); + } + @Test void waitForFinishReturnsWhenResourceAppearsAfter404() { // A just-started run can transiently 404 (replica lag); the wait must keep polling until it diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 31ac8bc..d8913e5 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -9,8 +9,10 @@ import com.apify.client.actor.ActorClient; import com.apify.client.actor.ActorStartOptions; import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.keyvalue.ListKeysOptions; import com.apify.client.log.StreamedLog; import com.apify.client.log.StreamedLogOptions; +import com.apify.client.requestqueue.ListRequestsOptions; import com.apify.client.run.ActorRun; import com.apify.client.run.LastRunOptions; import com.apify.client.run.RunClient; @@ -19,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; class ActorRunIntegrationTest extends IntegrationBase { @@ -45,7 +48,8 @@ void listRuns() { @Test void runActorAndReadOutputs() { ApifyClient client = requireClient(); - ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + ActorRun run = + client.actor("apify/hello-world").call(null, new ActorStartOptions(), TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); assertTrue(client.run(run.getId()).get().isPresent()); @@ -64,6 +68,17 @@ void runActorAndReadOutputs() { assertTrue(client.run(run.getId()).keyValueStore().get().isPresent()); assertTrue(client.run(run.getId()).requestQueue().get().isPresent()); + // A few more run-scoped GETs not otherwise exercised on this particular (run-nested) storage + // path; the same operations are already covered end-to-end against standalone storages + // elsewhere (DatasetIntegrationTest/KeyValueStoreIntegrationTest/RequestQueueIntegrationTest), + // this only confirms they also work when reached through `run().()`. + client.run(run.getId()).dataset().getStatistics(); + client.run(run.getId()).keyValueStore().listKeys(new ListKeysOptions()); + client.run(run.getId()).requestQueue().listRequests(new ListRequestsOptions()); + // hello-world's default request queue is empty, so a made-up id is expected to resolve to + // nothing; this still exercises the live GET-by-id code path through the run-nested client. + assertTrue(client.run(run.getId()).requestQueue().getRequest("does-not-exist").isEmpty()); + // Typed getters (previously only reachable via getExtra()): verify the API's response // actually deserializes into them, not just that the code compiles. assertTrue(run.getGeneralAccess() != null); @@ -73,26 +88,37 @@ void runActorAndReadOutputs() { assertTrue(run.getUsage() != null); } + /** + * Attempts and backoff bounding how long {@link #actorRunsNestedCollection} waits for a + * just-started run to surface in the Actor's run collection listing. Same class of race as {@code + * IterationIntegrationTest#ITER_FIND_ATTEMPTS}; kept as its own constant since this suite's retry + * budget for a run to appear is independently tunable from that one's. + */ + private static final int RUN_LIST_FIND_ATTEMPTS = 10; + + private static final long RUN_LIST_FIND_BACKOFF_MILLIS = 1000L; + @Test - void actorRunsNestedCollection() throws InterruptedException { + void actorRunsNestedCollection() { ApifyClient client = requireClient(); ActorClient actor = client.actor("apify/hello-world"); - ActorRun run = actor.call(null, new ActorStartOptions(), 120L); + ActorRun run = actor.call(null, new ActorStartOptions(), TEST_ACTOR_WAIT_SECS); // Runs are sorted ascending by startedAt by default, and "apify/hello-world" is a heavily // used public Actor, so the just-started run would never surface within a small default-order // page; request newest-first. The LIST endpoint's index can also lag a just-finished run by a // moment (eventual consistency, same class of race as // IterationIntegrationTest#findsAllEventually), so poll with a bounded retry too. - boolean found = false; - for (int attempt = 0; !found && attempt < 10; attempt++) { - var page = actor.runs().list(new ListOptions().limit(5L).desc(true), new RunListOptions()); - assertTrue(page.getTotal() >= 0); - found = page.getItems().stream().anyMatch(r -> run.getId().equals(r.getId())); - if (!found) { - Thread.sleep(1000); - } - } + boolean found = + pollUntil( + RUN_LIST_FIND_ATTEMPTS, + RUN_LIST_FIND_BACKOFF_MILLIS, + () -> { + var page = + actor.runs().list(new ListOptions().limit(5L).desc(true), new RunListOptions()); + assertTrue(page.getTotal() >= 0); + return page.getItems().stream().anyMatch(r -> run.getId().equals(r.getId())); + }); assertTrue(found, "expected the just-started run to appear in the Actor's run collection"); var iterated = @@ -104,7 +130,7 @@ void actorRunsNestedCollection() throws InterruptedException { void callWithActorCallOptionsStreamsLogByDefault() { ApifyClient client = requireClient(); ActorClient actor = client.actor("apify/hello-world"); - List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + List collected = new CopyOnWriteArrayList<>(); // The log-streaming call() overload (ActorCallOptions), matching the reference client's // default call(options.log='default') behavior: the run's log is streamed for the duration of // the wait without any explicit opt-in. runActorAndReadOutputs above exercises the plain @@ -113,7 +139,7 @@ void callWithActorCallOptionsStreamsLogByDefault() { actor.call( null, new ActorCallOptions().logOptions(new StreamedLogOptions().toLog(collected::add)), - 120L); + TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); } @@ -122,10 +148,15 @@ void callWithActorCallOptionsStreamsLogByDefault() { void callWithActorCallOptionsCanDisableLogStreaming() { ApifyClient client = requireClient(); ActorClient actor = client.actor("apify/hello-world"); - ActorRun run = actor.call(null, new ActorCallOptions().disableLogStreaming(), 120L); + ActorRun run = + actor.call(null, new ActorCallOptions().disableLogStreaming(), TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); } + // Note: this CRUD-style flow deliberately omits a list() step. Unlike Actors/Tasks/Schedules, + // a run has no natural "list its own kind" collection scoped to itself - the only list() calls + // for runs are `runs().list()` (account-wide) and the Actor/task-nested `runs()` collection, + // both of which are already covered by `listRuns` and `actorRunsNestedCollection`. @Test void runAbortUpdateResurrectDelete() { ApifyClient client = requireClient(); @@ -155,14 +186,18 @@ void runAbortUpdateResurrectDelete() { @Test void lastRunAccess() { ApifyClient client = requireClient(); - ActorRun run = client.actor("apify/hello-world").call(null, new ActorStartOptions(), 120L); + ActorRun run = + client.actor("apify/hello-world").call(null, new ActorStartOptions(), TEST_ACTOR_WAIT_SECS); + assertEquals("SUCCEEDED", run.getStatus()); + // `apify/hello-world` is a shared public store Actor: under the account's concurrent-execution + // isolation contract, other runs of that same Actor (e.g. a sibling-language client's suite + // running at the same time against the same test user) can legitimately be "last" between the + // `call` above and the `lastRun` lookup below. Assert presence/status/origin, not identity + // with the run just started. var lastRun = client.actor("apify/hello-world").lastRun("SUCCEEDED").get(); assertTrue(lastRun.isPresent()); assertEquals("SUCCEEDED", lastRun.get().getStatus()); - // Assert identity, not just status: the "last" run resolved above must actually be the run - // just created above, not merely some other SUCCEEDED run on the account. - assertEquals(run.getId(), lastRun.get().getId()); var byOrigin = client @@ -171,7 +206,17 @@ void lastRunAccess() { .get(); assertTrue(byOrigin.isPresent()); assertEquals("SUCCEEDED", byOrigin.get().getStatus()); - assertEquals(run.getId(), byOrigin.get().getId()); + assertEquals("API", byOrigin.get().getMeta().getOrigin()); + + // Last-run-scoped nested storage GETs (previously untested — only `lastRun().get()` itself + // was): `actor(...).lastRun(...)` returns a RunClient, and its nested storage clients must be + // reachable the same way `run(id).()`'s are. Whichever run resolves as "last" (see + // the concurrency note above) is a hello-world run, so its default storages are guaranteed to + // exist. + RunClient lastRunClient = client.actor("apify/hello-world").lastRun("SUCCEEDED"); + lastRunClient.dataset().listItems(new DatasetListItemsOptions()); + lastRunClient.keyValueStore().getRecord("OUTPUT"); + assertTrue(lastRunClient.log().get().isPresent()); } @Test @@ -180,11 +225,11 @@ void streamedLogRedirection() { ActorRun run = client.actor("apify/hello-world").start(null, new ActorStartOptions()); RunClient runClient = client.run(run.getId()); - java.util.List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + List collected = new CopyOnWriteArrayList<>(); try (StreamedLog streamedLog = runClient.getStreamedLog(new StreamedLogOptions().toLog(collected::add))) { streamedLog.start(); - runClient.waitForFinish(120L); + runClient.waitForFinish(TEST_ACTOR_WAIT_SECS); // A fast Actor (hello-world routinely finishes in a couple of seconds) can complete before // the background reader has pulled any bytes off the live log stream yet, even though the // log content itself is already fully available server-side once the run is done. Rather @@ -235,8 +280,8 @@ void streamedLogRedirection() { * to deliver the (now non-racing) content, so {@link #streamedLogRedirection} can retry once * after the first, live-tail stream comes up empty despite the run having produced a log. */ - private static java.util.List collectFinishedRunLog(RunClient runClient) { - java.util.List retryCollected = new java.util.concurrent.CopyOnWriteArrayList<>(); + private static List collectFinishedRunLog(RunClient runClient) { + List retryCollected = new CopyOnWriteArrayList<>(); try (StreamedLog retryStream = runClient.getStreamedLog( new StreamedLogOptions().toLog(retryCollected::add).fromStart(true))) { diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index f655631..b08c973 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -4,6 +4,7 @@ import com.apify.client.ApifyClient; import java.security.SecureRandom; +import java.util.function.BooleanSupplier; /** * Shared setup for the integration test suite. @@ -20,6 +21,14 @@ abstract class IntegrationBase { /** The integration-test contract fallback base URL. */ static final String DEFAULT_API_URL = "https://api.apify.com/v2"; + /** + * The {@code waitSecs} budget used across this suite for a live {@code apify/hello-world} run (a + * "store Actor finishes in a couple of seconds" test fixture): generous enough to absorb + * queueing/build delays on the shared test account without making a genuinely stuck run block the + * suite indefinitely. + */ + static final long TEST_ACTOR_WAIT_SECS = 120L; + private static final SecureRandom RANDOM = new SecureRandom(); /** @@ -64,4 +73,30 @@ static String uniqueName(String prefix) { } return "java-test-" + prefix + "-" + hex; } + + /** + * Polls {@code check} up to {@code maxAttempts} times, sleeping {@code backoffMillis} between + * attempts, returning as soon as it reports success (or once attempts are exhausted). Shared by + * every eventual-consistency wait in this suite (a write and a LIST/iterate endpoint's index can + * converge asynchronously, so a single-pass check right after a write can race that convergence) + * so the retry/backoff shape lives in exactly one place. Swallows {@link InterruptedException} by + * restoring the interrupt flag and returning {@code false} early, so callers don't need to + * declare a checked exception just for this bounded, best-effort wait. + */ + static boolean pollUntil(int maxAttempts, long backoffMillis, BooleanSupplier check) { + for (int attempt = 0; attempt < maxAttempts; attempt++) { + if (check.getAsBoolean()) { + return true; + } + if (attempt + 1 < maxAttempts) { + try { + Thread.sleep(backoffMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return false; + } } diff --git a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java index eb8c186..b390e7f 100644 --- a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java @@ -69,28 +69,18 @@ private static boolean findsAll( * resource created through a write endpoint is not always immediately reflected in its * collection's LIST response — the write and the list index converge asynchronously on the * server, so a create-then-iterate assertion that scans exactly once races that convergence and - * flakes when the just-created entity has not yet propagated. This helper rebuilds a fresh - * iterator via {@code newIterator} and re-scans up to {@link #ITER_FIND_ATTEMPTS} times, sleeping - * {@link #ITER_FIND_BACKOFF_MILLIS} between attempts, returning as soon as every target id is - * seen. An already-consistent account matches on the first pass with no sleeping. Mirrors the - * sibling Go and Rust clients' tolerance for this exact intermittent failure. + * flakes when the just-created entity has not yet propagated. Delegates the retry/backoff shape + * to {@link IntegrationBase#pollUntil}, rebuilding a fresh iterator via {@code newIterator} on + * every attempt (up to {@link #ITER_FIND_ATTEMPTS} times, sleeping {@link + * #ITER_FIND_BACKOFF_MILLIS} between attempts) and returning as soon as every target id is seen. + * An already-consistent account matches on the first pass with no sleeping. */ private static boolean findsAllEventually( Supplier> newIterator, Function idOf, Set targets) { - for (int attempt = 0; attempt < ITER_FIND_ATTEMPTS; attempt++) { - if (findsAll(newIterator.get(), idOf, targets)) { - return true; - } - if (attempt + 1 < ITER_FIND_ATTEMPTS) { - try { - Thread.sleep(ITER_FIND_BACKOFF_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - } - return false; + return pollUntil( + ITER_FIND_ATTEMPTS, + ITER_FIND_BACKOFF_MILLIS, + () -> findsAll(newIterator.get(), idOf, targets)); } @Test diff --git a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java index c683799..46a8d5b 100644 --- a/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ScheduleIntegrationTest.java @@ -69,6 +69,8 @@ void scheduleCrudFlow() { Schedule updated = schedule.update(Map.of("cronExpression", "0 12 * * *")); assertEquals("0 12 * * *", updated.getCronExpression()); schedule.getLog(); + // list() step of the create/get/modify/list/delete flow. + assertTrue(client.schedules().list(new ListOptions().limit(5L)).getTotal() >= 0); // Typed getters (previously only reachable via getExtra()): verify the API's response // actually deserializes into them, not just that the code compiles. diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index f6fe055..bb8f181 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -5,13 +5,17 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.log.StreamedLogOptions; import com.apify.client.run.ActorRun; import com.apify.client.run.RunListOptions; import com.apify.client.task.Task; import com.apify.client.task.TaskCallOptions; import com.apify.client.task.TaskClient; import com.apify.client.task.TaskStartOptions; +import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; class TaskIntegrationTest extends IntegrationBase { @@ -78,7 +82,7 @@ void taskLastRunAndWebhooks() { Task task = client.tasks().create(taskDef(uniqueName("task-lastrun"))); try { TaskClient tc = client.task(task.getId()); - ActorRun run = tc.call(null, new TaskStartOptions(), 120L); + ActorRun run = tc.call(null, new TaskStartOptions(), TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); assertTrue(run.getStats() != null); assertTrue(run.getOptions() != null); @@ -88,6 +92,13 @@ void taskLastRunAndWebhooks() { assertTrue(lastRun.isPresent()); assertEquals(run.getId(), lastRun.get().getId()); + // Last-run-scoped nested storage GETs (previously untested — only `lastRun().get()` itself + // was). This task is exclusively owned by this test, so `lastRun` is deterministically the + // run started above and its default storages are guaranteed to exist. + tc.lastRun("SUCCEEDED").dataset().listItems(new DatasetListItemsOptions()); + tc.lastRun("SUCCEEDED").keyValueStore().getRecord("OUTPUT"); + assertTrue(tc.lastRun("SUCCEEDED").log().get().isPresent()); + // Read-only nested webhook collection (GET + iterate); no webhooks are registered for this // fresh task, so this just exercises both calls succeeding against an empty result. assertTrue(tc.webhooks().list(new ListOptions()).getTotal() >= 0); @@ -103,16 +114,15 @@ void taskCallStreamsLogByDefault() throws InterruptedException { Task task = client.tasks().create(taskDef(uniqueName("task-call-log"))); try { TaskClient tc = client.task(task.getId()); - java.util.List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); + List collected = new CopyOnWriteArrayList<>(); // The log-streaming call() overload (TaskCallOptions), matching the reference client's // default call(options.log='default') behavior: the run's log is streamed for the duration // of the wait without any explicit opt-in. ActorRun run = tc.call( null, - new TaskCallOptions() - .logOptions(new com.apify.client.log.StreamedLogOptions().toLog(collected::add)), - 120L); + new TaskCallOptions().logOptions(new StreamedLogOptions().toLog(collected::add)), + TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); // As in ActorRunIntegrationTest#streamedLogRedirection: a fast run can finish (and call()'s // finally-block close the stream) before the background reader's first read completes, even From 83b41a5937894da46c41d39015fec733c6ebf3da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:25:44 +0000 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20loop-8=20correctness=20fixes=20?= =?UTF-8?q?=E2=80=94=20Q1/Q10/Q13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RequestQueueClient: null-coalesce BatchDeleteResult's list getters and RequestQueueRequest.getErrorMessages() to avoid NPEs on an explicit JSON null, mirroring BatchAddResult. - RunClient.metamorph: normalize targetActorId via ResourceContext.toSafeId before sending (matches the reference client's _toSafeId); update the offline mock test's expected query param. - RunClient.metamorph/resurrect and ActorClient.build: throw a clear IllegalArgumentException on a null required options argument instead of an NPE deep inside the method. - RunClient's default log-redirection prefix now uses the resolved run's real id instead of the field id (which is the literal "last" for a runs/last accessor). - Box ActorRunOptions.timeoutSecs/memoryMbytes/diskMbytes (Long) so an unset value is distinguishable from 0, matching the boxed sibling fields. --- docs/runs.md | 7 ++-- .../com/apify/client/actor/ActorClient.java | 3 ++ .../client/internal/ResourceContext.java | 9 +++++- .../requestqueue/BatchDeleteResult.java | 5 +-- .../requestqueue/RequestQueueRequest.java | 4 +-- .../com/apify/client/run/ActorRunOptions.java | 18 +++++------ .../java/com/apify/client/run/RunClient.java | 32 ++++++++++++++----- .../client/ClientBehaviourRegressionTest.java | 23 ++++++++++++- 8 files changed, 75 insertions(+), 26 deletions(-) diff --git a/docs/runs.md b/docs/runs.md index 374a06d..3efeb08 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -135,9 +135,10 @@ unit: `getActorComputeUnits()`, `getDatasetReads()`, `getDatasetWrites()`, `getDataTransferExternalGbytes()`, `getProxyResidentialTransferGbytes()`, `getProxySerps()`. Each is `null` when the run's Actor did not incur that kind of usage. -`ActorRunOptions` (`getOptions()`) exposes: `getBuild()` (`String`), `getTimeoutSecs()` (`long`), -`getMemoryMbytes()` (`long`), `getDiskMbytes()` (`long`), `getMaxItems()` (`Long`, nullable), -`getMaxTotalChargeUsd()` (`Double`, nullable), `getRestartOnError()` (`Boolean`, nullable). +`ActorRunOptions` (`getOptions()`) exposes: `getBuild()` (`String`), `getTimeoutSecs()` (`Long`, +nullable), `getMemoryMbytes()` (`Long`, nullable), `getDiskMbytes()` (`Long`, nullable), +`getMaxItems()` (`Long`, nullable), `getMaxTotalChargeUsd()` (`Double`, nullable), +`getRestartOnError()` (`Boolean`, nullable). `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/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index 9bc2126..078f6f7 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -132,6 +132,9 @@ public boolean validateInput(Object input, ValidateInputOptions options) { /** Builds the given version of the Actor and returns the created build. */ public Build build(String versionNumber, ActorBuildOptions options) { + if (options == null) { + throw new IllegalArgumentException("options is required and must not be null"); + } QueryParams params = new QueryParams(); params.addString("version", versionNumber); options.apply(params); diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index f00c2dc..53a92ea 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -497,8 +497,15 @@ public static boolean isNotFound(ApifyApiException e) { /** * Encodes a resource id so it is safe to embed in a URL path. Apify uses the {@code * username~resourcename} form, so the first {@code /} of an id is replaced with {@code ~}. + * + *

    Public (not just intra-package) because {@code RunClient#metamorph} in the {@code .run} + * package needs to apply the same normalization to a resource id supplied as an argument value + * (as opposed to the id embedded in this client's own URL path, which {@link #single} already + * normalizes internally) — a cross-package caller of internal plumbing, same pattern as every + * other member here promoted narrowly for a specific caller (see the package-split rationale in + * this class's other narrowly-public members). */ - static String toSafeId(String id) { + public static String toSafeId(String id) { int slash = id.indexOf('/'); return slash < 0 ? id : id.substring(0, slash) + "~" + id.substring(slash + 1); } diff --git a/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java index 8349161..3517cbb 100644 --- a/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java +++ b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java @@ -12,11 +12,12 @@ public final class BatchDeleteResult { /** The requests the API successfully deleted (unmodifiable). */ public List getProcessedRequests() { - return Collections.unmodifiableList(processedRequests); + return Collections.unmodifiableList(processedRequests == null ? List.of() : processedRequests); } /** The requests the API did not delete, e.g. due to rate limiting (unmodifiable). */ public List getUnprocessedRequests() { - return Collections.unmodifiableList(unprocessedRequests); + return Collections.unmodifiableList( + unprocessedRequests == null ? List.of() : unprocessedRequests); } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java index b3c68b7..511904e 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueRequest.java @@ -170,10 +170,10 @@ public RequestQueueRequest setLoadedUrl(String loadedUrl) { /** * Error messages recorded from previous failed processing attempts, oldest first (unmodifiable; - * {@code null} if unset). + * empty if unset). */ public List getErrorMessages() { - return errorMessages == null ? null : Collections.unmodifiableList(errorMessages); + return errorMessages == null ? List.of() : Collections.unmodifiableList(errorMessages); } /** Sets the error messages recorded from previous failed processing attempts. */ diff --git a/src/main/java/com/apify/client/run/ActorRunOptions.java b/src/main/java/com/apify/client/run/ActorRunOptions.java index 59a452e..35afd01 100644 --- a/src/main/java/com/apify/client/run/ActorRunOptions.java +++ b/src/main/java/com/apify/client/run/ActorRunOptions.java @@ -10,9 +10,9 @@ @JsonIgnoreProperties(ignoreUnknown = true) public final class ActorRunOptions { private String build; - private long timeoutSecs; - private long memoryMbytes; - private long diskMbytes; + private Long timeoutSecs; + private Long memoryMbytes; + private Long diskMbytes; private Long maxItems; private Double maxTotalChargeUsd; private Boolean restartOnError; @@ -22,18 +22,18 @@ public String getBuild() { return build; } - /** The applied run timeout, in seconds. */ - public long getTimeoutSecs() { + /** The applied run timeout, in seconds ({@code null} if not reported). */ + public Long getTimeoutSecs() { return timeoutSecs; } - /** The applied memory allocation, in megabytes. */ - public long getMemoryMbytes() { + /** The applied memory allocation, in megabytes ({@code null} if not reported). */ + public Long getMemoryMbytes() { return memoryMbytes; } - /** The applied disk allocation, in megabytes. */ - public long getDiskMbytes() { + /** The applied disk allocation, in megabytes ({@code null} if not reported). */ + public Long getDiskMbytes() { return diskMbytes; } diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index b968633..197ba8b 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -115,14 +115,19 @@ public ActorRun abort(Boolean gracefully) { /** * Transforms the run into a run of another Actor with a new input. {@code targetActorId} is the - * Actor to metamorph into; {@code input} is the new input ({@code null} for none). + * Actor to metamorph into ({@code username/actor-name} or the plain Actor id; normalized to the + * URL-safe {@code username~actor-name} form before sending, matching the reference client's + * {@code _toSafeId}); {@code input} is the new input ({@code null} for none). */ public ActorRun metamorph(String targetActorId, Object input, MetamorphOptions options) { if (targetActorId == null || targetActorId.isEmpty()) { throw new IllegalArgumentException("targetActorId is required and must not be empty"); } + if (options == null) { + throw new IllegalArgumentException("options is required and must not be null"); + } QueryParams params = new QueryParams(); - params.addString("targetActorId", targetActorId); + params.addString("targetActorId", ResourceContext.toSafeId(targetActorId)); if (options.buildValue() != null && !options.buildValue().isEmpty()) { params.addString("build", options.buildValue()); } @@ -138,6 +143,9 @@ public ActorRun reboot() { /** Resurrects a finished run, starting it again from the beginning. */ public ActorRun resurrect(RunResurrectOptions options) { + if (options == null) { + throw new IllegalArgumentException("options is required and must not be null"); + } QueryParams params = new QueryParams(); options.apply(params); return ctx.postWithBody("resurrect", params, null, "", ActorRun.class); @@ -267,22 +275,30 @@ public StreamedLog getStreamedLog(StreamedLogOptions options) { * followed by {@code " -> "}. Falls back to just the run id when the Actor name is unavailable. */ private String buildDefaultLogPrefix() { - String runPart = "runId:" + id; + // Fall back to the field `id`: correct for a direct run client, but it is the literal string + // "last" for a `runs/last` accessor (see #lastRun) — replaced with the resolved run's real id + // below as soon as the fetch succeeds. + String resolvedId = 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(); + if (run.isPresent()) { + resolvedId = run.get().getId(); + if (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. + // runId-only prefix (actorName stays "", resolvedId stays the unresolved field) so the helper + // is always created. } + String runPart = "runId:" + resolvedId; String name = actorName.isEmpty() ? runPart : actorName + " " + runPart; return name + " -> "; } diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 2d6a088..18aea51 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -147,7 +147,10 @@ void metamorphSendsTargetActorIdBuildAndInputBody() { java.util.Map.of("foo", "bar"), new MetamorphOptions().build("1.2.3")); assertTrue(backend.lastUrl.contains("actor-runs/run123/metamorph"), backend.lastUrl); - assertTrue(backend.lastUrl.contains("targetActorId=apify%2Fother-actor"), backend.lastUrl); + // targetActorId is normalized to the URL-safe username~actor-name form before sending + // (ResourceContext.toSafeId), matching the reference client's _toSafeId; '~' is then + // percent-encoded like any other query value. + assertTrue(backend.lastUrl.contains("targetActorId=apify%7Eother-actor"), backend.lastUrl); assertTrue(backend.lastUrl.contains("build=1.2.3"), backend.lastUrl); assertTrue(backend.lastBody.contains("\"foo\":\"bar\""), backend.lastBody); assertEquals("POST", backend.lastMethod); @@ -167,6 +170,24 @@ void metamorphRejectsMissingTargetActorId() { assertEquals(0, backend.calls, "no request should be sent for an invalid metamorph"); } + @Test + void requiredOptionsArgumentsRejectNullWithClearException() { + // metamorph/resurrect/build all use their options argument unconditionally, so a null would + // otherwise surface as a raw NullPointerException deep inside the method; guard it with a + // clear IllegalArgumentException instead, matching call()/validateInput()'s own null-defaulting + // (those default to an empty options instance because they are single-purpose convenience + // overloads; these three take options as their only way to configure a required action, so a + // missing options object is a caller error worth surfacing rather than silently defaulting). + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); + ApifyClient client = client(backend); + assertThrows( + IllegalArgumentException.class, + () -> client.run("run123").metamorph("apify/other-actor", null, null)); + assertThrows(IllegalArgumentException.class, () -> client.run("run123").resurrect(null)); + assertThrows(IllegalArgumentException.class, () -> client.actor("actor123").build("0.0", null)); + assertEquals(0, backend.calls, "no request should be sent for a null required options arg"); + } + @Test void rebootSendsPostToRebootWithNoBody() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"run123\"}}"); From e2beffa223ec73935d51310bfd1c78ad95ba1025 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:42:56 +0000 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20loop-8=20DTO=20parity=20=E2=80=94?= =?UTF-8?q?=20Q9/Q11/Q12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out response DTOs to match the JS reference's typed interfaces, resolving the recurring thin-DTO review category: - WebhookDispatch: typed status, eventType, userId, createdAt, calls (WebhookDispatchCall), webhook (WebhookDispatchWebhookInfo), eventData (WebhookDispatchEventData). - Actor: wire in getActorStandby() (ActorStandby, now also carrying the Actor-only isEnabled field), plus stats (ActorStats), versions, pricingInfos, defaultRunOptions (ActorDefaultRunOptions), taggedBuilds, actorPermissionLevel, categories, isDeprecated, deploymentKey, seoTitle/seoDescription. - Build: userId, meta (BuildMeta), stats (BuildStats), options (BuildOptions), usage/usageUsd/usageTotalUsd (BuildUsage). - User: profile (UserProfile), email, proxy (UserProxy), plan (UserPlan), effectivePlatformFeatures, createdAt, isPaying. - ActorStoreListItem: description, stats, currentPricingInfo (PricingInfo), pictureUrl, userPictureUrl, url, readmeSummary. Task/ActorRun were already complete from an earlier loop; verified against the JS reference and left unchanged. Every new field name/type was checked against apify-client-js's resource_clients interfaces before modelling. getExtra() is kept for forward-compat on every touched model. Docs field lists updated to match (actors.md, builds.md, misc.md, webhooks.md, examples.md); the GetAccount example and its doc snippet now read the typed getEmail() instead of reaching into getExtra(). spotbugs-exclude.xml: added the new pure-DTO classes to the existing UWF_UNWRITTEN_FIELD allow-list (same false-positive rationale as the pre-existing entries); rewrote the "historical note" to state only the current timeless rationale, dropping the loop-numbered narrative. --- docs/actors.md | 12 +- docs/builds.md | 11 +- docs/examples.md | 4 +- docs/misc.md | 29 +++- docs/webhooks.md | 11 +- spotbugs-exclude.xml | 32 ++-- .../java/com/apify/client/actor/Actor.java | 87 +++++++++++ .../client/actor/ActorDefaultRunOptions.java | 32 ++++ .../com/apify/client/actor/ActorStandby.java | 11 ++ .../com/apify/client/actor/ActorStats.java | 57 +++++++ .../java/com/apify/client/build/Build.java | 42 ++++++ .../com/apify/client/build/BuildMeta.java | 26 ++++ .../com/apify/client/build/BuildOptions.java | 32 ++++ .../com/apify/client/build/BuildStats.java | 26 ++++ .../com/apify/client/build/BuildUsage.java | 23 +++ .../client/store/ActorStoreListItem.java | 43 ++++++ .../com/apify/client/store/PricingInfo.java | 17 +++ .../com/apify/client/user/ProxyGroup.java | 26 ++++ src/main/java/com/apify/client/user/User.java | 53 ++++++- .../java/com/apify/client/user/UserPlan.java | 142 ++++++++++++++++++ .../com/apify/client/user/UserProfile.java | 44 ++++++ .../java/com/apify/client/user/UserProxy.java | 22 +++ .../apify/client/webhook/WebhookDispatch.java | 54 +++++++ .../client/webhook/WebhookDispatchCall.java | 41 +++++ .../webhook/WebhookDispatchEventData.java | 35 +++++ .../webhook/WebhookDispatchWebhookInfo.java | 26 ++++ .../com/apify/client/examples/GetAccount.java | 6 +- 27 files changed, 912 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java create mode 100644 src/main/java/com/apify/client/actor/ActorStats.java create mode 100644 src/main/java/com/apify/client/build/BuildMeta.java create mode 100644 src/main/java/com/apify/client/build/BuildOptions.java create mode 100644 src/main/java/com/apify/client/build/BuildStats.java create mode 100644 src/main/java/com/apify/client/build/BuildUsage.java create mode 100644 src/main/java/com/apify/client/store/PricingInfo.java create mode 100644 src/main/java/com/apify/client/user/ProxyGroup.java create mode 100644 src/main/java/com/apify/client/user/UserPlan.java create mode 100644 src/main/java/com/apify/client/user/UserProfile.java create mode 100644 src/main/java/com/apify/client/user/UserProxy.java create mode 100644 src/main/java/com/apify/client/webhook/WebhookDispatchCall.java create mode 100644 src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java create mode 100644 src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java diff --git a/docs/actors.md b/docs/actors.md index c0570b7..26ca96a 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -91,7 +91,17 @@ System.out.println(run.getStatus()); ``` `Actor` fields: `getId()`, `getUserId()`, `getName()`, `getUsername()`, `getTitle()`, -`getDescription()`, `isPublic()`, `getCreatedAt()`, `getModifiedAt()`, plus `getExtra()` for any +`getDescription()`, `isPublic()`, `getCreatedAt()`, `getModifiedAt()`, `getStats()` (`ActorStats` — +`getTotalBuilds()`/`getTotalRuns()`/`getTotalUsers()`/`getTotalUsers7Days()`/ +`getTotalUsers30Days()`/`getTotalUsers90Days()`/`getTotalMetamorphs()` as `Long`, +`getLastRunStartedAt()` as `Instant`), `getVersions()` (`List`), +`getPricingInfos()` (`List`; shape depends on pricing model), `getDefaultRunOptions()` +(`ActorDefaultRunOptions` — `getBuild()`, `getTimeoutSecs()`/`getMemoryMbytes()` as `Long`, +`getRestartOnError()` as `Boolean`), `getTaggedBuilds()` (`JsonNode`; dynamic tag-name keys), +`getIsDeprecated()` (`Boolean`), `getDeploymentKey()`, `getSeoTitle()`, `getSeoDescription()`, +`getCategories()` (`List`), `getActorStandby()` (`ActorStandby`, nullable — see +[Tasks](tasks.md) for its field list; only on `Actor` does it additionally expose +`getIsEnabled()`), `getActorPermissionLevel()` (e.g. `"OWNER"`), plus `getExtra()` for any unmodelled fields. ## `ActorVersionClient` and `ActorVersionCollectionClient` diff --git a/docs/builds.md b/docs/builds.md index ae1101d..5821557 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -22,9 +22,14 @@ builds) and a single build with `client.build(id)`. | `getOpenApiDefinition()` | The build's OpenAPI definition. Returns `Optional`. | | `log()` | A `LogClient` for the build's log. | -`Build` fields: `getId()`, `getActId()`, `getStatus()`, `getStartedAt()`, `getFinishedAt()`, -`getBuildNumber()`, plus `isTerminal()` and `getExtra()`. The status is one of `READY`, `RUNNING`, -`SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. +`Build` fields: `getId()`, `getActId()`, `getUserId()`, `getStatus()`, `getStartedAt()`, +`getFinishedAt()`, `getBuildNumber()`, `getMeta()` (`BuildMeta` — `getOrigin()`, `getClientIp()`, +`getUserAgent()`), `getStats()` (`BuildStats` — `getDurationMillis()`/`getRunTimeSecs()` as `Long`, +`getComputeUnits()` as `Double`), `getOptions()` (`BuildOptions` — `getUseCache()`/ +`getBetaPackages()` as `Boolean`, `getMemoryMbytes()`/`getDiskMbytes()` as `Long`), `getUsage()` / +`getUsageUsd()` (`BuildUsage` — `getActorComputeUnits()` as `Double`, the latter as its USD cost), +`getUsageTotalUsd()` (`Double`), plus `isTerminal()` and `getExtra()`. The status is one of +`READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. ```java Build build = client.actor("me/my-actor").build("0.0", new ActorBuildOptions().tag("latest")); diff --git a/docs/examples.md b/docs/examples.md index 7b1f1d9..0e1a417 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -63,8 +63,8 @@ Optional user = client.me().get(); user.ifPresent(u -> { System.out.println("Account ID: " + u.getId()); System.out.println("Username: " + u.getUsername()); - // Fields not modelled on User (email, plan, ...) live in the untyped extras map. - System.out.println("Email: " + u.getExtra().get("email")); + // email/plan/proxy/... are only populated for me() (not user(id)). + System.out.println("Email: " + u.getEmail()); }); ``` diff --git a/docs/misc.md b/docs/misc.md index 5b215ff..a7dd063 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -27,8 +27,11 @@ while (shown < 5 && it.hasNext()) { } ``` -`ActorStoreListItem` fields: `getId()`, `getName()`, `getUsername()`, `getTitle()`. Any field not -covered by a typed getter is still available via the inherited `getExtra()` (see +`ActorStoreListItem` fields: `getId()`, `getName()`, `getUsername()`, `getTitle()`, +`getDescription()`, `getStats()` (`ActorStats`; see [Actors](actors.md#actorclient)), +`getCurrentPricingInfo()` (`PricingInfo` — `getPricingModel()`), `getPictureUrl()`, +`getUserPictureUrl()`, `getUrl()`, `getReadmeSummary()`. Any field not covered by a typed getter is +still available via the inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). ## Users — `client.me()` / `client.user(id)` @@ -48,15 +51,27 @@ Optional me = client.me().get(); 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")); + // email/plan/proxy/... are only populated for me() (not user(id)); use getExtra() for any + // remaining account field not modelled directly. + System.out.println("Email: " + u.getEmail()); }); JsonNode usage = client.me().monthlyUsage(); ``` -`User` fields: `getId()`, `getUsername()`, plus `getExtra()` — a `Map` carrying any -account fields not modelled directly (for `me()`, private details such as `email` and `plan`). +`User` fields: `getId()`, `getUsername()`, `getProfile()` (`UserProfile` — `getBio()`, `getName()`, +`getPictureUrl()`, `getGithubUsername()`, `getWebsiteUrl()`, `getTwitterUsername()`, all nullable). +Only present for `me()`: `getEmail()`, `getProxy()` (`UserProxy` — `getPassword()`, `getGroups()` +as `List`, each with `getName()`/`getDescription()`/`getAvailableCount()` as `Long`), +`getPlan()` (`UserPlan` — `getId()`, `getDescription()`, `getIsEnabled()`, +`getMonthlyBasePriceUsd()`/`getMonthlyUsageCreditsUsd()`/`getUsageDiscountPercent()`/ +`getMaxMonthlyUsageUsd()`/`getMaxActorMemoryGbytes()`/`getMaxMonthlyActorComputeUnits()`/ +`getMaxMonthlyResidentialProxyGbytes()`/`getMaxMonthlyProxySerps()`/ +`getMaxMonthlyExternalDataTransferGbytes()` as `Double`, `getEnabledPlatformFeatures()` as +`List`, `getMaxActorCount()`/`getMaxActorTaskCount()`/`getDataRetentionDays()`/ +`getTeamAccountSeatCount()` as `Long`, `getAvailableProxyGroups()` as `JsonNode`, +`getSupportLevel()`, `getAvailableAddOns()` as `List`), `getEffectivePlatformFeatures()` +(`JsonNode`), `getCreatedAt()` (`Instant`), `getIsPaying()` (`Boolean`). Plus `getExtra()` — a +`Map` carrying any account field not modelled directly. ## Logs — `client.log(id)` diff --git a/docs/webhooks.md b/docs/webhooks.md index 3ef625d..ee431ef 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -71,6 +71,13 @@ inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodele | `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()`. Any field not covered by a typed getter is -still available via the inherited `getExtra()` (see +`WebhookDispatch` fields: `getId()`, `getUserId()`, `getWebhookId()`, `getCreatedAt()` (`Instant`), +`getStatus()` (one of `"ACTIVE"`/`"SUCCEEDED"`/`"FAILED"`), `getEventType()` (the event that +triggered the dispatch), `getCalls()` (unmodifiable `List` — each exposing +`getStartedAt()`/`getFinishedAt()` as `Instant`, `getErrorMessage()`, `getResponseStatus()` +(`Integer`), `getResponseBody()`), `getWebhook()` (`WebhookDispatchWebhookInfo`, nullable — a +`getRequestUrl()`/`isAdHoc()` summary of the webhook that produced the dispatch), and +`getEventData()` (`WebhookDispatchEventData`, nullable — `getActorRunId()`/`getActorId()`/ +`getActorTaskId()`/`getActorBuildId()`, whichever apply to the triggering event). Any field not +covered by a typed getter is still available via the inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 4b8a74f..729bd2c 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -18,19 +18,18 @@ 2. UWF_UNWRITTEN_FIELD / UWF_UNWRITTEN_PUBLIC_OR_PROTECTED_FIELD on Jackson-populated DTOs/response shapes ({@code KeyValueStoreKey}, {@code DeletedRequestInfo}, {@code ActorRunMeta}, {@code - TaskOptions}, {@code WebhookLastDispatch}, the internal error-response mapping in {@code - HttpClientCore}): their fields are written by the deserializer via reflection, which SpotBugs - cannot see - a known false positive for data-binding models. + TaskOptions}, {@code WebhookLastDispatch}, {@code ActorDefaultRunOptions}, {@code BuildMeta}, + {@code BuildOptions}, {@code BuildStats}, {@code PricingInfo}, {@code ProxyGroup}, {@code + UserProxy}, {@code WebhookDispatchEventData}, {@code WebhookDispatchWebhookInfo}, the internal + error-response mapping in {@code HttpClientCore}): their fields are written by the + deserializer via reflection, which SpotBugs cannot see - a known false positive for + data-binding models. - Historical note: an EI_EXPOSE_REP/EI_EXPOSE_REP2 exclusion for {@code ApiResponse} (its {@code - body} field is the raw response bytes, intentionally stored/returned without a defensive copy - - the payload can be large, e.g. dataset exports, and copying it on every access would be wasteful) - was carried here through loop 3 and dropped in loop 4 once it stopped reproducing. To be precise - about why, since "no longer reproduces" could otherwise be misread as "the exposure was fixed": - the no-copy contract itself never changed; SpotBugs simply stopped flagging that pattern on this - class. {@code ApiResponse} is still a plain public-field carrier with no copying anywhere in its - constructor or fields - the finding's disappearance reflects the tool's static-analysis coverage - of the pattern, not a code change that defensively fixed anything. + Note: {@code ApiResponse}'s {@code body} field intentionally holds the raw response bytes + without a defensive copy (the payload can be large, e.g. dataset exports, so copying it on every + access would be wasteful). It has no entry in this filter: per this file's own rule of dropping + exclusions that no longer reproduce anything, {@code ApiResponse} is not currently flagged by + SpotBugs for this field, even though the no-copy contract itself is deliberate and unchanged. --> @@ -47,6 +46,15 @@ + + + + + + + + + diff --git a/src/main/java/com/apify/client/actor/Actor.java b/src/main/java/com/apify/client/actor/Actor.java index 23bb4db..1895944 100644 --- a/src/main/java/com/apify/client/actor/Actor.java +++ b/src/main/java/com/apify/client/actor/Actor.java @@ -1,7 +1,10 @@ package com.apify.client.actor; import com.apify.client.ApifyResource; +import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; +import java.util.Collections; +import java.util.List; /** An Actor on the Apify platform. */ public final class Actor extends ApifyResource { @@ -14,6 +17,18 @@ public final class Actor extends ApifyResource { private boolean isPublic; private Instant createdAt; private Instant modifiedAt; + private ActorStats stats; + private List versions = List.of(); + private List pricingInfos = List.of(); + private ActorDefaultRunOptions defaultRunOptions; + private JsonNode taggedBuilds; + private Boolean isDeprecated; + private String deploymentKey; + private String seoTitle; + private String seoDescription; + private List categories = List.of(); + private ActorStandby actorStandby; + private String actorPermissionLevel; /** The unique Actor ID. */ public String getId() { @@ -59,4 +74,76 @@ public Instant getCreatedAt() { public Instant getModifiedAt() { return modifiedAt; } + + /** Usage and run statistics for the Actor. */ + public ActorStats getStats() { + return stats; + } + + /** All versions of this Actor (unmodifiable, never {@code null}). */ + public List getVersions() { + // Null-coalesce: Jackson binds directly to the (private) `versions` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"versions": null`. + return versions == null ? List.of() : Collections.unmodifiableList(versions); + } + + /** + * Pricing information for pay-per-result or pay-per-event Actors, as raw JSON (shape depends on + * the pricing model; unmodifiable, never {@code null}). + */ + public List getPricingInfos() { + return pricingInfos == null ? List.of() : Collections.unmodifiableList(pricingInfos); + } + + /** Default configuration options applied to this Actor's runs unless overridden. */ + public ActorDefaultRunOptions getDefaultRunOptions() { + return defaultRunOptions; + } + + /** + * The build tags mapped to specific build numbers/ids for this Actor (e.g. {@code "latest"}), as + * raw JSON since the keys are dynamic tag names, not a fixed set of fields. + */ + public JsonNode getTaggedBuilds() { + return taggedBuilds; + } + + /** Whether the Actor is deprecated and should no longer be used. */ + public Boolean getIsDeprecated() { + return isDeprecated; + } + + /** The deployment key used for automated deployments to this Actor. */ + public String getDeploymentKey() { + return deploymentKey; + } + + /** An SEO-optimized title for the Actor's public Store page. */ + public String getSeoTitle() { + return seoTitle; + } + + /** An SEO-optimized description for the Actor's public Store page. */ + public String getSeoDescription() { + return seoDescription; + } + + /** + * Categories the Actor belongs to (e.g. {@code "ECOMMERCE"}, {@code "SCRAPING"}; unmodifiable, + * never {@code null}). + */ + public List getCategories() { + return categories == null ? List.of() : Collections.unmodifiableList(categories); + } + + /** Standby-mode configuration for this Actor, if standby mode has ever been configured. */ + public ActorStandby getActorStandby() { + return actorStandby; + } + + /** The permission level of the Actor on the Apify platform (e.g. {@code "OWNER"}). */ + public String getActorPermissionLevel() { + return actorPermissionLevel; + } } diff --git a/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java new file mode 100644 index 0000000..a450dd0 --- /dev/null +++ b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java @@ -0,0 +1,32 @@ +package com.apify.client.actor; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Default configuration options applied to an {@link Actor}'s runs unless overridden. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorDefaultRunOptions { + private String build; + private Long timeoutSecs; + private Long memoryMbytes; + private Boolean restartOnError; + + /** The tag or number of the build run by default. */ + public String getBuild() { + return build; + } + + /** The default run timeout, in seconds. */ + public Long getTimeoutSecs() { + return timeoutSecs; + } + + /** The default memory allocation, in megabytes. */ + public Long getMemoryMbytes() { + return memoryMbytes; + } + + /** Whether a run is restarted by default if it fails. */ + public Boolean getRestartOnError() { + return restartOnError; + } +} diff --git a/src/main/java/com/apify/client/actor/ActorStandby.java b/src/main/java/com/apify/client/actor/ActorStandby.java index 33e874a..6dac7d8 100644 --- a/src/main/java/com/apify/client/actor/ActorStandby.java +++ b/src/main/java/com/apify/client/actor/ActorStandby.java @@ -5,6 +5,7 @@ /** Standby-mode configuration for an Actor, or for a task that overrides its Actor's defaults. */ @JsonIgnoreProperties(ignoreUnknown = true) public final class ActorStandby { + private Boolean isEnabled; private String build; private Long desiredRequestsPerActorRun; private Boolean disableStandbyFieldsOverride; @@ -13,6 +14,16 @@ public final class ActorStandby { private Long memoryMbytes; private Boolean shouldPassActorInput; + /** + * Whether standby mode is enabled. Only present on {@link Actor#getActorStandby()} (the API + * models it as an intersection type there); always {@code null} on {@link + * com.apify.client.task.Task#getActorStandby()}, which reuses the same shape for its per-task + * override without this field. + */ + public Boolean getIsEnabled() { + return isEnabled; + } + /** The tag or number of the build used to serve standby requests. */ public String getBuild() { return build; diff --git a/src/main/java/com/apify/client/actor/ActorStats.java b/src/main/java/com/apify/client/actor/ActorStats.java new file mode 100644 index 0000000..b38fe3a --- /dev/null +++ b/src/main/java/com/apify/client/actor/ActorStats.java @@ -0,0 +1,57 @@ +package com.apify.client.actor; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; + +/** Usage and activity statistics for an {@link Actor}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ActorStats { + private Long totalBuilds; + private Long totalRuns; + private Long totalUsers; + private Long totalUsers7Days; + private Long totalUsers30Days; + private Long totalUsers90Days; + private Long totalMetamorphs; + private Instant lastRunStartedAt; + + /** Total number of builds created for this Actor. */ + public Long getTotalBuilds() { + return totalBuilds; + } + + /** Total number of times this Actor has been run. */ + public Long getTotalRuns() { + return totalRuns; + } + + /** Total number of unique users who have run this Actor. */ + public Long getTotalUsers() { + return totalUsers; + } + + /** Number of unique users in the last 7 days. */ + public Long getTotalUsers7Days() { + return totalUsers7Days; + } + + /** Number of unique users in the last 30 days. */ + public Long getTotalUsers30Days() { + return totalUsers30Days; + } + + /** Number of unique users in the last 90 days. */ + public Long getTotalUsers90Days() { + return totalUsers90Days; + } + + /** Total number of times this Actor was used via metamorph. */ + public Long getTotalMetamorphs() { + return totalMetamorphs; + } + + /** When the last run of this Actor was started. */ + public Instant getLastRunStartedAt() { + return lastRunStartedAt; + } +} diff --git a/src/main/java/com/apify/client/build/Build.java b/src/main/java/com/apify/client/build/Build.java index 484ff24..8a5fa98 100644 --- a/src/main/java/com/apify/client/build/Build.java +++ b/src/main/java/com/apify/client/build/Build.java @@ -8,10 +8,17 @@ public final class Build extends ApifyResource { private String id; private String actId; + private String userId; private String status; private Instant startedAt; private Instant finishedAt; private String buildNumber; + private BuildMeta meta; + private BuildStats stats; + private BuildOptions options; + private BuildUsage usage; + private BuildUsage usageUsd; + private Double usageTotalUsd; /** The unique build ID. */ public String getId() { @@ -23,6 +30,11 @@ public String getActId() { return actId; } + /** The ID of the user who started the build. */ + public String getUserId() { + return userId; + } + /** * The current build status. One of the eight {@code ActorJobStatus} values: {@code READY}, {@code * RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, {@code TIMED-OUT}, {@code @@ -47,6 +59,36 @@ public String getBuildNumber() { return buildNumber; } + /** Metadata about how the build was initiated. */ + public BuildMeta getMeta() { + return meta; + } + + /** Runtime statistics for the build. */ + public BuildStats getStats() { + return stats; + } + + /** The configuration options actually applied to the build. */ + public BuildOptions getOptions() { + return options; + } + + /** Resource usage incurred by the build, per billable unit. */ + public BuildUsage getUsage() { + return usage; + } + + /** {@link #getUsage()}'s cost, in USD, per billable unit. */ + public BuildUsage getUsageUsd() { + return usageUsd; + } + + /** The build's total cost, in USD. */ + public Double getUsageTotalUsd() { + return usageTotalUsd; + } + /** Whether the build has reached a terminal (finished) status. */ public boolean isTerminal() { return Statuses.isTerminal(status); diff --git a/src/main/java/com/apify/client/build/BuildMeta.java b/src/main/java/com/apify/client/build/BuildMeta.java new file mode 100644 index 0000000..58224c7 --- /dev/null +++ b/src/main/java/com/apify/client/build/BuildMeta.java @@ -0,0 +1,26 @@ +package com.apify.client.build; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Metadata about how a {@link Build} was initiated. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BuildMeta { + private String origin; + private String clientIp; + private String userAgent; + + /** What triggered the build (e.g. {@code "WEB"}, {@code "API"}). */ + public String getOrigin() { + return origin; + } + + /** The IP address of the client that started the build, if known. */ + public String getClientIp() { + return clientIp; + } + + /** The {@code User-Agent} of the client that started the build. */ + public String getUserAgent() { + return userAgent; + } +} diff --git a/src/main/java/com/apify/client/build/BuildOptions.java b/src/main/java/com/apify/client/build/BuildOptions.java new file mode 100644 index 0000000..d4bdb64 --- /dev/null +++ b/src/main/java/com/apify/client/build/BuildOptions.java @@ -0,0 +1,32 @@ +package com.apify.client.build; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** The configuration options actually applied to a {@link Build}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BuildOptions { + private Boolean useCache; + private Boolean betaPackages; + private Long memoryMbytes; + private Long diskMbytes; + + /** Whether the build reused a cached image layer. */ + public Boolean getUseCache() { + return useCache; + } + + /** Whether the build used beta versions of Actor SDK packages. */ + public Boolean getBetaPackages() { + return betaPackages; + } + + /** The memory allocation applied to the build, in megabytes. */ + public Long getMemoryMbytes() { + return memoryMbytes; + } + + /** The disk allocation applied to the build, in megabytes. */ + public Long getDiskMbytes() { + return diskMbytes; + } +} diff --git a/src/main/java/com/apify/client/build/BuildStats.java b/src/main/java/com/apify/client/build/BuildStats.java new file mode 100644 index 0000000..d95e721 --- /dev/null +++ b/src/main/java/com/apify/client/build/BuildStats.java @@ -0,0 +1,26 @@ +package com.apify.client.build; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** Runtime statistics for a {@link Build}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BuildStats { + private Long durationMillis; + private Long runTimeSecs; + private Double computeUnits; + + /** How long the build took, in milliseconds. */ + public Long getDurationMillis() { + return durationMillis; + } + + /** How long the build took, in seconds. */ + public Long getRunTimeSecs() { + return runTimeSecs; + } + + /** Compute units consumed by the build. */ + public Double getComputeUnits() { + return computeUnits; + } +} diff --git a/src/main/java/com/apify/client/build/BuildUsage.java b/src/main/java/com/apify/client/build/BuildUsage.java new file mode 100644 index 0000000..e1b3e5c --- /dev/null +++ b/src/main/java/com/apify/client/build/BuildUsage.java @@ -0,0 +1,23 @@ +package com.apify.client.build; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Resource-usage metrics for a {@link Build}, broken down by billable unit (only compute units + * apply to builds). {@code null} when the build did not incur that kind of usage. + * + *

    The API reports this key in {@code SCREAMING_SNAKE_CASE} ({@code "ACTOR_COMPUTE_UNITS"}), + * mapped to its idiomatic camelCase Java name via {@code @JsonProperty}, matching {@link + * com.apify.client.run.ActorRunUsage}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class BuildUsage { + @JsonProperty("ACTOR_COMPUTE_UNITS") + private Double actorComputeUnits; + + /** Compute units consumed by the build. */ + public Double getActorComputeUnits() { + return actorComputeUnits; + } +} diff --git a/src/main/java/com/apify/client/store/ActorStoreListItem.java b/src/main/java/com/apify/client/store/ActorStoreListItem.java index 8057c01..cd2fde8 100644 --- a/src/main/java/com/apify/client/store/ActorStoreListItem.java +++ b/src/main/java/com/apify/client/store/ActorStoreListItem.java @@ -1,6 +1,7 @@ package com.apify.client.store; import com.apify.client.ApifyResource; +import com.apify.client.actor.ActorStats; /** An Actor as listed in the Apify Store. */ public final class ActorStoreListItem extends ApifyResource { @@ -8,6 +9,13 @@ public final class ActorStoreListItem extends ApifyResource { private String name; private String username; private String title; + private String description; + private ActorStats stats; + private PricingInfo currentPricingInfo; + private String pictureUrl; + private String userPictureUrl; + private String url; + private String readmeSummary; /** The unique Actor ID. */ public String getId() { @@ -28,4 +36,39 @@ public String getUsername() { public String getTitle() { return title; } + + /** A description of what the Actor does. */ + public String getDescription() { + return description; + } + + /** Usage and run statistics for the Actor. */ + public ActorStats getStats() { + return stats; + } + + /** The Actor's current pricing model. */ + public PricingInfo getCurrentPricingInfo() { + return currentPricingInfo; + } + + /** URL of the Actor's picture, if set. */ + public String getPictureUrl() { + return pictureUrl; + } + + /** URL of the Actor owner's profile picture, if set. */ + public String getUserPictureUrl() { + return userPictureUrl; + } + + /** The URL of the Actor's public Store page. */ + public String getUrl() { + return url; + } + + /** A brief, LLM-generated summary of the Actor's README. */ + public String getReadmeSummary() { + return readmeSummary; + } } diff --git a/src/main/java/com/apify/client/store/PricingInfo.java b/src/main/java/com/apify/client/store/PricingInfo.java new file mode 100644 index 0000000..af076eb --- /dev/null +++ b/src/main/java/com/apify/client/store/PricingInfo.java @@ -0,0 +1,17 @@ +package com.apify.client.store; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** A Store Actor's current pricing model summary. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PricingInfo { + private String pricingModel; + + /** + * The pricing model, e.g. {@code "FREE"}, {@code "FLAT_PRICE_PER_MONTH"}, {@code + * "PAY_PER_EVENT"}. + */ + public String getPricingModel() { + return pricingModel; + } +} diff --git a/src/main/java/com/apify/client/user/ProxyGroup.java b/src/main/java/com/apify/client/user/ProxyGroup.java new file mode 100644 index 0000000..ecef2ae --- /dev/null +++ b/src/main/java/com/apify/client/user/ProxyGroup.java @@ -0,0 +1,26 @@ +package com.apify.client.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** A group of proxies available to a {@link User}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ProxyGroup { + private String name; + private String description; + private Long availableCount; + + /** The proxy group's identifier (used as the {@code groups} value when configuring a proxy). */ + public String getName() { + return name; + } + + /** A human-readable description of the proxy group. */ + public String getDescription() { + return description; + } + + /** The number of proxies currently available in this group. */ + public Long getAvailableCount() { + return availableCount; + } +} diff --git a/src/main/java/com/apify/client/user/User.java b/src/main/java/com/apify/client/user/User.java index c429283..2b73342 100644 --- a/src/main/java/com/apify/client/user/User.java +++ b/src/main/java/com/apify/client/user/User.java @@ -1,14 +1,25 @@ package com.apify.client.user; import com.apify.client.ApifyResource; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; /** - * An Apify user account. Private account details for {@code me} are available via {@link - * #getExtra()}. + * An Apify user account. Private account details (available only for {@code me}) are typed below + * where reasonably finite; {@link #getEffectivePlatformFeatures()} stays raw JSON (a fixed set of + * feature-name keys, each with its own limits sub-object) and any other unmodelled field is still + * reachable via {@link #getExtra()}. */ public final class User extends ApifyResource { private String id; private String username; + private UserProfile profile; + private String email; + private UserProxy proxy; + private UserPlan plan; + private JsonNode effectivePlatformFeatures; + private Instant createdAt; + private Boolean isPaying; /** The unique user ID. */ public String getId() { @@ -19,4 +30,42 @@ public String getId() { public String getUsername() { return username; } + + /** The user's public profile. */ + public UserProfile getProfile() { + return profile; + } + + /** The user's email address. Only present for {@code me}. */ + public String getEmail() { + return email; + } + + /** The user's Apify Proxy credentials and available proxy groups. Only present for {@code me}. */ + public UserProxy getProxy() { + return proxy; + } + + /** The user's subscription plan. Only present for {@code me}. */ + public UserPlan getPlan() { + return plan; + } + + /** + * The platform features actually available to the user right now (accounting for plan limits and + * current usage), as raw JSON. Only present for {@code me}. + */ + public JsonNode getEffectivePlatformFeatures() { + return effectivePlatformFeatures; + } + + /** When the user account was created. Only present for {@code me}. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** Whether the user is on a paid plan. Only present for {@code me}. */ + public Boolean getIsPaying() { + return isPaying; + } } diff --git a/src/main/java/com/apify/client/user/UserPlan.java b/src/main/java/com/apify/client/user/UserPlan.java new file mode 100644 index 0000000..f441473 --- /dev/null +++ b/src/main/java/com/apify/client/user/UserPlan.java @@ -0,0 +1,142 @@ +package com.apify.client.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Collections; +import java.util.List; + +/** A {@link User}'s subscription plan and its associated limits. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class UserPlan { + private String id; + private String description; + private Boolean isEnabled; + private Double monthlyBasePriceUsd; + private Double monthlyUsageCreditsUsd; + private Double usageDiscountPercent; + private List enabledPlatformFeatures = List.of(); + private Double maxMonthlyUsageUsd; + private Double maxActorMemoryGbytes; + private Double maxMonthlyActorComputeUnits; + private Double maxMonthlyResidentialProxyGbytes; + private Double maxMonthlyProxySerps; + private Double maxMonthlyExternalDataTransferGbytes; + private Long maxActorCount; + private Long maxActorTaskCount; + private Long dataRetentionDays; + private JsonNode availableProxyGroups; + private Long teamAccountSeatCount; + private String supportLevel; + private List availableAddOns = List.of(); + + /** The plan's identifier. */ + public String getId() { + return id; + } + + /** A human-readable description of the plan. */ + public String getDescription() { + return description; + } + + /** Whether the plan is currently active. */ + public Boolean getIsEnabled() { + return isEnabled; + } + + /** The plan's monthly base price, in USD. */ + public Double getMonthlyBasePriceUsd() { + return monthlyBasePriceUsd; + } + + /** The USD value of usage credits included in the plan each month. */ + public Double getMonthlyUsageCreditsUsd() { + return monthlyUsageCreditsUsd; + } + + /** The discount percentage applied to usage beyond the included credits. */ + public Double getUsageDiscountPercent() { + return usageDiscountPercent; + } + + /** + * The platform features enabled by this plan (e.g. {@code "ACTORS"}, {@code "PROXY"}; + * unmodifiable, never {@code null}). + */ + public List getEnabledPlatformFeatures() { + return enabledPlatformFeatures == null + ? List.of() + : Collections.unmodifiableList(enabledPlatformFeatures); + } + + /** The plan's total monthly usage cap, in USD. */ + public Double getMaxMonthlyUsageUsd() { + return maxMonthlyUsageUsd; + } + + /** The maximum memory, in gigabytes, a single Actor run may use under this plan. */ + public Double getMaxActorMemoryGbytes() { + return maxActorMemoryGbytes; + } + + /** The plan's monthly Actor compute unit cap. */ + public Double getMaxMonthlyActorComputeUnits() { + return maxMonthlyActorComputeUnits; + } + + /** The plan's monthly residential proxy data-transfer cap, in gigabytes. */ + public Double getMaxMonthlyResidentialProxyGbytes() { + return maxMonthlyResidentialProxyGbytes; + } + + /** The plan's monthly SERP proxy request cap. */ + public Double getMaxMonthlyProxySerps() { + return maxMonthlyProxySerps; + } + + /** The plan's monthly external data-transfer cap, in gigabytes. */ + public Double getMaxMonthlyExternalDataTransferGbytes() { + return maxMonthlyExternalDataTransferGbytes; + } + + /** The maximum number of Actors this plan allows the user to own. */ + public Long getMaxActorCount() { + return maxActorCount; + } + + /** The maximum number of tasks this plan allows the user to own. */ + public Long getMaxActorTaskCount() { + return maxActorTaskCount; + } + + /** How many days storage data is retained under this plan. */ + public Long getDataRetentionDays() { + return dataRetentionDays; + } + + /** + * The proxy groups available under this plan and their counts, as raw JSON (dynamic group-name + * keys). + */ + public JsonNode getAvailableProxyGroups() { + return availableProxyGroups; + } + + /** The number of team-account seats included in this plan. */ + public Long getTeamAccountSeatCount() { + return teamAccountSeatCount; + } + + /** The plan's support tier. */ + public String getSupportLevel() { + return supportLevel; + } + + /** + * Add-ons available under this plan, as raw JSON (shape varies per add-on; unmodifiable, never + * {@code null}). + */ + public List getAvailableAddOns() { + return availableAddOns == null ? List.of() : Collections.unmodifiableList(availableAddOns); + } +} diff --git a/src/main/java/com/apify/client/user/UserProfile.java b/src/main/java/com/apify/client/user/UserProfile.java new file mode 100644 index 0000000..3d19fe3 --- /dev/null +++ b/src/main/java/com/apify/client/user/UserProfile.java @@ -0,0 +1,44 @@ +package com.apify.client.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** The public profile of a {@link User}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class UserProfile { + private String bio; + private String name; + private String pictureUrl; + private String githubUsername; + private String websiteUrl; + private String twitterUsername; + + /** A short biography, if set. */ + public String getBio() { + return bio; + } + + /** The user's display name, if set. */ + public String getName() { + return name; + } + + /** URL of the user's profile picture, if set. */ + public String getPictureUrl() { + return pictureUrl; + } + + /** The user's linked GitHub username, if any. */ + public String getGithubUsername() { + return githubUsername; + } + + /** The user's personal website URL, if set. */ + public String getWebsiteUrl() { + return websiteUrl; + } + + /** The user's linked Twitter/X username, if any. */ + public String getTwitterUsername() { + return twitterUsername; + } +} diff --git a/src/main/java/com/apify/client/user/UserProxy.java b/src/main/java/com/apify/client/user/UserProxy.java new file mode 100644 index 0000000..e8ba03c --- /dev/null +++ b/src/main/java/com/apify/client/user/UserProxy.java @@ -0,0 +1,22 @@ +package com.apify.client.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Collections; +import java.util.List; + +/** A {@link User}'s Apify Proxy credentials and available proxy groups. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class UserProxy { + private String password; + private List groups = List.of(); + + /** The password used to authenticate with Apify Proxy. */ + public String getPassword() { + return password; + } + + /** The proxy groups available to this user (unmodifiable, never {@code null}). */ + public List getGroups() { + return groups == null ? List.of() : Collections.unmodifiableList(groups); + } +} diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatch.java b/src/main/java/com/apify/client/webhook/WebhookDispatch.java index e8bdf3b..952b1fb 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatch.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatch.java @@ -1,19 +1,73 @@ package com.apify.client.webhook; import com.apify.client.ApifyResource; +import java.time.Instant; +import java.util.Collections; +import java.util.List; /** A single invocation of a webhook. */ public final class WebhookDispatch extends ApifyResource { private String id; + private String userId; private String webhookId; + private Instant createdAt; + private String status; + private String eventType; + private List calls = List.of(); + private WebhookDispatchWebhookInfo webhook; + private WebhookDispatchEventData eventData; /** The unique dispatch ID. */ public String getId() { return id; } + /** The ID of the user who owns the webhook that produced this dispatch. */ + public String getUserId() { + return userId; + } + /** The ID of the webhook that produced this dispatch. */ public String getWebhookId() { return webhookId; } + + /** When the dispatch was created. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** + * The dispatch's status: one of {@code "ACTIVE"} (still retrying), {@code "SUCCEEDED"}, or {@code + * "FAILED"}. + */ + public String getStatus() { + return status; + } + + /** The type of event that triggered this dispatch (see {@link Webhook#getEventTypes()}). */ + public String getEventType() { + return eventType; + } + + /** + * The individual HTTP delivery attempts made for this dispatch (unmodifiable, never {@code + * null}). + */ + public List getCalls() { + // Null-coalesce: Jackson binds directly to the (private) `calls` field for deserialization, + // which bypasses the `= List.of()` field initializer whenever the API response contains an + // explicit `"calls": null`. + return calls == null ? List.of() : Collections.unmodifiableList(calls); + } + + /** A summary of the webhook that produced this dispatch, if included by the API. */ + public WebhookDispatchWebhookInfo getWebhook() { + return webhook; + } + + /** The event payload that triggered this dispatch, if any. */ + public WebhookDispatchEventData getEventData() { + return eventData; + } } diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java b/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java new file mode 100644 index 0000000..7fa2150 --- /dev/null +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java @@ -0,0 +1,41 @@ +package com.apify.client.webhook; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; + +/** A single HTTP delivery attempt made for a {@link WebhookDispatch}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class WebhookDispatchCall { + private Instant startedAt; + private Instant finishedAt; + private String errorMessage; + private Integer responseStatus; + private String responseBody; + + /** When this delivery attempt started. */ + public Instant getStartedAt() { + return startedAt; + } + + /** When this delivery attempt finished. */ + public Instant getFinishedAt() { + return finishedAt; + } + + /** + * The error message if the attempt failed before receiving a response, otherwise {@code null}. + */ + public String getErrorMessage() { + return errorMessage; + } + + /** The HTTP status code returned by the target URL, or {@code null} if none was received. */ + public Integer getResponseStatus() { + return responseStatus; + } + + /** The response body returned by the target URL, or {@code null} if none was received. */ + public String getResponseBody() { + return responseBody; + } +} diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java b/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java new file mode 100644 index 0000000..710d6c5 --- /dev/null +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java @@ -0,0 +1,35 @@ +package com.apify.client.webhook; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * The event payload that triggered a {@link WebhookDispatch}. Fields not relevant to the triggering + * event are {@code null}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class WebhookDispatchEventData { + private String actorRunId; + private String actorId; + private String actorTaskId; + private String actorBuildId; + + /** The ID of the Actor run that triggered the event, if any. */ + public String getActorRunId() { + return actorRunId; + } + + /** The ID of the Actor that triggered the event, if any. */ + public String getActorId() { + return actorId; + } + + /** The ID of the task that triggered the event, if any. */ + public String getActorTaskId() { + return actorTaskId; + } + + /** The ID of the build that triggered the event, if any. */ + public String getActorBuildId() { + return actorBuildId; + } +} diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java new file mode 100644 index 0000000..d49e863 --- /dev/null +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java @@ -0,0 +1,26 @@ +package com.apify.client.webhook; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A summary of the {@link Webhook} that produced a {@link WebhookDispatch} (only the subset of + * fields the API includes alongside a dispatch, not the full webhook object). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class WebhookDispatchWebhookInfo { + private String requestUrl; + private boolean isAdHoc; + + /** The URL the webhook posts to. */ + public String getRequestUrl() { + return requestUrl; + } + + /** + * Whether the webhook is a one-off webhook attached to a single run, rather than a persistent, + * account-level webhook (see {@link Webhook#isAdHoc()}). + */ + public boolean isAdHoc() { + return isAdHoc; + } +} diff --git a/src/test/java/com/apify/client/examples/GetAccount.java b/src/test/java/com/apify/client/examples/GetAccount.java index cf13e52..69738b9 100644 --- a/src/test/java/com/apify/client/examples/GetAccount.java +++ b/src/test/java/com/apify/client/examples/GetAccount.java @@ -20,8 +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")); + // email/plan/proxy/... are only populated for me() (not user(id)); any remaining unmodelled + // account field is still available via the untyped extras map that getExtra() returns. + System.out.println("Email: " + user.get().getEmail()); } } From 67e1a33a754ad77eb313f9b8f038c4cbd8103c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:48:52 +0000 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20loop-8=20DRY=20+=20magic-number=20?= =?UTF-8?q?cleanup=20=E2=80=94=20Q2/Q3/Q4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store/WebhookDispatch collection clients now extend AbstractCollectionClient (StoreListOptions implements ListOptionsLike); duplicated deadline-poll loops in integration tests routed through IntegrationBase.pollUntil; stream catch-up and retry-bound literals extracted to named constants. Offline gates green (spotless:check, compile, test-compile, spotbugs:check); live integration suite runs in CI. Loop-8 remainder (Q5 comments, Q6 imports/records, Q7 CRUD-flow list steps, Q8 docs) still pending. --- .../client/store/StoreCollectionClient.java | 43 +++---------------- .../apify/client/store/StoreListOptions.java | 13 ++++-- .../WebhookDispatchCollectionClient.java | 41 +++--------------- .../com/apify/client/StreamedLogTest.java | 32 +++++++------- .../integration/ActorRunIntegrationTest.java | 27 ++---------- .../client/integration/IntegrationBase.java | 21 ++++++++- .../integration/IterationIntegrationTest.java | 9 +++- .../integration/TaskIntegrationTest.java | 7 +-- 8 files changed, 72 insertions(+), 121 deletions(-) diff --git a/src/main/java/com/apify/client/store/StoreCollectionClient.java b/src/main/java/com/apify/client/store/StoreCollectionClient.java index f7e6b0c..c80916b 100644 --- a/src/main/java/com/apify/client/store/StoreCollectionClient.java +++ b/src/main/java/com/apify/client/store/StoreCollectionClient.java @@ -1,47 +1,18 @@ package com.apify.client.store; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import java.util.Iterator; /** A client for browsing the Apify Store ({@code GET /v2/store}). */ -public final class StoreCollectionClient { - private final ResourceContext ctx; +public final class StoreCollectionClient + extends AbstractCollectionClient { public StoreCollectionClient(HttpClientCore http, String baseUrl) { - this.ctx = ResourceContext.collection(http, baseUrl, ApiPaths.STORE); - } - - /** Returns a single page of Store Actors matching the options. */ - public PaginationList list(StoreListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, ActorStoreListItem.class); - } - - /** - * Returns a lazy iterator over 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 iterate(options, null); - } - - /** - * 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); + super( + ResourceContext.collection(http, baseUrl, ApiPaths.STORE), + ActorStoreListItem.class, + StoreListOptions::new); } } diff --git a/src/main/java/com/apify/client/store/StoreListOptions.java b/src/main/java/com/apify/client/store/StoreListOptions.java index bb9d5f4..785415a 100644 --- a/src/main/java/com/apify/client/store/StoreListOptions.java +++ b/src/main/java/com/apify/client/store/StoreListOptions.java @@ -1,9 +1,10 @@ package com.apify.client.store; +import com.apify.client.internal.ListOptionsLike; import com.apify.client.internal.QueryParams; /** Options for listing/iterating the Apify Store ({@code GET /v2/store}). */ -public final class StoreListOptions { +public final class StoreListOptions implements ListOptionsLike { private Long offset; private Long limit; private String search; @@ -81,15 +82,18 @@ public StoreListOptions responseFormat(String responseFormat) { return this; } - Long limitValue() { + @Override + public Long limitValue() { return limit; } + @Override public Long offsetValue() { return offset; } - void apply(QueryParams q) { + @Override + public void apply(QueryParams q) { q.addLong("offset", offset).addLong("limit", limit); applyFilters(q); } @@ -97,7 +101,8 @@ void apply(QueryParams q) { /** * Applies every filter except {@code offset}/{@code limit}, which the iterator drives per page. */ - void applyFilters(QueryParams q) { + @Override + public void applyFilters(QueryParams q) { q.addString("search", search) .addString("sortBy", sortBy) .addString("category", category) diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java index c81e9b0..fb11b4b 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchCollectionClient.java @@ -1,48 +1,21 @@ package com.apify.client.webhook; import com.apify.client.ListOptions; -import com.apify.client.PaginationList; +import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.HttpClientCore; -import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -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. */ -public final class WebhookDispatchCollectionClient { - private final ResourceContext ctx; +public final class WebhookDispatchCollectionClient + extends AbstractCollectionClient { public WebhookDispatchCollectionClient(HttpClientCore http, String baseUrl, String resourcePath) { - this.ctx = ResourceContext.collection(http, baseUrl, resourcePath); - } - - /** Lists webhook dispatches. */ - public PaginationList list(ListOptions options) { - QueryParams params = new QueryParams(); - options.apply(params); - return ctx.listResource("", params, WebhookDispatch.class); - } - - /** - * 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); + super( + ResourceContext.collection(http, baseUrl, resourcePath), + WebhookDispatch.class, + ListOptions::new); } } diff --git a/src/test/java/com/apify/client/StreamedLogTest.java b/src/test/java/com/apify/client/StreamedLogTest.java index dd972d9..f469b5d 100644 --- a/src/test/java/com/apify/client/StreamedLogTest.java +++ b/src/test/java/com/apify/client/StreamedLogTest.java @@ -1,5 +1,6 @@ package com.apify.client; +import static com.apify.client.integration.IntegrationBase.pollUntil; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -28,6 +29,16 @@ */ class StreamedLogTest { + /** Poll interval used while waiting for a background reader to redirect expected messages. */ + private static final long REDIRECT_POLL_MILLIS = 5; + + /** + * {@link #pollUntil} attempt count giving a 5-second total budget at {@link + * #REDIRECT_POLL_MILLIS} per attempt, used by every "wait for N messages to be redirected" check + * in this suite. + */ + private static final int REDIRECT_POLL_ATTEMPTS = (int) (5_000 / REDIRECT_POLL_MILLIS); + private static ApifyClient client(MockTransport backend) { return ApifyClient.builder().token("test-token").httpTransport(backend).maxRetries(0).build(); } @@ -177,7 +188,7 @@ void closeWithoutStartIsNoOp() { } @Test - void deliversLastMessageWhenStoppingLiveStream() throws InterruptedException { + void deliversLastMessageWhenStoppingLiveStream() { // 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, @@ -199,10 +210,7 @@ void deliversLastMessageWhenStoppingLiveStream() throws InterruptedException { 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); - } + pollUntil(REDIRECT_POLL_ATTEMPTS, REDIRECT_POLL_MILLIS, () -> collected.size() >= 2); assertEquals(2, collected.size(), "a and b should be redirected before stop()"); streamedLog.stop(); assertEquals( @@ -301,7 +309,7 @@ void closeStopsActiveRedirection() throws InterruptedException { } @Test - void stopFromInsideConsumerDoesNotDeadlock() throws InterruptedException { + void stopFromInsideConsumerDoesNotDeadlock() { // 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 @@ -332,10 +340,7 @@ void stopFromInsideConsumerDoesNotDeadlock() throws InterruptedException { })); ref.set(streamedLog); streamedLog.start(); - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (collected.size() < 2 && System.nanoTime() < deadline) { - Thread.sleep(5); - } + pollUntil(REDIRECT_POLL_ATTEMPTS, REDIRECT_POLL_MILLIS, () -> collected.size() >= 2); assertEquals( List.of("2999-01-01T00:00:00.000Z a", "2999-01-01T00:00:01.000Z b"), collected, @@ -343,7 +348,7 @@ void stopFromInsideConsumerDoesNotDeadlock() throws InterruptedException { } @Test - void closeFromInsideConsumerDoesNotDeadlock() throws InterruptedException { + void closeFromInsideConsumerDoesNotDeadlock() { // 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 @@ -365,10 +370,7 @@ void closeFromInsideConsumerDoesNotDeadlock() throws InterruptedException { })); ref.set(streamedLog); streamedLog.start(); - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (collected.size() < 2 && System.nanoTime() < deadline) { - Thread.sleep(5); - } + pollUntil(REDIRECT_POLL_ATTEMPTS, REDIRECT_POLL_MILLIS, () -> collected.size() >= 2); assertEquals( List.of("2999-01-01T00:00:00.000Z a", "2999-01-01T00:00:01.000Z b"), collected, diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index d8913e5..91920d0 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -237,15 +237,7 @@ void streamedLogRedirection() { // right away (which would cut the reader off before its first read), give it a bounded // window to catch up and flush; the log is static at this point, so waiting longer never // helps once it is genuinely empty. - long deadline = System.currentTimeMillis() + STREAM_CATCH_UP_TIMEOUT_MILLIS; - while (collected.isEmpty() && System.currentTimeMillis() < deadline) { - try { - Thread.sleep(STREAM_CATCH_UP_POLL_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } + pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); } // The catch-up window above closes an eventual-consistency race, but not a genuine one: the @@ -286,22 +278,9 @@ private static List collectFinishedRunLog(RunClient runClient) { runClient.getStreamedLog( new StreamedLogOptions().toLog(retryCollected::add).fromStart(true))) { retryStream.start(); - long deadline = System.currentTimeMillis() + STREAM_CATCH_UP_TIMEOUT_MILLIS; - while (retryCollected.isEmpty() && System.currentTimeMillis() < deadline) { - try { - Thread.sleep(STREAM_CATCH_UP_POLL_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } + pollUntil( + STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !retryCollected.isEmpty()); } return retryCollected; } - - /** Bounded window given to {@link #streamedLogRedirection} for the log to catch up. */ - private static final long STREAM_CATCH_UP_TIMEOUT_MILLIS = 15_000; - - /** Poll interval while waiting for the log to catch up in {@link #streamedLogRedirection}. */ - private static final long STREAM_CATCH_UP_POLL_MILLIS = 250; } diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index b08c973..7f72001 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -16,7 +16,7 @@ *

    Tests are designed to run concurrently — including against the same test account from several * language clients at once — so every test creates uniquely-named resources and cleans them up. */ -abstract class IntegrationBase { +public abstract class IntegrationBase { /** The integration-test contract fallback base URL. */ static final String DEFAULT_API_URL = "https://api.apify.com/v2"; @@ -29,6 +29,23 @@ abstract class IntegrationBase { */ static final long TEST_ACTOR_WAIT_SECS = 120L; + /** + * Bounded window given to a just-finished run's live log stream to catch up and flush its last + * bytes: {@code streamedLogRedirection}-style assertions can otherwise race the background reader + * thread, which may not have pulled any bytes off the stream yet even though the log content + * itself is already fully available server-side (see {@code + * ActorRunIntegrationTest#streamedLogRedirection}). Shared by every test that needs the same + * catch-up wait, via {@link #STREAM_CATCH_UP_ATTEMPTS} + {@link #pollUntil}. + */ + static final long STREAM_CATCH_UP_TIMEOUT_MILLIS = 15_000; + + /** Poll interval used while waiting out {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS}. */ + static final long STREAM_CATCH_UP_POLL_MILLIS = 250; + + /** {@link #pollUntil} attempt count equivalent to {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS}. */ + static final int STREAM_CATCH_UP_ATTEMPTS = + (int) (STREAM_CATCH_UP_TIMEOUT_MILLIS / STREAM_CATCH_UP_POLL_MILLIS); + private static final SecureRandom RANDOM = new SecureRandom(); /** @@ -83,7 +100,7 @@ static String uniqueName(String prefix) { * restoring the interrupt flag and returning {@code false} early, so callers don't need to * declare a checked exception just for this bounded, best-effort wait. */ - static boolean pollUntil(int maxAttempts, long backoffMillis, BooleanSupplier check) { + public static boolean pollUntil(int maxAttempts, long backoffMillis, BooleanSupplier check) { for (int attempt = 0; attempt < maxAttempts; attempt++) { if (check.getAsBoolean()) { return true; diff --git a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java index b390e7f..dc9009c 100644 --- a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java @@ -53,12 +53,19 @@ class IterationIntegrationTest extends IntegrationBase { private static final long ITER_FIND_BACKOFF_MILLIS = 1000L; + /** + * Upper bound on items scanned by {@link #findsAll}, purely as a safety net against an infinite + * loop if a collection endpoint's iterator ever misbehaves (e.g. never terminates); no assertion + * in this suite iterates anywhere near this many items. + */ + private static final int FIND_ALL_SAFETY_LIMIT = 10_000; + /** 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) { + while (it.hasNext() && !remaining.isEmpty() && safety++ < FIND_ALL_SAFETY_LIMIT) { remaining.remove(idOf.apply(it.next())); } return remaining.isEmpty(); diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index bb8f181..f08ec43 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -109,7 +109,7 @@ void taskLastRunAndWebhooks() { } @Test - void taskCallStreamsLogByDefault() throws InterruptedException { + void taskCallStreamsLogByDefault() { ApifyClient client = requireClient(); Task task = client.tasks().create(taskDef(uniqueName("task-call-log"))); try { @@ -128,10 +128,7 @@ void taskCallStreamsLogByDefault() throws InterruptedException { // finally-block close the stream) before the background reader's first read completes, even // though the log content is already fully available server-side. Give it a bounded window to // catch up rather than asserting immediately. - long deadline = System.currentTimeMillis() + 15_000; - while (collected.isEmpty() && System.currentTimeMillis() < deadline) { - Thread.sleep(250); - } + pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); } finally { client.task(task.getId()).delete(); From 643b9b4e639db5d11e9e8dba74f03b7393d7b465 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 21:07:51 +0000 Subject: [PATCH 12/18] =?UTF-8?q?fix:=20loop-8=20comments,=20idiomatic=20s?= =?UTF-8?q?tyle,=20CRUD-flow=20list()=20coverage,=20docs=20=E2=80=94=20Q5/?= =?UTF-8?q?Q6/Q7/Q8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trims the spotbugs-exclude.xml/StreamedLog/PaginationList review-nit comments; imports java.util.Map/Locale instead of inline FQNs; converts ApiResponse and HttpClientCore.Compressed to records (matching QueryParams.Pair), updating every field-access site and adding the resulting EI_EXPOSE exclusion; adds a top-level list() assertion to the Actor/Dataset/RequestQueue/KeyValueStore/ Webhook/Task CRUD-flow integration tests; documents RequestQueueRequest's getters, adds argument types to Dataset*/SetRecord/ValidateInput option field lists, and adds an ApifyClientBuilder + HttpTransport contract reference to the README. mvn spotless:check/compile/spotbugs:check/test (unit + live integration + ExamplesTest + DocSnippetsTest) all green: 187/187 passing, 0 failures. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HuTYpvY6WkVvmsXzupzEk9 --- CHANGELOG.md | 43 ++++++++++++++++ README.md | 40 +++++++++++++++ docs/actors.md | 2 +- docs/storages.md | 51 +++++++++++-------- spotbugs-exclude.xml | 22 ++++++-- .../java/com/apify/client/PaginationList.java | 13 ++--- .../com/apify/client/build/BuildClient.java | 2 +- .../apify/client/dataset/DatasetClient.java | 8 +-- .../com/apify/client/http/ApiResponse.java | 22 +++----- .../apify/client/internal/HttpClientCore.java | 23 ++++----- .../client/internal/ResourceContext.java | 18 +++---- .../client/keyvalue/KeyValueStoreClient.java | 4 +- .../java/com/apify/client/log/LogClient.java | 2 +- .../com/apify/client/log/StreamedLog.java | 2 +- .../apify/client/schedule/ScheduleClient.java | 2 +- .../com/apify/client/task/TaskClient.java | 2 +- .../client/ClientBehaviourRegressionTest.java | 9 ++-- .../java/com/apify/client/ClientMetaTest.java | 4 +- .../com/apify/client/CompressionTest.java | 12 ++--- .../integration/ActorIntegrationTest.java | 15 ++++++ .../integration/DatasetIntegrationTest.java | 15 ++++++ .../client/integration/IntegrationBase.java | 13 +++++ .../KeyValueStoreIntegrationTest.java | 16 ++++++ .../RequestQueueIntegrationTest.java | 16 ++++++ .../integration/TaskIntegrationTest.java | 11 ++++ .../integration/WebhookIntegrationTest.java | 15 ++++++ 26 files changed, 282 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1c695..a54a879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `com.apify.client.actor` (its only consumer is `ActorClient.validateInput`); its internal `apply`/`contentTypeOrDefault` are package-private again now that it's same-package with that consumer. +- **Breaking:** `ActorRunOptions.getTimeoutSecs()`/`getMemoryMbytes()`/`getDiskMbytes()` now return + boxed `Long` instead of primitive `long`, matching the boxed-when-optional convention used by + every sibling field, so an unset value is distinguishable from a real `0`. +- **Breaking:** `ApiResponse` is now a `record` (`statusCode`, `headers`, `body` are accessor + methods, e.g. `resp.body()`, not public fields); its shape and no-copy `body` contract are + unchanged. `HttpClientCore.Compressed` (internal, non-exported) is likewise now a record. +- `StoreCollectionClient` and `WebhookDispatchCollectionClient` now extend + `AbstractCollectionClient` instead of hand-rolling `list`/`iterate` (`StoreListOptions` now + implements `ListOptionsLike`), closing the two collection clients the loop-3 extraction missed; + behavior is unchanged. +- `RunClient.metamorph` now normalizes `targetActorId` via the same `username~actorName` safe-id + form as every other Actor-id-accepting call, instead of sending it raw. ### Added @@ -84,6 +96,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `actorStandby`), `ActorRun` (`generalAccess`, `chargedEventCounts`, `pricingInfo`, `usage`, `usageUsd`, `stats`, `options`, `meta`, `usageTotalUsd`, `buildNumber`, `exitCode`, `isContainerServerReady`, `gitBranchName`, `storageIds`). +- Further typed getters resolving the remaining thin-DTO gaps against the JS reference client: + `WebhookDispatch` (`status`, `eventType`, `userId`, `createdAt`, `calls`, `webhook`, `eventData`); + `Actor` (`actorStandby`, `stats`, `versions`, `pricingInfos`, `defaultRunOptions`, `taggedBuilds`, + `actorPermissionLevel`, `categories`, `isDeprecated`, `deploymentKey`, `seoTitle`, + `seoDescription`); `Build` (`userId`, `meta`, `stats`, `options`, `usage`, `usageUsd`, + `usageTotalUsd`); `User` (`profile`, `email`, `proxy`, `plan`, `effectivePlatformFeatures`, + `createdAt`, `isPaying`); `ActorStoreListItem` (`description`, `stats`, `currentPricingInfo`, + `pictureUrl`, `userPictureUrl`, `url`, `readmeSummary`). `getExtra()` remains available on every + touched model for any field still not individually typed. ### Fixed @@ -128,6 +149,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). field. - `RequestQueueClient`'s chunk-by-byte-size slicing now accounts for the inter-element commas in its incremental size estimate, matching the exact full-array measurement. +- `BatchDeleteResult.getProcessedRequests()`/`getUnprocessedRequests()` and + `RequestQueueRequest.getErrorMessages()` no longer throw `NullPointerException` on an explicit + JSON `null` for either field, mirroring `BatchAddResult`'s existing guard. +- `RunClient.metamorph`/`resurrect` and `ActorClient.build` now throw a clear + `IllegalArgumentException` when called with a `null` required options argument, instead of a + `NullPointerException` deep inside the method. +- `RunClient`'s default log-redirection prefix now uses the run's resolved real id instead of the + client's own `id` field, which is the literal string `"last"` for a `runs/last`-scoped client. ### Documentation @@ -167,6 +196,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and corrected `spotbugs-exclude.xml`'s header to accurately explain why its former `ApiResponse` EI_EXPOSE exclusion stopped reproducing (the no-copy contract never changed; the tool simply stopped flagging it) rather than implying the exposure was defensively fixed. +- `docs/storages.md` now spells out `RequestQueueRequest`'s getters by name (`getUrl`, + `getUniqueKey`, ...) next to their setters, and adds argument types to the `DatasetListItemsOptions` + /`DatasetDownloadOptions`/`SetRecordOptions` field lists (`docs/actors.md` likewise for + `ValidateInputOptions`). The top-level README's Configuration section gained an + `ApifyClientBuilder` setter reference table (default + valid range for every setter) and an + `HttpTransport` contract table (`send`/`sendStreamingResponse` signatures and behavior a + custom-transport implementer must satisfy). Trimmed `PaginationList`'s ~10-line public-setter + justification comment to two sentences and made `StreamedLog`'s internal `(see below)` comment + reference the `finally` block explicitly. +- Added a `list()` step to the Actor/Dataset/RequestQueue/KeyValueStore/Webhook/Task CRUD-flow + integration tests, verifying the just-created resource's id appears in its top-level collection + listing (polled for eventual consistency via a new shared `IntegrationBase.LIST_FIND_ATTEMPTS`/ + `LIST_FIND_BACKOFF_MILLIS`), matching the pattern the run-collection and Schedule flows already + used. ## [0.3.1] - 2026-07-14 diff --git a/README.md b/README.md index 6d9ec89..4784403 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,28 @@ ApifyClient configured = `Duration` above is `java.time.Duration`. +### `ApifyClientBuilder` reference + +Every setter is optional; leaving one uncalled keeps its default. `int`/`Duration` arguments are +validated at call time (see below), not deferred to `build()`. + +| Setter | Type | Default | Valid range | +|---|---|---|---| +| `token(String)` | `String` | none (unauthenticated) | any non-null string | +| `baseUrl(String)` | `String` | `https://api.apify.com` (`/v2` appended automatically) | non-null, non-blank | +| `publicBaseUrl(String)` | `String` | same as `baseUrl` | non-null, non-blank | +| `maxRetries(int)` | `int` | `8` | `>= 0` | +| `minDelayBetweenRetries(Duration)` | `Duration` | `500ms` | non-null, `>= 0` | +| `maxDelayBetweenRetries(Duration)` | `Duration` | same as `timeout` (`360s`) | non-null, `>= 0` | +| `timeout(Duration)` | `Duration` | `360s` | non-null, strictly `> 0` | +| `userAgentSuffix(String)` | `String` | none (no suffix appended) | any string | +| `httpTransport(HttpTransport)` | `HttpTransport` | `new DefaultHttpTransport()` | non-null | + +A violated range throws `IllegalArgumentException` from the setter itself. `timeout` is the ceiling +each retry attempt's per-request (socket) timeout grows toward — not a wall-clock bound on the +cumulative time across all retries; the connection-establishment timeout is a separate, +transport-level setting (see `DefaultHttpTransport`'s constructors below). + ### Replaceable HTTP transport The transport is a replaceable component. The default is `DefaultHttpTransport` (backed by the JDK's @@ -129,6 +151,24 @@ Cross-cutting behaviour applied to every request lives in the client, not the tr implementation: bearer-token authentication, the mandated `User-Agent` header, and retries with exponential backoff and jitter on `429`, `5xx` and network errors. +#### `HttpTransport` contract + +A custom implementation (`com.apify.client.http.HttpTransport`) must provide both methods: + +| Method | Signature | Contract | +|---|---|---| +| `send` | `HttpResponse send(HttpRequest request) throws IOException, InterruptedException` | Sends the single, fully-prepared request (auth, `User-Agent` and any other headers are already set by the client) and returns the response with its body fully buffered as `byte[]`. Used for every non-streaming call. | +| `sendStreamingResponse` | `HttpResponse sendStreamingResponse(HttpRequest request) throws IOException, InterruptedException` | Sends the request and returns the response body as a live `InputStream` for incremental consumption, used by log streaming (`LogClient.stream(...)`). The *caller* (the client) is responsible for closing the returned stream; an implementation must not close it itself before returning. | + +Both methods perform exactly one network round-trip each — no retrying, no request mutation +(retries, auth, and the `User-Agent` header are handled one layer up, by the client itself). A +non-2xx HTTP status is **not** a transport-level error: return it as a normal `HttpResponse` with +its actual status code. Only throw for a genuine transport failure — connection refused, DNS +resolution failure, or a timeout. Any thrown `IOException` (or `InterruptedException`, with the +interrupt flag restored) is translated by the client into an `ApifyTransportException`; throw +`com.apify.client.http.HttpTimeoutException` specifically for a timeout so +`ApifyTransportException.isTimeout()` reports it correctly. + ## Fetching single resources Methods that fetch a single resource return an `Optional`: a missing resource is reported by an diff --git a/docs/actors.md b/docs/actors.md index 26ca96a..e916023 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -49,7 +49,7 @@ Actor created = client.actors().create(Map.of( | `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Returns `ActorRun`. | | `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely); does **not** stream the run's log. Returns `ActorRun`. | | `call(Object input, ActorCallOptions, Long waitSecs)` | As above, additionally streaming the run's log for the duration of the wait by default (matching the reference client's `call` defaulting `options.log` to `'default'`). Use `ActorCallOptions.disableLogStreaming()` to opt out, or `logOptions(StreamedLogOptions)` for a custom destination. | -| `validateInput(Object input)` / `validateInput(Object input, ValidateInputOptions)` | Validate an input against the Actor's input schema. Returns `boolean`. `ValidateInputOptions` fields (optional): `build`, `contentType`. | +| `validateInput(Object input)` / `validateInput(Object input, ValidateInputOptions)` | Validate an input against the Actor's input schema. Returns `boolean`. `ValidateInputOptions` fields (optional): `build` (`String`), `contentType` (`String`). | | `build(String versionNumber, ActorBuildOptions)` | Build a version. Returns `Build`. | | `defaultBuild(Long waitForFinish)` | Resolve the default build. Returns `BuildClient`. | | `lastRun(String status)` / `lastRun(LastRunOptions)` | A `RunClient` for the last run. | diff --git a/docs/storages.md b/docs/storages.md index f7266f9..22e6d13 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -65,20 +65,23 @@ 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` (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`. +`DatasetListItemsOptions` fields: `offset` (`Long`, number of items to skip), `limit` (`Long`, +maximum number of items to return), `desc` (`Boolean`, return items newest-first), `fields` +(`List`, restrict the output to these source fields), `outputFields` (`List`, +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` (`List`, exclude +these fields from the output), `skipEmpty` (`Boolean`, skip empty items), `skipHidden` (`Boolean`, +skip hidden fields, i.e. those starting with `#`), `clean` (`Boolean`, return only clean — +non-empty, non-hidden — items), `unwind` (`List`, expand these fields so each array element +becomes a separate item), `flatten` (`List`, flatten these nested fields into dot-notation +keys), `view` (`String`, select a predefined dataset view for field selection), `simplified` +(`Boolean`, return simplified — flattened and cleaned — items), `skipFailedPages` (`Boolean`, skip +items that come from failed pages), `signature` (`String`, a pre-shared URL signature granting +access without an API token). `downloadItems(...)` returns `byte[]` (the serialized export). +`DatasetDownloadOptions` wraps a `DatasetListItemsOptions` (`items(DatasetListItemsOptions)`) and +adds `attachment` (`Boolean`), `bom` (`Boolean`), `delimiter` (`String`), `skipHeaderRow` +(`Boolean`), `xmlRoot` (`String`), `xmlRow` (`String`), `feedTitle` (`String`), `feedDescription` +(`String`). `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` returns a `String` URL. If the dataset is private, the client fetches it, reads its URL-signing secret, and appends an HMAC-SHA256 @@ -111,7 +114,7 @@ datasets. | `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. No return value. | -| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`timeoutSecs`, `doNotRetryTimeouts`). No return value. | +| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`SetRecordOptions`: `timeoutSecs` (`Long`), `doNotRetryTimeouts` (`Boolean`)). 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. | @@ -239,12 +242,18 @@ queue.unlockRequests(); `RequestQueueRequest` models a request. Its `(url, uniqueKey)` constructor covers the common case (`uniqueKey` is the deduplication key); fluent setters and matching getters cover the rest (unset -fields are omitted on the wire): `setId`, `setUrl`, `setUniqueKey`, `setMethod`, -`setUserData(JsonNode)`, `setPayload(String)` (the HTTP request body), `setHeaders(Map)`, `setNoRetry(Boolean)`, `setHandledAt(Instant)`, `setRetryCount(Integer)`, -`setLoadedUrl(String)` (the URL actually loaded, after redirects), and -`setErrorMessages(List)`. `getLockExpiresAt()` (read-only, no setter) is populated only on -items returned from `listAndLockHead`. +fields are omitted on the wire): `setId`/`getId` (`String`), `setUrl`/`getUrl` (`String`), +`setUniqueKey`/`getUniqueKey` (`String`), `setMethod`/`getMethod` (`String`), +`setUserData(JsonNode)`/`getUserData()` (`JsonNode`), +`setPayload(String)`/`getPayload()` (`String`, the HTTP request body), +`setHeaders(Map)`/`getHeaders()` (`Map`), +`setNoRetry(Boolean)`/`getNoRetry()` (`Boolean`), +`setHandledAt(Instant)`/`getHandledAt()` (`Instant`), +`setRetryCount(Integer)`/`getRetryCount()` (`Integer`), +`setLoadedUrl(String)`/`getLoadedUrl()` (`String`, the URL actually loaded, after redirects), and +`setErrorMessages(List)`/`getErrorMessages()` (`List`, never `null` — empty when +unset). `getLockExpiresAt()` (read-only, no setter, `Instant`) is populated only on items returned +from `listAndLockHead`. Return types: - `RequestQueueOperationInfo` (from `addRequest`/`updateRequest`): `getRequestId()`, diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 729bd2c..49d9be3 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -25,11 +25,13 @@ deserializer via reflection, which SpotBugs cannot see - a known false positive for data-binding models. - Note: {@code ApiResponse}'s {@code body} field intentionally holds the raw response bytes - without a defensive copy (the payload can be large, e.g. dataset exports, so copying it on every - access would be wasteful). It has no entry in this filter: per this file's own rule of dropping - exclusions that no longer reproduce anything, {@code ApiResponse} is not currently flagged by - SpotBugs for this field, even though the no-copy contract itself is deliberate and unchanged. + 3. EI_EXPOSE_REP / EI_EXPOSE_REP2 on {@code ApiResponse} and {@code HttpClientCore$Compressed}'s + {@code body} component: both are {@code record}s whose {@code byte[] body} intentionally holds + the raw payload without a defensive copy (the payload can be large, e.g. dataset exports, so + copying it on every construction/access would be wasteful). A plain field of the same shape is + not flagged (SpotBugs' constructor/getter analysis does not trace a raw public field the way it + traces a record's synthesized accessor and canonical constructor), so this pair of exclusions is + specific to the record form. --> @@ -62,4 +64,14 @@ + + + + + + + + + + diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java index 827c474..c9dc39f 100644 --- a/src/main/java/com/apify/client/PaginationList.java +++ b/src/main/java/com/apify/client/PaginationList.java @@ -59,16 +59,9 @@ public List getItems() { return items == null ? List.of() : Collections.unmodifiableList(items); } - // Setters used by the collection-listing and dataset-items paths, which build a page from a - // parsed response (or, for dataset items, from response headers) one field at a time. These stay - // public rather than package-private: DatasetClient (com.apify.client.dataset, the only caller - // outside Jackson's own field-based deserialization) builds a page's metadata one header at a - // time in listItems/downloadItems, and Java has no cross-package "friend" visibility - narrowing - // this without a public setter would mean either moving that header-parsing logic into this - // (root) package, which is a bigger surgery than this getter surface warrants, or introducing a - // module-info split purely for this one class. Left as-is; a caller holding a page can mutate it - // after the fact, which is a real but minor and pre-existing encapsulation gap, not a correctness - // bug (this client never re-reads a page after constructing it). + // Public (not package-private) because DatasetClient, in a different package, builds a page's + // metadata from response headers one field at a time and Java has no cross-package visibility + // between "public" and "package-private". public void setTotal(long total) { this.total = total; } diff --git a/src/main/java/com/apify/client/build/BuildClient.java b/src/main/java/com/apify/client/build/BuildClient.java index 915f719..ea3f651 100644 --- a/src/main/java/com/apify/client/build/BuildClient.java +++ b/src/main/java/com/apify/client/build/BuildClient.java @@ -65,7 +65,7 @@ public Optional getOpenApiDefinition() { if (resp == null) { return Optional.empty(); } - return Optional.of(Json.parse(resp.body, JsonNode.class)); + return Optional.of(Json.parse(resp.body(), JsonNode.class)); } /** A client for accessing this build's log. */ diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index a2536de..b644149 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -156,7 +156,7 @@ private PaginationList fetchItemsPage( ApiResponse resp = http.call("GET", url, null, "", http.baseRequestTimeout()); JavaType listType = Json.parametric(List.class, Json.type(itemClass)); - List items = Json.parse(resp.body, listType); + List items = Json.parse(resp.body(), listType); long count = items.size(); PaginationList result = new PaginationList<>(); @@ -182,7 +182,7 @@ public byte[] downloadItems(DownloadItemsFormat format, DatasetDownloadOptions o options.apply(params); String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); ApiResponse resp = http.call("GET", url, null, "", http.baseRequestTimeout()); - return resp.body; + return resp.body(); } /** @@ -207,7 +207,7 @@ public Optional getStatistics() { if (resp == null) { return Optional.empty(); } - return Optional.of(Json.parseData(resp.body, JsonNode.class)); + return Optional.of(Json.parseData(resp.body(), JsonNode.class)); } /** @@ -237,6 +237,6 @@ public String createItemsPublicUrl(DatasetListItemsOptions options, Long expires } private static long headerLong(ApiResponse resp, String name, long fallback) { - return resp.headers.firstValueAsLong(name).orElse(fallback); + return resp.headers().firstValueAsLong(name).orElse(fallback); } } diff --git a/src/main/java/com/apify/client/http/ApiResponse.java b/src/main/java/com/apify/client/http/ApiResponse.java index 8b98c7c..ecc729d 100644 --- a/src/main/java/com/apify/client/http/ApiResponse.java +++ b/src/main/java/com/apify/client/http/ApiResponse.java @@ -9,22 +9,12 @@ *

    This is a plain, public data carrier - it is legitimately exported, not internal plumbing: * {@code com.apify.client.internal}'s HTTP core builds it as the result of every call it makes, and * it is used as the local unwrapped-response type across the resource clients in this module. The - * fields are exposed raw (no defensive copy of {@code body}) by design; see the {@code + * {@code body} component is exposed raw (no defensive copy) by design; see the {@code * spotbugs-exclude.xml} header for why that is a deliberate no-copy contract rather than an * oversight. + * + * @param statusCode the HTTP status code + * @param headers the response headers + * @param body the fully-buffered response body */ -public final class ApiResponse { - public final int statusCode; - public final HttpHeaders headers; - public final byte[] body; - - /** - * Public so the internal HTTP client (in a separate, non-exported package) can build one; end - * users are not expected to construct this directly. - */ - public ApiResponse(int statusCode, HttpHeaders headers, byte[] body) { - this.statusCode = statusCode; - this.headers = headers; - this.body = body; - } -} +public record ApiResponse(int statusCode, HttpHeaders headers, byte[] body) {} diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index 7dc4071..d1da4d0 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -170,9 +170,9 @@ public ApiResponse call( Map headers = extraHeaders; if (shouldCompress(requestBody, extraHeaders)) { Compressed compressed = compress(requestBody, BROTLI_AVAILABLE); - requestBody = compressed.body; + requestBody = compressed.body(); headers = new LinkedHashMap<>(extraHeaders == null ? Map.of() : extraHeaders); - headers.put(CONTENT_ENCODING_HEADER, compressed.encoding); + headers.put(CONTENT_ENCODING_HEADER, compressed.encoding()); } Duration delay = retry.minDelayBetweenRetries; @@ -191,11 +191,11 @@ public ApiResponse call( contentType, headers, attemptTimeout(baseTimeout, attempt)); - if (resp.statusCode < MAX_SUCCESS_STATUS) { + if (resp.statusCode() < MAX_SUCCESS_STATUS) { return resp; } - lastError = buildApiError(resp.statusCode, resp.body, attempt, method, path); - retryable = isStatusRetryable(resp.statusCode); + lastError = buildApiError(resp.statusCode(), resp.body(), attempt, method, path); + retryable = isStatusRetryable(resp.statusCode()); } catch (ApifyTransportException e) { lastError = e; // Network/timeout failures are retryable, unless the caller opted out of retrying timeouts. @@ -300,16 +300,11 @@ private static boolean shouldCompress(byte[] body, Map extraHead /** * A compressed request body together with the {@code Content-Encoding} token that describes it. + * + * @param body the compressed request body + * @param encoding the {@code Content-Encoding} token describing {@code body} */ - public static final class Compressed { - public final byte[] body; - public final String encoding; - - Compressed(byte[] body, String encoding) { - this.body = body; - this.encoding = encoding; - } - } + public record Compressed(byte[] body, String encoding) {} /** * Compresses a request body, preferring brotli and falling back to gzip, matching the reference diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index 53a92ea..b43fe97 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -180,7 +180,7 @@ public Optional getResource(String subPath, QueryParams params, Class public T getResourceRequired(String subPath, QueryParams params, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("GET", u, null, "", http.baseRequestTimeout()); - return Json.parseData(resp.body, dataType); + return Json.parseData(resp.body(), dataType); } public T getResourceRequired(String subPath, QueryParams params, Class dataClass) { @@ -191,7 +191,7 @@ public T updateResource(String subPath, Object body, Class dataClass) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataClass); + return Json.parseData(resp.body(), dataClass); } /** Performs a DELETE; a not-found is treated as a successful no-op. */ @@ -244,7 +244,7 @@ public T createResource(QueryParams params, Object body, Class dataClass) String u = mergedParams(params).applyToUrl(subUrl("")); ApiResponse resp = http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataClass); + return Json.parseData(resp.body(), dataClass); } /** POST that gets-or-creates a named resource ({@code POST {collection}?name=...}). */ @@ -278,7 +278,7 @@ public T getOrCreateNamed(String name, Object body, Class dataClass) { ApiResponse resp = http.call( "POST", u, bodyBytes, body == null ? "" : CONTENT_TYPE_JSON, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataClass); + return Json.parseData(resp.body(), dataClass); } /** POST with a raw body (optional) and content type, unwrapping the data envelope. */ @@ -291,7 +291,7 @@ public T postWithBody( String subPath, QueryParams params, byte[] body, String contentType, JavaType dataType) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataType); + return Json.parseData(resp.body(), dataType); } /** @@ -303,7 +303,7 @@ public T postWithBodyNoEnvelope( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("POST", u, body, contentType, http.baseRequestTimeout()); - return Json.parse(resp.body, dataClass); + return Json.parse(resp.body(), dataClass); } /** PUT with a raw body (optional) and content type, unwrapping the data envelope. */ @@ -311,7 +311,7 @@ public T putWithBody( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("PUT", u, body, contentType, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataClass); + return Json.parseData(resp.body(), dataClass); } /** @@ -323,7 +323,7 @@ public T putWithBodyNoEnvelope( String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("PUT", u, body, contentType, http.baseRequestTimeout()); - return Json.parse(resp.body, dataClass); + return Json.parse(resp.body(), dataClass); } /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ @@ -331,7 +331,7 @@ public T deleteWithBody(String subPath, QueryParams params, Object body, Cla String u = mergedParams(params).applyToUrl(subUrl(subPath)); ApiResponse resp = http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()); - return Json.parseData(resp.body, dataClass); + return Json.parseData(resp.body(), dataClass); } /** GET returning the raw response (no data envelope). Returns {@code null} on not-found. */ diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index 78731f4..9a6a85c 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -192,8 +192,8 @@ public Optional getRecord(String key, GetRecordOptions opti if (resp == null) { return Optional.empty(); } - String contentType = resp.headers.firstValue(HttpHeaders.CONTENT_TYPE).orElse(null); - return Optional.of(new KeyValueStoreRecord(key, resp.body, contentType)); + String contentType = resp.headers().firstValue(HttpHeaders.CONTENT_TYPE).orElse(null); + return Optional.of(new KeyValueStoreRecord(key, resp.body(), contentType)); } /** Stores a record with raw bytes and the given content type. */ diff --git a/src/main/java/com/apify/client/log/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java index b23967e..5ed3f54 100644 --- a/src/main/java/com/apify/client/log/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -49,7 +49,7 @@ public Optional get(LogOptions options) { if (resp == null) { return Optional.empty(); } - return Optional.of(new String(resp.body, StandardCharsets.UTF_8)); + return Optional.of(new String(resp.body(), StandardCharsets.UTF_8)); } /** diff --git a/src/main/java/com/apify/client/log/StreamedLog.java b/src/main/java/com/apify/client/log/StreamedLog.java index 960274b..1a92d18 100644 --- a/src/main/java/com/apify/client/log/StreamedLog.java +++ b/src/main/java/com/apify/client/log/StreamedLog.java @@ -171,7 +171,7 @@ private void stopStreaming() { 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). + // outside the try so the `finally` block below can still emit it during the final flush. 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 diff --git a/src/main/java/com/apify/client/schedule/ScheduleClient.java b/src/main/java/com/apify/client/schedule/ScheduleClient.java index 1192721..9604a74 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleClient.java @@ -37,6 +37,6 @@ public Optional getLog() { if (resp == null) { return Optional.empty(); } - return Optional.of(new String(resp.body, StandardCharsets.UTF_8)); + return Optional.of(new String(resp.body(), StandardCharsets.UTF_8)); } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 5b34c87..0802289 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -99,7 +99,7 @@ public Optional getInput() { if (resp == null) { return Optional.empty(); } - return Optional.of(Json.parse(resp.body, JsonNode.class)); + return Optional.of(Json.parse(resp.body(), JsonNode.class)); } /** Replaces the task's stored input and returns the updated input. */ diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 18aea51..2387563 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -32,6 +32,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -143,9 +144,7 @@ void metamorphSendsTargetActorIdBuildAndInputBody() { client(backend) .run("run123") .metamorph( - "apify/other-actor", - java.util.Map.of("foo", "bar"), - new MetamorphOptions().build("1.2.3")); + "apify/other-actor", Map.of("foo", "bar"), new MetamorphOptions().build("1.2.3")); assertTrue(backend.lastUrl.contains("actor-runs/run123/metamorph"), backend.lastUrl); // targetActorId is normalized to the URL-safe username~actor-name form before sending // (ResourceContext.toSafeId), matching the reference client's _toSafeId; '~' is then @@ -540,7 +539,7 @@ void nestedWebhookCollectionListsWithoutCreate() { @Test void accountWebhookCollectionCanCreate() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"wh1\"}}"); - Webhook created = client(backend).webhooks().create(java.util.Map.of("eventTypes", List.of())); + Webhook created = client(backend).webhooks().create(Map.of("eventTypes", List.of())); assertEquals("wh1", created.getId()); assertTrue(backend.lastUrl.endsWith("/webhooks"), backend.lastUrl); } @@ -548,7 +547,7 @@ void accountWebhookCollectionCanCreate() { @Test void updateLimitsSendsPutToMeLimits() { MockTransport backend = MockTransport.ofConstant(200, "{}"); - client(backend).me().updateLimits(java.util.Map.of("maxMonthlyUsageUsd", 100)); + client(backend).me().updateLimits(Map.of("maxMonthlyUsageUsd", 100)); assertTrue(backend.lastUrl.endsWith("/users/me/limits"), backend.lastUrl); assertTrue(backend.lastBody.contains("\"maxMonthlyUsageUsd\":100"), backend.lastBody); assertEquals(1, backend.calls); diff --git a/src/test/java/com/apify/client/ClientMetaTest.java b/src/test/java/com/apify/client/ClientMetaTest.java index d99c03b..11255a3 100644 --- a/src/test/java/com/apify/client/ClientMetaTest.java +++ b/src/test/java/com/apify/client/ClientMetaTest.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.Locale; import java.util.Properties; import org.junit.jupiter.api.Test; @@ -63,8 +64,7 @@ void userAgentOsTokenIsShortAndLowercase() { // the reference JS client's os.platform() output — never the human-readable os.name. String token = ApifyClientBuilder.platformToken(); assertFalse(token.isEmpty(), "platform token must not be empty"); - assertEquals( - token.toLowerCase(java.util.Locale.ROOT), token, "token must be lowercase: " + token); + assertEquals(token.toLowerCase(Locale.ROOT), token, "token must be lowercase: " + token); assertFalse(token.contains(" "), "token must not contain spaces: " + token); String ua = ApifyClient.create("token").getUserAgent(); diff --git a/src/test/java/com/apify/client/CompressionTest.java b/src/test/java/com/apify/client/CompressionTest.java index 7eea71a..37cbe84 100644 --- a/src/test/java/com/apify/client/CompressionTest.java +++ b/src/test/java/com/apify/client/CompressionTest.java @@ -84,10 +84,10 @@ void brotliPathEncodesAsBrAndRoundTrips() throws IOException { byte[] payload = payload(4096, (byte) 'a'); HttpClientCore.Compressed c = HttpClientCore.compress(payload, true); - assertEquals("br", c.encoding, "brotli path must announce Content-Encoding: br"); - assertTrue(c.body.length < payload.length, "brotli-compressed body should be smaller"); + assertEquals("br", c.encoding(), "brotli path must announce Content-Encoding: br"); + assertTrue(c.body().length < payload.length, "brotli-compressed body should be smaller"); assertArrayEquals( - payload, unbrotli(c.body), "server must recover the original body from brotli"); + payload, unbrotli(c.body()), "server must recover the original body from brotli"); } @Test @@ -95,9 +95,9 @@ void gzipPathEncodesAsGzipAndRoundTrips() throws IOException { byte[] payload = payload(4096, (byte) 'a'); HttpClientCore.Compressed c = HttpClientCore.compress(payload, false); - assertEquals("gzip", c.encoding, "gzip fallback must announce Content-Encoding: gzip"); - assertTrue(c.body.length < payload.length, "gzip-compressed body should be smaller"); - assertArrayEquals(payload, gunzip(c.body), "server must recover the original body from gzip"); + assertEquals("gzip", c.encoding(), "gzip fallback must announce Content-Encoding: gzip"); + assertTrue(c.body().length < payload.length, "gzip-compressed body should be smaller"); + assertArrayEquals(payload, gunzip(c.body()), "server must recover the original body from gzip"); } // --- Brotli native codec must load where a native is bundled (CI/runtime), so brotli runs. --- diff --git a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java index f7e295b..bb96f61 100644 --- a/src/test/java/com/apify/client/integration/ActorIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorIntegrationTest.java @@ -78,6 +78,21 @@ void actorCrudFlow() { assertEquals("Updated Title", updated.getTitle()); actor.builds().list(new ListOptions()); actor.versions().list(new ListOptions()); + + // list() step of the create/get/modify/list/delete flow: verify the just-created Actor + // appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client + .actors() + .list(new ActorListOptions().my(true).desc(true).limit(10L)) + .getItems() + .stream() + .anyMatch(a -> created.getId().equals(a.getId()))); + assertTrue(foundInList, "expected the just-created Actor to appear in the top-level list"); } finally { client.actor(created.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java index ebea377..45cf5df 100644 --- a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java @@ -68,6 +68,21 @@ void datasetCrudFlow() { Dataset updated = dataset.update(Map.of("name", uniqueName("ds-renamed"))); assertTrue(updated.getName() != null && !updated.getName().isEmpty()); + + // list() step of the create/get/modify/list/delete flow: verify the just-created dataset + // appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client + .datasets() + .list(new StorageListOptions().desc(true).limit(10L)) + .getItems() + .stream() + .anyMatch(d -> ds.getId().equals(d.getId()))); + assertTrue(foundInList, "expected the just-created dataset to appear in the top-level list"); } finally { client.dataset(ds.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index 7f72001..a16f685 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -46,6 +46,19 @@ public abstract class IntegrationBase { static final int STREAM_CATCH_UP_ATTEMPTS = (int) (STREAM_CATCH_UP_TIMEOUT_MILLIS / STREAM_CATCH_UP_POLL_MILLIS); + /** + * {@link #pollUntil} attempt count bounding how long a CRUD-flow test waits for a just-created + * resource to surface in its own top-level collection {@code list()} (a write and the LIST + * endpoint's index can converge asynchronously - the same class of eventual-consistency race as + * {@link #STREAM_CATCH_UP_ATTEMPTS} and {@code ActorRunIntegrationTest#RUN_LIST_FIND_ATTEMPTS}, + * just with a shorter budget since these are exclusively-owned resources, not entries in a + * heavily-shared public collection). + */ + static final int LIST_FIND_ATTEMPTS = 5; + + /** Poll interval used while waiting out {@link #LIST_FIND_ATTEMPTS}. */ + static final long LIST_FIND_BACKOFF_MILLIS = 500L; + private static final SecureRandom RANDOM = new SecureRandom(); /** diff --git a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java index 1f9a423..cf37a31 100644 --- a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java @@ -76,6 +76,22 @@ void keyValueStoreCrudFlow() throws Exception { assertTrue(!keys.getItems().isEmpty()); kvs.update(Map.of("name", uniqueName("kvs-renamed"))); kvs.deleteRecord("OUTPUT"); + + // list() step of the create/get/modify/list/delete flow: verify the just-created key-value + // store appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client + .keyValueStores() + .list(new StorageListOptions().desc(true).limit(10L)) + .getItems() + .stream() + .anyMatch(s -> store.getId().equals(s.getId()))); + assertTrue( + foundInList, "expected the just-created key-value store to appear in the top-level list"); } finally { client.keyValueStore(store.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java index 1cc62e0..c4b85a6 100644 --- a/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/RequestQueueIntegrationTest.java @@ -65,6 +65,22 @@ void requestQueueCrudFlow() { assertTrue(!queue.listHead(10L).getItems().isEmpty()); queue.update(Map.of("name", uniqueName("rq-renamed"))); queue.deleteRequest(info.getRequestId()); + + // list() step of the create/get/modify/list/delete flow: verify the just-created request + // queue appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client + .requestQueues() + .list(new StorageListOptions().desc(true).limit(10L)) + .getItems() + .stream() + .anyMatch(q -> rq.getId().equals(q.getId()))); + assertTrue( + foundInList, "expected the just-created request queue to appear in the top-level list"); } finally { client.requestQueue(rq.getId()).delete(); } diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index f08ec43..0153a15 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -65,6 +65,17 @@ void taskCrudFlow() { tc.update(Map.of("name", uniqueName("task-renamed"))); tc.runs().list(new ListOptions(), new RunListOptions()); + // list() step of the create/get/modify/list/delete flow: verify the just-created task + // appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client.tasks().list(new ListOptions().desc(true).limit(10L)).getItems().stream() + .anyMatch(t -> task.getId().equals(t.getId()))); + assertTrue(foundInList, "expected the just-created task to appear in the top-level list"); + // Typed getters (previously only reachable via getExtra()): verify the API's response // actually deserializes into them, not just that the code compiles. assertEquals("Integration test task", task.getDescription()); diff --git a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java index 0b1a5f3..86a3f4b 100644 --- a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java @@ -77,6 +77,21 @@ void webhookCrudFlow() { webhook.dispatches().list(new ListOptions()); webhook.test(); + // list() step of the create/get/modify/list/delete flow: verify the just-created webhook + // appears in the top-level collection listing. + boolean foundInList = + pollUntil( + LIST_FIND_ATTEMPTS, + LIST_FIND_BACKOFF_MILLIS, + () -> + client + .webhooks() + .list(new ListOptions().desc(true).limit(10L)) + .getItems() + .stream() + .anyMatch(w -> wh.getId().equals(w.getId()))); + assertTrue(foundInList, "expected the just-created webhook to appear in the top-level list"); + // Typed getters (previously only reachable via getExtra()): verify the API's response // actually deserializes into them, not just that the code compiles. assertTrue(wh.isAdHoc()); From b423f6bfa00b9c92493b0846806b5a8bc672e0a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 04:47:11 +0000 Subject: [PATCH 13/18] =?UTF-8?q?docs:=20loop-9=20review=20polish=20?= =?UTF-8?q?=E2=80=94=20CHANGELOG=20process-narrative=20cleanup,=20DTO-pari?= =?UTF-8?q?ty=20package-table=20entries,=20HttpTransport=20JDK-type=20disc?= =?UTF-8?q?losure,=20typed=20field=20lists,=20builder=20validation-timing?= =?UTF-8?q?=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses R1-R5 from the loop-8 review: drops a process-narrative clause from CHANGELOG, adds the loop-8 DTO-parity nested types to docs/README.md's package table, discloses HttpRequest/HttpResponse as java.net.http types in the HttpTransport contract, adds Java types to RunResurrectOptions/ TaskOptions/ActorStartOptions field lists plus a publicBaseUrl(String) explainer and ActorStandby package notes, and reconciles the ApifyClientBuilder validation-timing wording (call-time, not build-time) between CHANGELOG, README and the PR description. --- CHANGELOG.md | 13 +++++++------ README.md | 11 ++++++++++- docs/README.md | 10 +++++----- docs/actors.md | 20 +++++++++++--------- docs/runs.md | 4 ++-- docs/tasks.md | 11 ++++++----- 6 files changed, 41 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54a879..b5a835f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,8 +53,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Extracted a shared `RunStartSupport` helper (internal) backing `ActorClient`/`TaskClient`'s `start`/`call`, removing the duplicated implementation between the two. `ActorClient`/ `TaskClient` pass their options' behavior in as plain values and method references (e.g. - `options::apply`), so `ActorStartOptions`/`TaskStartOptions`/`ActorCallOptions`/`TaskCallOptions` - stay package-private with no public marker interface; behavior is unchanged. + `options::apply`), so `ActorStartOptions`/`TaskStartOptions`/`ActorCallOptions`/`TaskCallOptions`'s + internal `apply`/`contentTypeOrDefault` accessors stay package-private with no public marker + interface needed; the options classes themselves remain public. Behavior is unchanged. - Tightened `spotbugs-exclude.xml` to only the entries that currently reproduce a real finding. - `java-integration-tests.yml` now declares an explicit, least-privilege `permissions: contents: read` block. @@ -70,8 +71,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). unchanged. `HttpClientCore.Compressed` (internal, non-exported) is likewise now a record. - `StoreCollectionClient` and `WebhookDispatchCollectionClient` now extend `AbstractCollectionClient` instead of hand-rolling `list`/`iterate` (`StoreListOptions` now - implements `ListOptionsLike`), closing the two collection clients the loop-3 extraction missed; - behavior is unchanged. + implements `ListOptionsLike`); behavior is unchanged. - `RunClient.metamorph` now normalizes `targetActorId` via the same `username~actorName` safe-id form as every other Actor-id-accepting call, instead of sending it raw. @@ -122,8 +122,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `PaginationList.setItems` now defensively copies its input. - `ApifyClient`'s class javadoc no longer duplicates the "official, but experimental" disclaimer; it appears only once, in the top-level README, as required. -- `ApifyClientBuilder.timeout(Duration)` now rejects `Duration.ZERO`/a negative duration at build - time instead of building a client whose first request fails deep inside the transport. +- `ApifyClientBuilder.timeout(Duration)` now rejects `Duration.ZERO`/a negative duration from the + setter itself, at call time, instead of building a client whose first request fails deep inside + the transport. - `RequestQueueRequest.getUserData()`/`setUserData()` now defensively deep-copy the `JsonNode`, matching `getHeaders()`/`getErrorMessages()`, so external mutation of the passed-in/returned value can no longer corrupt the request's stored state. diff --git a/README.md b/README.md index 4784403..c733167 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ each retry attempt's per-request (socket) timeout grows toward — not a wall-cl cumulative time across all retries; the connection-establishment timeout is a separate, transport-level setting (see `DefaultHttpTransport`'s constructors below). +`publicBaseUrl` does not affect ordinary API requests (those always use `baseUrl`); it only changes +the origin embedded in the handful of public, directly-fetchable URLs the client builds without a +request — `DatasetClient.createItemsPublicUrl(...)` and `KeyValueStoreClient.getRecordPublicUrl(...)`/ +`createKeysPublicUrl(...)` (see [Storages](docs/storages.md)). Set it when the API is reached +through one origin (e.g. an internal proxy) but those public URLs must point at a different, +externally-reachable one. + ### Replaceable HTTP transport The transport is a replaceable component. The default is `DefaultHttpTransport` (backed by the JDK's @@ -153,7 +160,9 @@ exponential backoff and jitter on `429`, `5xx` and network errors. #### `HttpTransport` contract -A custom implementation (`com.apify.client.http.HttpTransport`) must provide both methods: +A custom implementation (`com.apify.client.http.HttpTransport`) must provide both methods. +`HttpRequest` and `HttpResponse` below are the JDK's `java.net.http.HttpRequest`/ +`java.net.http.HttpResponse` (not custom client types) — same as `Duration` above: | Method | Signature | Contract | |---|---|---| diff --git a/docs/README.md b/docs/README.md index be24454..73a0142 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,17 +37,17 @@ option types, so import each resource you use from its own package: |---|---| | `com.apify.client` (root) | `ApifyClient`, `ApifyClientBuilder`, `Version`, `PaginationList`, `ListOptions`, `StorageListOptions`, `ApifyResource` | | `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException` | -| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient`, `ValidateInputOptions` | -| `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient` | +| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient`, `ValidateInputOptions`, `ActorStats`, `ActorDefaultRunOptions` | +| `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient`, `BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage` | | `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | | `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions`, `DownloadItemsFormat` | | `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreCollectionClient`, `KeyValueStoreRecord`, `KeyValueStoreKeysPage`, `KeyValueStoreKey`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | | `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | | `com.apify.client.task` | `Task`, `TaskStats`, `TaskOptions`, `TaskClient`, `TaskCollectionClient`, `TaskStartOptions`, `TaskCallOptions` | | `com.apify.client.schedule` | `Schedule`, `ScheduleNotifications`, `ScheduleClient`, `ScheduleCollectionClient` | -| `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookLastDispatch`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient` | -| `com.apify.client.user` | `User`, `UserClient` | -| `com.apify.client.store` | `ActorStoreListItem`, `StoreCollectionClient`, `StoreListOptions` | +| `com.apify.client.webhook` | `Webhook`, `WebhookStats`, `WebhookLastDispatch`, `WebhookClient`, `WebhookCollectionClient`, `WebhookDispatchCollectionClient`, `NestedWebhookCollectionClient`, `WebhookDispatch`, `WebhookDispatchClient`, `WebhookDispatchCall`, `WebhookDispatchWebhookInfo`, `WebhookDispatchEventData` | +| `com.apify.client.user` | `User`, `UserClient`, `UserProfile`, `UserProxy`, `UserPlan`, `ProxyGroup` | +| `com.apify.client.store` | `ActorStoreListItem`, `StoreCollectionClient`, `StoreListOptions`, `PricingInfo` | | `com.apify.client.log` | `LogClient`, `LogOptions`, `StreamedLog`, `StreamedLogOptions` | For example, the [top-level README's dataset snippet](../README.md#quick-start) needs diff --git a/docs/actors.md b/docs/actors.md index e916023..a1d6ea9 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -57,13 +57,14 @@ Actor created = client.actors().create(Map.of( | `webhooks()` | Read-only nested webhook collection (`NestedWebhookCollectionClient`, `list` + `iterate`, no `create`). | | `version(String)` | An `ActorVersionClient`. | -`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: +`ActorStartOptions` fields (all optional): `build(String)` (the tag or number of the build to run, +e.g. `latest`, `0.1.2`), `memoryMbytes(Long)` (memory in megabytes allocated for the run), +`timeoutSecs(Long)` (run timeout in seconds; `0` means no timeout), `waitForFinish(Long)` (maximum +seconds to wait server-side for the run to finish, max 60), `maxItems(Long)` (maximum dataset items +to charge, pay-per-result Actors), `maxTotalChargeUsd(Double)` (maximum total charge in USD, +pay-per-event Actors), `contentType(String)` (content type of the input body, defaults to +`application/json`), `restartOnError(Boolean)` (restart the run if it fails), +`forcePermissionLevel(String)` (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. @@ -99,8 +100,9 @@ System.out.println(run.getStatus()); (`ActorDefaultRunOptions` — `getBuild()`, `getTimeoutSecs()`/`getMemoryMbytes()` as `Long`, `getRestartOnError()` as `Boolean`), `getTaggedBuilds()` (`JsonNode`; dynamic tag-name keys), `getIsDeprecated()` (`Boolean`), `getDeploymentKey()`, `getSeoTitle()`, `getSeoDescription()`, -`getCategories()` (`List`), `getActorStandby()` (`ActorStandby`, nullable — see -[Tasks](tasks.md) for its field list; only on `Actor` does it additionally expose +`getCategories()` (`List`), `getActorStandby()` (`ActorStandby`, from +`com.apify.client.actor`, nullable — see [Tasks](tasks.md) for its field list; only on `Actor` does +it additionally expose `getIsEnabled()`), `getActorPermissionLevel()` (e.g. `"OWNER"`), plus `getExtra()` for any unmodelled fields. diff --git a/docs/runs.md b/docs/runs.md index 3efeb08..df6ee58 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -149,5 +149,5 @@ client.run("RUN_ID").charge(new RunChargeOptions("my-event").count(3L)); ``` `MetamorphOptions` uses plain values `build(String)` and `contentType(String)`. -`RunResurrectOptions` fields: `build`, `memoryMbytes`, `timeoutSecs`, `maxItems`, -`maxTotalChargeUsd`, `restartOnError`. +`RunResurrectOptions` fields: `build(String)`, `memoryMbytes(Long)`, `timeoutSecs(Long)`, +`maxItems(Long)`, `maxTotalChargeUsd(Double)`, `restartOnError(Boolean)`. diff --git a/docs/tasks.md b/docs/tasks.md index 9ab416f..cd433ec 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -51,11 +51,12 @@ ActorRun streamed = client.task("TASK_ID").call(null, new TaskCallOptions().memo `Task` fields: `getId()`, `getActId()`, `getUserId()`, `getName()`, `getTitle()`, `getDescription()`, `getCreatedAt()`, `getModifiedAt()`, `getStats()` (`TaskStats`, exposing `getTotalRuns()`), `getOptions()` (`TaskOptions`: the task's stored default run configuration — -`getBuild()`, `getTimeoutSecs()`, `getMemoryMbytes()`, `getRestartOnError()`), `getInput()` (a -`JsonNode` snapshot of the stored input, from whichever response last returned this `Task` object; -prefer `TaskClient.getInput()` above to fetch it fresh on-demand), and `getActorStandby()` -(`ActorStandby`, standby-mode configuration overrides for this task, if any). Any field not covered -by a typed getter is still available via the inherited `getExtra()` (see +`getBuild()` (`String`), `getTimeoutSecs()` (`Long`), `getMemoryMbytes()` (`Long`), +`getRestartOnError()` (`Boolean`)), `getInput()` (a `JsonNode` snapshot of the stored input, from +whichever response last returned this `Task` object; prefer `TaskClient.getInput()` above to fetch +it fresh on-demand), and `getActorStandby()` (`ActorStandby`, from `com.apify.client.actor`, +standby-mode configuration overrides for this task, if any). Any field not covered by a typed +getter is still available via the inherited `getExtra()` (see [the docs index](README.md#model-fields-and-unmodeled-data-getextra)). `ActorStandby` fields (all optional; `null` when unset): `getBuild()` (tag/number of the build From 27c99bf389cf265290008227ccf34a3775007f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 05:30:27 +0000 Subject: [PATCH 14/18] fix: nested value DTOs preserve unknown API fields via getExtra() ActorRunStats/ActorRunMeta/BuildStats gained the missing typed getters (migrationCount/rebootCount, scheduleId/scheduledAt, imageSizeBytes) and, along with every other nested value DTO in the client, now extend the shared ApifyResource base so any unmodelled field the API returns stays reachable via getExtra() instead of being silently discarded. --- CHANGELOG.md | 18 ++- docs/README.md | 19 +-- docs/builds.md | 12 +- docs/runs.md | 16 +-- spotbugs-exclude.xml | 92 ++++++++++++ .../java/com/apify/client/PaginationList.java | 4 +- .../client/actor/ActorDefaultRunOptions.java | 5 +- .../com/apify/client/actor/ActorStandby.java | 5 +- .../com/apify/client/actor/ActorStats.java | 5 +- .../com/apify/client/build/BuildMeta.java | 5 +- .../com/apify/client/build/BuildOptions.java | 5 +- .../com/apify/client/build/BuildStats.java | 11 +- .../com/apify/client/build/BuildUsage.java | 5 +- .../keyvalue/KeyValueStoreKeysPage.java | 5 +- .../client/requestqueue/BatchAddResult.java | 5 +- .../requestqueue/BatchDeleteResult.java | 5 +- .../requestqueue/DeletedRequestInfo.java | 5 +- .../client/requestqueue/RequestLockInfo.java | 5 +- .../RequestQueueOperationInfo.java | 5 +- .../client/requestqueue/RequestsList.java | 5 +- .../requestqueue/UnlockRequestsResult.java | 5 +- .../com/apify/client/run/ActorRunMeta.java | 18 ++- .../com/apify/client/run/ActorRunOptions.java | 5 +- .../com/apify/client/run/ActorRunStats.java | 17 ++- .../com/apify/client/run/ActorRunUsage.java | 5 +- .../schedule/ScheduleNotifications.java | 5 +- .../com/apify/client/store/PricingInfo.java | 5 +- .../com/apify/client/task/TaskOptions.java | 5 +- .../java/com/apify/client/task/TaskStats.java | 5 +- .../com/apify/client/user/ProxyGroup.java | 5 +- .../java/com/apify/client/user/UserPlan.java | 5 +- .../com/apify/client/user/UserProfile.java | 5 +- .../java/com/apify/client/user/UserProxy.java | 5 +- .../client/webhook/WebhookDispatchCall.java | 5 +- .../webhook/WebhookDispatchEventData.java | 5 +- .../webhook/WebhookDispatchWebhookInfo.java | 5 +- .../client/webhook/WebhookLastDispatch.java | 5 +- .../apify/client/webhook/WebhookStats.java | 5 +- .../client/NestedDtoForwardCompatTest.java | 132 ++++++++++++++++++ 39 files changed, 363 insertions(+), 121 deletions(-) create mode 100644 src/test/java/com/apify/client/NestedDtoForwardCompatTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a835f..df2869c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `actorStandby`), `ActorRun` (`generalAccess`, `chargedEventCounts`, `pricingInfo`, `usage`, `usageUsd`, `stats`, `options`, `meta`, `usageTotalUsd`, `buildNumber`, `exitCode`, `isContainerServerReady`, `gitBranchName`, `storageIds`). +- `ActorRunStats.getMigrationCount()`/`getRebootCount()`, `ActorRunMeta.getScheduleId()`/ + `getScheduledAt()`, `BuildStats.getImageSizeBytes()`. - Further typed getters resolving the remaining thin-DTO gaps against the JS reference client: `WebhookDispatch` (`status`, `eventType`, `userId`, `createdAt`, `calls`, `webhook`, `eventData`); `Actor` (`actorStandby`, `stats`, `versions`, `pricingInfos`, `defaultRunOptions`, `taggedBuilds`, @@ -158,6 +160,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `NullPointerException` deep inside the method. - `RunClient`'s default log-redirection prefix now uses the run's resolved real id instead of the client's own `id` field, which is the literal string `"last"` for a `runs/last`-scoped client. +- Every nested value DTO (`ActorRunStats`, `ActorRunMeta`, `ActorRunUsage`, `ActorRunOptions`, + `BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage`, `ActorStandby`, `ActorStats`, + `ActorDefaultRunOptions`, `TaskStats`, `TaskOptions`, `ScheduleNotifications`, `WebhookStats`, + `WebhookLastDispatch`, `WebhookDispatchCall`, `WebhookDispatchWebhookInfo`, + `WebhookDispatchEventData`, `UserProfile`, `UserProxy`, `UserPlan`, `ProxyGroup`, `PricingInfo`, + `PaginationList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `RequestLockInfo`, + `RequestQueueOperationInfo`, `RequestsList`, `UnlockRequestsResult`, `KeyValueStoreKeysPage`) now + extends `ApifyResource`, so any field the API returns without a matching typed getter is + reachable via `getExtra()` instead of being silently discarded. ### Documentation @@ -192,7 +203,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Documented `ActorStandby`'s fields in `docs/tasks.md`, added the standard `getExtra()` note to `WebhookDispatch` (`docs/webhooks.md`) and `ActorStoreListItem` (`docs/misc.md`), added a `ListKeysOptions` field list to `docs/storages.md`, and made `ActorRunStats`'s field list in - `docs/runs.md` exhaustive while noting it has no `getExtra()` fallback. Reworded `ApiResponse`'s + `docs/runs.md` exhaustive. Reworded `ApiResponse`'s javadoc (it is a plain public data carrier used across resource clients, not internal plumbing) and corrected `spotbugs-exclude.xml`'s header to accurately explain why its former `ApiResponse` EI_EXPOSE exclusion stopped reproducing (the no-copy contract never changed; the tool simply @@ -206,6 +217,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). custom-transport implementer must satisfy). Trimmed `PaginationList`'s ~10-line public-setter justification comment to two sentences and made `StreamedLog`'s internal `(see below)` comment reference the `finally` block explicitly. +- `docs/runs.md`/`docs/builds.md` field lists updated for `ActorRunStats.getMigrationCount()`/ + `getRebootCount()`, `ActorRunMeta.getScheduleId()`/`getScheduledAt()`, and + `BuildStats.getImageSizeBytes()`; `docs/README.md`'s "What `ApifyResource` is" / "Model fields and + unmodeled data" sections now state that nested value DTOs, not only top-level resources, extend + `ApifyResource` and expose `getExtra()`. - Added a `list()` step to the Actor/Dataset/RequestQueue/KeyValueStore/Webhook/Task CRUD-flow integration tests, verifying the just-created resource's id appears in its top-level collection listing (polled for eventual consistency via a new shared `IntegrationBase.LIST_FIND_ATTEMPTS`/ diff --git a/docs/README.md b/docs/README.md index 73a0142..85452e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -92,19 +92,22 @@ than raw `JsonNode` — see [Storages](storages.md#request-queues). ## What `ApifyResource` is -`ApifyResource` (root package) is the base class every response model extends (`Actor`, `Dataset`, -`Schedule`, `ActorRun`, and so on). It has no fields of its own beyond the `extra` map described -below; it exists purely so every model shares one place to capture unmodelled API fields, and so -code that only needs the common capability (rare — most call sites use a concrete model type +`ApifyResource` (root package) is the base class every response model extends — both the top-level +resources (`Actor`, `Dataset`, `Schedule`, `ActorRun`, and so on) and the nested value DTOs embedded +in them (`ActorRunStats`, `ActorRunMeta`, `BuildStats`, `WebhookStats`, `UserProfile`, `PricingInfo`, +and every other nested model in this client). It has no fields of its own beyond the `extra` map +described below; it exists purely so every model shares one place to capture unmodelled API fields, +and so code that only needs the common capability (rare — most call sites use a concrete model type directly) can accept `ApifyResource` rather than a specific model. ## Model fields and unmodeled data (`getExtra`) Response models expose the commonly-used fields as typed getters. The API returns more fields than -are modelled; every model also carries a `getExtra()` map (`Map`, inherited from -`ApifyResource` above) holding any field not mapped to a typed getter, so nothing the API returns is -lost. For example a `me()` `User`'s private account details (email, plan, proxy settings, …) are -available via `getExtra()`, since `User` models only its most commonly used fields. +are modelled; every model — including nested value DTOs, not only the top-level resources — also +carries a `getExtra()` map (`Map`, inherited from `ApifyResource` above) holding any +field not mapped to a typed getter, so nothing the API returns is lost, now or as the API grows new +fields later. For example a `me()` `User`'s private account details (email, plan, proxy settings, +…) are available via `getExtra()`, since `User` models only its most commonly used fields. ```java User me = client.me().get().orElseThrow(); diff --git a/docs/builds.md b/docs/builds.md index 5821557..a2c9b1e 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -25,11 +25,13 @@ builds) and a single build with `client.build(id)`. `Build` fields: `getId()`, `getActId()`, `getUserId()`, `getStatus()`, `getStartedAt()`, `getFinishedAt()`, `getBuildNumber()`, `getMeta()` (`BuildMeta` — `getOrigin()`, `getClientIp()`, `getUserAgent()`), `getStats()` (`BuildStats` — `getDurationMillis()`/`getRunTimeSecs()` as `Long`, -`getComputeUnits()` as `Double`), `getOptions()` (`BuildOptions` — `getUseCache()`/ -`getBetaPackages()` as `Boolean`, `getMemoryMbytes()`/`getDiskMbytes()` as `Long`), `getUsage()` / -`getUsageUsd()` (`BuildUsage` — `getActorComputeUnits()` as `Double`, the latter as its USD cost), -`getUsageTotalUsd()` (`Double`), plus `isTerminal()` and `getExtra()`. The status is one of -`READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. +`getComputeUnits()` as `Double`, `getImageSizeBytes()` as `Long`), `getOptions()` (`BuildOptions` — +`getUseCache()`/`getBetaPackages()` as `Boolean`, `getMemoryMbytes()`/`getDiskMbytes()` as `Long`), +`getUsage()` / `getUsageUsd()` (`BuildUsage` — `getActorComputeUnits()` as `Double`, the latter as +its USD cost), `getUsageTotalUsd()` (`Double`), plus `isTerminal()` and `getExtra()`. The status is +one of `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `TIMED-OUT`, `ABORTING`, `ABORTED`. +Every nested model above (`BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage`) also has its own +`getExtra()`, so a field not yet listed here is still reachable rather than dropped. ```java Build build = client.actor("me/my-actor").build("0.0", new ActorBuildOptions().tag("latest")); diff --git a/docs/runs.md b/docs/runs.md index df6ee58..4a891d5 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -111,16 +111,16 @@ pay-per-event Actors, `null` otherwise), `getPricingInfo()` (`JsonNode`; shape d Actor's pricing model), `getUsage()` / `getUsageUsd()` (`ActorRunUsage`; per-billable-unit resource consumption, the latter as its USD cost — see below), `getUsageTotalUsd()` (`Double`; the run's total cost in USD, combining platform usage and/or event costs depending on pricing model), -`getStats()` (`ActorRunStats`; runtime metrics — `getInputBodyLen()`, `getRestartCount()`, -`getResurrectCount()`, `getMemAvgBytes()`/`getMemMaxBytes()`/`getMemCurrentBytes()`, +`getStats()` (`ActorRunStats`; runtime metrics — `getInputBodyLen()`, `getMigrationCount()`, +`getRebootCount()`, `getRestartCount()`, `getResurrectCount()`, +`getMemAvgBytes()`/`getMemMaxBytes()`/`getMemCurrentBytes()`, `getCpuAvgUsage()`/`getCpuMaxUsage()`/`getCpuCurrentUsage()`, `getNetRxBytes()`/`getNetTxBytes()`, -`getDurationMillis()`, `getRunTimeSecs()`, `getMetamorph()`, `getComputeUnits()`; this list is -exhaustive for the currently modelled fields, but `ActorRunStats` does not extend the common -`ApifyResource` base and so has no `getExtra()` - an API field added later without a matching -getter added here is silently dropped rather than surfaced), `getOptions()` (`ActorRunOptions`; the -run configuration actually applied, +`getDurationMillis()`, `getRunTimeSecs()`, `getMetamorph()`, `getComputeUnits()`; any other field is +still reachable via the inherited `getExtra()`), `getOptions()` (`ActorRunOptions`; the run +configuration actually applied, which may differ from what was requested — see below), `getMeta()` (`ActorRunMeta`; `getOrigin()`, -`getClientIp()`, `getUserAgent()` — how the run was initiated), `getBuildNumber()` (`String`; the +`getClientIp()`, `getUserAgent()`, `getScheduleId()`, `getScheduledAt()` — how and, if triggered by a +schedule, when the run was initiated), `getBuildNumber()` (`String`; the build's semver-like number, e.g. `"0.0.36"`), `getExitCode()` (`Integer`; the process exit code once finished), `isContainerServerReady()` (`Boolean`; whether the container's HTTP server can accept requests), `getGitBranchName()` (`String`; the git branch the build was built from, if any), and diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 49d9be3..c092917 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -32,6 +32,20 @@ not flagged (SpotBugs' constructor/getter analysis does not trace a raw public field the way it traces a record's synthesized accessor and canonical constructor), so this pair of exclusions is specific to the record form. + + 4. EI_EXPOSE_REP on the specific getters listed below, each returning a field whose type was just + changed (this PR) to extend the shared {@code ApifyResource} forward-compatibility base (an + {@code extra} catch-all map + package-private {@code @JsonAnySetter}), so unmodelled API fields + on nested value DTOs are preserved instead of silently dropped. That base class is not {@code + final} and holds a mutable {@code LinkedHashMap} field, which flips SpotBugs' mutability + heuristic for every subtype from "no public mutator -> immutable-enough, don't flag" to + "mutable -> flag every getter that returns one as a field of another class" - even though + nothing about this actually changed: {@code putExtra} stays package-private, {@code + getExtra()} still returns an unmodifiable view, and there is still no public way to mutate a + returned instance. Defensively copying on every getter call would require deep-cloning the + {@code extra} map each time, which is wasted work for a map that is (by construction) never + mutated after deserialization, so copying is not the fix here - the false positive is. Listed + per getter (not per class) to keep the exclusion exactly as narrow as the finding it suppresses. --> @@ -74,4 +88,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java index c9dc39f..2308644 100644 --- a/src/main/java/com/apify/client/PaginationList.java +++ b/src/main/java/com/apify/client/PaginationList.java @@ -1,6 +1,5 @@ package com.apify.client; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.util.Collections; import java.util.List; @@ -15,8 +14,7 @@ * * @param the item type */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class PaginationList { +public final class PaginationList extends ApifyResource { private long total; private long offset; diff --git a/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java index a450dd0..4ba7cfd 100644 --- a/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java +++ b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java @@ -1,10 +1,9 @@ package com.apify.client.actor; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Default configuration options applied to an {@link Actor}'s runs unless overridden. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorDefaultRunOptions { +public final class ActorDefaultRunOptions extends ApifyResource { private String build; private Long timeoutSecs; private Long memoryMbytes; diff --git a/src/main/java/com/apify/client/actor/ActorStandby.java b/src/main/java/com/apify/client/actor/ActorStandby.java index 6dac7d8..688b733 100644 --- a/src/main/java/com/apify/client/actor/ActorStandby.java +++ b/src/main/java/com/apify/client/actor/ActorStandby.java @@ -1,10 +1,9 @@ package com.apify.client.actor; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Standby-mode configuration for an Actor, or for a task that overrides its Actor's defaults. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorStandby { +public final class ActorStandby extends ApifyResource { private Boolean isEnabled; private String build; private Long desiredRequestsPerActorRun; diff --git a/src/main/java/com/apify/client/actor/ActorStats.java b/src/main/java/com/apify/client/actor/ActorStats.java index b38fe3a..ef56708 100644 --- a/src/main/java/com/apify/client/actor/ActorStats.java +++ b/src/main/java/com/apify/client/actor/ActorStats.java @@ -1,11 +1,10 @@ package com.apify.client.actor; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.time.Instant; /** Usage and activity statistics for an {@link Actor}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorStats { +public final class ActorStats extends ApifyResource { private Long totalBuilds; private Long totalRuns; private Long totalUsers; diff --git a/src/main/java/com/apify/client/build/BuildMeta.java b/src/main/java/com/apify/client/build/BuildMeta.java index 58224c7..79c3d9b 100644 --- a/src/main/java/com/apify/client/build/BuildMeta.java +++ b/src/main/java/com/apify/client/build/BuildMeta.java @@ -1,10 +1,9 @@ package com.apify.client.build; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Metadata about how a {@link Build} was initiated. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BuildMeta { +public final class BuildMeta extends ApifyResource { private String origin; private String clientIp; private String userAgent; diff --git a/src/main/java/com/apify/client/build/BuildOptions.java b/src/main/java/com/apify/client/build/BuildOptions.java index d4bdb64..8ebd87b 100644 --- a/src/main/java/com/apify/client/build/BuildOptions.java +++ b/src/main/java/com/apify/client/build/BuildOptions.java @@ -1,10 +1,9 @@ package com.apify.client.build; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** The configuration options actually applied to a {@link Build}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BuildOptions { +public final class BuildOptions extends ApifyResource { private Boolean useCache; private Boolean betaPackages; private Long memoryMbytes; diff --git a/src/main/java/com/apify/client/build/BuildStats.java b/src/main/java/com/apify/client/build/BuildStats.java index d95e721..e31d248 100644 --- a/src/main/java/com/apify/client/build/BuildStats.java +++ b/src/main/java/com/apify/client/build/BuildStats.java @@ -1,13 +1,13 @@ package com.apify.client.build; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Runtime statistics for a {@link Build}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BuildStats { +public final class BuildStats extends ApifyResource { private Long durationMillis; private Long runTimeSecs; private Double computeUnits; + private Long imageSizeBytes; /** How long the build took, in milliseconds. */ public Long getDurationMillis() { @@ -23,4 +23,9 @@ public Long getRunTimeSecs() { public Double getComputeUnits() { return computeUnits; } + + /** The size of the built Docker image, in bytes, if known. */ + public Long getImageSizeBytes() { + return imageSizeBytes; + } } diff --git a/src/main/java/com/apify/client/build/BuildUsage.java b/src/main/java/com/apify/client/build/BuildUsage.java index e1b3e5c..dce34f3 100644 --- a/src/main/java/com/apify/client/build/BuildUsage.java +++ b/src/main/java/com/apify/client/build/BuildUsage.java @@ -1,6 +1,6 @@ package com.apify.client.build; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import com.fasterxml.jackson.annotation.JsonProperty; /** @@ -11,8 +11,7 @@ * mapped to its idiomatic camelCase Java name via {@code @JsonProperty}, matching {@link * com.apify.client.run.ActorRunUsage}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BuildUsage { +public final class BuildUsage extends ApifyResource { @JsonProperty("ACTOR_COMPUTE_UNITS") private Double actorComputeUnits; diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java index 702654b..b3e45b1 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKeysPage.java @@ -1,12 +1,11 @@ package com.apify.client.keyvalue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; /** A page of keys from a key-value store. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class KeyValueStoreKeysPage { +public final class KeyValueStoreKeysPage extends ApifyResource { private long limit; private boolean isTruncated; private String exclusiveStartKey; diff --git a/src/main/java/com/apify/client/requestqueue/BatchAddResult.java b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java index 92e222f..edb99eb 100644 --- a/src/main/java/com/apify/client/requestqueue/BatchAddResult.java +++ b/src/main/java/com/apify/client/requestqueue/BatchAddResult.java @@ -1,13 +1,12 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** The result of {@link RequestQueueClient#batchAddRequests}: accepted and unprocessed requests. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BatchAddResult { +public final class BatchAddResult extends ApifyResource { private List processedRequests = new ArrayList<>(); private List unprocessedRequests = new ArrayList<>(); diff --git a/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java index 3517cbb..d91305a 100644 --- a/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java +++ b/src/main/java/com/apify/client/requestqueue/BatchDeleteResult.java @@ -1,12 +1,11 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; /** The result of {@link RequestQueueClient#batchDeleteRequests}: deleted and failed requests. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class BatchDeleteResult { +public final class BatchDeleteResult extends ApifyResource { private List processedRequests = List.of(); private List unprocessedRequests = List.of(); diff --git a/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java b/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java index be9e2d2..9b46231 100644 --- a/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java +++ b/src/main/java/com/apify/client/requestqueue/DeletedRequestInfo.java @@ -1,14 +1,13 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** * Confirmation that a request was successfully deleted by {@link * RequestQueueClient#batchDeleteRequests}, identified by whichever of {@code id}/{@code uniqueKey} * the API returned. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class DeletedRequestInfo { +public final class DeletedRequestInfo extends ApifyResource { private String id; private String uniqueKey; diff --git a/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java b/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java index 0a6e011..03c4b92 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java +++ b/src/main/java/com/apify/client/requestqueue/RequestLockInfo.java @@ -1,11 +1,10 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.time.Instant; /** Returned by {@link RequestQueueClient#prolongRequestLock}: the request's new lock expiry. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class RequestLockInfo { +public final class RequestLockInfo extends ApifyResource { private Instant lockExpiresAt; /** When the (extended) lock on the request expires. */ diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java b/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java index 8901eab..82047f8 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueOperationInfo.java @@ -1,10 +1,9 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Returned when adding or updating a request in a queue. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class RequestQueueOperationInfo { +public final class RequestQueueOperationInfo extends ApifyResource { private String requestId; private String uniqueKey; private boolean wasAlreadyPresent; diff --git a/src/main/java/com/apify/client/requestqueue/RequestsList.java b/src/main/java/com/apify/client/requestqueue/RequestsList.java index 502d8e9..70d97cc 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestsList.java +++ b/src/main/java/com/apify/client/requestqueue/RequestsList.java @@ -1,6 +1,6 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; @@ -8,8 +8,7 @@ * A page of requests returned by {@link RequestQueueClient#listRequests}, with cursor-based * pagination information. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class RequestsList { +public final class RequestsList extends ApifyResource { private List items = List.of(); private long limit; diff --git a/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java b/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java index f0318e9..ade06f6 100644 --- a/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java +++ b/src/main/java/com/apify/client/requestqueue/UnlockRequestsResult.java @@ -1,10 +1,9 @@ package com.apify.client.requestqueue; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Returned by {@link RequestQueueClient#unlockRequests}: how many requests were unlocked. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class UnlockRequestsResult { +public final class UnlockRequestsResult extends ApifyResource { private long unlockedCount; /** The number of requests that were successfully unlocked. */ diff --git a/src/main/java/com/apify/client/run/ActorRunMeta.java b/src/main/java/com/apify/client/run/ActorRunMeta.java index c211ee1..be5181f 100644 --- a/src/main/java/com/apify/client/run/ActorRunMeta.java +++ b/src/main/java/com/apify/client/run/ActorRunMeta.java @@ -1,13 +1,15 @@ package com.apify.client.run; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; +import java.time.Instant; /** Metadata about how an {@link ActorRun} was initiated. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorRunMeta { +public final class ActorRunMeta extends ApifyResource { private String origin; private String clientIp; private String userAgent; + private String scheduleId; + private Instant scheduledAt; /** What triggered the run (e.g. {@code "WEB"}, {@code "API"}, {@code "SCHEDULER"}). */ public String getOrigin() { @@ -23,4 +25,14 @@ public String getClientIp() { public String getUserAgent() { return userAgent; } + + /** The ID of the schedule that triggered the run, if any. */ + public String getScheduleId() { + return scheduleId; + } + + /** When the run was scheduled, if it was triggered by a schedule. */ + public Instant getScheduledAt() { + return scheduledAt; + } } diff --git a/src/main/java/com/apify/client/run/ActorRunOptions.java b/src/main/java/com/apify/client/run/ActorRunOptions.java index 35afd01..1a9b504 100644 --- a/src/main/java/com/apify/client/run/ActorRunOptions.java +++ b/src/main/java/com/apify/client/run/ActorRunOptions.java @@ -1,14 +1,13 @@ package com.apify.client.run; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** * The configuration options actually applied to an {@link ActorRun} (as opposed to {@link * com.apify.client.actor.ActorStartOptions}, which is what a caller requested and may differ, e.g. * when a requested value falls back to a task/Actor default). */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorRunOptions { +public final class ActorRunOptions extends ApifyResource { private String build; private Long timeoutSecs; private Long memoryMbytes; diff --git a/src/main/java/com/apify/client/run/ActorRunStats.java b/src/main/java/com/apify/client/run/ActorRunStats.java index a6c0221..c91682d 100644 --- a/src/main/java/com/apify/client/run/ActorRunStats.java +++ b/src/main/java/com/apify/client/run/ActorRunStats.java @@ -1,11 +1,12 @@ package com.apify.client.run; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Runtime resource-consumption and performance metrics for an {@link ActorRun}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorRunStats { +public final class ActorRunStats extends ApifyResource { private long inputBodyLen; + private long migrationCount; + private long rebootCount; private long restartCount; private long resurrectCount; private double memAvgBytes; @@ -26,6 +27,16 @@ public long getInputBodyLen() { return inputBodyLen; } + /** How many times the run's container was migrated to a different host. */ + public long getMigrationCount() { + return migrationCount; + } + + /** How many times the run's container was rebooted. */ + public long getRebootCount() { + return rebootCount; + } + /** How many times the run was automatically restarted. */ public long getRestartCount() { return restartCount; diff --git a/src/main/java/com/apify/client/run/ActorRunUsage.java b/src/main/java/com/apify/client/run/ActorRunUsage.java index 041aad1..6375faa 100644 --- a/src/main/java/com/apify/client/run/ActorRunUsage.java +++ b/src/main/java/com/apify/client/run/ActorRunUsage.java @@ -1,6 +1,6 @@ package com.apify.client.run; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import com.fasterxml.jackson.annotation.JsonProperty; /** @@ -11,8 +11,7 @@ * "ACTOR_COMPUTE_UNITS"}); each field is mapped to its idiomatic camelCase Java name via * {@code @JsonProperty}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ActorRunUsage { +public final class ActorRunUsage extends ApifyResource { @JsonProperty("ACTOR_COMPUTE_UNITS") private Double actorComputeUnits; diff --git a/src/main/java/com/apify/client/schedule/ScheduleNotifications.java b/src/main/java/com/apify/client/schedule/ScheduleNotifications.java index d0b247e..2aef2ae 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleNotifications.java +++ b/src/main/java/com/apify/client/schedule/ScheduleNotifications.java @@ -1,10 +1,9 @@ package com.apify.client.schedule; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Notification settings for a {@link Schedule}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ScheduleNotifications { +public final class ScheduleNotifications extends ApifyResource { private boolean email; /** Whether the schedule owner is notified by email of run failures. */ diff --git a/src/main/java/com/apify/client/store/PricingInfo.java b/src/main/java/com/apify/client/store/PricingInfo.java index af076eb..6710933 100644 --- a/src/main/java/com/apify/client/store/PricingInfo.java +++ b/src/main/java/com/apify/client/store/PricingInfo.java @@ -1,10 +1,9 @@ package com.apify.client.store; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** A Store Actor's current pricing model summary. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class PricingInfo { +public final class PricingInfo extends ApifyResource { private String pricingModel; /** diff --git a/src/main/java/com/apify/client/task/TaskOptions.java b/src/main/java/com/apify/client/task/TaskOptions.java index 168e118..238067b 100644 --- a/src/main/java/com/apify/client/task/TaskOptions.java +++ b/src/main/java/com/apify/client/task/TaskOptions.java @@ -1,10 +1,9 @@ package com.apify.client.task; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** The stored run configuration of a {@link Task} (the defaults applied when it is started). */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class TaskOptions { +public final class TaskOptions extends ApifyResource { private String build; private Long timeoutSecs; private Long memoryMbytes; diff --git a/src/main/java/com/apify/client/task/TaskStats.java b/src/main/java/com/apify/client/task/TaskStats.java index d73bd6e..571d898 100644 --- a/src/main/java/com/apify/client/task/TaskStats.java +++ b/src/main/java/com/apify/client/task/TaskStats.java @@ -1,10 +1,9 @@ package com.apify.client.task; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Usage statistics for a {@link Task}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class TaskStats { +public final class TaskStats extends ApifyResource { private long totalRuns; /** The total number of runs started from this task. */ diff --git a/src/main/java/com/apify/client/user/ProxyGroup.java b/src/main/java/com/apify/client/user/ProxyGroup.java index ecef2ae..8cb870a 100644 --- a/src/main/java/com/apify/client/user/ProxyGroup.java +++ b/src/main/java/com/apify/client/user/ProxyGroup.java @@ -1,10 +1,9 @@ package com.apify.client.user; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** A group of proxies available to a {@link User}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class ProxyGroup { +public final class ProxyGroup extends ApifyResource { private String name; private String description; private Long availableCount; diff --git a/src/main/java/com/apify/client/user/UserPlan.java b/src/main/java/com/apify/client/user/UserPlan.java index f441473..3800c79 100644 --- a/src/main/java/com/apify/client/user/UserPlan.java +++ b/src/main/java/com/apify/client/user/UserPlan.java @@ -1,13 +1,12 @@ package com.apify.client.user; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import com.fasterxml.jackson.databind.JsonNode; import java.util.Collections; import java.util.List; /** A {@link User}'s subscription plan and its associated limits. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class UserPlan { +public final class UserPlan extends ApifyResource { private String id; private String description; private Boolean isEnabled; diff --git a/src/main/java/com/apify/client/user/UserProfile.java b/src/main/java/com/apify/client/user/UserProfile.java index 3d19fe3..aa78094 100644 --- a/src/main/java/com/apify/client/user/UserProfile.java +++ b/src/main/java/com/apify/client/user/UserProfile.java @@ -1,10 +1,9 @@ package com.apify.client.user; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** The public profile of a {@link User}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class UserProfile { +public final class UserProfile extends ApifyResource { private String bio; private String name; private String pictureUrl; diff --git a/src/main/java/com/apify/client/user/UserProxy.java b/src/main/java/com/apify/client/user/UserProxy.java index e8ba03c..5817f7f 100644 --- a/src/main/java/com/apify/client/user/UserProxy.java +++ b/src/main/java/com/apify/client/user/UserProxy.java @@ -1,12 +1,11 @@ package com.apify.client.user; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.util.Collections; import java.util.List; /** A {@link User}'s Apify Proxy credentials and available proxy groups. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class UserProxy { +public final class UserProxy extends ApifyResource { private String password; private List groups = List.of(); diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java b/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java index 7fa2150..f0bb0e4 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchCall.java @@ -1,11 +1,10 @@ package com.apify.client.webhook; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.time.Instant; /** A single HTTP delivery attempt made for a {@link WebhookDispatch}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class WebhookDispatchCall { +public final class WebhookDispatchCall extends ApifyResource { private Instant startedAt; private Instant finishedAt; private String errorMessage; diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java b/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java index 710d6c5..d18172b 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchEventData.java @@ -1,13 +1,12 @@ package com.apify.client.webhook; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** * The event payload that triggered a {@link WebhookDispatch}. Fields not relevant to the triggering * event are {@code null}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class WebhookDispatchEventData { +public final class WebhookDispatchEventData extends ApifyResource { private String actorRunId; private String actorId; private String actorTaskId; diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java index d49e863..bab429d 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java @@ -1,13 +1,12 @@ package com.apify.client.webhook; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** * A summary of the {@link Webhook} that produced a {@link WebhookDispatch} (only the subset of * fields the API includes alongside a dispatch, not the full webhook object). */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class WebhookDispatchWebhookInfo { +public final class WebhookDispatchWebhookInfo extends ApifyResource { private String requestUrl; private boolean isAdHoc; diff --git a/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java b/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java index f40d829..b7e642f 100644 --- a/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java +++ b/src/main/java/com/apify/client/webhook/WebhookLastDispatch.java @@ -1,11 +1,10 @@ package com.apify.client.webhook; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; import java.time.Instant; /** A summary of the most recent dispatch of a {@link Webhook}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class WebhookLastDispatch { +public final class WebhookLastDispatch extends ApifyResource { private String status; private Instant finishedAt; private Instant removedAt; diff --git a/src/main/java/com/apify/client/webhook/WebhookStats.java b/src/main/java/com/apify/client/webhook/WebhookStats.java index 3f6aba7..b9d4d6a 100644 --- a/src/main/java/com/apify/client/webhook/WebhookStats.java +++ b/src/main/java/com/apify/client/webhook/WebhookStats.java @@ -1,10 +1,9 @@ package com.apify.client.webhook; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.apify.client.ApifyResource; /** Usage statistics for a {@link Webhook}. */ -@JsonIgnoreProperties(ignoreUnknown = true) -public final class WebhookStats { +public final class WebhookStats extends ApifyResource { private long totalDispatches; /** The total number of times this webhook has been dispatched. */ diff --git a/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java b/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java new file mode 100644 index 0000000..4e5167a --- /dev/null +++ b/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java @@ -0,0 +1,132 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.actor.ActorStandby; +import com.apify.client.build.BuildStats; +import com.apify.client.internal.Json; +import com.apify.client.keyvalue.KeyValueStoreKeysPage; +import com.apify.client.requestqueue.RequestQueueOperationInfo; +import com.apify.client.run.ActorRunMeta; +import com.apify.client.run.ActorRunStats; +import com.apify.client.schedule.ScheduleNotifications; +import com.apify.client.store.PricingInfo; +import com.apify.client.task.TaskStats; +import com.apify.client.user.UserProfile; +import com.apify.client.webhook.WebhookStats; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Guards the fix for the nested-value-DTO data-loss finding: every nested value DTO now extends + * {@link ApifyResource}, so (a) the specific spec fields the finding named as missing typed getters + * are modelled, and (b) any *other* field the API returns that still has no typed getter is + * reachable via {@code getExtra()} rather than silently discarded. Each assertion deserializes a + * literal JSON fixture directly (via the same {@link Json} the client uses for real responses), not + * just a hand-built object, so a regression that drops {@code extends ApifyResource} or a field + * name typo is actually caught here, not only at compile time. + */ +class NestedDtoForwardCompatTest { + + @Test + void actorRunStatsModelsMigrationAndRebootCountAndKeepsUnknownFields() { + ActorRunStats stats = + parse( + "{\"inputBodyLen\":10,\"migrationCount\":3,\"rebootCount\":2,\"restartCount\":1," + + "\"resurrectCount\":0,\"memAvgBytes\":1.0,\"memMaxBytes\":2.0," + + "\"memCurrentBytes\":3.0,\"cpuAvgUsage\":4.0,\"cpuMaxUsage\":5.0," + + "\"cpuCurrentUsage\":6.0,\"netRxBytes\":7,\"netTxBytes\":8," + + "\"durationMillis\":9,\"runTimeSecs\":1.5,\"metamorph\":0," + + "\"computeUnits\":0.1,\"someFutureStatField\":\"x\"}", + ActorRunStats.class); + + assertEquals(3L, stats.getMigrationCount()); + assertEquals(2L, stats.getRebootCount()); + assertEquals("x", stats.getExtra().get("someFutureStatField")); + } + + @Test + void actorRunMetaModelsScheduleFieldsAndKeepsUnknownFields() { + ActorRunMeta meta = + parse( + "{\"origin\":\"SCHEDULER\",\"clientIp\":\"1.2.3.4\",\"userAgent\":\"curl\"," + + "\"scheduleId\":\"sched123\",\"scheduledAt\":\"2026-07-20T09:00:00.000Z\"," + + "\"someFutureMetaField\":42}", + ActorRunMeta.class); + + assertEquals("sched123", meta.getScheduleId()); + assertEquals(Instant.parse("2026-07-20T09:00:00.000Z"), meta.getScheduledAt()); + assertEquals(42, meta.getExtra().get("someFutureMetaField")); + } + + @Test + void buildStatsModelsImageSizeBytesAndKeepsUnknownFields() { + BuildStats stats = + parse( + "{\"durationMillis\":1000,\"runTimeSecs\":45.7,\"computeUnits\":0.01," + + "\"imageSizeBytes\":975770223,\"someFutureStatField\":true}", + BuildStats.class); + + assertEquals(975770223L, stats.getImageSizeBytes().longValue()); + assertEquals(true, stats.getExtra().get("someFutureStatField")); + } + + /** + * Every other nested value DTO in the audit (one representative per resource package) also + * preserves fields it has no typed getter for, instead of discarding them — the general fix, not + * just the three fields the finding named explicitly. + */ + @Test + void otherAuditedNestedDtosAlsoKeepUnknownFields() { + assertUnknownFieldPreserved( + ActorStandby.class, "{\"isEnabled\":true,\"notYetModelled\":\"a\"}", "notYetModelled"); + assertUnknownFieldPreserved( + ScheduleNotifications.class, "{\"email\":true,\"notYetModelled\":\"b\"}", "notYetModelled"); + assertUnknownFieldPreserved( + WebhookStats.class, "{\"totalDispatches\":3,\"notYetModelled\":\"c\"}", "notYetModelled"); + assertUnknownFieldPreserved( + UserProfile.class, "{\"name\":\"a\",\"notYetModelled\":\"d\"}", "notYetModelled"); + assertUnknownFieldPreserved( + PricingInfo.class, + "{\"pricingModel\":\"FREE\",\"notYetModelled\":\"e\"}", + "notYetModelled"); + assertUnknownFieldPreserved( + TaskStats.class, "{\"totalRuns\":1,\"notYetModelled\":\"f\"}", "notYetModelled"); + assertUnknownFieldPreserved( + RequestQueueOperationInfo.class, + "{\"requestId\":\"r1\",\"notYetModelled\":\"g\"}", + "notYetModelled"); + assertUnknownFieldPreserved( + KeyValueStoreKeysPage.class, + "{\"limit\":10,\"isTruncated\":false,\"items\":[],\"notYetModelled\":\"h\"}", + "notYetModelled"); + } + + @Test + void paginationListKeepsUnknownFields() { + String json = + "{\"total\":1,\"offset\":0,\"limit\":10,\"count\":1,\"desc\":false," + + "\"items\":[\"x\"],\"notYetModelled\":\"i\"}"; + PaginationList page = + Json.parse( + json.getBytes(StandardCharsets.UTF_8), + Json.parametric(PaginationList.class, Json.type(String.class))); + + assertTrue(page.getItems().contains("x")); + assertEquals("i", page.getExtra().get("notYetModelled")); + } + + private static void assertUnknownFieldPreserved( + Class type, String json, String unknownFieldName) { + ApifyResource parsed = parse(json, type); + assertTrue( + parsed.getExtra().containsKey(unknownFieldName), + type.getSimpleName() + " should keep unmodelled field \"" + unknownFieldName + "\""); + } + + private static T parse(String json, Class type) { + return Json.parse(json.getBytes(StandardCharsets.UTF_8), type); + } +} From 6399194739f3f15efcc1ec4565a2e1ad48ad31db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 05:54:07 +0000 Subject: [PATCH 15/18] docs: fix stale "backend" terminology in docs/README.md The builder-settings sentence still referenced "custom HTTP backend" from before the HttpBackend->HttpTransport rename. Updated to "custom HTTP transport" to match the public API and the rest of the docs. --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 85452e1..dc62035 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ ApifyClient client = ApifyClient.create("my-api-token"); `ApifyClient.create` takes the token as an explicit argument — it does not read `APIFY_TOKEN` automatically. Use `ApifyClient.builder()` for non-default settings (base URL, retries, timeout, -user-agent suffix, custom HTTP backend). +user-agent suffix, custom HTTP transport). Get your API token from the [Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations). From bbab39cb4fef77f1d9e934795b6e79074bab2dae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:20:25 +0000 Subject: [PATCH 16/18] fix: extract error type/data independently of message in buildApiError A 404 error body carrying `type` (e.g. record-not-found) but no `message` was previously dropped entirely because type/data extraction was gated on message being present, causing ResourceContext.isNotFound to miss it and get()/delete() to throw instead of mapping to Optional.empty()/no-op. Extract type, message, and data independently; keep the generic fallback message only when message itself is absent. Adds unit tests covering the type-without-message 404 case for get()/delete() and the same shape for a non-404 error. --- .../apify/client/internal/HttpClientCore.java | 2 +- .../java/com/apify/client/UnitHttpTest.java | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index d1da4d0..dd44ce9 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -397,7 +397,7 @@ public static ApifyApiException buildApiError( String type = null; String message = null; Map data = null; - if (envelope != null && envelope.error != null && envelope.error.message != null) { + if (envelope != null && envelope.error != null) { type = envelope.error.type; message = envelope.error.message; data = envelope.error.data; diff --git a/src/test/java/com/apify/client/UnitHttpTest.java b/src/test/java/com/apify/client/UnitHttpTest.java index e9fda51..f132d29 100644 --- a/src/test/java/com/apify/client/UnitHttpTest.java +++ b/src/test/java/com/apify/client/UnitHttpTest.java @@ -96,6 +96,34 @@ void notFoundMapsToEmpty() { assertEquals(1, transport.calls); // no retry on 404 } + @Test + void notFoundWithTypeButNoMessageMapsToEmpty() { + // The `type` field alone must drive not-found detection; `message` may be absent. + MockTransport transport = + MockTransport.ofConstant(404, "{\"error\":{\"type\":\"record-not-found\"}}"); + Optional actor = client(transport, 5).actor("nope").get(); + assertFalse(actor.isPresent()); + assertEquals(1, transport.calls); // no retry on 404 + } + + @Test + void deleteOnNotFoundWithTypeButNoMessageIsNoOp() { + MockTransport transport = + MockTransport.ofConstant(404, "{\"error\":{\"type\":\"record-or-token-not-found\"}}"); + client(transport, 5).actor("nope").delete(); // must not throw + assertEquals(1, transport.calls); + } + + @Test + void nonNotFoundErrorWithTypeButNoMessageStillThrowsWithType() { + MockTransport transport = + MockTransport.ofConstant(400, "{\"error\":{\"type\":\"bad-request\"}}"); + ApifyApiException ex = + assertThrows(ApifyApiException.class, () -> client(transport, 0).me().get()); + assertEquals(400, ex.getStatusCode()); + assertEquals("bad-request", ex.getType()); + } + @Test void errorBodyIsParsed() { MockTransport transport = From e3a0701a752a8be211e25f9968cd22ebd9dde086 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:35:23 +0000 Subject: [PATCH 17/18] fix: gate streamed-log integration assertions on authoritative log presence callWithActorCallOptionsStreamsLogByDefault could red-flake when a short hello-world run's log-streaming lifecycle closed before the background reader caught any bytes, even though the log was fully persisted server- side. Extracted the deterministic gating already used by streamedLogRedirection (loop 6) into shared IntegrationBase helpers and applied it to both that test and TaskIntegrationTest's equivalent taskCallStreamsLogByDefault, so none of the live streamed-log assertions can race a fast-finishing run. --- .../integration/ActorRunIntegrationTest.java | 64 ++++--------------- .../client/integration/IntegrationBase.java | 64 +++++++++++++++++++ .../integration/TaskIntegrationTest.java | 12 ++-- 3 files changed, 83 insertions(+), 57 deletions(-) diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 91920d0..0c87683 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -141,7 +141,11 @@ void callWithActorCallOptionsStreamsLogByDefault() { new ActorCallOptions().logOptions(new StreamedLogOptions().toLog(collected::add)), TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); - assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); + // call()'s internal log-streaming lifecycle closes the stream as soon as the run is finished + // (RunStartSupport#callWithLogStreaming), which can race the background reader the same way + // streamedLogRedirection below does for its own explicit StreamedLog. Gate the assertion on + // whether the run actually produced a log at all, same as that test. + assertStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); } @Test @@ -230,57 +234,15 @@ void streamedLogRedirection() { runClient.getStreamedLog(new StreamedLogOptions().toLog(collected::add))) { streamedLog.start(); runClient.waitForFinish(TEST_ACTOR_WAIT_SECS); - // A fast Actor (hello-world routinely finishes in a couple of seconds) can complete before - // the background reader has pulled any bytes off the live log stream yet, even though the - // log content itself is already fully available server-side once the run is done. Rather - // than asserting immediately (a race with that background thread) or closing the stream - // right away (which would cut the reader off before its first read), give it a bounded - // window to catch up and flush; the log is static at this point, so waiting longer never - // helps once it is genuinely empty. - pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); } - // The catch-up window above closes an eventual-consistency race, but not a genuine one: the - // *first* stream was opened before the run finished, following the live log as the run was - // still writing to it, so the underlying HTTP stream can reach EOF (the container/log-follow - // connection closing) a hair before the final bytes are flushed to that same connection -- no - // amount of client-side waiting after that EOF recovers bytes that were never delivered on it. - // Ask the authoritative source - the run's persisted log via the plain (non-streaming) - // log().get() call - whether the run produced any log output at all, and if the first stream - // still came up empty despite that, retry once with a brand-new stream opened strictly after - // the run is already finished: that is no longer a live tail, just a GET against a static, - // fully-persisted log, so it cannot race the run's own writer the way the first stream could. - Optional authoritativeLog = runClient.log().get(); - boolean runProducedLog = authoritativeLog.isPresent() && !authoritativeLog.get().isEmpty(); - if (runProducedLog && collected.isEmpty()) { - collected.addAll(collectFinishedRunLog(runClient)); - } - if (runProducedLog) { - assertTrue( - !collected.isEmpty(), - "run produced a non-empty log (" - + authoritativeLog.get().length() - + " chars) but the" - + " streamed collector observed none - redirection did not work"); - } - } - - /** - * Opens a fresh {@link StreamedLog} against an already-finished run's static log (with {@link - * StreamedLogOptions#fromStart(boolean)} so the run's already-past-relative-to-construction - * messages are not filtered out) and waits up to {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} for it - * to deliver the (now non-racing) content, so {@link #streamedLogRedirection} can retry once - * after the first, live-tail stream comes up empty despite the run having produced a log. - */ - private static List collectFinishedRunLog(RunClient runClient) { - List retryCollected = new CopyOnWriteArrayList<>(); - try (StreamedLog retryStream = - runClient.getStreamedLog( - new StreamedLogOptions().toLog(retryCollected::add).fromStart(true))) { - retryStream.start(); - pollUntil( - STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !retryCollected.isEmpty()); - } - return retryCollected; + // The *first* stream above was opened before the run finished, following the live log as the + // run was still writing to it, so the underlying HTTP stream can reach EOF (the container/ + // log-follow connection closing) a hair before the final bytes are flushed to that same + // connection, or before the background reader has pulled any bytes off it at all - no amount + // of client-side waiting after that EOF recovers bytes that were never delivered on it. Gate + // the assertion on the authoritative persisted log (see + // IntegrationBase#assertStreamedLogNonEmptyIfProduced for the full retry/skip rationale). + assertStreamedLogNonEmptyIfProduced(runClient, collected); } } diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index a16f685..a4d65d7 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -1,9 +1,16 @@ package com.apify.client.integration; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.apify.client.ApifyClient; +import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; +import com.apify.client.run.RunClient; import java.security.SecureRandom; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BooleanSupplier; /** @@ -129,4 +136,61 @@ public static boolean pollUntil(int maxAttempts, long backoffMillis, BooleanSupp } return false; } + + /** + * Opens a fresh {@link StreamedLog} against an already-finished run's static log (with {@link + * StreamedLogOptions#fromStart(boolean)} so the run's already-past-relative-to-construction + * messages are not filtered out) and waits up to {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} for it + * to deliver the (now non-racing) content. Used by {@link #assertStreamedLogNonEmptyIfProduced} + * to retry once when a live-tail stream came up empty despite the run having produced a log: + * opening this stream strictly after the run is already finished means it is a plain GET against + * a static, fully-persisted log, not a live tail, so it cannot race the run's own writer the way + * the original stream could. + */ + static List collectFinishedRunLog(RunClient runClient) { + List retryCollected = new CopyOnWriteArrayList<>(); + try (StreamedLog retryStream = + runClient.getStreamedLog( + new StreamedLogOptions().toLog(retryCollected::add).fromStart(true))) { + retryStream.start(); + pollUntil( + STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !retryCollected.isEmpty()); + } + return retryCollected; + } + + /** + * Asserts that {@code collected} - lines captured by a live-tail {@link StreamedLog} (or a {@code + * call(...)} overload's built-in log streaming) for a now-finished run - is non-empty, but only + * when the run actually produced log output at all, per the authoritative, statically-persisted + * log ({@code runClient.log().get()}). + * + *

    A fast Actor/task run (the store Actors this suite exercises routinely finish in a couple of + * seconds) can complete - and any log-streaming lifecycle tied to the call/wait can close - + * before the background reader has pulled any bytes off the live log stream yet, even though the + * log content itself is already fully available server-side once the run is done. This method + * first gives {@code collected} a bounded {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} window to catch + * up in-place. If it is still empty, it consults the authoritative persisted log to find out + * whether the run produced any log output at all: if it did not, there is nothing to have + * streamed and the assertion is skipped entirely (this is not a race, just an Actor that logged + * nothing); if it did, one more attempt is made via {@link #collectFinishedRunLog} (a brand-new, + * non-racing stream) before failing. + */ + static void assertStreamedLogNonEmptyIfProduced(RunClient runClient, List collected) { + pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); + + Optional authoritativeLog = runClient.log().get(); + boolean runProducedLog = authoritativeLog.isPresent() && !authoritativeLog.get().isEmpty(); + if (runProducedLog && collected.isEmpty()) { + collected.addAll(collectFinishedRunLog(runClient)); + } + if (runProducedLog) { + assertTrue( + !collected.isEmpty(), + "run produced a non-empty log (" + + authoritativeLog.get().length() + + " chars) but the streamed collector observed none - log streaming/redirection did" + + " not work"); + } + } } diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index 0153a15..310330f 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -135,12 +135,12 @@ void taskCallStreamsLogByDefault() { new TaskCallOptions().logOptions(new StreamedLogOptions().toLog(collected::add)), TEST_ACTOR_WAIT_SECS); assertEquals("SUCCEEDED", run.getStatus()); - // As in ActorRunIntegrationTest#streamedLogRedirection: a fast run can finish (and call()'s - // finally-block close the stream) before the background reader's first read completes, even - // though the log content is already fully available server-side. Give it a bounded window to - // catch up rather than asserting immediately. - pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); - assertTrue(!collected.isEmpty(), "expected the default call() to have streamed log lines"); + // As in ActorRunIntegrationTest#callWithActorCallOptionsStreamsLogByDefault: call()'s + // internal log-streaming lifecycle closes the stream as soon as the run finishes, which can + // race the background reader before it has pulled any bytes off the live log stream, even + // though the log content is already fully available server-side. Gate the assertion on + // whether the run actually produced a log at all, rather than asserting unconditionally. + assertStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); } finally { client.task(task.getId()).delete(); } From dd5675f5484296a511e29019e26dbb235cde4bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:46:43 +0000 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20loop-13=20review=20follow-up=20?= =?UTF-8?q?=E2=80=94=20call()-default=20log-streaming=20tests=20must=20not?= =?UTF-8?q?=20be=20rescued=20by=20a=20bypassing=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TEST-1 review finding: assertStreamedLogNonEmptyIfProduced's rescue retry opens a separate, explicit StreamedLog on a mismatch, which is fine for streamedLogRedirection (its own subject) but would silently mask a genuinely broken call()-default log-streaming wiring for callWithActorCallOptionsStreamsLogByDefault and taskCallStreamsLogByDefault, since the rescue goes through a different code path than the one under test. Split off assertCallDefaultStreamedLogNonEmptyIfProduced, which keeps the bounded catch-up poll and the "skip if the run produced no log at all" gate but drops the rescue, and repointed both call()-default tests to it. --- .../integration/ActorRunIntegrationTest.java | 7 +- .../client/integration/IntegrationBase.java | 79 +++++++++++++++---- .../integration/TaskIntegrationTest.java | 8 +- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 0c87683..db9e6f3 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -144,8 +144,11 @@ void callWithActorCallOptionsStreamsLogByDefault() { // call()'s internal log-streaming lifecycle closes the stream as soon as the run is finished // (RunStartSupport#callWithLogStreaming), which can race the background reader the same way // streamedLogRedirection below does for its own explicit StreamedLog. Gate the assertion on - // whether the run actually produced a log at all, same as that test. - assertStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); + // whether the run actually produced a log at all - via the call()-default variant, which + // (unlike + // streamedLogRedirection's) never rescues via a separate explicit stream, so a genuinely broken + // default-streaming wiring still fails here rather than being masked. + assertCallDefaultStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); } @Test diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index a4d65d7..e9cf237 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -160,31 +160,76 @@ static List collectFinishedRunLog(RunClient runClient) { } /** - * Asserts that {@code collected} - lines captured by a live-tail {@link StreamedLog} (or a {@code - * call(...)} overload's built-in log streaming) for a now-finished run - is non-empty, but only - * when the run actually produced log output at all, per the authoritative, statically-persisted - * log ({@code runClient.log().get()}). + * Asserts that {@code collected} - lines captured by an explicit, directly-tested {@link + * StreamedLog} for a now-finished run - is non-empty, but only when the run actually produced log + * output at all, per the authoritative, statically-persisted log ({@code runClient.log().get()}). * - *

    A fast Actor/task run (the store Actors this suite exercises routinely finish in a couple of - * seconds) can complete - and any log-streaming lifecycle tied to the call/wait can close - - * before the background reader has pulled any bytes off the live log stream yet, even though the - * log content itself is already fully available server-side once the run is done. This method - * first gives {@code collected} a bounded {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} window to catch - * up in-place. If it is still empty, it consults the authoritative persisted log to find out - * whether the run produced any log output at all: if it did not, there is nothing to have - * streamed and the assertion is skipped entirely (this is not a race, just an Actor that logged - * nothing); if it did, one more attempt is made via {@link #collectFinishedRunLog} (a brand-new, - * non-racing stream) before failing. + *

    A fast Actor run (the store Actors this suite exercises routinely finish in a couple of + * seconds) can complete before the background reader has pulled any bytes off the live log stream + * yet, even though the log content itself is already fully available server-side once the run is + * done. This method first gives {@code collected} a bounded {@link + * #STREAM_CATCH_UP_TIMEOUT_MILLIS} window to catch up in-place. If it is still empty, it consults + * the authoritative persisted log to find out whether the run produced any log output at all: if + * it did not, there is nothing to have streamed and the assertion is skipped entirely (this is + * not a race, just an Actor that logged nothing); if it did, one more attempt is made via {@link + * #collectFinishedRunLog} (a brand-new, non-racing {@link StreamedLog}) before failing. + * + *

    The {@link #collectFinishedRunLog} retry opens its own explicit {@code StreamedLog} against + * the finished run, so it is only safe to use when the test's subject under test is that + * same explicit {@code getStreamedLog} API (as {@code streamedLogRedirection} is) - a genuine + * break in that API still fails the assertion. For a test whose subject is instead a {@code + * call(...)} overload's own internal, default log-streaming wiring, use {@link + * #assertCallDefaultStreamedLogNonEmptyIfProduced} instead: retrying via a separate explicit + * stream there would bypass the very wiring under test, silently masking a genuinely broken + * default (e.g. log streaming not actually enabled by default) behind a green assertion sourced + * from a different code path. */ static void assertStreamedLogNonEmptyIfProduced(RunClient runClient, List collected) { pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); Optional authoritativeLog = runClient.log().get(); - boolean runProducedLog = authoritativeLog.isPresent() && !authoritativeLog.get().isEmpty(); - if (runProducedLog && collected.isEmpty()) { + if (runProducedLog(authoritativeLog) && collected.isEmpty()) { collected.addAll(collectFinishedRunLog(runClient)); } - if (runProducedLog) { + assertNonEmptyIfLogProduced(authoritativeLog, collected); + } + + /** + * Asserts that {@code collected} - lines captured by a {@code call(...)} overload's own internal, + * default log-streaming wiring for a now-finished run - is non-empty, but only when the run + * actually produced log output at all, per the authoritative, statically-persisted log ({@code + * runClient.log().get()}). + * + *

    Like {@link #assertStreamedLogNonEmptyIfProduced}, this gives {@code collected} a bounded + * {@link #STREAM_CATCH_UP_TIMEOUT_MILLIS} window to catch up in-place and skips the assertion + * entirely if the authoritative log shows the run produced no output at all. Unlike that method, + * it does not retry via a separate, explicit {@link StreamedLog} on a mismatch: that + * retry goes through a different code path than the one under test here ({@code call(...)}'s own + * default-streaming wiring, not the standalone {@code getStreamedLog} API), so using it as a + * rescue would let a genuinely broken default (e.g. log streaming silently not enabled) pass by + * being quietly repopulated from the bypassing stream instead of failing. The bounded catch-up + * poll is kept as this variant's sole flake mitigation. + */ + static void assertCallDefaultStreamedLogNonEmptyIfProduced( + RunClient runClient, List collected) { + pollUntil(STREAM_CATCH_UP_ATTEMPTS, STREAM_CATCH_UP_POLL_MILLIS, () -> !collected.isEmpty()); + assertNonEmptyIfLogProduced(runClient.log().get(), collected); + } + + /** Whether the authoritative, statically-persisted log shows the run produced any output. */ + private static boolean runProducedLog(Optional authoritativeLog) { + return authoritativeLog.isPresent() && !authoritativeLog.get().isEmpty(); + } + + /** + * Shared assertion backing both {@link #assertStreamedLogNonEmptyIfProduced} and {@link + * #assertCallDefaultStreamedLogNonEmptyIfProduced}: fails if the run produced a non-empty + * authoritative log but nothing was collected, and is a no-op otherwise (including when the run + * produced no log at all, in which case there is nothing to have streamed). + */ + private static void assertNonEmptyIfLogProduced( + Optional authoritativeLog, List collected) { + if (runProducedLog(authoritativeLog)) { assertTrue( !collected.isEmpty(), "run produced a non-empty log (" diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index 310330f..3340f2b 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -139,8 +139,12 @@ void taskCallStreamsLogByDefault() { // internal log-streaming lifecycle closes the stream as soon as the run finishes, which can // race the background reader before it has pulled any bytes off the live log stream, even // though the log content is already fully available server-side. Gate the assertion on - // whether the run actually produced a log at all, rather than asserting unconditionally. - assertStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); + // whether the run actually produced a log at all - via the call()-default variant, which + // never + // rescues via a separate explicit stream, so a genuinely broken default-streaming wiring + // still + // fails here rather than being masked. + assertCallDefaultStreamedLogNonEmptyIfProduced(client.run(run.getId()), collected); } finally { client.task(task.getId()).delete(); }