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}