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