use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SolariRegion {
#[default]
UsWest,
}
impl SolariRegion {
pub fn base_url(&self) -> &'static str {
match self {
SolariRegion::UsWest => "https://api.getsolari.com",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cookie {
pub name: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<SameSite>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LocalStorageEntry {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageOrigin {
pub origin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_storage: Option<Vec<LocalStorageEntry>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookies: Option<Vec<Cookie>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origins: Option<Vec<StorageOrigin>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyTier {
Residential,
Static,
Mobile,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<ProxyTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub asn: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_duration: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
}
impl ProxyRequest {
pub fn country(code: impl Into<String>) -> Self {
ProxyRequest {
country: Some(code.into()),
..Default::default()
}
}
pub fn tier(mut self, tier: ProxyTier) -> Self {
self.tier = Some(tier);
self
}
pub fn asn(mut self, asn: impl Into<String>) -> Self {
self.asn = Some(asn.into());
self
}
pub fn session(mut self, id: impl Into<String>) -> Self {
self.session = Some(id.into());
self
}
pub fn session_duration(mut self, minutes: u32) -> Self {
self.session_duration = Some(minutes);
self
}
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn city(mut self, city: impl Into<String>) -> Self {
self.city = Some(city.into());
self
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum ProxySpec {
Shorthand(String),
Request(ProxyRequest),
}
impl ProxySpec {
pub fn country(code: impl Into<String>) -> Self {
ProxySpec::Shorthand(code.into())
}
pub fn off() -> Self {
ProxySpec::Shorthand("off".to_string())
}
pub fn smart() -> Self {
ProxySpec::Shorthand("smart".to_string())
}
}
impl From<ProxyRequest> for ProxySpec {
fn from(r: ProxyRequest) -> Self {
ProxySpec::Request(r)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedProxyConfig {
pub timezone_id: String,
pub country: String,
#[serde(default)]
pub tier: Option<ProxyTier>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProxyCountries {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub countries: Vec<String>,
}
#[derive(Clone)]
pub struct Session {
pub id: String,
pub ws_endpoint: String,
pub cdp_endpoint: String,
pub expires_at: String,
pub storage_state: Option<Option<StorageState>>,
pub proxy: Option<ResolvedProxyConfig>,
pub user_metadata: Option<Map<String, Value>>,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("id", &"<redacted>")
.field("ws_endpoint", &crate::http::redact_url(&self.ws_endpoint))
.field("cdp_endpoint", &crate::http::redact_url(&self.cdp_endpoint))
.field("expires_at", &self.expires_at)
.field("storage_state", &self.storage_state)
.field("proxy", &self.proxy)
.field("user_metadata", &self.user_metadata)
.finish()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionView {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub org: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub user_metadata: Option<Map<String, Value>>,
#[serde(default)]
pub ws_endpoint: Option<String>,
#[serde(default)]
pub cdp_endpoint: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub id: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub user_metadata: Option<Map<String, Value>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionList {
#[serde(default)]
pub sessions: Vec<SessionSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReplayUrl {
pub url: String,
pub expires_in_seconds: u64,
pub content_encoding: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: u64,
#[serde(default)]
pub size_bytes: Option<u64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default)]
pub struct ProfileSaveResult {
pub version: u64,
pub size_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateSessionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub recording: bool,
#[serde(skip_serializing_if = "is_false")]
pub stealth: bool,
#[serde(skip_serializing_if = "is_false")]
pub captcha: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy: Option<ProxySpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u32>,
#[serde(skip_serializing_if = "is_false")]
pub keep_alive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_metadata: Option<Map<String, Value>>,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StorageStateUrl {
#[serde(default)]
pub url: Option<String>,
#[allow(dead_code)]
#[serde(default)]
pub expires_in_seconds: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateSessionResponse {
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub ws_endpoint: Option<String>,
#[serde(default)]
pub cdp_endpoint: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default, deserialize_with = "double_option")]
pub storage_state_url: Option<Option<StorageStateUrl>>,
#[serde(default, deserialize_with = "double_option")]
pub storage_state: Option<Option<StorageState>>,
#[serde(default)]
pub proxy: Option<ResolvedProxyConfig>,
#[serde(default)]
pub user_metadata: Option<Map<String, Value>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReplayUrlResponse {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub expires_in_seconds: Option<u64>,
#[serde(default)]
pub content_encoding: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProfileSaveResponse {
#[serde(default)]
pub version: Option<u64>,
#[serde(default)]
pub size_bytes: Option<u64>,
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: Deserializer<'de>,
{
serde::Deserialize::deserialize(de).map(Some)
}
#[cfg(test)]
mod resolved_proxy_tests {
use super::*;
fn debug_field_names(dbg: &str) -> Vec<String> {
let inner = dbg
.split_once('{')
.and_then(|(_, rest)| rest.rsplit_once('}'))
.map(|(inner, _)| inner)
.unwrap_or("");
let mut names = Vec::new();
let mut depth = 0usize;
for part in inner.split(',') {
if depth == 0 {
if let Some((name, _)) = part.split_once(':') {
let name = name.trim();
if !name.is_empty() {
names.push(name.to_string());
}
}
}
depth += part.matches(['(', '{', '[']).count();
depth = depth.saturating_sub(part.matches([')', '}', ']']).count());
}
names
}
#[test]
fn resolved_proxy_config_has_exactly_the_confirmation_fields() {
let wire = r#"{"timezoneId":"America/Los_Angeles","country":"us","tier":"mobile"}"#;
let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
let names = debug_field_names(&format!("{rp:?}"));
assert_eq!(
names,
vec!["timezone_id", "country", "tier"],
"ResolvedProxyConfig must stay a COARSE CONFIRMATION: egress is applied \
server-side, so a caller never dials the proxy and must never receive \
its address or account. Got: {names:?}"
);
}
#[test]
fn legacy_credential_keys_are_dropped_not_resurrected() {
let wire = r#"{
"timezoneId":"America/Los_Angeles",
"country":"us",
"tier":"mobile",
"server":"http://resi.vendor.example:8000",
"username":"acct-12345",
"password":"hunter2"
}"#;
let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
assert_eq!(rp.timezone_id, "America/Los_Angeles");
assert_eq!(rp.country, "us");
let rendered = format!("{rp:?}");
for secret in ["resi.vendor.example", "acct-12345", "hunter2", "server", "username", "password"] {
assert!(
!rendered.contains(secret),
"resolved proxy leaks {secret:?}: {rendered}"
);
}
}
}