toolkit/api/rest/extract/
path.rs1use 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#[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
36fn 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 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 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 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}