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 Box<dyn std::any::Any + Send>,
545) -> BoxedDispatchFuture;
546
547/// Inventory item tying a request type to its type-erased dispatch function.
548pub struct BoxedDispatch {
549 /// The request type's `TypeId`.
550 pub req_type_id: std::any::TypeId,
551 /// Dispatch for that request type.
552 pub dispatch: BoxedDispatchFn,
553}
554
555inventory::collect!(BoxedDispatch);
556
557/// Runtime registry of compiled handler registrations.
558///
559/// Built at startup from `HandlerRegistration` inventory items.
560/// Maps request type name to handler entry (factory + call bridge).
561///
562/// Unlike the previous design, entries do **not** cache a handler instance:
563/// the factory is invoked per request with the request-scoped resolver so
564/// Scoped dependencies (DbContext) are freshly owned each time.
565pub struct HandlerCache {
566 pub entries: HashMap<&'static str, HandlerEntry>,
567 pub entries_by_id: HashMap<std::any::TypeId, HandlerEntry>,
568 boxed_dispatch: HashMap<std::any::TypeId, BoxedDispatchFn>,
569}
570
571impl HandlerCache {
572 /// Build the cache from all `HandlerRegistration` inventory items.
573 pub fn build() -> Self {
574 let mut entries = HashMap::new();
575 let mut entries_by_id = HashMap::new();
576 for reg in inventory::iter::<HandlerRegistration> {
577 let entry = HandlerEntry {
578 factory: reg.factory,
579 call: reg.call,
580 };
581 entries.insert(reg.req_type_name, entry.clone());
582 entries_by_id.insert(reg.req_type_id, entry);
583 }
584 let mut boxed_dispatch = HashMap::new();
585 for reg in inventory::iter::<BoxedDispatch> {
586 boxed_dispatch.insert(reg.req_type_id, reg.dispatch);
587 }
588 Self {
589 entries,
590 entries_by_id,
591 boxed_dispatch,
592 }
593 }
594
595 /// Look up a handler entry by request type name (route / OpenAPI diagnostics).
596 pub fn get(&self, req_type_name: &str) -> Option<&HandlerEntry> {
597 self.entries.get(req_type_name)
598 }
599
600 /// Look up a handler entry by request type id (Mediator / in-process dispatch).
601 pub fn get_by_type_id(&self, type_id: std::any::TypeId) -> Option<&HandlerEntry> {
602 self.entries_by_id.get(&type_id)
603 }
604
605 /// Type-erased dispatch for `IMediator::send_boxed`.
606 ///
607 /// `None` when the request type has no `#[handler]` (or a hand-written
608 /// handler registration without the boxed bridge).
609 pub fn boxed_dispatch(&self, type_id: std::any::TypeId) -> Option<BoxedDispatchFn> {
610 self.boxed_dispatch.get(&type_id).copied()
611 }
612}
613
614/// Preferred name for [`HandlerCache`] — emphasizes registry semantics over caching.
615pub type HandlerRegistry = HandlerCache;
616
617/// A single compiled handler entry in the registry.
618///
619/// Stores only the factory and call bridge — no cached instance.
620/// The factory is called per request to produce a fresh owned handler.
621#[derive(Clone)]
622pub struct HandlerEntry {
623 /// Per-request factory: receives the scoped resolver, returns an owned handler.
624 pub factory: fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn Any + Send>>,
625 /// Type-erased call bridge: invokes `handle(&mut self, req)` on the owned handler.
626 #[allow(clippy::type_complexity)]
627 pub call: fn(
628 handler: Box<dyn Any + Send>,
629 request: Box<dyn Any + Send>,
630 ) -> std::pin::Pin<
631 Box<dyn std::future::Future<Output = crate::error::Result<Box<dyn Any + Send>>> + Send>,
632 >,
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 /// A response DTO that cannot be represented in JSON — the stand-in for the
640 /// server-side defect this mapping exists to catch.
641 struct AlwaysFails;
642
643 impl serde::Serialize for AlwaysFails {
644 fn serialize<S: serde::Serializer>(
645 &self,
646 _serializer: S,
647 ) -> std::result::Result<S::Ok, S::Error> {
648 Err(serde::ser::Error::custom("DTO cannot be serialized"))
649 }
650 }
651
652 #[test]
653 fn response_data_json_failure_is_a_server_error() {
654 let err = ResponseData::json(&AlwaysFails).unwrap_err();
655 assert!(
656 matches!(err, crate::error::Error::ResponseSerialization(_)),
657 "response serialization must not reuse the request-parse variant: {err:?}"
658 );
659 assert_eq!(err.status_code(), 500);
660 assert_eq!(err.public_detail(), None);
661 }
662}