1use std::collections::BTreeMap;
10use std::time::Duration;
11
12use serde_json::{json, Map, Value};
13use url::Url;
14
15use crate::error::{api_error, SolariError, SolariErrorCode};
16use crate::http::HttpTransport;
17use crate::types::{
18 CreateSessionRequest, CreateSessionResponse, Profile, ProfileSaveResponse, ProfileSaveResult,
19 ProxyCountries, ProxySpec, ReplayUrl, ReplayUrlResponse, Session, SessionList, SessionView,
20 SolariRegion,
21 StorageState,
22};
23
24pub(crate) const DEFAULT_MAX_ATTEMPTS: u32 = 2;
25pub(crate) const DEFAULT_BACKOFF_MS: u64 = 500;
26pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 90_000;
27const STORAGE_STATE_FETCH_TIMEOUT: Duration = Duration::from_secs(8);
28
29#[derive(Clone)]
34pub struct ClientOptions {
35 pub api_key: String,
36 pub base_url: String,
37 pub max_attempts: u32,
39 pub backoff_ms: u64,
41 pub timeout_ms: u64,
43}
44
45impl std::fmt::Debug for ClientOptions {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("ClientOptions")
49 .field("api_key", &"<redacted>")
50 .field("base_url", &self.base_url)
51 .field("max_attempts", &self.max_attempts)
52 .field("backoff_ms", &self.backoff_ms)
53 .field("timeout_ms", &self.timeout_ms)
54 .finish()
55 }
56}
57
58impl ClientOptions {
59 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
61 ClientOptions {
62 api_key: api_key.into(),
63 base_url: base_url.into(),
64 max_attempts: DEFAULT_MAX_ATTEMPTS,
65 backoff_ms: DEFAULT_BACKOFF_MS,
66 timeout_ms: DEFAULT_TIMEOUT_MS,
67 }
68 }
69
70 pub fn for_region(api_key: impl Into<String>, region: SolariRegion) -> Self {
73 ClientOptions::new(api_key, region.base_url())
74 }
75
76 pub fn default_region(api_key: impl Into<String>) -> Self {
78 ClientOptions::for_region(api_key, SolariRegion::default())
79 }
80
81 pub fn max_attempts(mut self, n: u32) -> Self {
82 self.max_attempts = n;
83 self
84 }
85
86 pub fn backoff_ms(mut self, ms: u64) -> Self {
87 self.backoff_ms = ms;
88 self
89 }
90
91 pub fn timeout_ms(mut self, ms: u64) -> Self {
92 self.timeout_ms = ms;
93 self
94 }
95}
96
97#[derive(Debug, Clone, Default)]
102pub struct CreateSessionOptions {
103 pub profile_id: Option<String>,
105 pub recording: bool,
107 pub stealth: bool,
109 pub captcha: bool,
111 pub proxy: Option<ProxySpec>,
113 pub session_timeout_ms: Option<u64>,
117 #[deprecated(since = "0.5.0", note = "use `session_timeout_ms`; it wins when both are set")]
119 pub timeout_seconds: Option<u32>,
120 pub keep_alive: bool,
124 pub user_metadata: Option<Map<String, Value>>,
126}
127
128impl CreateSessionOptions {
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn profile_id(mut self, id: impl Into<String>) -> Self {
134 self.profile_id = Some(id.into());
135 self
136 }
137
138 pub fn recording(mut self, on: bool) -> Self {
139 self.recording = on;
140 self
141 }
142
143 pub fn stealth(mut self, on: bool) -> Self {
144 self.stealth = on;
145 self
146 }
147
148 pub fn captcha(mut self, on: bool) -> Self {
149 self.captcha = on;
150 self
151 }
152
153 pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
154 self.proxy = Some(spec.into());
155 self
156 }
157
158 pub fn session_timeout_ms(mut self, ms: u64) -> Self {
160 self.session_timeout_ms = Some(ms);
161 self
162 }
163
164 #[deprecated(since = "0.5.0", note = "use `session_timeout_ms`")]
166 #[allow(deprecated)]
167 pub fn timeout_seconds(mut self, seconds: u32) -> Self {
168 self.timeout_seconds = Some(seconds);
169 self
170 }
171
172 pub fn keep_alive(mut self, on: bool) -> Self {
173 self.keep_alive = on;
174 self
175 }
176
177 pub fn user_metadata(mut self, metadata: Map<String, Value>) -> Self {
178 self.user_metadata = Some(metadata);
179 self
180 }
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct ListSessionsOptions {
186 pub limit: Option<u32>,
188 pub cursor: Option<String>,
190 pub metadata: BTreeMap<String, String>,
193}
194
195#[derive(Debug, Clone)]
197pub struct Client {
198 http: HttpTransport,
199}
200
201impl Client {
202 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
203 if opts.api_key.is_empty() {
204 return Err(SolariError::config("Solari: apiKey is required"));
205 }
206 if opts.base_url.is_empty() {
207 return Err(SolariError::config("Solari: baseUrl is required"));
208 }
209 let base_url = opts.base_url.trim_end_matches('/').to_string();
210 let http = HttpTransport::new(
211 opts.api_key,
212 base_url,
213 opts.max_attempts,
214 opts.backoff_ms,
215 opts.timeout_ms,
216 )?;
217 Ok(Client { http })
218 }
219
220 pub fn sessions(&self) -> Sessions<'_> {
222 Sessions { client: self }
223 }
224
225 pub fn profiles(&self) -> Profiles<'_> {
227 Profiles { client: self }
228 }
229
230 pub fn proxy(&self) -> Proxy<'_> {
232 Proxy { client: self }
233 }
234
235 pub fn base_url(&self) -> &str {
237 self.http.base_url()
238 }
239}
240
241pub struct Sessions<'a> {
247 client: &'a Client,
248}
249
250impl Sessions<'_> {
251 pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
253 let req = CreateSessionRequest {
254 profile_id: options
256 .profile_id
257 .clone()
258 .filter(|p| !p.is_empty()),
259 recording: options.recording,
260 stealth: options.stealth,
261 captcha: options.captcha,
262 proxy: options.proxy.clone(),
263 timeout_seconds: session_timeout_seconds(
264 options.session_timeout_ms,
265 #[allow(deprecated)]
266 options.timeout_seconds,
267 )?,
268 keep_alive: options.keep_alive,
269 user_metadata: options.user_metadata.clone(),
270 };
271 let body = serde_json::to_value(&req)
272 .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
273 let body = match body.as_object() {
275 Some(o) if o.is_empty() => None,
276 _ => Some(body),
277 };
278
279 let res = self
280 .client
281 .http
282 .request_with_key(
283 "POST",
284 "/sessions",
285 body.as_ref(),
286 Some(&crate::http::new_idempotency_key()),
290 )
291 .await?;
292 if !res.ok() {
293 return Err(api_error("POST", "/sessions", res.status, &res.body));
294 }
295
296 let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
297 SolariError::protocol(format!("Solari: unexpected session response: {e}"))
298 })?;
299
300 let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
301 (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
302 _ => {
303 return Err(SolariError::protocol(format!(
304 "Solari: unexpected session response: {}",
305 res.body
306 )))
307 }
308 };
309
310 let cdp_endpoint = data
311 .cdp_endpoint
312 .clone()
313 .filter(|c| !c.is_empty())
314 .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
315
316 let expires_at = data
317 .expires_at
318 .clone()
319 .filter(|e| !e.is_empty())
320 .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
321
322 let mut session = Session {
323 id: session_id,
324 ws_endpoint,
325 cdp_endpoint,
326 expires_at,
327 storage_state: None,
328 proxy: data.proxy,
329 user_metadata: data.user_metadata,
330 };
331
332 if options.profile_id.is_some() {
335 session.storage_state = Some(match data.storage_state_url {
336 Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
338 Some(None) => None,
340 None => data.storage_state.flatten(),
342 });
343 }
344
345 Ok(session)
346 }
347
348 pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
350 let path = format!("/sessions/{}", enc(id));
351 let res = self.client.http.request("GET", &path, None).await?;
352 if !res.ok() {
353 return Err(api_error("GET", &path, res.status, &res.body));
354 }
355 serde_json::from_str(&res.body)
356 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
357 }
358
359 pub async fn list(&self, options: ListSessionsOptions) -> Result<SessionList, SolariError> {
362 let mut query: Vec<String> = Vec::new();
363 if let Some(limit) = options.limit.filter(|l| *l > 0) {
364 query.push(format!("limit={limit}"));
365 }
366 if let Some(cursor) = options.cursor.as_deref().filter(|c| !c.is_empty()) {
367 query.push(format!("cursor={}", enc(cursor)));
368 }
369 if !options.metadata.is_empty() {
370 query.push(format!("q={}", enc(&metadata_filter(&options.metadata)?)));
371 }
372 let path = if query.is_empty() {
373 "/sessions".to_string()
374 } else {
375 format!("/sessions?{}", query.join("&"))
376 };
377 let res = self.client.http.request("GET", &path, None).await?;
378 if !res.ok() {
379 return Err(api_error("GET", "/sessions", res.status, &res.body));
380 }
381 serde_json::from_str(&res.body)
382 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session list: {e}")))
383 }
384
385 pub async fn release(&self, id: &str) -> Result<(), SolariError> {
405 let path = format!("/sessions/{}", enc(id));
406 let res = self.client.http.request("DELETE", &path, None).await?;
407 if res.ok() {
408 return Ok(());
409 }
410 let err = api_error("DELETE", &path, res.status, &res.body);
411 if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
412 return Ok(());
413 }
414 Err(err)
415 }
416
417 pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
420 let path = format!("/sessions/{}/replay-url", enc(id));
421 let res = self.client.http.request("GET", &path, None).await?;
422 if !res.ok() {
423 return Err(api_error("GET", &path, res.status, &res.body));
424 }
425 let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
426 SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
427 })?;
428 let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
429 SolariError::protocol(format!(
430 "Solari: unexpected replay-url response: {}",
431 res.body
432 ))
433 })?;
434 Ok(ReplayUrl {
435 url,
436 expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
437 content_encoding: data
438 .content_encoding
439 .unwrap_or_else(|| "gzip".to_string()),
440 })
441 }
442
443 pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
448 let replay = self.replay_url(id).await?;
449 let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
450 if !(200..300).contains(&status) {
451 return Err(SolariError::Api {
452 status,
453 code: None,
454 message: format!("Solari: replay download failed: {status}"),
455 });
456 }
457 Ok(bytes)
458 }
459
460 async fn fetch_presigned_storage_state(
462 &self,
463 url: Option<&str>,
464 ) -> Result<Option<StorageState>, SolariError> {
465 let Some(url) = url.filter(|u| !u.is_empty()) else {
466 return Ok(None);
467 };
468 let res = self
469 .client
470 .http
471 .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
472 .await
473 .map_err(|e| {
474 SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
475 })?;
476 if !res.ok() {
477 let snippet: String = res.body.chars().take(256).collect();
478 return Err(SolariError::Api {
479 status: res.status,
480 code: None,
481 message: format!(
482 "Solari: storageState fetch returned {}: {snippet}",
483 res.status
484 ),
485 });
486 }
487 serde_json::from_str(&res.body).map(Some).map_err(|e| {
488 SolariError::protocol(format!(
489 "Solari: storageState response was not valid JSON: {e}"
490 ))
491 })
492 }
493}
494
495pub struct Profiles<'a> {
501 client: &'a Client,
502}
503
504impl Profiles<'_> {
505 pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
507 let res = self.client.http.request("GET", "/profiles", None).await?;
508 if !res.ok() {
509 return Err(api_error("GET", "/profiles", res.status, &res.body));
510 }
511 serde_json::from_str(&res.body)
512 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
513 }
514
515 pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
517 let body = json!({ "name": name.into() });
518 let res = self
519 .client
520 .http
521 .request("POST", "/profiles", Some(&body))
522 .await?;
523 if !res.ok() {
524 return Err(api_error("POST", "/profiles", res.status, &res.body));
525 }
526 serde_json::from_str(&res.body)
527 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
528 }
529
530 pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
532 let path = format!("/profiles/{}", enc(id));
533 let res = self.client.http.request("DELETE", &path, None).await?;
534 if !res.ok() && res.status != 404 {
535 return Err(api_error("DELETE", &path, res.status, &res.body));
536 }
537 Ok(())
538 }
539
540 pub async fn save(
542 &self,
543 id: &str,
544 storage_state: &StorageState,
545 ) -> Result<ProfileSaveResult, SolariError> {
546 let path = format!("/profiles/{}/save", enc(id));
547 let state = serde_json::to_value(storage_state)
548 .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
549 let body = json!({ "storageState": state });
550 let res = self.client.http.request("POST", &path, Some(&body)).await?;
551 if !res.ok() {
552 return Err(api_error("POST", &path, res.status, &res.body));
553 }
554 let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
555 SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
556 })?;
557 Ok(ProfileSaveResult {
558 version: data.version.unwrap_or(0),
559 size_bytes: data.size_bytes.unwrap_or(0),
560 })
561 }
562}
563
564pub struct Proxy<'a> {
570 client: &'a Client,
571}
572
573impl Proxy<'_> {
574 pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
577 let res = self
578 .client
579 .http
580 .request("GET", "/proxy/countries", None)
581 .await?;
582 if !res.ok() {
583 return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
584 }
585 serde_json::from_str(&res.body).map_err(|e| {
586 SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
587 })
588 }
589}
590
591const MAX_METADATA_CLAUSES: usize = 10;
597
598fn metadata_filter(m: &BTreeMap<String, String>) -> Result<String, SolariError> {
602 if m.len() > MAX_METADATA_CLAUSES {
603 return Err(SolariError::config(format!(
604 "Solari: at most {MAX_METADATA_CLAUSES} metadata filter pairs"
605 )));
606 }
607 let mut clauses = Vec::with_capacity(m.len());
608 for (k, v) in m {
609 if k.is_empty() || k.chars().count() > 128 || k.contains('\'') || v.contains('\'') {
610 return Err(SolariError::config(format!(
611 "Solari: metadata filter key {k:?} or its value cannot be expressed (1-128 characters, no single quote)"
612 )));
613 }
614 clauses.push(format!("user_metadata['{k}']:'{v}'"));
615 }
616 Ok(clauses.join(" AND "))
617}
618
619pub(crate) fn session_timeout_seconds(
623 ms: Option<u64>,
624 legacy_seconds: Option<u32>,
625) -> Result<Option<u32>, SolariError> {
626 match ms.filter(|m| *m > 0) {
627 Some(ms) => {
628 let secs = ms / 1000 + u64::from(ms % 1000 != 0);
629 u32::try_from(secs).map(Some).map_err(|_| {
630 SolariError::config("Solari: session_timeout_ms is too large")
631 })
632 }
633 None => Ok(legacy_seconds.filter(|s| *s > 0)),
634 }
635}
636
637fn enc(s: &str) -> String {
641 let mut out = String::with_capacity(s.len());
642 for b in s.bytes() {
643 match b {
644 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
645 | b'\'' | b'(' | b')' => out.push(b as char),
646 _ => out.push_str(&format!("%{b:02X}")),
647 }
648 }
649 out
650}
651
652pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
656 match Url::parse(ws_endpoint) {
657 Ok(mut u) => {
658 let path = u.path().to_string();
659 if let Some(rest) = path.strip_prefix("/ws/") {
660 u.set_path(&format!("/cdp/{rest}"));
661 u.to_string()
662 } else {
663 ws_endpoint.to_string()
664 }
665 }
666 Err(_) => ws_endpoint.to_string(),
667 }
668}
669
670fn iso8601_utc_in(d: Duration) -> String {
673 let now = std::time::SystemTime::now()
674 .duration_since(std::time::UNIX_EPOCH)
675 .map(|x| x.as_secs())
676 .unwrap_or(0);
677 iso8601_utc(now as i64 + d.as_secs() as i64)
678}
679
680pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
682 let days = unix_secs.div_euclid(86_400);
683 let sod = unix_secs.rem_euclid(86_400);
684 let (y, m, d) = civil_from_days(days);
685 format!(
686 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
687 y,
688 m,
689 d,
690 sod / 3600,
691 (sod % 3600) / 60,
692 sod % 60
693 )
694}
695
696fn civil_from_days(z: i64) -> (i64, u32, u32) {
698 let z = z + 719_468;
699 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
700 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
703 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
708}