diff --git a/CHANGELOG.md b/CHANGELOG.md index e474b94..a56d836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 0639aeb..b2da2c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apify-client" -version = "0.8.0" +version = "0.9.0" authors = ["Apify Technologies "] description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)." license = "Apache-2.0" diff --git a/docs/tasks.md b/docs/tasks.md index 6cbfb26..ee3a80e 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -42,4 +42,21 @@ are preserved in `extra`. | `title` | `Option` | Human-readable title shown in the UI. | | `created_at` | `Option>` | When the task was created. | | `modified_at` | `Option>` | When the task was last modified. | +| `is_public` | `Option` | 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` | 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>` | When the task was published, or `None` if unpublished. Read-only. | +| `seo_title` | `Option` | Name shown by search engines. Defaults to the task title when unset. | +| `seo_description` | `Option` | Description shown by search engines. Defaults to the task description when unset. | +| `input_schema_fields` | `Option>` | Names of the task input fields displayed on the public task page. | +| `dataset_name` | `Option` | Name of the Actor dataset schema entry whose results are displayed. `None` uses the Actor's default dataset. | +| `dataset_view` | `Option` | Key of the dataset view (from the Actor's dataset schema) used to display results. Required to publish the task. | diff --git a/src/clients/task.rs b/src/clients/task.rs index 9041d4a..1cbc8a9 100644 --- a/src/clients/task.rs +++ b/src/clients/task.rs @@ -50,7 +50,8 @@ 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 { self.update(&serde_json::json!({ "isPublic": true })).await } @@ -58,9 +59,9 @@ impl TaskClient { /// 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 { self.update(&serde_json::json!({ "isPublic": false })).await } diff --git a/src/models.rs b/src/models.rs index 6a4dde0..b12b7ba 100644 --- a/src/models.rs +++ b/src/models.rs @@ -171,11 +171,52 @@ pub struct Task { /// When the task was last modified. #[serde(default)] pub modified_at: Option>, + /// 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, + /// The task's public landing page display configuration, or `None` if never configured. + #[serde(default)] + pub public_config: Option, /// 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>, + /// Name shown by search engines. Defaults to the task title when unset. + #[serde(default)] + pub seo_title: Option, + /// Description shown by search engines. Defaults to the task description when unset. + #[serde(default)] + pub seo_description: Option, + /// Names of the task input fields displayed on the public task page. + #[serde(default)] + pub input_schema_fields: Option>, + /// Name of the Actor dataset schema entry whose results are displayed. `None` uses the + /// Actor's default dataset. + #[serde(default)] + pub dataset_name: Option, + /// 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, +} + /// A dataset storage. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src/version.rs b/src/version.rs index 5e368b2..f405220 100644 --- a/src/version.rs +++ b/src/version.rs @@ -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"; diff --git a/tests/actor.rs b/tests/actor.rs index 609c8b6..21e91b5 100644 --- a/tests/actor.rs +++ b/tests/actor.rs @@ -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. diff --git a/tests/build.rs b/tests/build.rs index dab9193..1464b40 100644 --- a/tests/build.rs +++ b/tests/build.rs @@ -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, @@ -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, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 6cf6724..8145a90 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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]) +} diff --git a/tests/task.rs b/tests/task.rs index 3a45b6e..6ad2af7 100644 --- a/tests/task.rs +++ b/tests/task.rs @@ -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!(); @@ -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)) => { @@ -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")]