axum_extra/routing/typed.rs
1use std::{any::type_name, fmt};
2
3use super::sealed::Sealed;
4use http::Uri;
5use serde::Serialize;
6
7/// A type safe path.
8///
9/// This is used to statically connect a path to its corresponding handler using
10/// [`RouterExt::typed_get`], [`RouterExt::typed_post`], etc.
11///
12/// # Example
13///
14/// ```rust
15/// use serde::Deserialize;
16/// use axum::{Router, extract::Json};
17/// use axum_extra::routing::{
18/// TypedPath,
19/// RouterExt, // for `Router::typed_*`
20/// };
21///
22/// // A type safe route with `/users/{id}` as its associated path.
23/// #[derive(TypedPath, Deserialize)]
24/// #[typed_path("/users/{id}")]
25/// struct UsersMember {
26/// id: u32,
27/// }
28///
29/// // A regular handler function that takes `UsersMember` as the first argument
30/// // and thus creates a typed connection between this handler and the `/users/{id}` path.
31/// //
32/// // The `TypedPath` must be the first argument to the function.
33/// async fn users_show(
34/// UsersMember { id }: UsersMember,
35/// ) {
36/// // ...
37/// }
38///
39/// let app = Router::new()
40/// // Add our typed route to the router.
41/// //
42/// // The path will be inferred to `/users/{id}` since `users_show`'s
43/// // first argument is `UsersMember` which implements `TypedPath`
44/// .typed_get(users_show)
45/// .typed_post(users_create)
46/// .typed_delete(users_destroy);
47///
48/// #[derive(TypedPath)]
49/// #[typed_path("/users")]
50/// struct UsersCollection;
51///
52/// #[derive(Deserialize)]
53/// struct UsersCreatePayload { /* ... */ }
54///
55/// async fn users_create(
56/// _: UsersCollection,
57/// // Our handlers can accept other extractors.
58/// Json(payload): Json<UsersCreatePayload>,
59/// ) {
60/// // ...
61/// }
62///
63/// async fn users_destroy(_: UsersCollection) { /* ... */ }
64///
65/// #
66/// # let app: Router = app;
67/// ```
68///
69/// # Using `#[derive(TypedPath)]`
70///
71/// While `TypedPath` can be implemented manually, it's _highly_ recommended to derive it:
72///
73/// ```
74/// use serde::Deserialize;
75/// use axum_extra::routing::TypedPath;
76///
77/// #[derive(TypedPath, Deserialize)]
78/// #[typed_path("/users/{id}")]
79/// struct UsersMember {
80/// id: u32,
81/// }
82/// ```
83///
84/// The macro expands to:
85///
86/// - A `TypedPath` implementation.
87/// - A [`FromRequest`] implementation compatible with [`RouterExt::typed_get`],
88/// [`RouterExt::typed_post`], etc. This implementation uses [`Path`] and thus your struct must
89/// also implement [`serde::Deserialize`], unless it's a unit struct.
90/// - A [`Display`] implementation that interpolates the captures. This can be used to, among other
91/// things, create links to known paths and have them verified statically. Note that the
92/// [`Display`] implementation for each field must return something that's compatible with its
93/// [`Deserialize`] implementation.
94///
95/// Additionally the macro will verify the captures in the path matches the fields of the struct.
96/// For example this fails to compile since the struct doesn't have a `team_id` field:
97///
98/// ```compile_fail
99/// use serde::Deserialize;
100/// use axum_extra::routing::TypedPath;
101///
102/// #[derive(TypedPath, Deserialize)]
103/// #[typed_path("/users/{id}/teams/{team_id}")]
104/// struct UsersMember {
105/// id: u32,
106/// }
107/// ```
108///
109/// Unit and tuple structs are also supported:
110///
111/// ```
112/// use serde::Deserialize;
113/// use axum_extra::routing::TypedPath;
114///
115/// #[derive(TypedPath)]
116/// #[typed_path("/users")]
117/// struct UsersCollection;
118///
119/// #[derive(TypedPath, Deserialize)]
120/// #[typed_path("/users/{id}")]
121/// struct UsersMember(u32);
122/// ```
123///
124/// ## Percent encoding
125///
126/// The generated [`Display`] implementation will automatically percent-encode the arguments:
127///
128/// ```
129/// use serde::Deserialize;
130/// use axum_extra::routing::TypedPath;
131///
132/// #[derive(TypedPath, Deserialize)]
133/// #[typed_path("/users/{id}")]
134/// struct UsersMember {
135/// id: String,
136/// }
137///
138/// assert_eq!(
139/// UsersMember {
140/// id: "foo bar".to_string(),
141/// }.to_string(),
142/// "/users/foo%20bar",
143/// );
144/// ```
145///
146/// ## Customizing the rejection
147///
148/// By default the rejection used in the [`FromRequest`] implementation will be [`PathRejection`].
149///
150/// That can be customized using `#[typed_path("...", rejection(YourType))]`:
151///
152/// ```
153/// use serde::Deserialize;
154/// use axum_extra::routing::TypedPath;
155/// use axum::{
156/// response::{IntoResponse, Response},
157/// extract::rejection::PathRejection,
158/// };
159///
160/// #[derive(TypedPath, Deserialize)]
161/// #[typed_path("/users/{id}", rejection(UsersMemberRejection))]
162/// struct UsersMember {
163/// id: String,
164/// }
165///
166/// struct UsersMemberRejection;
167///
168/// // Your rejection type must implement `From<PathRejection>`.
169/// //
170/// // Here you can grab whatever details from the inner rejection
171/// // that you need.
172/// impl From<PathRejection> for UsersMemberRejection {
173/// fn from(rejection: PathRejection) -> Self {
174/// # UsersMemberRejection
175/// // ...
176/// }
177/// }
178///
179/// // Your rejection must implement `IntoResponse`, like all rejections.
180/// impl IntoResponse for UsersMemberRejection {
181/// fn into_response(self) -> Response {
182/// # ().into_response()
183/// // ...
184/// }
185/// }
186/// ```
187///
188/// The `From<PathRejection>` requirement only applies if your typed path is a struct with named
189/// fields or a tuple struct. For unit structs your rejection type must implement `Default`:
190///
191/// ```
192/// use axum_extra::routing::TypedPath;
193/// use axum::response::{IntoResponse, Response};
194///
195/// #[derive(TypedPath)]
196/// #[typed_path("/users", rejection(UsersCollectionRejection))]
197/// struct UsersCollection;
198///
199/// #[derive(Default)]
200/// struct UsersCollectionRejection;
201///
202/// impl IntoResponse for UsersCollectionRejection {
203/// fn into_response(self) -> Response {
204/// # ().into_response()
205/// // ...
206/// }
207/// }
208/// ```
209///
210/// [`FromRequest`]: axum::extract::FromRequest
211/// [`RouterExt::typed_get`]: super::RouterExt::typed_get
212/// [`RouterExt::typed_post`]: super::RouterExt::typed_post
213/// [`Path`]: axum::extract::Path
214/// [`Display`]: std::fmt::Display
215/// [`Deserialize`]: serde::Deserialize
216/// [`PathRejection`]: axum::extract::rejection::PathRejection
217pub trait TypedPath: std::fmt::Display {
218 /// The path with optional captures such as `/users/{id}`.
219 const PATH: &'static str;
220
221 /// Convert the path into a `Uri`.
222 ///
223 /// # Panics
224 ///
225 /// The default implementation parses the required [`Display`] implementation. If that fails it
226 /// will panic.
227 ///
228 /// Using `#[derive(TypedPath)]` will never result in a panic since it percent-encodes
229 /// arguments.
230 ///
231 /// [`Display`]: std::fmt::Display
232 fn to_uri(&self) -> Uri {
233 self.to_string().parse().unwrap()
234 }
235
236 /// Add query parameters to a path.
237 ///
238 /// # Example
239 ///
240 /// ```
241 /// use axum_extra::routing::TypedPath;
242 /// use serde::Serialize;
243 ///
244 /// #[derive(TypedPath)]
245 /// #[typed_path("/users")]
246 /// struct Users;
247 ///
248 /// #[derive(Serialize)]
249 /// struct Pagination {
250 /// page: u32,
251 /// per_page: u32,
252 /// }
253 ///
254 /// let path = Users.with_query_params(Pagination {
255 /// page: 1,
256 /// per_page: 10,
257 /// });
258 ///
259 /// assert_eq!(path.to_uri(), "/users?&page=1&per_page=10");
260 /// ```
261 ///
262 /// # Panics
263 ///
264 /// If `params` doesn't support being serialized as query params [`WithQueryParams`]'s [`Display`]
265 /// implementation will panic, and thus [`WithQueryParams::to_uri`] will also panic.
266 ///
267 /// [`WithQueryParams::to_uri`]: TypedPath::to_uri
268 /// [`Display`]: std::fmt::Display
269 fn with_query_params<T>(self, params: T) -> WithQueryParams<Self, T>
270 where
271 T: Serialize,
272 Self: Sized,
273 {
274 WithQueryParams { path: self, params }
275 }
276}
277
278/// A [`TypedPath`] with query params.
279///
280/// See [`TypedPath::with_query_params`] for more details.
281#[derive(Debug, Clone, Copy)]
282pub struct WithQueryParams<P, T> {
283 path: P,
284 params: T,
285}
286
287impl<P, T> fmt::Display for WithQueryParams<P, T>
288where
289 P: TypedPath,
290 T: Serialize,
291{
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 let mut out = self.path.to_string();
294 if !out.contains('?') {
295 out.push('?');
296 }
297 let mut urlencoder = form_urlencoded::Serializer::new(&mut out);
298 self.params
299 .serialize(serde_html_form::ser::Serializer::new(&mut urlencoder))
300 .unwrap_or_else(|err| {
301 panic!(
302 "failed to URL encode value of type `{}`: {err}",
303 type_name::<T>(),
304 )
305 });
306 f.write_str(&out)?;
307
308 Ok(())
309 }
310}
311
312impl<P, T> TypedPath for WithQueryParams<P, T>
313where
314 P: TypedPath,
315 T: Serialize,
316{
317 const PATH: &'static str = P::PATH;
318}
319
320/// Utility trait used with [`RouterExt`] to ensure the second element of a tuple type is a
321/// given type.
322///
323/// If you see it in type errors it's most likely because the second argument to your handler doesn't
324/// implement [`TypedPath`].
325///
326/// You normally shouldn't have to use this trait directly.
327///
328/// It is sealed such that it cannot be implemented outside this crate.
329///
330/// [`RouterExt`]: super::RouterExt
331pub trait SecondElementIs<P>: Sealed {}
332
333macro_rules! impl_second_element_is {
334 ( $($ty:ident),* $(,)? ) => {
335 impl<M, P, $($ty,)*> SecondElementIs<P> for (M, P, $($ty,)*)
336 where
337 P: TypedPath
338 {}
339
340 impl<M, P, $($ty,)*> Sealed for (M, P, $($ty,)*)
341 where
342 P: TypedPath
343 {}
344
345 impl<M, P, $($ty,)*> SecondElementIs<P> for (M, Option<P>, $($ty,)*)
346 where
347 P: TypedPath
348 {}
349
350 impl<M, P, $($ty,)*> Sealed for (M, Option<P>, $($ty,)*)
351 where
352 P: TypedPath
353 {}
354
355 impl<M, P, E, $($ty,)*> SecondElementIs<P> for (M, Result<P, E>, $($ty,)*)
356 where
357 P: TypedPath
358 {}
359
360 impl<M, P, E, $($ty,)*> Sealed for (M, Result<P, E>, $($ty,)*)
361 where
362 P: TypedPath
363 {}
364 };
365}
366
367impl_second_element_is!();
368impl_second_element_is!(T1);
369impl_second_element_is!(T1, T2);
370impl_second_element_is!(T1, T2, T3);
371impl_second_element_is!(T1, T2, T3, T4);
372impl_second_element_is!(T1, T2, T3, T4, T5);
373impl_second_element_is!(T1, T2, T3, T4, T5, T6);
374impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7);
375impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8);
376impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9);
377impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10);
378impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11);
379impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12);
380impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13);
381impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14);
382impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15);
383impl_second_element_is!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16);
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::{
389 extract::WithRejection,
390 routing::{RouterExt, TypedPath},
391 };
392 use axum::{
393 extract::rejection::PathRejection,
394 response::{IntoResponse, Response},
395 Router,
396 };
397 use serde::Deserialize;
398
399 #[derive(TypedPath, Deserialize)]
400 #[typed_path("/users/{id}")]
401 struct UsersShow {
402 id: i32,
403 }
404
405 #[derive(Serialize)]
406 struct Params {
407 foo: &'static str,
408 bar: i32,
409 baz: bool,
410 }
411
412 #[test]
413 fn with_params() {
414 let path = UsersShow { id: 1 }.with_query_params(Params {
415 foo: "foo",
416 bar: 123,
417 baz: true,
418 });
419
420 let uri = path.to_uri();
421
422 // according to [the spec] starting the params with `?&` is allowed specifically:
423 //
424 // > If bytes is the empty byte sequence, then continue.
425 //
426 // [the spec]: https://url.spec.whatwg.org/#urlencoded-parsing
427 assert_eq!(uri, "/users/1?&foo=foo&bar=123&baz=true");
428 }
429
430 #[test]
431 fn with_params_called_multiple_times() {
432 let path = UsersShow { id: 1 }
433 .with_query_params(Params {
434 foo: "foo",
435 bar: 123,
436 baz: true,
437 })
438 .with_query_params([("qux", 1337)]);
439
440 let uri = path.to_uri();
441
442 assert_eq!(uri, "/users/1?&foo=foo&bar=123&baz=true&qux=1337");
443 }
444
445 #[allow(dead_code)] // just needs to compile
446 fn supports_with_rejection() {
447 async fn handler(_: WithRejection<UsersShow, MyRejection>) {}
448
449 struct MyRejection {}
450
451 impl IntoResponse for MyRejection {
452 fn into_response(self) -> Response {
453 unimplemented!()
454 }
455 }
456
457 impl From<PathRejection> for MyRejection {
458 fn from(_: PathRejection) -> Self {
459 unimplemented!()
460 }
461 }
462
463 let _: Router = Router::new().typed_get(handler);
464 }
465}