1use std::collections::HashMap;
9
10use reqwest::Method;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::client::Client;
15use crate::error::{from_response, ScrapflyError};
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct ScheduleEnd {
20 #[serde(rename = "type")]
22 pub kind: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub date: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub count: Option<i64>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct ScheduleRecurrence {
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub cron: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub interval: Option<i64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub unit: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub days: Option<Vec<String>>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub ends: Option<ScheduleEnd>,
50}
51
52#[derive(Debug, Clone, Serialize, Default)]
55pub struct CreateScheduleRequest {
56 pub webhook_name: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub recurrence: Option<ScheduleRecurrence>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub scheduled_date: Option<String>,
64 pub allow_concurrency: bool,
66 pub retry_on_failure: bool,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub max_retries: Option<i64>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub notes: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Default)]
78pub struct UpdateScheduleRequest {
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub recurrence: Option<ScheduleRecurrence>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub scheduled_date: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub allow_concurrency: Option<bool>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub retry_on_failure: Option<bool>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub max_retries: Option<i64>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub notes: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub scrape_config: Option<HashMap<String, Value>>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub screenshot_config: Option<HashMap<String, Value>>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub crawler_config: Option<HashMap<String, Value>>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110pub struct Schedule {
111 pub id: String,
113 pub kind: String,
115 pub status: String,
117 #[serde(default)]
119 pub next_scheduled_date: Option<String>,
120 #[serde(default)]
122 pub scheduled_date: Option<String>,
123 #[serde(default)]
125 pub recurrence: Option<ScheduleRecurrence>,
126 #[serde(default)]
128 pub metadata: Option<HashMap<String, Value>>,
129 #[serde(default)]
131 pub notes: Option<String>,
132 #[serde(default)]
134 pub created_by: Option<String>,
135 pub created_at: String,
137 pub updated_at: String,
139 #[serde(default)]
141 pub cancelled_at: Option<String>,
142 pub allow_concurrency: bool,
144 pub retry_on_failure: bool,
146 pub max_retries: i64,
148 #[serde(default)]
150 pub webhook_uuid: Option<String>,
151 #[serde(default)]
153 pub user_uuid: Option<String>,
154 #[serde(default)]
156 pub consecutive_failures: Option<i64>,
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct ListSchedulesOptions {
162 pub status: Option<String>,
164 pub kind: Option<String>,
166}
167
168impl Client {
169 pub async fn create_scrape_schedule(
171 &self,
172 scrape_config: HashMap<String, Value>,
173 request: &CreateScheduleRequest,
174 ) -> Result<Schedule, ScrapflyError> {
175 self.create_schedule_inner("/scrape/schedules", "scrape_config", scrape_config, request)
176 .await
177 }
178
179 pub async fn create_screenshot_schedule(
181 &self,
182 screenshot_config: HashMap<String, Value>,
183 request: &CreateScheduleRequest,
184 ) -> Result<Schedule, ScrapflyError> {
185 self.create_schedule_inner(
186 "/screenshot/schedules",
187 "screenshot_config",
188 screenshot_config,
189 request,
190 )
191 .await
192 }
193
194 pub async fn create_crawler_schedule(
196 &self,
197 crawler_config: HashMap<String, Value>,
198 request: &CreateScheduleRequest,
199 ) -> Result<Schedule, ScrapflyError> {
200 self.create_schedule_inner(
201 "/crawl/schedules",
202 "crawler_config",
203 crawler_config,
204 request,
205 )
206 .await
207 }
208
209 pub async fn get_schedule(&self, id: &str) -> Result<Schedule, ScrapflyError> {
211 let path = format!("/schedules/{}", url_path_escape(id));
212 self.schedule_request_json::<Schedule>(Method::GET, &path, &[], None)
213 .await
214 }
215
216 pub async fn list_schedules(
218 &self,
219 opts: Option<&ListSchedulesOptions>,
220 ) -> Result<Vec<Schedule>, ScrapflyError> {
221 self.list_schedules_inner("/schedules", opts).await
222 }
223
224 pub async fn list_scrape_schedules(
226 &self,
227 status: Option<&str>,
228 ) -> Result<Vec<Schedule>, ScrapflyError> {
229 self.list_schedules_inner(
230 "/scrape/schedules",
231 status
232 .map(|s| ListSchedulesOptions {
233 status: Some(s.into()),
234 kind: None,
235 })
236 .as_ref(),
237 )
238 .await
239 }
240
241 pub async fn list_screenshot_schedules(
243 &self,
244 status: Option<&str>,
245 ) -> Result<Vec<Schedule>, ScrapflyError> {
246 self.list_schedules_inner(
247 "/screenshot/schedules",
248 status
249 .map(|s| ListSchedulesOptions {
250 status: Some(s.into()),
251 kind: None,
252 })
253 .as_ref(),
254 )
255 .await
256 }
257
258 pub async fn list_crawler_schedules(
260 &self,
261 status: Option<&str>,
262 ) -> Result<Vec<Schedule>, ScrapflyError> {
263 self.list_schedules_inner(
264 "/crawl/schedules",
265 status
266 .map(|s| ListSchedulesOptions {
267 status: Some(s.into()),
268 kind: None,
269 })
270 .as_ref(),
271 )
272 .await
273 }
274
275 pub async fn update_schedule(
277 &self,
278 id: &str,
279 request: &UpdateScheduleRequest,
280 ) -> Result<Schedule, ScrapflyError> {
281 let path = format!("/schedules/{}", url_path_escape(id));
282 let body = serde_json::to_vec(request)?;
283 self.schedule_request_json::<Schedule>(Method::PATCH, &path, &[], Some(body))
284 .await
285 }
286
287 pub async fn cancel_schedule(&self, id: &str) -> Result<(), ScrapflyError> {
289 let path = format!("/schedules/{}", url_path_escape(id));
290 self.schedule_request_empty(Method::DELETE, &path, &[], None)
291 .await
292 }
293
294 pub async fn pause_schedule(&self, id: &str) -> Result<Schedule, ScrapflyError> {
296 let path = format!("/schedules/{}/pause", url_path_escape(id));
297 self.schedule_request_json::<Schedule>(Method::POST, &path, &[], None)
298 .await
299 }
300
301 pub async fn resume_schedule(&self, id: &str) -> Result<Schedule, ScrapflyError> {
303 let path = format!("/schedules/{}/resume", url_path_escape(id));
304 self.schedule_request_json::<Schedule>(Method::POST, &path, &[], None)
305 .await
306 }
307
308 pub async fn execute_schedule(&self, id: &str) -> Result<Schedule, ScrapflyError> {
310 let path = format!("/schedules/{}/execute", url_path_escape(id));
311 self.schedule_request_json::<Schedule>(Method::POST, &path, &[], None)
312 .await
313 }
314
315 async fn create_schedule_inner(
318 &self,
319 path: &str,
320 config_key: &'static str,
321 config: HashMap<String, Value>,
322 request: &CreateScheduleRequest,
323 ) -> Result<Schedule, ScrapflyError> {
324 let mut body = serde_json::Map::new();
325 body.insert(config_key.to_string(), Value::Object(map_to_object(config)));
326 body.insert(
327 "webhook_name".into(),
328 Value::String(request.webhook_name.clone()),
329 );
330 body.insert(
331 "allow_concurrency".into(),
332 Value::Bool(request.allow_concurrency),
333 );
334 body.insert(
335 "retry_on_failure".into(),
336 Value::Bool(request.retry_on_failure),
337 );
338 if let Some(rec) = &request.recurrence {
339 body.insert("recurrence".into(), serde_json::to_value(rec)?);
340 }
341 if let Some(d) = &request.scheduled_date {
342 body.insert("scheduled_date".into(), Value::String(d.clone()));
343 }
344 if let Some(n) = request.max_retries {
345 body.insert("max_retries".into(), Value::Number(n.into()));
346 }
347 if let Some(n) = &request.notes {
348 body.insert("notes".into(), Value::String(n.clone()));
349 }
350 let payload = serde_json::to_vec(&Value::Object(body))?;
351 self.schedule_request_json::<Schedule>(Method::POST, path, &[], Some(payload))
352 .await
353 }
354
355 async fn list_schedules_inner(
356 &self,
357 path: &str,
358 opts: Option<&ListSchedulesOptions>,
359 ) -> Result<Vec<Schedule>, ScrapflyError> {
360 let mut query: Vec<(String, String)> = Vec::new();
361 if let Some(o) = opts {
362 if let Some(s) = &o.status {
363 query.push(("status".into(), s.clone()));
364 }
365 if let Some(k) = &o.kind {
366 query.push(("kind".into(), k.clone()));
367 }
368 }
369 self.schedule_request_json::<Vec<Schedule>>(Method::GET, path, &query, None)
370 .await
371 }
372
373 async fn schedule_request_json<T: for<'de> serde::Deserialize<'de>>(
374 &self,
375 method: Method,
376 path: &str,
377 query: &[(String, String)],
378 body: Option<Vec<u8>>,
379 ) -> Result<T, ScrapflyError> {
380 let (status, body_bytes) = self.schedule_send(method, path, query, body).await?;
381 if status == 204 {
382 return Err(ScrapflyError::Config(
383 "schedule endpoint returned 204 but a JSON body was expected".into(),
384 ));
385 }
386 if status >= 400 {
387 return Err(from_response(status, &body_bytes, 0, false));
388 }
389 Ok(serde_json::from_slice(&body_bytes)?)
390 }
391
392 async fn schedule_request_empty(
393 &self,
394 method: Method,
395 path: &str,
396 query: &[(String, String)],
397 body: Option<Vec<u8>>,
398 ) -> Result<(), ScrapflyError> {
399 let (status, body_bytes) = self.schedule_send(method, path, query, body).await?;
400 if status >= 400 {
401 return Err(from_response(status, &body_bytes, 0, false));
402 }
403 Ok(())
404 }
405
406 async fn schedule_send(
407 &self,
408 method: Method,
409 path: &str,
410 query: &[(String, String)],
411 body: Option<Vec<u8>>,
412 ) -> Result<(u16, bytes::Bytes), ScrapflyError> {
413 let url = self.build_url_public(path, query)?;
414 let mut headers = reqwest::header::HeaderMap::new();
415 headers.insert(
416 reqwest::header::ACCEPT,
417 reqwest::header::HeaderValue::from_static("application/json"),
418 );
419 if body.is_some() {
420 headers.insert(
421 reqwest::header::CONTENT_TYPE,
422 reqwest::header::HeaderValue::from_static("application/json"),
423 );
424 }
425 let resp = self
426 .send_simple_public(method, url, Some(headers), body)
427 .await?;
428 let status = resp.status().as_u16();
429 let bytes = resp.bytes().await.map_err(ScrapflyError::Transport)?;
430 Ok((status, bytes))
431 }
432}
433
434fn map_to_object(map: HashMap<String, Value>) -> serde_json::Map<String, Value> {
435 map.into_iter().collect()
436}
437
438fn url_path_escape(s: &str) -> String {
442 let mut out = String::with_capacity(s.len());
443 for b in s.bytes() {
444 match b {
445 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
446 out.push(b as char);
447 }
448 _ => out.push_str(&format!("%{:02X}", b)),
449 }
450 }
451 out
452}