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