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}