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);