Skip to main content

dynamic_config_firestore/
lib.rs

1//! Read [`dynamic-config`] configuration from a Firestore document.
2//!
3//! Firestore's REST API is plain HTTP, so this implements the **blocking**
4//! [`RemoteSource`] trait: nothing here needs an async runtime, and neither
5//! does using it.
6//!
7//! ```no_run
8//! use dynamic_config_firestore::{Auth, Firestore};
9//!
10//! # struct DbConfig;
11//! # impl DbConfig {
12//! #     fn set_remote(_: Firestore) {}
13//! #     fn refresh_remote() -> Result<(), dynamic_config::Error> { Ok(()) }
14//! # }
15//! DbConfig::set_remote(
16//!     Firestore::new("my-project", "config/db")
17//!         // On GKE, Cloud Run or GCE, the workload's own identity.
18//!         .with_auth(Auth::metadata_server()),
19//! );
20//!
21//! // Fetching is explicit; the load that follows touches no network.
22//! DbConfig::refresh_remote()?;
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25//!
26//! # What it reads
27//!
28//! One document, at a path like `config/db` — collection, then document. Its
29//! fields become the configuration, wrapped under the section key, which is the
30//! same shape [`dynamic-config-vault`] uses and for the same reason: Firestore
31//! stores a map of named fields, so the natural unit is the field.
32//!
33//! Firestore types map onto configuration the obvious way — `stringValue`,
34//! `integerValue`, `booleanValue`, `doubleValue`, `arrayValue`, `mapValue`. A
35//! `timestampValue`, `bytesValue` or `referenceValue` becomes its string form,
36//! because a configuration file has no better answer for one either.
37//!
38//! # Several documents as one section
39//!
40//! One section can be split across several documents, and [`Keys`] says which:
41//!
42//! ```no_run
43//! # use dynamic_config_firestore::{Firestore, Keys};
44//! // Merged in the order given — later wins — and all under the one section key.
45//! let firestore = Firestore::new("my-project", Keys::several(["config/db", "overrides/db"]));
46//! ```
47//!
48//! **A named list is one request**, and that is Firestore's own answer rather
49//! than a loop wearing a batch's name: `:batchGet` takes the documents the
50//! caller named and returns them together. Two things follow from what the API
51//! actually promises:
52//!
53//! - **The answer arrives in whatever order the service likes** — `BatchGet`
54//!   says so explicitly — so it is put back into call order here. The order a
55//!   caller wrote is the precedence; the order a service replies in is not.
56//! - **One request is not one snapshot.** Without a transaction each document
57//!   is read at its own time, and this asks for none: an open read-only
58//!   transaction is state on the service that a configuration read would have
59//!   to remember to release. So a write landing mid-request can still produce
60//!   a section that never existed as a whole. One round trip is the win;
61//!   atomicity is not.
62//!
63//! **Every document lands under the same section key**, because that is what a
64//! Firestore document is here: the contents of a section, not a whole
65//! configuration file. So a list is layering — a shared document and an
66//! override.
67//!
68//! **There is deliberately no collection form.** `documents.list` exists, so
69//! the missing piece is not the protocol; it is the mapping. Folding a whole
70//! collection into one section makes `config/db` and `config/server` collide
71//! on `host` — the ordinary layout, refused — and naming a sub-section after
72//! each document's id would invent a convention no other store here has, and
73//! would make a list of one document mean something different from one
74//! document. A deployment that wants several sections installs one source per
75//! section, which is what it did before.
76//!
77//! Two consequences the multi-document form shares with the rest of the
78//! family:
79//!
80//! - **Provenance becomes store-grained.** The merged section is one layer, so
81//!   `source_of` names the set rather than which document supplied a value.
82//! - **One unreadable document fails the whole fetch.** A section quietly
83//!   missing half of itself is worse than a refresh that failed and left the
84//!   last document serving.
85//!
86//! A **multi-document source cannot be watched**, and refuses at
87//! [`watch`](Firestore::watch) rather than pretending to: the `updateTime` it
88//! compares belongs to one document, and a set of them has none of its own.
89//!
90//! # Authenticating
91//!
92//! | Method | Constructor | For |
93//! |---|---|---|
94//! | Workload identity | [`Auth::metadata_server`] | GKE, Cloud Run, GCE — no secret to distribute |
95//! | An access token | [`Auth::access_token`] | anything that already has one, including `gcloud auth print-access-token` |
96//! | None | [`Auth::Emulator`] | the Firestore emulator, which wants no credentials |
97//!
98//! **A service-account JSON key is deliberately not supported**, and that is a
99//! recommendation rather than a gap: signing one means an RS256 stack in a
100//! configuration library, and Google's own guidance is that a downloaded key is
101//! the option of last resort. Workload identity covers GKE, Cloud Run, GCE and
102//! Cloud Functions; for anything else, mint a token outside the process and
103//! hand it over with [`Auth::access_token`].
104//!
105//!
106//! # Every failure branch of the watch loop, and what it reports
107//!
108//! A watch is the half of a store `dynamic-config` cannot see, and
109//! [`reporting_to`](Firestore::reporting_to) is what lets it speak: the sink the
110//! loop already holds is told about every attempt that came back with
111//! nothing. Which attempts those are is a table rather than prose, because
112//! the question an operator asks is *which* silence is deliberate.
113//!
114//! Three rules decide the column, and they are the same three in all seven
115//! store crates:
116//!
117//! 1. **A failure the loop survives by retrying reports.** That is the case
118//!    the whole feature exists for: the stream is down, the last delivery is
119//!    old, and nothing else would ever say so out loud.
120//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
121//!    clears the streak, so reporting a five-minute token turning over on a
122//!    healthy cluster would drive `remote_up` to zero and leave it there.
123//! 3. **A refusal that never asked the store reports nowhere.** No format, a
124//!    key shape that cannot be watched, material that will not build a
125//!    client: `RemoteStatus::reachable()` is *whether the store answered the
126//!    last time it was asked*, and these never ask. They are returned to the
127//!    caller, who is the one holding the mistake — and a status cannot
128//!    correct them, since it carries a kind and a path and no message.
129//!
130//! | Branch | Reports |
131//! |---|---|
132//! | the source reads several documents, so it cannot be watched | no — rule 3: nothing has been asked of Firestore |
133//! | the read fails — a blip, an expired token, a document briefly unreachable | **yes**, and the loop waits out the interval |
134//! | the document has no `updateTime`, so a change could never be detected | **yes**, and the watch ends |
135//! | the first read, or an update time that has not moved | no — Firestore answered |
136//! | `on_change` refuses the document | no — Firestore answered; `apply` counted the delivery, and what the document did next is `ConfigStatus`'s half |
137//!
138//! [`dynamic-config`]: https://docs.rs/dynamic-config
139//! [`dynamic-config-vault`]: https://docs.rs/dynamic-config-vault
140
141#![forbid(unsafe_code)]
142#![deny(missing_docs)]
143#![cfg_attr(docsrs, feature(doc_cfg))]
144
145mod auth;
146mod tls;
147mod value;
148
149use std::time::Duration;
150
151use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
152use dynamic_config_store_core::attempts::Attempts;
153use dynamic_config_store_core::documents::{self, Overlap};
154use dynamic_config_store_core::guarded;
155
156pub use auth::Auth;
157
158/// A private certificate authority and a client certificate, as data.
159///
160/// The shared vocabulary all seven store crates take, so that reaching TLS
161/// never means naming `ureq`'s types — see [`with_tls`](Firestore::with_tls).
162pub use dynamic_config_store_core::tls::TlsConfig;
163
164/// How long to wait for Firestore before giving up.
165const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
166
167/// What a source reads: one document, or several named ones.
168///
169/// Every constructor takes one, and a bare `&str` or `String` is
170/// [`Keys::one`] — so the single-document spelling every caller already wrote
171/// keeps working unchanged.
172///
173/// There is no collection variant, and that is a decision rather than an
174/// omission: a Firestore document is a section's *contents*, so a whole
175/// collection folded into one section collides on every field name two
176/// documents share. The crate documentation says the whole of it.
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub enum Keys {
179    /// One document, whose fields are the whole section.
180    One(String),
181    /// Several named documents, merged **in the order given — later wins**.
182    ///
183    /// The rule a list of `.file(..)` calls already teaches: the caller wrote
184    /// the list, so the list is the precedence. One `:batchGet` request for
185    /// the set, put back into call order — the service answers in whatever
186    /// order it likes, and says so.
187    Several(Vec<String>),
188}
189
190impl Keys {
191    /// One document, whose fields are the whole section.
192    #[must_use]
193    pub fn one(path: impl Into<String>) -> Self {
194        Self::One(path.into())
195    }
196
197    /// Several named documents, merged in the order given — later wins.
198    #[must_use]
199    pub fn several<I, S>(paths: I) -> Self
200    where
201        I: IntoIterator<Item = S>,
202        S: Into<String>,
203    {
204        Self::Several(paths.into_iter().map(Into::into).collect())
205    }
206
207    /// How a diagnostic names what this source reads.
208    ///
209    /// One document renders as the path itself, so every message a
210    /// single-document source has ever produced is unchanged.
211    fn describe(&self) -> String {
212        match self {
213            Self::One(path) => path.clone(),
214            Self::Several(paths) => format!("documents {}", paths.join(", ")),
215        }
216    }
217}
218
219impl From<&str> for Keys {
220    fn from(path: &str) -> Self {
221        Self::one(path)
222    }
223}
224
225impl From<String> for Keys {
226    fn from(path: String) -> Self {
227        Self::One(path)
228    }
229}
230
231impl From<&String> for Keys {
232    fn from(path: &String) -> Self {
233        Self::one(path)
234    }
235}
236
237/// A failed call, sorted by what a caller can do about it.
238///
239/// Sorted on `ureq`'s *typed* status, before anything becomes a string: an
240/// error message mentioning a path like `config/401` must not read as an
241/// expired token.
242enum CallError {
243    /// Firestore said 401: the token is the problem, and a fresh one might be
244    /// the cure.
245    Unauthorized(Error),
246    /// Firestore said 403: the token was accepted and the identity behind it
247    /// is not allowed to read this document. Minting another token is the
248    /// same identity, so there is nothing to retry — but it is still an auth
249    /// failure to the caller, and one that fixing the IAM binding cures.
250    ///
251    /// Google's REST mapping is what makes this safe to name: `PERMISSION_DENIED`
252    /// is the only thing that becomes a 403 here, because exhausted quota
253    /// becomes a 429 and a missing credential a 401.
254    Forbidden(Error),
255    /// Everything else — network, timeouts, 500s. A new token fixes none of
256    /// it, and any of it may fix itself.
257    Other(Error),
258}
259
260impl CallError {
261    fn into_error(self) -> Error {
262        match self {
263            Self::Unauthorized(error) | Self::Forbidden(error) | Self::Other(error) => error,
264        }
265    }
266}
267
268/// A document in Firestore, as a configuration source.
269///
270/// Not `Clone`: it holds the session that caches an access token, and two
271/// clones fetching tokens separately would double the traffic.
272pub struct Firestore {
273    project: String,
274    database: String,
275    keys: Keys,
276    key: String,
277    auth: Auth,
278    session: auth::Session,
279    endpoint: Option<String>,
280    timeout: Duration,
281    agent: Option<ureq::Agent>,
282    /// What [`with_tls`](Firestore::with_tls) was given, translated into an
283    /// agent on first use.
284    tls: Option<TlsConfig>,
285    /// The fallback client, built once. A fresh agent per request would mean
286    /// a fresh connection pool per request — a TLS handshake per poll tick.
287    ///
288    /// A `Result`, because building it can now fail: a CA file that is not
289    /// there is discovered when the client is built, and the first request is
290    /// where a caller can be told. Cached either way, so a bad path does not
291    /// re-read a missing file once per poll tick. The failure is kept as its
292    /// message because `Error` is deliberately not `Clone`; every failure this
293    /// can hold is a `remote` one, so re-wrapping loses nothing.
294    default_agent: std::sync::OnceLock<Result<ureq::Agent, String>>,
295    /// Where [`watch`](Firestore::watch) reports a tick that came back with
296    /// nothing; see [`reporting_to`](Firestore::reporting_to). Nobody, by
297    /// default, which is what makes reporting free for a caller who never
298    /// asked for it.
299    attempts: Attempts,
300}
301
302impl Firestore {
303    /// The document at `path` in `project`'s default database.
304    ///
305    /// `path` is collection-then-document — `config/db`, or
306    /// `environments/prod/config/db` for a nested one — or a [`Keys`], for the
307    /// several-documents form.
308    ///
309    /// The document is wrapped under the section key the configuration type
310    /// uses, `"db"` by default; change it with [`with_key`](Self::with_key).
311    /// Several documents all land under that one key and merge, later winning.
312    #[must_use]
313    pub fn new(project: impl Into<String>, path: impl Into<Keys>) -> Self {
314        Self {
315            project: project.into(),
316            database: "(default)".to_owned(),
317            keys: trimmed(path.into()),
318            key: "db".to_owned(),
319            auth: Auth::Emulator,
320            session: auth::Session::new(),
321            endpoint: None,
322            timeout: DEFAULT_TIMEOUT,
323            agent: None,
324            tls: None,
325            default_agent: std::sync::OnceLock::new(),
326            attempts: Attempts::default(),
327        }
328    }
329
330    /// The section key to wrap the document under.
331    ///
332    /// Must match the key the config type's `builder(..)` was given.
333    #[must_use]
334    pub fn with_key(mut self, key: impl Into<String>) -> Self {
335        self.key = key.into();
336        self
337    }
338
339    /// A database other than `(default)`.
340    #[must_use]
341    pub fn with_database(mut self, database: impl Into<String>) -> Self {
342        self.database = database.into();
343        self
344    }
345
346    /// How to obtain an access token.
347    ///
348    /// Defaults to [`Auth::Emulator`], which sends none — right for the
349    /// emulator and wrong for anything else, so a real deployment always names
350    /// one.
351    #[must_use]
352    pub fn with_auth(mut self, auth: Auth) -> Self {
353        self.auth = auth;
354        self.session.invalidate();
355        self
356    }
357
358    /// A different API endpoint.
359    ///
360    /// What the Firestore emulator needs: `FIRESTORE_EMULATOR_HOST` is
361    /// `127.0.0.1:8080`, and this takes `http://127.0.0.1:8080`.
362    #[must_use]
363    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
364        self.endpoint = Some(endpoint.into().trim_end_matches('/').to_owned());
365        self
366    }
367
368    /// How long a single fetch may take before it is given up on. Ten seconds
369    /// by default.
370    ///
371    /// The deadline for **one fetch attempt**, excluding retries the
372    /// underlying client performs — the same sentence every store in this
373    /// family answers to. `ureq` performs none of its own, so here the
374    /// deadline is the whole story.
375    ///
376    /// It covers fetching a token from the metadata server too: that request
377    /// goes through the same client, and a token fetch that hangs stalls the
378    /// read behind it.
379    #[must_use]
380    pub fn with_timeout(mut self, timeout: Duration) -> Self {
381        self.timeout = timeout;
382        // The cached fallback client baked in the old timeout.
383        self.default_agent = std::sync::OnceLock::new();
384        self
385    }
386
387    /// Uses an HTTP client the program already has.
388    ///
389    /// The escape hatch, and it stays one: [`with_tls`](Self::with_tls) covers
390    /// a private CA and a client certificate, and everything else — a proxy, a
391    /// connection pool, an option this crate has never heard of — still lives
392    /// here. Setting both is refused rather than resolved; see
393    /// [`with_tls`](Self::with_tls).
394    #[must_use]
395    pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
396        self.agent = Some(agent);
397        self
398    }
399
400    /// A private certificate authority, a client certificate, or both.
401    ///
402    /// The same three settings, spelled the same way, in all seven store
403    /// crates — and spelled as *data*, so nothing here names a `ureq` type:
404    ///
405    /// ```no_run
406    /// # use dynamic_config_firestore::{Firestore, TlsConfig};
407    /// let firestore = Firestore::new("my-project", "config/db")
408    ///     .with_endpoint("https://firestore.internal")
409    ///     .with_tls(TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem"));
410    /// ```
411    ///
412    /// Firestore expresses all of it: a CA from a file or from bytes, and a
413    /// client certificate from either. A CA replaces the platform trust store
414    /// rather than adding to it, so a deployment that needs both puts both in
415    /// the file.
416    ///
417    /// Against Google's own endpoint this is rarely what you want — their
418    /// certificates chain to a public authority the platform already trusts.
419    /// It is for the deployments that do not go there directly: an enterprise
420    /// TLS-inspecting proxy, or an emulator behind
421    /// [`with_endpoint`](Self::with_endpoint) with a certificate of its own.
422    ///
423    /// There is no way to turn verification off; [`TlsConfig`]'s own
424    /// documentation argues that one.
425    ///
426    /// **Nothing is read here.** The files are opened when the first request
427    /// builds the client, so a missing CA is an error naming the path.
428    ///
429    /// # With `with_agent`
430    ///
431    /// Setting both is **refused**, at the first request, naming both calls.
432    /// An agent already carries a complete TLS configuration, so "apply this
433    /// too" has no meaning that is not a guess — and the guess that loses
434    /// silently discards a CA.
435    #[must_use]
436    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
437        self.tls = Some(tls);
438        // The cached fallback client baked in the old configuration.
439        self.default_agent = std::sync::OnceLock::new();
440        self
441    }
442
443    /// Reports the watch loop's *failed* attempts to `sink`.
444    ///
445    /// A watch loop is the half of a store `dynamic-config` cannot otherwise
446    /// see. [`RemoteSink::apply`] records a delivery, so a working watch keeps
447    /// [`RemoteStatus`] current — but a loop whose poll is failing, whose
448    /// document was deleted or whose access token was refused delivers
449    /// nothing, and without this says nothing: `dynamic_config_remote_up`
450    /// would report the last *delivery* rather than the last *attempt*, and a
451    /// project that stopped answering an hour ago would look healthy until
452    /// something called `refresh_remote`.
453    ///
454    /// ```no_run
455    /// # use dynamic_config::Watching;
456    /// # use dynamic_config_firestore::{Auth, Firestore};
457    /// # use std::time::Duration;
458    /// # struct DbConfig;
459    /// # impl DbConfig {
460    /// #     fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
461    /// # }
462    /// # fn example(watching: Watching) -> Result<(), dynamic_config::Error> {
463    /// let sink = DbConfig::remote_sink();
464    ///
465    /// Firestore::new("my-project", "config/db")
466    ///     .with_auth(Auth::metadata_server())
467    ///     .reporting_to(sink)
468    ///     .watch(&watching, Duration::from_secs(30), move |document| sink.apply(document))
469    /// # }
470    /// ```
471    ///
472    /// One sink serves both halves, and it is taken **once, where the loop is
473    /// wired**: a sink is `Copy`, and the generation it captures there is what
474    /// fences a loop winding down after its source was replaced from charging
475    /// its failures to the replacement.
476    ///
477    /// A failure to report a failure never reaches the loop — reporting is
478    /// infallible and silent — and what it moves is deliberately narrow: the
479    /// failure streak and the last failure, never the fetch clock. So
480    /// `dynamic_config_remote_last_fetch_seconds` keeps ageing while
481    /// `dynamic_config_remote_up` goes to zero, which is the pair that says
482    /// both *the store is not answering* and *how stale what it last said has
483    /// become*.
484    ///
485    /// A [`fetch`](RemoteSource::fetch) needs none of this: a fetch records
486    /// itself, through the `Remote` that performed it.
487    ///
488    /// [`RemoteStatus`]: dynamic_config::RemoteStatus
489    #[must_use]
490    pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
491        self.attempts = Attempts::to(sink);
492        self
493    }
494
495    /// Calls `on_change` when the document's update time moves, checking every
496    /// `interval`.
497    ///
498    /// Firestore *can* push — the real-time API is a gRPC stream — and this
499    /// deliberately does not use it: that would put a gRPC stack in a crate
500    /// whose whole point is a plain HTTP read. Polling reads one small document
501    /// and compares `updateTime`, which for a configuration document checked
502    /// every thirty seconds is a rounding error against a project's quota.
503    ///
504    /// The current value is **not** delivered at startup, for the same reason a
505    /// file watcher does not report an edit when it starts.
506    ///
507    /// A failed check does not end the watch. `stop` is noticed within a
508    /// quarter second whatever `interval` is. Surviving a failure quietly is
509    /// not the same as hiding it: [`reporting_to`](Self::reporting_to) hands
510    /// each failed attempt to a [`RemoteSink`], so a loop that has been
511    /// failing for an hour stops reporting the store as healthy.
512    ///
513    /// # Errors
514    ///
515    /// If the document comes back without an `updateTime` — there is then
516    /// nothing to compare, so every tick would find "no change" and the watch
517    /// would silently never fire. Or if `on_change` returns an error, which
518    /// ends the watch. Transport failures do not surface here; they are
519    /// retried.
520    pub fn watch<F>(
521        &self,
522        watching: &Watching,
523        interval: Duration,
524        mut on_change: F,
525    ) -> Result<(), Error>
526    where
527        F: FnMut(Fetched) -> Result<(), Error>,
528    {
529        // Refused before the first tick, so a multi-document source fails at
530        // `watch` rather than never firing: the loop below treats a failed
531        // read as a blip worth waiting out, which is right for a network and
532        // wrong for a configuration mistake. Returned and recorded nowhere:
533        // nothing has been asked of Firestore yet, and `reachable()` is
534        // *whether the store answered the last time it was asked*.
535        self.single_path()?;
536
537        let mut seen: Option<String> = None;
538
539        while watching.keep_going() {
540            // A failed read — a blip, an expired token, a document briefly
541            // unreachable — does not reach the caller: that is what a watch
542            // exists to survive. It is still recorded, because a poll that
543            // has been failing since yesterday and a document that simply has
544            // not changed deliver the same nothing, and only this tells the
545            // two apart.
546            match self.read() {
547                Ok((document, updated)) => {
548                    // No `updateTime` means no way to ever detect a change:
549                    // every tick would compare nothing to nothing and find "no
550                    // change", and the watch would sit silent forever. A
551                    // server answering like that is misconfigured, and that is
552                    // reported, not waited out — and recorded on the way out,
553                    // since a watch that has ended is a configuration that has
554                    // stopped updating for good.
555                    let Some(updated) = updated else {
556                        let error = Error::remote(format!(
557                            "{}: the document has no `updateTime`, so changes cannot be detected; is this a real Firestore?",
558                            self.describe()
559                        ));
560
561                        self.attempts.failed(&error);
562
563                        return Err(error);
564                    };
565
566                    // The first read records the time without firing: the
567                    // document it names is the one the caller already has.
568                    if seen.is_none() {
569                        seen = Some(updated);
570                    } else if seen.as_deref() != Some(&*updated) {
571                        seen = Some(updated);
572
573                        // A callback that refuses is deliberately not a failed
574                        // attempt: the store answered and the document arrived.
575                        // What the callback then did with it is
576                        // `ConfigStatus`'s business, and `RemoteSink::apply`
577                        // already records it there.
578                        guarded(&mut on_change, document, &self.describe())?;
579                    }
580                }
581                Err(error) => self.attempts.failed(&error),
582            }
583
584            watching.sleep_for(interval);
585        }
586
587        Ok(())
588    }
589
590    /// The one document a watch follows, and the `updateTime` it was read at.
591    fn read(&self) -> Result<(Fetched, Option<String>), Error> {
592        let path = self.single_path()?;
593        let body = self.get(&self.url(path))?;
594
595        let (document, updated) = self.section_of(&body, path)?;
596
597        Ok((Fetched::new(document, Format::Json), updated))
598    }
599
600    /// One document's `fields`, wrapped under the section key, and its
601    /// `updateTime`.
602    ///
603    /// The wrapping happens per document rather than after the merge because
604    /// that is what makes the merge mean something: two documents supplying
605    /// `host` collide at `db.host`, which is the path a reader of the
606    /// configuration would name, not at a bare `host` that belongs to nothing.
607    fn section_of(
608        &self,
609        body: &serde_json::Value,
610        path: &str,
611    ) -> Result<(String, Option<String>), Error> {
612        let fields = body.get("fields").ok_or_else(|| {
613            Error::remote(format!(
614                "{}: `{path}` answered without `fields`; is that a document?",
615                self.describe()
616            ))
617        })?;
618
619        let values = value::to_json(fields);
620        let document = serde_json::json!({ &self.key: values });
621
622        let updated = body
623            .get("updateTime")
624            .and_then(serde_json::Value::as_str)
625            .map(str::to_owned);
626
627        Ok((document.to_string(), updated))
628    }
629
630    /// The one document this source reads, or an error saying it reads
631    /// several.
632    ///
633    /// A watch here compares `updateTime`, and that belongs to a document: a
634    /// set of them has none, and following one member's would fire on that
635    /// member and miss every other.
636    fn single_path(&self) -> Result<&str, Error> {
637        match &self.keys {
638            Keys::One(path) => Ok(path),
639            Keys::Several(_) => Err(Error::remote(format!(
640                "{}: a source that reads several documents cannot be watched; \
641                 poll `refresh_remote()` on a timer instead",
642                self.describe()
643            ))),
644        }
645    }
646
647    /// What two of this source's documents supplying one field means.
648    ///
649    /// Only [`Overlap::LaterWins`] here: a caller who wrote the list wrote the
650    /// precedence with it, and there is no collection form whose order nobody
651    /// chose.
652    fn overlap(&self) -> Overlap {
653        Overlap::LaterWins
654    }
655
656    /// The `(path, document)` pairs this source reads, in merge order.
657    fn documents(&self) -> Result<Vec<(String, String)>, Error> {
658        match &self.keys {
659            Keys::One(path) => {
660                let body = self.get(&self.url(path))?;
661
662                Ok(vec![(path.clone(), self.section_of(&body, path)?.0)])
663            }
664            Keys::Several(paths) => self.batch(paths),
665        }
666    }
667
668    /// Every named document, in one `:batchGet`, put back into call order.
669    ///
670    /// The reordering is not tidiness. `BatchGetDocuments` states that the
671    /// documents are not returned in the order they were asked for, and the
672    /// order the caller wrote *is* the precedence — so a merge in reply order
673    /// would make which value wins a property of the service's mood.
674    fn batch(&self, paths: &[String]) -> Result<Vec<(String, String)>, Error> {
675        let names: Vec<String> = paths.iter().map(|path| self.name_of(path)).collect();
676
677        let answered = self.post(
678            &self.batch_url(),
679            &serde_json::json!({ "documents": names }),
680        )?;
681
682        let entries = answered.as_array().ok_or_else(|| {
683            Error::remote(format!(
684                "{}: the batch response is not a list of results",
685                self.describe()
686            ))
687        })?;
688
689        // A server can answer with any number of results, in any order, for
690        // documents nobody asked about. Held by name and looked up afterwards,
691        // so every one of those is refused rather than merged.
692        let mut held: Vec<(String, String)> = Vec::with_capacity(entries.len());
693
694        for entry in entries {
695            if let Some(missing) = entry.get("missing").and_then(serde_json::Value::as_str) {
696                // Fail-whole, not merge-what-came-back: a section quietly
697                // missing half of itself is worse than a refresh that failed.
698                return Err(Error::remote(format!(
699                    "{}: `{}` holds no document",
700                    self.describe(),
701                    self.path_of(missing)
702                )));
703            }
704
705            let found = entry.get("found").ok_or_else(|| {
706                Error::remote(format!(
707                    "{}: a batch result is neither `found` nor `missing`",
708                    self.describe()
709                ))
710            })?;
711
712            let name = found
713                .get("name")
714                .and_then(serde_json::Value::as_str)
715                .ok_or_else(|| {
716                    Error::remote(format!(
717                        "{}: a batch result names no document",
718                        self.describe()
719                    ))
720                })?;
721
722            let path = self.path_of(name);
723
724            if !paths.contains(&path) {
725                return Err(Error::remote(format!(
726                    "{}: the store answered with `{path}`, which is not one of \
727                     the documents that were asked for",
728                    self.describe()
729                )));
730            }
731
732            if held.iter().any(|(held, _)| *held == path) {
733                return Err(Error::remote(format!(
734                    "{}: the store answered for `{path}` twice",
735                    self.describe()
736                )));
737            }
738
739            held.push((path.clone(), self.section_of(found, &path)?.0));
740        }
741
742        paths
743            .iter()
744            .map(|path| {
745                held.iter()
746                    .find(|(held, _)| held == path)
747                    .cloned()
748                    .ok_or_else(|| {
749                        Error::remote(format!(
750                            "{}: the store answered nothing at all about `{path}`",
751                            self.describe()
752                        ))
753                    })
754            })
755            .collect()
756    }
757
758    /// One GET, retried once if the token turned out to be dead.
759    fn get(&self, url: &str) -> Result<serde_json::Value, Error> {
760        match self.get_once(url) {
761            Err(CallError::Unauthorized(_)) if self.can_refresh() => {
762                // The proactive refresh should have caught an expiring token,
763                // but clocks skew. One fresh token and one retry — not a loop.
764                self.session.invalidate();
765
766                self.get_once(url).map_err(CallError::into_error)
767            }
768            outcome => outcome.map_err(CallError::into_error),
769        }
770    }
771
772    /// One POST, with the same one-fresh-token retry a GET gets.
773    fn post(&self, url: &str, body: &serde_json::Value) -> Result<serde_json::Value, Error> {
774        match self.post_once(url, body) {
775            Err(CallError::Unauthorized(_)) if self.can_refresh() => {
776                self.session.invalidate();
777
778                self.post_once(url, body).map_err(CallError::into_error)
779            }
780            outcome => outcome.map_err(CallError::into_error),
781        }
782    }
783
784    /// Whether a refused token can be traded for a fresh one.
785    ///
786    /// Only the metadata server can mint another: a supplied access token is
787    /// whatever it is, and the emulator sends none at all.
788    fn can_refresh(&self) -> bool {
789        matches!(self.auth, Auth::MetadataServer { .. })
790    }
791
792    fn get_once(&self, url: &str) -> Result<serde_json::Value, CallError> {
793        // A TLS configuration that will not build is not a refused token, so
794        // it must not become one: `Other` is what keeps a bad CA path out of
795        // the refresh-and-retry path it would otherwise loop through.
796        let agent = self.agent().map_err(CallError::Other)?;
797
798        let mut request = agent.get(url);
799
800        if let Some(token) = self.bearer()? {
801            request = request.header("Authorization", &format!("Bearer {token}"));
802        }
803
804        let response = request.call().map_err(|error| self.sorted(&error))?;
805
806        Self::json(response, &self.describe())
807    }
808
809    fn post_once(
810        &self,
811        url: &str,
812        body: &serde_json::Value,
813    ) -> Result<serde_json::Value, CallError> {
814        let agent = self.agent().map_err(CallError::Other)?;
815
816        let mut request = agent.post(url);
817
818        if let Some(token) = self.bearer()? {
819            request = request.header("Authorization", &format!("Bearer {token}"));
820        }
821
822        let response = request
823            .send_json(body)
824            .map_err(|error| self.sorted(&error))?;
825
826        Self::json(response, &self.describe())
827    }
828
829    /// The access token to present, if this method has one.
830    fn bearer(&self) -> Result<Option<String>, CallError> {
831        let agent = self.agent().map_err(CallError::Other)?;
832
833        self.session
834            .token(&self.auth, agent)
835            .map_err(CallError::Other)
836    }
837
838    /// Sorts one of `ureq`'s failures into a kind.
839    ///
840    /// Only the typed status decides it. `{error}` here is `ureq`'s own
841    /// rendering of the status — never the request, so neither the
842    /// `Authorization` header nor a request body can ride along.
843    fn sorted(&self, error: &ureq::Error) -> CallError {
844        let described = format!("{}: {error}", self.describe());
845
846        match error {
847            ureq::Error::StatusCode(401) => CallError::Unauthorized(Error::auth(described)),
848            ureq::Error::StatusCode(403) => CallError::Forbidden(Error::auth(described)),
849            _ => CallError::Other(Error::remote(described)),
850        }
851    }
852
853    /// The response body, as JSON.
854    fn json(
855        mut response: ureq::http::Response<ureq::Body>,
856        described: &str,
857    ) -> Result<serde_json::Value, CallError> {
858        response.body_mut().read_json().map_err(|error| {
859            CallError::Other(Error::remote(format!(
860                "{described}: the response was not JSON: {error}"
861            )))
862        })
863    }
864
865    /// The API host: the emulator's if one was named, otherwise Google's.
866    fn host(&self) -> String {
867        self.endpoint
868            .clone()
869            .unwrap_or_else(|| "https://firestore.googleapis.com".to_owned())
870    }
871
872    /// Where the documents of this database live.
873    fn root(&self) -> String {
874        format!(
875            "projects/{}/databases/{}/documents",
876            self.project, self.database
877        )
878    }
879
880    fn url(&self, path: &str) -> String {
881        format!("{}/v1/{}/{path}", self.host(), self.root())
882    }
883
884    fn batch_url(&self) -> String {
885        format!("{}/v1/{}:batchGet", self.host(), self.root())
886    }
887
888    /// The full resource name `:batchGet` asks for.
889    fn name_of(&self, path: &str) -> String {
890        format!("{}/{path}", self.root())
891    }
892
893    /// The path inside the database, from a full resource name.
894    ///
895    /// A name that is not under this database's documents is returned whole,
896    /// so the caller compares it against what was asked for and refuses it —
897    /// rather than being silently trimmed into something that matches.
898    fn path_of(&self, name: &str) -> String {
899        let root = format!("{}/", self.root());
900
901        name.strip_prefix(&root).unwrap_or(name).to_owned()
902    }
903
904    /// The HTTP client: the caller's if they supplied one, otherwise ours.
905    ///
906    /// Ours is built once and kept: an agent owns a connection pool and a TLS
907    /// session cache, and rebuilding it per request would pay a handshake per
908    /// poll tick.
909    fn agent(&self) -> Result<&ureq::Agent, Error> {
910        if let Some(agent) = &self.agent {
911            // Refused rather than resolved: an agent is already a complete TLS
912            // configuration, so applying a second one on top would mean
913            // silently dropping one of them, and the one that would be dropped
914            // is a CA the caller believes is pinned.
915            if self.tls.is_some() {
916                return Err(Error::remote(format!(
917                    "{}: `with_agent` and `with_tls` were both called; \
918                     an agent already carries its own TLS configuration, so \
919                     this is refused rather than resolved — put the certificate \
920                     authority on the agent, or drop the agent",
921                    self.describe()
922                )));
923            }
924
925            return Ok(agent);
926        }
927
928        self.default_agent
929            .get_or_init(|| match &self.tls {
930                Some(tls) => tls::agent(tls, self.timeout, &self.describe())
931                    .map_err(|error| error.to_string()),
932                None => Ok(ureq::Agent::config_builder()
933                    .timeout_global(Some(self.timeout))
934                    .build()
935                    .new_agent()),
936            })
937            .as_ref()
938            .map_err(Error::remote)
939    }
940}
941
942// Hand-written, never derived: a derive would print every field, and the
943// fields include credentials. `{:?}` reaching a log is an ordinary accident —
944// a `dbg!`, a `tracing::debug!(?source)` — and an accident must not disclose
945// a secret. The other store crates follow the same rule.
946impl std::fmt::Debug for Firestore {
947    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
948        f.debug_struct("Firestore")
949            .field("project", &self.project)
950            .field("database", &self.database)
951            .field("keys", &self.keys)
952            .field("key", &self.key)
953            .field("endpoint", &self.endpoint)
954            .field("auth", &self.auth)
955            .finish_non_exhaustive()
956    }
957}
958
959impl RemoteSource for Firestore {
960    fn fetch(&self) -> Result<Fetched, Error> {
961        let documents = self.documents()?;
962
963        // Already put back into call order, which is the order the rule wants
964        // — so nothing is sorted here.
965        documents::merged(&documents, Format::Json, self.overlap(), &self.describe())
966    }
967
968    fn describe(&self) -> String {
969        // The endpoint tells the emulator apart from the real service — the
970        // question an error actually raises. The auth method is not part of
971        // *where*, so it no longer rides along.
972        match &self.endpoint {
973            Some(endpoint) => format!(
974                "firestore {endpoint} {}/{}",
975                self.project,
976                self.keys.describe()
977            ),
978            None => format!("firestore {}/{}", self.project, self.keys.describe()),
979        }
980    }
981}
982
983/// Every path with its slashes trimmed, the way one path always was.
984///
985/// A leading or trailing slash in a document path produces a URL with a double
986/// slash in it, which Firestore answers with a 404 about a document nobody
987/// meant to ask for.
988fn trimmed(keys: Keys) -> Keys {
989    match keys {
990        Keys::One(path) => Keys::One(path.trim_matches('/').to_owned()),
991        Keys::Several(paths) => Keys::Several(
992            paths
993                .into_iter()
994                .map(|path| path.trim_matches('/').to_owned())
995                .collect(),
996        ),
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn debug_never_prints_a_credential() {
1006        let source = Firestore::new("my-project", "config/db")
1007            .with_auth(Auth::access_token("hunter2-access-token"));
1008
1009        let printed = format!(
1010            "{source:?} {:?}",
1011            Auth::access_token("hunter2-access-token")
1012        );
1013
1014        assert!(!printed.contains("hunter2"), "{printed}");
1015        assert!(printed.contains("AccessToken(***)"), "{printed}");
1016    }
1017}