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