Skip to main content

toolkit/api/rest/extract/
path.rs

1//! `Path<T>` - a drop-in `axum::extract::Path<T>` replacement whose
2//! extraction failures render as `application/problem+json` (RFC 9457
3//! `Problem`) instead of axum's default plain-text rejection body. Same
4//! class of gap as `extract::Json`, same fix shape - see this module's
5//! parent doc comment.
6
7use axum::extract::rejection::PathRejection;
8use axum::extract::{FromRequestParts, Path as AxumPath};
9use axum::http::request::Parts;
10use toolkit_canonical_errors::CanonicalError;
11
12use super::error::rejection_to_canonical;
13
14/// Drop-in replacement for `axum::extract::Path<T>` as a handler parameter.
15/// Extraction success is identical to `axum::extract::Path<T>`; extraction
16/// failure produces a `CanonicalError` instead of `PathRejection`'s
17/// plain-text body.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct Path<T>(pub T);
20
21impl<T, S> FromRequestParts<S> for Path<T>
22where
23    AxumPath<T>: FromRequestParts<S, Rejection = PathRejection>,
24    S: Send + Sync,
25{
26    type Rejection = CanonicalError;
27
28    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
29        match AxumPath::<T>::from_request_parts(parts, state).await {
30            Ok(AxumPath(value)) => Ok(Self(value)),
31            Err(rejection) => Err(path_rejection_to_canonical(&rejection)),
32        }
33    }
34}
35
36/// Maps a `PathRejection` to a `CanonicalError`. Not a `From` impl - both
37/// types are foreign to this crate, so the orphan rule forbids it.
38///
39/// Unlike `extract::json`/`extract::query`, this isn't purely a
40/// client-fault mapping: `MissingPathParams` (the extractor used outside a
41/// matched route) and `FailedToDeserializePathParams`'s
42/// `WrongNumberOfParameters`/`UnsupportedType` kinds are route/type
43/// *definition* bugs, not something any client input could trigger - axum
44/// itself reports both at `.status() == 500`. `rejection_to_canonical`'s own
45/// `>= 500` branch handles this uniformly (mapping to `internal` instead of
46/// mislabeling a route/type bug as `invalid_argument`), so no special-casing
47/// is needed here at all - every variant, known or not, goes through the
48/// same call.
49///
50/// `PathRejection` is `#[non_exhaustive]`, so this needs a fallback for a
51/// variant it doesn't know about - the classification is entirely
52/// status-driven, not variant-specific, so an unknown variant is handled
53/// correctly with nothing invented. No panic: this just logs the
54/// unrecognized variant for visibility.
55fn path_rejection_to_canonical(rejection: &PathRejection) -> CanonicalError {
56    if !matches!(
57        rejection,
58        PathRejection::MissingPathParams(_) | PathRejection::FailedToDeserializePathParams(_)
59    ) {
60        tracing::error!(
61            rejection = %rejection,
62            "extract::Path: unhandled PathRejection variant, falling back to status-driven classification"
63        );
64    }
65    rejection_to_canonical(
66        "path",
67        "invalid_path_params",
68        rejection.status().as_u16(),
69        rejection.body_text(),
70    )
71}
72
73#[cfg(test)]
74#[cfg_attr(coverage_nightly, coverage(off))]
75mod tests {
76    use axum::Router;
77    use axum::body::Body;
78    use axum::http::{Request, StatusCode, header};
79    use axum::routing::get;
80    use serde::Deserialize;
81    use serde_json::{Value, json};
82    use toolkit_canonical_errors::Problem;
83    use tower::ServiceExt;
84
85    use super::Path;
86
87    const RESOURCE_TYPE: &str = "gts.cf.core.http.request.v1~";
88    const INVALID_ARGUMENT_TYPE: &str =
89        "gts://gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~";
90    const INTERNAL_TYPE: &str = "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~";
91
92    #[derive(Debug, Deserialize)]
93    struct ItemParams {
94        id: u32,
95    }
96
97    fn app() -> Router {
98        Router::new().route(
99            "/items/{id}",
100            get(|Path(p): Path<ItemParams>| async move { axum::Json(p.id) }),
101        )
102    }
103
104    async fn get_uri(uri: &str) -> axum::response::Response {
105        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
106        app().oneshot(req).await.unwrap()
107    }
108
109    async fn body_json(response: axum::response::Response) -> Value {
110        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
111            .await
112            .unwrap();
113        serde_json::from_slice(&bytes).expect("response body is valid JSON")
114    }
115
116    #[tokio::test]
117    async fn valid_path_extracts_normally() {
118        // Echoes the extracted `id` back and asserts it, not just the
119        // status - a `Path<T>` that silently yielded a wrong or defaulted
120        // value would still return 200 and pass a status-only assertion.
121        let res = get_uri("/items/42").await;
122        assert_eq!(res.status(), StatusCode::OK);
123        let json = body_json(res).await;
124        assert_eq!(json, json!(42));
125    }
126
127    #[tokio::test]
128    async fn non_numeric_segment_returns_400_problem() {
129        let res = get_uri("/items/not-a-number").await;
130        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
131        assert_eq!(
132            res.headers().get(header::CONTENT_TYPE).unwrap(),
133            "application/problem+json"
134        );
135        let json = body_json(res).await;
136        assert_eq!(
137            json,
138            json!({
139                "type": INVALID_ARGUMENT_TYPE,
140                "title": "Invalid Argument",
141                "status": 400,
142                "detail": "Request validation failed",
143                "context": {
144                    "resource_type": RESOURCE_TYPE,
145                    "field_violations": [{
146                        "field": "path",
147                        "description": "Invalid URL: Cannot parse `id` with value `not-a-number` to a `u32`",
148                        "reason": "invalid_path_params",
149                    }],
150                },
151            })
152        );
153    }
154
155    #[tokio::test]
156    async fn wrong_number_of_path_params_maps_to_internal_not_invalid_argument() {
157        // `FailedToDeserializePathParams`'s `WrongNumberOfParameters` kind is
158        // a route/type definition bug (the handler declared more path
159        // parameters than the route template has), not something client
160        // input could trigger - axum reports it at `.status() == 500`, so it
161        // must map to `internal`, not `invalid_argument`, per
162        // `path_rejection_to_canonical`'s doc comment.
163        let app = Router::new().route(
164            "/items/{id}",
165            get(|Path(_p): Path<(u32, u32)>| async { StatusCode::OK }),
166        );
167        let req = Request::builder()
168            .uri("/items/42")
169            .body(Body::empty())
170            .unwrap();
171        let res = app.oneshot(req).await.unwrap();
172
173        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
174        let json = body_json(res).await;
175        assert_eq!(
176            json,
177            json!({
178                "type": INTERNAL_TYPE,
179                "title": "Internal",
180                "status": 500,
181                "detail": "An internal error occurred. Please retry later.",
182                "context": {},
183            })
184        );
185    }
186
187    #[test]
188    fn missing_path_params_maps_to_internal_not_invalid_argument() {
189        // `MissingPathParams` (the extractor used outside a matched route)
190        // is a route/type definition bug, not the client's fault - verified
191        // directly against `path_rejection_to_canonical` rather than via
192        // real routing, since axum's router always populates path params
193        // for any matched route, making this case impractical to trigger
194        // end-to-end.
195        use axum::extract::rejection::{MissingPathParams, PathRejection};
196
197        let rejection = PathRejection::MissingPathParams(MissingPathParams::default());
198        let err = super::path_rejection_to_canonical(&rejection);
199        let problem: Problem = err.into();
200        let json = serde_json::to_value(&problem).unwrap();
201
202        assert_eq!(
203            json,
204            json!({
205                "type": INTERNAL_TYPE,
206                "title": "Internal",
207                "status": 500,
208                "detail": "An internal error occurred. Please retry later.",
209                "context": {},
210            })
211        );
212    }
213}