loco_rs/app.rs
1//! This module contains the core components and traits for building a web
2//! server application.
3#[cfg(feature = "with-db")]
4use {sea_orm::DatabaseConnection, std::path::Path};
5
6use std::{
7 any::{Any, TypeId},
8 net::SocketAddr,
9 sync::Arc,
10};
11
12use async_trait::async_trait;
13use axum::extract::FromRef;
14use axum::Router as AxumRouter;
15use dashmap::DashMap;
16
17use crate::{
18 bgworker::{self, Queue},
19 boot::{shutdown_signal, BootResult, ServeParams, StartMode},
20 cache::{self},
21 config::Config,
22 controller::{
23 middleware::{self, MiddlewareLayer},
24 AppRoutes,
25 },
26 environment::Environment,
27 mailer::EmailSender,
28 storage::{self, Storage},
29 task::Tasks,
30 Result,
31};
32
33/// Type-safe heterogeneous storage for arbitrary application data
34#[derive(Default, Debug)]
35pub struct SharedStore {
36 // Use DashMap for concurrent access with fine-grained locking
37 storage: DashMap<TypeId, Box<dyn Any + Send + Sync>>,
38}
39
40impl SharedStore {
41 /// Insert a value of type T into the shared store
42 ///
43 /// # Example
44 /// ```
45 /// # use loco_rs::app::SharedStore;
46 /// let shared_store = SharedStore::default();
47 ///
48 /// #[derive(Debug)]
49 /// struct TestService {
50 /// name: String,
51 /// value: i32,
52 /// }
53 ///
54 /// let service = TestService {
55 /// name: "test".to_string(),
56 /// value: 100,
57 /// };
58 ///
59 /// shared_store.insert(service);
60 /// assert!(shared_store.contains::<TestService>());
61 /// ```
62 pub fn insert<T: 'static + Send + Sync>(&self, val: T) {
63 self.storage.insert(TypeId::of::<T>(), Box::new(val));
64 }
65
66 /// Remove a value of type T from the shared store
67 ///
68 /// Returns `Some(T)` if the value was present and removed, `None` otherwise.
69 ///
70 /// # Example
71 /// ```
72 /// # use loco_rs::app::SharedStore;
73 /// let shared_store = SharedStore::default();
74 ///
75 /// struct TestService {
76 /// name: String,
77 /// value: i32,
78 /// }
79 ///
80 /// let service = TestService {
81 /// name: "test".to_string(),
82 /// value: 100,
83 /// };
84 ///
85 /// shared_store.insert(service);
86 /// assert!(shared_store.contains::<TestService>());
87 ///
88 /// // Remove and get the value
89 /// let removed_service_opt = shared_store.remove::<TestService>();
90 /// assert!(removed_service_opt.is_some(), "Service should be present");
91 /// // Assert fields individually instead of comparing the whole struct
92 /// if let Some(removed_service) = removed_service_opt {
93 /// assert_eq!(removed_service.name, "test");
94 /// assert_eq!(removed_service.value, 100);
95 /// }
96 /// // Ensure it's gone
97 /// assert!(!shared_store.contains::<TestService>());
98 ///
99 /// // Trying to remove again returns None
100 /// assert!(shared_store.remove::<TestService>().is_none());
101 /// ```
102 #[must_use]
103 pub fn remove<T: 'static + Send + Sync>(&self) -> Option<T> {
104 self.storage
105 .remove(&TypeId::of::<T>())
106 .map(|(_, v)| v) // Extract the Box<dyn Any>
107 .and_then(|any| any.downcast::<T>().ok()) // Downcast to Box<T>
108 .map(|boxed| *boxed) // Dereference the Box<T> to get T
109 }
110
111 /// Get a reference to a value of type T from the shared store.
112 ///
113 /// Returns `None` if the value doesn't exist.
114 /// The reference is valid for as long as the returned `RefGuard` is held.
115 /// If you need to clone the value, you can do so directly from the
116 /// returned reference, or use the `get` method instead.
117 ///
118 /// # Example
119 /// ```
120 /// # use loco_rs::app::SharedStore;
121 /// let shared_store = SharedStore::default();
122 ///
123 /// #[derive(Clone)]
124 /// struct TestService {
125 /// name: String,
126 /// value: i32,
127 /// }
128 ///
129 /// let service = TestService {
130 /// name: "test".to_string(),
131 /// value: 100,
132 /// };
133 ///
134 /// shared_store.insert(service);
135 ///
136 /// // Get a reference to the service
137 /// let service_ref = shared_store.get_ref::<TestService>().expect("Service not found");
138 /// // Access fields directly
139 /// assert_eq!(service_ref.name, "test");
140 /// assert_eq!(service_ref.value, 100);
141 ///
142 /// // Clone if needed (the field itself)
143 /// let name_clone = service_ref.name.clone();
144 /// assert_eq!(name_clone, "test");
145 ///
146 /// // Compute values from the reference
147 /// let name_len = service_ref.name.len();
148 /// assert_eq!(name_len, 4);
149 /// ```
150 #[must_use]
151 pub fn get_ref<T: 'static + Send + Sync>(&self) -> Option<RefGuard<'_, T>> {
152 let type_id = TypeId::of::<T>();
153 self.storage.get(&type_id).map(|r| RefGuard::<T> {
154 inner: r,
155 _phantom: std::marker::PhantomData,
156 })
157 }
158
159 /// Get a clone of a value of type T from the shared store.
160 /// Requires T to implement Clone.
161 ///
162 /// Returns `None` if the value doesn't exist.
163 /// This method clones the stored value.
164 /// If cloning is not desired or T does not implement Clone,
165 /// use `get_ref` instead.
166 ///
167 /// # Example
168 /// ```
169 /// # use loco_rs::app::SharedStore;
170 /// let shared_store = SharedStore::default();
171 ///
172 /// #[derive(Clone)]
173 /// struct TestService {
174 /// name: String,
175 /// value: i32,
176 /// }
177 ///
178 /// let service = TestService {
179 /// name: "test".to_string(),
180 /// value: 100,
181 /// };
182 ///
183 /// shared_store.insert(service);
184 ///
185 /// // Get a clone of the service
186 /// let service_clone_opt = shared_store.get::<TestService>();
187 /// assert!(service_clone_opt.is_some(), "Service not found");
188 /// // Assert fields individually
189 /// if let Some(ref service_clone) = service_clone_opt {
190 /// assert_eq!(service_clone.name, "test");
191 /// assert_eq!(service_clone.value, 100);
192 /// }
193 /// ```
194 #[must_use]
195 pub fn get<T: 'static + Send + Sync + Clone>(&self) -> Option<T> {
196 self.get_ref::<T>().map(|guard| (*guard).clone())
197 }
198
199 /// Check if the shared store contains a value of type T
200 ///
201 /// # Example
202 /// ```
203 /// # use loco_rs::app::SharedStore;
204 /// let shared_store = SharedStore::default();
205 ///
206 /// struct TestService {
207 /// name: String,
208 /// value: i32,
209 /// }
210 ///
211 /// let service = TestService {
212 /// name: "test".to_string(),
213 /// value: 100,
214 /// };
215 ///
216 /// shared_store.insert(service);
217 /// assert!(shared_store.contains::<TestService>());
218 /// assert!(!shared_store.contains::<String>());
219 /// ```
220 #[must_use]
221 pub fn contains<T: 'static + Send + Sync>(&self) -> bool {
222 self.storage.contains_key(&TypeId::of::<T>())
223 }
224}
225
226// A wrapper around DashMap's Ref type that erases the exact type
227// but provides deref to the target type
228pub struct RefGuard<'a, T: 'static + Send + Sync> {
229 inner: dashmap::mapref::one::Ref<'a, TypeId, Box<dyn Any + Send + Sync>>,
230 _phantom: std::marker::PhantomData<&'a T>,
231}
232
233impl<T: 'static + Send + Sync> std::ops::Deref for RefGuard<'_, T> {
234 type Target = T;
235
236 fn deref(&self) -> &Self::Target {
237 // This is safe because we only create a RefGuard for a specific type
238 // after looking it up by its TypeId
239 #[allow(clippy::coerce_container_to_any)]
240 self.inner
241 .value()
242 .downcast_ref::<T>()
243 .expect("Type mismatch in RefGuard")
244 }
245}
246
247/// Represents the application context for a web server.
248///
249/// This struct encapsulates various components and configurations required by
250/// the web server to operate. It is typically used to store and manage shared
251/// resources and settings that are accessible throughout the application's
252/// lifetime.
253#[derive(Clone, FromRef)]
254#[allow(clippy::module_name_repetitions)]
255#[non_exhaustive]
256pub struct AppContext {
257 /// The environment in which the application is running.
258 pub environment: Environment,
259 #[cfg(feature = "with-db")]
260 /// A database connection used by the application.
261 pub db: DatabaseConnection,
262 /// Queue provider
263 pub queue_provider: Option<Arc<bgworker::Queue>>,
264 /// Configuration settings for the application
265 pub config: Config,
266 /// An optional email sender component that can be used to send email.
267 pub mailer: Option<EmailSender>,
268 // An optional storage instance for the application
269 pub storage: Arc<Storage>,
270 // Cache instance for the application
271 pub cache: Arc<cache::Cache>,
272 /// Shared store for arbitrary application data
273 pub shared_store: Arc<SharedStore>,
274}
275
276/// Builder for [`AppContext`].
277///
278/// Because `AppContext` is `#[non_exhaustive]`,
279/// external crates must construct it through this builder (or the framework's
280/// boot path) rather than a struct literal — so new fields added in future
281/// releases are non-breaking. Required components are constructor arguments;
282/// optional components default to no-op providers unless set.
283#[must_use]
284pub struct AppContextBuilder {
285 environment: Environment,
286 #[cfg(feature = "with-db")]
287 db: DatabaseConnection,
288 config: Config,
289 queue_provider: Option<Arc<bgworker::Queue>>,
290 mailer: Option<EmailSender>,
291 storage: Option<Arc<Storage>>,
292 cache: Option<Arc<cache::Cache>>,
293 shared_store: Option<Arc<SharedStore>>,
294}
295
296impl AppContext {
297 /// Start building an [`AppContext`]. (with-db)
298 #[cfg(feature = "with-db")]
299 pub fn builder(
300 environment: Environment,
301 db: DatabaseConnection,
302 config: Config,
303 ) -> AppContextBuilder {
304 AppContextBuilder {
305 environment,
306 db,
307 config,
308 queue_provider: None,
309 mailer: None,
310 storage: None,
311 cache: None,
312 shared_store: None,
313 }
314 }
315
316 /// Start building an [`AppContext`]. (no-db)
317 #[cfg(not(feature = "with-db"))]
318 pub fn builder(environment: Environment, config: Config) -> AppContextBuilder {
319 AppContextBuilder {
320 environment,
321 config,
322 queue_provider: None,
323 mailer: None,
324 storage: None,
325 cache: None,
326 shared_store: None,
327 }
328 }
329
330 /// Turn an existing context back into a builder, carrying **every**
331 /// component over.
332 ///
333 /// This is the escape hatch for [`Hooks::after_context`]. Because
334 /// `AppContext` is `#[non_exhaustive]`, functional-update syntax —
335 /// `AppContext { storage, ..ctx }`, the idiom that hook was documented
336 /// with — does not compile outside this crate. Starting over from
337 /// [`AppContext::builder`] does compile, but silently discards whatever
338 /// the boot sequence already placed on the context: the mailer, the queue
339 /// provider, the cache, the shared store. Round-tripping through the
340 /// builder replaces one component and keeps the rest.
341 ///
342 /// ```rust,ignore
343 /// async fn after_context(ctx: AppContext) -> Result<AppContext> {
344 /// Ok(ctx
345 /// .into_builder()
346 /// .storage(Storage::single(storage::drivers::local::new()).into())
347 /// .build())
348 /// }
349 /// ```
350 pub fn into_builder(self) -> AppContextBuilder {
351 AppContextBuilder {
352 environment: self.environment,
353 #[cfg(feature = "with-db")]
354 db: self.db,
355 config: self.config,
356 queue_provider: self.queue_provider,
357 mailer: self.mailer,
358 storage: Some(self.storage),
359 cache: Some(self.cache),
360 shared_store: Some(self.shared_store),
361 }
362 }
363}
364
365impl AppContextBuilder {
366 /// Set the background-queue provider (default: none).
367 pub fn queue_provider(mut self, queue_provider: Arc<bgworker::Queue>) -> Self {
368 self.queue_provider = Some(queue_provider);
369 self
370 }
371 /// Set the email sender (default: none).
372 pub fn mailer(mut self, mailer: EmailSender) -> Self {
373 self.mailer = Some(mailer);
374 self
375 }
376 /// Set the storage (default: single null driver).
377 pub fn storage(mut self, storage: Arc<Storage>) -> Self {
378 self.storage = Some(storage);
379 self
380 }
381 /// Set the cache (default: null cache).
382 pub fn cache(mut self, cache: Arc<cache::Cache>) -> Self {
383 self.cache = Some(cache);
384 self
385 }
386 /// Set the shared store (default: empty).
387 pub fn shared_store(mut self, shared_store: Arc<SharedStore>) -> Self {
388 self.shared_store = Some(shared_store);
389 self
390 }
391 /// Finalize the [`AppContext`], filling any unset optional component with a
392 /// no-op default.
393 #[must_use]
394 pub fn build(self) -> AppContext {
395 AppContext {
396 environment: self.environment,
397 #[cfg(feature = "with-db")]
398 db: self.db,
399 queue_provider: self.queue_provider,
400 config: self.config,
401 mailer: self.mailer,
402 storage: self
403 .storage
404 .unwrap_or_else(|| Storage::single(storage::drivers::null::new()).into()),
405 cache: self
406 .cache
407 .unwrap_or_else(|| cache::Cache::new(cache::drivers::null::new()).into()),
408 shared_store: self
409 .shared_store
410 .unwrap_or_else(|| Arc::new(SharedStore::default())),
411 }
412 }
413}
414
415/// A trait that defines hooks for customizing and extending the behavior of a
416/// web server application.
417///
418/// Users of the web server application should implement this trait to customize
419/// the application's routing, worker connections, task registration, and
420/// database actions according to their specific requirements and use cases.
421#[async_trait]
422pub trait Hooks: Send {
423 /// Defines the composite app version
424 #[must_use]
425 fn app_version() -> String {
426 "dev".to_string()
427 }
428 /// Defines the crate name
429 ///
430 /// Example
431 /// ```rust
432 /// fn app_name() -> &'static str {
433 /// env!("CARGO_CRATE_NAME")
434 /// }
435 /// ```
436 fn app_name() -> &'static str;
437
438 /// Initializes and boots the application based on the specified mode and
439 /// environment.
440 ///
441 /// The boot initialization process may vary depending on whether a DB
442 /// migrator is used or not.
443 ///
444 /// # Examples
445 ///
446 /// With DB:
447 /// ```rust,ignore
448 /// async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult> {
449 /// create_app::<Self, Migrator>(mode, environment, config).await
450 /// }
451 /// ````
452 ///
453 /// Without DB:
454 /// ```rust,ignore
455 /// async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult> {
456 /// create_app::<Self>(mode, environment, config).await
457 /// }
458 /// ````
459 ///
460 ///
461 /// # Errors
462 /// Could not boot the application
463 async fn boot(mode: StartMode, environment: &Environment, config: Config)
464 -> Result<BootResult>;
465
466 /// Start serving the Axum web application on the specified address and
467 /// port.
468 ///
469 /// # Returns
470 /// A Result indicating success () or an error if the server fails to start.
471 async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()> {
472 let listener = tokio::net::TcpListener::bind(&format!(
473 "{}:{}",
474 serve_params.binding, serve_params.port
475 ))
476 .await?;
477
478 let cloned_ctx = ctx.clone();
479 axum::serve(
480 listener,
481 app.into_make_service_with_connect_info::<SocketAddr>(),
482 )
483 .with_graceful_shutdown(async move {
484 shutdown_signal().await;
485 tracing::info!("shutting down...");
486 Self::on_shutdown(&cloned_ctx).await;
487 })
488 .await?;
489
490 Ok(())
491 }
492
493 /// Override and return `Ok(true)` to provide an alternative logging and
494 /// tracing stack of your own.
495 /// When returning `Ok(true)`, Loco will *not* initialize its own logger,
496 /// so you should set up a complete tracing and logging stack.
497 ///
498 /// # Errors
499 /// If fails returns an error
500 fn init_logger(_ctx: &AppContext) -> Result<bool> {
501 Ok(false)
502 }
503
504 /// Loads the configuration settings for the application based on the given environment.
505 ///
506 /// This function is responsible for retrieving the configuration for the application
507 /// based on the current environment.
508 async fn load_config(env: &Environment) -> Result<Config> {
509 env.load()
510 }
511
512 /// Returns the initial Axum router for the application, allowing the user
513 /// to control the construction of the Axum router. This is where a fallback
514 /// handler can be installed before middleware or other routes are added.
515 ///
516 /// # Errors
517 /// Return an [`Result`] when the router could not be created
518 async fn before_routes(_ctx: &AppContext) -> Result<AxumRouter<AppContext>> {
519 Ok(AxumRouter::new())
520 }
521
522 /// Invoke this function after the Loco routers have been constructed. This
523 /// function enables you to configure custom Axum logics, such as layers,
524 /// that are compatible with Axum.
525 ///
526 /// # Errors
527 /// Axum router error
528 async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
529 Ok(router)
530 }
531
532 /// Provide a list of initializers
533 /// An initializer can be used to seamlessly add functionality to your app
534 /// or to initialize some aspects of it.
535 async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
536 Ok(vec![])
537 }
538
539 /// Provide a list of middlewares
540 #[must_use]
541 fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>> {
542 middleware::default_middleware_stack(ctx)
543 }
544
545 /// Calling the function before run the app
546 /// You can now code some custom loading of resources or other things before
547 /// the app runs
548 async fn before_run(_app_context: &AppContext) -> Result<()> {
549 Ok(())
550 }
551
552 /// Defines the application's routing configuration.
553 fn routes(_ctx: &AppContext) -> AppRoutes;
554
555 // Provides the options to change Loco [`AppContext`] after initialization.
556 async fn after_context(ctx: AppContext) -> Result<AppContext> {
557 Ok(ctx)
558 }
559
560 /// Connects custom workers to the application using the provided
561 /// [`Queue`] and [`AppContext`].
562 async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()>;
563
564 /// Registers custom tasks with the provided [`Tasks`] object.
565 fn register_tasks(tasks: &mut Tasks);
566
567 /// Truncates the database as required. Users should implement this
568 /// function. The truncate controlled from the [`crate::config::Database`]
569 /// by changing `dangerously_truncate` to true (default false).
570 /// Truncate can be useful when you want to truncate the database before any
571 /// test.
572 #[cfg(feature = "with-db")]
573 async fn truncate(_ctx: &AppContext) -> Result<()>;
574
575 /// Seeds the database with initial data.
576 #[cfg(feature = "with-db")]
577 async fn seed(_ctx: &AppContext, path: &Path) -> Result<()>;
578
579 /// Dumps database tables to YAML fixtures under `base`, the counterpart to
580 /// [`Hooks::seed`] used by `cargo loco db seed --dump`.
581 ///
582 /// The default implementation dumps every table via schema introspection
583 /// ([`crate::db::dump_tables`]). Override it to dump specific entities with
584 /// the typed, streaming [`crate::db::dump`] for full type fidelity and
585 /// bounded memory:
586 ///
587 /// ```ignore
588 /// async fn dump(ctx: &AppContext, base: &Path) -> Result<()> {
589 /// db::dump::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").to_string_lossy()).await?;
590 /// Ok(())
591 /// }
592 /// ```
593 #[cfg(feature = "with-db")]
594 async fn dump(ctx: &AppContext, base: &Path) -> Result<()> {
595 crate::db::dump_tables(&ctx.db, base, None).await
596 }
597
598 /// Called when the application is shutting down.
599 /// This function allows users to perform any necessary cleanup or final
600 /// actions before the application stops completely.
601 async fn on_shutdown(_ctx: &AppContext) {}
602}
603
604/// An initializer.
605/// Initializers should be kept in `src/initializers/`
606///
607/// Initializers can provide health checks by implementing the `check` method.
608/// These checks will be run during the `cargo loco doctor` command to validate
609/// the initializer's configuration and test its connections.
610#[async_trait]
611// <snip id="initializers-trait">
612pub trait Initializer: Sync + Send {
613 /// The initializer name or identifier
614 fn name(&self) -> String;
615
616 /// Occurs after the app's `before_run`.
617 /// Use this to for one-time initializations, load caches, perform web
618 /// hooks, etc.
619 async fn before_run(&self, _app_context: &AppContext) -> Result<()> {
620 Ok(())
621 }
622
623 /// Occurs after the app's `after_routes`.
624 /// Use this to compose additional functionality and wire it into an Axum
625 /// Router
626 async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
627 Ok(router)
628 }
629
630 /// Perform health checks for this initializer.
631 /// This method is called during the doctor command to validate the initializer's configuration.
632 /// Return `None` if no check is needed, or `Some(Check)` if a check should be performed.
633 async fn check(&self, _app_context: &AppContext) -> Result<Option<crate::doctor::Check>> {
634 Ok(None)
635 }
636}
637// </snip>
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::tests_cfg::app::get_app_context;
643
644 struct TestService {
645 name: String,
646 value: i32,
647 }
648
649 #[derive(Clone)]
650 struct CloneableTestService {
651 name: String,
652 value: i32,
653 }
654
655 #[test]
656 fn test_extensions_insert_and_get() {
657 // Setup
658 let shared_store = SharedStore::default();
659
660 shared_store.insert(42i32);
661 assert_eq!(shared_store.get::<i32>().expect("Value should exist"), 42);
662
663 let service = TestService {
664 name: "test".to_string(),
665 value: 100,
666 };
667
668 shared_store.insert(service);
669
670 let service_ref_opt = shared_store.get_ref::<TestService>();
671 assert!(service_ref_opt.is_some(), "Service ref should exist");
672 if let Some(service_ref) = service_ref_opt {
673 assert_eq!(service_ref.name, "test");
674 assert_eq!(service_ref.value, 100);
675 let name_clone = service_ref.name.clone();
676 assert_eq!(name_clone, "test");
677 } else {
678 panic!("Should have gotten Some(service_ref)");
679 }
680 }
681
682 #[test]
683 fn test_extensions_get_without_clone() {
684 let shared_store = SharedStore::default();
685
686 let service = TestService {
687 name: "test_direct".to_string(),
688 value: 100,
689 };
690 shared_store.insert(service);
691
692 let service_ref_opt = shared_store.get_ref::<TestService>();
693 assert!(service_ref_opt.is_some(), "Service ref should exist");
694 if let Some(service_ref) = service_ref_opt {
695 assert_eq!(service_ref.name, "test_direct");
696 assert_eq!(service_ref.value, 100);
697 } else {
698 panic!("Should have gotten Some(service_ref)");
699 }
700
701 let name_len_opt = shared_store.get_ref::<TestService>().map(|r| r.name.len());
702 assert!(
703 name_len_opt.is_some(),
704 "Service ref should exist for len check"
705 );
706 assert_eq!(name_len_opt.unwrap(), 11);
707
708 let value_opt = shared_store.get_ref::<TestService>().map(|r| r.value);
709 assert!(
710 value_opt.is_some(),
711 "Service ref should exist for value check"
712 );
713 assert_eq!(value_opt.unwrap(), 100);
714 }
715
716 #[test]
717 fn test_extensions_remove() {
718 let shared_store = SharedStore::default();
719
720 shared_store.insert(42i32);
721 assert!(shared_store.contains::<i32>());
722 assert_eq!(shared_store.remove::<i32>(), Some(42));
723 assert!(!shared_store.contains::<i32>());
724 assert_eq!(shared_store.remove::<i32>(), None);
725
726 let service = TestService {
727 name: "rem".to_string(),
728 value: 50,
729 };
730 shared_store.insert(service);
731 assert!(shared_store.contains::<TestService>());
732 let removed_opt = shared_store.remove::<TestService>();
733 assert!(removed_opt.is_some());
734 if let Some(removed) = removed_opt {
735 assert_eq!(removed.name, "rem");
736 assert_eq!(removed.value, 50);
737 } else {
738 panic!("Removed option should be Some");
739 }
740 assert!(!shared_store.contains::<TestService>());
741 assert!(shared_store.remove::<TestService>().is_none());
742 }
743
744 #[test]
745 fn test_extensions_contains() {
746 let shared_store = SharedStore::default();
747
748 shared_store.insert(42i32);
749 shared_store.insert(TestService {
750 name: "contains".to_string(),
751 value: 1,
752 });
753
754 assert!(shared_store.contains::<i32>());
755 assert!(shared_store.contains::<TestService>());
756 assert!(!shared_store.contains::<String>());
757 assert!(!shared_store.contains::<CloneableTestService>());
758 }
759
760 #[test]
761 fn test_extensions_get_cloned() {
762 let shared_store = SharedStore::default();
763
764 shared_store.insert(42i32);
765 assert_eq!(shared_store.get::<i32>(), Some(42));
766 assert!(shared_store.contains::<i32>());
767
768 let service = CloneableTestService {
769 name: "cloned_test".to_string(),
770 value: 200,
771 };
772 shared_store.insert(service.clone());
773
774 let service_clone_opt = shared_store.get::<CloneableTestService>();
775 assert!(service_clone_opt.is_some(), "Cloned service should exist");
776 if let Some(ref service_clone) = service_clone_opt {
777 assert_eq!(service_clone.name, "cloned_test");
778 assert_eq!(service_clone.value, 200);
779 } else {
780 panic!("Should have gotten Some(service_clone)");
781 }
782
783 assert!(shared_store.contains::<CloneableTestService>());
784 let original_ref_opt = shared_store.get_ref::<CloneableTestService>();
785 assert!(original_ref_opt.is_some(), "Original ref should exist");
786 if let Some(original_ref) = original_ref_opt {
787 assert_eq!(original_ref.name, "cloned_test");
788 assert_eq!(original_ref.value, 200);
789 } else {
790 panic!("Should have gotten Some(original_ref)");
791 }
792
793 assert_eq!(shared_store.get::<String>(), None);
794 assert!(shared_store.get::<CloneableTestService>().is_some());
795 // The following line correctly fails to compile because TestService doesn't impl Clone,
796 // which is required by the `get` method.
797 // let non_existent_clone = shared_store.get::<TestService>();
798 }
799
800 #[tokio::test]
801 async fn test_app_context_extensions() {
802 let ctx = get_app_context().await;
803
804 let service_cloneable = CloneableTestService {
805 name: "app_context_test_cloneable".to_string(),
806 value: 42,
807 };
808 ctx.shared_store.insert(service_cloneable.clone());
809
810 let ref_opt = ctx.shared_store.get_ref::<CloneableTestService>();
811 assert!(ref_opt.is_some(), "Cloneable service ref should exist");
812 if let Some(service_ref) = ref_opt {
813 assert_eq!(service_ref.name, "app_context_test_cloneable");
814 assert_eq!(service_ref.value, 42);
815 } else {
816 panic!("Should have gotten Some(service_ref)");
817 }
818
819 let clone_opt = ctx.shared_store.get::<CloneableTestService>();
820 assert!(clone_opt.is_some(), "Should get cloned service");
821 if let Some(service_clone) = clone_opt {
822 assert_eq!(service_clone.name, "app_context_test_cloneable");
823 assert_eq!(service_clone.value, 42);
824 } else {
825 panic!("Should have gotten Some(service_clone)");
826 }
827
828 assert!(ctx.shared_store.contains::<CloneableTestService>());
829 assert!(!ctx.shared_store.contains::<String>());
830
831 let removed_cloneable_opt = ctx.shared_store.remove::<CloneableTestService>();
832 assert!(removed_cloneable_opt.is_some());
833 if let Some(removed) = removed_cloneable_opt {
834 assert_eq!(removed.name, "app_context_test_cloneable");
835 assert_eq!(removed.value, 42);
836 } else {
837 panic!("Removed cloneable option should be Some");
838 }
839 assert!(!ctx.shared_store.contains::<CloneableTestService>());
840
841 let service_non_cloneable = TestService {
842 name: "app_context_test_non_cloneable".to_string(),
843 value: 99,
844 };
845 ctx.shared_store.insert(service_non_cloneable);
846
847 let non_clone_ref_opt = ctx.shared_store.get_ref::<TestService>();
848 assert!(
849 non_clone_ref_opt.is_some(),
850 "Non-cloneable service ref should exist"
851 );
852 if let Some(service_ref) = non_clone_ref_opt {
853 assert_eq!(service_ref.name, "app_context_test_non_cloneable");
854 assert_eq!(service_ref.value, 99);
855 } else {
856 panic!("Should have gotten Some(service_ref)");
857 }
858
859 assert!(ctx.shared_store.contains::<TestService>());
860
861 let removed_non_cloneable_opt = ctx.shared_store.remove::<TestService>();
862 assert!(removed_non_cloneable_opt.is_some());
863 if let Some(removed) = removed_non_cloneable_opt {
864 assert_eq!(removed.name, "app_context_test_non_cloneable");
865 assert_eq!(removed.value, 99);
866 } else {
867 panic!("Removed non-cloneable option should be Some");
868 }
869 assert!(!ctx.shared_store.contains::<TestService>());
870 }
871
872 /// `after_context` is documented as the way to swap a component — storage,
873 /// most often — on the booted context. Since `AppContext` was sealed
874 /// `#[non_exhaustive]`, `AppContext { storage, ..ctx }` no longer compiles
875 /// in a user's app, and rebuilding from `AppContext::builder` silently
876 /// drops everything boot already attached. This asserts the round trip
877 /// keeps every component, so replacing one is not a way to lose the rest.
878 #[tokio::test]
879 async fn into_builder_carries_every_component_over() {
880 let mut ctx = get_app_context().await;
881 ctx.mailer = Some(crate::mailer::EmailSender::stub());
882 ctx.shared_store.insert(TestService {
883 name: "attached-at-boot".to_string(),
884 value: 7,
885 });
886
887 let before = ctx.clone();
888 let after = ctx
889 .into_builder()
890 .storage(Storage::single(storage::drivers::null::new()).into())
891 .build();
892
893 // The one component we asked to replace:
894 assert!(
895 !Arc::ptr_eq(&before.storage, &after.storage),
896 "storage should be the one we set"
897 );
898
899 // Everything else survives, by identity — not merely by being non-None.
900 assert!(
901 Arc::ptr_eq(&before.cache, &after.cache),
902 "cache was dropped"
903 );
904 assert!(
905 Arc::ptr_eq(&before.shared_store, &after.shared_store),
906 "shared store was dropped"
907 );
908 assert!(after.mailer.is_some(), "mailer was dropped");
909 assert_eq!(after.environment, before.environment);
910 assert!(
911 after
912 .shared_store
913 .get_ref::<TestService>()
914 .is_some_and(|service| service.value == 7),
915 "shared store contents were dropped"
916 );
917 }
918}