Skip to main content

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}