#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod auth;
mod tls;
mod value;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::guarded;
pub use auth::Auth;
pub use dynamic_config_store_core::tls::TlsConfig;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Keys {
One(String),
Several(Vec<String>),
}
impl Keys {
#[must_use]
pub fn one(path: impl Into<String>) -> Self {
Self::One(path.into())
}
#[must_use]
pub fn several<I, S>(paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Several(paths.into_iter().map(Into::into).collect())
}
fn describe(&self) -> String {
match self {
Self::One(path) => path.clone(),
Self::Several(paths) => format!("documents {}", paths.join(", ")),
}
}
}
impl From<&str> for Keys {
fn from(path: &str) -> Self {
Self::one(path)
}
}
impl From<String> for Keys {
fn from(path: String) -> Self {
Self::One(path)
}
}
impl From<&String> for Keys {
fn from(path: &String) -> Self {
Self::one(path)
}
}
enum CallError {
Unauthorized(Error),
Forbidden(Error),
Other(Error),
}
impl CallError {
fn into_error(self) -> Error {
match self {
Self::Unauthorized(error) | Self::Forbidden(error) | Self::Other(error) => error,
}
}
}
pub struct Firestore {
project: String,
database: String,
keys: Keys,
key: String,
auth: Auth,
session: auth::Session,
endpoint: Option<String>,
timeout: Duration,
agent: Option<ureq::Agent>,
tls: Option<TlsConfig>,
default_agent: std::sync::OnceLock<Result<ureq::Agent, String>>,
attempts: Attempts,
}
impl Firestore {
#[must_use]
pub fn new(project: impl Into<String>, path: impl Into<Keys>) -> Self {
Self {
project: project.into(),
database: "(default)".to_owned(),
keys: trimmed(path.into()),
key: "db".to_owned(),
auth: Auth::Emulator,
session: auth::Session::new(),
endpoint: None,
timeout: DEFAULT_TIMEOUT,
agent: None,
tls: None,
default_agent: std::sync::OnceLock::new(),
attempts: Attempts::default(),
}
}
#[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
}
#[must_use]
pub fn with_tls(mut self, tls: TlsConfig) -> Self {
self.tls = Some(tls);
self.default_agent = std::sync::OnceLock::new();
self
}
#[must_use]
pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
self.attempts = Attempts::to(sink);
self
}
pub fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
self.single_path()?;
let mut seen: Option<String> = None;
while watching.keep_going() {
match self.read() {
Ok((document, updated)) => {
let Some(updated) = updated else {
let error = Error::remote(format!(
"{}: the document has no `updateTime`, so changes cannot be detected; is this a real Firestore?",
self.describe()
));
self.attempts.failed(&error);
return Err(error);
};
if seen.is_none() {
seen = Some(updated);
} else if seen.as_deref() != Some(&*updated) {
seen = Some(updated);
guarded(&mut on_change, document, &self.describe())?;
}
}
Err(error) => self.attempts.failed(&error),
}
watching.sleep_for(interval);
}
Ok(())
}
fn read(&self) -> Result<(Fetched, Option<String>), Error> {
let path = self.single_path()?;
let body = self.get(&self.url(path))?;
let (document, updated) = self.section_of(&body, path)?;
Ok((Fetched::new(document, Format::Json), updated))
}
fn section_of(
&self,
body: &serde_json::Value,
path: &str,
) -> Result<(String, Option<String>), Error> {
let fields = body.get("fields").ok_or_else(|| {
Error::remote(format!(
"{}: `{path}` answered without `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((document.to_string(), updated))
}
fn single_path(&self) -> Result<&str, Error> {
match &self.keys {
Keys::One(path) => Ok(path),
Keys::Several(_) => Err(Error::remote(format!(
"{}: a source that reads several documents cannot be watched; \
poll `refresh_remote()` on a timer instead",
self.describe()
))),
}
}
fn overlap(&self) -> Overlap {
Overlap::LaterWins
}
fn documents(&self) -> Result<Vec<(String, String)>, Error> {
match &self.keys {
Keys::One(path) => {
let body = self.get(&self.url(path))?;
Ok(vec![(path.clone(), self.section_of(&body, path)?.0)])
}
Keys::Several(paths) => self.batch(paths),
}
}
fn batch(&self, paths: &[String]) -> Result<Vec<(String, String)>, Error> {
let names: Vec<String> = paths.iter().map(|path| self.name_of(path)).collect();
let answered = self.post(
&self.batch_url(),
&serde_json::json!({ "documents": names }),
)?;
let entries = answered.as_array().ok_or_else(|| {
Error::remote(format!(
"{}: the batch response is not a list of results",
self.describe()
))
})?;
let mut held: Vec<(String, String)> = Vec::with_capacity(entries.len());
for entry in entries {
if let Some(missing) = entry.get("missing").and_then(serde_json::Value::as_str) {
return Err(Error::remote(format!(
"{}: `{}` holds no document",
self.describe(),
self.path_of(missing)
)));
}
let found = entry.get("found").ok_or_else(|| {
Error::remote(format!(
"{}: a batch result is neither `found` nor `missing`",
self.describe()
))
})?;
let name = found
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::remote(format!(
"{}: a batch result names no document",
self.describe()
))
})?;
let path = self.path_of(name);
if !paths.contains(&path) {
return Err(Error::remote(format!(
"{}: the store answered with `{path}`, which is not one of \
the documents that were asked for",
self.describe()
)));
}
if held.iter().any(|(held, _)| *held == path) {
return Err(Error::remote(format!(
"{}: the store answered for `{path}` twice",
self.describe()
)));
}
held.push((path.clone(), self.section_of(found, &path)?.0));
}
paths
.iter()
.map(|path| {
held.iter()
.find(|(held, _)| held == path)
.cloned()
.ok_or_else(|| {
Error::remote(format!(
"{}: the store answered nothing at all about `{path}`",
self.describe()
))
})
})
.collect()
}
fn get(&self, url: &str) -> Result<serde_json::Value, Error> {
match self.get_once(url) {
Err(CallError::Unauthorized(_)) if self.can_refresh() => {
self.session.invalidate();
self.get_once(url).map_err(CallError::into_error)
}
outcome => outcome.map_err(CallError::into_error),
}
}
fn post(&self, url: &str, body: &serde_json::Value) -> Result<serde_json::Value, Error> {
match self.post_once(url, body) {
Err(CallError::Unauthorized(_)) if self.can_refresh() => {
self.session.invalidate();
self.post_once(url, body).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, url: &str) -> Result<serde_json::Value, CallError> {
let agent = self.agent().map_err(CallError::Other)?;
let mut request = agent.get(url);
if let Some(token) = self.bearer()? {
request = request.header("Authorization", &format!("Bearer {token}"));
}
let response = request.call().map_err(|error| self.sorted(&error))?;
Self::json(response, &self.describe())
}
fn post_once(
&self,
url: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, CallError> {
let agent = self.agent().map_err(CallError::Other)?;
let mut request = agent.post(url);
if let Some(token) = self.bearer()? {
request = request.header("Authorization", &format!("Bearer {token}"));
}
let response = request
.send_json(body)
.map_err(|error| self.sorted(&error))?;
Self::json(response, &self.describe())
}
fn bearer(&self) -> Result<Option<String>, CallError> {
let agent = self.agent().map_err(CallError::Other)?;
self.session
.token(&self.auth, agent)
.map_err(CallError::Other)
}
fn sorted(&self, error: &ureq::Error) -> CallError {
let described = format!("{}: {error}", self.describe());
match error {
ureq::Error::StatusCode(401) => CallError::Unauthorized(Error::auth(described)),
ureq::Error::StatusCode(403) => CallError::Forbidden(Error::auth(described)),
_ => CallError::Other(Error::remote(described)),
}
}
fn json(
mut response: ureq::http::Response<ureq::Body>,
described: &str,
) -> Result<serde_json::Value, CallError> {
response.body_mut().read_json().map_err(|error| {
CallError::Other(Error::remote(format!(
"{described}: the response was not JSON: {error}"
)))
})
}
fn host(&self) -> String {
self.endpoint
.clone()
.unwrap_or_else(|| "https://firestore.googleapis.com".to_owned())
}
fn root(&self) -> String {
format!(
"projects/{}/databases/{}/documents",
self.project, self.database
)
}
fn url(&self, path: &str) -> String {
format!("{}/v1/{}/{path}", self.host(), self.root())
}
fn batch_url(&self) -> String {
format!("{}/v1/{}:batchGet", self.host(), self.root())
}
fn name_of(&self, path: &str) -> String {
format!("{}/{path}", self.root())
}
fn path_of(&self, name: &str) -> String {
let root = format!("{}/", self.root());
name.strip_prefix(&root).unwrap_or(name).to_owned()
}
fn agent(&self) -> Result<&ureq::Agent, Error> {
if let Some(agent) = &self.agent {
if self.tls.is_some() {
return Err(Error::remote(format!(
"{}: `with_agent` and `with_tls` were both called; \
an agent already carries its own TLS configuration, so \
this is refused rather than resolved — put the certificate \
authority on the agent, or drop the agent",
self.describe()
)));
}
return Ok(agent);
}
self.default_agent
.get_or_init(|| match &self.tls {
Some(tls) => tls::agent(tls, self.timeout, &self.describe())
.map_err(|error| error.to_string()),
None => Ok(ureq::Agent::config_builder()
.timeout_global(Some(self.timeout))
.build()
.new_agent()),
})
.as_ref()
.map_err(Error::remote)
}
}
impl std::fmt::Debug for Firestore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Firestore")
.field("project", &self.project)
.field("database", &self.database)
.field("keys", &self.keys)
.field("key", &self.key)
.field("endpoint", &self.endpoint)
.field("auth", &self.auth)
.finish_non_exhaustive()
}
}
impl RemoteSource for Firestore {
fn fetch(&self) -> Result<Fetched, Error> {
let documents = self.documents()?;
documents::merged(&documents, Format::Json, self.overlap(), &self.describe())
}
fn describe(&self) -> String {
match &self.endpoint {
Some(endpoint) => format!(
"firestore {endpoint} {}/{}",
self.project,
self.keys.describe()
),
None => format!("firestore {}/{}", self.project, self.keys.describe()),
}
}
}
fn trimmed(keys: Keys) -> Keys {
match keys {
Keys::One(path) => Keys::One(path.trim_matches('/').to_owned()),
Keys::Several(paths) => Keys::Several(
paths
.into_iter()
.map(|path| path.trim_matches('/').to_owned())
.collect(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_never_prints_a_credential() {
let source = Firestore::new("my-project", "config/db")
.with_auth(Auth::access_token("hunter2-access-token"));
let printed = format!(
"{source:?} {:?}",
Auth::access_token("hunter2-access-token")
);
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("AccessToken(***)"), "{printed}");
}
}