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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to the Rust Apify API client are documented here. The format
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/).

## [0.9.0] - 2026-08-15

### Added
- `Task::is_public` and `Task::public_config` (new `TaskPublicConfig` model: `published_at`,
`seo_title`, `seo_description`, `input_schema_fields`, `dataset_name`, `dataset_view`),
matching the spec's newly-documented Actor task publication fields. Previously only
accessible untyped via `Task::extra`.

### Changed
- Bumped `API_SPEC_VERSION` to `v2-2026-08-14T072928Z`.
- Bumped crate version to `0.9.0`.

## [0.8.0] - 2026-08-11

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "apify-client"
version = "0.8.0"
version = "0.9.0"
authors = ["Apify Technologies <support@apify.com>"]
description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)."
license = "Apache-2.0"
Expand Down
17 changes: 17 additions & 0 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,21 @@ are preserved in `extra`.
| `title` | `Option<String>` | Human-readable title shown in the UI. |
| `created_at` | `Option<DateTime<Utc>>` | When the task was created. |
| `modified_at` | `Option<DateTime<Utc>>` | When the task was last modified. |
| `is_public` | `Option<bool>` | Whether the task is published on its public landing page. Derived from `public_config.published_at`; set it via `publish()`/`unpublish()` or `update()`. |
| `public_config` | `Option<TaskPublicConfig>` | The task's public landing page display configuration, or `None` if never configured. |
| `extra` | `Extra` | Any other fields returned by the API. |

## The `TaskPublicConfig` model

`TaskPublicConfig` lives in `apify_client::models` (`use apify_client::models::TaskPublicConfig;`).
The task is published when `published_at` is set and unpublished when it is `None`; `published_at`
is server-controlled (read-only) - use `publish()`/`unpublish()` to change the publication state.

| Field | Type | Description |
|---|---|---|
| `published_at` | `Option<DateTime<Utc>>` | When the task was published, or `None` if unpublished. Read-only. |
| `seo_title` | `Option<String>` | Name shown by search engines. Defaults to the task title when unset. |
| `seo_description` | `Option<String>` | Description shown by search engines. Defaults to the task description when unset. |
| `input_schema_fields` | `Option<Vec<String>>` | Names of the task input fields displayed on the public task page. |
| `dataset_name` | `Option<String>` | Name of the Actor dataset schema entry whose results are displayed. `None` uses the Actor's default dataset. |
| `dataset_view` | `Option<String>` | Key of the dataset view (from the Actor's dataset schema) used to display results. Required to publish the task. |
9 changes: 5 additions & 4 deletions src/clients/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,18 @@ impl TaskClient {
/// [`TaskClient::update`].
///
/// The task's Actor must be public and the task must already have its public display
/// configuration (`publicConfig`) set up. Publishing an already-published task does nothing.
/// configuration ([`Task::public_config`]) set up. Publishing an already-published task
/// does nothing. The returned [`Task::is_public`] reflects the new publication state.
pub async fn publish(&self) -> ApifyClientResult<Task> {
self.update(&serde_json::json!({ "isPublic": true })).await
}

/// Unpublishes the task from its public landing page, by setting `isPublic: false` through
/// [`TaskClient::update`].
///
/// The public display configuration (`publicConfig`) is preserved, so the task can be
/// published again later without re-entering it. Unpublishing a task that is not published
/// does nothing.
/// The public display configuration ([`Task::public_config`]) is preserved, so the task can
/// be published again later without re-entering it. Unpublishing a task that is not
/// published does nothing.
pub async fn unpublish(&self) -> ApifyClientResult<Task> {
self.update(&serde_json::json!({ "isPublic": false })).await
}
Expand Down
41 changes: 41 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,52 @@ pub struct Task {
/// When the task was last modified.
#[serde(default)]
pub modified_at: Option<DateTime<Utc>>,
/// Whether the task is published on its public landing page. Derived from
/// `public_config.published_at`; set it via [`TaskClient::update`](crate::clients::task::TaskClient::update)
/// (or the [`publish`](crate::clients::task::TaskClient::publish)/
/// [`unpublish`](crate::clients::task::TaskClient::unpublish) wrappers) to change it.
#[serde(default)]
pub is_public: Option<bool>,
/// The task's public landing page display configuration, or `None` if never configured.
#[serde(default)]
pub public_config: Option<TaskPublicConfig>,
/// Any other fields returned by the API.
#[serde(flatten)]
pub extra: Extra,
}

/// Public-facing display configuration of a task's public landing page.
///
/// The task is published when `published_at` is set and unpublished when it is `None`.
/// `published_at` is server-controlled (read-only) - use
/// [`TaskClient::publish`](crate::clients::task::TaskClient::publish) /
/// [`TaskClient::unpublish`](crate::clients::task::TaskClient::unpublish) to change the
/// publication state.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskPublicConfig {
/// When the task was published, or `None` if it isn't published. Read-only.
#[serde(default)]
pub published_at: Option<DateTime<Utc>>,
/// Name shown by search engines. Defaults to the task title when unset.
#[serde(default)]
pub seo_title: Option<String>,
/// Description shown by search engines. Defaults to the task description when unset.
#[serde(default)]
pub seo_description: Option<String>,
/// Names of the task input fields displayed on the public task page.
#[serde(default)]
pub input_schema_fields: Option<Vec<String>>,
/// Name of the Actor dataset schema entry whose results are displayed. `None` uses the
/// Actor's default dataset.
#[serde(default)]
pub dataset_name: Option<String>,
/// Key of the dataset view (from the Actor's dataset schema) used to display results.
/// Required to publish the task.
#[serde(default)]
pub dataset_view: Option<String>,
}

/// A dataset storage.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down
2 changes: 1 addition & 1 deletion src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// and verified against.
///
/// This corresponds to the `info.version` field of the Apify OpenAPI document.
pub const API_SPEC_VERSION: &str = "v2-2026-08-05T133145Z";
pub const API_SPEC_VERSION: &str = "v2-2026-08-14T072928Z";
3 changes: 1 addition & 2 deletions tests/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ async fn list_actors() {
}

fn actor_name(prefix: &str) -> String {
let name = common::unique_name(prefix).replace('-', ""); // actor names are stricter
format!("a{}", &name[..name.len().min(20)])
common::unique_actor_name(prefix)
}

/// Minimal Actor definition with one inline source-files version.
Expand Down
6 changes: 2 additions & 4 deletions tests/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ async fn list_builds() {
#[tokio::test(flavor = "multi_thread")]
async fn iterate_builds() {
let client = require_client!();
let name = common::unique_name("build-iter").replace('-', "");
let name = format!("b{}", &name[..name.len().min(20)]);
let name = common::unique_actor_name("build-iter");

let definition = json!({
"name": name,
Expand Down Expand Up @@ -86,8 +85,7 @@ async fn iterate_builds() {
#[tokio::test(flavor = "multi_thread")]
async fn build_actor_flow() {
let client = require_client!();
let name = common::unique_name("build").replace('-', "");
let name = format!("b{}", &name[..name.len().min(20)]);
let name = common::unique_actor_name("build");

let definition = json!({
"name": name,
Expand Down
24 changes: 24 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,27 @@ pub fn unique_name(prefix: &str) -> String {
let uuid = uuid::Uuid::new_v4().simple().to_string();
format!("rust-test-{prefix}-{}", &uuid[..12])
}

/// Length of the random suffix [`unique_actor_name`] appends, in hex characters.
const ACTOR_NAME_SUFFIX_LEN: usize = 12;

/// Longest human-readable head [`unique_actor_name`] keeps from its (sanitized) `prefix` arg.
const ACTOR_NAME_HEAD_CAP: usize = 8;

/// Generates a unique name that satisfies Apify's (stricter) Actor naming rules: starts with a
/// letter and contains no hyphens, unlike the names [`unique_name`] produces for other resources.
///
/// Built directly from `prefix` (filtered to alphanumerics) plus a full
/// [`ACTOR_NAME_SUFFIX_LEN`]-hex-char random suffix, rather than truncating an already-formatted
/// `unique_name(prefix)` string: that would fix the random suffix's *position* but not its
/// *presence* - a short length cap could still truncate into the fixed `rust-test-` boilerplate
/// `unique_name` prepends, silently keeping the same literal head (and thus the same account-wide
/// collision domain, dropping the caller's `prefix` entirely) for every call regardless of
/// `prefix`. Building the name from `prefix` directly keeps collision resistance and
/// traceability-to-caller both guaranteed by construction.
pub fn unique_actor_name(prefix: &str) -> String {
let sanitized: String = prefix.chars().filter(char::is_ascii_alphanumeric).collect();
let head = &sanitized[..sanitized.len().min(ACTOR_NAME_HEAD_CAP)];
let uuid = uuid::Uuid::new_v4().simple().to_string();
format!("a{head}{}", &uuid[..ACTOR_NAME_SUFFIX_LEN])
}
131 changes: 125 additions & 6 deletions tests/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ async fn iterate_tasks() {

/// `publish`/`unpublish` are thin wrappers around `update` that flip `isPublic`. Unpublishing an
/// already-unpublished task is a documented no-op, so it round-trips cleanly; publishing a task
/// requires write permission over its Actor (here the shared, Apify-owned `apify/hello-world`,
/// per [`task_definition`]), so the API is expected to reject it with `insufficient-permissions` -
/// this exercises the same request path without needing a private Actor the test account can
/// actually publish (which would additionally require an SEO description, an input field
/// selection, and a dataset view set up on the task's public display configuration).
/// (and, per the spec, editing `publicConfig` at all) requires write permission over its Actor.
/// Here that's the shared, Apify-owned `apify/hello-world` (per [`task_definition`]), so both are
/// expected to be rejected with `insufficient-permissions` - this exercises the same request path
/// without needing a private Actor the test account can actually publish. See
/// [`task_public_config_update`] for the `publicConfig`-editing path against an owned Actor.
#[tokio::test(flavor = "multi_thread")]
async fn task_publish_unpublish() {
let client = require_client!();
Expand All @@ -116,7 +116,7 @@ async fn task_publish_unpublish() {
.unpublish()
.await
.expect("unpublish an already-unpublished task should be a no-op");
assert_eq!(unpublished.extra.get("isPublic"), Some(&json!(false)));
assert_eq!(unpublished.is_public, Some(false));

match task_client.publish().await {
Err(apify_client::ApifyClientError::Api(err)) => {
Expand All @@ -130,6 +130,125 @@ async fn task_publish_unpublish() {
}
}

/// Minimal private-Actor definition, owned by the test account (unlike the shared
/// `apify/hello-world` used elsewhere in this file), so the test account has write permission
/// to edit a task's `publicConfig`.
fn owned_actor_definition(name: &str) -> serde_json::Value {
json!({
"name": name,
"isPublic": false,
"versions": [{
"versionNumber": "0.0",
"sourceType": "SOURCE_FILES",
"buildTag": "latest",
"sourceFiles": [
{
"name": "Dockerfile",
"format": "TEXT",
"content": "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"
},
{
"name": "main.js",
"format": "TEXT",
"content": "console.log('hello from rust client test');"
}
]
}]
})
}

/// Exercises `Task::public_config` (`TaskPublicConfig`): editing it via `update` requires write
/// permission to the task's Actor, so this uses a private Actor owned by the test account. Sets
/// the `seoTitle`/`seoDescription` metadata fields and reads them back typed - `inputSchemaFields`/
/// `datasetView` are omitted since the API validates them against the Actor's input/dataset
/// schema (which this minimal Actor doesn't declare) even on a plain `publicConfig` edit, not
/// only on publish. Also checks that `unpublish` (a no-op here, since the task was never
/// published) leaves `publicConfig` untouched.
///
/// Task creation resolves the Actor's default (`latest`) build tag up front, so - unlike the
/// other Actor-owning tests in this suite, which only need the Actor to exist - the Actor must
/// actually be built first (mirrors `tests/build.rs::build_actor_flow`'s build-and-wait).
#[tokio::test(flavor = "multi_thread")]
async fn task_public_config_update() {
let client = require_client!();

let actor_name = common::unique_actor_name("task-pubcfg-actor");
let actor = client
.actors()
.create(&owned_actor_definition(&actor_name))
.await
.expect("create owned actor");
let cleanup_actor_client = client.clone();
let actor_id = actor.id.clone();
let _actor_guard = common::Cleanup::new(move || async move {
let _ = cleanup_actor_client.actor(&actor_id).delete().await;
});

let build = client
.actor(&actor.id)
.build("0.0", Default::default())
.await
.expect("start build");
let finished = client
.build(&build.id)
.wait_for_finish(Some(300))
.await
.expect("wait for build");
assert_eq!(
finished.status.as_deref(),
Some("SUCCEEDED"),
"build must succeed for the task to be able to reference it"
);

let task_name = common::unique_name("task-pubcfg");
let task = client
.tasks()
.create(&json!({ "actId": actor.id, "name": task_name }))
.await
.expect("create task for owned actor");
let cleanup_task_client = client.clone();
let task_id = task.id.clone();
let _task_guard = common::Cleanup::new(move || async move {
let _ = cleanup_task_client.task(&task_id).delete().await;
});

let task_client = client.task(&task.id);

let configured = task_client
.update(&json!({
"publicConfig": {
"seoTitle": "Test task",
"seoDescription": "A test task.",
}
}))
.await
.expect("set publicConfig on an owned actor's task");
let public_config = configured
.public_config
.expect("publicConfig should be set after update");
assert_eq!(public_config.seo_title.as_deref(), Some("Test task"));
assert_eq!(
public_config.seo_description.as_deref(),
Some("A test task.")
);
assert_eq!(public_config.published_at, None);
assert_eq!(configured.is_public, Some(false));

let unpublished = task_client
.unpublish()
.await
.expect("unpublish an already-unpublished task should be a no-op");
assert_eq!(unpublished.is_public, Some(false));
assert_eq!(
unpublished
.public_config
.as_ref()
.and_then(|c| c.seo_title.as_deref()),
Some("Test task"),
"unpublish must not disturb the previously-configured publicConfig"
);
}

/// Complex flow: create a task for the public hello-world Actor, get it, update its input,
/// list its runs, and delete it.
#[tokio::test(flavor = "multi_thread")]
Expand Down
Loading