Skip to main content

webx_core/route/
binding.rs

1//! Request model binding — the runtime half of `#[derive(WebxRequestMeta)]`.
2//!
3//! A request struct declares where each field comes from (`#[frompath]`,
4//! `#[fromquery]`, `#[frombody]`, `#[fromform]`, `#[fromheader]`,
5//! `#[fromclaim]`). The derive compiles that declaration into a
6//! [`RequestModel::bind`] implementation; this module provides the value
7//! providers and coercion helpers that implementation calls.
8//!
9//! # Why not `serde` for query/path/header/claim
10//!
11//! HTTP values are *strings*, often repeated (`?id=1&id=2`), and a failure in
12//! one field must not hide the others. `serde_json` models documents, not HTTP
13//! values, so it cannot coerce `"2"` into `u32`, cannot represent repeated keys,
14//! and fails at the first error. Here every scalar is parsed with [`FromStr`],
15//! lists keep every occurrence, and failures accumulate in a [`Binder`] so the
16//! response can report all of them at once.
17//!
18//! JSON bodies stay with `serde`: the derive generates a hidden body struct
19//! containing only the body-bound fields, so `rename`, `flatten`, `default` and
20//! custom deserializers keep working exactly as written.
21
22use crate::auth::IClaims;
23use crate::error::{Error, Result};
24use crate::form::{FormFile, MultipartForm};
25use crate::http::IHttpContext;
26use crate::problem::{FieldError, ProblemDetails};
27use crate::route::scan::ParamMeta;
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::str::FromStr;
32
33/// Where a request field's value comes from.
34///
35/// [`Source::Auto`] is the absence of an explicit marker; the generated binder
36/// then resolves it from the request method:
37///
38/// * `GET` / `HEAD` / `DELETE` / `OPTIONS` — query string, then the route;
39/// * `POST` / `PUT` / `PATCH` — JSON body, then form, then query string;
40/// * `multipart/form-data` — form, then query string.
41///
42/// Headers and claims are never implicit: a header or token that happens to
43/// share a field name must not silently win.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum Source {
46    /// A `{name}` segment of the route template.
47    Path,
48    /// A query-string key (`?name=value`, repeatable).
49    Query,
50    /// A `multipart/form-data` part (text or file).
51    Form,
52    /// An HTTP header, looked up case-insensitively.
53    Header,
54    /// A claim from the authenticated principal.
55    Claim,
56    /// The JSON request body.
57    Body,
58    /// No marker: inferred from the request method.
59    Auto,
60}
61
62impl Source {
63    /// The OpenAPI `in` value (`""` for [`Source::Auto`], resolved per route).
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Source::Path => "path",
67            Source::Query => "query",
68            Source::Form => "form",
69            Source::Header => "header",
70            Source::Claim => "claim",
71            Source::Body => "body",
72            Source::Auto => "",
73        }
74    }
75
76    /// Whether an explicitly marked field must be present.
77    pub const fn is_explicit(self) -> bool {
78        !matches!(self, Source::Auto)
79    }
80}
81
82/// Whether `method` carries a JSON body by default (ASP.NET Core's rule).
83pub fn is_body_method(method: &str) -> bool {
84    matches!(method, "POST" | "PUT" | "PATCH")
85}
86
87/// A type that can be constructed from an HTTP request.
88///
89/// Implemented by `#[derive(WebxRequestMeta)]`. The endpoint macros call
90/// [`RequestModel::bind`] before dispatching to the handler, so a request type
91/// without the derive does not compile.
92///
93/// ```ignore
94/// #[derive(Default, Deserialize, WebxRequestMeta)]
95/// pub struct GetUserRequest {
96///     #[frompath]
97///     pub id: String,
98///     #[fromquery]
99///     pub expand: Option<String>,
100/// }
101/// ```
102#[diagnostic::on_unimplemented(
103    message = "`{Self}` cannot be bound from an HTTP request",
104    label = "add `#[derive(WebxRequestMeta)]` to this request struct",
105    note = "the endpoint macros call `RequestModel::bind`; the derive is what implements it"
106)]
107pub trait RequestModel: Sized + Send + 'static {
108    /// OpenAPI parameter metadata for this request type.
109    ///
110    /// Sources are `"path"`, `"query"`, `"header"`, `"form"`, `"body"`,
111    /// `"claim"`, or `""` for an unmarked field resolved by the route method.
112    fn param_meta() -> &'static [ParamMeta];
113
114    /// Bind an instance from the live request context.
115    fn bind<'a>(
116        ctx: &'a mut dyn IHttpContext,
117    ) -> Pin<Box<dyn Future<Output = Result<Self>> + Send + 'a>>;
118}
119
120/// The value providers a generated [`RequestModel::bind`] reads from.
121///
122/// Borrowed from the request context: binding never copies the route, query or
123/// header maps, and the form is shared through the request's cached parse.
124pub struct BindInput<'a> {
125    /// Route parameters captured by the router (`{}` segments).
126    pub path: &'a HashMap<String, String>,
127    /// Query string, last value per key (the compatibility view).
128    pub query: &'a HashMap<String, String>,
129    /// Query string, every value per key; `None` when the host does not
130    /// provide multi-value parsing.
131    pub query_multi: Option<&'a HashMap<String, Vec<String>>>,
132    /// The authenticated principal, when the request is authenticated.
133    pub claims: Option<&'a dyn IClaims>,
134    /// The parsed `multipart/form-data` body, when the request is multipart.
135    pub form: Option<&'a MultipartForm>,
136    /// Whether the route method is `POST`, `PUT` or `PATCH`.
137    pub is_body: bool,
138    /// Whether the request declares `multipart/form-data`.
139    pub is_multipart: bool,
140}
141
142impl<'a> BindInput<'a> {
143    /// The route value for `name`.
144    pub fn path_one(&self, name: &str) -> Option<&'a str> {
145        self.path.get(name).map(String::as_str)
146    }
147
148    /// Every route value for `name`, comma-separated lists expanded.
149    ///
150    /// A route segment is a single value, but `{ids}` accepting `1,2,3` is a
151    /// convenient way to bind `Vec<T>` from the path — the same rule query
152    /// values follow.
153    pub fn path_list(&self, name: &str) -> Vec<&'a str> {
154        match self.path.get(name) {
155            Some(value) => split_one(value),
156            None => Vec::new(),
157        }
158    }
159
160    /// The last query value for `name`.
161    pub fn query_one(&self, name: &str) -> Option<&'a str> {
162        if let Some(multi) = self.query_multi {
163            if let Some(values) = multi.get(name) {
164                return values.last().map(String::as_str);
165            }
166        }
167        self.query.get(name).map(String::as_str)
168    }
169
170    /// Every query value for `name`, comma-separated lists expanded.
171    pub fn query_list(&self, name: &str) -> Vec<&'a str> {
172        if let Some(multi) = self.query_multi {
173            if let Some(values) = multi.get(name) {
174                return split_values(values);
175            }
176        }
177        match self.query.get(name) {
178            Some(value) => split_one(value),
179            None => Vec::new(),
180        }
181    }
182
183    /// The first text part named `name` in the form.
184    pub fn form_one(&self, name: &str) -> Option<&'a str> {
185        self.form?.text(name)
186    }
187
188    /// Every text part named `name`, comma-separated lists expanded.
189    pub fn form_list(&self, name: &str) -> Vec<&'a str> {
190        match self.form {
191            Some(form) => form.texts(name).flat_map(split_one).collect(),
192            None => Vec::new(),
193        }
194    }
195
196    /// The first file part named `name`.
197    pub fn file(&self, name: &str) -> Option<&'a FormFile> {
198        self.form?.file(name)
199    }
200
201    /// Every file part named `name`, in order.
202    pub fn files(&self, name: &str) -> Vec<&'a FormFile> {
203        match self.form {
204            Some(form) => form.files_named(name).collect(),
205            None => Vec::new(),
206        }
207    }
208
209    /// A claim value by key.
210    ///
211    /// The raw claim map wins, so a token that literally carries `user_id` or
212    /// `tenant` keeps that value. The dedicated accessors are only a fallback
213    /// when the raw map has no such key: `sub` / `subject` resolve to the
214    /// subject, `tenant_id` / `tenant` to the tenant helper.
215    pub fn claim(&self, key: &str) -> Option<&'a str> {
216        let claims = self.claims?;
217        if let Some(value) = claims.claims().get(key) {
218            return Some(value.as_str());
219        }
220        match key {
221            "sub" | "subject" | "user_id" => Some(claims.subject()),
222            "tenant_id" | "tenant" => claims.get_tenantid(),
223            _ => None,
224        }
225    }
226
227    /// A claim as a list: the raw claim map wins (comma-separated);
228    /// `roles` / `permissions` fall back to their dedicated slots when the raw
229    /// map has no such key.
230    pub fn claim_list(&self, key: &str) -> Vec<&'a str> {
231        let Some(claims) = self.claims else {
232            return Vec::new();
233        };
234        if let Some(value) = claims.claims().get(key) {
235            return split_one(value);
236        }
237        match key {
238            "roles" => claims.roles().iter().map(String::as_str).collect(),
239            "permissions" => claims.permissions().iter().map(String::as_str).collect(),
240            _ => Vec::new(),
241        }
242    }
243}
244
245/// Convert a JSON body deserialization failure into a field-level 400.
246///
247/// `serde_json` reports the offending field for the common cases; those become
248/// `errors.<field>` entries, and everything else is reported at the body level
249/// under the empty key — the same convention ASP.NET Core uses for a body
250/// model that cannot be bound at all.
251pub fn body_error(error: serde_json::Error) -> Error {
252    let message = error.to_string();
253    let field = extract_error_field(&message)
254        .unwrap_or_default()
255        .to_string();
256    ProblemDetails::validation(vec![FieldError::new(field, message)]).to_error()
257}
258
259fn extract_error_field(message: &str) -> Option<&str> {
260    for marker in ["missing field `", "unknown field `", "duplicate field `"] {
261        if let Some((_, rest)) = message.split_once(marker) {
262            if let Some((name, _)) = rest.split_once('`') {
263                return Some(name);
264            }
265        }
266    }
267    None
268}
269
270/// A field-level body failure: the JSON key was present but its value did not
271/// match the field type.
272pub fn body_field_error(field: &str, error: serde_json::Error) -> Error {
273    ProblemDetails::validation(vec![FieldError::new(
274        field,
275        format!("The `{field}` field is invalid: {error}"),
276    )])
277    .to_error()
278}
279
280/// Remove `key` (or one of `aliases`) from a JSON object.
281///
282/// `serde`'s `rename`/`alias` are resolved by the derive; this looks the
283/// resolved keys up and takes ownership of the value, so the remaining map only
284/// holds keys no field claimed (which `deny_unknown_fields` can reject).
285pub fn take_body_field(
286    object: &mut serde_json::Map<String, serde_json::Value>,
287    key: &str,
288    aliases: &[&str],
289) -> Option<serde_json::Value> {
290    if let Some(value) = object.remove(key) {
291        return Some(value);
292    }
293    for alias in aliases {
294        if let Some(value) = object.remove(*alias) {
295            return Some(value);
296        }
297    }
298    None
299}
300
301/// Split one raw value on commas, dropping empty segments.
302///
303/// An empty value is an **empty list**, not a list holding one empty string:
304/// `?ids=` means "present, no ids" (`Option<Vec<T>>` binds `Some(vec![])`).
305fn split_one(value: &str) -> Vec<&str> {
306    if value.is_empty() {
307        Vec::new()
308    } else if value.contains(',') {
309        value
310            .split(',')
311            .map(str::trim)
312            .filter(|part| !part.is_empty())
313            .collect()
314    } else {
315        vec![value]
316    }
317}
318
319/// Expand one optional raw value into a list, splitting on commas.
320///
321/// Public because the derive uses it for `Vec<T>` header fields, which carry a
322/// single comma-separated line.
323pub fn split_csv(value: Option<&str>) -> Vec<&str> {
324    match value {
325        Some(value) => split_one(value),
326        None => Vec::new(),
327    }
328}
329
330/// Split every raw value on commas, flattening the results.
331fn split_values(values: &[String]) -> Vec<&str> {
332    let mut out = Vec::with_capacity(values.len());
333    for value in values {
334        out.extend(split_one(value));
335    }
336    out
337}
338
339/// Accumulates field-level binding failures into one 400 response.
340///
341/// Binding is best-effort: a bad field records an error and yields `None`, the
342/// remaining fields still bind, and the request is rejected once at the end
343/// with every failure listed — matching ASP.NET Core's `ModelState`.
344#[derive(Default)]
345pub struct Binder {
346    errors: Vec<FieldError>,
347}
348
349impl Binder {
350    /// A binder with no recorded errors.
351    pub fn new() -> Self {
352        Self::default()
353    }
354
355    /// Whether any field failed.
356    pub fn has_errors(&self) -> bool {
357        !self.errors.is_empty()
358    }
359
360    /// The recorded failures, in binding order.
361    pub fn errors(&self) -> &[FieldError] {
362        &self.errors
363    }
364
365    /// Consume the binder, yielding the 400 `ProblemDetails` error.
366    ///
367    /// The response body carries `errors` as `{ "field": ["message"] }`.
368    pub fn into_error(self) -> Error {
369        ProblemDetails::validation(self.errors).to_error()
370    }
371
372    /// Record the canonical "required" failure for `field`.
373    pub fn missing(&mut self, field: &str) {
374        self.errors.push(FieldError::new(
375            field,
376            format!("The `{field}` field is required."),
377        ));
378    }
379
380    /// Record an unparsable value for `field`.
381    pub fn invalid(&mut self, field: &str, kind: &str, value: &str) {
382        self.errors.push(FieldError::new(
383            field,
384            format!("The `{field}` field must be a valid {kind} (got `{value}`)."),
385        ));
386    }
387
388    /// Record a JSON key that no field claimed (`deny_unknown_fields`).
389    pub fn unknown_field(&mut self, name: &str) {
390        self.errors.push(FieldError::new(
391            "",
392            format!("The body contains an unknown field `{name}`."),
393        ));
394    }
395
396    /// Parse a required scalar.
397    ///
398    /// Returns `None` (and records an error) when the value is absent or
399    /// unparsable; the caller checks [`Binder::has_errors`] before building the
400    /// request.
401    pub fn required<T: FromStr>(
402        &mut self,
403        raw: Option<&str>,
404        field: &str,
405        kind: &str,
406    ) -> Option<T> {
407        let Some(raw) = raw else {
408            self.missing(field);
409            return None;
410        };
411        match raw.parse::<T>() {
412            Ok(value) => Some(value),
413            Err(_) => {
414                self.invalid(field, kind, raw);
415                None
416            }
417        }
418    }
419
420    /// Parse an optional scalar: absent is `None`, present-but-invalid is an
421    /// error (never silently `None`).
422    pub fn optional<T: FromStr>(
423        &mut self,
424        raw: Option<&str>,
425        field: &str,
426        kind: &str,
427    ) -> Option<T> {
428        let raw = raw?;
429        match raw.parse::<T>() {
430            Ok(value) => Some(value),
431            Err(_) => {
432                self.invalid(field, kind, raw);
433                None
434            }
435        }
436    }
437
438    /// Parse every occurrence of a repeated value into a list.
439    ///
440    /// An empty list is valid (ASP.NET Core binds missing collections as
441    /// empty); unparsable elements are errors.
442    pub fn list<T: FromStr>(&mut self, raw: &[&str], field: &str, kind: &str) -> Vec<T> {
443        let mut out = Vec::with_capacity(raw.len());
444        for value in raw {
445            match value.parse::<T>() {
446                Ok(parsed) => out.push(parsed),
447                Err(_) => self.invalid(field, kind, value),
448            }
449        }
450        out
451    }
452
453    /// Record a "required" failure for an already-extracted optional value.
454    ///
455    /// Used for body fields: `serde` has already validated the body, so a
456    /// missing value here means the field was skipped by the shadow struct.
457    pub fn take<T>(&mut self, value: Option<T>, field: &str) -> Option<T> {
458        if value.is_none() {
459            self.missing(field);
460        }
461        value
462    }
463
464    /// The first uploaded file, required.
465    pub fn required_file(&mut self, file: Option<&FormFile>, field: &str) -> Option<FormFile> {
466        match file {
467            Some(file) => Some(file.clone()),
468            None => {
469                self.missing(field);
470                None
471            }
472        }
473    }
474
475    /// The first uploaded file, optional.
476    pub fn optional_file(&mut self, file: Option<&FormFile>) -> Option<FormFile> {
477        file.cloned()
478    }
479
480    /// Every uploaded file under `name`.
481    pub fn files(&mut self, files: Vec<&FormFile>) -> Vec<FormFile> {
482        files.into_iter().cloned().collect()
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::collections::HashMap;
490
491    #[test]
492    fn required_reports_missing_and_invalid() {
493        let mut binder = Binder::new();
494        assert_eq!(binder.required::<u32>(None, "page", "integer"), None);
495        assert_eq!(binder.required::<u32>(Some("abc"), "page", "integer"), None);
496        assert_eq!(
497            binder.required::<u32>(Some("2"), "page", "integer"),
498            Some(2)
499        );
500        assert_eq!(binder.errors().len(), 2);
501        assert!(binder.errors()[0].message.contains("required"));
502    }
503
504    #[test]
505    fn optional_keeps_absent_as_none_and_rejects_invalid() {
506        let mut binder = Binder::new();
507        assert_eq!(binder.optional::<u32>(None, "page", "integer"), None);
508        assert!(!binder.has_errors());
509        assert_eq!(binder.optional::<u32>(Some("x"), "page", "integer"), None);
510        assert!(binder.has_errors());
511    }
512
513    #[test]
514    fn list_binds_every_occurrence() {
515        let mut binder = Binder::new();
516        assert_eq!(
517            binder.list::<u32>(&["1", "2", "3"], "ids", "integer"),
518            vec![1, 2, 3]
519        );
520        assert!(!binder.has_errors());
521        assert_eq!(binder.list::<u32>(&["1", "x"], "ids", "integer"), vec![1]);
522        assert!(binder.has_errors());
523    }
524
525    #[test]
526    fn query_values_keep_every_occurrence_and_expand_comma_lists() {
527        let path = HashMap::new();
528        let query = HashMap::new();
529        let mut multi: HashMap<String, Vec<String>> = HashMap::new();
530        multi.insert("ids".into(), vec!["1".into(), "2,3".into()]);
531
532        let input = BindInput {
533            path: &path,
534            query: &query,
535            query_multi: Some(&multi),
536            claims: None,
537            form: None,
538            is_body: false,
539            is_multipart: false,
540        };
541        assert_eq!(input.query_list("ids"), vec!["1", "2", "3"]);
542        assert_eq!(input.query_one("ids"), Some("2,3"));
543    }
544
545    #[test]
546    fn error_body_groups_fields() {
547        let mut binder = Binder::new();
548        binder.missing("a");
549        binder.missing("a");
550        binder.missing("b");
551        let error = binder.into_error();
552        let Error::Problem(problem) = error else {
553            panic!("expected Problem, got {error:?}");
554        };
555        assert_eq!(problem.status, 400);
556        let errors = problem.extensions.as_ref().unwrap().get("errors").unwrap();
557        assert_eq!(
558            errors["a"],
559            serde_json::json!(["The `a` field is required.", "The `a` field is required."])
560        );
561        assert_eq!(
562            errors["b"],
563            serde_json::json!(["The `b` field is required."])
564        );
565    }
566
567    /// Claims whose dedicated accessors deliberately disagree with the raw map,
568    /// so a test can tell which one a lookup reads.
569    struct FakeClaims {
570        subject: String,
571        roles: Vec<String>,
572        permissions: Vec<String>,
573        tenant: Option<String>,
574        raw: HashMap<String, String>,
575    }
576
577    impl FakeClaims {
578        fn new(raw: &[(&str, &str)]) -> Self {
579            Self {
580                subject: "subject-from-helper".to_string(),
581                roles: vec!["slot-role".to_string()],
582                permissions: vec!["slot-permission".to_string()],
583                tenant: Some("tenant-from-helper".to_string()),
584                raw: raw
585                    .iter()
586                    .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
587                    .collect(),
588            }
589        }
590    }
591
592    impl IClaims for FakeClaims {
593        fn subject(&self) -> &str {
594            &self.subject
595        }
596
597        fn roles(&self) -> &[String] {
598            &self.roles
599        }
600
601        fn permissions(&self) -> &[String] {
602            &self.permissions
603        }
604
605        fn claims(&self) -> &HashMap<String, String> {
606            &self.raw
607        }
608
609        fn get_tenantid(&self) -> Option<&str> {
610            self.tenant.as_deref()
611        }
612
613        fn clone_box(&self) -> Box<dyn IClaims> {
614            Box::new(Self {
615                subject: self.subject.clone(),
616                roles: self.roles.clone(),
617                permissions: self.permissions.clone(),
618                tenant: self.tenant.clone(),
619                raw: self.raw.clone(),
620            })
621        }
622    }
623
624    fn claims_input<'a>(
625        path: &'a HashMap<String, String>,
626        claims: &'a FakeClaims,
627    ) -> BindInput<'a> {
628        BindInput {
629            path,
630            query: path,
631            query_multi: None,
632            claims: Some(claims),
633            form: None,
634            is_body: false,
635            is_multipart: false,
636        }
637    }
638
639    #[test]
640    fn claim_prefers_the_raw_map_over_the_dedicated_accessors() {
641        let path = HashMap::new();
642        let claims = FakeClaims::new(&[
643            ("sub", "raw-sub"),
644            ("user_id", "raw-user-id"),
645            ("tenant", "raw-tenant"),
646        ]);
647        let input = claims_input(&path, &claims);
648
649        // A token that literally carries these keys keeps its own values; the
650        // legacy aliases must not silently swap the source.
651        assert_eq!(input.claim("sub"), Some("raw-sub"));
652        assert_eq!(input.claim("user_id"), Some("raw-user-id"));
653        assert_eq!(input.claim("tenant"), Some("raw-tenant"));
654    }
655
656    #[test]
657    fn claim_falls_back_to_the_dedicated_accessors() {
658        let path = HashMap::new();
659        let claims = FakeClaims::new(&[]);
660        let input = claims_input(&path, &claims);
661
662        assert_eq!(input.claim("sub"), Some("subject-from-helper"));
663        assert_eq!(input.claim("subject"), Some("subject-from-helper"));
664        assert_eq!(input.claim("user_id"), Some("subject-from-helper"));
665        assert_eq!(input.claim("tenant_id"), Some("tenant-from-helper"));
666        assert_eq!(input.claim("tenant"), Some("tenant-from-helper"));
667        assert_eq!(input.claim("email"), None);
668    }
669
670    #[test]
671    fn claim_list_prefers_the_raw_map_then_the_dedicated_slots() {
672        let path = HashMap::new();
673        let claims = FakeClaims::new(&[("roles", "raw-admin, raw-auditor")]);
674        let input = claims_input(&path, &claims);
675
676        assert_eq!(input.claim_list("roles"), vec!["raw-admin", "raw-auditor"]);
677        // No raw key: the dedicated slot supplies the values.
678        assert_eq!(input.claim_list("permissions"), vec!["slot-permission"]);
679        assert_eq!(input.claim_list("missing"), Vec::<&str>::new());
680    }
681
682    #[test]
683    fn an_empty_value_is_an_empty_list_not_one_empty_element() {
684        assert_eq!(split_one(""), Vec::<&str>::new());
685        assert_eq!(split_one("a"), vec!["a"]);
686        assert_eq!(split_one("a,, b"), vec!["a", "b"]);
687        assert_eq!(split_csv(Some("")), Vec::<&str>::new());
688        assert_eq!(split_csv(None), Vec::<&str>::new());
689
690        // `?ids=` (present, empty) is an empty list rather than a parse error
691        // or a one-element list holding `""`.
692        let path = HashMap::new();
693        let query = HashMap::new();
694        let mut multi: HashMap<String, Vec<String>> = HashMap::new();
695        multi.insert("ids".into(), vec![String::new()]);
696        let input = BindInput {
697            path: &path,
698            query: &query,
699            query_multi: Some(&multi),
700            claims: None,
701            form: None,
702            is_body: false,
703            is_multipart: false,
704        };
705        assert_eq!(input.query_list("ids"), Vec::<&str>::new());
706    }
707}