Skip to main content

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}