Skip to main content

apify_client/clients/
dataset.rs

1//! Client for a single dataset (`/v2/datasets/{datasetId}` and run-nested variants).
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::clients::base::{
8    delete_resource, get_byte_stream, get_resource, update_resource, ResourceContext,
9};
10use crate::clients::pagination::ListIterator;
11use crate::common::{parse_data_envelope, sign_storage_content, PaginationList, QueryParams};
12use crate::error::ApifyClientResult;
13use crate::http_client::{HttpClient, HttpMethod, HttpRequest};
14use crate::models::Dataset;
15
16/// Options for listing or downloading dataset items.
17///
18/// Covers the filtering, projection and transformation parameters of
19/// `GET /v2/datasets/{datasetId}/items`.
20#[derive(Debug, Default, Clone)]
21pub struct DatasetListItemsOptions {
22    /// Number of items to skip.
23    pub offset: Option<i64>,
24    /// Maximum number of items to return.
25    pub limit: Option<i64>,
26    /// Return items newest-first.
27    pub desc: Option<bool>,
28    /// Only include these fields.
29    pub fields: Option<Vec<String>>,
30    /// Positionally renames the fields selected by `fields` in the output (requires `fields`
31    /// to be set). The i-th name here becomes the output name of the i-th `fields` entry.
32    pub output_fields: Option<Vec<String>>,
33    /// Exclude these fields.
34    pub omit: Option<Vec<String>>,
35    /// Skip empty items.
36    pub skip_empty: Option<bool>,
37    /// Skip hidden fields (those starting with `#`).
38    pub skip_hidden: Option<bool>,
39    /// Only return clean (non-empty, non-hidden) items.
40    pub clean: Option<bool>,
41    /// Unwind these fields (each array element becomes a separate item).
42    pub unwind: Option<Vec<String>>,
43    /// Flatten these nested fields into dot-notation keys.
44    pub flatten: Option<Vec<String>>,
45    /// Use a predefined dataset view for field selection.
46    pub view: Option<String>,
47    /// Return simplified (flattened, cleaned) items.
48    pub simplified: Option<bool>,
49    /// Skip items that come from failed pages.
50    pub skip_failed_pages: Option<bool>,
51    /// Pre-shared URL signature granting access to a private dataset without an API token.
52    pub signature: Option<String>,
53}
54
55impl DatasetListItemsOptions {
56    fn apply(&self, params: &mut QueryParams) {
57        params
58            .add_int("offset", self.offset)
59            .add_int("limit", self.limit)
60            .add_bool("desc", self.desc)
61            .add_csv("fields", self.fields.as_deref())
62            .add_csv("outputFields", self.output_fields.as_deref())
63            .add_csv("omit", self.omit.as_deref())
64            .add_bool("skipEmpty", self.skip_empty)
65            .add_bool("skipHidden", self.skip_hidden)
66            .add_bool("clean", self.clean)
67            .add_csv("unwind", self.unwind.as_deref())
68            .add_csv("flatten", self.flatten.as_deref())
69            .add_str("view", self.view.clone())
70            .add_bool("simplified", self.simplified)
71            .add_bool("skipFailedPages", self.skip_failed_pages)
72            .add_str("signature", self.signature.clone());
73    }
74}
75
76/// Output formats supported by [`DatasetClient::download_items`].
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum DownloadItemsFormat {
79    /// JSON array.
80    Json,
81    /// Newline-delimited JSON.
82    Jsonl,
83    /// Comma-separated values.
84    Csv,
85    /// Microsoft Excel (XLSX).
86    Xlsx,
87    /// XML.
88    Xml,
89    /// RSS feed.
90    Rss,
91    /// HTML table.
92    Html,
93}
94
95impl DownloadItemsFormat {
96    fn as_str(&self) -> &'static str {
97        match self {
98            DownloadItemsFormat::Json => "json",
99            DownloadItemsFormat::Jsonl => "jsonl",
100            DownloadItemsFormat::Csv => "csv",
101            DownloadItemsFormat::Xlsx => "xlsx",
102            DownloadItemsFormat::Xml => "xml",
103            DownloadItemsFormat::Rss => "rss",
104            DownloadItemsFormat::Html => "html",
105        }
106    }
107}
108
109/// Format-specific options for [`DatasetClient::download_items`], on top of the shared
110/// filtering/projection options.
111#[derive(Debug, Default, Clone)]
112pub struct DatasetDownloadOptions {
113    /// Shared item filtering/projection options.
114    pub items: DatasetListItemsOptions,
115    /// Set `Content-Disposition: attachment` on the response.
116    pub attachment: Option<bool>,
117    /// Prepend a UTF-8 BOM (useful for Excel-compatible CSV).
118    pub bom: Option<bool>,
119    /// CSV field delimiter (default `,`).
120    pub delimiter: Option<String>,
121    /// Omit the CSV header row.
122    pub skip_header_row: Option<bool>,
123    /// Name of the root XML element (default `items`).
124    pub xml_root: Option<String>,
125    /// Name of the per-item XML element (default `item`).
126    pub xml_row: Option<String>,
127    /// Title to use for RSS/Atom feed exports.
128    pub feed_title: Option<String>,
129    /// Description to use for RSS/Atom feed exports.
130    pub feed_description: Option<String>,
131}
132
133impl DatasetDownloadOptions {
134    fn apply(&self, params: &mut QueryParams) {
135        self.items.apply(params);
136        params
137            .add_bool("attachment", self.attachment)
138            .add_bool("bom", self.bom)
139            .add_str("delimiter", self.delimiter.clone())
140            .add_bool("skipHeaderRow", self.skip_header_row)
141            .add_str("xmlRoot", self.xml_root.clone())
142            .add_str("xmlRow", self.xml_row.clone())
143            .add_str("feedTitle", self.feed_title.clone())
144            .add_str("feedDescription", self.feed_description.clone());
145    }
146}
147
148/// Client for a specific dataset.
149#[derive(Debug, Clone)]
150pub struct DatasetClient {
151    ctx: ResourceContext,
152}
153
154impl DatasetClient {
155    pub(crate) fn new(http: HttpClient, base_url: &str, resource_path: &str, id: &str) -> Self {
156        Self {
157            ctx: ResourceContext::single(http, base_url, resource_path, id),
158        }
159    }
160
161    /// Creates a dataset client for a run's default dataset (no ID; nested path only).
162    pub(crate) fn nested(http: HttpClient, base_url: &str, sub_path: &str) -> Self {
163        Self {
164            ctx: ResourceContext::collection(http, base_url, sub_path),
165        }
166    }
167
168    /// Sets the public origin used when building shareable URLs.
169    pub(crate) fn with_public_base(mut self, public_base_url: &str) -> Self {
170        self.ctx = self.ctx.with_public_origin(public_base_url);
171        self
172    }
173
174    /// Fetches the dataset metadata, or `None` if it does not exist.
175    pub async fn get(&self) -> ApifyClientResult<Option<Dataset>> {
176        get_resource(&self.ctx, None, &QueryParams::new()).await
177    }
178
179    /// Updates the dataset metadata (e.g. `name`, `title`).
180    pub async fn update<T: Serialize>(&self, new_fields: &T) -> ApifyClientResult<Dataset> {
181        update_resource(&self.ctx, None, new_fields).await
182    }
183
184    /// Deletes the dataset.
185    pub async fn delete(&self) -> ApifyClientResult<()> {
186        delete_resource(&self.ctx, None).await
187    }
188
189    /// Lists items from the dataset.
190    ///
191    /// The dataset items endpoint returns a bare JSON array (not a `data` envelope) and
192    /// reports pagination via `X-Apify-Pagination-*` headers, which are surfaced in the
193    /// returned [`PaginationList`].
194    pub async fn list_items<T: DeserializeOwned>(
195        &self,
196        options: DatasetListItemsOptions,
197    ) -> ApifyClientResult<PaginationList<T>> {
198        let mut params = QueryParams::new();
199        options.apply(&mut params);
200        let url = params.apply_to_url(&self.ctx.url(Some("items")));
201        let response = self
202            .ctx
203            .http
204            .call(HttpRequest {
205                method: HttpMethod::Get,
206                url,
207                headers: Default::default(),
208                body: None,
209                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
210            })
211            .await?;
212
213        let items: Vec<T> = serde_json::from_slice(&response.body)?;
214        let count = items.len() as i64;
215        // `PaginationList::total` is informational only here (`ListIterator` never consults it,
216        // see `scanned` below); falling back to `0` rather than `count` just avoids implying a
217        // precise total when the API did not report one.
218        let total = response
219            .header("x-apify-pagination-total")
220            .and_then(|v| v.parse().ok())
221            .unwrap_or(0);
222        let offset = response
223            .header("x-apify-pagination-offset")
224            .and_then(|v| v.parse().ok())
225            .unwrap_or(0);
226        let limit = response
227            .header("x-apify-pagination-limit")
228            .and_then(|v| v.parse().ok())
229            .unwrap_or(count);
230        // The number of rows scanned to produce this page, when the API reports it separately
231        // from the item count (see `PaginationList::scanned`): a filter can scan more rows than
232        // it returns, and `unwind` can return more items than rows scanned.
233        let scanned = response
234            .header("x-apify-pagination-count")
235            .and_then(|v| v.parse().ok());
236
237        Ok(PaginationList {
238            total,
239            offset,
240            limit,
241            count,
242            desc: options.desc.unwrap_or(false),
243            items,
244            scanned,
245        })
246    }
247
248    /// Lazily iterates over all items in the dataset, fetching pages on demand.
249    ///
250    /// The idiomatic-Rust counterpart of the reference client's async-iterable
251    /// `listItems`/`iterateItems`: yields one deserialized item of type `T` at a time,
252    /// transparently paging. The caller's `options.limit` caps the total number of items yielded
253    /// (unset = all); use [`ListIterator::with_chunk_size`] to control the per-page fetch size.
254    ///
255    /// Server-side filters (`skip_empty`/`clean`/`skip_hidden`) and `unwind` are forwarded on
256    /// every page request. Paging advances the offset by the number of underlying rows each page
257    /// *scanned* (the `x-apify-pagination-count` response header), not by `items.len()`, matching
258    /// the reference JavaScript client: a filter can scan more rows than it returns, and `unwind`
259    /// can return more items than rows scanned, so advancing by the returned count alone would
260    /// re-scan or skip rows. The listing's reported `total` is never consulted, so a dataset a
261    /// still-running Actor keeps pushing to is read to the end: iteration keeps requesting pages
262    /// until one scans no rows (one extra, empty request past the last page with data), rather
263    /// than stopping at the size the dataset had when the loop started.
264    ///
265    /// If `x-apify-pagination-count` is ever absent, the scanned count falls back to `items.len()`.
266    pub fn iterate_items<T: DeserializeOwned + Send + 'static>(
267        &self,
268        options: DatasetListItemsOptions,
269    ) -> ListIterator<T> {
270        let client = self.clone();
271        let start = options.offset.unwrap_or(0);
272        let total_limit = options.limit;
273        ListIterator::new(
274            start,
275            total_limit,
276            Box::new(move |offset, page_limit| {
277                let client = client.clone();
278                let mut options = options.clone();
279                options.offset = Some(offset);
280                options.limit = page_limit;
281                Box::pin(async move { client.list_items::<T>(options).await })
282            }),
283        )
284    }
285
286    /// Downloads dataset items serialized in the given `format`, returning the raw bytes.
287    ///
288    /// Unlike [`list_items`](Self::list_items), which returns parsed items, this returns the
289    /// items already serialized to JSON, CSV, XLSX, XML, RSS or HTML — useful for exporting.
290    /// Use [`DatasetDownloadOptions`] to control export-specific behaviour (BOM, CSV
291    /// delimiter/header, XML element names, attachment disposition).
292    pub async fn download_items(
293        &self,
294        format: DownloadItemsFormat,
295        options: DatasetDownloadOptions,
296    ) -> ApifyClientResult<Vec<u8>> {
297        let mut params = QueryParams::new();
298        params.add_str("format", Some(format.as_str()));
299        options.apply(&mut params);
300        let url = params.apply_to_url(&self.ctx.url(Some("items")));
301        let response = self
302            .ctx
303            .http
304            .call(HttpRequest {
305                method: HttpMethod::Get,
306                url,
307                headers: Default::default(),
308                body: None,
309                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
310            })
311            .await?;
312        Ok(response.body)
313    }
314
315    /// Like [`download_items`](Self::download_items), but streams the export as it arrives
316    /// instead of buffering the whole response in memory.
317    ///
318    /// Returns a `Stream` of byte chunks; drive it with `futures_util::StreamExt::next()` (or
319    /// pipe it through an `AsyncRead` adapter) to process a large export in constant memory. A
320    /// gap of more than 6 minutes between chunks ends the stream with
321    /// [`ApifyClientError::Timeout`](crate::error::ApifyClientError::Timeout) — an inactivity
322    /// timeout, not a cap on the export's total duration.
323    ///
324    /// Goes through the configured `HttpBackend`'s streaming path instead of the buffered one,
325    /// to keep the connection open — like [`crate::clients::log::LogClient::stream`], a failed
326    /// request (e.g. a `404`) still surfaces the same structured [`crate::error::ApiError`] as
327    /// the buffered endpoints, from reading the error body before the stream is handed back.
328    pub async fn download_items_stream(
329        &self,
330        format: DownloadItemsFormat,
331        options: DatasetDownloadOptions,
332    ) -> ApifyClientResult<impl futures_util::Stream<Item = ApifyClientResult<Vec<u8>>>> {
333        let mut params = QueryParams::new();
334        params.add_str("format", Some(format.as_str()));
335        options.apply(&mut params);
336        let url = params.apply_to_url(&self.ctx.url(Some("items")));
337        get_byte_stream(&self.ctx, url).await
338    }
339
340    /// Pushes one or more items to the dataset.
341    ///
342    /// `items` must serialize to a JSON object or an array of objects.
343    pub async fn push_items<T: Serialize>(&self, items: &T) -> ApifyClientResult<()> {
344        let body = serde_json::to_vec(items)?;
345        let url = self.ctx.url(Some("items"));
346        let mut headers = std::collections::HashMap::new();
347        headers.insert(
348            "Content-Type".to_string(),
349            "application/json; charset=utf-8".to_string(),
350        );
351        self.ctx
352            .http
353            .call(HttpRequest {
354                method: HttpMethod::Post,
355                url,
356                headers,
357                body: Some(body),
358                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
359            })
360            .await?;
361        Ok(())
362    }
363
364    /// Builds a public URL for downloading this dataset's items.
365    ///
366    /// Mirrors the reference client's `createItemsPublicUrl`: it fetches the dataset, and if
367    /// the dataset exposes a URL-signing secret key (i.e. it is private), appends an
368    /// HMAC-SHA256 `signature` so the URL grants access without an API token. `expires_in_secs`
369    /// optionally bounds the validity of a signed URL. The URL is built from the configured
370    /// public base URL. The served items are JSON; to request another export format, use
371    /// [`create_items_public_url_with_format`](Self::create_items_public_url_with_format).
372    pub async fn create_items_public_url(
373        &self,
374        options: DatasetListItemsOptions,
375        expires_in_secs: Option<i64>,
376    ) -> ApifyClientResult<String> {
377        self.create_items_public_url_with_format(options, expires_in_secs, None)
378            .await
379    }
380
381    /// Like [`create_items_public_url`](Self::create_items_public_url), but lets the caller
382    /// choose the `format` the items are served in (JSON, CSV, XLSX, ...) via a `format` query
383    /// parameter on the generated URL. `None` defaults to `json`, matching the endpoint's own
384    /// default.
385    pub async fn create_items_public_url_with_format(
386        &self,
387        options: DatasetListItemsOptions,
388        expires_in_secs: Option<i64>,
389        format: Option<DownloadItemsFormat>,
390    ) -> ApifyClientResult<String> {
391        let mut params = QueryParams::new();
392        options.apply(&mut params);
393        params.add_str("format", format.map(|f| f.as_str().to_string()));
394
395        if let Some(dataset) = self.get().await? {
396            if let Some(secret) = dataset
397                .extra
398                .get("urlSigningSecretKey")
399                .and_then(|v| v.as_str())
400            {
401                let signature = sign_storage_content(secret, &dataset.id, expires_in_secs);
402                params.add_str("signature", Some(signature));
403            }
404        }
405        Ok(params.apply_to_url(&self.ctx.public_url(Some("items"))))
406    }
407
408    /// Returns statistical information about the dataset, or `None` if unavailable.
409    pub async fn get_statistics(&self) -> ApifyClientResult<Option<Value>> {
410        let result: ApifyClientResult<Value> = async {
411            let response = self
412                .ctx
413                .http
414                .call(HttpRequest {
415                    method: HttpMethod::Get,
416                    url: self.ctx.url(Some("statistics")),
417                    headers: Default::default(),
418                    body: None,
419                    timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
420                })
421                .await?;
422            parse_data_envelope(&response.body)
423        }
424        .await;
425        crate::common::catch_not_found(result)
426    }
427}