use crate::logging::{env_flag_enabled, log_error, log_info};
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::{de::DeserializeOwned, Serialize};
use std::sync::{Arc, Mutex};
#[cfg(target_arch = "wasm32")]
fn wasm_global_string(key: &str) -> Option<String> {
let global = js_sys::global();
js_sys::Reflect::get(&global, &wasm_bindgen::JsValue::from_str(key))
.ok()
.and_then(|v| v.as_string())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn encode_path_segment(segment: &str) -> String {
segment
.bytes()
.flat_map(|b| {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
vec![b as char]
} else {
format!("%{:02X}", b).chars().collect::<Vec<_>>()
}
})
.collect()
}
fn encode_resource_path(path: &str) -> String {
path.split('/')
.filter(|segment| !segment.is_empty())
.map(encode_path_segment)
.collect::<Vec<_>>()
.join("/")
}
fn validate_doc_id(id: &str) -> Result<()> {
if id.is_empty() {
anyhow::bail!("Document ID must not be empty");
}
if id.len() > 1500 {
anyhow::bail!("Document ID exceeds maximum length of 1500 bytes");
}
if id.contains('/') || id.contains('\0') {
anyhow::bail!("Document ID contains invalid character ('/' or null byte)");
}
Ok(())
}
fn firestore_verbose() -> bool {
env_flag_enabled("OPENRTC_FIRESTORE_VERBOSE")
}
#[derive(Clone)]
pub struct FirestoreClient {
client: reqwest::Client,
token_provider: Arc<Mutex<Box<dyn Fn() -> Option<String> + Send + Sync>>>,
project_id: String,
}
impl FirestoreClient {
pub fn new(
project_id: String,
token_provider: Box<dyn Fn() -> Option<String> + Send + Sync>,
) -> Self {
Self {
client: reqwest::Client::new(),
token_provider: Arc::new(Mutex::new(token_provider)),
project_id,
}
}
pub fn project_id(&self) -> &str {
&self.project_id
}
pub(crate) fn token_provider(
&self,
) -> Arc<Mutex<Box<dyn Fn() -> Option<String> + Send + Sync>>> {
self.token_provider.clone()
}
fn base_url(&self) -> String {
#[cfg(target_arch = "wasm32")]
{
if let Some(host) = wasm_global_string("__OPENRTC_FIRESTORE_EMULATOR_HOST__") {
if firestore_verbose() {
log_info(&format!(
"[OPENRTC][FIRESTORE] using wasm global emulator host={} project_id={}",
host, self.project_id
));
}
return format!(
"http://{}/v1/projects/{}/databases/(default)/documents",
host, self.project_id
);
}
if firestore_verbose() {
log_info(&format!(
"[OPENRTC][FIRESTORE] wasm global emulator host not set; falling back to env/prod project_id={}",
self.project_id
));
}
}
if let Ok(host) = std::env::var("OPENRTC_FIRESTORE_EMULATOR_HOST") {
let host = host.trim();
if !host.is_empty() {
return format!(
"http://{}/v1/projects/{}/databases/(default)/documents",
host, self.project_id
);
}
}
format!(
"https://firestore.googleapis.com/v1/projects/{}/databases/(default)/documents",
self.project_id
)
}
fn get_token(&self) -> Result<String> {
(self.token_provider.lock().unwrap())().context("No auth token available")
}
fn headers_with_token(&self, token: Option<&str>) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token))?,
);
}
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(headers)
}
async fn send_request<T: Serialize + ?Sized>(
&self,
method: &reqwest::Method,
url: &str,
json_body: Option<&T>,
token: Option<&str>,
) -> Result<reqwest::Response> {
let has_token = token.map(|t| !t.is_empty()).unwrap_or(false);
let token_len = token.map(|t| t.len()).unwrap_or(0);
if firestore_verbose() {
log_info(&format!(
"[OPENRTC][FIRESTORE] request method={} url={} has_token={} token_len={} has_body={}",
method,
url,
has_token,
token_len,
json_body.is_some()
));
}
let mut req = self.client.request(method.clone(), url);
match self.headers_with_token(token) {
Ok(h) => {
req = req.headers(h);
}
Err(error) => {
log_error(&format!(
"[OPENRTC][FIRESTORE] failed building headers method={} url={} error={}",
method, url, error
));
}
}
if let Some(body) = json_body {
req = req.json(body);
}
Ok(req.send().await?)
}
async fn request_with_retry<T: Serialize + ?Sized>(
&self,
method: reqwest::Method,
url: &str,
json_body: Option<&T>,
) -> Result<reqwest::Response> {
let mut token = self.get_token().ok();
let mut res = self
.send_request(&method, url, json_body, token.as_deref())
.await?;
if firestore_verbose() || !res.status().is_success() {
let logger: fn(&str) = if res.status().is_success() {
log_info
} else {
log_error
};
logger(&format!(
"[OPENRTC][FIRESTORE] response method={} url={} status={}",
method,
url,
res.status()
));
}
if res.status() == reqwest::StatusCode::UNAUTHORIZED {
let refreshed_token = self.get_token().ok();
let changed = refreshed_token != token;
if changed {
log_info(&format!(
"[OPENRTC][FIRESTORE] received 401; retrying request with refreshed token method={} url={}",
method, url
));
token = refreshed_token;
res = self
.send_request(&method, url, json_body, token.as_deref())
.await?;
}
}
Ok(res)
}
pub async fn get_document<T: DeserializeOwned>(
&self,
collection: &str,
doc_id: &str,
) -> Result<super::models::FirestoreDocument<T>> {
validate_doc_id(doc_id)?;
let url = format!(
"{}/{}/{}",
self.base_url(),
encode_resource_path(collection),
encode_path_segment(doc_id)
);
let res = self
.request_with_retry(reqwest::Method::GET, &url, None::<&()>)
.await?;
if !res.status().is_success() {
let error_text = res.text().await?;
anyhow::bail!("Firestore Error: {}", error_text);
}
let doc = res.json().await?;
Ok(doc)
}
pub async fn update_document<T: Serialize>(
&self,
collection: &str,
doc_id: &str,
fields: &T,
update_mask: Option<Vec<&str>>,
) -> Result<()> {
validate_doc_id(doc_id)?;
let mut url = format!(
"{}/{}/{}",
self.base_url(),
encode_resource_path(collection),
encode_path_segment(doc_id)
);
let mut query_params = vec![];
if let Some(mask) = update_mask {
for field in mask {
query_params.push(format!("updateMask.fieldPaths={}", field));
}
}
if !query_params.is_empty() {
url.push_str("?");
url.push_str(&query_params.join("&"));
}
#[derive(Serialize)]
struct UpdatePayload<'a, T> {
fields: &'a T,
}
let payload = UpdatePayload { fields };
let res = self
.request_with_retry(reqwest::Method::PATCH, &url, Some(&payload))
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().await?;
log_error(&format!(
"[OPENRTC][FIRESTORE] update_document failed collection={} doc_id={} status={} body={}",
collection, doc_id, status, error_text
));
anyhow::bail!("Firestore Update Error: {}", error_text);
}
Ok(())
}
pub async fn delete_document(&self, doc_path: &str) -> Result<()> {
let url = format!("{}/{}", self.base_url(), encode_resource_path(doc_path));
let res = self
.request_with_retry(reqwest::Method::DELETE, &url, None::<&()>)
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().await?;
log_error(&format!(
"[OPENRTC][FIRESTORE] delete_document failed doc_path={} status={} body={}",
doc_path, status, error_text
));
anyhow::bail!("Firestore Delete Error: {}", error_text);
}
Ok(())
}
#[allow(dead_code)]
pub async fn create_document<T: Serialize>(
&self,
collection: &str,
doc_id: Option<&str>,
fields: &T,
) -> Result<String> {
if let Some(id) = doc_id {
validate_doc_id(id)?;
}
let mut url = format!("{}/{}", self.base_url(), encode_resource_path(collection));
if let Some(id) = doc_id {
url.push_str(&format!("?documentId={}", encode_path_segment(id)));
}
#[derive(Serialize)]
struct CreatePayload<'a, T> {
fields: &'a T,
}
let payload = CreatePayload { fields };
let res = self
.request_with_retry(reqwest::Method::POST, &url, Some(&payload))
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().await?;
log_error(&format!(
"[OPENRTC][FIRESTORE] create_document failed collection={} doc_id={:?} status={} body={}",
collection, doc_id, status, error_text
));
anyhow::bail!("Firestore Create Error: {}", error_text);
}
let json: serde_json::Value = res.json().await?;
Ok(json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string())
}
pub async fn run_query<T: DeserializeOwned>(
&self,
query_json: serde_json::Value,
) -> Result<Vec<T>> {
let url = format!("{}:runQuery", self.base_url());
let res = self
.request_with_retry(reqwest::Method::POST, &url, Some(&query_json))
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().await?;
log_error(&format!(
"[OPENRTC][FIRESTORE] run_query failed status={} body={}",
status, error_text
));
anyhow::bail!("Firestore Query Error: {}", error_text);
}
let json: serde_json::Value = res.json().await?;
let mut results = Vec::new();
if let Some(arr) = json.as_array() {
for item in arr {
if let Some(doc) = item.get("document") {
match serde_json::from_value::<T>(doc.clone()) {
Ok(parsed) => results.push(parsed),
Err(e) => {
log_error(&format!(
"[OPENRTC][FIRESTORE] failed to parse query result: {}",
e
));
log_error(&format!(
"[OPENRTC][FIRESTORE] query parse error details: {:?}",
doc
));
}
}
}
}
}
Ok(results)
}
#[allow(dead_code)]
pub async fn list_documents<T: DeserializeOwned>(
&self,
collection: &str,
) -> Result<Vec<super::models::FirestoreDocument<T>>> {
let mut results = Vec::new();
let mut next_page_token: Option<String> = None;
let encoded_collection = encode_resource_path(collection);
loop {
let mut url = format!("{}/{}", self.base_url(), encoded_collection);
if let Some(token) = next_page_token.as_ref() {
if !token.is_empty() {
url.push_str("?pageToken=");
url.push_str(token);
}
}
let res = self
.request_with_retry(reqwest::Method::GET, &url, None::<&()>)
.await?;
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().await?;
log_error(&format!(
"[OPENRTC][FIRESTORE] list_documents failed collection={} status={} body={}",
collection, status, error_text
));
anyhow::bail!("Firestore List Error: {}", error_text);
}
let json: serde_json::Value = res.json().await?;
if let Some(documents) = json.get("documents").and_then(|d| d.as_array()) {
for doc in documents {
match serde_json::from_value::<super::models::FirestoreDocument<T>>(doc.clone())
{
Ok(parsed) => results.push(parsed),
Err(e) => {
log_error(&format!(
"[OPENRTC][FIRESTORE] failed to parse listed document: {}",
e
));
log_error(&format!(
"[OPENRTC][FIRESTORE] list parse error details: {:?}",
doc
));
}
}
}
}
next_page_token = json
.get("nextPageToken")
.and_then(|token| token.as_str())
.map(|token| token.to_string())
.filter(|token| !token.is_empty());
if next_page_token.is_none() {
break;
}
}
Ok(results)
}
}