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}