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
203
204
205
206
//! Error types for the Shopify plugin.
//!
//! [`Error`] is the plugin error. [`ErrorKind`] classifies it.
//! [`Error::to_autumn_error`] maps each kind to an HTTP status.
//!
//! Do not use `?` to change an [`Error`] into an [`AutumnError`]. Autumn has
//! a blanket `From` impl that gives 500 for every error. Call
//! [`Error::to_autumn_error`].
use autumn_web::AutumnError;
use autumn_web::reexports::http::StatusCode;
use thiserror::Error;
/// Classifies an [`Error`] without its payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
/// The `[shopify]` config is missing or not valid.
Config,
/// An HMAC is missing, malformed, or wrong.
Signature,
/// The request is malformed (headers, query, or shop domain).
InvalidRequest,
/// The request body is larger than the limit.
PayloadTooLarge,
/// A JSON payload does not parse.
Json,
/// A Shopify call failed at the transport level.
Http,
/// Shopify answered with a non-success status.
ShopifyApi,
/// The GraphQL response has errors.
GraphQl,
/// Shopify throttled the call and the retries ran out.
Throttled,
/// Shopify answered 200, but the body has the wrong shape.
UnexpectedResponse,
/// Another delivery of this webhook id still runs.
InProgress,
/// The OAuth state does not match the browser cookie.
StateMismatch,
/// The merchant did not grant all requested scopes.
ScopeMismatch,
/// The shop has no stored access token.
NotInstalled,
/// The plugin cannot refresh the token. The merchant must install the app again.
Reauthorize,
/// OAuth is off (`[shopify] app_url` is not set).
OAuthDisabled,
/// A token or delivery store failed.
Store,
/// The app does not use the plugin (no runtime in the app state).
MissingRuntime,
}
/// The plugin error type.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
/// The `[shopify]` config is missing or not valid.
#[error("shopify config error: {0}")]
Config(String),
/// An HMAC is missing, malformed, or wrong.
#[error("shopify signature error: {0}")]
Signature(String),
/// The request is malformed.
#[error("shopify invalid request: {0}")]
InvalidRequest(String),
/// The request body is larger than the limit.
#[error("shopify request body is larger than {limit} bytes")]
PayloadTooLarge {
/// The limit in bytes.
limit: usize,
},
/// A JSON payload does not parse.
#[error("shopify json error: {0}")]
Json(#[from] serde_json::Error),
/// A Shopify call failed at the transport level.
#[error("shopify http error: {0}")]
Http(#[from] reqwest::Error),
/// Shopify answered with a non-success status.
#[error("shopify api error: status {status}: {body}")]
ShopifyApi {
/// The HTTP status from Shopify.
status: u16,
/// The response body, truncated to 500 chars.
body: String,
},
/// The GraphQL response has errors.
#[error("shopify graphql error: {}", .0.join("; "))]
GraphQl(Vec<String>),
/// Shopify throttled the call and the retries ran out.
#[error("shopify throttled the request after {retries} retries")]
Throttled {
/// The number of retries done.
retries: u32,
},
/// Shopify answered 200, but the body has the wrong shape.
#[error("shopify response has the wrong shape: {0}")]
UnexpectedResponse(String),
/// Another delivery of this webhook id still runs.
#[error("shopify webhook {0} is in progress")]
InProgress(String),
/// The OAuth state does not match the browser cookie.
#[error("shopify oauth state does not match")]
StateMismatch,
/// The merchant did not grant all requested scopes.
#[error("shopify scopes not granted: {}", .missing.join(","))]
ScopeMismatch {
/// The requested scopes that are missing.
missing: Vec<String>,
},
/// The shop has no stored access token.
#[error("shop {0} has no stored token; install the app")]
NotInstalled(String),
/// The plugin cannot refresh the token. The merchant must install the app again.
#[error("shop {0} must install the app again")]
Reauthorize(String),
/// OAuth is off.
#[error("shopify oauth is off; set [shopify] app_url")]
OAuthDisabled,
/// A token or delivery store failed.
#[error("shopify store error: {0}")]
Store(String),
/// The app does not use the plugin (no runtime in the app state).
#[error("shopify runtime is missing; add ShopifyPlugin to the app")]
MissingRuntime,
}
impl Error {
/// Classify this error.
#[must_use]
pub const fn kind(&self) -> ErrorKind {
match self {
Self::Config(_) => ErrorKind::Config,
Self::Signature(_) => ErrorKind::Signature,
Self::InvalidRequest(_) => ErrorKind::InvalidRequest,
Self::PayloadTooLarge { .. } => ErrorKind::PayloadTooLarge,
Self::Json(_) => ErrorKind::Json,
Self::Http(_) => ErrorKind::Http,
Self::ShopifyApi { .. } => ErrorKind::ShopifyApi,
Self::GraphQl(_) => ErrorKind::GraphQl,
Self::Throttled { .. } => ErrorKind::Throttled,
Self::UnexpectedResponse(_) => ErrorKind::UnexpectedResponse,
Self::InProgress(_) => ErrorKind::InProgress,
Self::StateMismatch => ErrorKind::StateMismatch,
Self::ScopeMismatch { .. } => ErrorKind::ScopeMismatch,
Self::NotInstalled(_) => ErrorKind::NotInstalled,
Self::Reauthorize(_) => ErrorKind::Reauthorize,
Self::OAuthDisabled => ErrorKind::OAuthDisabled,
Self::Store(_) => ErrorKind::Store,
Self::MissingRuntime => ErrorKind::MissingRuntime,
}
}
/// The HTTP status this error maps to.
#[must_use]
pub const fn status(&self) -> StatusCode {
match self.kind() {
ErrorKind::Signature | ErrorKind::NotInstalled | ErrorKind::Reauthorize => {
StatusCode::UNAUTHORIZED
}
ErrorKind::InvalidRequest | ErrorKind::Json => StatusCode::BAD_REQUEST,
ErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorKind::StateMismatch | ErrorKind::ScopeMismatch => StatusCode::FORBIDDEN,
ErrorKind::OAuthDisabled => StatusCode::NOT_FOUND,
ErrorKind::InProgress => StatusCode::CONFLICT,
ErrorKind::Http
| ErrorKind::ShopifyApi
| ErrorKind::GraphQl
| ErrorKind::UnexpectedResponse => StatusCode::BAD_GATEWAY,
ErrorKind::Throttled | ErrorKind::MissingRuntime => StatusCode::SERVICE_UNAVAILABLE,
ErrorKind::Config | ErrorKind::Store => StatusCode::INTERNAL_SERVER_ERROR,
}
}
/// Convert into an [`AutumnError`] with the status from
/// [`status`](Self::status).
#[must_use]
pub fn to_autumn_error(&self) -> AutumnError {
AutumnError::internal_server_error_msg(self.to_string()).with_status(self.status())
}
}
/// Result alias for this crate.
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests;