Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Task task = client.tasks().create(Map.of(
| Method | Description |
|---|---|
| `get()` / `update(Object)` / `delete()` | CRUD. Complete with `Optional<Task>` / `Task` / no value. |
| `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. |
Expand Down Expand Up @@ -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<String>`), `getDatasetName()`, `getDatasetView()`.

```java
Task task = client.task("TASK_ID").unpublish().join();
Boolean isPublic = task.getIsPublic();
if (isPublic != null) {
System.out.println("published: " + isPublic);
}
```
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.apify</groupId>
<artifactId>apify-client</artifactId>
<version>0.5.0</version>
<version>0.6.0</version>
<packaging>jar</packaging>

<name>Apify Java Client</name>
Expand Down
1 change: 1 addition & 0 deletions spotbugs-exclude.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
<Or>
<Method name="getActorStandby"/>
<Method name="getOptions"/>
<Method name="getPublicConfig"/>
<Method name="getStats"/>
</Or>
<Bug pattern="EI_EXPOSE_REP"/>
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/apify/client/Version.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ public final class Version {
* The semantic version of this client library (see <a href="https://semver.org/">SemVer</a>).
* 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() {}
}
21 changes: 18 additions & 3 deletions src/main/java/com/apify/client/internal/HttpClientCore.java
Original file line number Diff line number Diff line change
Expand Up @@ -383,11 +383,26 @@ 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.
*
* <p>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 <a
* href="https://github.com/apify/apify-client-js/pull/990">apify-client-js#990</a> 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) {
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 (Throwable t) {
return new Compressed(gzip(data), ENCODING_GZIP);
}
}
return new Compressed(gzip(data), ENCODING_GZIP);
}

/** Brotli-compresses a request body using the loaded native codec. */
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/apify/client/task/Task.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/apify/client/task/TaskClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,6 +49,31 @@ public CompletableFuture<Void> delete() {
return ctx.deleteResource("");
}

/**
* Publishes the task on its public landing page, by setting {@code isPublic} through {@link
* #update(Object)}.
*
* <p>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<Task> publish() {
return update(Map.of("isPublic", true));
}

/**
* Unpublishes the task from its public landing page, by setting {@code isPublic} through {@link
* #update(Object)}.
*
* <p>The public display configuration ({@code publicConfig}) is preserved, so the task can be
* 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<Task> 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).
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/com/apify/client/task/TaskPublicConfig.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<String> 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<String> 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;
}
}
9 changes: 9 additions & 0 deletions src/test/java/com/apify/client/CompressionTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Not exercised here: {@code HttpClientCore.compress}'s per-call fallback from a
* <em>failing</em> 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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -93,6 +97,33 @@ 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() 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() 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(
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();
Expand Down
Loading