Skip to main content

reinhardt_di/params/
path.rs

1//! Path parameter extraction
2//!
3//! Extract typed values from URL path parameters.
4
5use async_trait::async_trait;
6use reinhardt_http::Request;
7use serde::de::DeserializeOwned;
8use std::fmt::{self, Debug};
9use std::ops::Deref;
10
11use super::{
12	ParamContext, ParamError, ParamErrorContext, ParamResult, ParamType, extract::FromRequest,
13};
14
15/// Extract typed values from URL path parameters.
16///
17/// # Single-parameter extraction
18///
19/// `Path<T>` for a primitive type (e.g. `Path<i64>`, `Path<String>`) requires
20/// the URL pattern to declare exactly one path parameter. Extraction returns
21/// HTTP 400 if the pattern declares zero or more than one parameter.
22///
23/// # Tuple extraction and parameter order
24///
25/// For tuple extractors `Path<(T1, T2, ...)>`, tuple elements are populated in
26/// **URL pattern declaration order** — i.e. the order in which `{...}`
27/// placeholders appear in the route pattern, **not** alphabetical order of
28/// parameter names.
29///
30/// ```text
31/// // Route:    /orgs/{org}/clusters/{cluster_id}/
32/// // Tuple:    Path<(String, i64)>
33/// //                  ^^^^^^  ^^^
34/// //                  org     cluster_id
35/// ```
36///
37/// Prior to issue #4013, parameters were sorted alphabetically by name before
38/// being assigned to tuple fields, which silently produced HTTP 400 when the
39/// tuple type order did not match alphabetical name order. Tuple extraction
40/// now follows URL pattern order, matching common conventions in other Rust
41/// web frameworks.
42///
43/// For unambiguous extraction with many parameters, prefer
44/// [`PathStruct<T>`](super::PathStruct) (named struct deserialization), which
45/// matches by parameter name rather than position.
46///
47/// # Example
48///
49/// ```rust
50/// use reinhardt_di::params::Path;
51///
52/// let id = Path(42_i64);
53/// let user_id: i64 = id.0; // or *id
54/// assert_eq!(user_id, 42);
55/// ```
56pub struct Path<T>(pub T);
57
58impl<T> Path<T> {
59	/// Unwrap the Path and return the inner value
60	///
61	/// # Examples
62	///
63	/// ```
64	/// use reinhardt_di::params::Path;
65	///
66	/// let path = Path(42i64);
67	/// let inner = path.into_inner();
68	/// assert_eq!(inner, 42);
69	/// ```
70	pub fn into_inner(self) -> T {
71		self.0
72	}
73}
74
75impl<T> Deref for Path<T> {
76	type Target = T;
77
78	fn deref(&self) -> &Self::Target {
79		&self.0
80	}
81}
82
83impl<T: Debug> Debug for Path<T> {
84	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85		self.0.fmt(f)
86	}
87}
88
89impl<T: Clone> Clone for Path<T> {
90	fn clone(&self) -> Self {
91		Path(self.0.clone())
92	}
93}
94
95// Macro to implement FromRequest for primitive types
96// This allows extracting primitive types directly from path parameters
97macro_rules! impl_path_from_str {
98    ($($ty:ty),+ $(,)?) => {
99        $(
100            #[async_trait]
101            impl FromRequest for Path<$ty> {
102                async fn from_request(_req: &Request, ctx: &ParamContext) -> ParamResult<Self> {
103                    // For primitive types, extract the single value directly
104                    if ctx.path_params.len() != 1 {
105                        return Err(ParamError::InvalidParameter(Box::new(
106                            ParamErrorContext::new(
107                                ParamType::Path,
108                                format!(
109                                    "Expected exactly 1 path parameter for primitive type, found {}",
110                                    ctx.path_params.len()
111                                ),
112                            )
113                            .with_expected_type::<$ty>(),
114                        )));
115                    }
116
117                    let value = ctx.path_params.values().next().unwrap();
118                    value.parse::<$ty>()
119                        .map(Path)
120                        .map_err(|e| {
121                            ParamError::parse::<$ty>(
122                                ParamType::Path,
123                                format!("Failed to parse '{}' as {}: {}", value, stringify!($ty), e),
124                                Box::new(std::io::Error::new(
125                                    std::io::ErrorKind::InvalidData,
126                                    e.to_string(),
127                                )),
128                            )
129                        })
130                }
131            }
132        )+
133    };
134}
135
136// Implement for common primitive types
137impl_path_from_str!(
138	i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool
139);
140
141// Implement for Uuid when the uuid feature is enabled
142#[cfg(feature = "uuid")]
143impl_path_from_str!(uuid::Uuid);
144
145// Implementation for 2-tuple path parameters
146// This enables extracting multiple path parameters like Path<(Uuid, Uuid)>
147macro_rules! impl_path_tuple2_from_str {
148    ($($t1:ty, $t2:ty);+ $(;)?) => {
149        $(
150            #[async_trait]
151            impl FromRequest for Path<($t1, $t2)> {
152                async fn from_request(_req: &Request, ctx: &ParamContext) -> ParamResult<Self> {
153                    if ctx.path_params.len() != 2 {
154                        return Err(ParamError::InvalidParameter(Box::new(
155                            ParamErrorContext::new(
156                                ParamType::Path,
157                                format!(
158                                    "Expected exactly 2 path parameters for tuple type, found {}",
159                                    ctx.path_params.len()
160                                ),
161                            )
162                            .with_expected_type::<($t1, $t2)>(),
163                        )));
164                    }
165
166                    // Iterate path parameters in URL pattern declaration order
167                    // (preserved end-to-end from matchit through `PathParams`).
168                    // Tuple element `T_n` is populated from the n-th parameter
169                    // declared in the route pattern. See issue #4013.
170                    let values: Vec<&String> = ctx.path_params.iter().map(|(_, v)| v).collect();
171                    if values.len() != 2 {
172                        return Err(ParamError::InvalidParameter(Box::new(
173                            ParamErrorContext::new(
174                                ParamType::Path,
175                                "Expected exactly 2 path parameters".to_string(),
176                            )
177                            .with_expected_type::<($t1, $t2)>(),
178                        )));
179                    }
180
181                    let v1 = values[0].parse::<$t1>()
182                        .map_err(|e| {
183                            let ctx = ParamErrorContext::new(
184                                ParamType::Path,
185                                format!("Failed to parse '{}' as {}: {}", values[0], stringify!($t1), e),
186                            )
187                            .with_field("path[0]")
188                            .with_expected_type::<$t1>()
189                            .with_raw_value(values[0].as_str())
190                            .with_source(Box::new(std::io::Error::new(
191                                std::io::ErrorKind::InvalidData,
192                                e.to_string(),
193                            )));
194                            ParamError::ParseError(Box::new(ctx))
195                        })?;
196
197                    let v2 = values[1].parse::<$t2>()
198                        .map_err(|e| {
199                            let ctx = ParamErrorContext::new(
200                                ParamType::Path,
201                                format!("Failed to parse '{}' as {}: {}", values[1], stringify!($t2), e),
202                            )
203                            .with_field("path[1]")
204                            .with_expected_type::<$t2>()
205                            .with_raw_value(values[1].as_str())
206                            .with_source(Box::new(std::io::Error::new(
207                                std::io::ErrorKind::InvalidData,
208                                e.to_string(),
209                            )));
210                            ParamError::ParseError(Box::new(ctx))
211                        })?;
212
213                    Ok(Path((v1, v2)))
214                }
215            }
216        )+
217    };
218}
219
220// Common tuple combinations
221impl_path_tuple2_from_str!(
222	i64, i64;
223	String, i64;
224	i64, String;
225	String, String
226);
227
228// Uuid tuple combinations when uuid feature is enabled
229#[cfg(feature = "uuid")]
230impl_path_tuple2_from_str!(
231	uuid::Uuid, uuid::Uuid;
232	uuid::Uuid, i64;
233	i64, uuid::Uuid;
234	uuid::Uuid, String;
235	String, uuid::Uuid
236);
237
238// Special implementation for String (no parsing needed)
239#[async_trait]
240impl FromRequest for Path<String> {
241	async fn from_request(_req: &Request, ctx: &ParamContext) -> ParamResult<Self> {
242		if ctx.path_params.len() != 1 {
243			return Err(ParamError::InvalidParameter(Box::new(
244				ParamErrorContext::new(
245					ParamType::Path,
246					format!(
247						"Expected exactly 1 path parameter for String, found {}",
248						ctx.path_params.len()
249					),
250				)
251				.with_expected_type::<String>(),
252			)));
253		}
254
255		let value = ctx.path_params.values().next().unwrap().clone();
256		Ok(Path(value))
257	}
258}
259
260// Note: For complex types like enums, Vec, HashMap, etc., users should use
261// a custom deserializer or validate that the type is not suitable for path parameters.
262// We intentionally don't provide a generic DeserializeOwned impl to avoid conflicts
263// with the FromStr-based implementations above.
264
265/// PathStruct is a helper type for extracting structured path parameters
266///
267/// Use this when you need to extract multiple path parameters into a struct.
268///
269/// # Example
270///
271/// ```rust
272/// use reinhardt_di::params::PathStruct;
273/// # use serde::Deserialize;
274/// #[derive(Deserialize)]
275/// struct UserPath {
276///     user_id: i64,
277///     post_id: i64,
278/// }
279///
280/// let user_path = UserPath { user_id: 123, post_id: 456 };
281/// let path = PathStruct(user_path);
282/// let user_id = path.user_id;
283/// let post_id = path.post_id;
284/// assert_eq!(user_id, 123);
285/// assert_eq!(post_id, 456);
286/// ```
287pub struct PathStruct<T>(pub T);
288
289impl<T> PathStruct<T> {
290	/// Unwrap the PathStruct and return the inner value
291	///
292	/// # Examples
293	///
294	/// ```
295	/// use reinhardt_di::params::PathStruct;
296	/// use serde::Deserialize;
297	///
298	/// #[derive(Deserialize, Debug, PartialEq)]
299	/// struct UserPath {
300	///     user_id: i64,
301	///     post_id: i64,
302	/// }
303	///
304	/// let path = PathStruct(UserPath {
305	///     user_id: 123,
306	///     post_id: 456,
307	/// });
308	/// let inner = path.into_inner();
309	/// assert_eq!(inner.user_id, 123);
310	/// assert_eq!(inner.post_id, 456);
311	/// ```
312	pub fn into_inner(self) -> T {
313		self.0
314	}
315}
316
317impl<T> Deref for PathStruct<T> {
318	type Target = T;
319	fn deref(&self) -> &Self::Target {
320		&self.0
321	}
322}
323
324impl<T: Debug> Debug for PathStruct<T> {
325	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326		self.0.fmt(f)
327	}
328}
329
330#[async_trait]
331impl<T> FromRequest for PathStruct<T>
332where
333	T: DeserializeOwned + Send,
334{
335	async fn from_request(_req: &Request, ctx: &ParamContext) -> ParamResult<Self> {
336		// Convert path params to URL-encoded format for deserialization.
337		// This enables proper type coercion from strings (e.g., "42" -> 42).
338		// `serde_urlencoded` accepts a slice of `(K, V)` pairs, matching the
339		// internal representation of `PathParams`.
340		let encoded = serde_urlencoded::to_string(ctx.path_params.as_slice()).map_err(|e| {
341			ParamError::ParseError(Box::new(
342				ParamErrorContext::new(
343					ParamType::Path,
344					format!("Failed to encode path params: {}", e),
345				)
346				.with_expected_type::<T>()
347				.with_source(Box::new(e)),
348			))
349		})?;
350
351		serde_urlencoded::from_str(&encoded)
352			.map(PathStruct)
353			.map_err(|e| ParamError::url_encoding::<T>(ParamType::Path, e, Some(encoded.clone())))
354	}
355}
356
357// Implement WithValidation trait for Path
358#[cfg(feature = "validation")]
359impl<T> super::validation::WithValidation for Path<T> {}
360
361#[cfg(test)]
362mod tests {
363	use super::*;
364	use std::collections::HashMap;
365
366	#[tokio::test]
367	async fn test_path_struct_params() {
368		use bytes::Bytes;
369		use hyper::{HeaderMap, Method, Version};
370		use serde::Deserialize;
371
372		#[derive(Debug, Deserialize, PartialEq)]
373		struct PathParams {
374			id: i64,
375		}
376
377		let mut params = HashMap::new();
378		params.insert("id".to_string(), "42".to_string());
379
380		let ctx = ParamContext::with_path_params(params);
381		let req = Request::builder()
382			.method(Method::GET)
383			.uri("/test")
384			.version(Version::HTTP_11)
385			.headers(HeaderMap::new())
386			.body(Bytes::new())
387			.build()
388			.unwrap();
389
390		let result = PathStruct::<PathParams>::from_request(&req, &ctx).await;
391		assert!(result.is_ok());
392		assert_eq!(result.unwrap().id, 42);
393	}
394
395	// Test primitive type extraction
396	#[tokio::test]
397	async fn test_path_primitive_i64() {
398		use bytes::Bytes;
399		use hyper::{HeaderMap, Method, Version};
400
401		let mut params = HashMap::new();
402		params.insert("id".to_string(), "42".to_string());
403
404		let ctx = ParamContext::with_path_params(params);
405		let req = Request::builder()
406			.method(Method::GET)
407			.uri("/test")
408			.version(Version::HTTP_11)
409			.headers(HeaderMap::new())
410			.body(Bytes::new())
411			.build()
412			.unwrap();
413
414		let result = Path::<i64>::from_request(&req, &ctx).await;
415		assert!(result.is_ok(), "Failed to extract i64: {:?}", result.err());
416		assert_eq!(*result.unwrap(), 42);
417	}
418
419	#[tokio::test]
420	async fn test_path_primitive_string() {
421		use bytes::Bytes;
422		use hyper::{HeaderMap, Method, Version};
423
424		let mut params = HashMap::new();
425		params.insert("name".to_string(), "foobar".to_string());
426
427		let ctx = ParamContext::with_path_params(params);
428		let req = Request::builder()
429			.method(Method::GET)
430			.uri("/test")
431			.version(Version::HTTP_11)
432			.headers(HeaderMap::new())
433			.body(Bytes::new())
434			.build()
435			.unwrap();
436
437		let result = Path::<String>::from_request(&req, &ctx).await;
438		assert!(
439			result.is_ok(),
440			"Failed to extract String: {:?}",
441			result.err()
442		);
443		assert_eq!(*result.unwrap(), "foobar");
444	}
445
446	#[tokio::test]
447	async fn test_path_primitive_f64() {
448		use bytes::Bytes;
449		use hyper::{HeaderMap, Method, Version};
450
451		let mut params = HashMap::new();
452		params.insert("price".to_string(), "19.99".to_string());
453
454		let ctx = ParamContext::with_path_params(params);
455		let req = Request::builder()
456			.method(Method::GET)
457			.uri("/test")
458			.version(Version::HTTP_11)
459			.headers(HeaderMap::new())
460			.body(Bytes::new())
461			.build()
462			.unwrap();
463
464		let result = Path::<f64>::from_request(&req, &ctx).await;
465		assert!(result.is_ok(), "Failed to extract f64: {:?}", result.err());
466		assert_eq!(*result.unwrap(), 19.99);
467	}
468
469	#[tokio::test]
470	async fn test_path_primitive_bool() {
471		use bytes::Bytes;
472		use hyper::{HeaderMap, Method, Version};
473
474		let mut params = HashMap::new();
475		params.insert("active".to_string(), "true".to_string());
476
477		let ctx = ParamContext::with_path_params(params);
478		let req = Request::builder()
479			.method(Method::GET)
480			.uri("/test")
481			.version(Version::HTTP_11)
482			.headers(HeaderMap::new())
483			.body(Bytes::new())
484			.build()
485			.unwrap();
486
487		let result = Path::<bool>::from_request(&req, &ctx).await;
488		assert!(result.is_ok(), "Failed to extract bool: {:?}", result.err());
489		assert!(*result.unwrap());
490	}
491
492	#[tokio::test]
493	async fn test_path_multiple_params_struct() {
494		use bytes::Bytes;
495		use hyper::{HeaderMap, Method, Version};
496		use serde::Deserialize;
497
498		#[derive(Debug, Deserialize, PartialEq)]
499		struct MultiParams {
500			user_id: i64,
501			post_id: i64,
502		}
503
504		let mut params = HashMap::new();
505		params.insert("user_id".to_string(), "123".to_string());
506		params.insert("post_id".to_string(), "456".to_string());
507
508		let ctx = ParamContext::with_path_params(params);
509		let req = Request::builder()
510			.method(Method::GET)
511			.uri("/test")
512			.version(Version::HTTP_11)
513			.headers(HeaderMap::new())
514			.body(Bytes::new())
515			.build()
516			.unwrap();
517
518		let result = PathStruct::<MultiParams>::from_request(&req, &ctx).await;
519		let params = result.unwrap();
520		assert_eq!(params.user_id, 123);
521		assert_eq!(params.post_id, 456);
522	}
523
524	// =====================================================================
525	// Tuple extraction order tests (issue #4013)
526	//
527	// These tests verify that `Path<(T1, T2)>` populates tuple fields in URL
528	// pattern declaration order, NOT alphabetical order of parameter names.
529	// =====================================================================
530
531	#[tokio::test]
532	async fn test_path_tuple_uses_url_pattern_order_not_alphabetical() {
533		use bytes::Bytes;
534		use hyper::{HeaderMap, Method, Version};
535		use reinhardt_http::PathParams;
536
537		// Arrange: simulate the route `/orgs/{org}/clusters/{cluster_id}/`.
538		// URL declaration order: `org` first, `cluster_id` second.
539		// Alphabetical order would put `cluster_id` first — this is the bug
540		// from issue #4013 that we are guarding against.
541		let mut params = PathParams::new();
542		params.insert("org", "myslug");
543		params.insert("cluster_id", "5");
544
545		let ctx = ParamContext::with_path_params(params);
546		let req = Request::builder()
547			.method(Method::GET)
548			.uri("/orgs/myslug/clusters/5/")
549			.version(Version::HTTP_11)
550			.headers(HeaderMap::new())
551			.body(Bytes::new())
552			.build()
553			.unwrap();
554
555		// Act
556		let result = Path::<(String, i64)>::from_request(&req, &ctx).await;
557
558		// Assert: must follow URL pattern order — `org` (String) first,
559		// `cluster_id` (i64) second. Pre-fix, alphabetical sort would put
560		// "5" at position 0 and "myslug" at position 1, causing the i64
561		// parse of "myslug" to fail with HTTP 400.
562		let Path((org, cluster_id)) =
563			result.expect("tuple extraction must follow URL pattern order");
564		assert_eq!(org, "myslug");
565		assert_eq!(cluster_id, 5);
566	}
567
568	#[tokio::test]
569	async fn test_path_tuple_reverse_alphabetical_order() {
570		use bytes::Bytes;
571		use hyper::{HeaderMap, Method, Version};
572		use reinhardt_http::PathParams;
573
574		// Arrange: insertion order `z`, `a` — reverse of alphabetical.
575		let mut params = PathParams::new();
576		params.insert("z", "first");
577		params.insert("a", "second");
578
579		let ctx = ParamContext::with_path_params(params);
580		let req = Request::builder()
581			.method(Method::GET)
582			.uri("/test")
583			.version(Version::HTTP_11)
584			.headers(HeaderMap::new())
585			.body(Bytes::new())
586			.build()
587			.unwrap();
588
589		// Act
590		let result = Path::<(String, String)>::from_request(&req, &ctx).await;
591
592		// Assert: tuple is populated in insertion order `(z, a)`, which
593		// happens to be the reverse of alphabetical order. This proves the
594		// extractor follows declaration order rather than sorting.
595		let Path((first, second)) = result.expect("tuple extraction must follow insertion order");
596		assert_eq!(first, "first");
597		assert_eq!(second, "second");
598	}
599}