Skip to main content

autumn_plugin_graphql/
plugin.rs

1//! [`GraphqlPlugin`]: mount any async-graphql executor on an Autumn app.
2
3use std::borrow::Cow;
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6
7use async_graphql::{Executor, ObjectType, Schema, SchemaBuilder, SubscriptionType};
8use autumn_web::AppState;
9use autumn_web::app::AppBuilder;
10use autumn_web::plugin::Plugin;
11use autumn_web::plugin_contract::PluginContract;
12use autumn_web::route_listing::{RouteClassification, RouteInfo};
13use axum::Router;
14use axum::extract::Extension;
15use axum::response::IntoResponse;
16use axum::routing::{get, post};
17use tokio::sync::{Semaphore, watch};
18
19use crate::config::{
20    ConfigError, DEFAULT_SECTION, GraphqlConfig, PersistedQueryMode, Resolved, Toggle,
21};
22use crate::context::{ContextHook, WsInitHook};
23use crate::metrics::Metrics;
24use crate::persisted::{InMemoryPersistedQueryStore, PersistedQueryStore, TrustedDocuments};
25use crate::pipeline::{Pipeline, PipelineSettings};
26use crate::transport::{Endpoint, http, ws};
27
28/// Name this plugin reports in its contract, in `autumn routes`, and in
29/// `autumn plugin-check` output.
30pub const PLUGIN_NAME: &str = "autumn-plugin-graphql";
31
32/// The `autumn-web` series this release is verified against, as the semver
33/// requirement declared in [`Plugin::contract`]. Autumn's startup gate and
34/// `autumn plugin-check` refuse any other series.
35pub const SUPPORTED_AUTUMN_WEB: &str = "0.10";
36
37type Materialize<E> = Box<dyn FnOnce(&GraphqlConfig) -> (E, Option<String>) + Send>;
38type RouterTransform = Box<dyn FnOnce(Router<AppState>) -> Router<AppState> + Send>;
39type Override = Arc<dyn Fn(&mut GraphqlConfig) + Send + Sync>;
40
41enum Source<E> {
42    Ready { executor: E, sdl: Option<String> },
43    Deferred(Materialize<E>),
44}
45
46enum ConfigSource {
47    /// Read `[section]` from the app's files and environment.
48    Resolve(String),
49    /// Use this configuration; read no files.
50    Explicit(Box<GraphqlConfig>),
51}
52
53struct Guard {
54    apply: RouterTransform,
55    label: String,
56}
57
58/// Mounts an async-graphql schema (or any [`Executor`]) on an Autumn app.
59///
60/// ```rust,ignore
61/// use autumn_plugin_graphql::GraphqlPlugin;
62///
63/// autumn_web::app()
64///     .plugin(GraphqlPlugin::new(schema))   // POST/GET /graphql, /graphql/sdl, /graphql/ws
65///     .run()
66///     .await;
67/// ```
68///
69/// Configuration comes from the `[graphql]` section of `autumn.toml` (with
70/// profile layering and `AUTUMN_GRAPHQL__*` overrides — see
71/// [`GraphqlConfig`]); the fluent setters here are applied **on top** of it,
72/// so code can pin a value that operations must not change. Call
73/// [`config`](Self::config) to supply the whole configuration in code and
74/// read no files.
75pub struct GraphqlPlugin<E> {
76    /// `None` only after `build` consumed it.
77    source: Option<Source<E>>,
78    subscriptions_supported: bool,
79    has_sdl: bool,
80    config_source: ConfigSource,
81    overrides: Vec<Override>,
82    resolved: OnceLock<Result<Resolved, ConfigError>>,
83    development: Option<bool>,
84    guard: Option<Guard>,
85    hooks: Vec<Arc<dyn ContextHook>>,
86    ws_init: Option<Arc<dyn WsInitHook>>,
87    store: Option<Arc<dyn PersistedQueryStore>>,
88    trusted: TrustedDocuments,
89    drain: DrainHandle,
90}
91
92/// Closes every WebSocket and SSE stream an endpoint holds.
93///
94/// The plugin drains automatically when the app shuts down (sockets close
95/// with `1001 Going Away`, SSE streams end with `complete`), so clients
96/// reconnect to a healthy instance. Take a handle with
97/// [`GraphqlPlugin::drain_handle`] to drain earlier — for example when a
98/// readiness probe flips during a rolling deploy. Draining is permanent for
99/// the streams open at the time; new connections are still accepted.
100#[derive(Clone, Debug)]
101pub struct DrainHandle {
102    /// A generation counter: every drain bumps it, and a stream ends on the
103    /// first change after it subscribed. (A boolean set-then-reset would let
104    /// a slow receiver see only the reset and miss the drain.)
105    tx: watch::Sender<u64>,
106}
107
108impl DrainHandle {
109    pub(crate) fn new() -> Self {
110        Self {
111            tx: watch::channel(0).0,
112        }
113    }
114
115    /// Close every stream open at this moment.
116    pub fn drain(&self) {
117        self.tx
118            .send_modify(|generation| *generation = generation.wrapping_add(1));
119    }
120
121    /// A receiver whose current generation is already marked seen.
122    pub(crate) fn subscribe(&self) -> watch::Receiver<u64> {
123        self.tx.subscribe()
124    }
125}
126
127impl<Q, M, S> GraphqlPlugin<Schema<Q, M, S>>
128where
129    Q: ObjectType + 'static,
130    M: ObjectType + 'static,
131    S: SubscriptionType + 'static,
132{
133    /// Mount a built schema. Static limits are enforced by the plugin's own
134    /// analysis; for async-graphql's complexity model as well, use
135    /// [`from_builder`](Self::from_builder).
136    #[must_use]
137    pub fn new(schema: Schema<Q, M, S>) -> Self {
138        let sdl = schema.sdl();
139        Self::with_source(
140            Source::Ready {
141                executor: schema,
142                sdl: Some(sdl),
143            },
144            !S::is_empty(),
145            true,
146        )
147    }
148
149    /// Mount a schema the plugin finishes itself, so the configured
150    /// `limits.max_depth`, `limits.max_complexity` and
151    /// `limits.max_directives` are also installed as async-graphql's own
152    /// schema-level limits (defence in depth, and the only way to use its
153    /// `#[graphql(complexity = …)]` cost model).
154    #[must_use]
155    pub fn from_builder(builder: SchemaBuilder<Q, M, S>) -> Self
156    where
157        SchemaBuilder<Q, M, S>: Send,
158    {
159        Self::with_source(
160            Source::Deferred(Box::new(move |config: &GraphqlConfig| {
161                let limits = &config.limits;
162                let mut builder = builder;
163                if limits.max_depth != 0 {
164                    builder = builder.limit_depth(limits.max_depth);
165                }
166                if limits.max_complexity != 0 {
167                    builder = builder.limit_complexity(limits.max_complexity);
168                }
169                if limits.max_directives != 0 {
170                    builder = builder.limit_directives(limits.max_directives);
171                }
172                let schema = builder.finish();
173                let sdl = schema.sdl();
174                (schema, Some(sdl))
175            })),
176            !S::is_empty(),
177            true,
178        )
179    }
180}
181
182impl<E: Executor> GraphqlPlugin<E> {
183    /// Mount any [`Executor`] — for example an
184    /// `async_graphql::dynamic::Schema` built at runtime. Subscriptions are
185    /// assumed supported (turn the transports off in `[graphql.subscriptions]`
186    /// if they are not); provide the SDL with [`sdl_text`](Self::sdl_text) to
187    /// serve `GET {path}/sdl`.
188    #[must_use]
189    pub fn from_executor(executor: E) -> Self {
190        Self::with_source(
191            Source::Ready {
192                executor,
193                sdl: None,
194            },
195            true,
196            false,
197        )
198    }
199
200    fn with_source(source: Source<E>, subscriptions_supported: bool, has_sdl: bool) -> Self {
201        Self {
202            source: Some(source),
203            subscriptions_supported,
204            has_sdl,
205            config_source: ConfigSource::Resolve(DEFAULT_SECTION.to_owned()),
206            overrides: Vec::new(),
207            resolved: OnceLock::new(),
208            development: None,
209            guard: None,
210            hooks: Vec::new(),
211            ws_init: None,
212            store: None,
213            trusted: TrustedDocuments::new(),
214            drain: DrainHandle::new(),
215        }
216    }
217
218    /// A handle that closes every open WebSocket and SSE stream of this
219    /// endpoint on demand (see [`DrainHandle`]).
220    #[must_use]
221    pub fn drain_handle(&self) -> DrainHandle {
222        self.drain.clone()
223    }
224
225    /// Supply the SDL served at `GET {path}/sdl` (for
226    /// [`from_executor`](Self::from_executor) mounts).
227    #[must_use]
228    pub fn sdl_text(mut self, sdl: impl Into<String>) -> Self {
229        let sdl = sdl.into();
230        self.has_sdl = true;
231        self.source = match self.source {
232            Some(Source::Ready { executor, .. }) => Some(Source::Ready {
233                executor,
234                sdl: Some(sdl),
235            }),
236            other => other,
237        };
238        self
239    }
240
241    fn reset(&mut self) {
242        self.resolved = OnceLock::new();
243    }
244
245    /// Adjust the configuration in code, on top of whatever the files say.
246    ///
247    /// ```rust,ignore
248    /// GraphqlPlugin::new(schema).configure(|c| {
249    ///     c.limits.max_depth = 8;
250    ///     c.batching.enabled = true;
251    /// })
252    /// ```
253    #[must_use]
254    pub fn configure(mut self, apply: impl Fn(&mut GraphqlConfig) + Send + Sync + 'static) -> Self {
255        self.overrides.push(Arc::new(apply));
256        self.reset();
257        self
258    }
259
260    /// Use `config` as the whole configuration: no `autumn.toml` section and
261    /// no environment overrides are read. Fluent setters still apply on top.
262    #[must_use]
263    pub fn config(mut self, config: GraphqlConfig) -> Self {
264        self.config_source = ConfigSource::Explicit(Box::new(config));
265        self.reset();
266        self
267    }
268
269    /// Read a different top-level section than `[graphql]` — needed when an
270    /// app mounts two endpoints with different settings.
271    #[must_use]
272    pub fn config_section(mut self, section: impl Into<String>) -> Self {
273        self.config_source = ConfigSource::Resolve(section.into());
274        self.reset();
275        self
276    }
277
278    /// Force development (`true`) or production (`false`) defaults for the
279    /// `auto` settings, instead of deriving them from the active profile.
280    #[must_use]
281    pub fn development(mut self, development: bool) -> Self {
282        self.development = Some(development);
283        self.reset();
284        self
285    }
286
287    /// Mount under a different path (for example `/api/graphql`).
288    #[must_use]
289    pub fn path(self, path: impl Into<String>) -> Self {
290        let path = path.into();
291        self.configure(move |c| c.path.clone_from(&path))
292    }
293
294    /// Allow or forbid introspection regardless of profile.
295    #[must_use]
296    pub fn introspection(self, enabled: bool) -> Self {
297        self.configure(move |c| c.introspection = enabled.into())
298    }
299
300    /// Do not serve `GET {path}/sdl`.
301    #[must_use]
302    pub fn without_sdl(self) -> Self {
303        self.configure(|c| c.sdl = Toggle::Off)
304    }
305
306    /// Mask (or stop masking) uncoded resolver errors regardless of profile.
307    #[must_use]
308    pub fn mask_unexpected_errors(self, mask: bool) -> Self {
309        self.configure(move |c| c.mask_unexpected_errors = mask.into())
310    }
311
312    /// Per-operation execution timeout (`Duration::ZERO` = none).
313    #[must_use]
314    pub fn timeout(self, timeout: Duration) -> Self {
315        let millis = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX);
316        self.configure(move |c| c.timeout_ms = millis)
317    }
318
319    /// Accept batched requests of up to `max_operations` operations.
320    #[must_use]
321    pub fn batching(self, max_operations: usize) -> Self {
322        self.configure(move |c| {
323            c.batching.enabled = true;
324            c.batching.max_operations = max_operations;
325        })
326    }
327
328    /// Set the persisted-query mode.
329    #[must_use]
330    pub fn persisted_queries(self, mode: PersistedQueryMode) -> Self {
331        self.configure(move |c| c.persisted_queries.mode = mode)
332    }
333
334    /// Add trusted documents in code (merged with any manifest file).
335    #[must_use]
336    pub fn trusted_documents(mut self, documents: TrustedDocuments) -> Self {
337        self.trusted.extend(documents);
338        self
339    }
340
341    /// Replace the in-memory APQ cache (e.g. with a Redis-backed store shared
342    /// by every instance).
343    #[must_use]
344    pub fn persisted_query_store(mut self, store: impl PersistedQueryStore) -> Self {
345        self.store = Some(Arc::new(store));
346        self
347    }
348
349    /// Add a per-request [`ContextHook`]. Hooks run in registration order on
350    /// every HTTP operation and on each WebSocket upgrade.
351    ///
352    /// ```rust,ignore
353    /// GraphqlPlugin::new(schema).context(|parts, state, data| {
354    ///     Box::pin(async move {
355    ///         let user = CurrentUser::from_request_parts(parts, state).await?;
356    ///         data.insert(user);
357    ///         Ok(())
358    ///     })
359    /// })
360    /// ```
361    #[must_use]
362    pub fn context<F>(mut self, hook: F) -> Self
363    where
364        F: for<'a> Fn(
365                &'a mut ::http::request::Parts,
366                &'a AppState,
367                &'a mut async_graphql::Data,
368            ) -> crate::persisted::BoxFuture<'a, autumn_web::AutumnResult<()>>
369            + Send
370            + Sync
371            + 'static,
372    {
373        self.hooks.push(Arc::new(hook));
374        self
375    }
376
377    /// Add a [`ContextHook`] implemented as a type.
378    #[must_use]
379    pub fn context_hook(mut self, hook: impl ContextHook) -> Self {
380        self.hooks.push(Arc::new(hook));
381        self
382    }
383
384    /// Handle the WebSocket `connection_init` payload (see [`WsInitHook`]).
385    #[must_use]
386    pub fn on_ws_init(mut self, hook: impl WsInitHook) -> Self {
387        self.ws_init = Some(Arc::new(hook));
388        self
389    }
390
391    /// Guard every route this plugin mounts with `layer` — for example
392    /// `RequireApiToken`, so the endpoint needs `Authorization: Bearer …`.
393    ///
394    /// This is the seam a nested router needs: `AppBuilder::scoped(prefix,
395    /// layer, routes![…])` wraps only the routes handed to it, never a raw
396    /// router a plugin nests. The layer is the outermost on the plugin's
397    /// router, so it runs before any handler, including `/sdl` and the
398    /// WebSocket upgrade. `label` is what `autumn routes` lists as the
399    /// route's middleware; the routes are then classified `Gated`.
400    #[must_use]
401    pub fn guard<L>(mut self, layer: L, label: impl Into<String>) -> Self
402    where
403        L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
404        L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
405        <L::Service as tower::Service<axum::extract::Request>>::Response: IntoResponse + 'static,
406        <L::Service as tower::Service<axum::extract::Request>>::Error:
407            Into<std::convert::Infallible> + 'static,
408        <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
409    {
410        self.guard = Some(Guard {
411            apply: Box::new(move |router| router.layer(layer)),
412            label: label.into(),
413        });
414        self
415    }
416
417    /// The configuration this plugin will run with, after files,
418    /// environment and code overrides.
419    ///
420    /// # Errors
421    ///
422    /// The [`ConfigError`] that will abort boot.
423    pub fn effective_config(&self) -> Result<&GraphqlConfig, &ConfigError> {
424        self.resolved().as_ref().map(|r| &r.config)
425    }
426
427    fn resolved(&self) -> &Result<Resolved, ConfigError> {
428        self.resolved.get_or_init(|| {
429            let mut resolved = match &self.config_source {
430                ConfigSource::Resolve(section) => GraphqlConfig::resolve(section)?,
431                ConfigSource::Explicit(config) => Resolved::explicit((**config).clone()),
432            };
433            for apply in &self.overrides {
434                apply(&mut resolved.config);
435            }
436            if let Some(development) = self.development {
437                if development { "dev" } else { "prod" }.clone_into(&mut resolved.profile);
438            }
439            resolved.config.validate()?;
440            Ok(resolved)
441        })
442    }
443
444    /// The effective configuration, or the defaults (with overrides) when
445    /// resolution failed — the failure itself aborts boot from the startup
446    /// hook; this keeps every other path total.
447    fn config_or_default(&self) -> (GraphqlConfig, bool) {
448        self.resolved().as_ref().map_or_else(
449            |_| {
450                let mut config = GraphqlConfig::default();
451                for apply in &self.overrides {
452                    apply(&mut config);
453                }
454                (config, self.development.unwrap_or(false))
455            },
456            |resolved| (resolved.config.clone(), resolved.is_development()),
457        )
458    }
459
460    fn mount_plan(&self) -> MountPlan {
461        let (config, development) = self.config_or_default();
462        let introspection = config.introspection.resolve(development);
463        MountPlan {
464            sdl: self.has_sdl && config.sdl.resolve(introspection),
465            websocket: config
466                .subscriptions
467                .websocket
468                .resolve(self.subscriptions_supported),
469            sse: config
470                .subscriptions
471                .sse
472                .resolve(self.subscriptions_supported),
473            introspection,
474            development,
475            config,
476        }
477    }
478
479    /// The routes this plugin mounts, as `autumn routes` lists them.
480    ///
481    /// Without a [`guard`](Self::guard) every route is classified
482    /// [`RouteClassification::Public`]; with one, `Gated` with the guard's
483    /// label as middleware.
484    #[must_use]
485    pub fn route_infos(&self) -> Vec<RouteInfo> {
486        let plan = self.mount_plan();
487        if !plan.config.enabled {
488            return Vec::new();
489        }
490        let path = plan.config.path.trim().to_owned();
491        let guard = self.guard.as_ref().map(|g| g.label.as_str());
492        let mut routes = vec![route("POST", path.clone(), "post_graphql", guard)];
493        if plan.config.allow_get || plan.sse {
494            routes.push(route("GET", path.clone(), "get_graphql", guard));
495        }
496        if plan.sdl {
497            routes.push(route("GET", format!("{path}/sdl"), "sdl", guard));
498        }
499        if plan.websocket {
500            routes.push(route("GET", format!("{path}/ws"), "websocket", guard));
501        }
502        routes
503    }
504
505    fn trusted_documents_for(&self, plan: &MountPlan) -> Result<TrustedDocuments, String> {
506        let mut trusted = self.trusted.clone();
507        let manifest = plan.config.persisted_queries.manifest.trim();
508        if !manifest.is_empty() {
509            let base = self
510                .resolved()
511                .as_ref()
512                .map_or_else(|_| std::path::PathBuf::from("."), |r| r.base_dir.clone());
513            let path = base.join(manifest);
514            trusted.extend(TrustedDocuments::from_file(&path).map_err(|e| e.to_string())?);
515        }
516        if plan.config.persisted_queries.mode == PersistedQueryMode::Trusted && trusted.is_empty() {
517            return Err(
518                "graphql.persisted_queries.mode = \"trusted\" needs a manifest (or documents \
519                 added with `trusted_documents`); with none, every operation would be refused"
520                    .to_owned(),
521            );
522        }
523        Ok(trusted)
524    }
525
526    /// Assemble the request-time state of one endpoint.
527    fn endpoint(
528        &mut self,
529        plan: &MountPlan,
530        path: &str,
531        executor: E,
532        sdl: Option<String>,
533        trusted: TrustedDocuments,
534    ) -> Endpoint<E> {
535        let config = &plan.config;
536        let store = self.store.take().unwrap_or_else(|| {
537            Arc::new(InMemoryPersistedQueryStore::new(
538                config.persisted_queries.cache_capacity,
539            ))
540        });
541        let metrics = Metrics::new(path);
542        let millis = |ms: u64| (ms > 0).then(|| Duration::from_millis(ms));
543        let pipeline = Arc::new(Pipeline {
544            settings: PipelineSettings {
545                introspection: plan.introspection,
546                mask_unexpected_errors: config.mask_unexpected_errors.resolve(!plan.development),
547                timeout: millis(config.timeout_ms),
548                slow_operation: millis(config.slow_operation_ms),
549                limits: config.limits.clone(),
550                persisted_mode: config.persisted_queries.mode,
551            },
552            store,
553            trusted: Arc::new(trusted),
554            metrics: metrics.clone(),
555        });
556        let subs = &config.subscriptions;
557        Endpoint {
558            executor,
559            pipeline,
560            hooks: std::mem::take(&mut self.hooks),
561            ws_init: self.ws_init.take(),
562            path: Arc::from(path),
563            sdl: if plan.sdl { sdl.map(Arc::from) } else { None },
564            allow_get: config.allow_get,
565            max_body_bytes: config.max_body_bytes,
566            batching: config.batching.clone(),
567            uploads: config.uploads.clone(),
568            csrf_prevention: config.csrf_prevention,
569            sse: plan.sse,
570            keepalive: Duration::from_secs(subs.keepalive_secs.max(1)),
571            ws_init_timeout: Duration::from_secs(subs.init_timeout_secs.max(1)),
572            streams: (subs.max_connections > 0)
573                .then(|| Arc::new(Semaphore::new(subs.max_connections))),
574            shutdown: self.drain.clone(),
575            metrics,
576        }
577    }
578}
579
580#[allow(clippy::struct_excessive_bools)] // a plan is a set of independent switches
581struct MountPlan {
582    config: GraphqlConfig,
583    development: bool,
584    introspection: bool,
585    sdl: bool,
586    websocket: bool,
587    sse: bool,
588}
589
590fn route(method: &str, path: String, handler: &str, guard: Option<&str>) -> RouteInfo {
591    RouteInfo {
592        method: method.to_owned(),
593        path,
594        handler: format!("autumn_plugin_graphql::{handler}"),
595        classification: guard.map_or(RouteClassification::Public, |_| RouteClassification::Gated),
596        middleware: guard.map(str::to_owned).into_iter().collect(),
597        ..Default::default()
598    }
599}
600
601impl<E: Executor> Plugin for GraphqlPlugin<E> {
602    /// Keyed by mount path, so two endpoints can coexist at different paths
603    /// while a second plugin at the *same* path is refused as a duplicate.
604    fn name(&self) -> Cow<'static, str> {
605        Cow::Owned(format!(
606            "{PLUGIN_NAME}@{}",
607            self.mount_plan().config.path.trim()
608        ))
609    }
610
611    /// Declares the supported `autumn-web` series ([`SUPPORTED_AUTUMN_WEB`]).
612    fn contract(&self) -> Option<PluginContract> {
613        Some(
614            PluginContract::new(PLUGIN_NAME)
615                .plugin_version(env!("CARGO_PKG_VERSION"))
616                .autumn_web(SUPPORTED_AUTUMN_WEB),
617        )
618    }
619
620    fn build(mut self, app: AppBuilder) -> AppBuilder {
621        let app = match &self.config_source {
622            ConfigSource::Resolve(section) => app.config_section(section.clone()),
623            ConfigSource::Explicit(_) => app,
624        };
625        let plan = self.mount_plan();
626        let routes = self.route_infos();
627
628        let mut problems: Vec<String> = Vec::new();
629        if let Err(error) = self.resolved() {
630            problems.push(error.to_string());
631        }
632        let trusted = self.trusted_documents_for(&plan).unwrap_or_else(|error| {
633            problems.push(error);
634            TrustedDocuments::new()
635        });
636        let app = app.on_startup(move |_state| {
637            let problems = problems.clone();
638            async move {
639                if problems.is_empty() {
640                    Ok(())
641                } else {
642                    Err(autumn_web::AutumnError::internal_server_error_msg(format!(
643                        "{PLUGIN_NAME}: {}",
644                        problems.join("; ")
645                    )))
646                }
647            }
648        });
649
650        let config = plan.config.clone();
651        if !config.enabled {
652            tracing::info!(path = %config.path, "GraphQL endpoint disabled by configuration");
653            return app;
654        }
655        let path = config.path.trim().to_owned();
656
657        let (executor, sdl) = match self.source.take() {
658            Some(Source::Ready { executor, sdl }) => (executor, sdl),
659            Some(Source::Deferred(materialize)) => materialize(&config),
660            None => return app,
661        };
662
663        let endpoint = Arc::new(self.endpoint(&plan, &path, executor, sdl, trusted));
664
665        let mut endpoint_router = Router::new().route(
666            "/",
667            post(http::post_graphql::<E>).get(http::get_graphql::<E>),
668        );
669        if endpoint.sdl.is_some() {
670            endpoint_router = endpoint_router.route("/sdl", get(http::sdl::<E>));
671        }
672        if plan.websocket {
673            endpoint_router = endpoint_router.route("/ws", get(ws::upgrade::<E>));
674        }
675        let layered = endpoint_router.layer(Extension(endpoint));
676        // Applied last, so the guard is the outermost layer and runs first.
677        let guarded = match self.guard.take() {
678            Some(guard) => (guard.apply)(layered),
679            None => layered,
680        };
681
682        tracing::info!(
683            path = %path,
684            introspection = plan.introspection,
685            sdl = plan.sdl,
686            websocket = plan.websocket,
687            sse = plan.sse,
688            persisted_queries = ?config.persisted_queries.mode,
689            "mounting GraphQL endpoint"
690        );
691        let drain = self.drain.clone();
692        app.nest(&path, guarded)
693            .declare_plugin_routes(routes)
694            .on_shutdown(move || {
695                let drain = drain.clone();
696                async move { drain.drain() }
697            })
698    }
699}