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}
113
114impl DiContainerBuilder {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Register a fully-constructed singleton. Useful for primitives and
120    /// configuration values that aren't `#[Injectable]`.
121    #[inline]
122    pub fn register<T: Send + Sync + 'static>(&mut self, v: T) -> &mut Self {
123        self.direct.insert(TypeId::of::<T>(), Arc::new(v));
124        self
125    }
126
127    /// Register a provider descriptor (emitted by `#[Injectable]`).
128    #[inline]
129    pub fn add_provider(&mut self, d: &'static ProviderDescriptor) -> &mut Self {
130        self.providers.push(d);
131        self
132    }
133
134    /// Run topological sort, detect cycles, build providers in dependency
135    /// order, then leak as `&'static`.
136    pub fn freeze(self) -> &'static FrozenDiContainer {
137        let order = Self::topo_sort(&self.providers, &self.direct);
138
139        let mut map: HashMap<TypeId, AnyProvider> = self.direct;
140        for d in order {
141            let resolver = Resolver { map: &map };
142            let instance = (d.build)(&resolver);
143            map.insert((d.type_id_fn)(), instance);
144        }
145
146        Box::leak(Box::new(FrozenDiContainer { map }))
147    }
148
149    /// Kahn's algorithm with deterministic ordering for stable build traces.
150    fn topo_sort(
151        providers: &[&'static ProviderDescriptor],
152        direct: &HashMap<TypeId, AnyProvider>,
153    ) -> Vec<&'static ProviderDescriptor> {
154        use std::collections::VecDeque;
155
156        // Index providers by their produced TypeId.
157        let mut by_ty: HashMap<TypeId, &'static ProviderDescriptor> = HashMap::new();
158        for p in providers {
159            let prev = by_ty.insert((p.type_id_fn)(), *p);
160            if prev.is_some() {
161                panic!("Arcly DI: provider for `{}` registered twice", p.name);
162            }
163        }
164
165        // Compute in-degree: number of deps that are themselves providers.
166        // Deps satisfied by direct-registered singletons are free.
167        let mut indeg: HashMap<TypeId, usize> = HashMap::new();
168        let mut adj: HashMap<TypeId, Vec<TypeId>> = HashMap::new();
169        for p in providers {
170            let ty = (p.type_id_fn)();
171            let mut d = 0usize;
172            for dep in (p.deps_fn)() {
173                if by_ty.contains_key(&dep) {
174                    d += 1;
175                    adj.entry(dep).or_default().push(ty);
176                } else if direct.contains_key(&dep) {
177                    // satisfied by direct singleton — no edge
178                } else {
179                    panic!(
180                        "Arcly DI: provider `{}` depends on type `{:?}` which has no registered provider",
181                        p.name, dep
182                    );
183                }
184            }
185            indeg.insert(ty, d);
186        }
187
188        let mut q: VecDeque<TypeId> = indeg
189            .iter()
190            .filter_map(|(k, &v)| (v == 0).then_some(*k))
191            .collect();
192        let mut order: Vec<&'static ProviderDescriptor> = Vec::with_capacity(providers.len());
193
194        while let Some(ty) = q.pop_front() {
195            order.push(by_ty[&ty]);
196            if let Some(children) = adj.get(&ty) {
197                for c in children {
198                    let e = indeg.get_mut(c).unwrap();
199                    *e -= 1;
200                    if *e == 0 {
201                        q.push_back(*c);
202                    }
203                }
204            }
205        }
206
207        if order.len() != providers.len() {
208            let unresolved: Vec<&'static str> = providers
209                .iter()
210                .filter(|p| !order.iter().any(|q| (q.type_id_fn)() == (p.type_id_fn)()))
211                .map(|p| p.name)
212                .collect();
213            panic!("Arcly DI: dependency cycle detected. Unresolved providers: {unresolved:?}");
214        }
215        order
216    }
217}
218
219/// Immutable, lock-free DI container. Reads are O(1) and inlined.
220pub struct FrozenDiContainer {
221    map: HashMap<TypeId, AnyProvider>,
222}
223
224impl FrozenDiContainer {
225    #[inline(always)]
226    pub fn get<T: Send + Sync + 'static>(&self) -> &T {
227        self.map
228            .get(&TypeId::of::<T>())
229            .and_then(|a| a.downcast_ref::<T>())
230            .expect("Arcly DI: missing provider")
231    }
232
233    /// Non-panicking variant. Returns `None` when the type has not been provided.
234    /// Used in `boundary.rs` to skip JWT decoding when no `JwtService` is registered.
235    #[inline]
236    pub fn try_get<T: Send + Sync + 'static>(&self) -> Option<&T> {
237        self.map
238            .get(&TypeId::of::<T>())
239            .and_then(|a| a.downcast_ref::<T>())
240    }
241
242    /// Produce a borrowing [`Resolver`] view over the frozen singleton set.
243    ///
244    /// # Safety (invariant)
245    ///
246    /// `Resolver::get<T>` extends borrows to `'static` under the assumption that
247    /// `self` has been `Box::leak`-ed (i.e. produced by `freeze()` and never
248    /// dropped).  Only macro-generated `__arcly_build` functions call this; it
249    /// is `#[doc(hidden)]` to prevent accidental use from application code.
250    #[doc(hidden)]
251    #[inline]
252    pub fn resolver(&self) -> Resolver<'_> {
253        Resolver { map: &self.map }
254    }
255}
256
257// ─── HTTP method + route descriptors (unchanged shape) ───────────────────
258
259#[derive(Clone, Copy, Debug)]
260pub enum HttpMethod {
261    GET,
262    POST,
263    PUT,
264    DELETE,
265    PATCH,
266}
267
268impl From<HttpMethod> for axum::http::Method {
269    #[inline]
270    fn from(m: HttpMethod) -> Self {
271        match m {
272            HttpMethod::GET => axum::http::Method::GET,
273            HttpMethod::POST => axum::http::Method::POST,
274            HttpMethod::PUT => axum::http::Method::PUT,
275            HttpMethod::DELETE => axum::http::Method::DELETE,
276            HttpMethod::PATCH => axum::http::Method::PATCH,
277        }
278    }
279}
280
281#[derive(Clone, Copy, Debug)]
282pub enum ParamLoc {
283    Path,
284    Query,
285    Header,
286}
287
288pub struct ParamSpec {
289    pub name: &'static str,
290    pub loc: ParamLoc,
291    pub required: bool,
292    pub schema: fn() -> serde_json::Value,
293}
294
295pub struct RouteSpec {
296    pub summary: &'static str,
297    pub description: &'static str,
298    pub operation_id: &'static str,
299    pub tags: &'static [&'static str],
300    pub security: &'static [&'static str],
301    pub status_code: Option<u16>,
302    pub deprecated: bool,
303    pub params: &'static [ParamSpec],
304    pub has_body: bool,
305    pub body_schema: Option<fn() -> serde_json::Value>,
306    pub query_schema: Option<fn() -> serde_json::Value>,
307    pub response_schema: Option<fn() -> serde_json::Value>,
308    /// `#[CacheTTL(N)]` — seconds. 0/None disables the cache interceptor for
309    /// this route regardless of `#[UseInterceptors(CacheInterceptor)]`.
310    pub cache_ttl_secs: u64,
311    /// `#[CacheKey("template")]` — empty string falls back to
312    /// `method + ' ' + path + '?' + query`.
313    pub cache_key: &'static str,
314    /// `#[Version("v1")]` on the controller — empty string = unversioned.
315    /// The version is also baked into the mounted path (`/v1/...`).
316    pub api_version: &'static str,
317    /// `#[Deprecated(sunset = "YYYY-MM-DD")]` — when non-empty the boundary
318    /// adds RFC 8594 `Deprecation`/`Sunset` response headers.
319    pub sunset: &'static str,
320    /// `#[Idempotent(ttl = "…")]` — TTL seconds; 0 = not idempotent.
321    /// Surfaces the `Idempotency-Key` header + 409 + replay header in OpenAPI.
322    pub idempotent_ttl_secs: u64,
323    /// `#[RequirePolicies("…")]` — ABAC actions gating this route.
324    pub policies: &'static [&'static str],
325    /// `#[AuditLog(action, resource)]` — empty action = no audit.
326    pub audit_action: &'static str,
327    pub audit_resource: &'static str,
328    /// `#[Timeout("…")]` — milliseconds; 0 = no route deadline.
329    pub timeout_ms: u64,
330    /// `#[Transactional]` present on the handler.
331    pub transactional: bool,
332    /// `#[MaskFields("…")]` — route-local PII rules (surfaced in OpenAPI).
333    pub mask_fields: &'static [&'static str],
334}
335
336pub struct RouteDescriptor {
337    pub method: HttpMethod,
338    pub path: &'static str,
339    pub handler: fn(
340        crate::web::context::RequestContext,
341    ) -> futures::future::BoxFuture<'static, axum::response::Response>,
342    pub spec: &'static RouteSpec,
343    /// Owning controller's type name. Empty string for free-fn routes (those
344    /// always mount). Routes whose controller is not in the reachable module
345    /// DAG are skipped at launch — enforcing NestJS-style encapsulation.
346    pub controller: &'static str,
347}
348
349inventory::collect!(&'static ModuleDescriptor);
350inventory::collect!(&'static RouteDescriptor);