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