Skip to main content

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