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 timeout_seconds: Option<u32>,
116 pub keep_alive: bool,
120 pub user_metadata: Option<Map<String, Value>>,
122}
123
124impl CreateSessionOptions {
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 pub fn profile_id(mut self, id: impl Into<String>) -> Self {
130 self.profile_id = Some(id.into());
131 self
132 }
133
134 pub fn recording(mut self, on: bool) -> Self {
135 self.recording = on;
136 self
137 }
138
139 pub fn stealth(mut self, on: bool) -> Self {
140 self.stealth = on;
141 self
142 }
143
144 pub fn captcha(mut self, on: bool) -> Self {
145 self.captcha = on;
146 self
147 }
148
149 pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
150 self.proxy = Some(spec.into());
151 self
152 }
153
154 pub fn timeout_seconds(mut self, seconds: u32) -> Self {
155 self.timeout_seconds = Some(seconds);
156 self
157 }
158
159 pub fn keep_alive(mut self, on: bool) -> Self {
160 self.keep_alive = on;
161 self
162 }
163
164 pub fn user_metadata(mut self, metadata: Map<String, Value>) -> Self {
165 self.user_metadata = Some(metadata);
166 self
167 }
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct ListSessionsOptions {
173 pub limit: Option<u32>,
175 pub cursor: Option<String>,
177 pub metadata: BTreeMap<String, String>,
180}
181
182#[derive(Debug, Clone)]
184pub struct Client {
185 http: HttpTransport,
186}
187
188impl Client {
189 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
190 if opts.api_key.is_empty() {
191 return Err(SolariError::config("Solari: apiKey is required"));
192 }
193 if opts.base_url.is_empty() {
194 return Err(SolariError::config("Solari: baseUrl is required"));
195 }
196 let base_url = opts.base_url.trim_end_matches('/').to_string();
197 let http = HttpTransport::new(
198 opts.api_key,
199 base_url,
200 opts.max_attempts,
201 opts.backoff_ms,
202 opts.timeout_ms,
203 )?;
204 Ok(Client { http })
205 }
206
207 pub fn sessions(&self) -> Sessions<'_> {
209 Sessions { client: self }
210 }
211
212 pub fn profiles(&self) -> Profiles<'_> {
214 Profiles { client: self }
215 }
216
217 pub fn proxy(&self) -> Proxy<'_> {
219 Proxy { client: self }
220 }
221
222 pub fn base_url(&self) -> &str {
224 self.http.base_url()
225 }
226}
227
228pub struct Sessions<'a> {
234 client: &'a Client,
235}
236
237impl Sessions<'_> {
238 pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
240 let req = CreateSessionRequest {
241 profile_id: options
243 .profile_id
244 .clone()
245 .filter(|p| !p.is_empty()),
246 recording: options.recording,
247 stealth: options.stealth,
248 captcha: options.captcha,
249 proxy: options.proxy.clone(),
250 timeout_seconds: options.timeout_seconds,
251 keep_alive: options.keep_alive,
252 user_metadata: options.user_metadata.clone(),
253 };
254 let body = serde_json::to_value(&req)
255 .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
256 let body = match body.as_object() {
258 Some(o) if o.is_empty() => None,
259 _ => Some(body),
260 };
261
262 let res = self
263 .client
264 .http
265 .request_with_key(
266 "POST",
267 "/sessions",
268 body.as_ref(),
269 Some(&crate::http::new_idempotency_key()),
273 )
274 .await?;
275 if !res.ok() {
276 return Err(api_error("POST", "/sessions", res.status, &res.body));
277 }
278
279 let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
280 SolariError::protocol(format!("Solari: unexpected session response: {e}"))
281 })?;
282
283 let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
284 (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
285 _ => {
286 return Err(SolariError::protocol(format!(
287 "Solari: unexpected session response: {}",
288 res.body
289 )))
290 }
291 };
292
293 let cdp_endpoint = data
294 .cdp_endpoint
295 .clone()
296 .filter(|c| !c.is_empty())
297 .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
298
299 let expires_at = data
300 .expires_at
301 .clone()
302 .filter(|e| !e.is_empty())
303 .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
304
305 let mut session = Session {
306 id: session_id,
307 ws_endpoint,
308 cdp_endpoint,
309 expires_at,
310 storage_state: None,
311 proxy: data.proxy,
312 user_metadata: data.user_metadata,
313 };
314
315 if options.profile_id.is_some() {
318 session.storage_state = Some(match data.storage_state_url {
319 Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
321 Some(None) => None,
323 None => data.storage_state.flatten(),
325 });
326 }
327
328 Ok(session)
329 }
330
331 pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
333 let path = format!("/sessions/{}", enc(id));
334 let res = self.client.http.request("GET", &path, None).await?;
335 if !res.ok() {
336 return Err(api_error("GET", &path, res.status, &res.body));
337 }
338 serde_json::from_str(&res.body)
339 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
340 }
341
342 pub async fn list(&self, options: ListSessionsOptions) -> Result<SessionList, SolariError> {
345 let mut query: Vec<String> = Vec::new();
346 if let Some(limit) = options.limit.filter(|l| *l > 0) {
347 query.push(format!("limit={limit}"));
348 }
349 if let Some(cursor) = options.cursor.as_deref().filter(|c| !c.is_empty()) {
350 query.push(format!("cursor={}", enc(cursor)));
351 }
352 if !options.metadata.is_empty() {
353 query.push(format!("q={}", enc(&metadata_filter(&options.metadata)?)));
354 }
355 let path = if query.is_empty() {
356 "/sessions".to_string()
357 } else {
358 format!("/sessions?{}", query.join("&"))
359 };
360 let res = self.client.http.request("GET", &path, None).await?;
361 if !res.ok() {
362 return Err(api_error("GET", "/sessions", res.status, &res.body));
363 }
364 serde_json::from_str(&res.body)
365 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session list: {e}")))
366 }
367
368 pub async fn release(&self, id: &str) -> Result<(), SolariError> {
388 let path = format!("/sessions/{}", enc(id));
389 let res = self.client.http.request("DELETE", &path, None).await?;
390 if res.ok() {
391 return Ok(());
392 }
393 let err = api_error("DELETE", &path, res.status, &res.body);
394 if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
395 return Ok(());
396 }
397 Err(err)
398 }
399
400 pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
403 let path = format!("/sessions/{}/replay-url", enc(id));
404 let res = self.client.http.request("GET", &path, None).await?;
405 if !res.ok() {
406 return Err(api_error("GET", &path, res.status, &res.body));
407 }
408 let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
409 SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
410 })?;
411 let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
412 SolariError::protocol(format!(
413 "Solari: unexpected replay-url response: {}",
414 res.body
415 ))
416 })?;
417 Ok(ReplayUrl {
418 url,
419 expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
420 content_encoding: data
421 .content_encoding
422 .unwrap_or_else(|| "gzip".to_string()),
423 })
424 }
425
426 pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
431 let replay = self.replay_url(id).await?;
432 let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
433 if !(200..300).contains(&status) {
434 return Err(SolariError::Api {
435 status,
436 code: None,
437 message: format!("Solari: replay download failed: {status}"),
438 });
439 }
440 Ok(bytes)
441 }
442
443 async fn fetch_presigned_storage_state(
445 &self,
446 url: Option<&str>,
447 ) -> Result<Option<StorageState>, SolariError> {
448 let Some(url) = url.filter(|u| !u.is_empty()) else {
449 return Ok(None);
450 };
451 let res = self
452 .client
453 .http
454 .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
455 .await
456 .map_err(|e| {
457 SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
458 })?;
459 if !res.ok() {
460 let snippet: String = res.body.chars().take(256).collect();
461 return Err(SolariError::Api {
462 status: res.status,
463 code: None,
464 message: format!(
465 "Solari: storageState fetch returned {}: {snippet}",
466 res.status
467 ),
468 });
469 }
470 serde_json::from_str(&res.body).map(Some).map_err(|e| {
471 SolariError::protocol(format!(
472 "Solari: storageState response was not valid JSON: {e}"
473 ))
474 })
475 }
476}
477
478pub struct Profiles<'a> {
484 client: &'a Client,
485}
486
487impl Profiles<'_> {
488 pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
490 let res = self.client.http.request("GET", "/profiles", None).await?;
491 if !res.ok() {
492 return Err(api_error("GET", "/profiles", res.status, &res.body));
493 }
494 serde_json::from_str(&res.body)
495 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
496 }
497
498 pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
500 let body = json!({ "name": name.into() });
501 let res = self
502 .client
503 .http
504 .request("POST", "/profiles", Some(&body))
505 .await?;
506 if !res.ok() {
507 return Err(api_error("POST", "/profiles", res.status, &res.body));
508 }
509 serde_json::from_str(&res.body)
510 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
511 }
512
513 pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
515 let path = format!("/profiles/{}", enc(id));
516 let res = self.client.http.request("DELETE", &path, None).await?;
517 if !res.ok() && res.status != 404 {
518 return Err(api_error("DELETE", &path, res.status, &res.body));
519 }
520 Ok(())
521 }
522
523 pub async fn save(
525 &self,
526 id: &str,
527 storage_state: &StorageState,
528 ) -> Result<ProfileSaveResult, SolariError> {
529 let path = format!("/profiles/{}/save", enc(id));
530 let state = serde_json::to_value(storage_state)
531 .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
532 let body = json!({ "storageState": state });
533 let res = self.client.http.request("POST", &path, Some(&body)).await?;
534 if !res.ok() {
535 return Err(api_error("POST", &path, res.status, &res.body));
536 }
537 let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
538 SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
539 })?;
540 Ok(ProfileSaveResult {
541 version: data.version.unwrap_or(0),
542 size_bytes: data.size_bytes.unwrap_or(0),
543 })
544 }
545}
546
547pub struct Proxy<'a> {
553 client: &'a Client,
554}
555
556impl Proxy<'_> {
557 pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
560 let res = self
561 .client
562 .http
563 .request("GET", "/proxy/countries", None)
564 .await?;
565 if !res.ok() {
566 return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
567 }
568 serde_json::from_str(&res.body).map_err(|e| {
569 SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
570 })
571 }
572}
573
574const MAX_METADATA_CLAUSES: usize = 10;
580
581fn metadata_filter(m: &BTreeMap<String, String>) -> Result<String, SolariError> {
585 if m.len() > MAX_METADATA_CLAUSES {
586 return Err(SolariError::config(format!(
587 "Solari: at most {MAX_METADATA_CLAUSES} metadata filter pairs"
588 )));
589 }
590 let mut clauses = Vec::with_capacity(m.len());
591 for (k, v) in m {
592 if k.is_empty() || k.chars().count() > 128 || k.contains('\'') || v.contains('\'') {
593 return Err(SolariError::config(format!(
594 "Solari: metadata filter key {k:?} or its value cannot be expressed (1-128 characters, no single quote)"
595 )));
596 }
597 clauses.push(format!("user_metadata['{k}']:'{v}'"));
598 }
599 Ok(clauses.join(" AND "))
600}
601
602fn enc(s: &str) -> String {
606 let mut out = String::with_capacity(s.len());
607 for b in s.bytes() {
608 match b {
609 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
610 | b'\'' | b'(' | b')' => out.push(b as char),
611 _ => out.push_str(&format!("%{b:02X}")),
612 }
613 }
614 out
615}
616
617pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
621 match Url::parse(ws_endpoint) {
622 Ok(mut u) => {
623 let path = u.path().to_string();
624 if let Some(rest) = path.strip_prefix("/ws/") {
625 u.set_path(&format!("/cdp/{rest}"));
626 u.to_string()
627 } else {
628 ws_endpoint.to_string()
629 }
630 }
631 Err(_) => ws_endpoint.to_string(),
632 }
633}
634
635fn iso8601_utc_in(d: Duration) -> String {
638 let now = std::time::SystemTime::now()
639 .duration_since(std::time::UNIX_EPOCH)
640 .map(|x| x.as_secs())
641 .unwrap_or(0);
642 iso8601_utc(now as i64 + d.as_secs() as i64)
643}
644
645pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
647 let days = unix_secs.div_euclid(86_400);
648 let sod = unix_secs.rem_euclid(86_400);
649 let (y, m, d) = civil_from_days(days);
650 format!(
651 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
652 y,
653 m,
654 d,
655 sod / 3600,
656 (sod % 3600) / 60,
657 sod % 60
658 )
659}
660
661fn civil_from_days(z: i64) -> (i64, u32, u32) {
663 let z = z + 719_468;
664 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
665 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
668 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)
673}