1use std::time::Duration;
10
11use serde_json::json;
12use url::Url;
13
14use crate::error::{api_error, SolariError, SolariErrorCode};
15use crate::http::HttpTransport;
16use crate::types::{
17 CreateSessionRequest, CreateSessionResponse, Profile, ProfileSaveResponse, ProfileSaveResult,
18 ProxyCountries, ProxySpec, ReplayUrl, ReplayUrlResponse, Session, SessionView, SolariRegion,
19 StorageState,
20};
21
22pub(crate) const DEFAULT_MAX_ATTEMPTS: u32 = 2;
23pub(crate) const DEFAULT_BACKOFF_MS: u64 = 500;
24pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 90_000;
25const STORAGE_STATE_FETCH_TIMEOUT: Duration = Duration::from_secs(8);
26
27#[derive(Clone)]
32pub struct ClientOptions {
33 pub api_key: String,
34 pub base_url: String,
35 pub max_attempts: u32,
37 pub backoff_ms: u64,
39 pub timeout_ms: u64,
41}
42
43impl std::fmt::Debug for ClientOptions {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("ClientOptions")
47 .field("api_key", &"<redacted>")
48 .field("base_url", &self.base_url)
49 .field("max_attempts", &self.max_attempts)
50 .field("backoff_ms", &self.backoff_ms)
51 .field("timeout_ms", &self.timeout_ms)
52 .finish()
53 }
54}
55
56impl ClientOptions {
57 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
59 ClientOptions {
60 api_key: api_key.into(),
61 base_url: base_url.into(),
62 max_attempts: DEFAULT_MAX_ATTEMPTS,
63 backoff_ms: DEFAULT_BACKOFF_MS,
64 timeout_ms: DEFAULT_TIMEOUT_MS,
65 }
66 }
67
68 pub fn for_region(api_key: impl Into<String>, region: SolariRegion) -> Self {
71 ClientOptions::new(api_key, region.base_url())
72 }
73
74 pub fn default_region(api_key: impl Into<String>) -> Self {
76 ClientOptions::for_region(api_key, SolariRegion::default())
77 }
78
79 pub fn max_attempts(mut self, n: u32) -> Self {
80 self.max_attempts = n;
81 self
82 }
83
84 pub fn backoff_ms(mut self, ms: u64) -> Self {
85 self.backoff_ms = ms;
86 self
87 }
88
89 pub fn timeout_ms(mut self, ms: u64) -> Self {
90 self.timeout_ms = ms;
91 self
92 }
93}
94
95#[derive(Debug, Clone, Default)]
100pub struct CreateSessionOptions {
101 pub profile_id: Option<String>,
103 pub recording: bool,
105 pub stealth: bool,
107 pub captcha: bool,
109 pub proxy: Option<ProxySpec>,
111}
112
113impl CreateSessionOptions {
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn profile_id(mut self, id: impl Into<String>) -> Self {
119 self.profile_id = Some(id.into());
120 self
121 }
122
123 pub fn recording(mut self, on: bool) -> Self {
124 self.recording = on;
125 self
126 }
127
128 pub fn stealth(mut self, on: bool) -> Self {
129 self.stealth = on;
130 self
131 }
132
133 pub fn captcha(mut self, on: bool) -> Self {
134 self.captcha = on;
135 self
136 }
137
138 pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
139 self.proxy = Some(spec.into());
140 self
141 }
142}
143
144#[derive(Debug)]
146pub struct Client {
147 http: HttpTransport,
148}
149
150impl Client {
151 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
152 if opts.api_key.is_empty() {
153 return Err(SolariError::config("Solari: apiKey is required"));
154 }
155 if opts.base_url.is_empty() {
156 return Err(SolariError::config("Solari: baseUrl is required"));
157 }
158 let base_url = opts.base_url.trim_end_matches('/').to_string();
159 let http = HttpTransport::new(
160 opts.api_key,
161 base_url,
162 opts.max_attempts,
163 opts.backoff_ms,
164 opts.timeout_ms,
165 )?;
166 Ok(Client { http })
167 }
168
169 pub fn sessions(&self) -> Sessions<'_> {
171 Sessions { client: self }
172 }
173
174 pub fn profiles(&self) -> Profiles<'_> {
176 Profiles { client: self }
177 }
178
179 pub fn proxy(&self) -> Proxy<'_> {
181 Proxy { client: self }
182 }
183
184 pub fn base_url(&self) -> &str {
186 self.http.base_url()
187 }
188}
189
190pub struct Sessions<'a> {
196 client: &'a Client,
197}
198
199impl Sessions<'_> {
200 pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
202 let req = CreateSessionRequest {
203 profile_id: options
205 .profile_id
206 .clone()
207 .filter(|p| !p.is_empty()),
208 recording: options.recording,
209 stealth: options.stealth,
210 captcha: options.captcha,
211 proxy: options.proxy.clone(),
212 };
213 let body = serde_json::to_value(&req)
214 .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
215 let body = match body.as_object() {
217 Some(o) if o.is_empty() => None,
218 _ => Some(body),
219 };
220
221 let res = self
222 .client
223 .http
224 .request_with_key(
225 "POST",
226 "/sessions",
227 body.as_ref(),
228 Some(&crate::http::new_idempotency_key()),
232 )
233 .await?;
234 if !res.ok() {
235 return Err(api_error("POST", "/sessions", res.status, &res.body));
236 }
237
238 let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
239 SolariError::protocol(format!("Solari: unexpected session response: {e}"))
240 })?;
241
242 let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
243 (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
244 _ => {
245 return Err(SolariError::protocol(format!(
246 "Solari: unexpected session response: {}",
247 res.body
248 )))
249 }
250 };
251
252 let cdp_endpoint = data
253 .cdp_endpoint
254 .clone()
255 .filter(|c| !c.is_empty())
256 .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
257
258 let expires_at = data
259 .expires_at
260 .clone()
261 .filter(|e| !e.is_empty())
262 .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
263
264 let mut session = Session {
265 id: session_id,
266 ws_endpoint,
267 cdp_endpoint,
268 expires_at,
269 storage_state: None,
270 proxy: data.proxy,
271 };
272
273 if options.profile_id.is_some() {
276 session.storage_state = Some(match data.storage_state_url {
277 Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
279 Some(None) => None,
281 None => data.storage_state.flatten(),
283 });
284 }
285
286 Ok(session)
287 }
288
289 pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
291 let path = format!("/sessions/{}", enc(id));
292 let res = self.client.http.request("GET", &path, None).await?;
293 if !res.ok() {
294 return Err(api_error("GET", &path, res.status, &res.body));
295 }
296 serde_json::from_str(&res.body)
297 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
298 }
299
300 pub async fn release(&self, id: &str) -> Result<(), SolariError> {
320 let path = format!("/sessions/{}", enc(id));
321 let res = self.client.http.request("DELETE", &path, None).await?;
322 if res.ok() {
323 return Ok(());
324 }
325 let err = api_error("DELETE", &path, res.status, &res.body);
326 if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
327 return Ok(());
328 }
329 Err(err)
330 }
331
332 pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
335 let path = format!("/sessions/{}/replay-url", enc(id));
336 let res = self.client.http.request("GET", &path, None).await?;
337 if !res.ok() {
338 return Err(api_error("GET", &path, res.status, &res.body));
339 }
340 let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
341 SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
342 })?;
343 let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
344 SolariError::protocol(format!(
345 "Solari: unexpected replay-url response: {}",
346 res.body
347 ))
348 })?;
349 Ok(ReplayUrl {
350 url,
351 expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
352 content_encoding: data
353 .content_encoding
354 .unwrap_or_else(|| "gzip".to_string()),
355 })
356 }
357
358 pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
363 let replay = self.replay_url(id).await?;
364 let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
365 if !(200..300).contains(&status) {
366 return Err(SolariError::Api {
367 status,
368 code: None,
369 message: format!("Solari: replay download failed: {status}"),
370 });
371 }
372 Ok(bytes)
373 }
374
375 async fn fetch_presigned_storage_state(
377 &self,
378 url: Option<&str>,
379 ) -> Result<Option<StorageState>, SolariError> {
380 let Some(url) = url.filter(|u| !u.is_empty()) else {
381 return Ok(None);
382 };
383 let res = self
384 .client
385 .http
386 .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
387 .await
388 .map_err(|e| {
389 SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
390 })?;
391 if !res.ok() {
392 let snippet: String = res.body.chars().take(256).collect();
393 return Err(SolariError::Api {
394 status: res.status,
395 code: None,
396 message: format!(
397 "Solari: storageState fetch returned {}: {snippet}",
398 res.status
399 ),
400 });
401 }
402 serde_json::from_str(&res.body).map(Some).map_err(|e| {
403 SolariError::protocol(format!(
404 "Solari: storageState response was not valid JSON: {e}"
405 ))
406 })
407 }
408}
409
410pub struct Profiles<'a> {
416 client: &'a Client,
417}
418
419impl Profiles<'_> {
420 pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
422 let res = self.client.http.request("GET", "/profiles", None).await?;
423 if !res.ok() {
424 return Err(api_error("GET", "/profiles", res.status, &res.body));
425 }
426 serde_json::from_str(&res.body)
427 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
428 }
429
430 pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
432 let body = json!({ "name": name.into() });
433 let res = self
434 .client
435 .http
436 .request("POST", "/profiles", Some(&body))
437 .await?;
438 if !res.ok() {
439 return Err(api_error("POST", "/profiles", res.status, &res.body));
440 }
441 serde_json::from_str(&res.body)
442 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
443 }
444
445 pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
447 let path = format!("/profiles/{}", enc(id));
448 let res = self.client.http.request("DELETE", &path, None).await?;
449 if !res.ok() && res.status != 404 {
450 return Err(api_error("DELETE", &path, res.status, &res.body));
451 }
452 Ok(())
453 }
454
455 pub async fn save(
457 &self,
458 id: &str,
459 storage_state: &StorageState,
460 ) -> Result<ProfileSaveResult, SolariError> {
461 let path = format!("/profiles/{}/save", enc(id));
462 let state = serde_json::to_value(storage_state)
463 .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
464 let body = json!({ "storageState": state });
465 let res = self.client.http.request("POST", &path, Some(&body)).await?;
466 if !res.ok() {
467 return Err(api_error("POST", &path, res.status, &res.body));
468 }
469 let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
470 SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
471 })?;
472 Ok(ProfileSaveResult {
473 version: data.version.unwrap_or(0),
474 size_bytes: data.size_bytes.unwrap_or(0),
475 })
476 }
477}
478
479pub struct Proxy<'a> {
485 client: &'a Client,
486}
487
488impl Proxy<'_> {
489 pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
492 let res = self
493 .client
494 .http
495 .request("GET", "/proxy/countries", None)
496 .await?;
497 if !res.ok() {
498 return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
499 }
500 serde_json::from_str(&res.body).map_err(|e| {
501 SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
502 })
503 }
504}
505
506fn enc(s: &str) -> String {
514 let mut out = String::with_capacity(s.len());
515 for b in s.bytes() {
516 match b {
517 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
518 | b'\'' | b'(' | b')' => out.push(b as char),
519 _ => out.push_str(&format!("%{b:02X}")),
520 }
521 }
522 out
523}
524
525pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
529 match Url::parse(ws_endpoint) {
530 Ok(mut u) => {
531 let path = u.path().to_string();
532 if let Some(rest) = path.strip_prefix("/ws/") {
533 u.set_path(&format!("/cdp/{rest}"));
534 u.to_string()
535 } else {
536 ws_endpoint.to_string()
537 }
538 }
539 Err(_) => ws_endpoint.to_string(),
540 }
541}
542
543fn iso8601_utc_in(d: Duration) -> String {
546 let now = std::time::SystemTime::now()
547 .duration_since(std::time::UNIX_EPOCH)
548 .map(|x| x.as_secs())
549 .unwrap_or(0);
550 iso8601_utc(now as i64 + d.as_secs() as i64)
551}
552
553pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
555 let days = unix_secs.div_euclid(86_400);
556 let sod = unix_secs.rem_euclid(86_400);
557 let (y, m, d) = civil_from_days(days);
558 format!(
559 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
560 y,
561 m,
562 d,
563 sod / 3600,
564 (sod % 3600) / 60,
565 sod % 60
566 )
567}
568
569fn civil_from_days(z: i64) -> (i64, u32, u32) {
571 let z = z + 719_468;
572 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
573 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
576 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)
581}