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