1use serde::{Deserialize, Deserializer, Serialize};
5use serde_json::{Map, Value};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SolariRegion {
14 #[default]
16 UsWest,
17}
18
19impl SolariRegion {
20 pub fn base_url(&self) -> &'static str {
22 match self {
23 SolariRegion::UsWest => "https://api.getsolari.com",
24 }
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum SameSite {
35 Strict,
36 Lax,
37 None,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Cookie {
44 pub name: String,
45 pub value: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub domain: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub path: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub expires: Option<f64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub http_only: Option<bool>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub secure: Option<bool>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub same_site: Option<SameSite>,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct LocalStorageEntry {
63 pub name: String,
64 pub value: String,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct StorageOrigin {
71 pub origin: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub local_storage: Option<Vec<LocalStorageEntry>>,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct StorageState {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub cookies: Option<Vec<Cookie>>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub origins: Option<Vec<StorageOrigin>>,
85 #[serde(flatten)]
87 pub extra: Map<String, Value>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum ProxyTier {
98 Residential,
100 Static,
102 Mobile,
104}
105
106#[derive(Debug, Clone, Default, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ProxyRequest {
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub country: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub tier: Option<ProxyTier>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub asn: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
122 pub session: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub session_duration: Option<u32>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub state: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub city: Option<String>,
132}
133
134impl ProxyRequest {
135 pub fn country(code: impl Into<String>) -> Self {
137 ProxyRequest {
138 country: Some(code.into()),
139 ..Default::default()
140 }
141 }
142
143 pub fn tier(mut self, tier: ProxyTier) -> Self {
144 self.tier = Some(tier);
145 self
146 }
147
148 pub fn asn(mut self, asn: impl Into<String>) -> Self {
149 self.asn = Some(asn.into());
150 self
151 }
152
153 pub fn session(mut self, id: impl Into<String>) -> Self {
155 self.session = Some(id.into());
156 self
157 }
158
159 pub fn session_duration(mut self, minutes: u32) -> Self {
160 self.session_duration = Some(minutes);
161 self
162 }
163
164 pub fn state(mut self, state: impl Into<String>) -> Self {
165 self.state = Some(state.into());
166 self
167 }
168
169 pub fn city(mut self, city: impl Into<String>) -> Self {
170 self.city = Some(city.into());
171 self
172 }
173}
174
175#[derive(Debug, Clone, Serialize)]
187#[serde(untagged)]
188pub enum ProxySpec {
189 Shorthand(String),
191 Request(ProxyRequest),
193}
194
195impl ProxySpec {
196 pub fn country(code: impl Into<String>) -> Self {
198 ProxySpec::Shorthand(code.into())
199 }
200
201 pub fn off() -> Self {
203 ProxySpec::Shorthand("off".to_string())
204 }
205
206 pub fn smart() -> Self {
208 ProxySpec::Shorthand("smart".to_string())
209 }
210}
211
212impl From<ProxyRequest> for ProxySpec {
213 fn from(r: ProxyRequest) -> Self {
214 ProxySpec::Request(r)
215 }
216}
217
218#[derive(Debug, Clone, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct ResolvedProxyConfig {
226 pub timezone_id: String,
227 pub country: String,
228 #[serde(default)]
229 pub tier: Option<ProxyTier>,
230}
231
232#[derive(Debug, Clone, Default, Deserialize)]
235pub struct ProxyCountries {
236 #[serde(default)]
239 pub enabled: bool,
240 #[serde(default)]
242 pub countries: Vec<String>,
243}
244
245#[derive(Clone)]
253pub struct Session {
254 pub id: String,
255 pub ws_endpoint: String,
257 pub cdp_endpoint: String,
260 pub expires_at: String,
262 pub storage_state: Option<Option<StorageState>>,
267 pub proxy: Option<ResolvedProxyConfig>,
269 pub user_metadata: Option<Map<String, Value>>,
271}
272
273impl std::fmt::Debug for Session {
274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275 f.debug_struct("Session")
276 .field("id", &"<redacted>")
277 .field("ws_endpoint", &crate::http::redact_url(&self.ws_endpoint))
278 .field("cdp_endpoint", &crate::http::redact_url(&self.cdp_endpoint))
279 .field("expires_at", &self.expires_at)
280 .field("storage_state", &self.storage_state)
281 .field("proxy", &self.proxy)
282 .field("user_metadata", &self.user_metadata)
283 .finish()
284 }
285}
286
287#[derive(Debug, Clone, Default, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SessionView {
291 #[serde(default)]
293 pub id: Option<String>,
294 #[serde(default)]
296 pub session_id: Option<String>,
297 #[serde(default)]
298 pub status: Option<String>,
299 #[serde(default)]
301 pub kind: Option<String>,
302 #[serde(default)]
304 pub org: Option<String>,
305 #[serde(default)]
306 pub created_at: Option<String>,
307 #[serde(default)]
308 pub expires_at: Option<String>,
309 #[serde(default)]
311 pub user_metadata: Option<Map<String, Value>>,
312 #[serde(default)]
314 pub ws_endpoint: Option<String>,
315 #[serde(default)]
316 pub cdp_endpoint: Option<String>,
317 #[serde(flatten)]
318 pub extra: Map<String, Value>,
319}
320
321#[derive(Debug, Clone, Default, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SessionSummary {
326 pub id: String,
327 #[serde(default)]
328 pub status: String,
329 #[serde(default)]
330 pub kind: Option<String>,
331 #[serde(default)]
332 pub created_at: Option<String>,
333 #[serde(default)]
334 pub expires_at: Option<String>,
335 #[serde(default)]
336 pub user_metadata: Option<Map<String, Value>>,
337}
338
339#[derive(Debug, Clone, Default, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct SessionList {
343 #[serde(default)]
344 pub sessions: Vec<SessionSummary>,
345 #[serde(default)]
347 pub next_cursor: Option<String>,
348}
349
350#[derive(Debug, Clone)]
352pub struct ReplayUrl {
353 pub url: String,
354 pub expires_in_seconds: u64,
356 pub content_encoding: String,
358}
359
360#[derive(Debug, Clone, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct Profile {
368 pub id: String,
369 #[serde(default)]
370 pub name: String,
371 #[serde(default)]
373 pub version: u64,
374 #[serde(flatten)]
376 pub extra: Map<String, Value>,
377}
378
379#[derive(Debug, Clone, Default)]
381pub struct ProfileSaveResult {
382 pub version: u64,
384 pub size_bytes: u64,
386}
387
388#[derive(Debug, Clone, Default, Serialize)]
395#[serde(rename_all = "camelCase")]
396pub(crate) struct CreateSessionRequest {
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub profile_id: Option<String>,
399 #[serde(skip_serializing_if = "is_false")]
400 pub recording: bool,
401 #[serde(skip_serializing_if = "is_false")]
402 pub stealth: bool,
403 #[serde(skip_serializing_if = "is_false")]
404 pub captcha: bool,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub proxy: Option<ProxySpec>,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub timeout_seconds: Option<u32>,
409 #[serde(skip_serializing_if = "is_false")]
410 pub keep_alive: bool,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub user_metadata: Option<Map<String, Value>>,
413}
414
415fn is_false(b: &bool) -> bool {
416 !*b
417}
418
419#[derive(Debug, Clone, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub(crate) struct StorageStateUrl {
423 #[serde(default)]
424 pub url: Option<String>,
425 #[allow(dead_code)]
426 #[serde(default)]
427 pub expires_in_seconds: Option<u64>,
428}
429
430#[derive(Debug, Clone, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub(crate) struct CreateSessionResponse {
434 #[serde(default)]
435 pub session_id: Option<String>,
436 #[serde(default)]
437 pub ws_endpoint: Option<String>,
438 #[serde(default)]
439 pub cdp_endpoint: Option<String>,
440 #[serde(default)]
441 pub expires_at: Option<String>,
442 #[serde(default, deserialize_with = "double_option")]
444 pub storage_state_url: Option<Option<StorageStateUrl>>,
445 #[serde(default, deserialize_with = "double_option")]
447 pub storage_state: Option<Option<StorageState>>,
448 #[serde(default)]
449 pub proxy: Option<ResolvedProxyConfig>,
450 #[serde(default)]
451 pub user_metadata: Option<Map<String, Value>>,
452}
453
454#[derive(Debug, Clone, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub(crate) struct ReplayUrlResponse {
458 #[serde(default)]
459 pub url: Option<String>,
460 #[serde(default)]
461 pub expires_in_seconds: Option<u64>,
462 #[serde(default)]
463 pub content_encoding: Option<String>,
464}
465
466#[derive(Debug, Clone, Deserialize)]
468#[serde(rename_all = "camelCase")]
469pub(crate) struct ProfileSaveResponse {
470 #[serde(default)]
471 pub version: Option<u64>,
472 #[serde(default)]
473 pub size_bytes: Option<u64>,
474}
475
476fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
479where
480 T: serde::Deserialize<'de>,
481 D: Deserializer<'de>,
482{
483 serde::Deserialize::deserialize(de).map(Some)
484}
485
486#[cfg(test)]
489mod resolved_proxy_tests {
490 use super::*;
491
492 fn debug_field_names(dbg: &str) -> Vec<String> {
505 let inner = dbg
506 .split_once('{')
507 .and_then(|(_, rest)| rest.rsplit_once('}'))
508 .map(|(inner, _)| inner)
509 .unwrap_or("");
510 let mut names = Vec::new();
511 let mut depth = 0usize;
512 for part in inner.split(',') {
513 if depth == 0 {
515 if let Some((name, _)) = part.split_once(':') {
516 let name = name.trim();
517 if !name.is_empty() {
518 names.push(name.to_string());
519 }
520 }
521 }
522 depth += part.matches(['(', '{', '[']).count();
523 depth = depth.saturating_sub(part.matches([')', '}', ']']).count());
524 }
525 names
526 }
527
528 #[test]
529 fn resolved_proxy_config_has_exactly_the_confirmation_fields() {
530 let wire = r#"{"timezoneId":"America/Los_Angeles","country":"us","tier":"mobile"}"#;
531 let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
532
533 let names = debug_field_names(&format!("{rp:?}"));
534 assert_eq!(
535 names,
536 vec!["timezone_id", "country", "tier"],
537 "ResolvedProxyConfig must stay a COARSE CONFIRMATION: egress is applied \
538 server-side, so a caller never dials the proxy and must never receive \
539 its address or account. Got: {names:?}"
540 );
541 }
542
543 #[test]
544 fn legacy_credential_keys_are_dropped_not_resurrected() {
545 let wire = r#"{
546 "timezoneId":"America/Los_Angeles",
547 "country":"us",
548 "tier":"mobile",
549 "server":"http://resi.vendor.example:8000",
550 "username":"acct-12345",
551 "password":"hunter2"
552 }"#;
553 let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
554
555 assert_eq!(rp.timezone_id, "America/Los_Angeles");
556 assert_eq!(rp.country, "us");
557
558 let rendered = format!("{rp:?}");
559 for secret in ["resi.vendor.example", "acct-12345", "hunter2", "server", "username", "password"] {
560 assert!(
561 !rendered.contains(secret),
562 "resolved proxy leaks {secret:?}: {rendered}"
563 );
564 }
565 }
566}