├── .gitignore ├── Cargo.toml ├── README.md ├── axum-typed-routing-macros ├── Cargo.toml ├── README.md └── src │ ├── compilation.rs │ ├── lib.rs │ └── parsing.rs └── axum-typed-routing ├── Cargo.toml ├── examples ├── aide.rs └── basic.rs ├── src └── lib.rs └── tests └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | .vscode -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["axum-typed-routing-macros", "axum-typed-routing"] 3 | resolver = "2" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Crates.io](https://img.shields.io/crates/v/axum-typed-routing)](https://crates.io/crates/axum-typed-routing) 2 | [![Documentation](https://docs.rs/axum-typed-routing/badge.svg)](https://docs.rs/axum-typed-routing) 3 | 4 | # Axum-typed-routing 5 | A library for creating statically-typed handlers in axum using macros, similar to Rocket with OpenAPI support using [aide](https://docs.rs/aide/0.12.0/aide/index.html). 6 | 7 | See the [docs](https://docs.rs/axum-typed-routing) for more information. -------------------------------------------------------------------------------- /axum-typed-routing-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-typed-routing-macros" 3 | version = "0.3.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | keywords = ["axum", "routing", "handler", "typed", "macro"] 7 | categories = ["web-programming"] 8 | description = "Typed routing macros for axum" 9 | homepage = "https://github.com/jvdwrf/axum-typed-routing" 10 | repository = "https://github.com/jvdwrf/axum-typed-routing" 11 | readme = "../README.md" 12 | 13 | [dependencies] 14 | syn = { version = "2", features = ["full"] } 15 | quote = "1" 16 | proc-macro2 = "1" 17 | 18 | [dev-dependencies] 19 | axum = { version = "0.8", features = [] } 20 | aide = { version = "0.14", features = ["axum", "axum-json", "axum-query"] } 21 | serde = { version = "1.0", features = ["derive"] } 22 | schemars = "0.8" 23 | 24 | [lib] 25 | proc-macro = true 26 | -------------------------------------------------------------------------------- /axum-typed-routing-macros/README.md: -------------------------------------------------------------------------------- 1 | Macro's for `axum-typed-routing`. -------------------------------------------------------------------------------- /axum-typed-routing-macros/src/compilation.rs: -------------------------------------------------------------------------------- 1 | use quote::ToTokens; 2 | use syn::{spanned::Spanned, LitBool, LitInt, Pat, PatType}; 3 | 4 | use crate::parsing::{OapiOptions, Responses, Security, StrArray}; 5 | 6 | use self::parsing::PathParam; 7 | 8 | use super::*; 9 | 10 | pub struct CompiledRoute { 11 | pub method: Method, 12 | #[allow(clippy::type_complexity)] 13 | pub path_params: Vec<(Slash, PathParam)>, 14 | pub query_params: Vec<(Ident, Box)>, 15 | pub state: Type, 16 | pub route_lit: LitStr, 17 | pub oapi_options: Option, 18 | } 19 | 20 | impl CompiledRoute { 21 | pub fn to_axum_path_string(&self) -> String { 22 | let mut path = String::new(); 23 | 24 | for (_slash, param) in &self.path_params { 25 | path.push('/'); 26 | match param { 27 | PathParam::Capture(lit, _brace_1, _, _, _brace_2) => { 28 | path.push('{'); 29 | path.push_str(&lit.value()); 30 | path.push('}'); 31 | } 32 | PathParam::WildCard(lit, _brace_1, _, _, _, _brace_2) => { 33 | path.push('{'); 34 | path.push('*'); 35 | path.push_str(&lit.value()); 36 | path.push('}'); 37 | } 38 | PathParam::Static(lit) => path.push_str(&lit.value()), 39 | } 40 | // if colon.is_some() { 41 | // path.push(':'); 42 | // } 43 | // path.push_str(&ident.value()); 44 | } 45 | 46 | path 47 | } 48 | 49 | /// Removes the arguments in `route` from `args`, and merges them in the output. 50 | pub fn from_route(mut route: Route, function: &ItemFn, with_aide: bool) -> syn::Result { 51 | if !with_aide && route.oapi_options.is_some() { 52 | return Err(syn::Error::new( 53 | Span::call_site(), 54 | "Use `api_route` instead of `route` to use OpenAPI options", 55 | )); 56 | } else if with_aide && route.oapi_options.is_none() { 57 | route.oapi_options = Some(OapiOptions { 58 | summary: None, 59 | description: None, 60 | id: None, 61 | hidden: None, 62 | tags: None, 63 | security: None, 64 | responses: None, 65 | transform: None, 66 | }); 67 | } 68 | 69 | let sig = &function.sig; 70 | let mut arg_map = sig 71 | .inputs 72 | .iter() 73 | .filter_map(|item| match item { 74 | syn::FnArg::Receiver(_) => None, 75 | syn::FnArg::Typed(pat_type) => Some(pat_type), 76 | }) 77 | .filter_map(|pat_type| match &*pat_type.pat { 78 | syn::Pat::Ident(ident) => Some((ident.ident.clone(), pat_type.ty.clone())), 79 | _ => None, 80 | }) 81 | .collect::>(); 82 | 83 | for (_slash, path_param) in &mut route.path_params { 84 | match path_param { 85 | PathParam::Capture(_lit, _, ident, ty, _) => { 86 | let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| { 87 | syn::Error::new( 88 | ident.span(), 89 | format!("path parameter `{}` not found in function arguments", ident), 90 | ) 91 | })?; 92 | *ident = new_ident; 93 | *ty = new_ty; 94 | } 95 | PathParam::WildCard(_lit, _, _star, ident, ty, _) => { 96 | let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| { 97 | syn::Error::new( 98 | ident.span(), 99 | format!("path parameter `{}` not found in function arguments", ident), 100 | ) 101 | })?; 102 | *ident = new_ident; 103 | *ty = new_ty; 104 | } 105 | PathParam::Static(_lit) => {} 106 | } 107 | } 108 | 109 | let mut query_params = Vec::new(); 110 | for ident in route.query_params { 111 | let (ident, ty) = arg_map.remove_entry(&ident).ok_or_else(|| { 112 | syn::Error::new( 113 | ident.span(), 114 | format!( 115 | "query parameter `{}` not found in function arguments", 116 | ident 117 | ), 118 | ) 119 | })?; 120 | query_params.push((ident, ty)); 121 | } 122 | 123 | if let Some(options) = route.oapi_options.as_mut() { 124 | options.merge_with_fn(function) 125 | } 126 | 127 | Ok(Self { 128 | route_lit: route.route_lit, 129 | method: route.method, 130 | path_params: route.path_params, 131 | query_params, 132 | state: route.state.unwrap_or_else(|| guess_state_type(sig)), 133 | oapi_options: route.oapi_options, 134 | }) 135 | } 136 | 137 | pub fn path_extractor(&self) -> Option { 138 | if !self.path_params.iter().any(|(_, param)| param.captures()) { 139 | return None; 140 | } 141 | 142 | let path_iter = self 143 | .path_params 144 | .iter() 145 | .filter_map(|(_slash, path_param)| path_param.capture()); 146 | let idents = path_iter.clone().map(|item| item.0); 147 | let types = path_iter.clone().map(|item| item.1); 148 | Some(quote! { 149 | ::axum::extract::Path((#(#idents,)*)): ::axum::extract::Path<(#(#types,)*)>, 150 | }) 151 | } 152 | 153 | pub fn query_extractor(&self) -> Option { 154 | if self.query_params.is_empty() { 155 | return None; 156 | } 157 | 158 | let idents = self.query_params.iter().map(|item| &item.0); 159 | Some(quote! { 160 | ::axum::extract::Query(__QueryParams__ { 161 | #(#idents,)* 162 | }): ::axum::extract::Query<__QueryParams__>, 163 | }) 164 | } 165 | 166 | pub fn query_params_struct(&self, with_aide: bool) -> Option { 167 | match self.query_params.is_empty() { 168 | true => None, 169 | false => { 170 | let idents = self.query_params.iter().map(|item| &item.0); 171 | let types = self.query_params.iter().map(|item| &item.1); 172 | let derive = match with_aide { 173 | true => quote! { #[derive(::serde::Deserialize, ::schemars::JsonSchema)] }, 174 | false => quote! { #[derive(::serde::Deserialize)] }, 175 | }; 176 | Some(quote! { 177 | #derive 178 | struct __QueryParams__ { 179 | #(#idents: #types,)* 180 | } 181 | }) 182 | } 183 | } 184 | } 185 | 186 | pub fn extracted_idents(&self) -> Vec { 187 | let mut idents = Vec::new(); 188 | for (_slash, path_param) in &self.path_params { 189 | if let Some((ident, _ty)) = path_param.capture() { 190 | idents.push(ident.clone()); 191 | } 192 | } 193 | for (ident, _ty) in &self.query_params { 194 | idents.push(ident.clone()); 195 | } 196 | idents 197 | } 198 | 199 | /// The arguments not used in the route. 200 | /// Map the identifier to `___arg___{i}: Type`. 201 | pub fn remaining_pattypes_numbered( 202 | &self, 203 | args: &Punctuated, 204 | ) -> Punctuated { 205 | args.iter() 206 | .enumerate() 207 | .filter_map(|(i, item)| { 208 | if let FnArg::Typed(pat_type) = item { 209 | if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { 210 | if self.path_params.iter().any(|(_slash, path_param)| { 211 | if let Some((path_ident, _ty)) = path_param.capture() { 212 | path_ident == &pat_ident.ident 213 | } else { 214 | false 215 | } 216 | }) || self 217 | .query_params 218 | .iter() 219 | .any(|(query_ident, _)| query_ident == &pat_ident.ident) 220 | { 221 | return None; 222 | } 223 | } 224 | 225 | let mut new_pat_type = pat_type.clone(); 226 | let ident = format_ident!("___arg___{}", i); 227 | new_pat_type.pat = Box::new(parse_quote!(#ident)); 228 | Some(new_pat_type) 229 | } else { 230 | unimplemented!("Self type is not supported") 231 | } 232 | }) 233 | .collect() 234 | } 235 | 236 | pub fn ide_documentation_for_aide_methods(&self) -> TokenStream2 { 237 | let Some(options) = &self.oapi_options else { 238 | return quote! {}; 239 | }; 240 | let summary = options.summary.as_ref().map(|(ident, _)| { 241 | let method = Ident::new("summary", ident.span()); 242 | quote!( let x = x.#method(""); ) 243 | }); 244 | let description = options.description.as_ref().map(|(ident, _)| { 245 | let method = Ident::new("description", ident.span()); 246 | quote!( let x = x.#method(""); ) 247 | }); 248 | let id = options.id.as_ref().map(|(ident, _)| { 249 | let method = Ident::new("id", ident.span()); 250 | quote!( let x = x.#method(""); ) 251 | }); 252 | let hidden = options.hidden.as_ref().map(|(ident, _)| { 253 | let method = Ident::new("hidden", ident.span()); 254 | quote!( let x = x.#method(false); ) 255 | }); 256 | let tags = options.tags.as_ref().map(|(ident, _)| { 257 | let method = Ident::new("tag", ident.span()); 258 | quote!( let x = x.#method(""); ) 259 | }); 260 | let security = options.security.as_ref().map(|(ident, _)| { 261 | let method = Ident::new("security_requirement_scopes", ident.span()); 262 | quote!( let x = x.#method("", [""]); ) 263 | }); 264 | let responses = options.responses.as_ref().map(|(ident, _)| { 265 | let method = Ident::new("response", ident.span()); 266 | quote!( let x = x.#method::<0, String>(); ) 267 | }); 268 | let transform = options.transform.as_ref().map(|(ident, _)| { 269 | let method = Ident::new("with", ident.span()); 270 | quote!( let x = x.#method(|x|x); ) 271 | }); 272 | 273 | quote! { 274 | #[allow(unused)] 275 | #[allow(clippy::no_effect)] 276 | fn ____ide_documentation_for_aide____(x: ::aide::transform::TransformOperation) { 277 | #summary 278 | #description 279 | #id 280 | #hidden 281 | #tags 282 | #security 283 | #responses 284 | #transform 285 | } 286 | } 287 | } 288 | 289 | pub fn get_oapi_summary(&self) -> Option { 290 | if let Some(oapi_options) = &self.oapi_options { 291 | if let Some(summary) = &oapi_options.summary { 292 | return Some(summary.1.clone()); 293 | } 294 | } 295 | None 296 | } 297 | 298 | pub fn get_oapi_description(&self) -> Option { 299 | if let Some(oapi_options) = &self.oapi_options { 300 | if let Some(description) = &oapi_options.description { 301 | return Some(description.1.clone()); 302 | } 303 | } 304 | None 305 | } 306 | 307 | pub fn get_oapi_hidden(&self) -> Option { 308 | if let Some(oapi_options) = &self.oapi_options { 309 | if let Some(hidden) = &oapi_options.hidden { 310 | return Some(hidden.1.clone()); 311 | } 312 | } 313 | None 314 | } 315 | 316 | pub fn get_oapi_tags(&self) -> Vec { 317 | if let Some(oapi_options) = &self.oapi_options { 318 | if let Some(tags) = &oapi_options.tags { 319 | return tags.1 .0.clone(); 320 | } 321 | } 322 | Vec::new() 323 | } 324 | 325 | pub fn get_oapi_id(&self, sig: &Signature) -> Option { 326 | if let Some(oapi_options) = &self.oapi_options { 327 | if let Some(id) = &oapi_options.id { 328 | return Some(id.1.clone()); 329 | } 330 | } 331 | Some(LitStr::new(&sig.ident.to_string(), sig.ident.span())) 332 | } 333 | 334 | pub fn get_oapi_transform(&self) -> syn::Result> { 335 | if let Some(oapi_options) = &self.oapi_options { 336 | if let Some(transform) = &oapi_options.transform { 337 | if transform.1.inputs.len() != 1 { 338 | return Err(syn::Error::new( 339 | transform.1.span(), 340 | "expected a single identifier", 341 | )); 342 | } 343 | 344 | let pat = transform.1.inputs.first().unwrap(); 345 | let body = &transform.1.body; 346 | 347 | if let Pat::Ident(pat_ident) = pat { 348 | let ident = &pat_ident.ident; 349 | return Ok(Some(quote! { 350 | let #ident = __op__; 351 | let __op__ = #body; 352 | })); 353 | } else { 354 | return Err(syn::Error::new( 355 | pat.span(), 356 | "expected a single identifier without type", 357 | )); 358 | } 359 | } 360 | } 361 | Ok(None) 362 | } 363 | 364 | pub fn get_oapi_responses(&self) -> Vec<(LitInt, Type)> { 365 | if let Some(oapi_options) = &self.oapi_options { 366 | if let Some((_ident, Responses(responses))) = &oapi_options.responses { 367 | return responses.clone(); 368 | } 369 | } 370 | Default::default() 371 | } 372 | 373 | pub fn get_oapi_security(&self) -> Vec<(LitStr, Vec)> { 374 | if let Some(oapi_options) = &self.oapi_options { 375 | if let Some((_ident, Security(security))) = &oapi_options.security { 376 | return security 377 | .iter() 378 | .map(|(scheme, StrArray(scopes))| (scheme.clone(), scopes.clone())) 379 | .collect(); 380 | } 381 | } 382 | Default::default() 383 | } 384 | 385 | pub(crate) fn to_doc_comments(&self) -> TokenStream2 { 386 | let mut doc = format!( 387 | "# Handler information 388 | - Method: `{}` 389 | - Path: `{}` 390 | - State: `{}`", 391 | self.method.to_axum_method_name(), 392 | self.route_lit.value(), 393 | self.state.to_token_stream(), 394 | ); 395 | 396 | if let Some(options) = &self.oapi_options { 397 | let summary = options 398 | .summary 399 | .as_ref() 400 | .map(|(_, summary)| format!("\"{}\"", summary.value())) 401 | .unwrap_or("None".to_string()); 402 | let description = options 403 | .description 404 | .as_ref() 405 | .map(|(_, description)| format!("\"{}\"", description.value())) 406 | .unwrap_or("None".to_string()); 407 | let id = options 408 | .id 409 | .as_ref() 410 | .map(|(_, id)| format!("\"{}\"", id.value())) 411 | .unwrap_or("None".to_string()); 412 | let hidden = options 413 | .hidden 414 | .as_ref() 415 | .map(|(_, hidden)| hidden.value().to_string()) 416 | .unwrap_or("None".to_string()); 417 | let tags = options 418 | .tags 419 | .as_ref() 420 | .map(|(_, tags)| tags.to_string()) 421 | .unwrap_or("[]".to_string()); 422 | let security = options 423 | .security 424 | .as_ref() 425 | .map(|(_, security)| security.to_string()) 426 | .unwrap_or("{}".to_string()); 427 | 428 | doc = format!( 429 | "{doc} 430 | 431 | ## OpenAPI 432 | - Summary: `{summary}` 433 | - Description: `{description}` 434 | - Operation id: `{id}` 435 | - Tags: `{tags}` 436 | - Security: `{security}` 437 | - Hidden: `{hidden}` 438 | " 439 | ); 440 | } 441 | 442 | quote!( 443 | #[doc = #doc] 444 | ) 445 | } 446 | } 447 | 448 | fn guess_state_type(sig: &syn::Signature) -> Type { 449 | for arg in &sig.inputs { 450 | if let FnArg::Typed(pat_type) = arg { 451 | // Returns `T` if the type of the last segment is exactly `State`. 452 | if let Type::Path(ty) = &*pat_type.ty { 453 | let last_segment = ty.path.segments.last().unwrap(); 454 | if last_segment.ident == "State" { 455 | if let PathArguments::AngleBracketed(args) = &last_segment.arguments { 456 | if args.args.len() == 1 { 457 | if let GenericArgument::Type(ty) = args.args.first().unwrap() { 458 | return ty.clone(); 459 | } 460 | } 461 | } 462 | } 463 | } 464 | } 465 | } 466 | 467 | parse_quote! { () } 468 | } 469 | -------------------------------------------------------------------------------- /axum-typed-routing-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | use compilation::CompiledRoute; 2 | use parsing::{Method, Route}; 3 | use proc_macro::TokenStream; 4 | use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; 5 | use std::collections::HashMap; 6 | use syn::{ 7 | parse::{Parse, ParseStream}, 8 | punctuated::Punctuated, 9 | token::{Comma, Slash}, 10 | FnArg, GenericArgument, ItemFn, LitStr, Meta, PathArguments, Signature, Type, 11 | }; 12 | #[macro_use] 13 | extern crate quote; 14 | #[macro_use] 15 | extern crate syn; 16 | 17 | mod compilation; 18 | mod parsing; 19 | 20 | /// A macro that generates statically-typed routes for axum handlers. 21 | /// 22 | /// # Syntax 23 | /// ```ignore 24 | /// #[route( "" [with ])] 25 | /// ``` 26 | /// - `METHOD` is the HTTP method, such as `GET`, `POST`, `PUT`, etc. 27 | /// - `PATH` is the path of the route, with optional path parameters and query parameters, 28 | /// e.g. `/item/{id}?amount&offset`. 29 | /// - `STATE` is the type of axum-state, passed to the handler. This is optional, and if not 30 | /// specified, the state type is guessed based on the parameters of the handler. 31 | /// 32 | /// # Example 33 | /// ``` 34 | /// use axum::extract::{State, Json}; 35 | /// use axum_typed_routing_macros::route; 36 | /// 37 | /// #[route(GET "/item/{id}?amount&offset")] 38 | /// async fn item_handler( 39 | /// id: u32, 40 | /// amount: Option, 41 | /// offset: Option, 42 | /// State(state): State, 43 | /// Json(json): Json, 44 | /// ) -> String { 45 | /// todo!("handle request") 46 | /// } 47 | /// ``` 48 | /// 49 | /// # State type 50 | /// Normally, the state-type is guessed based on the parameters of the function: 51 | /// If the function has a parameter of type `[..]::State`, then `T` is used as the state type. 52 | /// This should work for most cases, however when not sufficient, the state type can be specified 53 | /// explicitly using the `with` keyword: 54 | /// ```ignore 55 | /// #[route(GET "/item/{id}?amount&offset" with String)] 56 | /// ``` 57 | /// 58 | /// # Internals 59 | /// The macro expands to a function with signature `fn() -> (&'static str, axum::routing::MethodRouter)`. 60 | /// The first element of the tuple is the path, and the second is axum's `MethodRouter`. 61 | /// 62 | /// The path and query are extracted using axum's `extract::Path` and `extract::Query` extractors, as the first 63 | /// and second parameters of the function. The remaining parameters are the parameters of the handler. 64 | #[proc_macro_attribute] 65 | pub fn route(attr: TokenStream, mut item: TokenStream) -> TokenStream { 66 | match _route(attr, item.clone(), false) { 67 | Ok(tokens) => tokens.into(), 68 | Err(err) => { 69 | let err: TokenStream = err.to_compile_error().into(); 70 | item.extend(err); 71 | item 72 | } 73 | } 74 | } 75 | 76 | /// Same as [`macro@route`], but with support for OpenApi using `aide`. See [`macro@route`] for more 77 | /// information and examples. 78 | /// 79 | /// # Syntax 80 | /// ```ignore 81 | /// #[api_route( "" [with ] [{ 82 | /// summary: "", 83 | /// description: "", 84 | /// id: "", 85 | /// tags: ["", ..], 86 | /// hidden: , 87 | /// security: { : ["", ..], .. }, 88 | /// responses: { : , .. }, 89 | /// transform: |op| { .. }, 90 | /// }])] 91 | /// ``` 92 | /// - `summary` is the OpenApi summary. If not specified, the first line of the function's doc-comments 93 | /// - `description` is the OpenApi description. If not specified, the rest of the function's doc-comments 94 | /// - `id` is the OpenApi operationId. If not specified, the function's name is used. 95 | /// - `tags` are the OpenApi tags. 96 | /// - `hidden` sets whether docs should be hidden for this route. 97 | /// - `security` is the OpenApi security requirements. 98 | /// - `responses` are the OpenApi responses. 99 | /// - `transform` is a closure that takes an `TransformOperation` and returns an `TransformOperation`. 100 | /// This may override the other options. (see the crate `aide` for more information). 101 | /// 102 | /// # Example 103 | /// ``` 104 | /// use axum::extract::{State, Json}; 105 | /// use axum_typed_routing_macros::api_route; 106 | /// 107 | /// #[api_route(GET "/item/{id}?amount&offset" with String { 108 | /// summary: "Get an item", 109 | /// description: "Get an item by id", 110 | /// id: "get-item", 111 | /// tags: ["items"], 112 | /// hidden: false, 113 | /// security: { "bearer": ["read:items"] }, 114 | /// responses: { 200: String }, 115 | /// transform: |op| op.tag("private"), 116 | /// })] 117 | /// async fn item_handler( 118 | /// id: u32, 119 | /// amount: Option, 120 | /// offset: Option, 121 | /// State(state): State, 122 | /// ) -> String { 123 | /// todo!("handle request") 124 | /// } 125 | /// ``` 126 | #[proc_macro_attribute] 127 | pub fn api_route(attr: TokenStream, mut item: TokenStream) -> TokenStream { 128 | match _route(attr, item.clone(), true) { 129 | Ok(tokens) => tokens.into(), 130 | Err(err) => { 131 | let err: TokenStream = err.to_compile_error().into(); 132 | item.extend(err); 133 | item 134 | } 135 | } 136 | } 137 | 138 | fn _route(attr: TokenStream, item: TokenStream, with_aide: bool) -> syn::Result { 139 | // Parse the route and function 140 | let route = syn::parse::(attr)?; 141 | let function = syn::parse::(item)?; 142 | 143 | // Now we can compile the route 144 | let route = CompiledRoute::from_route(route, &function, with_aide)?; 145 | let path_extractor = route.path_extractor(); 146 | let query_extractor = route.query_extractor(); 147 | let query_params_struct = route.query_params_struct(with_aide); 148 | let state_type = &route.state; 149 | let axum_path = route.to_axum_path_string(); 150 | let http_method = route.method.to_axum_method_name(); 151 | let remaining_numbered_pats = route.remaining_pattypes_numbered(&function.sig.inputs); 152 | let extracted_idents = route.extracted_idents(); 153 | let remaining_numbered_idents = remaining_numbered_pats.iter().map(|pat_type| &pat_type.pat); 154 | let route_docs = route.to_doc_comments(); 155 | 156 | // Get the variables we need for code generation 157 | let fn_name = &function.sig.ident; 158 | let fn_output = &function.sig.output; 159 | let vis = &function.vis; 160 | let asyncness = &function.sig.asyncness; 161 | let (impl_generics, ty_generics, where_clause) = &function.sig.generics.split_for_impl(); 162 | let ty_generics = ty_generics.as_turbofish(); 163 | let fn_docs = function 164 | .attrs 165 | .iter() 166 | .filter(|attr| attr.path().is_ident("doc")); 167 | 168 | let (aide_ident_docs, inner_fn_call, method_router_ty) = if with_aide { 169 | let http_method = format_ident!("{}_with", http_method); 170 | let summary = route 171 | .get_oapi_summary() 172 | .map(|summary| quote! { .summary(#summary) }); 173 | let description = route 174 | .get_oapi_description() 175 | .map(|description| quote! { .description(#description) }); 176 | let hidden = route 177 | .get_oapi_hidden() 178 | .map(|hidden| quote! { .hidden(#hidden) }); 179 | let tags = route.get_oapi_tags(); 180 | let id = route 181 | .get_oapi_id(&function.sig) 182 | .map(|id| quote! { .id(#id) }); 183 | let transform = route.get_oapi_transform()?; 184 | let responses = route.get_oapi_responses(); 185 | let response_code = responses.iter().map(|response| &response.0); 186 | let response_type = responses.iter().map(|response| &response.1); 187 | let security = route.get_oapi_security(); 188 | let schemes = security.iter().map(|sec| &sec.0); 189 | let scopes = security.iter().map(|sec| &sec.1); 190 | 191 | ( 192 | route.ide_documentation_for_aide_methods(), 193 | quote! { 194 | ::aide::axum::routing::#http_method( 195 | __inner__function__ #ty_generics, 196 | |__op__| { 197 | let __op__ = __op__ 198 | #summary 199 | #description 200 | #hidden 201 | #id 202 | #(.tag(#tags))* 203 | #(.security_requirement_scopes::, _>(#schemes, vec![#(#scopes),*]))* 204 | #(.response::<#response_code, #response_type>())* 205 | ; 206 | #transform 207 | __op__ 208 | } 209 | ) 210 | }, 211 | quote! { ::aide::axum::routing::ApiMethodRouter }, 212 | ) 213 | } else { 214 | ( 215 | quote!(), 216 | quote! { ::axum::routing::#http_method(__inner__function__ #ty_generics) }, 217 | quote! { ::axum::routing::MethodRouter }, 218 | ) 219 | }; 220 | 221 | // Generate the code 222 | Ok(quote! { 223 | #(#fn_docs)* 224 | #route_docs 225 | #vis fn #fn_name #impl_generics() -> (&'static str, #method_router_ty<#state_type>) #where_clause { 226 | 227 | #query_params_struct 228 | 229 | #aide_ident_docs 230 | #asyncness fn __inner__function__ #impl_generics( 231 | #path_extractor 232 | #query_extractor 233 | #remaining_numbered_pats 234 | ) #fn_output #where_clause { 235 | #function 236 | 237 | #fn_name #ty_generics(#(#extracted_idents,)* #(#remaining_numbered_idents,)* ).await 238 | } 239 | 240 | (#axum_path, #inner_fn_call) 241 | } 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /axum-typed-routing-macros/src/parsing.rs: -------------------------------------------------------------------------------- 1 | use core::panic; 2 | 3 | use quote::ToTokens; 4 | use syn::{ 5 | token::{Brace, Star}, 6 | Attribute, Expr, ExprClosure, Lit, LitBool, LitInt, 7 | }; 8 | 9 | use super::*; 10 | 11 | struct RouteParser { 12 | path_params: Vec<(Slash, PathParam)>, 13 | query_params: Vec, 14 | } 15 | 16 | impl RouteParser { 17 | fn new(lit: LitStr) -> syn::Result { 18 | let val = lit.value(); 19 | let span = lit.span(); 20 | let split_route = val.split('?').collect::>(); 21 | if split_route.len() > 2 { 22 | return Err(syn::Error::new(span, "expected at most one '?'")); 23 | } 24 | 25 | let path = split_route[0]; 26 | if !path.starts_with('/') { 27 | return Err(syn::Error::new(span, "expected path to start with '/'")); 28 | } 29 | let path = path.strip_prefix('/').unwrap(); 30 | 31 | let mut path_params = Vec::new(); 32 | 33 | for path_param in path.split('/') { 34 | path_params.push(( 35 | Slash(span), 36 | PathParam::new(path_param, span, Box::new(parse_quote!(())))?, 37 | )); 38 | } 39 | 40 | let path_param_len = path_params.len(); 41 | for (i, (_slash, path_param)) in path_params.iter().enumerate() { 42 | match path_param { 43 | PathParam::WildCard(_, _, _, _, _, _) => { 44 | if i != path_param_len - 1 { 45 | return Err(syn::Error::new( 46 | span, 47 | "wildcard path param must be the last path param", 48 | )); 49 | } 50 | } 51 | PathParam::Capture(_, _, _, _, _) => (), 52 | PathParam::Static(lit) => { 53 | if lit.value() == "*" && i != path_param_len - 1 { 54 | return Err(syn::Error::new( 55 | span, 56 | "wildcard path param must be the last path param", 57 | )); 58 | } 59 | } 60 | } 61 | } 62 | 63 | let mut query_params = Vec::new(); 64 | if split_route.len() == 2 { 65 | let query = split_route[1]; 66 | for query_param in query.split('&') { 67 | query_params.push(Ident::new(query_param, span)); 68 | } 69 | } 70 | 71 | Ok(Self { 72 | path_params, 73 | query_params, 74 | }) 75 | } 76 | } 77 | 78 | pub enum PathParam { 79 | WildCard(LitStr, Brace, Star, Ident, Box, Brace), 80 | Capture(LitStr, Brace, Ident, Box, Brace), 81 | Static(LitStr), 82 | } 83 | 84 | impl PathParam { 85 | pub fn captures(&self) -> bool { 86 | matches!(self, Self::Capture(..) | Self::WildCard(..)) 87 | } 88 | 89 | pub fn capture(&self) -> Option<(&Ident, &Type)> { 90 | match self { 91 | Self::Capture(_, _, ident, ty, _) => Some((ident, ty)), 92 | Self::WildCard(_, _, _, ident, ty, _) => Some((ident, ty)), 93 | _ => None, 94 | } 95 | } 96 | 97 | fn new(str: &str, span: Span, ty: Box) -> syn::Result { 98 | let ok = if str.starts_with('{') { 99 | let str = str 100 | .strip_prefix('{') 101 | .unwrap() 102 | .strip_suffix('}') 103 | .ok_or_else(|| { 104 | syn::Error::new(span, "expected path param to be wrapped in curly braces") 105 | })?; 106 | Self::Capture( 107 | LitStr::new(str, span), 108 | Brace(span), 109 | Ident::new(str, span), 110 | ty, 111 | Brace(span), 112 | ) 113 | } else if str.starts_with('*') && str.len() > 1 { 114 | let str = str.strip_prefix('*').unwrap(); 115 | Self::WildCard( 116 | LitStr::new(str, span), 117 | Brace(span), 118 | Star(span), 119 | Ident::new(str, span), 120 | ty, 121 | Brace(span), 122 | ) 123 | } else { 124 | Self::Static(LitStr::new(str, span)) 125 | }; 126 | 127 | Ok(ok) 128 | } 129 | } 130 | 131 | pub struct OapiOptions { 132 | pub summary: Option<(Ident, LitStr)>, 133 | pub description: Option<(Ident, LitStr)>, 134 | pub id: Option<(Ident, LitStr)>, 135 | pub hidden: Option<(Ident, LitBool)>, 136 | pub tags: Option<(Ident, StrArray)>, 137 | pub security: Option<(Ident, Security)>, 138 | pub responses: Option<(Ident, Responses)>, 139 | pub transform: Option<(Ident, ExprClosure)>, 140 | } 141 | 142 | pub struct Security(pub Vec<(LitStr, StrArray)>); 143 | impl Parse for Security { 144 | fn parse(input: ParseStream) -> syn::Result { 145 | let inner; 146 | braced!(inner in input); 147 | 148 | let mut arr = Vec::new(); 149 | while !inner.is_empty() { 150 | let scheme = inner.parse::()?; 151 | let _ = inner.parse::()?; 152 | let scopes = inner.parse::()?; 153 | let _ = inner.parse::().ok(); 154 | arr.push((scheme, scopes)); 155 | } 156 | 157 | Ok(Self(arr)) 158 | } 159 | } 160 | 161 | impl ToString for Security { 162 | fn to_string(&self) -> String { 163 | let mut s = String::new(); 164 | s.push('{'); 165 | for (i, (scheme, scopes)) in self.0.iter().enumerate() { 166 | if i > 0 { 167 | s.push_str(", "); 168 | } 169 | s.push_str(&scheme.value()); 170 | s.push_str(": "); 171 | s.push_str(&scopes.to_string()); 172 | } 173 | s.push('}'); 174 | s 175 | } 176 | } 177 | 178 | pub struct Responses(pub Vec<(LitInt, Type)>); 179 | impl Parse for Responses { 180 | fn parse(input: ParseStream) -> syn::Result { 181 | let inner; 182 | braced!(inner in input); 183 | 184 | let mut arr = Vec::new(); 185 | while !inner.is_empty() { 186 | let status = inner.parse::()?; 187 | let _ = inner.parse::()?; 188 | let ty = inner.parse::()?; 189 | let _ = inner.parse::().ok(); 190 | arr.push((status, ty)); 191 | } 192 | 193 | Ok(Self(arr)) 194 | } 195 | } 196 | 197 | impl ToString for Responses { 198 | fn to_string(&self) -> String { 199 | let mut s = String::new(); 200 | s.push('{'); 201 | for (i, (status, ty)) in self.0.iter().enumerate() { 202 | if i > 0 { 203 | s.push_str(", "); 204 | } 205 | s.push_str(&status.to_string()); 206 | s.push_str(": "); 207 | s.push_str(&ty.to_token_stream().to_string()); 208 | } 209 | s.push('}'); 210 | s 211 | } 212 | } 213 | 214 | #[derive(Clone)] 215 | pub struct StrArray(pub Vec); 216 | impl Parse for StrArray { 217 | fn parse(input: ParseStream) -> syn::Result { 218 | let inner; 219 | bracketed!(inner in input); 220 | let mut arr = Vec::new(); 221 | while !inner.is_empty() { 222 | arr.push(inner.parse::()?); 223 | inner.parse::().ok(); 224 | } 225 | Ok(Self(arr)) 226 | } 227 | } 228 | 229 | impl ToString for StrArray { 230 | fn to_string(&self) -> String { 231 | let mut s = String::new(); 232 | s.push('['); 233 | for (i, lit) in self.0.iter().enumerate() { 234 | if i > 0 { 235 | s.push_str(", "); 236 | } 237 | s.push('"'); 238 | s.push_str(&lit.value()); 239 | s.push('"'); 240 | } 241 | s.push(']'); 242 | s 243 | } 244 | } 245 | 246 | impl Parse for OapiOptions { 247 | fn parse(input: ParseStream) -> syn::Result { 248 | let mut this = Self { 249 | summary: None, 250 | description: None, 251 | id: None, 252 | hidden: None, 253 | tags: None, 254 | security: None, 255 | responses: None, 256 | transform: None, 257 | }; 258 | 259 | while !input.is_empty() { 260 | let ident = input.parse::()?; 261 | let _ = input.parse::()?; 262 | match ident.to_string().as_str() { 263 | "summary" => this.summary = Some((ident, input.parse()?)), 264 | "description" => this.description = Some((ident, input.parse()?)), 265 | "id" => this.id = Some((ident, input.parse()?)), 266 | "hidden" => this.hidden = Some((ident, input.parse()?)), 267 | "tags" => this.tags = Some((ident, input.parse()?)), 268 | "security" => this.security = Some((ident, input.parse()?)), 269 | "responses" => this.responses = Some((ident, input.parse()?)), 270 | "transform" => this.transform = Some((ident, input.parse()?)), 271 | _ => { 272 | return Err(syn::Error::new( 273 | ident.span(), 274 | "unexpected field, expected one of (summary, description, id, hidden, tags, security, responses, transform)", 275 | )) 276 | } 277 | } 278 | let _ = input.parse::().ok(); 279 | } 280 | 281 | Ok(this) 282 | } 283 | } 284 | 285 | impl OapiOptions { 286 | pub fn merge_with_fn(&mut self, function: &ItemFn) { 287 | if self.description.is_none() { 288 | self.description = doc_iter(&function.attrs) 289 | .skip(2) 290 | .map(|item| item.value()) 291 | .reduce(|mut acc, item| { 292 | acc.push('\n'); 293 | acc.push_str(&item); 294 | acc 295 | }) 296 | .map(|item| (parse_quote!(description), parse_quote!(#item))) 297 | } 298 | if self.summary.is_none() { 299 | self.summary = doc_iter(&function.attrs) 300 | .next() 301 | .map(|item| (parse_quote!(summary), item.clone())) 302 | } 303 | if self.id.is_none() { 304 | let id = &function.sig.ident; 305 | self.id = Some((parse_quote!(id), LitStr::new(&id.to_string(), id.span()))); 306 | } 307 | } 308 | } 309 | 310 | fn doc_iter(attrs: &[Attribute]) -> impl Iterator + '_ { 311 | attrs 312 | .iter() 313 | .filter(|attr| attr.path().is_ident("doc")) 314 | .map(|attr| { 315 | let Meta::NameValue(meta) = &attr.meta else { 316 | panic!("doc attribute is not a name-value attribute"); 317 | }; 318 | let Expr::Lit(lit) = &meta.value else { 319 | panic!("doc attribute is not a string literal"); 320 | }; 321 | let Lit::Str(lit_str) = &lit.lit else { 322 | panic!("doc attribute is not a string literal"); 323 | }; 324 | lit_str 325 | }) 326 | } 327 | 328 | pub struct Route { 329 | pub method: Method, 330 | pub path_params: Vec<(Slash, PathParam)>, 331 | pub query_params: Vec, 332 | pub state: Option, 333 | pub route_lit: LitStr, 334 | pub oapi_options: Option, 335 | } 336 | 337 | impl Parse for Route { 338 | fn parse(input: ParseStream) -> syn::Result { 339 | let method = input.parse::()?; 340 | let route_lit = input.parse::()?; 341 | let route_parser = RouteParser::new(route_lit.clone())?; 342 | let state = match input.parse::() { 343 | Ok(_) => Some(input.parse::()?), 344 | Err(_) => None, 345 | }; 346 | let oapi_options = input 347 | .peek(Brace) 348 | .then(|| { 349 | let inner; 350 | braced!(inner in input); 351 | inner.parse::() 352 | }) 353 | .transpose()?; 354 | 355 | Ok(Route { 356 | method, 357 | path_params: route_parser.path_params, 358 | query_params: route_parser.query_params, 359 | state, 360 | route_lit, 361 | oapi_options, 362 | }) 363 | } 364 | } 365 | 366 | pub enum Method { 367 | Get(Span), 368 | Post(Span), 369 | Put(Span), 370 | Delete(Span), 371 | Head(Span), 372 | Connect(Span), 373 | Options(Span), 374 | Trace(Span), 375 | } 376 | 377 | impl Parse for Method { 378 | fn parse(input: ParseStream) -> syn::Result { 379 | let ident = input.parse::()?; 380 | match ident.to_string().to_uppercase().as_str() { 381 | "GET" => Ok(Self::Get(ident.span())), 382 | "POST" => Ok(Self::Post(ident.span())), 383 | "PUT" => Ok(Self::Put(ident.span())), 384 | "DELETE" => Ok(Self::Delete(ident.span())), 385 | "HEAD" => Ok(Self::Head(ident.span())), 386 | "CONNECT" => Ok(Self::Connect(ident.span())), 387 | "OPTIONS" => Ok(Self::Options(ident.span())), 388 | "TRACE" => Ok(Self::Trace(ident.span())), 389 | _ => Err(input 390 | .error("expected one of (GET, POST, PUT, DELETE, HEAD, CONNECT, OPTIONS, TRACE)")), 391 | } 392 | } 393 | } 394 | 395 | impl Method { 396 | pub fn to_axum_method_name(&self) -> Ident { 397 | match self { 398 | Self::Get(span) => Ident::new("get", *span), 399 | Self::Post(span) => Ident::new("post", *span), 400 | Self::Put(span) => Ident::new("put", *span), 401 | Self::Delete(span) => Ident::new("delete", *span), 402 | Self::Head(span) => Ident::new("head", *span), 403 | Self::Connect(span) => Ident::new("connect", *span), 404 | Self::Options(span) => Ident::new("options", *span), 405 | Self::Trace(span) => Ident::new("trace", *span), 406 | } 407 | } 408 | } 409 | 410 | mod kw { 411 | syn::custom_keyword!(with); 412 | } 413 | -------------------------------------------------------------------------------- /axum-typed-routing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-typed-routing" 3 | version = "0.3.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | keywords = ["axum", "routing", "handler", "typed", "macro"] 7 | categories = ["web-programming"] 8 | description = "Typed routing macros for axum" 9 | homepage = "https://github.com/jvdwrf/axum-typed-routing" 10 | repository = "https://github.com/jvdwrf/axum-typed-routing" 11 | readme = "../README.md" 12 | 13 | [package.metadata.docs.rs] 14 | features = ["aide"] 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | axum = "0.8" 20 | axum-extra = { version = "0.10", features = ["query"] } 21 | axum-macros = "0.5" 22 | aide = { version = "0.14", features = ["axum", "axum-query"], optional = true } 23 | axum-typed-routing-macros = { version = "0.3.0", path = "../axum-typed-routing-macros" } 24 | 25 | [dev-dependencies] 26 | tokio = { version = "1", features = ["full"] } 27 | axum-test = { version = "17", features = [] } 28 | serde = { version = "1.0.209", features = ["derive"] } 29 | json = "0.12" 30 | schemars = "0.8" 31 | 32 | [features] 33 | aide = ["dep:aide"] 34 | -------------------------------------------------------------------------------- /axum-typed-routing/examples/aide.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | use aide::{axum::ApiRouter, OperationInput}; 3 | use axum::extract::{Json, State}; 4 | use axum_typed_routing::TypedApiRouter; 5 | use axum_typed_routing_macros::api_route; 6 | use schemars::JsonSchema; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | #[api_route(GET "/item/{id}?amount&offset" { 10 | summary: "Get an item", 11 | description: "Get an item by id", 12 | id: "get-item", 13 | tags: ["items"], 14 | hidden: false 15 | })] 16 | async fn item_handler( 17 | id: u32, 18 | amount: Option, 19 | offset: Option, 20 | State(state): State, 21 | json: String, 22 | ) -> String { 23 | todo!("handle request") 24 | } 25 | 26 | fn main() { 27 | let router: ApiRouter = ApiRouter::new() 28 | .typed_api_route(item_handler) 29 | .with_state("state".to_string()); 30 | } 31 | 32 | #[derive(Serialize, Deserialize, JsonSchema)] 33 | pub struct Integer(pub u32); 34 | 35 | impl OperationInput for Integer {} 36 | -------------------------------------------------------------------------------- /axum-typed-routing/examples/basic.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | use axum::extract::{Json, State}; 3 | use axum_typed_routing::{route, TypedRouter}; 4 | 5 | #[route(GET "/item/{id}?amount&offset")] 6 | async fn item_handler( 7 | id: u32, 8 | amount: Option, 9 | offset: Option, 10 | State(state): State, 11 | Json(json): Json, 12 | ) -> String { 13 | todo!("handle request") 14 | } 15 | 16 | fn main() { 17 | let router: axum::Router = axum::Router::new() 18 | .typed_route(item_handler) 19 | .with_state("state".to_string()); 20 | } 21 | -------------------------------------------------------------------------------- /axum-typed-routing/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../../README.md")] 2 | //! 3 | //! ## Basic usage 4 | //! The following example demonstrates the basic usage of the library. 5 | //! On top of any regular handler, you can add the [`route`] macro to create a typed route. 6 | //! Any path- or query-parameters in the url will be type-checked at compile-time, and properly 7 | //! extracted into the handler. 8 | //! 9 | //! The following example shows how the path parameter `id`, and query parameters `amount` and 10 | //! `offset` are type-checked and extracted into the handler. 11 | //! 12 | //! ``` 13 | #![doc = include_str!("../examples/basic.rs")] 14 | //! ``` 15 | //! 16 | //! Some valid url's as get-methods are: 17 | //! - `/item/1?amount=2&offset=3` 18 | //! - `/item/1?amount=2` 19 | //! - `/item/1?offset=3` 20 | //! - `/item/500` 21 | //! 22 | //! By marking the `amount` and `offset` parameters as `Option`, they become optional. 23 | //! 24 | //! ## Example with `aide` 25 | //! When the `aide` feature is enabled, it's possible to automatically generate OpenAPI 26 | //! documentation for the routes. The [`api_route`] macro is used in place of the [`route`] macro. 27 | //! 28 | //! Please read the [`aide`] documentation for more information on usage. 29 | //! ``` 30 | #![doc = include_str!("../examples/aide.rs")] 31 | //! ``` 32 | 33 | use axum::routing::MethodRouter; 34 | 35 | type TypedHandler = fn() -> (&'static str, MethodRouter); 36 | pub use axum_typed_routing_macros::route; 37 | 38 | /// A trait that allows typed routes, created with the [`route`] macro to 39 | /// be added to an axum router. 40 | /// 41 | /// Typed handlers are of the form `fn() -> (&'static str, MethodRouter)`, where 42 | /// `S` is the state type. The first element of the tuple is the path, and the second 43 | /// is the method router. 44 | pub trait TypedRouter: Sized { 45 | /// The state type of the router. 46 | type State: Clone + Send + Sync + 'static; 47 | 48 | /// Add a typed route to the router, usually created with the [`route`] macro. 49 | /// 50 | /// Typed handlers are of the form `fn() -> (&'static str, MethodRouter)`, where 51 | /// `S` is the state type. The first element of the tuple is the path, and the second 52 | /// is the method router. 53 | fn typed_route(self, handler: TypedHandler) -> Self; 54 | } 55 | 56 | impl TypedRouter for axum::Router 57 | where 58 | S: Send + Sync + Clone + 'static, 59 | { 60 | type State = S; 61 | 62 | fn typed_route(self, handler: TypedHandler) -> Self { 63 | let (path, method_router) = handler(); 64 | self.route(path, method_router) 65 | } 66 | } 67 | 68 | #[cfg(feature = "aide")] 69 | pub use aide_support::*; 70 | #[cfg(feature = "aide")] 71 | mod aide_support { 72 | use crate::{TypedHandler, TypedRouter}; 73 | use aide::{ 74 | axum::{routing::ApiMethodRouter, ApiRouter}, 75 | transform::TransformPathItem, 76 | }; 77 | 78 | type TypedApiHandler = fn() -> (&'static str, ApiMethodRouter); 79 | 80 | pub use axum_typed_routing_macros::api_route; 81 | 82 | impl TypedRouter for ApiRouter 83 | where 84 | S: Send + Sync + Clone + 'static, 85 | { 86 | type State = S; 87 | 88 | fn typed_route(self, handler: TypedHandler) -> Self { 89 | let (path, method_router) = handler(); 90 | self.route(path, method_router) 91 | } 92 | } 93 | 94 | /// Same as [`TypedRouter`], but with support for `aide`. 95 | pub trait TypedApiRouter: TypedRouter { 96 | /// Same as [`TypedRouter::typed_route`], but with support for `aide`. 97 | fn typed_api_route(self, handler: TypedApiHandler) -> Self; 98 | 99 | /// Same as [`TypedApiRouter::typed_api_route`], but with a custom path transform for 100 | /// use with `aide`. 101 | fn typed_api_route_with( 102 | self, 103 | handler: TypedApiHandler, 104 | transform: impl FnOnce(TransformPathItem) -> TransformPathItem, 105 | ) -> Self; 106 | } 107 | 108 | impl TypedApiRouter for ApiRouter 109 | where 110 | S: Send + Sync + Clone + 'static, 111 | { 112 | fn typed_api_route(self, handler: TypedApiHandler) -> Self { 113 | let (path, method_router) = handler(); 114 | self.api_route(path, method_router) 115 | } 116 | 117 | fn typed_api_route_with( 118 | self, 119 | handler: TypedApiHandler, 120 | transform: impl FnOnce(TransformPathItem) -> TransformPathItem, 121 | ) -> Self { 122 | let (path, method_router) = handler(); 123 | self.api_route_with(path, method_router, transform) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /axum-typed-routing/tests/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | #![allow(clippy::extra_unused_type_parameters)] 3 | 4 | use std::net::TcpListener; 5 | 6 | use axum::{ 7 | extract::{Path, State}, 8 | routing::get, 9 | Form, Json, 10 | }; 11 | use axum_test::TestServer; 12 | use axum_typed_routing::TypedRouter; 13 | use axum_typed_routing_macros::route; 14 | 15 | /// This is a handler that is documented! 16 | #[route(GET "/hello/{id}?user_id&name")] 17 | async fn generic_handler_with_complex_options( 18 | mut id: u32, 19 | user_id: String, 20 | name: String, 21 | State(state): State, 22 | hello: State, 23 | Json(mut json): Json, 24 | ) -> String { 25 | format!("Hello, {id} - {user_id} - {name}!") 26 | } 27 | 28 | #[route(POST "/one")] 29 | async fn one(state: State) -> String { 30 | String::from("Hello!") 31 | } 32 | 33 | #[route(POST "/two")] 34 | async fn two() -> String { 35 | String::from("Hello!") 36 | } 37 | 38 | #[route(GET "/three/{id}")] 39 | async fn three(id: u32) -> String { 40 | format!("Hello {id}!") 41 | } 42 | 43 | #[route(GET "/four?id")] 44 | async fn four(id: u32) -> String { 45 | format!("Hello {id:?}!") 46 | // String::from("Hello 123!") 47 | } 48 | 49 | // Tests that hyphens are allowed in route names 50 | #[route(GET "/foo-bar")] 51 | async fn foo_bar() {} 52 | 53 | #[tokio::test] 54 | async fn test_normal() { 55 | let router: axum::Router = axum::Router::new() 56 | .typed_route(generic_handler_with_complex_options::) 57 | .typed_route(one) 58 | .with_state("state".to_string()) 59 | .typed_route(two) 60 | .typed_route(three) 61 | .typed_route(four); 62 | 63 | let server = TestServer::new(router).unwrap(); 64 | 65 | let response = server.post("/one").await; 66 | response.assert_status_ok(); 67 | response.assert_text("Hello!"); 68 | 69 | let response = server.post("/two").await; 70 | response.assert_status_ok(); 71 | response.assert_text("Hello!"); 72 | 73 | let response = server.get("/three/123").await; 74 | response.assert_status_ok(); 75 | response.assert_text("Hello 123!"); 76 | 77 | let response = server.get("/four").add_query_param("id", 123).await; 78 | response.assert_status_ok(); 79 | response.assert_text("Hello 123!"); 80 | 81 | let response = server 82 | .get("/hello/123") 83 | .add_query_param("user_id", 321.to_string()) 84 | .add_query_param("name", "John".to_string()) 85 | .json(&100) 86 | .await; 87 | response.assert_status_ok(); 88 | response.assert_text("Hello, 123 - 321 - John!"); 89 | 90 | let (path, method_router) = generic_handler_with_complex_options::(); 91 | assert_eq!(path, "/hello/{id}"); 92 | } 93 | 94 | #[route(GET "/*")] 95 | async fn wildcard() {} 96 | 97 | #[route(GET "/*capture")] 98 | async fn wildcard_capture(capture: String) -> Json { 99 | Json(capture) 100 | } 101 | 102 | #[route(GET "/")] 103 | async fn root() {} 104 | 105 | #[tokio::test] 106 | async fn test_wildcard() { 107 | let router: axum::Router = axum::Router::new().typed_route(wildcard_capture); 108 | 109 | let server = TestServer::new(router).unwrap(); 110 | 111 | let response = server.get("/foo/bar").await; 112 | response.assert_status_ok(); 113 | assert_eq!(response.json::(), "foo/bar"); 114 | } 115 | 116 | #[cfg(feature = "aide")] 117 | mod aide_support { 118 | use super::*; 119 | use aide::{axum::ApiRouter, openapi::OpenApi, transform::TransformOperation}; 120 | use axum_typed_routing::TypedApiRouter; 121 | use axum_typed_routing_macros::api_route; 122 | 123 | /// get-summary 124 | /// 125 | /// get-description 126 | #[api_route(GET "/hello")] 127 | async fn get_hello(state: State) -> String { 128 | String::from("Hello!") 129 | } 130 | 131 | /// post-summary 132 | /// 133 | /// post-description 134 | #[api_route(POST "/hello")] 135 | async fn post_hello(state: State) -> String { 136 | String::from("Hello!") 137 | } 138 | 139 | #[test] 140 | fn test_aide() { 141 | let router: aide::axum::ApiRouter = aide::axum::ApiRouter::new() 142 | .typed_route(one) 143 | .typed_api_route(get_hello) 144 | .with_state("state".to_string()); 145 | 146 | let (path, method_router) = get_hello(); 147 | assert_eq!(path, "/hello"); 148 | 149 | let (path, method_router) = post_hello(); 150 | assert_eq!(path, "/hello"); 151 | } 152 | 153 | #[test] 154 | fn summary_and_description_are_generated_from_doc_comments() { 155 | let router = ApiRouter::new() 156 | .typed_api_route(get_hello) 157 | .typed_api_route(post_hello); 158 | let mut api = OpenApi::default(); 159 | router.finish_api(&mut api); 160 | 161 | let get_op = path_item(&api, "/hello").get.as_ref().unwrap(); 162 | let post_op = path_item(&api, "/hello").post.as_ref().unwrap(); 163 | 164 | assert_eq!(get_op.summary, Some(" get-summary".to_string())); 165 | assert_eq!(get_op.description, Some(" get-description".to_string())); 166 | assert!(get_op.tags.is_empty()); 167 | 168 | assert_eq!(post_op.summary, Some(" post-summary".to_string())); 169 | assert_eq!(post_op.description, Some(" post-description".to_string())); 170 | assert!(post_op.tags.is_empty()); 171 | } 172 | 173 | /// unused-summary 174 | /// 175 | /// unused-description 176 | #[api_route(GET "/hello" { 177 | summary: "MySummary", 178 | description: "MyDescription", 179 | hidden: false, 180 | id: "MyRoute", 181 | tags: ["MyTag1", "MyTag2"], 182 | security: { 183 | "MySecurity1": ["MyScope1", "MyScope2"], 184 | "MySecurity2": [], 185 | }, 186 | responses: { 187 | 300: String, 188 | }, 189 | transform: |x| x.summary("OverriddenSummary"), 190 | })] 191 | async fn get_gello_with_attributes(state: State) -> String { 192 | String::from("Hello!") 193 | } 194 | 195 | #[test] 196 | fn generated_from_attributes() { 197 | let router = ApiRouter::new().typed_api_route(get_gello_with_attributes); 198 | let mut api = OpenApi::default(); 199 | router.finish_api(&mut api); 200 | 201 | let get_op = path_item(&api, "/hello").get.as_ref().unwrap(); 202 | 203 | assert_eq!(get_op.summary, Some("OverriddenSummary".to_string())); 204 | assert_eq!(get_op.description, Some("MyDescription".to_string())); 205 | assert_eq!( 206 | get_op.tags, 207 | vec!["MyTag1".to_string(), "MyTag2".to_string()] 208 | ); 209 | assert_eq!(get_op.operation_id, Some("MyRoute".to_string())); 210 | } 211 | 212 | /// summary 213 | /// 214 | /// description 215 | /// description 216 | #[api_route(GET "/hello")] 217 | async fn get_gello_without_attributes(state: State) -> String { 218 | String::from("Hello!") 219 | } 220 | 221 | fn path_item<'a>(api: &'a OpenApi, path: &str) -> &'a aide::openapi::PathItem { 222 | api.paths 223 | .as_ref() 224 | .unwrap() 225 | .iter() 226 | .find(|(p, _)| *p == path) 227 | .unwrap() 228 | .1 229 | .as_item() 230 | .unwrap() 231 | } 232 | } 233 | --------------------------------------------------------------------------------