utoipa_axum/
lib.rs

1#![cfg_attr(doc_cfg, feature(doc_cfg))]
2#![warn(missing_docs)]
3#![warn(rustdoc::broken_intra_doc_links)]
4
5//! Utoipa axum brings `utoipa` and `axum` closer together by the way of providing an ergonomic API that is extending on
6//! the `axum` API. It gives a natural way to register handlers known to `axum` and also simultaneously generates OpenAPI
7//! specification from the handlers.
8//!
9//! ## Crate features
10//!
11//! - **`debug`**: Implement debug traits for types.
12//!
13//! ## Install
14//!
15//! Add dependency declaration to `Cargo.toml`.
16//!
17//! ```toml
18//! [dependencies]
19//! utoipa-axum = "0.2"
20//! ```
21//!
22//! ## Examples
23//!
24//! _**Use [`OpenApiRouter`][router] to collect handlers with _`#[utoipa::path]`_ macro to compose service and form OpenAPI spec.**_
25//!
26//! ```rust
27//! # use axum::Json;
28//! # use utoipa::openapi::OpenApi;
29//! # use utoipa_axum::{routes, PathItemExt, router::OpenApiRouter};
30//!  #[derive(utoipa::ToSchema, serde::Serialize)]
31//!  struct User {
32//!      id: i32,
33//!  }
34//!
35//!  #[utoipa::path(get, path = "/user", responses((status = OK, body = User)))]
36//!  async fn get_user() -> Json<User> {
37//!     Json(User { id: 1 })
38//!  }
39//!  
40//!  let (router, api): (axum::Router, OpenApi) = OpenApiRouter::new()
41//!      .routes(routes!(get_user))
42//!      .split_for_parts();
43//! ```
44//!
45//! [router]: router/struct.OpenApiRouter.html
46
47pub mod router;
48
49use axum::routing::MethodFilter;
50use utoipa::openapi::HttpMethod;
51
52/// Extends [`utoipa::openapi::path::PathItem`] by providing conversion methods to convert this
53/// path item type to a [`axum::routing::MethodFilter`].
54pub trait PathItemExt {
55    /// Convert this path item type to a [`axum::routing::MethodFilter`].
56    ///
57    /// Method filter is used with handler registration on [`axum::routing::MethodRouter`].
58    fn to_method_filter(&self) -> MethodFilter;
59}
60
61impl PathItemExt for HttpMethod {
62    fn to_method_filter(&self) -> MethodFilter {
63        match self {
64            HttpMethod::Get => MethodFilter::GET,
65            HttpMethod::Put => MethodFilter::PUT,
66            HttpMethod::Post => MethodFilter::POST,
67            HttpMethod::Head => MethodFilter::HEAD,
68            HttpMethod::Patch => MethodFilter::PATCH,
69            HttpMethod::Trace => MethodFilter::TRACE,
70            HttpMethod::Delete => MethodFilter::DELETE,
71            HttpMethod::Options => MethodFilter::OPTIONS,
72        }
73    }
74}
75
76/// re-export paste so users do not need to add the dependency.
77#[doc(hidden)]
78pub use paste::paste;
79
80/// Collect axum handlers annotated with [`utoipa::path`] to [`router::UtoipaMethodRouter`].
81///
82/// [`routes`] macro will return [`router::UtoipaMethodRouter`] which contains an
83/// [`axum::routing::MethodRouter`] and currently registered paths. The output of this macro is
84/// meant to be used together with [`router::OpenApiRouter`] which combines the paths and axum
85/// routers to a single entity.
86///
87/// Only handlers collected with [`routes`] macro will get registered to the OpenApi.
88///
89/// # Panics
90///
91/// Routes registered via [`routes`] macro or via `axum::routing::*` operations are bound to same
92/// rules where only one one HTTP method can can be registered once per call. This means that the
93/// following will produce runtime panic from axum code.
94///
95/// ```rust,no_run
96/// # use utoipa_axum::{routes, router::UtoipaMethodRouter};
97/// # use utoipa::path;
98///  #[utoipa::path(get, path = "/search")]
99///  async fn search_user() {}
100///
101///  #[utoipa::path(get, path = "")]
102///  async fn get_user() {}
103///
104///  let _: UtoipaMethodRouter = routes!(get_user, search_user);
105/// ```
106/// Since the _`axum`_ does not support method filter for `CONNECT` requests, using this macro with
107/// handler having request method type `CONNECT` `#[utoipa::path(connect, path = "")]` will panic at
108/// runtime.
109///
110/// # Examples
111///
112/// _**Create new `OpenApiRouter` with `get_user` and `post_user` paths.**_
113/// ```rust
114/// # use utoipa_axum::{routes, router::{OpenApiRouter, UtoipaMethodRouter}};
115/// # use utoipa::path;
116///  #[utoipa::path(get, path = "")]
117///  async fn get_user() {}
118///
119///  #[utoipa::path(post, path = "")]
120///  async fn post_user() {}
121///
122///  let _: OpenApiRouter = OpenApiRouter::new().routes(routes!(get_user, post_user));
123/// ```
124#[macro_export]
125macro_rules! routes {
126    ( $handler:path $(, $tail:path)* $(,)? ) => {
127        {
128            use $crate::PathItemExt;
129            let mut paths = utoipa::openapi::path::Paths::new();
130            let mut schemas = Vec::<(String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>)>::new();
131            let (path, item, types) = $crate::routes!(@resolve_types $handler : schemas);
132            #[allow(unused_mut)]
133            let mut method_router = types.iter().by_ref().fold(axum::routing::MethodRouter::new(), |router, path_type| {
134                router.on(path_type.to_method_filter(), $handler)
135            });
136            paths.add_path_operation(&path, types, item);
137            $( method_router = $crate::routes!( schemas: method_router: paths: $tail ); )*
138            (schemas, paths, method_router)
139        }
140    };
141    ( $schemas:tt: $router:ident: $paths:ident: $handler:path $(, $tail:tt)* ) => {
142        {
143            let (path, item, types) = $crate::routes!(@resolve_types $handler : $schemas);
144            let router = types.iter().by_ref().fold($router, |router, path_type| {
145                router.on(path_type.to_method_filter(), $handler)
146            });
147            $paths.add_path_operation(&path, types, item);
148            router
149        }
150    };
151    ( @resolve_types $handler:path : $schemas:tt ) => {
152        {
153            $crate::paste! {
154                let path = $crate::routes!( @path [path()] of $handler );
155                let mut operation = $crate::routes!( @path [operation()] of $handler );
156                let types = $crate::routes!( @path [methods()] of $handler );
157                let tags = $crate::routes!( @path [tags()] of $handler );
158                $crate::routes!( @path [schemas(&mut $schemas)] of $handler );
159                if !tags.is_empty() {
160                    let operation_tags = operation.tags.get_or_insert(Vec::new());
161                    operation_tags.extend(tags.iter().map(ToString::to_string));
162                }
163                (path, operation, types)
164            }
165        }
166    };
167    ( @path $op:tt of $part:ident $( :: $tt:tt )* ) => {
168        $crate::routes!( $op : [ $part $( $tt )*] )
169    };
170    ( $op:tt : [ $first:tt $( $rest:tt )* ] $( $rev:tt )* ) => {
171        $crate::routes!( $op : [ $( $rest )* ] $first $( $rev)* )
172    };
173    ( $op:tt : [] $first:tt $( $rest:tt )* ) => {
174        $crate::routes!( @inverse $op : $first $( $rest )* )
175    };
176    ( @inverse $op:tt : $tt:tt $( $rest:tt )* ) => {
177        $crate::routes!( @rev $op : $tt [$($rest)*] )
178    };
179    ( @rev $op:tt : $tt:tt [ $first:tt $( $rest:tt)* ] $( $reversed:tt )* ) => {
180        $crate::routes!( @rev $op : $tt [ $( $rest )* ] $first $( $reversed )* )
181    };
182    ( @rev [$op:ident $( $args:tt )* ] : $handler:tt [] $($tt:tt)* ) => {
183        {
184            #[allow(unused_imports)]
185            use utoipa::{Path, __dev::{Tags, SchemaReferences}};
186            $crate::paste! {
187                $( $tt :: )* [<__path_ $handler>]::$op $( $args )*
188            }
189        }
190    };
191    ( ) => {};
192}
193
194#[cfg(test)]
195mod tests {
196    use std::collections::BTreeMap;
197
198    use super::*;
199    use axum::extract::{Path, State};
200    use insta::assert_json_snapshot;
201    use router::*;
202    use tower::util::ServiceExt;
203    use utoipa::openapi::{Content, OpenApi, Ref, ResponseBuilder};
204    use utoipa::PartialSchema;
205
206    #[utoipa::path(get, path = "/")]
207    async fn root() {}
208
209    // --- user
210
211    #[utoipa::path(get, path = "/")]
212    async fn get_user() {}
213
214    #[utoipa::path(post, path = "/")]
215    async fn post_user() {}
216
217    #[utoipa::path(delete, path = "/")]
218    async fn delete_user() {}
219
220    #[utoipa::path(get, path = "/search")]
221    async fn search_user() {}
222
223    // --- customer
224
225    #[utoipa::path(get, path = "/")]
226    async fn get_customer() {}
227
228    #[utoipa::path(post, path = "/")]
229    async fn post_customer() {}
230
231    #[utoipa::path(delete, path = "/")]
232    async fn delete_customer() {}
233
234    // test that with state handler compiles
235    #[utoipa::path(get, path = "/search")]
236    async fn search_customer(State(_s): State<String>) {}
237
238    #[test]
239    fn axum_router_nest_openapi_routes_compile() {
240        let user_router: OpenApiRouter = OpenApiRouter::new()
241            .routes(routes!(search_user))
242            .routes(routes!(get_user, post_user, delete_user));
243
244        let customer_router: OpenApiRouter = OpenApiRouter::new()
245            .routes(routes!(get_customer, post_customer, delete_customer))
246            .routes(routes!(search_customer))
247            .with_state(String::new());
248
249        let router = OpenApiRouter::new()
250            .nest("/api/user", user_router)
251            .nest("/api/customer", customer_router)
252            .route("/", axum::routing::get(root));
253
254        let _ = router.get_openapi();
255    }
256
257    #[test]
258    fn routes_with_trailing_comma_compiles() {
259        let _: OpenApiRouter =
260            OpenApiRouter::new().routes(routes!(get_user, post_user, delete_user,));
261    }
262
263    #[test]
264    fn openapi_router_with_openapi() {
265        use utoipa::OpenApi;
266
267        #[derive(utoipa::ToSchema)]
268        #[allow(unused)]
269        struct Todo {
270            id: i32,
271        }
272        #[derive(utoipa::OpenApi)]
273        #[openapi(components(schemas(Todo)))]
274        struct Api;
275
276        let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi())
277            .routes(routes!(search_user))
278            .routes(routes!(get_user));
279
280        let paths = router.to_openapi().paths;
281        let expected_paths = utoipa::openapi::path::PathsBuilder::new()
282            .path(
283                "/",
284                utoipa::openapi::PathItem::new(
285                    utoipa::openapi::path::HttpMethod::Get,
286                    utoipa::openapi::path::OperationBuilder::new().operation_id(Some("get_user")),
287                ),
288            )
289            .path(
290                "/search",
291                utoipa::openapi::PathItem::new(
292                    utoipa::openapi::path::HttpMethod::Get,
293                    utoipa::openapi::path::OperationBuilder::new()
294                        .operation_id(Some("search_user")),
295                ),
296            );
297        assert_eq!(expected_paths.build(), paths);
298    }
299
300    #[test]
301    fn openapi_router_nest_openapi() {
302        use utoipa::OpenApi;
303
304        #[derive(utoipa::ToSchema)]
305        #[allow(unused)]
306        struct Todo {
307            id: i32,
308        }
309        #[derive(utoipa::OpenApi)]
310        #[openapi(components(schemas(Todo)))]
311        struct Api;
312
313        let router: router::OpenApiRouter =
314            router::OpenApiRouter::with_openapi(Api::openapi()).routes(routes!(search_user));
315
316        let customer_router: router::OpenApiRouter = router::OpenApiRouter::new()
317            .routes(routes!(get_customer))
318            .with_state(String::new());
319
320        let mut router = router.nest("/api/customer", customer_router);
321        let paths = router.to_openapi().paths;
322        let expected_paths = utoipa::openapi::path::PathsBuilder::new()
323            .path(
324                "/api/customer",
325                utoipa::openapi::PathItem::new(
326                    utoipa::openapi::path::HttpMethod::Get,
327                    utoipa::openapi::path::OperationBuilder::new()
328                        .operation_id(Some("get_customer")),
329                ),
330            )
331            .path(
332                "/search",
333                utoipa::openapi::PathItem::new(
334                    utoipa::openapi::path::HttpMethod::Get,
335                    utoipa::openapi::path::OperationBuilder::new()
336                        .operation_id(Some("search_user")),
337                ),
338            );
339        assert_eq!(expected_paths.build(), paths);
340    }
341
342    #[test]
343    fn openapi_with_auto_collected_schemas() {
344        #[derive(utoipa::ToSchema)]
345        #[allow(unused)]
346        struct Todo {
347            id: i32,
348        }
349
350        #[utoipa::path(get, path = "/todo", responses((status = 200, body = Todo)))]
351        async fn get_todo() {}
352
353        let mut router: router::OpenApiRouter =
354            router::OpenApiRouter::new().routes(routes!(get_todo));
355
356        let openapi = router.to_openapi();
357        let paths = openapi.paths;
358        let schemas = openapi
359            .components
360            .expect("Router must have auto collected schemas")
361            .schemas;
362
363        let expected_paths = utoipa::openapi::path::PathsBuilder::new().path(
364            "/todo",
365            utoipa::openapi::PathItem::new(
366                utoipa::openapi::path::HttpMethod::Get,
367                utoipa::openapi::path::OperationBuilder::new()
368                    .operation_id(Some("get_todo"))
369                    .response(
370                        "200",
371                        ResponseBuilder::new().content(
372                            "application/json",
373                            Content::builder()
374                                .schema(Some(Ref::from_schema_name("Todo")))
375                                .build(),
376                        ),
377                    ),
378            ),
379        );
380        let expected_schemas =
381            BTreeMap::from_iter(std::iter::once(("Todo".to_string(), Todo::schema())));
382        assert_eq!(expected_paths.build(), paths);
383        assert_eq!(expected_schemas, schemas);
384    }
385
386    mod pets {
387
388        #[utoipa::path(get, path = "/")]
389        pub async fn get_pet() {}
390
391        #[utoipa::path(post, path = "/")]
392        pub async fn post_pet() {}
393
394        #[utoipa::path(delete, path = "/")]
395        pub async fn delete_pet() {}
396    }
397
398    #[test]
399    fn openapi_routes_from_another_path() {
400        let mut router: OpenApiRouter =
401            OpenApiRouter::new().routes(routes!(pets::get_pet, pets::post_pet, pets::delete_pet));
402        let paths = router.to_openapi().paths;
403
404        let expected_paths = utoipa::openapi::path::PathsBuilder::new()
405            .path(
406                "/",
407                utoipa::openapi::PathItem::new(
408                    utoipa::openapi::path::HttpMethod::Get,
409                    utoipa::openapi::path::OperationBuilder::new().operation_id(Some("get_pet")),
410                ),
411            )
412            .path(
413                "/",
414                utoipa::openapi::PathItem::new(
415                    utoipa::openapi::path::HttpMethod::Post,
416                    utoipa::openapi::path::OperationBuilder::new().operation_id(Some("post_pet")),
417                ),
418            )
419            .path(
420                "/",
421                utoipa::openapi::PathItem::new(
422                    utoipa::openapi::path::HttpMethod::Delete,
423                    utoipa::openapi::path::OperationBuilder::new().operation_id(Some("delete_pet")),
424                ),
425            );
426        assert_eq!(expected_paths.build(), paths);
427    }
428
429    #[tokio::test]
430    async fn test_axum_router() {
431        #[utoipa::path(
432            get,
433            path = "/pet/{pet_id}",
434            params(("pet_id" = u32, Path, description = "ID of pet to return")),
435        )]
436        pub async fn get_pet_by_id(Path(pet_id): Path<u32>) -> String {
437            pet_id.to_string()
438        }
439
440        let (router, openapi) = OpenApiRouter::with_openapi(OpenApi::default())
441            .routes(routes!(get_pet_by_id))
442            .split_for_parts();
443
444        assert_json_snapshot!(openapi);
445
446        let request = http::Request::builder()
447            .uri("/pet/1")
448            .body("".to_string())
449            .unwrap();
450
451        let response = router.clone().oneshot(request).await.unwrap();
452        assert_eq!(response.status(), http::StatusCode::OK);
453
454        let body = response.into_body();
455        let body = axum::body::to_bytes(body, usize::MAX).await.unwrap();
456        assert_eq!(body, "1");
457
458        let request = http::Request::builder()
459            .uri("/pet/foo")
460            .body("".to_string())
461            .unwrap();
462
463        let response = router.oneshot(request).await.unwrap();
464        assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
465    }
466}