#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod auth;
mod value;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
pub use auth::Auth;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
enum CallError {
Unauthorized(Error),
Other(Error),
}
impl CallError {
fn into_error(self) -> Error {
match self {
Self::Unauthorized(error) | Self::Other(error) => error,
}
}
}
#[derive(Debug)]
pub struct Firestore {
project: String,
database: String,
path: String,
key: String,
auth: Auth,
session: auth::Session,
endpoint: Option<String>,
timeout: Duration,
agent: Option<ureq::Agent>,
default_agent: std::sync::OnceLock<ureq::Agent>,
}
impl Firestore {
#[must_use]
pub fn new(project: impl Into<String>, path: impl Into<String>) -> Self {
Self {
project: project.into(),
database: "(default)".to_owned(),
path: path.into().trim_matches('/').to_owned(),
key: "db".to_owned(),
auth: Auth::Emulator,
session: auth::Session::new(),
endpoint: None,
timeout: DEFAULT_TIMEOUT,
agent: None,
default_agent: std::sync::OnceLock::new(),
}
}
#[must_use]
pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = key.into();
self
}
#[must_use]
pub fn with_database(mut self, database: impl Into<String>) -> Self {
self.database = database.into();
self
}
#[must_use]
pub fn with_auth(mut self, auth: Auth) -> Self {
self.auth = auth;
self.session.invalidate();
self
}
#[must_use]
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into().trim_end_matches('/').to_owned());
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.default_agent = std::sync::OnceLock::new();
self
}
#[must_use]
pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
self.agent = Some(agent);
self
}
pub fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
let mut seen: Option<String> = None;
while watching.keep_going() {
if let Ok((document, updated)) = self.read() {
let Some(updated) = updated else {
return Err(Error::remote(format!(
"{}: the document has no `updateTime`, so changes cannot be detected; is this a real Firestore?",
self.describe()
)));
};
if seen.is_none() {
seen = Some(updated);
} else if seen.as_deref() != Some(&*updated) {
seen = Some(updated);
on_change(document)?;
}
}
watching.sleep_for(interval);
}
Ok(())
}
fn read(&self) -> Result<(Fetched, Option<String>), Error> {
let body = self.get()?;
let fields = body.get("fields").ok_or_else(|| {
Error::remote(format!(
"{}: the response has no `fields`; is that a document?",
self.describe()
))
})?;
let values = value::to_json(fields);
let document = serde_json::json!({ &self.key: values });
let updated = body
.get("updateTime")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
Ok((Fetched::new(document.to_string(), Format::Json), updated))
}
fn get(&self) -> Result<serde_json::Value, Error> {
match self.get_once() {
Err(CallError::Unauthorized(_)) if self.can_refresh() => {
self.session.invalidate();
self.get_once().map_err(CallError::into_error)
}
outcome => outcome.map_err(CallError::into_error),
}
}
fn can_refresh(&self) -> bool {
matches!(self.auth, Auth::MetadataServer { .. })
}
fn get_once(&self) -> Result<serde_json::Value, CallError> {
let mut request = self.agent().get(&self.url());
if let Some(token) = self
.session
.token(&self.auth, self.agent())
.map_err(CallError::Other)?
{
request = request.header("Authorization", &format!("Bearer {token}"));
}
request
.call()
.map_err(|error| {
let rendered = Error::remote(format!("{}: {error}", self.describe()));
match error {
ureq::Error::StatusCode(401) => CallError::Unauthorized(rendered),
_ => CallError::Other(rendered),
}
})?
.body_mut()
.read_json()
.map_err(|error| {
CallError::Other(Error::remote(format!(
"{}: the response was not JSON: {error}",
self.describe()
)))
})
}
fn url(&self) -> String {
let host = self
.endpoint
.clone()
.unwrap_or_else(|| "https://firestore.googleapis.com".to_owned());
format!(
"{host}/v1/projects/{}/databases/{}/documents/{}",
self.project, self.database, self.path
)
}
fn agent(&self) -> &ureq::Agent {
self.agent.as_ref().unwrap_or_else(|| {
self.default_agent.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_global(Some(self.timeout))
.build()
.new_agent()
})
})
}
}
impl RemoteSource for Firestore {
fn fetch(&self) -> Result<Fetched, Error> {
self.read().map(|(document, _updated)| document)
}
fn describe(&self) -> String {
match &self.endpoint {
Some(endpoint) => format!("firestore {endpoint} {}/{}", self.project, self.path),
None => format!("firestore {}/{}", self.project, self.path),
}
}
}