grabcli_service/lib.rs
1//! A service-shaped Grab (food delivery) client over grabcli's library
2//! crates, for a server or a bot that keeps many people's Grab accounts.
3//!
4//! grabcli's `grab` binary keeps one account in files; a service keeps many,
5//! in its own storage. This crate is the part such a service needs that is
6//! not specific to it:
7//!
8//! - [`api`]: plain, serde-able types ([`Merchant`], [`Menu`], [`Cart`],
9//! [`Quote`], [`Placed`], [`Tracked`]), the per-account [`GrabClient`],
10//! the [`LoginDriver`] and the [`GrabBackend`] that makes both, and
11//! [`money`]. A session is opaque here (grabcli's serde `Session` as
12//! JSON), read and written only through a [`SessionSlot`] the service
13//! implements over its storage.
14//! - [`real`]: the backend over `grabcli-core` and `grabfood`.
15//! - [`fake`]: a backend with no network, for tests and local development.
16//! - [`login`]: many phone logins at once, each on its own thread, with a
17//! per-account send budget; keyed by plain strings (a `person` who
18//! started it, an `org`: the account it connects).
19//! - [`text`]: the answers as plain text (for a chat reply or a model).
20//!
21//! Nothing here reads files, environment variables or the network on its
22//! own, and nothing is printed: diagnostics go to an optional [`Log`] hook.
23//! Phone numbers and codes are held in [`Secret`]s and never logged.
24//!
25//! ```
26//! use std::sync::Arc;
27//! use grabcli_service::{CartOp, GrabBackend, MemorySlot, fake::Fake, text};
28//!
29//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
30//! let rt = tokio::runtime::Runtime::new()?;
31//! let backend = Fake::default();
32//! let slot = Arc::new(MemorySlot::new("acct-1", Some(Fake::session("Sample"))));
33//! let client = backend.client(slot);
34//! rt.block_on(async {
35//! let found = client.search("ramen", 5).await?;
36//! println!("{}", text::merchants(&found));
37//! let menu = client.menu(&found[0].id).await?;
38//! client
39//! .cart(&CartOp::Add {
40//! merchant_id: menu.merchant.id.clone(),
41//! item_id: menu.items[0].id.clone(),
42//! quantity: 1,
43//! note: None,
44//! })
45//! .await?;
46//! println!("{}", text::quote(&client.quote().await?));
47//! Ok::<_, grabcli_service::GrabError>(())
48//! })?;
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! Placing a real order still needs grabcli's own gate: `allow_ordering` in
54//! the [`grabcli_core::Config`] given to [`real::Real::new`], and a quote id
55//! that matches a fresh re-quote. Never retry [`GrabClient::place_order`].
56
57#![warn(missing_docs)]
58
59extern crate alloc;
60
61pub mod api;
62pub mod fake;
63pub mod login;
64pub mod real;
65pub mod text;
66mod util;
67
68use alloc::sync::Arc;
69use core::fmt;
70use std::sync::Mutex;
71
72use serde_json::Value;
73
74pub use self::api::{
75 Cart, CartOp, Fee, GrabBackend, GrabClient, GrabError, GrabResult, Health, Line, LoginDriver,
76 Menu, MenuItem, Merchant, Op, Placed, Quote, SessionSlot, Step, Tracked, money,
77};
78pub use self::login::{LoggedIn, LoginError, Logins, OnDone, Snapshot, Timing, View};
79
80/// A diagnostics hook (a login abandoned, a cart that couldn't be emptied):
81/// lines without secrets, for the embedder's log. None: dropped.
82pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
83
84/// A secret string (a phone number, a login code): `Debug` never shows it.
85#[derive(Clone, Default, PartialEq, Eq)]
86pub struct Secret(String);
87
88impl Secret {
89 /// Wrap `s`.
90 pub fn new(s: impl Into<String>) -> Self {
91 Self(s.into())
92 }
93
94 /// The value, for Grab's form; never for a log.
95 pub fn expose(&self) -> &str {
96 &self.0
97 }
98
99 /// Whether it is empty.
100 pub fn is_empty(&self) -> bool {
101 self.0.is_empty()
102 }
103
104 /// `text` with the value replaced (an error body that echoes it).
105 pub fn redact(&self, text: &str) -> String {
106 if self.0.is_empty() {
107 text.to_owned()
108 } else {
109 text.replace(&self.0, "<redacted>")
110 }
111 }
112}
113
114impl fmt::Debug for Secret {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str("<redacted>")
117 }
118}
119
120/// Stands in for a secret in a `Debug` (`<redacted>`).
121pub(crate) struct Redacted;
122
123impl fmt::Debug for Redacted {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str("<redacted>")
126 }
127}
128
129/// A [`SessionSlot`] in memory (tests, examples, a single-process tool).
130/// Its `Debug` shows only the account id and whether a session and a cart
131/// are kept.
132#[derive(Default)]
133pub struct MemorySlot {
134 id: String,
135 session: Mutex<Option<Value>>,
136 cart: Mutex<Option<Value>>,
137}
138
139impl fmt::Debug for MemorySlot {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 let held = |m: &Mutex<Option<Value>>| util::lock(m).as_ref().map(|_| Redacted);
142 f.debug_struct("MemorySlot")
143 .field("id", &self.id)
144 .field("session", &held(&self.session))
145 .field("cart", &held(&self.cart))
146 .finish()
147 }
148}
149
150impl MemorySlot {
151 /// A slot for account `id` holding `session`.
152 pub fn new(id: impl Into<String>, session: Option<Value>) -> Self {
153 Self {
154 id: id.into(),
155 session: Mutex::new(session),
156 cart: Mutex::default(),
157 }
158 }
159
160 /// The session now (as the backend last saved it).
161 pub fn session(&self) -> Option<Value> {
162 util::lock(&self.session).clone()
163 }
164
165 /// The kept cart now.
166 pub fn cart(&self) -> Option<Value> {
167 util::lock(&self.cart).clone()
168 }
169}
170
171#[async_trait::async_trait]
172impl SessionSlot for MemorySlot {
173 fn id(&self) -> &str {
174 &self.id
175 }
176
177 async fn load(&self) -> GrabResult<Option<Value>> {
178 Ok(self.session())
179 }
180
181 async fn save(&self, session: &Value) -> GrabResult<()> {
182 *util::lock(&self.session) = Some(session.clone());
183 Ok(())
184 }
185
186 async fn load_cart(&self) -> GrabResult<Option<Value>> {
187 Ok(self.cart())
188 }
189
190 async fn save_cart(&self, cart: Option<&Value>) -> GrabResult<()> {
191 *util::lock(&self.cart) = cart.cloned();
192 Ok(())
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn a_secret_never_debugs() {
202 let s = Secret::new("+10000000000");
203 assert_eq!(format!("{s:?}"), "<redacted>");
204 assert_eq!(s.redact("got +10000000000"), "got <redacted>");
205 assert!(Secret::default().is_empty());
206 }
207}
208
209/// Package README examples, compiled as doctests.
210#[cfg(doctest)]
211#[doc = include_str!("../README.md")]
212struct ReadmeDoctests;