Skip to main content

autumn_plugin_shopify/
plugin.rs

1//! The Autumn plugin: [`ShopifyPlugin`], [`ShopifyRuntime`], and the
2//! [`Shopify`] extractor.
3//!
4//! ```no_run
5//! use autumn_plugin_shopify::{ShopifyPlugin, WebhookContext};
6//! use autumn_web::prelude::*;
7//!
8//! async fn order_created(ctx: WebhookContext) -> AutumnResult<()> {
9//!     tracing::info!(shop = %ctx.shop, "order created");
10//!     Ok(())
11//! }
12//!
13//! #[autumn_web::main]
14//! async fn main() {
15//!     autumn_web::app()
16//!         .plugin(ShopifyPlugin::new().webhook("orders/create", order_created))
17//!         .run()
18//!         .await;
19//! }
20//! ```
21//!
22//! The plugin declares the `[shopify]` section, mounts its routes under
23//! `/shopify`, and installs an `on_startup` hook. The hook loads and checks
24//! the config, builds the [`ShopifyRuntime`], and puts it in the
25//! [`AppState`] extensions. A bad config stops the startup.
26
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::{Arc, Mutex};
32
33use autumn_web::actuator::IndicatorGroup;
34use autumn_web::app::AppBuilder;
35use autumn_web::plugin::Plugin;
36use autumn_web::plugin_contract::PluginContract;
37use autumn_web::reexports::axum::Router;
38use autumn_web::reexports::axum::extract::FromRequestParts;
39use autumn_web::reexports::http::request::Parts;
40use autumn_web::route_listing::RouteInfo;
41use autumn_web::{AppState, AutumnError, AutumnResult};
42
43use crate::client::{RetryPolicy, ShopifyClient};
44use crate::config::ShopifyConfig;
45use crate::delivery::{DeliveryStore, InMemoryDeliveryStore};
46use crate::error::{Error, Result};
47use crate::tokens::{InMemoryTokenStore, ShopToken, TokenStore};
48use crate::types::{ApiVersion, ShopDomain};
49use crate::verify::SecretSet;
50use crate::webhooks::{IntoWebhookHandler, WebhookHandler, WebhookRegistry};
51
52/// The `autumn-web` range this plugin supports. Keep it equal to the
53/// `autumn-web` requirement in `Cargo.toml`.
54const SUPPORTED_AUTUMN_WEB: &str = "0.10";
55
56/// `client_for` refreshes a token that expires within this many seconds.
57pub const REFRESH_MARGIN_SECS: i64 = 60;
58
59/// After a failed refresh, callers use the old token (while it is valid)
60/// for this many seconds before the next try.
61pub const REFRESH_BACKOFF_SECS: i64 = 10;
62
63/// A builder closure for [`ShopifyPlugin::configure`].
64type ConfigureFn = Arc<dyn Fn(&mut ShopifyConfig) + Send + Sync + 'static>;
65
66/// The future an install handler returns.
67pub type InstallFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
68
69/// A type-erased install handler.
70pub type InstallHandler = Arc<dyn Fn(InstallContext) -> InstallFuture + Send + Sync + 'static>;
71
72/// What an install handler receives. The token is already stored.
73#[derive(Clone)]
74#[non_exhaustive]
75pub struct InstallContext {
76    /// The shop that installed the app.
77    pub shop: ShopDomain,
78    /// The granted scopes, comma-separated.
79    pub scope: String,
80    /// The app state.
81    pub state: AppState,
82    /// The plugin handle. `client_for(&shop)` works at once.
83    pub shopify: Shopify,
84}
85
86impl InstallContext {
87    /// Make a context, for example to test an install handler.
88    #[must_use]
89    pub fn new(shop: ShopDomain, scope: impl Into<String>, shopify: Shopify) -> Self {
90        Self {
91            shop,
92            scope: scope.into(),
93            state: shopify.state().clone(),
94            shopify,
95        }
96    }
97}
98
99impl std::fmt::Debug for InstallContext {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("InstallContext")
102            .field("shop", &self.shop)
103            .field("scope", &self.scope)
104            .finish_non_exhaustive()
105    }
106}
107
108/// The per-shop refresh state, behind the per-shop lock.
109#[derive(Debug, Default)]
110struct RefreshState {
111    failed_at: Option<i64>,
112}
113
114/// Token work: storage, refresh, install, and uninstall, serialized per
115/// shop. It lives behind an `Arc`, so that a refresh can run in its own
116/// task.
117pub(crate) struct Keeper {
118    http: reqwest::Client,
119    admin_base_url: Option<String>,
120    api_key: Option<String>,
121    secret: String,
122    tokens: Arc<dyn TokenStore>,
123    locks: Mutex<HashMap<ShopDomain, Arc<tokio::sync::Mutex<RefreshState>>>>,
124}
125
126impl Keeper {
127    /// The shared HTTP pool.
128    pub(crate) const fn http(&self) -> &reqwest::Client {
129        &self.http
130    }
131
132    /// The client id, for token calls.
133    pub(crate) fn api_key(&self) -> Result<&str> {
134        self.api_key
135            .as_deref()
136            .ok_or_else(|| Error::Config("token calls need [shopify] api_key".to_owned()))
137    }
138
139    /// The current client secret, for token calls.
140    pub(crate) fn secret(&self) -> &str {
141        &self.secret
142    }
143
144    /// The origin for Admin API and OAuth calls to `shop`.
145    pub(crate) fn admin_origin(&self, shop: &ShopDomain) -> String {
146        self.admin_base_url.as_deref().map_or_else(
147            || format!("https://{shop}"),
148            |base| base.trim_end_matches('/').to_owned(),
149        )
150    }
151
152    /// The lock for `shop`.
153    fn lock_for(&self, shop: &ShopDomain) -> Result<Arc<tokio::sync::Mutex<RefreshState>>> {
154        let mut locks = self.lock_table()?;
155        Ok(Arc::clone(locks.entry(shop.clone()).or_default()))
156    }
157
158    fn lock_table(
159        &self,
160    ) -> Result<std::sync::MutexGuard<'_, HashMap<ShopDomain, Arc<tokio::sync::Mutex<RefreshState>>>>>
161    {
162        self.locks
163            .lock()
164            .map_err(|_| Error::Store("token lock table is poisoned".to_owned()))
165    }
166
167    /// A token for `shop` that is fresh at `now`. Refreshes it when needed.
168    #[allow(
169        clippy::significant_drop_tightening,
170        reason = "the per-shop lock must cover the read, the refresh, and the write"
171    )]
172    async fn fresh_token(&self, shop: &ShopDomain, now: i64) -> Result<ShopToken> {
173        let lock = self.lock_for(shop)?;
174        let mut state = lock.lock().await;
175        let token = self
176            .tokens
177            .get(shop)
178            .await?
179            .ok_or_else(|| Error::NotInstalled(shop.to_string()))?;
180        if !token.needs_refresh(now, REFRESH_MARGIN_SECS) {
181            return Ok(token);
182        }
183        let backing_off = state
184            .failed_at
185            .is_some_and(|at| now.saturating_sub(at) < REFRESH_BACKOFF_SECS);
186        if backing_off && !token.is_expired(now) {
187            return Ok(token);
188        }
189        let Some(refresh) = token
190            .refresh_token
191            .clone()
192            .filter(|_| token.can_refresh(now))
193        else {
194            self.delete_if_unchanged(shop, &token).await?;
195            return Err(Error::Reauthorize(shop.to_string()));
196        };
197        match crate::oauth::refresh_token(self, shop, &refresh, now).await {
198            Ok(mut fresh) => {
199                fresh.installed_at = token.installed_at;
200                self.tokens.put(shop, fresh.clone()).await?;
201                state.failed_at = None;
202                tracing::info!(shop = %shop, "shopify token refreshed");
203                Ok(fresh)
204            }
205            Err(Error::Reauthorize(_)) => {
206                // Another replica or a new install can have stored a new
207                // pair. Use it when it is fresh; else delete the dead pair.
208                if let Some(current) = self.tokens.get(shop).await?
209                    && current != token
210                    && !current.needs_refresh(now, REFRESH_MARGIN_SECS)
211                {
212                    return Ok(current);
213                }
214                self.delete_if_unchanged(shop, &token).await?;
215                tracing::warn!(shop = %shop, "shopify refused the refresh token. The shop must install the app again");
216                Err(Error::Reauthorize(shop.to_string()))
217            }
218            Err(err) => {
219                state.failed_at = Some(now);
220                if token.is_expired(now) {
221                    Err(err)
222                } else {
223                    tracing::warn!(shop = %shop, error = %err, "shopify refresh failed; the old token is still valid");
224                    Ok(token)
225                }
226            }
227        }
228    }
229
230    /// Delete the token of `shop` only when the store still holds `seen`.
231    /// Another replica or a new install can have replaced it.
232    async fn delete_if_unchanged(&self, shop: &ShopDomain, seen: &ShopToken) -> Result<()> {
233        if self.tokens.get(shop).await?.as_ref() == Some(seen) {
234            self.tokens.delete(shop).await?;
235        }
236        Ok(())
237    }
238
239    /// Store the token from an install.
240    #[allow(
241        clippy::significant_drop_tightening,
242        reason = "the per-shop lock must cover the write"
243    )]
244    pub(crate) async fn store_install(&self, shop: &ShopDomain, token: ShopToken) -> Result<()> {
245        let lock = self.lock_for(shop)?;
246        let mut state = lock.lock().await;
247        self.tokens.put(shop, token).await?;
248        state.failed_at = None;
249        Ok(())
250    }
251
252    /// Delete the token of `shop` for an event at `event_at` (Unix seconds).
253    #[allow(
254        clippy::significant_drop_tightening,
255        reason = "the per-shop lock must cover the read and the delete"
256    )]
257    ///
258    /// A token from an install after the event stays. Returns `true` when
259    /// the token is gone.
260    pub(crate) async fn delete_for_event(
261        &self,
262        shop: &ShopDomain,
263        event_at: Option<i64>,
264    ) -> Result<bool> {
265        // The lock entry stays in the table. A removed entry lets a waiter
266        // on the old lock and a new install on a new lock run at one time.
267        let lock = self.lock_for(shop)?;
268        let _guard = lock.lock().await;
269        let current = self.tokens.get(shop).await?;
270        let newer = matches!(
271            (current.as_ref().and_then(|t| t.installed_at), event_at),
272            (Some(installed), Some(event)) if installed > event
273        );
274        if newer {
275            return Ok(false);
276        }
277        self.tokens.delete(shop).await?;
278        Ok(true)
279    }
280}
281
282/// Runtime state shared by the routes and the [`Shopify`] extractor.
283///
284/// The startup hook puts it in the [`AppState`] extensions.
285pub struct ShopifyRuntime {
286    config: ShopifyConfig,
287    secrets: SecretSet,
288    api_version: ApiVersion,
289    single_store: Option<(ShopDomain, String)>,
290    retry: RetryPolicy,
291    registry: WebhookRegistry,
292    deliveries: Arc<dyn DeliveryStore>,
293    on_install: Option<InstallHandler>,
294    keeper: Arc<Keeper>,
295}
296
297/// `Debug` shows no secrets and no tokens.
298impl std::fmt::Debug for ShopifyRuntime {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("ShopifyRuntime")
301            .field("config", &self.config)
302            .field(
303                "single_store",
304                &self.single_store.as_ref().map(|(shop, _)| shop),
305            )
306            .field("topics", &self.registry.topics())
307            .finish_non_exhaustive()
308    }
309}
310
311impl ShopifyRuntime {
312    /// The resolved, validated config.
313    #[must_use]
314    pub const fn config(&self) -> &ShopifyConfig {
315        &self.config
316    }
317
318    /// The client secrets.
319    pub(crate) const fn secrets(&self) -> &SecretSet {
320        &self.secrets
321    }
322
323    /// The webhook handlers.
324    #[must_use]
325    pub const fn registry(&self) -> &WebhookRegistry {
326        &self.registry
327    }
328
329    /// The token store, to read tokens.
330    ///
331    /// Do not write or delete tokens through it: the plugin serializes
332    /// writes per shop, and a direct write can race a refresh.
333    #[must_use]
334    pub fn token_store(&self) -> &Arc<dyn TokenStore> {
335        &self.keeper.tokens
336    }
337
338    /// The duplicate-delivery store.
339    #[must_use]
340    pub const fn delivery_store(&self) -> &Arc<dyn DeliveryStore> {
341        &self.deliveries
342    }
343
344    /// The install handler, if set.
345    pub(crate) const fn on_install(&self) -> Option<&InstallHandler> {
346        self.on_install.as_ref()
347    }
348
349    /// The token keeper.
350    pub(crate) fn keeper(&self) -> &Keeper {
351        &self.keeper
352    }
353
354    /// The client for the single-store shop.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`Error::Config`] when `shop` or `SHOPIFY_ACCESS_TOKEN` is
359    /// not set.
360    pub fn client(&self) -> Result<ShopifyClient> {
361        let (shop, token) = self.single_store.as_ref().ok_or_else(|| {
362            Error::Config(format!(
363                "no single-store client: set [shopify] shop and {}",
364                crate::config::ACCESS_TOKEN_ENV
365            ))
366        })?;
367        Ok(self.make_client(shop, token.clone()))
368    }
369
370    /// A client on the shared pool, with the configured version and retry.
371    fn make_client(&self, shop: &ShopDomain, token: String) -> ShopifyClient {
372        ShopifyClient::from_parts(
373            self.keeper.http.clone(),
374            shop.clone(),
375            self.api_version.clone(),
376            token,
377        )
378        .with_base_url(self.keeper.admin_origin(shop))
379        .with_retry(self.retry)
380    }
381
382    /// The client for `shop`, at Unix time `now`.
383    ///
384    /// The single-store shop uses the fixed token. Other shops use the
385    /// token store. When the token expires within
386    /// [`REFRESH_MARGIN_SECS`], it refreshes first. Only one refresh for a
387    /// shop runs at a time, in its own task: a cancelled caller does not
388    /// lose the new token pair. When a refresh fails and the old token is
389    /// still valid, the client uses the old token.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`Error::NotInstalled`] when the shop has no token,
394    /// [`Error::Reauthorize`] when Shopify refuses the refresh token, or a
395    /// store or HTTP error.
396    pub async fn client_for(&self, shop: &ShopDomain, now: i64) -> Result<ShopifyClient> {
397        if let Some((single, token)) = &self.single_store
398            && single == shop
399        {
400            return Ok(self.make_client(shop, token.clone()));
401        }
402        let token = self
403            .keeper
404            .tokens
405            .get(shop)
406            .await?
407            .ok_or_else(|| Error::NotInstalled(shop.to_string()))?;
408        if !token.needs_refresh(now, REFRESH_MARGIN_SECS) {
409            return Ok(self.make_client(shop, token.access_token));
410        }
411        let keeper = Arc::clone(&self.keeper);
412        let owned = shop.clone();
413        let token = tokio::spawn(async move { keeper.fresh_token(&owned, now).await })
414            .await
415            .map_err(|_| Error::Store("the token refresh task stopped".to_owned()))??;
416        Ok(self.make_client(shop, token.access_token))
417    }
418}
419
420/// The plugin handle for app routes.
421///
422/// ```no_run
423/// use autumn_plugin_shopify::Shopify;
424/// use autumn_web::prelude::*;
425///
426/// #[get("/shop-name")]
427/// #[public]
428/// async fn shop_name(shopify: Shopify) -> AutumnResult<String> {
429///     let client = shopify.client().map_err(|e| e.to_autumn_error())?;
430///     let info = client.shop_info().await.map_err(|e| e.to_autumn_error())?;
431///     Ok(info.name)
432/// }
433/// ```
434///
435/// Extraction fails with 503 when the app does not use the plugin.
436#[derive(Clone)]
437pub struct Shopify {
438    runtime: Arc<ShopifyRuntime>,
439    state: AppState,
440}
441
442impl std::fmt::Debug for Shopify {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("Shopify")
445            .field("runtime", &self.runtime)
446            .finish_non_exhaustive()
447    }
448}
449
450impl Shopify {
451    /// Read the handle from the app state.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`Error::MissingRuntime`] when the app does not use the
456    /// plugin.
457    pub fn from_state(state: &AppState) -> Result<Self> {
458        let runtime = state
459            .extension::<ShopifyRuntime>()
460            .ok_or(Error::MissingRuntime)?;
461        Ok(Self::from_runtime(runtime, state.clone()))
462    }
463
464    /// Make a handle from a runtime and a state.
465    pub(crate) const fn from_runtime(runtime: Arc<ShopifyRuntime>, state: AppState) -> Self {
466        Self { runtime, state }
467    }
468
469    /// The runtime.
470    #[must_use]
471    pub const fn runtime(&self) -> &Arc<ShopifyRuntime> {
472        &self.runtime
473    }
474
475    /// The app state.
476    #[must_use]
477    pub const fn state(&self) -> &AppState {
478        &self.state
479    }
480
481    /// The client for the single-store shop.
482    ///
483    /// # Errors
484    ///
485    /// See [`ShopifyRuntime::client`].
486    pub fn client(&self) -> Result<ShopifyClient> {
487        self.runtime.client()
488    }
489
490    /// The client for `shop`. Time comes from the app clock.
491    ///
492    /// # Errors
493    ///
494    /// See [`ShopifyRuntime::client_for`].
495    pub async fn client_for(&self, shop: &ShopDomain) -> Result<ShopifyClient> {
496        let now = self.state.clock().now().timestamp();
497        self.runtime.client_for(shop, now).await
498    }
499
500    /// The local URL that starts an install for `shop`. Send the browser
501    /// there on [`Error::NotInstalled`] or [`Error::Reauthorize`].
502    #[must_use]
503    pub fn install_url(shop: &ShopDomain) -> String {
504        format!("{}?shop={shop}", crate::oauth::AUTH_PATH)
505    }
506}
507
508impl FromRequestParts<AppState> for Shopify {
509    type Rejection = AutumnError;
510
511    fn from_request_parts(
512        _parts: &mut Parts,
513        state: &AppState,
514    ) -> impl Future<Output = std::result::Result<Self, Self::Rejection>> {
515        std::future::ready(Self::from_state(state).map_err(|err| err.to_autumn_error()))
516    }
517}
518
519/// Shopify as an Autumn plugin.
520#[derive(Clone, Default)]
521pub struct ShopifyPlugin {
522    configure: Vec<ConfigureFn>,
523    webhooks: Vec<(String, WebhookHandler)>,
524    token_store: Option<Arc<dyn TokenStore>>,
525    delivery_store: Option<Arc<dyn DeliveryStore>>,
526    admin_base_url: Option<String>,
527    on_install: Option<InstallHandler>,
528}
529
530impl std::fmt::Debug for ShopifyPlugin {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        let topics: Vec<&str> = self.webhooks.iter().map(|(t, _)| t.as_str()).collect();
533        f.debug_struct("ShopifyPlugin")
534            .field("topics", &topics)
535            .finish_non_exhaustive()
536    }
537}
538
539impl ShopifyPlugin {
540    /// Make the plugin.
541    #[must_use]
542    pub fn new() -> Self {
543        Self::default()
544    }
545
546    /// Change config values in code. The closure runs after the file and
547    /// the environment, so its values win. It cannot set secrets.
548    #[must_use]
549    pub fn configure(
550        mut self,
551        configure: impl Fn(&mut ShopifyConfig) + Send + Sync + 'static,
552    ) -> Self {
553        self.configure.push(Arc::new(configure));
554        self
555    }
556
557    /// Handle webhooks for `topic`, for example `orders/create`.
558    ///
559    /// A malformed or repeated topic stops the startup.
560    #[must_use]
561    pub fn webhook(mut self, topic: impl Into<String>, handler: impl IntoWebhookHandler) -> Self {
562        self.webhooks.push((topic.into(), handler.into_handler()));
563        self
564    }
565
566    /// Replace the in-memory token store.
567    #[must_use]
568    pub fn token_store(mut self, store: Arc<dyn TokenStore>) -> Self {
569        self.token_store = Some(store);
570        self
571    }
572
573    /// Replace the in-memory duplicate-delivery store.
574    #[must_use]
575    pub fn delivery_store(mut self, store: Arc<dyn DeliveryStore>) -> Self {
576        self.delivery_store = Some(store);
577        self
578    }
579
580    /// Run `handler` after each install, when the token is stored. Use it
581    /// to save a shop record or to subscribe webhooks. An error gives 500.
582    /// The token stays in the store.
583    #[must_use]
584    pub fn on_install<F, Fut>(mut self, handler: F) -> Self
585    where
586        F: Fn(InstallContext) -> Fut + Send + Sync + 'static,
587        Fut: Future<Output = AutumnResult<()>> + Send + 'static,
588    {
589        self.on_install = Some(Arc::new(move |ctx| Box::pin(handler(ctx))));
590        self
591    }
592
593    /// Send Admin API and OAuth calls to `base_url` instead of
594    /// `https://{shop}`. For tests only: the startup fails unless the host
595    /// is `localhost`, `127.0.0.1`, or `[::1]`, because the calls carry the
596    /// client secret and the tokens.
597    #[must_use]
598    pub fn admin_base_url(mut self, base_url: impl Into<String>) -> Self {
599        self.admin_base_url = Some(base_url.into());
600        self
601    }
602
603    /// Build the runtime from a loaded config: apply the builder closures,
604    /// validate, and register the webhook handlers.
605    ///
606    /// The startup hook calls this with [`ShopifyConfig::load`]. Tests call
607    /// it with [`ShopifyConfig::from_sources`].
608    ///
609    /// # Errors
610    ///
611    /// Returns [`Error::Config`] when the config is not valid or a webhook
612    /// topic is malformed or repeated.
613    pub fn build_runtime(&self, config: ShopifyConfig) -> Result<ShopifyRuntime> {
614        let mut config = config;
615        for configure in &self.configure {
616            configure(&mut config);
617        }
618        config.validate()?;
619        let secrets = config.secrets()?;
620        let api_version = config.api_version()?;
621        let single_store = match (config.shop_domain()?, config.access_token()) {
622            (Some(shop), Some(token)) => Some((shop, token.to_owned())),
623            _ => None,
624        };
625        let mut registry = WebhookRegistry::new();
626        for (topic, handler) in &self.webhooks {
627            registry.register(topic, Arc::clone(handler))?;
628        }
629        if let Some(base) = &self.admin_base_url {
630            check_loopback(base)?;
631        }
632        let retry = RetryPolicy::new(config.max_throttle_retries, RetryPolicy::default().max_wait);
633        let keeper = Keeper {
634            http: crate::client::http_client()?,
635            admin_base_url: self.admin_base_url.clone(),
636            api_key: config.api_key.clone(),
637            secret: secrets.current().to_owned(),
638            tokens: self
639                .token_store
640                .clone()
641                .unwrap_or_else(|| Arc::new(InMemoryTokenStore::new())),
642            locks: Mutex::new(HashMap::new()),
643        };
644        Ok(ShopifyRuntime {
645            secrets,
646            api_version,
647            single_store,
648            retry,
649            registry,
650            deliveries: self
651                .delivery_store
652                .clone()
653                .unwrap_or_else(|| Arc::new(InMemoryDeliveryStore::default())),
654            on_install: self.on_install.clone(),
655            keeper: Arc::new(keeper),
656            config,
657        })
658    }
659}
660
661impl ShopifyPlugin {
662    /// The startup work: build the runtime, log warnings, register the
663    /// health indicator, and put the runtime in the app state.
664    fn start(&self, state: &AppState, config: ShopifyConfig) -> Result<()> {
665        let runtime = self.build_runtime(config)?;
666        for warning in startup_warnings(&runtime, &state.config_arc()) {
667            tracing::warn!("{warning}");
668        }
669        if let Ok(client) = runtime.client() {
670            // Health only, not readiness: a Shopify outage must not take
671            // the app out of the load balancer, or webhooks fail too. No
672            // retries: a probe must answer fast.
673            let client = client.with_retry(RetryPolicy::new(0, std::time::Duration::ZERO));
674            // A duplicate name means the app adds the plugin twice. The
675            // framework already warns about that.
676            let _ = state.health_indicator_registry().register(
677                "shopify",
678                IndicatorGroup::HealthOnly,
679                crate::health::indicator(&client),
680            );
681        }
682        tracing::info!(
683            topics = runtime.registry().topics().len(),
684            oauth = runtime.config().oauth_enabled(),
685            single_store = runtime.single_store.is_some(),
686            "shopify plugin ready"
687        );
688        state.extension_or_insert_with(|| runtime);
689        Ok(())
690    }
691}
692
693impl Plugin for ShopifyPlugin {
694    fn name(&self) -> Cow<'static, str> {
695        Cow::Borrowed("autumn-plugin-shopify")
696    }
697
698    /// Declare the supported `autumn-web` range. A mismatch fails at
699    /// registration with both versions named.
700    fn contract(&self) -> Option<PluginContract> {
701        Some(
702            PluginContract::new(env!("CARGO_PKG_NAME"))
703                .plugin_version(env!("CARGO_PKG_VERSION"))
704                .autumn_web(SUPPORTED_AUTUMN_WEB),
705        )
706    }
707
708    fn build(self, app: AppBuilder) -> AppBuilder {
709        let (router, infos) = mount();
710        app.config_section(crate::config::CONFIG_SECTION)
711            .on_startup(move |state: AppState| {
712                let plugin = self.clone();
713                async move {
714                    let config = ShopifyConfig::load().map_err(|err| err.to_autumn_error())?;
715                    plugin
716                        .start(&state, config)
717                        .map_err(|err| err.to_autumn_error())
718                }
719            })
720            .nest("/shopify", router)
721            .declare_plugin_routes(infos)
722    }
723}
724
725/// Startup warnings: compliance topics with no handler (OAuth on), a
726/// half-set single-store mode, and a webhook path that CSRF checks block.
727pub(crate) fn startup_warnings(
728    runtime: &ShopifyRuntime,
729    app_config: &autumn_web::config::AutumnConfig,
730) -> Vec<String> {
731    let mut warnings = Vec::new();
732    if runtime.config().oauth_enabled() {
733        for topic in crate::types::Topic::COMPLIANCE {
734            if !runtime.registry().contains(topic) {
735                warnings.push(format!(
736                    "shopify: no handler for the mandatory compliance topic {topic}; \
737                     public apps must handle it"
738                ));
739            }
740        }
741    }
742    let config = runtime.config();
743    if config.shop.is_some() != config.access_token().is_some() {
744        warnings.push(format!(
745            "shopify: single-store mode is off; it needs both [shopify] shop and {}",
746            crate::config::ACCESS_TOKEN_ENV
747        ));
748    }
749    let csrf = &app_config.security.csrf;
750    if csrf.enabled
751        && !csrf
752            .exempt_paths
753            .iter()
754            .any(|path| covers(path, WEBHOOK_PATH))
755    {
756        warnings.push(format!(
757            "shopify: CSRF checks block {WEBHOOK_PATH}; add it to security.csrf.exempt_paths"
758        ));
759    }
760    warnings
761}
762
763/// The plugin routes under `/shopify`, with their route metadata.
764///
765/// [`AppBuilder::declare_plugin_routes`] gets the metadata, so
766/// `autumn routes audit` sees the mount as enumerable. Autumn derives each
767/// route classification from `#[public]` on the handler.
768pub(crate) fn mount() -> (Router<AppState>, Vec<RouteInfo>) {
769    let all_routes: Vec<autumn_web::Route> = crate::webhooks::webhook_routes()
770        .into_iter()
771        .chain(crate::oauth::oauth_routes())
772        .collect();
773    // These routes declare no API version, so this call cannot fail.
774    let mut infos = autumn_web::route_listing::collect_route_infos(&all_routes, &[], &[], &[])
775        .unwrap_or_default();
776    for info in &mut infos {
777        info.path = format!("/shopify{}", info.path);
778    }
779    let mut router = Router::<AppState>::new();
780    for route in all_routes {
781        router = router.route(route.path, route.handler);
782    }
783    (router, infos)
784}
785
786/// Check that `base` is an `http(s)` URL on a loopback host.
787fn check_loopback(base: &str) -> Result<()> {
788    let url = url::Url::parse(base)
789        .map_err(|_| Error::Config(format!("admin_base_url {base:?} is not a valid URL")))?;
790    let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
791    if loopback && matches!(url.scheme(), "http" | "https") {
792        Ok(())
793    } else {
794        Err(Error::Config(
795            "admin_base_url is for tests only and must use a loopback host".to_owned(),
796        ))
797    }
798}
799
800/// The webhook route path.
801const WEBHOOK_PATH: &str = "/shopify/webhooks";
802
803/// True when the exempt prefix `prefix` covers `path`, with the same
804/// segment rule as Autumn's CSRF layer.
805fn covers(prefix: &str, path: &str) -> bool {
806    path == prefix
807        || (path.starts_with(prefix)
808            && (prefix.ends_with('/') || path.as_bytes().get(prefix.len()) == Some(&b'/')))
809}
810
811#[cfg(test)]
812mod tests;