1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//! One error type for every grabcli command.
//!
//! Each error carries a stable machine-readable [`ErrorKind`] so scripts and
//! assistants can react to it (`--json` prints `{"error":{"kind","message"}}`)
//! and a process exit code derived from the kind.
use serde::Serialize;
/// Stable error categories. The `snake_case` names are part of the CLI's
/// public interface: don't rename them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
/// Bad arguments or input files.
InvalidInput,
/// No session saved yet: run `grab auth import`.
AuthMissing,
/// Grab rejected the session cookies (401) or they expired.
AuthExpired,
/// The hydra device JWT is missing, expired or rejected and could not be
/// re-minted.
HydraExpired,
/// The Chrome DevTools endpoint could not be reached or misbehaved.
CdpUnavailable,
/// A request URL was not on the allowlist.
UrlNotAllowed,
/// Ordering is disabled in config (`allow_ordering = false`).
OrderingDisabled,
/// An order needs an interactive confirmation or `--yes <quote-id>`.
ConfirmationRequired,
/// The fresh quote differs from the one the user confirmed.
QuoteChanged,
/// The local cart is empty or invalid against the menu.
CartInvalid,
/// No saved delivery address (`location` cookie) in the session.
AddressMissing,
/// Something requested does not exist (merchant, item, order, voucher).
NotFound,
/// Grab answered 429.
RateLimited,
/// `grab auth login --otp`: the code was wrong or already used.
OtpInvalid,
/// The login page demanded an interactive captcha a headless tab can't do.
CaptchaRequired,
/// The pending `grab auth login` expired (or none was started).
LoginExpired,
/// Grab answered with an error we don't map more precisely.
Api,
/// Network / TLS / timeout.
Network,
/// Local file system error.
Io,
/// A response or file didn't have the expected shape.
Parse,
}
impl ErrorKind {
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidInput => "invalid_input",
Self::AuthMissing => "auth_missing",
Self::AuthExpired => "auth_expired",
Self::HydraExpired => "hydra_expired",
Self::CdpUnavailable => "cdp_unavailable",
Self::UrlNotAllowed => "url_not_allowed",
Self::OrderingDisabled => "ordering_disabled",
Self::ConfirmationRequired => "confirmation_required",
Self::QuoteChanged => "quote_changed",
Self::CartInvalid => "cart_invalid",
Self::AddressMissing => "address_missing",
Self::NotFound => "not_found",
Self::RateLimited => "rate_limited",
Self::OtpInvalid => "otp_invalid",
Self::CaptchaRequired => "captcha_required",
Self::LoginExpired => "login_expired",
Self::Api => "api",
Self::Network => "network",
Self::Io => "io",
Self::Parse => "parse",
}
}
/// Process exit code for this kind. 0 is success, 1 generic failure.
pub fn exit_code(self) -> i32 {
match self {
Self::InvalidInput | Self::CartInvalid | Self::NotFound | Self::AddressMissing => 2,
Self::QuoteChanged => 3,
Self::AuthMissing
| Self::AuthExpired
| Self::HydraExpired
| Self::CdpUnavailable
| Self::OtpInvalid
| Self::CaptchaRequired
| Self::LoginExpired => 4,
Self::OrderingDisabled | Self::UrlNotAllowed => 5,
Self::ConfirmationRequired => 6,
Self::RateLimited => 7,
Self::Api | Self::Network | Self::Io | Self::Parse => 1,
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct Error {
pub kind: ErrorKind,
pub message: String,
/// Optional structured details (e.g. a quote diff), printed under
/// `error.details` in `--json` mode.
pub details: Option<serde_json::Value>,
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
impl Error {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
details: None,
}
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(ErrorKind::InvalidInput, message)
}
pub fn parse(message: impl Into<String>) -> Self {
Self::new(ErrorKind::Parse, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, message)
}
pub fn exit_code(&self) -> i32 {
self.kind.exit_code()
}
/// The `{"error": {...}}` object printed in `--json` mode.
pub fn to_json(&self) -> serde_json::Value {
let mut e = serde_json::json!({
"kind": self.kind.as_str(),
"message": self.message,
});
if let Some(d) = &self.details {
e["details"] = d.clone();
}
serde_json::json!({ "error": e })
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::new(ErrorKind::Io, e.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::new(ErrorKind::Parse, format!("JSON: {e}"))
}
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
// reqwest errors can include the URL; ours never carry secrets in the
// URL (cookies and tokens travel in headers), so this is safe to show.
Self::new(ErrorKind::Network, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_shape() {
let e = Error::new(ErrorKind::HydraExpired, "hydra gone");
let j = e.to_json();
assert_eq!(j["error"]["kind"], "hydra_expired");
assert_eq!(j["error"]["message"], "hydra gone");
assert_eq!(e.exit_code(), 4);
}
#[test]
fn address_missing_is_not_an_auth_error() {
assert_eq!(ErrorKind::AddressMissing.as_str(), "address_missing");
assert_eq!(ErrorKind::AddressMissing.exit_code(), 2);
}
#[test]
fn quote_changed_exits_3() {
assert_eq!(ErrorKind::QuoteChanged.exit_code(), 3);
}
}