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 web_bot_auth: bool,
114 pub proxy: Option<ProxySpec>,
116}
117
118impl CreateSessionOptions {
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn profile_id(mut self, id: impl Into<String>) -> Self {
124 self.profile_id = Some(id.into());
125 self
126 }
127
128 pub fn recording(mut self, on: bool) -> Self {
129 self.recording = on;
130 self
131 }
132
133 pub fn stealth(mut self, on: bool) -> Self {
134 self.stealth = on;
135 self
136 }
137
138 pub fn captcha(mut self, on: bool) -> Self {
139 self.captcha = on;
140 self
141 }
142
143 pub fn web_bot_auth(mut self, on: bool) -> Self {
144 self.web_bot_auth = on;
145 self
146 }
147
148 pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
149 self.proxy = Some(spec.into());
150 self
151 }
152}
153
154#[derive(Debug)]
156pub struct Client {
157 http: HttpTransport,
158}
159
160impl Client {
161 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
162 if opts.api_key.is_empty() {
163 return Err(SolariError::config("Solari: apiKey is required"));
164 }
165 if opts.base_url.is_empty() {
166 return Err(SolariError::config("Solari: baseUrl is required"));
167 }
168 let base_url = opts.base_url.trim_end_matches('/').to_string();
169 let http = HttpTransport::new(
170 opts.api_key,
171 base_url,
172 opts.max_attempts,
173 opts.backoff_ms,
174 opts.timeout_ms,
175 )?;
176 Ok(Client { http })
177 }
178
179 pub fn sessions(&self) -> Sessions<'_> {
181 Sessions { client: self }
182 }
183
184 pub fn profiles(&self) -> Profiles<'_> {
186 Profiles { client: self }
187 }
188
189 pub fn proxy(&self) -> Proxy<'_> {
191 Proxy { client: self }
192 }
193
194 pub fn base_url(&self) -> &str {
196 self.http.base_url()
197 }
198}
199
200pub struct Sessions<'a> {
206 client: &'a Client,
207}
208
209impl Sessions<'_> {
210 pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
212 let req = CreateSessionRequest {
213 profile_id: options
215 .profile_id
216 .clone()
217 .filter(|p| !p.is_empty()),
218 recording: options.recording,
219 stealth: options.stealth,
220 captcha: options.captcha,
221 web_bot_auth: options.web_bot_auth,
222 proxy: options.proxy.clone(),
223 };
224 let body = serde_json::to_value(&req)
225 .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
226 let body = match body.as_object() {
228 Some(o) if o.is_empty() => None,
229 _ => Some(body),
230 };
231
232 let res = self
233 .client
234 .http
235 .request("POST", "/sessions", body.as_ref())
236 .await?;
237 if !res.ok() {
238 return Err(api_error("POST", "/sessions", res.status, &res.body));
239 }
240
241 let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
242 SolariError::protocol(format!("Solari: unexpected session response: {e}"))
243 })?;
244
245 let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
246 (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
247 _ => {
248 return Err(SolariError::protocol(format!(
249 "Solari: unexpected session response: {}",
250 res.body
251 )))
252 }
253 };
254
255 let cdp_endpoint = data
256 .cdp_endpoint
257 .clone()
258 .filter(|c| !c.is_empty())
259 .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
260
261 let expires_at = data
262 .expires_at
263 .clone()
264 .filter(|e| !e.is_empty())
265 .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
266
267 let mut session = Session {
268 id: session_id,
269 ws_endpoint,
270 cdp_endpoint,
271 expires_at,
272 storage_state: None,
273 proxy: data.proxy,
274 };
275
276 if options.profile_id.is_some() {
279 session.storage_state = Some(match data.storage_state_url {
280 Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
282 Some(None) => None,
284 None => data.storage_state.flatten(),
286 });
287 }
288
289 Ok(session)
290 }
291
292 pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
294 let path = format!("/sessions/{}", enc(id));
295 let res = self.client.http.request("GET", &path, None).await?;
296 if !res.ok() {
297 return Err(api_error("GET", &path, res.status, &res.body));
298 }
299 serde_json::from_str(&res.body)
300 .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
301 }
302
303 pub async fn release(&self, id: &str) -> Result<(), SolariError> {
310 let path = format!("/sessions/{}", enc(id));
311 let res = self.client.http.request("DELETE", &path, None).await?;
312 if !res.ok() && res.status != 404 {
313 return Err(api_error("DELETE", &path, res.status, &res.body));
314 }
315 Ok(())
316 }
317
318 pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
321 let path = format!("/sessions/{}/replay-url", enc(id));
322 let res = self.client.http.request("GET", &path, None).await?;
323 if !res.ok() {
324 return Err(api_error("GET", &path, res.status, &res.body));
325 }
326 let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
327 SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
328 })?;
329 let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
330 SolariError::protocol(format!(
331 "Solari: unexpected replay-url response: {}",
332 res.body
333 ))
334 })?;
335 Ok(ReplayUrl {
336 url,
337 expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
338 content_encoding: data
339 .content_encoding
340 .unwrap_or_else(|| "gzip".to_string()),
341 })
342 }
343
344 pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
347 let replay = self.replay_url(id).await?;
348 let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
349 if !(200..300).contains(&status) {
350 return Err(SolariError::Api {
351 status,
352 code: None,
353 message: format!("Solari: replay download failed: {status}"),
354 });
355 }
356 Ok(bytes)
357 }
358
359 async fn fetch_presigned_storage_state(
361 &self,
362 url: Option<&str>,
363 ) -> Result<Option<StorageState>, SolariError> {
364 let Some(url) = url.filter(|u| !u.is_empty()) else {
365 return Ok(None);
366 };
367 let res = self
368 .client
369 .http
370 .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
371 .await
372 .map_err(|e| {
373 SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
374 })?;
375 if !res.ok() {
376 let snippet: String = res.body.chars().take(256).collect();
377 return Err(SolariError::Api {
378 status: res.status,
379 code: None,
380 message: format!(
381 "Solari: storageState fetch returned {}: {snippet}",
382 res.status
383 ),
384 });
385 }
386 serde_json::from_str(&res.body).map(Some).map_err(|e| {
387 SolariError::protocol(format!(
388 "Solari: storageState response was not valid JSON: {e}"
389 ))
390 })
391 }
392}
393
394pub struct Profiles<'a> {
400 client: &'a Client,
401}
402
403impl Profiles<'_> {
404 pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
406 let res = self.client.http.request("GET", "/profiles", None).await?;
407 if !res.ok() {
408 return Err(api_error("GET", "/profiles", res.status, &res.body));
409 }
410 serde_json::from_str(&res.body)
411 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
412 }
413
414 pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
416 let body = json!({ "name": name.into() });
417 let res = self
418 .client
419 .http
420 .request("POST", "/profiles", Some(&body))
421 .await?;
422 if !res.ok() {
423 return Err(api_error("POST", "/profiles", res.status, &res.body));
424 }
425 serde_json::from_str(&res.body)
426 .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
427 }
428
429 pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
431 let path = format!("/profiles/{}", enc(id));
432 let res = self.client.http.request("DELETE", &path, None).await?;
433 if !res.ok() && res.status != 404 {
434 return Err(api_error("DELETE", &path, res.status, &res.body));
435 }
436 Ok(())
437 }
438
439 pub async fn save(
441 &self,
442 id: &str,
443 storage_state: &StorageState,
444 ) -> Result<ProfileSaveResult, SolariError> {
445 let path = format!("/profiles/{}/save", enc(id));
446 let state = serde_json::to_value(storage_state)
447 .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
448 let body = json!({ "storageState": state });
449 let res = self.client.http.request("POST", &path, Some(&body)).await?;
450 if !res.ok() {
451 return Err(api_error("POST", &path, res.status, &res.body));
452 }
453 let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
454 SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
455 })?;
456 Ok(ProfileSaveResult {
457 version: data.version.unwrap_or(0),
458 size_bytes: data.size_bytes.unwrap_or(0),
459 })
460 }
461}
462
463pub struct Proxy<'a> {
469 client: &'a Client,
470}
471
472impl Proxy<'_> {
473 pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
476 let res = self
477 .client
478 .http
479 .request("GET", "/proxy/countries", None)
480 .await?;
481 if !res.ok() {
482 return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
483 }
484 serde_json::from_str(&res.body).map_err(|e| {
485 SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
486 })
487 }
488}
489
490fn enc(s: &str) -> String {
498 let mut out = String::with_capacity(s.len());
499 for b in s.bytes() {
500 match b {
501 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
502 | b'\'' | b'(' | b')' => out.push(b as char),
503 _ => out.push_str(&format!("%{b:02X}")),
504 }
505 }
506 out
507}
508
509pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
513 match Url::parse(ws_endpoint) {
514 Ok(mut u) => {
515 let path = u.path().to_string();
516 if let Some(rest) = path.strip_prefix("/ws/") {
517 u.set_path(&format!("/cdp/{rest}"));
518 u.to_string()
519 } else {
520 ws_endpoint.to_string()
521 }
522 }
523 Err(_) => ws_endpoint.to_string(),
524 }
525}
526
527fn iso8601_utc_in(d: Duration) -> String {
530 let now = std::time::SystemTime::now()
531 .duration_since(std::time::UNIX_EPOCH)
532 .map(|x| x.as_secs())
533 .unwrap_or(0);
534 iso8601_utc(now as i64 + d.as_secs() as i64)
535}
536
537pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
539 let days = unix_secs.div_euclid(86_400);
540 let sod = unix_secs.rem_euclid(86_400);
541 let (y, m, d) = civil_from_days(days);
542 format!(
543 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
544 y,
545 m,
546 d,
547 sod / 3600,
548 (sod % 3600) / 60,
549 sod % 60
550 )
551}
552
553fn civil_from_days(z: i64) -> (i64, u32, u32) {
555 let z = z + 719_468;
556 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
557 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
560 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)
565}