grabcli_service/api.rs
1//! What a service asks of Grab: the per-account client ([`GrabClient`]:
2//! search, menu, cart, quote, order, tracking, the refresh and the liveness
3//! check), the login driver ([`LoginDriver`]) and the backend that makes
4//! both ([`GrabBackend`]: grabcli's library, or the fake). The session
5//! itself is opaque here (grabcli's serde `Session` as JSON), read and
6//! written only through a [`SessionSlot`]: wherever the service keeps it.
7
8use alloc::sync::Arc;
9use core::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::Secret;
15
16/// A cart change.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum CartOp {
19 /// The cart as it is.
20 Show,
21 /// Add `quantity` of an item (the cart holds one merchant at a time).
22 Add {
23 /// The merchant's id (from a search).
24 merchant_id: String,
25 /// The item's id (from the merchant's menu).
26 item_id: String,
27 /// How many.
28 quantity: u32,
29 /// A note for the merchant ("no onions").
30 note: Option<String>,
31 },
32 /// Drop an item's line.
33 Remove {
34 /// The item's id.
35 item_id: String,
36 },
37 /// Empty it.
38 Clear,
39}
40
41/// One call a service's caller (a tool, a command) asks for, checked.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Op {
44 /// Restaurants matching `query`.
45 Search {
46 /// What to look for.
47 query: String,
48 /// At most this many.
49 limit: usize,
50 },
51 /// A merchant's menu, optionally only the items matching `query`.
52 Menu {
53 /// The merchant's id.
54 merchant_id: String,
55 /// A filter on the items' name, category and description.
56 query: Option<String>,
57 },
58 /// A cart change.
59 Cart(CartOp),
60 /// Price the cart now.
61 Quote,
62 /// Ongoing orders (or one).
63 Track {
64 /// One order (none: the ongoing ones).
65 order_id: Option<String>,
66 },
67 /// Place the cart's order against a fresh matching quote.
68 PlaceOrder {
69 /// The quote id the person agreed to.
70 quote_id: String,
71 },
72}
73
74/// Why a Grab call failed.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76pub enum GrabError {
77 /// The session is dead (its SSO refused): the account logs in again.
78 #[error("Grab needs re-login: {0}")]
79 NeedsRelogin(String),
80 /// Anything else (Grab's answer, the network, a bad argument).
81 #[error("{0}")]
82 Failed(String),
83}
84
85/// A Grab call's result.
86pub type GrabResult<T> = Result<T, GrabError>;
87
88/// A restaurant from a search.
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90pub struct Merchant {
91 /// Grab's merchant id.
92 pub id: String,
93 /// Its name.
94 pub name: String,
95 /// Its cuisines, comma-separated.
96 #[serde(default)]
97 pub cuisine: String,
98 /// Delivery estimate, minutes.
99 pub eta_min: Option<u32>,
100 /// How far, km.
101 pub distance_km: Option<f64>,
102 /// Its rating (out of 5).
103 pub rating: Option<f64>,
104 /// A promo line ("20% off"), if any.
105 #[serde(default)]
106 pub promo: String,
107 /// Whether it takes orders now.
108 #[serde(default)]
109 pub open: bool,
110}
111
112/// One menu item.
113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
114pub struct MenuItem {
115 /// The item's id (for [`CartOp::Add`]).
116 pub id: String,
117 /// Its name.
118 pub name: String,
119 /// Its menu section.
120 #[serde(default)]
121 pub category: String,
122 /// Minor units (centavos).
123 pub price: i64,
124 /// Its description.
125 #[serde(default)]
126 pub description: String,
127 /// Whether it can be ordered now.
128 #[serde(default)]
129 pub available: bool,
130}
131
132/// A merchant's menu.
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
134pub struct Menu {
135 /// Whose menu.
136 pub merchant: Merchant,
137 /// ISO code of the prices (`PHP`).
138 pub currency: String,
139 /// The items.
140 pub items: Vec<MenuItem>,
141}
142
143/// A line of the cart or a quote.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Line {
146 /// The item's id.
147 pub item_id: String,
148 /// The item's name.
149 pub name: String,
150 /// How many.
151 pub quantity: u32,
152 /// The line's total, minor units.
153 pub amount: i64,
154 /// The note for the merchant.
155 #[serde(default)]
156 pub note: String,
157}
158
159/// The cart (one merchant at a time).
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct Cart {
162 /// The merchant's id (empty: an empty cart).
163 pub merchant_id: String,
164 /// The merchant's name.
165 pub merchant_name: String,
166 /// ISO code (`PHP`).
167 pub currency: String,
168 /// The lines.
169 pub lines: Vec<Line>,
170 /// The lines' sum, minor units.
171 pub subtotal: i64,
172}
173
174/// A fee of a quote.
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
176pub struct Fee {
177 /// What it is ("Delivery").
178 pub label: String,
179 /// Minor units.
180 pub amount: i64,
181}
182
183/// A priced checkout of the cart: what an order would cost now.
184#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Quote {
186 /// Grab's id for this exact checkout (items, fees, voucher, address):
187 /// an order is placed only against a fresh re-quote with the same id.
188 pub quote_id: String,
189 /// The merchant's name.
190 pub merchant_name: String,
191 /// ISO code (`PHP`).
192 pub currency: String,
193 /// The lines.
194 pub lines: Vec<Line>,
195 /// Before fees and discounts, minor units.
196 pub subtotal: i64,
197 /// Delivery and other fees.
198 pub fees: Vec<Fee>,
199 /// Off the total (positive), with the voucher that gives it.
200 pub discount: i64,
201 /// The voucher(s) behind the discount.
202 #[serde(default)]
203 pub voucher: String,
204 /// What would be paid, minor units.
205 pub total: i64,
206 /// Delivery estimate, minutes.
207 pub eta_min: Option<u32>,
208 /// How it would be paid (Grab's payment type).
209 pub payment_method: String,
210 /// Where it goes, as a label only ("Home").
211 pub address_label: String,
212}
213
214/// A placed order.
215#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
216pub struct Placed {
217 /// Grab's order id (for [`GrabClient::track`]).
218 pub order_id: String,
219 /// Delivery estimate, minutes.
220 pub eta_min: Option<u32>,
221}
222
223/// An order being delivered (or just delivered).
224#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct Tracked {
226 /// Grab's order id.
227 pub order_id: String,
228 /// The merchant's name.
229 pub merchant_name: String,
230 /// Where it is, as Grab words it.
231 pub status: String,
232 /// Arrival estimate, minutes.
233 pub eta_min: Option<u32>,
234 /// The driver's name, once assigned.
235 #[serde(default)]
236 pub driver: String,
237}
238
239/// What a refresh or a liveness check found.
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct Health {
242 /// The Grab account's name (none: not asked this time).
243 pub display_name: Option<String>,
244 /// When the SSO login lapses (unix seconds), if known.
245 pub sso_expires_at: Option<i64>,
246}
247
248/// An account's session, wherever the service keeps it (sealed, ideally):
249/// read and written only through this (the client's refreshes save here).
250#[async_trait::async_trait]
251pub trait SessionSlot: Send + Sync {
252 /// Whose (the account's key): keys what the backend keeps per account.
253 fn id(&self) -> &str;
254 /// The session (none: not connected).
255 async fn load(&self) -> GrabResult<Option<Value>>;
256 /// Keep the session (a refresh renewed it).
257 async fn save(&self, session: &Value) -> GrabResult<()>;
258 /// The backend's cart, kept beside the session (none: empty).
259 async fn load_cart(&self) -> GrabResult<Option<Value>>;
260 /// Keep (none: drop) the cart.
261 async fn save_cart(&self, cart: Option<&Value>) -> GrabResult<()>;
262}
263
264/// One account's Grab, over its [`SessionSlot`].
265#[async_trait::async_trait]
266pub trait GrabClient: Send + Sync {
267 /// Restaurants matching `query` (at most `limit`).
268 async fn search(&self, query: &str, limit: usize) -> GrabResult<Vec<Merchant>>;
269 /// A merchant's menu.
270 async fn menu(&self, merchant_id: &str) -> GrabResult<Menu>;
271 /// Change (or show) the cart; the cart after.
272 async fn cart(&self, op: &CartOp) -> GrabResult<Cart>;
273 /// The cart priced now, with the best voucher that applies.
274 async fn quote(&self) -> GrabResult<Quote>;
275 /// Place the cart's order against `quote_id` (the caller re-quoted
276 /// and matched it). Never retried.
277 async fn place_order(&self, quote_id: &str) -> GrabResult<Placed>;
278 /// One order's tracking, or the ongoing orders'.
279 async fn track(&self, order_id: Option<&str>) -> GrabResult<Vec<Tracked>>;
280 /// A silent SSO refresh when the web session is near its end (saved
281 /// through the slot), and with `liveness` a check that the account
282 /// answers (its name).
283 async fn refresh(&self, liveness: bool) -> GrabResult<Health>;
284}
285
286/// Where a login is. Its `Debug` never shows a done step's session.
287#[derive(Clone, PartialEq, Eq)]
288pub enum Step {
289 /// Grab sent a code (`channel`: sms, whatsapp, ...).
290 AwaitingCode {
291 /// How it was sent.
292 channel: String,
293 },
294 /// Grab asked the app to approve it.
295 AwaitingApproval,
296 /// Logged in: the session to keep, the account's name.
297 Done {
298 /// grabcli's `Session` as JSON.
299 session: Value,
300 /// How the account is shown: for the real backend, grabcli's
301 /// masked phone (`+1••••••••00`, the last two digits shown).
302 display_name: String,
303 /// When the SSO login lapses (unix seconds), if known.
304 sso_expires_at: Option<i64>,
305 },
306 /// It ended (a wrong code, a step-up the app can't do: reCAPTCHA,
307 /// selfie, PIN), why.
308 Failed(String),
309}
310
311impl core::fmt::Debug for Step {
312 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
313 match self {
314 Self::AwaitingCode { channel } => f
315 .debug_struct("AwaitingCode")
316 .field("channel", channel)
317 .finish(),
318 Self::AwaitingApproval => f.write_str("AwaitingApproval"),
319 Self::Done {
320 display_name,
321 sso_expires_at,
322 ..
323 } => f
324 .debug_struct("Done")
325 .field("session", &crate::Redacted)
326 .field("display_name", display_name)
327 .field("sso_expires_at", sso_expires_at)
328 .finish(),
329 Self::Failed(m) => f.debug_tuple("Failed").field(m).finish(),
330 }
331 }
332}
333
334/// One login in a fresh isolated browser context, driven from its own
335/// thread. The phone and the code are passed in and never kept by the
336/// caller.
337pub trait LoginDriver: Send {
338 /// Begin with `phone` (international form).
339 fn start(&mut self, phone: &Secret) -> Step;
340 /// The code Grab sent.
341 fn submit_code(&mut self, code: &Secret) -> Step;
342 /// Wait up to `timeout` for the app's approval.
343 fn wait(&mut self, timeout: Duration) -> Step;
344 /// Stop and dispose of the browser context (also on drop).
345 fn cancel(&mut self);
346}
347
348/// Makes the clients and the login drivers.
349pub trait GrabBackend: Send + Sync {
350 /// The client of the account behind `session`.
351 fn client(&self, session: Arc<dyn SessionSlot>) -> Arc<dyn GrabClient>;
352 /// A login driver on the browser at `cdp_url`; `previous` is the
353 /// account's session if it has one (its delivery address carries over).
354 ///
355 /// # Errors
356 /// The browser can't be reached.
357 fn login(&self, cdp_url: &str, previous: Option<Value>)
358 -> Result<Box<dyn LoginDriver>, String>;
359}
360
361/// `₱1,234.50` for `minor` centavos of `currency` (PHP's sign; others by
362/// code).
363pub fn money(minor: i64, currency: &str) -> String {
364 let sign = if minor < 0 { "-" } else { "" };
365 let m = minor.unsigned_abs();
366 let whole = (m / 100).to_string();
367 let mut grouped = String::new();
368 for (i, c) in whole.chars().enumerate() {
369 if i > 0 && (whole.len() - i).is_multiple_of(3) {
370 grouped.push(',');
371 }
372 grouped.push(c);
373 }
374 let cents = m % 100;
375 let unit = match currency {
376 "" | "PHP" => "₱".to_owned(),
377 c => format!("{c} "),
378 };
379 format!("{sign}{unit}{grouped}.{cents:02}")
380}