diff --git a/CHANGELOG.md b/CHANGELOG.md index c642d4c..dfb9f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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.10.0] - 2026-10-21 + +### Added + +- `RunChargeOptions.requestId(String)`, sent as `requestId` in the charge request body: the ID of the + Server Actor request being charged, required for multi-tenant Server Actor runs. +- `Build.getActVersion()` and the `BuildActVersion` model (with the `SourceCodeFile` model for its + inline `sourceFiles`); `buildTag` is `null` for a version without a build tag. + +### Changed + +- Updated to Apify OpenAPI specification `v2-2026-10-20T121847Z`. +- Documented that the `FLAT_PRICE_PER_MONTH` (rental) pricing model is deprecated and no longer + listed as a Store `pricingModel` filter value. + ## [0.9.0] - 2026-10-19 ### Added diff --git a/README.md b/README.md index 980125c..c272636 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.9.0 + 0.10.0 ``` @@ -35,7 +35,7 @@ repositories { } dependencies { - implementation 'com.apify:apify-client:0.9.0' + implementation 'com.apify:apify-client:0.10.0' } ``` @@ -317,10 +317,10 @@ 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.9.0`). +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.10.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-10-08T073338Z`). It is a snapshot, not a live compatibility + and checked against (`v2-2026-10-20T121847Z`). 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. diff --git a/docs/README.md b/docs/README.md index e21c877..04ad78f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,8 +40,8 @@ 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`, `ActorStats`, `ActorDefaultRunOptions` | -| `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient`, `BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage` | +| `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `SourceCodeFile`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient`, `ValidateInputOptions`, `ActorStats`, `ActorDefaultRunOptions` | +| `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient`, `BuildActVersion`, `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` | diff --git a/docs/builds.md b/docs/builds.md index b5704a4..b1f5d8c 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -23,7 +23,9 @@ builds) and a single build with `client.build(id)`. | `log()` | A `LogClient` for the build's log. | `Build` fields: `getId()`, `getActId()`, `getUserId()`, `getStatus()`, `getStartedAt()`, -`getFinishedAt()`, `getBuildNumber()`, `getImageDigest()` (`String`, nullable — the built Docker +`getFinishedAt()`, `getBuildNumber()`, `getActVersion()` (`BuildActVersion` — `getSourceType()`, `getBuildTag()` +(nullable), `getVersionNumber()`, `getGitRepoUrl()`, `getSourceFiles()` (`List` — `getName()`, +`getFormat()`, `getContent()`)), `getImageDigest()` (`String`, nullable — the built Docker image manifest's digest, without the `sha256:` prefix; compare two builds' digests to tell whether their image contents differ), `getMeta()` (`BuildMeta` — `getOrigin()`, `getClientIp()`, `getUserAgent()`), `getStats()` (`BuildStats` — `getDurationMillis()`/`getRunTimeSecs()` as `Long`, @@ -32,7 +34,7 @@ their image contents differ), `getMeta()` (`BuildMeta` — `getOrigin()`, `getCl `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 +Every nested model above (`BuildActVersion`, `BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage`) also has its own `getExtra()`, so a field not yet listed here is still reachable rather than dropped. ```java diff --git a/docs/misc.md b/docs/misc.md index 2d86256..63b8773 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -12,7 +12,8 @@ Browse public Actors in the Apify Store. `client.store()` returns a `StoreCollec `StoreListOptions` fields: `offset` (number of Actors to skip), `limit` (maximum number of Actors to return), `search` (full-text search query), `sortBy` (the sort field, e.g. `popularity`, `newest`), `category` (filter Actors by category), `username` (filter Actors by owner username), `pricingModel` -(filter by pricing model: `FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`), +(filter by pricing model: `FREE`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`; the deprecated rental model +`FLAT_PRICE_PER_MONTH` is no longer listed), `includeUnrunnableActors` (include Actors the current user cannot run), `allowsAgenticUsers` (only Actors that allow agentic users), `responseFormat` (the response shape: `full` or `agent`). diff --git a/docs/runs.md b/docs/runs.md index f406ddf..2668b71 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -151,9 +151,10 @@ nullable), `getMemoryMbytes()` (`Long`, nullable), `getDiskMbytes()` (`Long`, nu `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 -applied at most once. +`RunChargeOptions` (constructed with the required event name) uses plain values: `count(Long)`, +`idempotencyKey(String)` — auto-generated when unset so a transport-retried charge is applied at +most once — and `requestId(String)`, the `X-Actor-Request-ID` of the Server Actor request being +charged (required for multi-tenant Server Actor runs, omitted otherwise). ```java client.run("RUN_ID").charge(new RunChargeOptions("my-event").count(3L)).join(); diff --git a/pom.xml b/pom.xml index d6753b4..e189322 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.9.0 + 0.10.0 jar Apify Java Client diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 4111b75..cfafadb 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -18,8 +18,8 @@ 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}, {@code ActorDefaultRunOptions}, {@code BuildMeta}, - {@code BuildOptions}, {@code BuildStats}, {@code PricingInfo}, {@code ProxyGroup}, {@code + TaskOptions}, {@code WebhookLastDispatch}, {@code ActorDefaultRunOptions}, {@code BuildActVersion}, + {@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 @@ -74,6 +74,8 @@ + + @@ -111,6 +113,7 @@ + diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index fe488ad..95b44ba 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.9.0"; + public static final String CLIENT_VERSION = "0.10.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-10-08T073338Z"; + public static final String API_SPEC_VERSION = "v2-2026-10-20T121847Z"; private Version() {} } diff --git a/src/main/java/com/apify/client/actor/SourceCodeFile.java b/src/main/java/com/apify/client/actor/SourceCodeFile.java new file mode 100644 index 0000000..8a1c98f --- /dev/null +++ b/src/main/java/com/apify/client/actor/SourceCodeFile.java @@ -0,0 +1,28 @@ +package com.apify.client.actor; + +import com.apify.client.ApifyResource; + +/** A single file in an Actor's source code. */ +public final class SourceCodeFile extends ApifyResource { + private String name; + private String format; + private String content; + + /** The path of the file relative to the Actor's root directory. */ + public String getName() { + return name; + } + + /** + * The format of {@link #getContent()}: {@code "TEXT"} for plain text, {@code "BASE64"} for + * encoded content. + */ + public String getFormat() { + return format; + } + + /** The contents of the file, interpreted according to {@link #getFormat()}. */ + public String getContent() { + return content; + } +} diff --git a/src/main/java/com/apify/client/build/Build.java b/src/main/java/com/apify/client/build/Build.java index cd12040..7a01932 100644 --- a/src/main/java/com/apify/client/build/Build.java +++ b/src/main/java/com/apify/client/build/Build.java @@ -14,6 +14,7 @@ public final class Build extends ApifyResource { private Instant finishedAt; private String buildNumber; private String imageDigest; + private BuildActVersion actVersion; private BuildMeta meta; private BuildStats stats; private BuildOptions options; @@ -69,6 +70,14 @@ public String getImageDigest() { return imageDigest; } + /** + * A snapshot of the Actor version this build was created from. Its {@link + * BuildActVersion#getBuildTag()} is {@code null} for a version without a build tag. + */ + public BuildActVersion getActVersion() { + return actVersion; + } + /** Metadata about how the build was initiated. */ public BuildMeta getMeta() { return meta; diff --git a/src/main/java/com/apify/client/build/BuildActVersion.java b/src/main/java/com/apify/client/build/BuildActVersion.java new file mode 100644 index 0000000..2e66599 --- /dev/null +++ b/src/main/java/com/apify/client/build/BuildActVersion.java @@ -0,0 +1,50 @@ +package com.apify.client.build; + +import com.apify.client.ApifyResource; +import com.apify.client.actor.SourceCodeFile; +import java.util.Collections; +import java.util.List; + +/** A snapshot of the Actor version a {@link Build} was created from. */ +public final class BuildActVersion extends ApifyResource { + private String sourceType; + private String buildTag; + private String versionNumber; + private String gitRepoUrl; + private List sourceFiles; + + /** + * How the version's source was provided (e.g. {@code "SOURCE_FILES"}, {@code "GIT_REPO"}, {@code + * "TARBALL"}, {@code "GITHUB_GIST"}, {@code "SOURCE_CODE"}). + */ + public String getSourceType() { + return sourceType; + } + + /** + * The build tag of the Actor version the build was created from. {@code null} when the version + * has no build tag, in which case the build is tagged on success only if a tag was passed when + * starting it. + */ + public String getBuildTag() { + return buildTag; + } + + /** The version number (e.g. {@code "0.1"}). */ + public String getVersionNumber() { + return versionNumber; + } + + /** URL of the Git repository; present when the source type is {@code GIT_REPO}. */ + public String getGitRepoUrl() { + return gitRepoUrl; + } + + /** + * The inline source files; non-empty only when the source type is {@code SOURCE_FILES}, an empty + * list otherwise. + */ + public List getSourceFiles() { + return sourceFiles == null ? List.of() : Collections.unmodifiableList(sourceFiles); + } +} diff --git a/src/main/java/com/apify/client/run/RunChargeOptions.java b/src/main/java/com/apify/client/run/RunChargeOptions.java index 7a3f64a..6c19fc9 100644 --- a/src/main/java/com/apify/client/run/RunChargeOptions.java +++ b/src/main/java/com/apify/client/run/RunChargeOptions.java @@ -5,6 +5,7 @@ public final class RunChargeOptions { private String eventName; private Long count; private String idempotencyKey; + private String requestId; /** Creates options for the given (required) event name. */ public RunChargeOptions(String eventName) { @@ -32,6 +33,16 @@ public RunChargeOptions idempotencyKey(String idempotencyKey) { return this; } + /** + * The ID of the Server Actor request being charged, taken from the {@code X-Actor-Request-ID} + * header of the incoming request. Required for multi-tenant Server Actor runs, where it + * identifies the request the charge belongs to; omit it otherwise. + */ + public RunChargeOptions requestId(String requestId) { + this.requestId = requestId; + return this; + } + String eventNameValue() { return eventName; } @@ -43,4 +54,8 @@ long countValue() { String idempotencyKeyValue() { return idempotencyKey; } + + String requestIdValue() { + return requestId; + } } diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index a8dd46b..3bf64a5 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -172,6 +172,10 @@ public CompletableFuture charge(RunChargeOptions options) { Map body = new LinkedHashMap<>(); body.put("eventName", eventName); body.put("count", options.countValue()); + String requestId = options.requestIdValue(); + if (requestId != null && !requestId.isEmpty()) { + body.put("requestId", requestId); + } Map headers = Map.of(CHARGE_IDEMPOTENCY_HEADER, idempotencyKey); // Route through mergedParams like the run's other actions, so a last-run-seeded context charges // the same run its read methods resolve to (its pinned status/origin filters are preserved). diff --git a/src/main/java/com/apify/client/store/PricingInfo.java b/src/main/java/com/apify/client/store/PricingInfo.java index 6710933..3d7c728 100644 --- a/src/main/java/com/apify/client/store/PricingInfo.java +++ b/src/main/java/com/apify/client/store/PricingInfo.java @@ -7,7 +7,7 @@ public final class PricingInfo extends ApifyResource { private String pricingModel; /** - * The pricing model, e.g. {@code "FREE"}, {@code "FLAT_PRICE_PER_MONTH"}, {@code + * The pricing model, e.g. {@code "FREE"}, {@code "PRICE_PER_DATASET_ITEM"}, {@code * "PAY_PER_EVENT"}. */ public String getPricingModel() { diff --git a/src/main/java/com/apify/client/store/StoreListOptions.java b/src/main/java/com/apify/client/store/StoreListOptions.java index 785415a..df086b3 100644 --- a/src/main/java/com/apify/client/store/StoreListOptions.java +++ b/src/main/java/com/apify/client/store/StoreListOptions.java @@ -56,8 +56,9 @@ public StoreListOptions username(String username) { } /** - * Filter Actors by pricing model ({@code FREE}, {@code FLAT_PRICE_PER_MONTH}, {@code - * PRICE_PER_DATASET_ITEM}, {@code PAY_PER_EVENT}). + * Filter Actors by pricing model ({@code FREE}, {@code PRICE_PER_DATASET_ITEM}, {@code + * PAY_PER_EVENT}). The rental model ({@code FLAT_PRICE_PER_MONTH}) is deprecated and no longer + * supported, so it is no longer documented as a filter value and matches no Actors. */ public StoreListOptions pricingModel(String pricingModel) { this.pricingModel = pricingModel; diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 7d19607..62b5771 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -119,6 +119,20 @@ void chargeHonorsExplicitIdempotencyKey() { assertEquals("fixed-key", backend.lastHeaders.firstValue("idempotency-key").orElse("")); } + @Test + void chargeSendsRequestIdOnlyWhenSet() { + MockTransport withId = MockTransport.ofConstant(200, "{\"data\":{}}"); + client(withId) + .run("run123") + .charge(new RunChargeOptions("e").requestId("7Hs2kQw9LxP4mN1vB")) + .join(); + assertTrue(withId.lastBody.contains("\"requestId\":\"7Hs2kQw9LxP4mN1vB\""), withId.lastBody); + + MockTransport without = MockTransport.ofConstant(200, "{\"data\":{}}"); + client(without).run("run123").charge(new RunChargeOptions("e")).join(); + assertFalse(without.lastBody.contains("requestId"), without.lastBody); + } + @Test void chargeRejectsMissingEventName() { MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); diff --git a/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java b/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java index 4e5167a..64278e2 100644 --- a/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java +++ b/src/test/java/com/apify/client/NestedDtoForwardCompatTest.java @@ -1,9 +1,11 @@ package com.apify.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.actor.ActorStandby; +import com.apify.client.build.Build; import com.apify.client.build.BuildStats; import com.apify.client.internal.Json; import com.apify.client.keyvalue.KeyValueStoreKeysPage; @@ -14,6 +16,7 @@ import com.apify.client.store.PricingInfo; import com.apify.client.task.TaskStats; import com.apify.client.user.UserProfile; +import com.apify.client.webhook.Webhook; import com.apify.client.webhook.WebhookStats; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -73,6 +76,50 @@ void buildStatsModelsImageSizeBytesAndKeepsUnknownFields() { assertEquals(true, stats.getExtra().get("someFutureStatField")); } + @Test + void buildActVersionAcceptsNullBuildTag() { + Build tagged = + parse( + "{\"id\":\"b1\",\"actVersion\":{\"sourceType\":\"GIT_REPO\"," + + "\"buildTag\":\"latest\",\"versionNumber\":\"0.1\"," + + "\"gitRepoUrl\":\"https://github.com/apify/x.git\"}}", + Build.class); + assertEquals("latest", tagged.getActVersion().getBuildTag()); + assertEquals("0.1", tagged.getActVersion().getVersionNumber()); + assertEquals("GIT_REPO", tagged.getActVersion().getSourceType()); + assertEquals("https://github.com/apify/x.git", tagged.getActVersion().getGitRepoUrl()); + assertTrue(tagged.getActVersion().getSourceFiles().isEmpty()); + + Build untagged = + parse( + "{\"id\":\"b2\",\"actVersion\":{\"sourceType\":\"SOURCE_FILES\"," + + "\"buildTag\":null,\"versionNumber\":\"0.0\",\"sourceFiles\":[" + + "{\"name\":\"main.js\",\"format\":\"TEXT\",\"content\":\"x\"}]}}", + Build.class); + assertNull(untagged.getActVersion().getBuildTag()); + assertEquals(1, untagged.getActVersion().getSourceFiles().size()); + assertEquals("main.js", untagged.getActVersion().getSourceFiles().get(0).getName()); + assertEquals("TEXT", untagged.getActVersion().getSourceFiles().get(0).getFormat()); + assertEquals("x", untagged.getActVersion().getSourceFiles().get(0).getContent()); + assertEquals("SOURCE_FILES", untagged.getActVersion().getSourceType()); + } + + @Test + void webhookModelsAdHocFlagAndCondition() { + Webhook adHoc = + parse( + "{\"id\":\"w1\",\"isAdHoc\":true,\"condition\":{\"actorRunId\":\"run1\"}}", + Webhook.class); + assertTrue(adHoc.isAdHoc()); + assertEquals("run1", adHoc.getCondition().get("actorRunId").asString()); + + Webhook persistent = + parse( + "{\"id\":\"w2\",\"isAdHoc\":false,\"condition\":{\"actorTaskId\":\"t1\"}}", + Webhook.class); + assertEquals("t1", persistent.getCondition().get("actorTaskId").asString()); + } + /** * 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 diff --git a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java index 1d472aa..2270130 100644 --- a/src/test/java/com/apify/client/integration/IterationIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/IterationIntegrationTest.java @@ -300,12 +300,15 @@ void iterateSchedules() { void iterateWebhooks() { ApifyClient client = requireClient(); Set ids = new HashSet<>(); + Task task = WebhookIntegrationTest.createOwnedTask(client); try { for (int i = 0; i < 2; i++) { ids.add( client .webhooks() - .create(WebhookIntegrationTest.webhookDef("https://example.com/it-wh-" + i)) + .create( + WebhookIntegrationTest.webhookDef( + "https://example.com/it-wh-" + i, task.getId())) .join() .getId()); } @@ -315,8 +318,12 @@ void iterateWebhooks() { Webhook::getId, ids)); } finally { - for (String id : ids) { - client.webhook(id).delete().join(); + try { + for (String id : ids) { + client.webhook(id).delete().join(); + } + } finally { + client.task(task.getId()).delete().join(); } } } diff --git a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java index a8ca400..f2b6b55 100644 --- a/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/WebhookIntegrationTest.java @@ -5,6 +5,7 @@ import com.apify.client.ApifyClient; import com.apify.client.ListOptions; +import com.apify.client.task.Task; import com.apify.client.webhook.Webhook; import com.apify.client.webhook.WebhookClient; import com.apify.client.webhook.WebhookDispatch; @@ -14,18 +15,29 @@ class WebhookIntegrationTest extends IntegrationBase { - static Map webhookDef(String url) { + /** + * A persistent (non-ad-hoc) webhook definition conditioned on the given task. The API rejects a + * condition that targets a resource that does not exist, and ad hoc webhooks can only target + * Actor runs. A webhook on a shared Actor would fire on other suites' concurrent runs, so each + * test conditions its webhook on a task it owns. + */ + static Map webhookDef(String url, String taskId) { return Map.of( "isAdHoc", - true, + false, "eventTypes", List.of("ACTOR.RUN.SUCCEEDED"), "condition", - Map.of("actorRunId", "ZZZZZZZZZZZZZZZZZ"), + Map.of("actorTaskId", taskId), "requestUrl", url); } + /** Creates a uniquely named task owned by the calling test; the caller deletes it. */ + static Task createOwnedTask(ApifyClient client) { + return client.tasks().create(TaskIntegrationTest.taskDef(uniqueName("wh-task"))).join(); + } + @Test void listWebhooks() { ApifyClient client = requireClient(); @@ -41,67 +53,86 @@ void listWebhookDispatches() { @Test void getWebhook() { ApifyClient client = requireClient(); - Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")).join(); + Task task = createOwnedTask(client); try { - var got = client.webhook(wh.getId()).get().join(); - assertTrue(got.isPresent()); - assertEquals(wh.getId(), got.get().getId()); + Webhook wh = + client.webhooks().create(webhookDef("https://example.com/webhook", task.getId())).join(); + try { + var got = client.webhook(wh.getId()).get().join(); + assertTrue(got.isPresent()); + assertEquals(wh.getId(), got.get().getId()); + } finally { + client.webhook(wh.getId()).delete().join(); + } } finally { - client.webhook(wh.getId()).delete().join(); + client.task(task.getId()).delete().join(); } } @Test void getWebhookDispatch() { ApifyClient client = requireClient(); - Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")).join(); + Task task = createOwnedTask(client); try { - WebhookDispatch dispatch = client.webhook(wh.getId()).test().join(); - var got = client.webhookDispatch(dispatch.getId()).get().join(); - assertTrue(got.isPresent()); - assertEquals(dispatch.getId(), got.get().getId()); + Webhook wh = + client.webhooks().create(webhookDef("https://example.com/webhook", task.getId())).join(); + try { + WebhookDispatch dispatch = client.webhook(wh.getId()).test().join(); + var got = client.webhookDispatch(dispatch.getId()).get().join(); + assertTrue(got.isPresent()); + assertEquals(dispatch.getId(), got.get().getId()); + } finally { + client.webhook(wh.getId()).delete().join(); + } } finally { - client.webhook(wh.getId()).delete().join(); + client.task(task.getId()).delete().join(); } } @Test void webhookCrudFlow() { ApifyClient client = requireClient(); - Webhook wh = client.webhooks().create(webhookDef("https://example.com/webhook")).join(); + Task task = createOwnedTask(client); try { - WebhookClient webhook = client.webhook(wh.getId()); - assertTrue(webhook.get().join().isPresent()); - Webhook updated = webhook.update(Map.of("requestUrl", "https://example.com/updated")).join(); - assertEquals("https://example.com/updated", updated.getRequestUrl()); - webhook.dispatches().list(new ListOptions()).join(); - webhook.test().join(); + Webhook wh = + client.webhooks().create(webhookDef("https://example.com/webhook", task.getId())).join(); + try { + WebhookClient webhook = client.webhook(wh.getId()); + assertTrue(webhook.get().join().isPresent()); + Webhook updated = + webhook.update(Map.of("requestUrl", "https://example.com/updated")).join(); + assertEquals("https://example.com/updated", updated.getRequestUrl()); + webhook.dispatches().list(new ListOptions()).join(); + webhook.test().join(); - // 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)) - .join() - .getItems() - .stream() - .anyMatch(w -> wh.getId().equals(w.getId()))); - assertTrue(foundInList, "expected the just-created webhook to appear in the top-level list"); + // 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)) + .join() + .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()); - assertTrue(wh.getCondition() != null); - assertTrue(wh.getCreatedAt() != null); - assertTrue(wh.getModifiedAt() != null); - assertTrue(wh.getStats() != null); + // Typed getters (previously only reachable via getExtra()): verify the API's response + // actually deserializes into them, not just that the code compiles. + assertEquals(task.getId(), wh.getCondition().get("actorTaskId").asString()); + assertTrue(wh.getCreatedAt() != null); + assertTrue(wh.getModifiedAt() != null); + assertTrue(wh.getStats() != null); + } finally { + client.webhook(wh.getId()).delete().join(); + } } finally { - client.webhook(wh.getId()).delete().join(); + client.task(task.getId()).delete().join(); } } }