Skip to main content

autumn_plugin_shopify/
error.rs

1//! Error types for the Shopify plugin.
2//!
3//! [`Error`] is the plugin error. [`ErrorKind`] classifies it.
4//! [`Error::to_autumn_error`] maps each kind to an HTTP status.
5//!
6//! Do not use `?` to change an [`Error`] into an [`AutumnError`]. Autumn has
7//! a blanket `From` impl that gives 500 for every error. Call
8//! [`Error::to_autumn_error`].
9
10use autumn_web::AutumnError;
11use autumn_web::reexports::http::StatusCode;
12use thiserror::Error;
13
14/// Classifies an [`Error`] without its payload.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum ErrorKind {
18    /// The `[shopify]` config is missing or not valid.
19    Config,
20    /// An HMAC is missing, malformed, or wrong.
21    Signature,
22    /// The request is malformed (headers, query, or shop domain).
23    InvalidRequest,
24    /// The request body is larger than the limit.
25    PayloadTooLarge,
26    /// A JSON payload does not parse.
27    Json,
28    /// A Shopify call failed at the transport level.
29    Http,
30    /// Shopify answered with a non-success status.
31    ShopifyApi,
32    /// The GraphQL response has errors.
33    GraphQl,
34    /// Shopify throttled the call and the retries ran out.
35    Throttled,
36    /// Shopify answered 200, but the body has the wrong shape.
37    UnexpectedResponse,
38    /// Another delivery of this webhook id still runs.
39    InProgress,
40    /// The OAuth state does not match the browser cookie.
41    StateMismatch,
42    /// The merchant did not grant all requested scopes.
43    ScopeMismatch,
44    /// The shop has no stored access token.
45    NotInstalled,
46    /// The plugin cannot refresh the token. The merchant must install the app again.
47    Reauthorize,
48    /// OAuth is off (`[shopify] app_url` is not set).
49    OAuthDisabled,
50    /// A token or delivery store failed.
51    Store,
52    /// The app does not use the plugin (no runtime in the app state).
53    MissingRuntime,
54}
55
56/// The plugin error type.
57#[derive(Debug, Error)]
58#[non_exhaustive]
59pub enum Error {
60    /// The `[shopify]` config is missing or not valid.
61    #[error("shopify config error: {0}")]
62    Config(String),
63
64    /// An HMAC is missing, malformed, or wrong.
65    #[error("shopify signature error: {0}")]
66    Signature(String),
67
68    /// The request is malformed.
69    #[error("shopify invalid request: {0}")]
70    InvalidRequest(String),
71
72    /// The request body is larger than the limit.
73    #[error("shopify request body is larger than {limit} bytes")]
74    PayloadTooLarge {
75        /// The limit in bytes.
76        limit: usize,
77    },
78
79    /// A JSON payload does not parse.
80    #[error("shopify json error: {0}")]
81    Json(#[from] serde_json::Error),
82
83    /// A Shopify call failed at the transport level.
84    #[error("shopify http error: {0}")]
85    Http(#[from] reqwest::Error),
86
87    /// Shopify answered with a non-success status.
88    #[error("shopify api error: status {status}: {body}")]
89    ShopifyApi {
90        /// The HTTP status from Shopify.
91        status: u16,
92        /// The response body, truncated to 500 chars.
93        body: String,
94    },
95
96    /// The GraphQL response has errors.
97    #[error("shopify graphql error: {}", .0.join("; "))]
98    GraphQl(Vec<String>),
99
100    /// Shopify throttled the call and the retries ran out.
101    #[error("shopify throttled the request after {retries} retries")]
102    Throttled {
103        /// The number of retries done.
104        retries: u32,
105    },
106
107    /// Shopify answered 200, but the body has the wrong shape.
108    #[error("shopify response has the wrong shape: {0}")]
109    UnexpectedResponse(String),
110
111    /// Another delivery of this webhook id still runs.
112    #[error("shopify webhook {0} is in progress")]
113    InProgress(String),
114
115    /// The OAuth state does not match the browser cookie.
116    #[error("shopify oauth state does not match")]
117    StateMismatch,
118
119    /// The merchant did not grant all requested scopes.
120    #[error("shopify scopes not granted: {}", .missing.join(","))]
121    ScopeMismatch {
122        /// The requested scopes that are missing.
123        missing: Vec<String>,
124    },
125
126    /// The shop has no stored access token.
127    #[error("shop {0} has no stored token; install the app")]
128    NotInstalled(String),
129
130    /// The plugin cannot refresh the token. The merchant must install the app again.
131    #[error("shop {0} must install the app again")]
132    Reauthorize(String),
133
134    /// OAuth is off.
135    #[error("shopify oauth is off; set [shopify] app_url")]
136    OAuthDisabled,
137
138    /// A token or delivery store failed.
139    #[error("shopify store error: {0}")]
140    Store(String),
141
142    /// The app does not use the plugin (no runtime in the app state).
143    #[error("shopify runtime is missing; add ShopifyPlugin to the app")]
144    MissingRuntime,
145}
146
147impl Error {
148    /// Classify this error.
149    #[must_use]
150    pub const fn kind(&self) -> ErrorKind {
151        match self {
152            Self::Config(_) => ErrorKind::Config,
153            Self::Signature(_) => ErrorKind::Signature,
154            Self::InvalidRequest(_) => ErrorKind::InvalidRequest,
155            Self::PayloadTooLarge { .. } => ErrorKind::PayloadTooLarge,
156            Self::Json(_) => ErrorKind::Json,
157            Self::Http(_) => ErrorKind::Http,
158            Self::ShopifyApi { .. } => ErrorKind::ShopifyApi,
159            Self::GraphQl(_) => ErrorKind::GraphQl,
160            Self::Throttled { .. } => ErrorKind::Throttled,
161            Self::UnexpectedResponse(_) => ErrorKind::UnexpectedResponse,
162            Self::InProgress(_) => ErrorKind::InProgress,
163            Self::StateMismatch => ErrorKind::StateMismatch,
164            Self::ScopeMismatch { .. } => ErrorKind::ScopeMismatch,
165            Self::NotInstalled(_) => ErrorKind::NotInstalled,
166            Self::Reauthorize(_) => ErrorKind::Reauthorize,
167            Self::OAuthDisabled => ErrorKind::OAuthDisabled,
168            Self::Store(_) => ErrorKind::Store,
169            Self::MissingRuntime => ErrorKind::MissingRuntime,
170        }
171    }
172
173    /// The HTTP status this error maps to.
174    #[must_use]
175    pub const fn status(&self) -> StatusCode {
176        match self.kind() {
177            ErrorKind::Signature | ErrorKind::NotInstalled | ErrorKind::Reauthorize => {
178                StatusCode::UNAUTHORIZED
179            }
180            ErrorKind::InvalidRequest | ErrorKind::Json => StatusCode::BAD_REQUEST,
181            ErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
182            ErrorKind::StateMismatch | ErrorKind::ScopeMismatch => StatusCode::FORBIDDEN,
183            ErrorKind::OAuthDisabled => StatusCode::NOT_FOUND,
184            ErrorKind::InProgress => StatusCode::CONFLICT,
185            ErrorKind::Http
186            | ErrorKind::ShopifyApi
187            | ErrorKind::GraphQl
188            | ErrorKind::UnexpectedResponse => StatusCode::BAD_GATEWAY,
189            ErrorKind::Throttled | ErrorKind::MissingRuntime => StatusCode::SERVICE_UNAVAILABLE,
190            ErrorKind::Config | ErrorKind::Store => StatusCode::INTERNAL_SERVER_ERROR,
191        }
192    }
193
194    /// Convert into an [`AutumnError`] with the status from
195    /// [`status`](Self::status).
196    #[must_use]
197    pub fn to_autumn_error(&self) -> AutumnError {
198        AutumnError::internal_server_error_msg(self.to_string()).with_status(self.status())
199    }
200}
201
202/// Result alias for this crate.
203pub type Result<T> = std::result::Result<T, Error>;
204
205#[cfg(test)]
206mod tests;