webx_core/route/scan.rs
1//! Type scanning and automatic service registration logic.
2//!
3//! In the rust-webx framework, "scanning" is achieved at compile time via:
4//!
5//! 1. `#[rust_dix::module]` + `rust_dix::inject!` — declare handlers in a module group
6//! 2. `#[endpoint]` (or shortcuts `#[get]`, `#[post]`, `#[put]`, `#[delete]`) — register route metadata via `inventory::submit!`
7//!
8//! This module provides the `RouteEntry` type that connects compile-time
9//! macro output to runtime routing.
10
11use crate::http::ResponseBody;
12use crate::localization::TextRef;
13use crate::routing::HttpMethod;
14use std::any::Any;
15use std::collections::HashMap;
16use std::sync::{Arc, OnceLock, RwLock};
17
18// --- Deprecated process-wide fallback (prefer [`DispatchRuntime`] on `Host`) ---
19
20static GLOBAL_PROVIDER: RwLock<Option<Arc<rust_dix::ServiceProvider>>> = RwLock::new(None);
21
22/// Deprecated shim: set a process-wide `ServiceProvider` fallback.
23///
24/// `Host::build()` no longer calls this. Prefer `Host::provider()` or run work
25/// inside `Host::dispatch_runtime().run()` so `dispatch_provider()` resolves
26/// from the active runtime.
27#[deprecated(
28 since = "0.2.0",
29 note = "Use Host::provider() or dispatch_provider() within an active DispatchRuntime"
30)]
31pub fn set_global_provider(provider: Arc<rust_dix::ServiceProvider>) {
32 let mut guard = GLOBAL_PROVIDER
33 .write()
34 .expect("global provider lock poisoned");
35 if guard.is_some() {
36 tracing::warn!(
37 "Replacing deprecated global ServiceProvider. \
38 Prefer Host::dispatch_runtime() for per-host isolation."
39 );
40 }
41 *guard = Some(provider);
42}
43
44/// Deprecated shim: read the process-wide `ServiceProvider` fallback.
45///
46/// Panics when no shim was set and no [`DispatchRuntime`](crate::DispatchRuntime)
47/// is active on this task.
48#[deprecated(
49 since = "0.2.0",
50 note = "Use Host::provider() or dispatch_provider() within an active DispatchRuntime"
51)]
52pub fn global_provider() -> Arc<rust_dix::ServiceProvider> {
53 try_global_provider().unwrap_or_else(|| {
54 panic!(
55 "global_provider() is deprecated and not set. \
56 Use host.provider() for the host instance, or dispatch_provider() \
57 inside Host::dispatch_runtime().run() (HTTP and hosted services are scoped automatically)."
58 )
59 })
60}
61
62/// Internal fallback for `dispatch_provider()` when no task-local runtime is active.
63pub(crate) fn try_global_provider() -> Option<Arc<rust_dix::ServiceProvider>> {
64 GLOBAL_PROVIDER
65 .read()
66 .expect("global provider lock poisoned")
67 .clone()
68}
69
70/// Metadata about a request parameter for OpenAPI generation.
71#[derive(Debug, Clone)]
72pub struct ParamMeta {
73 /// Field name (e.g., "id", "body").
74 pub name: &'static str,
75
76 /// Parameter location: "path", "query", "header", "form", "body",
77 /// "claim", or "" for an unmarked field that the route method resolves.
78 pub source: &'static str,
79
80 /// Type hint for OpenAPI schema (e.g., "string", "integer", "object").
81 pub type_hint: &'static str,
82
83 /// Whether the parameter must be present (`Option<T>` and `Vec<T>` are
84 /// not required; path parameters always are).
85 pub required: bool,
86
87 /// The JSON object key this parameter binds from, when it can bind from a
88 /// JSON body: an explicit `#[frombody]` field, a composite field, or an
89 /// unmarked field on a write method (the route method decides; `source` is
90 /// `""` for those).
91 ///
92 /// `None` for parameters bound from the path, query, header, form or
93 /// claims, and for a body field with no JSON key of its own — a
94 /// `#[serde(flatten)]` field spreads its inner properties into the body
95 /// object instead of owning one key.
96 pub json_key: Option<&'static str>,
97}
98
99/// One `#[authorize]` declaration on an endpoint.
100///
101/// Every rule on an endpoint must be satisfied (AND). Within a rule, a non-empty
102/// `roles` list means "the user has any of these roles" and a non-empty
103/// `permissions` list means "the user has any of these permissions"; the two
104/// dimensions are ANDed. Empty lists leave a dimension unconstrained, so a rule
105/// with both empty means "any authenticated user".
106///
107/// Multiple values in one declaration
108/// (`#[authorize(roles = ["admin", "auditor"])]`) express alternatives; multiple
109/// `#[authorize]` attributes express requirements that all have to hold.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
111pub struct AuthorizeRule {
112 /// Any-of role names; empty = no role constraint.
113 pub roles: &'static [&'static str],
114
115 /// Any-of permission names; empty = no permission constraint.
116 pub permissions: &'static [&'static str],
117}
118
119impl AuthorizeRule {
120 /// A rule that only requires a valid authenticated principal.
121 pub const fn authenticated() -> Self {
122 Self {
123 roles: &[],
124 permissions: &[],
125 }
126 }
127
128 /// Whether this rule is satisfied by the given claims.
129 ///
130 /// The `"authenticated"` sentinel is accepted in `roles` for hand-written
131 /// metadata; a rule with no constraints has the same meaning.
132 pub fn matches(&self, roles: &[String], permissions: &[String]) -> bool {
133 let role_ok = self.roles.is_empty()
134 || self.roles.iter().any(|required| {
135 *required == "authenticated" || roles.iter().any(|actual| actual == required)
136 });
137 let permission_ok = self.permissions.is_empty()
138 || self
139 .permissions
140 .iter()
141 .any(|required| permissions.iter().any(|actual| actual == required));
142 role_ok && permission_ok
143 }
144}
145
146/// A route entry registered at compile time via `#[endpoint]` (or its shortcuts `#[get]`/`#[post]`/...).
147///
148/// Collected by the `inventory` crate and read at application startup.
149#[derive(Debug, Clone)]
150pub struct RouteEntry {
151 /// HTTP method for this route.
152 pub method: HttpMethod,
153
154 /// Route path pattern (e.g., "/users/{id}").
155 pub path: &'static str,
156
157 /// Compile-time identity of the request type this route dispatches to.
158 ///
159 /// OpenAPI looks the request type's parameter metadata up by this identity,
160 /// so a qualified `self_ty` (`contracts::ListOrdersRequest`) and two
161 /// same-named request types in different modules/crates stay distinct.
162 /// `TypeId::of::<()>()` is the "not provided" sentinel written by
163 /// [`RouteEntry::new`]; [`RouteEntry::with_type_id`] fills it in.
164 pub req_type_id: std::any::TypeId,
165
166 /// Type name of the request handler.
167 /// Used to dispatch to the correct handler at runtime.
168 pub handler_type: &'static str,
169
170 /// OpenAPI response type name (e.g., `"UserModel"`, `"Vec<UserModel>"`, `"String"`).
171 pub rsp_type: &'static str,
172
173 /// Human-readable endpoint name, as a resource reference.
174 ///
175 /// From `#[display]`, else the first line of the impl block's `///` doc
176 /// comment, else a name generated from the request type.
177 pub name: TextRef,
178
179 /// Long-form description, as a resource reference.
180 ///
181 /// From `#[description]`, else the impl block's `///` doc comment.
182 pub description: TextRef,
183
184 /// Optional grouping label, as a resource reference.
185 ///
186 /// From `#[category]`; empty when the endpoint is uncategorized. Categories
187 /// are for display grouping only — they do not affect routing or
188 /// authorization.
189 pub category: TextRef,
190
191 /// OpenAPI parameter metadata: path params, body params, etc.
192 pub params: &'static [ParamMeta],
193
194 /// Authorization rules from `#[authorize]`; empty = public endpoint.
195 pub authorization: &'static [AuthorizeRule],
196}
197
198// Collect RouteEntry instances at compile time using `inventory`.
199inventory::collect!(RouteEntry);
200
201/// Optional query/path/body metadata for a request type, collected via
202/// `#[derive(WebxRequestMeta)]` on the request struct.
203#[derive(Debug, Clone)]
204pub struct RequestParamEntry {
205 /// Request type name, kept for diagnostics and for the legacy name lookup.
206 ///
207 /// The bare name is not unique (qualified paths, same-named types in
208 /// different modules), so consumers must prefer [`Self::req_type_id`].
209 pub request_type: &'static str,
210
211 /// Compile-time identity of the request type — the authoritative key.
212 ///
213 /// The derive is the only producer of real values. Hand-written entries can
214 /// use `TypeId::of::<()>()` as the "unknown" sentinel and fall back to the
215 /// name.
216 pub req_type_id: std::any::TypeId,
217
218 pub params: &'static [ParamMeta],
219}
220
221inventory::collect!(RequestParamEntry);
222
223/// Handler registration collected at compile time.
224/// Each `#[handler]` annotation submits one of these to inventory.
225///
226/// The factory is called **per request** with the request-scoped `IServiceResolver`
227/// so it can resolve Scoped dependencies (e.g. `DbContext`) via `get_owned`.
228/// The result is an owned, type-erased handler — no `Arc<dyn Trait>` caching,
229/// so `handle(&mut self)` works without interior mutability.
230pub struct HandlerRegistration {
231 /// Compile-time type id of the request type — primary dispatch key.
232 pub req_type_id: std::any::TypeId,
233 /// Request type name (e.g., "HelloRequest") — used for diagnostics and route matching.
234 pub req_type_name: &'static str,
235 /// Constructs a fresh owned handler, boxed as `Box<dyn Any + Send>`.
236 /// Called per-request with the per-request scope as resolver.
237 pub factory:
238 fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn std::any::Any + Send>>,
239 /// Type-erased call bridge: receives the owned handler and the boxed request,
240 /// invokes `handle(&mut self, req)`, and returns the boxed response.
241 #[allow(clippy::type_complexity)]
242 pub call: fn(
243 handler: Box<dyn std::any::Any + Send>,
244 request: Box<dyn std::any::Any + Send>,
245 ) -> std::pin::Pin<
246 Box<
247 dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
248 + Send,
249 >,
250 >,
251}
252
253inventory::collect!(HandlerRegistration);
254
255/// The signature every route dispatch function implements.
256///
257/// It receives the live request context — headers, route parameters, claims and
258/// the body stream — and returns the response to write.
259pub type RouteDispatchFn = fn(
260 ctx: &mut dyn crate::http::IHttpContext,
261) -> std::pin::Pin<
262 Box<dyn std::future::Future<Output = crate::error::Result<ResponseData>> + Send + '_>,
263>;
264
265/// A dispatch function registered at compile time via the endpoint macros.
266///
267/// The function receives the live request context, so it can inspect headers,
268/// stream the body as `multipart/form-data`, and produce any
269/// [`ResponseData`] — including streamed file downloads.
270pub struct RouteDispatch {
271 pub handler_type: &'static str,
272 pub dispatch: RouteDispatchFn,
273}
274
275/// The HTTP response produced by an endpoint.
276///
277/// Most endpoints never build one directly: the endpoint macro turns a
278/// serializable response value into `application/json`. Return `ResponseData`
279/// from a handler when you need full control over the status, headers and body
280/// — for example to stream a file:
281///
282/// ```ignore
283/// async fn handle(&mut self, req: DownloadReportRequest) -> Result<ResponseData> {
284/// Ok(ResponseData::file(self.reports.path_for(&req.id)?)
285/// .content_type("text/csv")
286/// .download_name("report.csv"))
287/// }
288/// ```
289#[derive(Debug)]
290pub struct ResponseData {
291 /// HTTP status code.
292 pub status: u16,
293 /// Media type. Applied before `headers`, so a header can override it.
294 pub content_type: String,
295 /// Extra response headers, applied in order.
296 pub headers: Vec<(String, String)>,
297 /// The response body.
298 pub body: ResponseBody,
299}
300
301impl ResponseData {
302 /// An empty `200` response.
303 pub fn new(status: u16) -> Self {
304 Self {
305 status,
306 content_type: String::new(),
307 headers: Vec::new(),
308 body: ResponseBody::Bytes(Vec::new()),
309 }
310 }
311
312 /// Serialize `value` as an `application/json` response.
313 ///
314 /// A serialization failure here is a server-side defect in the response
315 /// DTO, so it becomes [`Error::ResponseSerialization`] (500) rather than
316 /// the request-parse [`Error::Serialization`] (400).
317 ///
318 /// [`Error::ResponseSerialization`]: crate::error::Error::ResponseSerialization
319 /// [`Error::Serialization`]: crate::error::Error::Serialization
320 pub fn json<T: serde::Serialize + ?Sized>(value: &T) -> crate::error::Result<Self> {
321 let body = serde_json::to_vec(value).map_err(crate::error::Error::ResponseSerialization)?;
322 Ok(Self {
323 status: 200,
324 content_type: "application/json".to_string(),
325 headers: Vec::new(),
326 body: ResponseBody::Bytes(body),
327 })
328 }
329
330 /// `204 No Content`.
331 pub fn no_content() -> Self {
332 Self {
333 status: 204,
334 content_type: "application/json".to_string(),
335 headers: Vec::new(),
336 body: ResponseBody::Bytes(Vec::new()),
337 }
338 }
339
340 /// A `text/plain; charset=utf-8` response.
341 pub fn text(body: impl Into<String>) -> Self {
342 Self {
343 status: 200,
344 content_type: "text/plain; charset=utf-8".to_string(),
345 headers: Vec::new(),
346 body: ResponseBody::Bytes(body.into().into_bytes()),
347 }
348 }
349
350 /// A `text/html; charset=utf-8` response.
351 pub fn html(body: impl Into<String>) -> Self {
352 Self {
353 status: 200,
354 content_type: "text/html; charset=utf-8".to_string(),
355 headers: Vec::new(),
356 body: ResponseBody::Bytes(body.into().into_bytes()),
357 }
358 }
359
360 /// A binary response. Set [`ResponseData::content_type`] for anything other
361 /// than `application/octet-stream`.
362 pub fn bytes(body: impl Into<Vec<u8>>) -> Self {
363 Self {
364 status: 200,
365 content_type: "application/octet-stream".to_string(),
366 headers: Vec::new(),
367 body: ResponseBody::Bytes(body.into()),
368 }
369 }
370
371 /// Send the file at `path` — ASP.NET Core's `PhysicalFile(...)`.
372 ///
373 /// The host opens the file once, sends `Content-Length`, `Last-Modified` and
374 /// an `ETag`, honours conditional requests, and serves `Range` requests so
375 /// downloads can be resumed. The media type is inferred from the extension
376 /// unless overridden with [`ResponseData::content_type`].
377 pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
378 Self::with_file(crate::http::FileBody::path(path))
379 }
380
381 /// Send an async reader of unknown length — ASP.NET Core's `File(Stream, ...)`.
382 ///
383 /// Nothing is buffered: bytes flow from the reader to the socket. The
384 /// response uses chunked transfer encoding, so there is no `Content-Length`
385 /// and no `Range` support; use [`ResponseData::file_seekable_stream`] when
386 /// the reader knows its length and can seek.
387 ///
388 /// ```ignore
389 /// // Stream a zip being built on the fly.
390 /// let (reader, writer) = tokio::io::duplex(64 * 1024);
391 /// tokio::spawn(async move { zip_into(writer).await });
392 /// Ok(ResponseData::file_stream(reader, "application/zip")
393 /// .download_name("export.zip"))
394 /// ```
395 pub fn file_stream(
396 reader: impl tokio::io::AsyncRead + Send + Unpin + 'static,
397 content_type: impl Into<String>,
398 ) -> Self {
399 Self::with_file(crate::http::FileBody::stream(reader).content_type(content_type))
400 }
401
402 /// Send an async reader of known length that supports seeking.
403 ///
404 /// Keeps exact `Content-Length` and `Range`/`206` support, so clients can
405 /// resume. Pass an [`crate::http::FileBody::entity_tag`] to also get
406 /// conditional requests.
407 pub fn file_seekable_stream(
408 reader: impl tokio::io::AsyncRead + tokio::io::AsyncSeek + Send + Unpin + 'static,
409 len: u64,
410 content_type: impl Into<String>,
411 ) -> Self {
412 Self::with_file(
413 crate::http::FileBody::seekable_stream(reader, len).content_type(content_type),
414 )
415 }
416
417 /// Send a fully specified [`crate::http::FileBody`].
418 ///
419 /// Use this to set the file-specific HTTP knobs: `enable_range_processing`,
420 /// `last_modified` and `entity_tag`.
421 pub fn with_file(file: crate::http::FileBody) -> Self {
422 Self {
423 status: 200,
424 content_type: String::new(),
425 headers: Vec::new(),
426 body: ResponseBody::File(file),
427 }
428 }
429
430 /// Override the status code.
431 pub fn status(mut self, status: u16) -> Self {
432 self.status = status;
433 self
434 }
435
436 /// Override the media type.
437 pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
438 self.content_type = content_type.into();
439 self
440 }
441
442 /// Add a response header.
443 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
444 self.headers.push((key.into(), value.into()));
445 self
446 }
447
448 /// Send the body as a download named `file_name`.
449 ///
450 /// For file bodies the host emits `Content-Disposition` while streaming;
451 /// for in-memory bodies the header is set here.
452 pub fn download_name(mut self, file_name: impl Into<String>) -> Self {
453 self.set_disposition(crate::http::Disposition::Attachment, file_name.into());
454 self
455 }
456
457 /// Present the body inline under `file_name`.
458 pub fn inline_name(mut self, file_name: impl Into<String>) -> Self {
459 self.set_disposition(crate::http::Disposition::Inline, file_name.into());
460 self
461 }
462
463 /// Enable or disable `Range` processing for a file body.
464 ///
465 /// Has no effect on in-memory bodies, which are always sent whole.
466 pub fn enable_range_processing(mut self, enabled: bool) -> Self {
467 if let ResponseBody::File(file) = &mut self.body {
468 file.enable_range_processing = enabled;
469 }
470 self
471 }
472
473 /// Advertise an explicit `Last-Modified`.
474 ///
475 /// For a file body this becomes the response validator; otherwise it is
476 /// written as a plain header.
477 pub fn last_modified(mut self, last_modified: std::time::SystemTime) -> Self {
478 if let ResponseBody::File(file) = &mut self.body {
479 file.last_modified = Some(last_modified);
480 } else {
481 let value = httpdate::fmt_http_date(last_modified);
482 self.headers.push(("last-modified".to_string(), value));
483 }
484 self
485 }
486
487 /// Advertise an explicit `ETag` (include the quotes).
488 ///
489 /// For a file body this becomes the response validator; otherwise it is
490 /// written as a plain header.
491 pub fn entity_tag(mut self, entity_tag: impl Into<String>) -> Self {
492 let entity_tag = entity_tag.into();
493 if let ResponseBody::File(file) = &mut self.body {
494 file.entity_tag = Some(entity_tag);
495 } else {
496 self.headers.push(("etag".to_string(), entity_tag));
497 }
498 self
499 }
500
501 fn set_disposition(&mut self, disposition: crate::http::Disposition, file_name: String) {
502 if let ResponseBody::File(file) = &mut self.body {
503 file.file_name = Some(file_name);
504 file.disposition = disposition;
505 } else {
506 let value = crate::http::content_disposition_value(disposition, &file_name);
507 self.headers
508 .push(("content-disposition".to_string(), value));
509 }
510 }
511}
512
513inventory::collect!(RouteDispatch);
514
515impl RouteEntry {
516 /// Create a new route entry without a request type identity.
517 ///
518 /// Kept for hand-written entries and fixtures; [`RouteEntry::with_type_id`]
519 /// records the request type's `TypeId` so OpenAPI can index its parameter
520 /// metadata unambiguously.
521 #[allow(clippy::too_many_arguments)]
522 pub const fn new(
523 method: HttpMethod,
524 path: &'static str,
525 handler_type: &'static str,
526 rsp_type: &'static str,
527 name: TextRef,
528 description: TextRef,
529 category: TextRef,
530 params: &'static [ParamMeta],
531 authorization: &'static [AuthorizeRule],
532 ) -> Self {
533 Self {
534 method,
535 path,
536 req_type_id: std::any::TypeId::of::<()>(),
537 handler_type,
538 rsp_type,
539 name,
540 description,
541 category,
542 params,
543 authorization,
544 }
545 }
546
547 /// Create a new route entry carrying its request type's `TypeId`.
548 ///
549 /// The endpoint macros use this so OpenAPI resolves parameter metadata by
550 /// type instead of by a name string.
551 #[allow(clippy::too_many_arguments)]
552 pub const fn with_type_id(
553 method: HttpMethod,
554 path: &'static str,
555 req_type_id: std::any::TypeId,
556 handler_type: &'static str,
557 rsp_type: &'static str,
558 name: TextRef,
559 description: TextRef,
560 category: TextRef,
561 params: &'static [ParamMeta],
562 authorization: &'static [AuthorizeRule],
563 ) -> Self {
564 Self {
565 method,
566 path,
567 req_type_id,
568 handler_type,
569 rsp_type,
570 name,
571 description,
572 category,
573 params,
574 authorization,
575 }
576 }
577}
578
579// --- Handler Cache ---
580
581/// Runtime registry of compiled handler registrations.
582///
583/// Built at startup from `HandlerRegistration` inventory items.
584/// Maps request type name to handler entry (factory + call bridge).
585///
586/// Unlike the previous design, entries do **not** cache a handler instance:
587/// the factory is invoked per request with the request-scoped resolver so
588/// Scoped dependencies (DbContext) are freshly owned each time.
589pub struct HandlerCache {
590 pub entries: HashMap<&'static str, HandlerEntry>,
591 pub entries_by_id: HashMap<std::any::TypeId, HandlerEntry>,
592}
593
594/// Deprecated process-wide handler registry fallback (inventory is process-wide).
595static HANDLER_CACHE: OnceLock<Arc<HandlerCache>> = OnceLock::new();
596
597impl HandlerCache {
598 /// Build the cache from all `HandlerRegistration` inventory items.
599 pub fn build() -> Self {
600 let mut entries = HashMap::new();
601 let mut entries_by_id = HashMap::new();
602 for reg in inventory::iter::<HandlerRegistration> {
603 let entry = HandlerEntry {
604 factory: reg.factory,
605 call: reg.call,
606 };
607 entries.insert(reg.req_type_name, entry.clone());
608 entries_by_id.insert(reg.req_type_id, entry);
609 }
610 Self {
611 entries,
612 entries_by_id,
613 }
614 }
615
616 /// Deprecated shim: lazy-build the process-wide handler registry.
617 ///
618 /// `Host::build()` uses `HandlerCache::build()` per instance. This remains only
619 /// as a fallback for in-process `Mediator::send` without an active runtime.
620 #[deprecated(
621 since = "0.2.0",
622 note = "Prefer dispatch_handler_cache() within an active DispatchRuntime"
623 )]
624 pub fn get_or_init() -> Arc<Self> {
625 Arc::clone(HANDLER_CACHE.get_or_init(|| Arc::new(Self::build())))
626 }
627
628 /// Look up a handler entry by request type name (route / OpenAPI diagnostics).
629 pub fn get(&self, req_type_name: &str) -> Option<&HandlerEntry> {
630 self.entries.get(req_type_name)
631 }
632
633 /// Look up a handler entry by request type id (Mediator / in-process dispatch).
634 pub fn get_by_type_id(&self, type_id: std::any::TypeId) -> Option<&HandlerEntry> {
635 self.entries_by_id.get(&type_id)
636 }
637}
638
639/// Preferred name for [`HandlerCache`] — emphasizes registry semantics over caching.
640pub type HandlerRegistry = HandlerCache;
641
642/// A single compiled handler entry in the registry.
643///
644/// Stores only the factory and call bridge — no cached instance.
645/// The factory is called per request to produce a fresh owned handler.
646#[derive(Clone)]
647pub struct HandlerEntry {
648 /// Per-request factory: receives the scoped resolver, returns an owned handler.
649 pub factory: fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn Any + Send>>,
650 /// Type-erased call bridge: invokes `handle(&mut self, req)` on the owned handler.
651 #[allow(clippy::type_complexity)]
652 pub call: fn(
653 handler: Box<dyn Any + Send>,
654 request: Box<dyn Any + Send>,
655 ) -> std::pin::Pin<
656 Box<dyn std::future::Future<Output = crate::error::Result<Box<dyn Any + Send>>> + Send>,
657 >,
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 /// A response DTO that cannot be represented in JSON — the stand-in for the
665 /// server-side defect this mapping exists to catch.
666 struct AlwaysFails;
667
668 impl serde::Serialize for AlwaysFails {
669 fn serialize<S: serde::Serializer>(
670 &self,
671 _serializer: S,
672 ) -> std::result::Result<S::Ok, S::Error> {
673 Err(serde::ser::Error::custom("DTO cannot be serialized"))
674 }
675 }
676
677 #[test]
678 fn response_data_json_failure_is_a_server_error() {
679 let err = ResponseData::json(&AlwaysFails).unwrap_err();
680 assert!(
681 matches!(err, crate::error::Error::ResponseSerialization(_)),
682 "response serialization must not reuse the request-parse variant: {err:?}"
683 );
684 assert_eq!(err.status_code(), 500);
685 assert_eq!(err.public_detail(), None);
686 }
687}