Skip to main content

arcly_http_core/core/
engine.rs

1//! Lock-free DI engine + macro registration descriptors.
2//!
3//! Two-phase lifecycle:
4//!
5//! 1. **Boot.** `DiContainerBuilder` collects [`ProviderDescriptor`]s emitted
6//!    by `#[Injectable]` / `#[Module]`. It topologically sorts them by
7//!    `TypeId` dependency edges, detects cycles deterministically, then
8//!    invokes each provider's `build` function in dependency order. The
9//!    build function receives a `Resolver` view of already-constructed
10//!    singletons.
11//!
12//! 2. **Run.** The container is wrapped in an `Arc<FrozenDiContainer>` shared
13//!    by the router, request contexts, gateways, and plugin tasks. Every read
14//!    is a single `HashMap::get` + downcast, both inlined. Zero locks, zero
15//!    heap allocations on the request path (`Arc::clone` is an atomic bump).
16//!    The container drops when the server stops — no per-launch leak.
17
18use std::any::{Any, TypeId};
19use std::collections::HashMap;
20use std::sync::Arc;
21
22pub type AnyProvider = Arc<dyn Any + Send + Sync>;
23
24/// Build-time resolver passed to each provider's `build` fn. Returns the
25/// already-constructed dependency by `TypeId`. Panics if a dependency hasn't
26/// been built yet — topological order guarantees this can't happen for a
27/// well-formed graph.
28pub struct Resolver<'a> {
29    map: &'a HashMap<TypeId, AnyProvider>,
30}
31
32impl<'a> Resolver<'a> {
33    /// Look up a raw provider by `TypeId`. Hidden from user-facing docs; prefer
34    /// the typed `get<T>()` accessor.
35    #[doc(hidden)]
36    #[inline]
37    pub fn get_any(&self, ty: TypeId) -> Option<&'a AnyProvider> {
38        self.map.get(&ty)
39    }
40
41    /// Resolve a singleton by type as an owned `Arc<T>`.
42    ///
43    /// Clones the `Arc` already held in the resolved map and downcasts it — a
44    /// single atomic refcount bump, fully safe (no lifetime laundering). Used
45    /// by macro-generated `__arcly_build` fns to populate `Inject<T>` fields.
46    /// Panics if the dependency hasn't been built yet — topological order
47    /// guarantees this can't happen for a well-formed graph.
48    #[doc(hidden)]
49    #[inline]
50    pub fn get<T: Send + Sync + 'static>(&self) -> Arc<T> {
51        self.map
52            .get(&TypeId::of::<T>())
53            .cloned()
54            .and_then(|a| a.downcast::<T>().ok())
55            .expect("Arcly DI: dependency requested by a provider was not in the resolved set")
56    }
57}
58
59/// One row in the dependency graph.
60///
61/// `deps_fn` returns the declared dependency `TypeId`s. (We use a function
62/// pointer rather than a `&'static [TypeId]` because `TypeId::of` is not
63/// const on stable.)
64pub struct ProviderDescriptor {
65    pub name: &'static str,
66    pub type_id_fn: fn() -> TypeId,
67    pub deps_fn: fn() -> Vec<TypeId>,
68    pub build: fn(&Resolver<'_>) -> AnyProvider,
69}
70
71/// Emitted by `#[Module]`. Owns its providers, the controllers it exposes,
72/// and the imports it pulls in. Used to walk the module DAG at boot.
73pub struct ModuleDescriptor {
74    pub name: &'static str,
75    pub providers: &'static [&'static ProviderDescriptor],
76    /// Names of controllers (type idents) belonging to this module. Routes
77    /// emitted under these controllers are eligible to mount when this
78    /// module is reachable from the root.
79    pub controllers: &'static [&'static str],
80    /// Submodules to walk transitively. Empty array means "leaf module".
81    pub imports: &'static [fn() -> &'static ModuleDescriptor],
82    /// Names of real-time gateways (type idents) belonging to this module.
83    /// Gateways emitted under these names mount their WebSocket route only
84    /// when the owning module is reachable from the root — same encapsulation
85    /// rule as controllers.
86    pub gateways: &'static [&'static str],
87}
88
89/// Implemented by every `#[Module]`-annotated struct so `App::launch::<RootMod>`
90/// can find the entry point without a runtime registry lookup.
91pub trait Module: 'static {
92    fn descriptor() -> &'static ModuleDescriptor;
93}
94
95/// Mutable builder used only during boot.
96#[derive(Default)]
97pub struct DiContainerBuilder {
98    providers: Vec<&'static ProviderDescriptor>,
99    /// Direct-registered singletons (legacy / hand-wired). Always treated as
100    /// dep-less and appended at the front of the build order.
101    direct: HashMap<TypeId, AnyProvider>,
102    /// Type names of direct registrations, for collision diagnostics.
103    direct_names: HashMap<TypeId, &'static str>,
104    /// Types whose `#[Injectable]` descriptor is deliberately replaced by a
105    /// direct registration (`register_override`). Their descriptors are
106    /// dropped before the topological sort.
107    overridden: std::collections::HashSet<TypeId>,
108}
109
110impl DiContainerBuilder {
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Register a fully-constructed singleton. Useful for primitives and
116    /// configuration values that aren't `#[Injectable]`.
117    ///
118    /// If an `#[Injectable]` provider for the same type also exists, `freeze`
119    /// panics — a silent winner would mask which instance the app runs with.
120    /// Use [`register_override`](Self::register_override) to intentionally
121    /// replace a descriptor-built provider.
122    #[inline]
123    pub fn register<T: Send + Sync + 'static>(&mut self, v: T) -> &mut Self {
124        self.direct.insert(TypeId::of::<T>(), Arc::new(v));
125        self.direct_names
126            .insert(TypeId::of::<T>(), std::any::type_name::<T>());
127        self
128    }
129
130    /// Register a singleton that *replaces* any `#[Injectable]` provider of
131    /// the same type. The descriptor is discarded; everything that depends on
132    /// `T` resolves to this instance. Intended for plugins swapping a core
133    /// service (e.g. a custom `SessionStore`) — explicit, not accidental.
134    #[inline]
135    pub fn register_override<T: Send + Sync + 'static>(&mut self, v: T) -> &mut Self {
136        self.overridden.insert(TypeId::of::<T>());
137        self.register(v)
138    }
139
140    /// Register a provider descriptor (emitted by `#[Injectable]`).
141    #[inline]
142    pub fn add_provider(&mut self, d: &'static ProviderDescriptor) -> &mut Self {
143        self.providers.push(d);
144        self
145    }
146
147    /// Run topological sort, detect cycles, build providers in dependency
148    /// order, then hand back a shared `Arc<FrozenDiContainer>`.
149    ///
150    /// The container is reference-counted rather than `Box::leak`-ed: it lives
151    /// exactly as long as the running server holds a clone (router state,
152    /// request contexts, gateway runtimes, plugin background tasks) and is
153    /// dropped when `App::launch_on_listener` returns — so repeatedly launching
154    /// in one process (tests) no longer accumulates leaked containers.
155    ///
156    /// Panics if a direct registration collides with an `#[Injectable]`
157    /// descriptor without an explicit override — previously the descriptor
158    /// silently won, which made plugin-side "overrides" no-ops.
159    pub fn freeze(self) -> Arc<FrozenDiContainer> {
160        let providers: Vec<&'static ProviderDescriptor> = self
161            .providers
162            .into_iter()
163            .filter(|d| !self.overridden.contains(&(d.type_id_fn)()))
164            .collect();
165
166        for d in &providers {
167            let ty = (d.type_id_fn)();
168            if self.direct.contains_key(&ty) {
169                let direct_name = self.direct_names.get(&ty).copied().unwrap_or("<unknown>");
170                panic!(
171                    "Arcly DI: `{}` is registered both directly (as `{direct_name}`) and \
172                     via #[Injectable] `{}`. Use `register_override` (plugins: \
173                     `ArclyPluginContext::override_provider`) to replace the injectable, \
174                     or drop one of the registrations.",
175                    d.name, d.name
176                );
177            }
178        }
179
180        let order = Self::topo_sort(&providers, &self.direct);
181
182        let mut map: HashMap<TypeId, AnyProvider> = self.direct;
183        for d in order {
184            let resolver = Resolver { map: &map };
185            let instance = (d.build)(&resolver);
186            map.insert((d.type_id_fn)(), instance);
187        }
188
189        Arc::new(FrozenDiContainer { map })
190    }
191
192    /// Kahn's algorithm with deterministic ordering for stable build traces.
193    fn topo_sort(
194        providers: &[&'static ProviderDescriptor],
195        direct: &HashMap<TypeId, AnyProvider>,
196    ) -> Vec<&'static ProviderDescriptor> {
197        use std::collections::VecDeque;
198
199        // Index providers by their produced TypeId.
200        let mut by_ty: HashMap<TypeId, &'static ProviderDescriptor> = HashMap::new();
201        for p in providers {
202            let prev = by_ty.insert((p.type_id_fn)(), *p);
203            if prev.is_some() {
204                panic!("Arcly DI: provider for `{}` registered twice", p.name);
205            }
206        }
207
208        // Compute in-degree: number of deps that are themselves providers.
209        // Deps satisfied by direct-registered singletons are free.
210        let mut indeg: HashMap<TypeId, usize> = HashMap::new();
211        let mut adj: HashMap<TypeId, Vec<TypeId>> = HashMap::new();
212        for p in providers {
213            let ty = (p.type_id_fn)();
214            let mut d = 0usize;
215            for dep in (p.deps_fn)() {
216                if by_ty.contains_key(&dep) {
217                    d += 1;
218                    adj.entry(dep).or_default().push(ty);
219                } else if direct.contains_key(&dep) {
220                    // satisfied by direct singleton — no edge
221                } else {
222                    panic!(
223                        "Arcly DI: provider `{}` depends on type `{:?}` which has no registered provider",
224                        p.name, dep
225                    );
226                }
227            }
228            indeg.insert(ty, d);
229        }
230
231        let mut q: VecDeque<TypeId> = indeg
232            .iter()
233            .filter_map(|(k, &v)| (v == 0).then_some(*k))
234            .collect();
235        let mut order: Vec<&'static ProviderDescriptor> = Vec::with_capacity(providers.len());
236
237        while let Some(ty) = q.pop_front() {
238            order.push(by_ty[&ty]);
239            if let Some(children) = adj.get(&ty) {
240                for c in children {
241                    let e = indeg.get_mut(c).unwrap();
242                    *e -= 1;
243                    if *e == 0 {
244                        q.push_back(*c);
245                    }
246                }
247            }
248        }
249
250        if order.len() != providers.len() {
251            let unresolved: Vec<&'static str> = providers
252                .iter()
253                .filter(|p| !order.iter().any(|q| (q.type_id_fn)() == (p.type_id_fn)()))
254                .map(|p| p.name)
255                .collect();
256            panic!("Arcly DI: dependency cycle detected. Unresolved providers: {unresolved:?}");
257        }
258        order
259    }
260}
261
262/// Immutable, lock-free DI container. Reads are O(1) and inlined.
263pub struct FrozenDiContainer {
264    map: HashMap<TypeId, AnyProvider>,
265}
266
267impl FrozenDiContainer {
268    #[inline(always)]
269    pub fn get<T: Send + Sync + 'static>(&self) -> &T {
270        self.map
271            .get(&TypeId::of::<T>())
272            .and_then(|a| a.downcast_ref::<T>())
273            .expect("Arcly DI: missing provider")
274    }
275
276    /// Non-panicking variant. Returns `None` when the type has not been provided.
277    /// Used in `boundary.rs` to skip JWT decoding when no `JwtService` is registered.
278    #[inline]
279    pub fn try_get<T: Send + Sync + 'static>(&self) -> Option<&T> {
280        self.map
281            .get(&TypeId::of::<T>())
282            .and_then(|a| a.downcast_ref::<T>())
283    }
284
285    /// Resolve a singleton as an owned `Arc<T>` (one atomic refcount bump).
286    /// Backs `Inject<T>` resolution at request entry — owned handle, no
287    /// lifetime laundering. Panics if the type was never provided.
288    #[inline]
289    pub fn get_arc<T: Send + Sync + 'static>(&self) -> Arc<T> {
290        self.map
291            .get(&TypeId::of::<T>())
292            .cloned()
293            .and_then(|a| a.downcast::<T>().ok())
294            .expect("Arcly DI: missing provider")
295    }
296
297    /// Produce a borrowing [`Resolver`] view over the frozen singleton set.
298    ///
299    /// `Resolver::get<T>` clones the `Arc<T>` held in the map — fully safe, no
300    /// lifetime laundering. Only macro-generated `__arcly_build` functions call
301    /// this; it is `#[doc(hidden)]` to keep it out of application code.
302    #[doc(hidden)]
303    #[inline]
304    pub fn resolver(&self) -> Resolver<'_> {
305        Resolver { map: &self.map }
306    }
307}
308
309// ─── HTTP method + route descriptors (unchanged shape) ───────────────────
310
311#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
312pub enum HttpMethod {
313    GET,
314    POST,
315    PUT,
316    DELETE,
317    PATCH,
318}
319
320impl From<HttpMethod> for axum::http::Method {
321    #[inline]
322    fn from(m: HttpMethod) -> Self {
323        match m {
324            HttpMethod::GET => axum::http::Method::GET,
325            HttpMethod::POST => axum::http::Method::POST,
326            HttpMethod::PUT => axum::http::Method::PUT,
327            HttpMethod::DELETE => axum::http::Method::DELETE,
328            HttpMethod::PATCH => axum::http::Method::PATCH,
329        }
330    }
331}
332
333#[derive(Clone, Copy, Debug)]
334pub enum ParamLoc {
335    Path,
336    Query,
337    Header,
338}
339
340pub struct ParamSpec {
341    pub name: &'static str,
342    pub loc: ParamLoc,
343    pub required: bool,
344    pub schema: fn() -> serde_json::Value,
345}
346
347pub struct RouteSpec {
348    pub summary: &'static str,
349    pub description: &'static str,
350    pub operation_id: &'static str,
351    pub tags: &'static [&'static str],
352    pub security: &'static [&'static str],
353    pub status_code: Option<u16>,
354    pub deprecated: bool,
355    pub params: &'static [ParamSpec],
356    pub has_body: bool,
357    pub body_schema: Option<fn() -> serde_json::Value>,
358    /// Request-body media type for OpenAPI (`requestBody.content` key).
359    /// Defaults to `application/json`; `#[Multipart]` sets it to
360    /// `multipart/form-data`.
361    pub consumes: &'static str,
362    pub query_schema: Option<fn() -> serde_json::Value>,
363    pub response_schema: Option<fn() -> serde_json::Value>,
364    /// `#[CacheTTL(N)]` — seconds. 0/None disables the cache interceptor for
365    /// this route regardless of `#[UseInterceptors(CacheInterceptor)]`.
366    pub cache_ttl_secs: u64,
367    /// `#[CacheKey("template")]` — empty string falls back to
368    /// `method + ' ' + path + '?' + query`.
369    pub cache_key: &'static str,
370    /// `#[Version("v1")]` on the controller — empty string = unversioned.
371    /// The version is also baked into the mounted path (`/v1/...`).
372    pub api_version: &'static str,
373    /// `#[Deprecated(sunset = "YYYY-MM-DD")]` — when non-empty the boundary
374    /// adds RFC 8594 `Deprecation`/`Sunset` response headers.
375    pub sunset: &'static str,
376    /// `#[Idempotent(ttl = "…")]` — TTL seconds; 0 = not idempotent.
377    /// Surfaces the `Idempotency-Key` header + 409 + replay header in OpenAPI.
378    pub idempotent_ttl_secs: u64,
379    /// `#[RequirePolicies("…")]` — ABAC actions gating this route.
380    pub policies: &'static [&'static str],
381    /// `#[AuditLog(action, resource)]` — empty action = no audit.
382    pub audit_action: &'static str,
383    pub audit_resource: &'static str,
384    /// `#[Timeout("…")]` — milliseconds; 0 = no route deadline.
385    pub timeout_ms: u64,
386    /// `#[Transactional]` present on the handler.
387    pub transactional: bool,
388    /// `#[MaskFields("…")]` — route-local PII rules (surfaced in OpenAPI).
389    pub mask_fields: &'static [&'static str],
390}
391
392pub struct RouteDescriptor {
393    pub method: HttpMethod,
394    pub path: &'static str,
395    pub handler: fn(
396        crate::web::context::RequestContext,
397    ) -> futures::future::BoxFuture<'static, axum::response::Response>,
398    pub spec: &'static RouteSpec,
399    /// Owning controller's type name. Empty string for free-fn routes (those
400    /// always mount). Routes whose controller is not in the reachable module
401    /// DAG are skipped at launch — enforcing NestJS-style encapsulation.
402    pub controller: &'static str,
403}
404
405inventory::collect!(&'static ModuleDescriptor);
406inventory::collect!(&'static RouteDescriptor);
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::sync::Weak;
412
413    // Holds the canary alive; the field is read only via the `Weak` upgrade.
414    #[allow(dead_code)]
415    struct Marker(Arc<()>);
416
417    /// The frozen container is reference-counted, not leaked: once the last
418    /// `Arc` clone is dropped (server stopped), the container and every
419    /// singleton it owns are freed. This is the regression guard for the
420    /// pre-0.6 `Box::leak` that accumulated a container per launch.
421    #[test]
422    fn frozen_container_drops_with_its_singletons() {
423        let canary = Arc::new(());
424        let weak: Weak<()> = Arc::downgrade(&canary);
425
426        let mut b = DiContainerBuilder::new();
427        b.register(Marker(canary)); // singleton owns the only strong ref
428        let container = b.freeze();
429
430        // While the container lives, the singleton (and its canary) live.
431        assert!(
432            weak.upgrade().is_some(),
433            "singleton alive while container held"
434        );
435        assert_eq!(Arc::strong_count(&container), 1);
436
437        drop(container);
438
439        // Container gone → singleton dropped → no leak.
440        assert!(
441            weak.upgrade().is_none(),
442            "container drop must free its singletons (no per-launch leak)"
443        );
444    }
445
446    /// `get_arc` yields an owned clone whose lifetime is independent of the
447    /// container borrow — the safe replacement for the old `&'static` laundering.
448    #[test]
449    fn get_arc_returns_independent_owned_handle() {
450        let mut b = DiContainerBuilder::new();
451        b.register(7u32);
452        let container = b.freeze();
453        let handle: Arc<u32> = container.get_arc::<u32>();
454        assert_eq!(*handle, 7);
455        assert_eq!(Arc::strong_count(&handle), 2); // map + handle
456    }
457}