From edae3f90288daea2db347b3c418ab854c015339a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:09:39 +0000 Subject: [PATCH 1/2] feat: sync Java client with Apify OpenAPI spec v2-2026-08-05T133145Z Adds TaskClient.publish()/unpublish() and Task.isPublic/publicConfig (TaskPublicConfig), mirroring apify-client-js#989. The four apify-docs spec PRs since the last sync are description/operationId-only, so no other code change is spec-mandated. Also hardens brotli request-body compression to fall back to gzip if the encode call itself fails, not only when the native codec fails to load (apify-client-js#990's intent). --- CHANGELOG.md | 13 ++++ docs/tasks.md | 23 ++++++- pom.xml | 2 +- spotbugs-exclude.xml | 1 + src/main/java/com/apify/client/Version.java | 4 +- .../apify/client/internal/HttpClientCore.java | 17 ++++- src/main/java/com/apify/client/task/Task.java | 16 +++++ .../com/apify/client/task/TaskClient.java | 25 ++++++++ .../apify/client/task/TaskPublicConfig.java | 64 +++++++++++++++++++ .../integration/TaskIntegrationTest.java | 30 +++++++++ 10 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/apify/client/task/TaskPublicConfig.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f45ae8c..56be841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.6.0] - 2026-08-11 + +### Added + +- `TaskClient.publish()` / `TaskClient.unpublish()`, and `Task.getIsPublic()` / + `Task.getPublicConfig()` (`TaskPublicConfig`), mirroring the reference JS client. + +### Changed + +- Bumped `Version.API_SPEC_VERSION` to `v2-2026-08-05T133145Z`. +- Request-body brotli compression now falls back to gzip if the brotli encoding call itself fails, + not only when the native codec fails to load, matching the reference JS client. + ## [0.5.0] - 2026-07-23 ### Changed diff --git a/docs/tasks.md b/docs/tasks.md index 179b931..bd78055 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -24,6 +24,7 @@ Task task = client.tasks().create(Map.of( | Method | Description | |---|---| | `get()` / `update(Object)` / `delete()` | CRUD. Complete with `Optional` / `Task` / no value. | +| `publish()` / `unpublish()` | Publish/unpublish the task on its public landing page, by setting `isPublic` through `update(Object)`. Publishing requires the task's Actor to be public, write permission to both, and a configured `publicConfig`; unpublishing preserves `publicConfig` so the task can be republished without re-entering it. Complete with the updated `Task`. | | `start(Object input, TaskStartOptions)` | Start a task run (input overrides stored input; `null` uses it). Completes with `ActorRun`. | | `call(Object input, TaskStartOptions, Long waitSecs)` | Start and poll until finished; does **not** stream the run's log. Completes with `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. | @@ -55,12 +56,28 @@ ActorRun streamed = `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 +it fresh on-demand), `getActorStandby()` (`ActorStandby`, from `com.apify.client.actor`, +standby-mode configuration overrides for this task, if any), `getIsPublic()` (`Boolean`; not part +of the documented `Task` schema in the OpenAPI spec, but the API returns it in practice, mirroring +the reference JS client — use `publish()`/`unpublish()` above to change it), and `getPublicConfig()` +(`TaskPublicConfig`, the task's public landing page display configuration, 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()`. + +`TaskPublicConfig` fields (all optional; `null` when unset): `getPublishedAt()` (`Instant`; set +when the task is published, `null` when unpublished — read-only, changed via `publish()` / +`unpublish()`), `getSeoTitle()`, `getSeoDescription()`, `getCategorization()`, +`getInputSchemaFields()` (`List`), `getDatasetName()`, `getDatasetView()`. + +```java +Task task = client.task("TASK_ID").unpublish().join(); +Boolean isPublic = task.getIsPublic(); +if (isPublic != null) { + System.out.println("published: " + isPublic); +} +``` diff --git a/pom.xml b/pom.xml index 27cdad5..0b0ec30 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.5.0 + 0.6.0 jar Apify Java Client diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 218e961..97992ef 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -148,6 +148,7 @@ + diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index 61a6b16..8f62f1e 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.5.0"; + public static final String CLIENT_VERSION = "0.6.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-22T122437Z"; + public static final String API_SPEC_VERSION = "v2-2026-08-05T133145Z"; private Version() {} } diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index be91d15..9ac59ba 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -383,11 +383,22 @@ public record Compressed(byte[] body, String encoding) {} * pass {@link #BROTLI_AVAILABLE}; making the coding an explicit parameter keeps this a pure * function of its inputs rather than of hidden static state. Public so {@code CompressionTest} * (outside this non-exported package) can exercise it directly. + * + *

If the brotli path itself fails despite the native codec having loaded (e.g. a partial or + * mismatched native library that loads but cannot encode), this falls back to gzip rather than + * failing the whole request - matching the reference JS client, which since apify-client-js#990 keys the + * fallback on compression actually failing rather than only on an upfront availability check. */ public static Compressed compress(byte[] data, boolean preferBrotli) { - return preferBrotli - ? new Compressed(brotli(data), ENCODING_BROTLI) - : new Compressed(gzip(data), ENCODING_GZIP); + if (preferBrotli) { + try { + return new Compressed(brotli(data), ENCODING_BROTLI); + } catch (RuntimeException e) { + return new Compressed(gzip(data), ENCODING_GZIP); + } + } + return new Compressed(gzip(data), ENCODING_GZIP); } /** Brotli-compresses a request body using the loaded native codec. */ diff --git a/src/main/java/com/apify/client/task/Task.java b/src/main/java/com/apify/client/task/Task.java index 53231cf..2f063bd 100644 --- a/src/main/java/com/apify/client/task/Task.java +++ b/src/main/java/com/apify/client/task/Task.java @@ -19,6 +19,8 @@ public final class Task extends ApifyResource { private TaskOptions options; private JsonNode input; private ActorStandby actorStandby; + private Boolean isPublic; + private TaskPublicConfig publicConfig; /** The unique task ID. */ public String getId() { @@ -83,4 +85,18 @@ public JsonNode getInput() { public ActorStandby getActorStandby() { return actorStandby; } + + /** + * Whether the task is published on its public landing page. Not part of the documented {@code + * Task} schema in the OpenAPI spec, but the API returns it in practice (mirroring the reference + * JS client). Use {@link TaskClient#publish()} / {@link TaskClient#unpublish()} to change it. + */ + public Boolean getIsPublic() { + return isPublic; + } + + /** The task's public landing page display configuration, if it has one. */ + public TaskPublicConfig getPublicConfig() { + return publicConfig; + } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 4404017..01db9f7 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -12,6 +12,7 @@ import com.apify.client.run.RunClient; import com.apify.client.run.RunCollectionClient; import com.apify.client.webhook.NestedWebhookCollectionClient; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import tools.jackson.databind.JsonNode; @@ -48,6 +49,30 @@ public CompletableFuture delete() { return ctx.deleteResource(""); } + /** + * Publishes the task on its public landing page, by setting {@code isPublic} through {@link + * #update(Object)}. + * + *

The task's Actor must be public and the task must have its public display configuration + * ({@code publicConfig}) set up first. Requires write permission to both the task and its Actor. + * Publishing an already published task does nothing. + */ + public CompletableFuture publish() { + return update(Map.of("isPublic", true)); + } + + /** + * Unpublishes the task from its public landing page, by setting {@code isPublic} through {@link + * #update(Object)}. + * + *

The public display configuration ({@code publicConfig}) is preserved, so the task can be + * published again without re-entering it. Requires write permission to both the task and its + * Actor. Unpublishing a task that is not published does nothing. + */ + public CompletableFuture unpublish() { + return update(Map.of("isPublic", false)); + } + /** * Starts the task and completes with the created run as soon as it exists (no waiting). {@code * input} optionally overrides the task's stored input ({@code null} to use the stored input). diff --git a/src/main/java/com/apify/client/task/TaskPublicConfig.java b/src/main/java/com/apify/client/task/TaskPublicConfig.java new file mode 100644 index 0000000..a5ee76f --- /dev/null +++ b/src/main/java/com/apify/client/task/TaskPublicConfig.java @@ -0,0 +1,64 @@ +package com.apify.client.task; + +import com.apify.client.ApifyResource; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +/** + * Public-facing display configuration of a task's public landing page. + * + *

The task is published when {@link #getPublishedAt()} is set and unpublished when it is {@code + * null}. {@code publishedAt} is read-only - use {@link TaskClient#publish()} and {@link + * TaskClient#unpublish()} to change the publication state. + * + *

Not part of the documented {@code Task} schema in the OpenAPI spec, but the API returns it in + * practice (mirroring the reference JS client's {@code TaskPublicConfig}). + */ +public final class TaskPublicConfig extends ApifyResource { + private Instant publishedAt; + private String seoTitle; + private String seoDescription; + private String categorization; + private List inputSchemaFields; + private String datasetName; + private String datasetView; + + /** When the task was published, or {@code null} if it is currently unpublished. */ + public Instant getPublishedAt() { + return publishedAt; + } + + /** The SEO title shown on the task's public landing page. */ + public String getSeoTitle() { + return seoTitle; + } + + /** The SEO description shown on the task's public landing page. */ + public String getSeoDescription() { + return seoDescription; + } + + /** The category the task is listed under on its public landing page. */ + public String getCategorization() { + return categorization; + } + + /** Which input schema fields are shown on the public landing page. */ + public List getInputSchemaFields() { + // Null-coalesce: Jackson binds directly to the (private) `inputSchemaFields` field for + // deserialization, which can leave it null (field absent or explicit `null` in the response). + // Unmodifiable wrapper: avoid exposing the backing list for external mutation. + return inputSchemaFields == null ? List.of() : Collections.unmodifiableList(inputSchemaFields); + } + + /** The name of the dataset shown on the public landing page, if any. */ + public String getDatasetName() { + return datasetName; + } + + /** Which view of the dataset is shown on the public landing page, if any. */ + public String getDatasetView() { + return datasetView; + } +} diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index b47c2fa..4993df9 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -1,12 +1,16 @@ package com.apify.client.integration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.apify.client.ApifyClient; import com.apify.client.ListOptions; import com.apify.client.Publishers; +import com.apify.client.TestAsync; import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.http.ApifyApiException; import com.apify.client.log.StreamedLogOptions; import com.apify.client.run.ActorRun; import com.apify.client.run.RunListOptions; @@ -93,6 +97,32 @@ void taskCrudFlow() { } } + @Test + void taskPublishUnpublish() { + ApifyClient client = requireClient(); + Task task = client.tasks().create(taskDef(uniqueName("task-publish"))).join(); + try { + TaskClient tc = client.task(task.getId()); + + // unpublish() is a no-op the task's Actor need not be owned by this test account for - + // reuses the update() PUT and leaves isPublic not-true. + Task unpublished = tc.unpublish().join(); + assertFalse(Boolean.TRUE.equals(unpublished.getIsPublic())); + + // publish() requires write permission to the task's Actor (apify/hello-world, unowned by + // this test account) and a configured publicConfig, so it is expected to fail rather than + // succeed here - this still exercises the convenience method sends the documented request. + ApifyApiException error = + assertThrows(ApifyApiException.class, () -> TestAsync.await(tc.publish())); + assertTrue( + error.getStatusCode() == 400 || error.getStatusCode() == 403, + "expected publish() on an unowned Actor's task to fail with 400 or 403, got " + + error.getStatusCode()); + } finally { + client.task(task.getId()).delete().join(); + } + } + @Test void taskLastRunAndWebhooks() { ApifyClient client = requireClient(); From 5aec7ce9b4624a385a151d045d1cd23f593fb00e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 05:22:57 +0000 Subject: [PATCH 2/2] fix: address review feedback on task publish/unpublish and compress fallback Fixes unpublish()'s Javadoc (it only needs write permission to the task, not its Actor), widens compress()'s fallback catch to Throwable so it actually covers the native-codec-failure case its comment describes (matching detectBrotli()'s existing Throwable catch), cleans up two garbled test comments, and documents the untested fallback branch as an accepted gap with rationale. --- docs/tasks.md | 2 +- .../com/apify/client/internal/HttpClientCore.java | 12 ++++++++---- src/main/java/com/apify/client/task/TaskClient.java | 5 +++-- src/test/java/com/apify/client/CompressionTest.java | 9 +++++++++ .../client/integration/TaskIntegrationTest.java | 11 ++++++----- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/tasks.md b/docs/tasks.md index bd78055..102f637 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -24,7 +24,7 @@ Task task = client.tasks().create(Map.of( | Method | Description | |---|---| | `get()` / `update(Object)` / `delete()` | CRUD. Complete with `Optional` / `Task` / no value. | -| `publish()` / `unpublish()` | Publish/unpublish the task on its public landing page, by setting `isPublic` through `update(Object)`. Publishing requires the task's Actor to be public, write permission to both, and a configured `publicConfig`; unpublishing preserves `publicConfig` so the task can be republished without re-entering it. Complete with the updated `Task`. | +| `publish()` / `unpublish()` | Publish/unpublish the task on its public landing page, by setting `isPublic` through `update(Object)`. `publish()` requires the task's Actor to be public, write permission to both the task and its Actor, and a configured `publicConfig`. `unpublish()` only requires write permission to the task; it preserves `publicConfig` so the task can be republished without re-entering it. Complete with the updated `Task`. | | `start(Object input, TaskStartOptions)` | Start a task run (input overrides stored input; `null` uses it). Completes with `ActorRun`. | | `call(Object input, TaskStartOptions, Long waitSecs)` | Start and poll until finished; does **not** stream the run's log. Completes with `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. | diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index 9ac59ba..2483012 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -384,17 +384,21 @@ public record Compressed(byte[] body, String encoding) {} * function of its inputs rather than of hidden static state. Public so {@code CompressionTest} * (outside this non-exported package) can exercise it directly. * - *

If the brotli path itself fails despite the native codec having loaded (e.g. a partial or - * mismatched native library that loads but cannot encode), this falls back to gzip rather than - * failing the whole request - matching the reference JS client, which since If the brotli path itself fails despite the native codec having loaded, this falls back to + * gzip rather than failing the whole request - matching the reference JS client, which since apify-client-js#990 keys the * fallback on compression actually failing rather than only on an upfront availability check. + * Catches {@code Throwable}, not just {@code RuntimeException}: a native codec that loaded + * successfully at startup can still fail a specific call with an {@code Error} (e.g. a partial or + * mismatched native library surfacing {@link UnsatisfiedLinkError} only once a method is actually + * invoked), the same reason {@link #detectBrotli()} itself catches {@code Throwable} rather than + * {@code Exception}. */ public static Compressed compress(byte[] data, boolean preferBrotli) { if (preferBrotli) { try { return new Compressed(brotli(data), ENCODING_BROTLI); - } catch (RuntimeException e) { + } catch (Throwable t) { return new Compressed(gzip(data), ENCODING_GZIP); } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 01db9f7..f97def6 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -66,8 +66,9 @@ public CompletableFuture publish() { * #update(Object)}. * *

The public display configuration ({@code publicConfig}) is preserved, so the task can be - * published again without re-entering it. Requires write permission to both the task and its - * Actor. Unpublishing a task that is not published does nothing. + * published again without re-entering it. Requires write permission to the task only (unlike + * {@link #publish()}, it does not require permission to the task's Actor). Unpublishing a task + * that is not published does nothing. */ public CompletableFuture unpublish() { return update(Map.of("isPublic", false)); diff --git a/src/test/java/com/apify/client/CompressionTest.java b/src/test/java/com/apify/client/CompressionTest.java index c074851..89912b9 100644 --- a/src/test/java/com/apify/client/CompressionTest.java +++ b/src/test/java/com/apify/client/CompressionTest.java @@ -25,6 +25,15 @@ * verbatim. The client prefers brotli ({@code br}) and falls back to gzip; both codings are * exercised here — the brotli/gzip decision (a pure function) directly, and the live client path * via a real request. + * + *

Not exercised here: {@code HttpClientCore.compress}'s per-call fallback from a + * failing brotli encode to gzip (as opposed to the upfront {@code BROTLI_AVAILABLE} check, + * which the brotli-path tests below do exercise). Accepted gap, not an oversight: brotli4j's + * in-memory {@code Encoder.compress} has no documented, portable way to be made to throw once the + * native codec has loaded for a given platform, so a test forcing that failure would need to fake + * the codec behind a seam that does not otherwise exist in this client - not worth adding for a + * defense-in-depth branch whose only effect, if it were ever reached, is choosing gzip over brotli + * (both already-tested, already-correct codings). */ class CompressionTest { diff --git a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java index 4993df9..62f702a 100644 --- a/src/test/java/com/apify/client/integration/TaskIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/TaskIntegrationTest.java @@ -104,14 +104,15 @@ void taskPublishUnpublish() { try { TaskClient tc = client.task(task.getId()); - // unpublish() is a no-op the task's Actor need not be owned by this test account for - - // reuses the update() PUT and leaves isPublic not-true. + // unpublish() only requires write permission to the task itself, not its Actor, so it + // succeeds here even though the task's Actor (apify/hello-world) is unowned by this test + // account. Reuses the update() PUT and leaves isPublic not-true. Task unpublished = tc.unpublish().join(); assertFalse(Boolean.TRUE.equals(unpublished.getIsPublic())); - // publish() requires write permission to the task's Actor (apify/hello-world, unowned by - // this test account) and a configured publicConfig, so it is expected to fail rather than - // succeed here - this still exercises the convenience method sends the documented request. + // publish() additionally requires write permission to the task's Actor and a configured + // publicConfig, so it is expected to fail here rather than succeed - this still exercises + // that the convenience method sends the documented request. ApifyApiException error = assertThrows(ApifyApiException.class, () -> TestAsync.await(tc.publish())); assertTrue(