1use std::time::Duration;
10
11use serde_json::json;
12use url::Url;
13
14use crate::error::{api_error, SolariError};
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("POST", "/sessions", body.as_ref())
225 .await?;
226 if !res.ok() {
227 return Err(api_error("POST", "/sessions", res.status, &res.body));
228 }
229
230 let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
231 SolariError::protocol(format!("Solari: unexpected session response: {e}"))
232 })?;
233
234 let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
235 (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
236 _ => {
237 return Err(SolariError::protocol(format!(
238 "Solari: unexpected session response: {}",
239 res.body
240 )))
241 }
242 };
243
244 let cdp_endpoint = data
245 .cdp_endpoint
246 .clone()
247 .filter(|c| !c.is_empty())
248 .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
249
250 let expires_at = data
251 .expires_at
252 .clone()
253 .filter(|e| !e.is_empty())
254 .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
255
256 let mut session = Session {
257 id: session_id,
258 ws_endpoint,
259 cdp_endpoint,
260 expires_at,
261 storage_state: None,
262 proxy: data.proxy,
263 };
264
265 if options.profile_id.is_some() {
268 session.storage_state = Some(match data.storage_state_url {
269 Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
271 Some(None) => None,
273 None => data.storage_state.flatten(),
275 });
276 }
277
278 Ok(session)
279 }
280
281 pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
283 let path = format!("/sessions/{}", enc(id));
284 let res = self.client.http.request("GET", &path, None).await?;
285 if !res.ok() {
286 return Err(api_error("GET", &path, res.status, &res.body));
287 }
288 serde_json::from_str(&res.body)
289 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
290 }
291
292 pub async fn release(&self, id: &str) -> Result<(), SolariError> {
299 let path = format!("/sessions/{}", enc(id));
300 let res = self.client.http.request("DELETE", &path, None).await?;
301 if !res.ok() && res.status != 404 {
302 return Err(api_error("DELETE", &path, res.status, &res.body));
303 }
304 Ok(())
305 }
306
307 pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
310 let path = format!("/sessions/{}/replay-url", enc(id));
311 let res = self.client.http.request("GET", &path, None).await?;
312 if !res.ok() {
313 return Err(api_error("GET", &path, res.status, &res.body));
314 }
315 let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
316 SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
317 })?;
318 let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
319 SolariError::protocol(format!(
320 "Solari: unexpected replay-url response: {}",
321 res.body
322 ))
323 })?;
324 Ok(ReplayUrl {
325 url,
326 expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
327 content_encoding: data
328 .content_encoding
329 .unwrap_or_else(|| "gzip".to_string()),
330 })
331 }
332
333 pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
338 let replay = self.replay_url(id).await?;
339 let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
340 if !(200..300).contains(&status) {
341 return Err(SolariError::Api {
342 status,
343 code: None,
344 message: format!("Solari: replay download failed: {status}"),
345 });
346 }
347 Ok(bytes)
348 }
349
350 async fn fetch_presigned_storage_state(
352 &self,
353 url: Option<&str>,
354 ) -> Result<Option<StorageState>, SolariError> {
355 let Some(url) = url.filter(|u| !u.is_empty()) else {
356 return Ok(None);
357 };
358 let res = self
359 .client
360 .http
361 .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
362 .await
363 .map_err(|e| {
364 SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
365 })?;
366 if !res.ok() {
367 let snippet: String = res.body.chars().take(256).collect();
368 return Err(SolariError::Api {
369 status: res.status,
370 code: None,
371 message: format!(
372 "Solari: storageState fetch returned {}: {snippet}",
373 res.status
374 ),
375 });
376 }
377 serde_json::from_str(&res.body).map(Some).map_err(|e| {
378 SolariError::protocol(format!(
379 "Solari: storageState response was not valid JSON: {e}"
380 ))
381 })
382 }
383}
384
385pub struct Profiles<'a> {
391 client: &'a Client,
392}
393
394impl Profiles<'_> {
395 pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
397 let res = self.client.http.request("GET", "/profiles", None).await?;
398 if !res.ok() {
399 return Err(api_error("GET", "/profiles", res.status, &res.body));
400 }
401 serde_json::from_str(&res.body)
402 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
403 }
404
405 pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
407 let body = json!({ "name": name.into() });
408 let res = self
409 .client
410 .http
411 .request("POST", "/profiles", Some(&body))
412 .await?;
413 if !res.ok() {
414 return Err(api_error("POST", "/profiles", res.status, &res.body));
415 }
416 serde_json::from_str(&res.body)
417 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
418 }
419
420 pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
422 let path = format!("/profiles/{}", enc(id));
423 let res = self.client.http.request("DELETE", &path, None).await?;
424 if !res.ok() && res.status != 404 {
425 return Err(api_error("DELETE", &path, res.status, &res.body));
426 }
427 Ok(())
428 }
429
430 pub async fn save(
432 &self,
433 id: &str,
434 storage_state: &StorageState,
435 ) -> Result<ProfileSaveResult, SolariError> {
436 let path = format!("/profiles/{}/save", enc(id));
437 let state = serde_json::to_value(storage_state)
438 .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
439 let body = json!({ "storageState": state });
440 let res = self.client.http.request("POST", &path, Some(&body)).await?;
441 if !res.ok() {
442 return Err(api_error("POST", &path, res.status, &res.body));
443 }
444 let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
445 SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
446 })?;
447 Ok(ProfileSaveResult {
448 version: data.version.unwrap_or(0),
449 size_bytes: data.size_bytes.unwrap_or(0),
450 })
451 }
452}
453
454pub struct Proxy<'a> {
460 client: &'a Client,
461}
462
463impl Proxy<'_> {
464 pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
467 let res = self
468 .client
469 .http
470 .request("GET", "/proxy/countries", None)
471 .await?;
472 if !res.ok() {
473 return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
474 }
475 serde_json::from_str(&res.body).map_err(|e| {
476 SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
477 })
478 }
479}
480
481fn enc(s: &str) -> String {
489 let mut out = String::with_capacity(s.len());
490 for b in s.bytes() {
491 match b {
492 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
493 | b'\'' | b'(' | b')' => out.push(b as char),
494 _ => out.push_str(&format!("%{b:02X}")),
495 }
496 }
497 out
498}
499
500pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
504 match Url::parse(ws_endpoint) {
505 Ok(mut u) => {
506 let path = u.path().to_string();
507 if let Some(rest) = path.strip_prefix("/ws/") {
508 u.set_path(&format!("/cdp/{rest}"));
509 u.to_string()
510 } else {
511 ws_endpoint.to_string()
512 }
513 }
514 Err(_) => ws_endpoint.to_string(),
515 }
516}
517
518fn iso8601_utc_in(d: Duration) -> String {
521 let now = std::time::SystemTime::now()
522 .duration_since(std::time::UNIX_EPOCH)
523 .map(|x| x.as_secs())
524 .unwrap_or(0);
525 iso8601_utc(now as i64 + d.as_secs() as i64)
526}
527
528pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
530 let days = unix_secs.div_euclid(86_400);
531 let sod = unix_secs.rem_euclid(86_400);
532 let (y, m, d) = civil_from_days(days);
533 format!(
534 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
535 y,
536 m,
537 d,
538 sod / 3600,
539 (sod % 3600) / 60,
540 sod % 60
541 )
542}
543
544fn civil_from_days(z: i64) -> (i64, u32, u32) {
546 let z = z + 719_468;
547 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
548 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
551 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)
556}