Compare commits

...
Sign in to create a new pull request.

5 commits

22 changed files with 3253 additions and 20 deletions

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
target target
Cargo.lock
.vscode .vscode
.direnv .direnv

1508
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,15 @@
[workspace] [workspace]
members = ["axum-controller", "axum-controller-macros"] members = ["axum-controller", "axum-controller-macros"]
resolver = "2" resolver = "3"
[workspace.package] [workspace.package]
authors = ["Tristan Druyen <ek36g2vcc@mozmail.com>"] authors = ["Tristan Druyen <ek36g2vcc@mozmail.com>"]
categories = ["web-programming"] categories = ["web-programming"]
description = "A controller & route macro for axum" description = "Helper macro's for better readability of axum handlers"
edition = "2024" edition = "2024"
homepage = "https://git.vlt81.de/vault81/axum-controller" homepage = "https://git.vlt81.de/vault81/axum-controller"
keywords = ["axum", "controller", "macro", "routing"] keywords = ["axum", "controller", "macro", "routing"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
readme = "README.md" readme = "README.md"
repository = "https://git.vlt81.de/vault81/axum-controller" repository = "https://git.vlt81.de/vault81/axum-controller"
version = "0.1.1" version = "0.2.1"

View file

@ -15,7 +15,7 @@ version.workspace = true
prettyplease = "0.2" prettyplease = "0.2"
proc-macro2 = "1" proc-macro2 = "1"
quote = "1" quote = "1"
syn = { version = "2", features = ["parsing"] } syn = { version = "2", features = ["extra-traits", "parsing", "printing"] }
[dev-dependencies] [dev-dependencies]
axum = { version = "0.8", features = [] } axum = { version = "0.8", features = [] }

View file

@ -130,8 +130,9 @@ pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
.into_iter() .into_iter()
.map(move |route| { .map(move |route| {
quote! { quote! {
.typed_route(#struct_name :: #route) .typed_route(#struct_name :: #route)
}
}
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -139,6 +140,23 @@ pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
.nest(#route, __nested_router) .nest(#route, __nested_router)
}; };
let nested_router_qoute = quote! {
axum::Router::new()
#nesting_call
};
let unnested_router_quote = quote! {
__nested_router
};
let maybe_nesting_call = if let syn::Expr::Lit(lit) = route {
if lit.eq(&syn::parse_quote!("/")) {
unnested_router_quote
} else {
nested_router_qoute
}
} else {
nested_router_qoute
};
let middleware_calls = args let middleware_calls = args
.middlewares .middlewares
.clone() .clone()
@ -151,15 +169,14 @@ pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
// one where it's #state // one where it's #state
let from_controller_into_router_impl = quote! { let from_controller_into_router_impl = quote! {
impl #struct_name { impl #struct_name {
fn into_router(&self, state: #state) -> axum::Router<#state> { fn into_router(state: #state) -> axum::Router<#state> {
let __nested_router = axum::Router::new() let __nested_router = axum::Router::new()
#(#route_calls)* #(#route_calls)*
#(#middleware_calls)* #(#middleware_calls)*
.with_state(state) .with_state(state)
; ;
axum::Router::new() #maybe_nesting_call
#nesting_call
} }
} }
}; };

View file

@ -12,11 +12,12 @@ repository.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
axum-controller-macros = { path = "../axum-controller-macros", version = "0.1.1" } axum-controller-macros = { path = "../axum-controller-macros", version = "0.2.1" }
axum-typed-routing = { git = "https://github.com/jvdwrf/axum-typed-routing?ref=160684a406d616974d851bbfc6d0d9ffa65367e5", version = "0.2.0" } # version with axum 0.8 compat isn't pushed sadly axum-typed-routing = { path = "../vendor/axum-typed-routing", version = "0.2.0"}
[dev-dependencies] [dev-dependencies]
axum = "0.8" axum = "0.8"
axum-typed-routing = { path = "../vendor/axum-typed-routing", version = "0.2.0", features = ["aide"]}
axum-test = { version = "17", features = [] } axum-test = { version = "17", features = [] }
json = "0.12" json = "0.12"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View file

@ -39,5 +39,5 @@ impl ExampleController {
} }
fn main() { fn main() {
let _router: axum::Router<AppState>= ExampleController.into_router(AppState()); let _router: axum::Router<AppState> = ExampleController::into_router(AppState());
} }

8
flake.lock generated
View file

@ -84,16 +84,16 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1741073343, "lastModified": 1741010256,
"narHash": "sha256-8qmLpDUmaiBGLZkFfVyK5/T5fyTXXGdzCRdqAtO0gf4=", "narHash": "sha256-WZNlK/KX7Sni0RyqLSqLPbK8k08Kq7H7RijPJbq9KHM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "72bccb2960235fd31de456566789c324a251f297", "rev": "ba487dbc9d04e0634c64e3b1f0d25839a0a68246",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-unstable-small", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View file

@ -10,7 +10,7 @@
]; ];
}; };
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
@ -53,7 +53,6 @@
]; ];
in in
{ {
apps.devshell = self.outputs.devShells.${system}.default.flakeApp;
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = with pkgs; packages = with pkgs;
[ [
@ -90,5 +89,18 @@
export MALLOC_CONF=thp:always,metadata_thp:always export MALLOC_CONF=thp:always,metadata_thp:always
''; '';
}; };
}); packages = {
# default = pkgs.callPackage ./package.nix { };
};
}) // {
hydraJobs =
let
system = "x86_64-linux";
# packages = self.packages."${system}";
devShells = self.devShells."${system}";
in
{
inherit devShells;
};
};
} }

24
package.nix Normal file
View file

@ -0,0 +1,24 @@
{ lib
, fetchFromGitHub
, rustPlatform
,
}:
rustPlatform.buildRustPackage rec {
pname = "axum-controller";
version = "0.0.1";
src = ./.;
useFetchCargoVendor = true;
cargoLock = {
lockFile = ./Cargo.lock;
};
meta = {
# description = "";
# homepage = "";
# license = lib.licenses.unlicense;
maintainers = [ ];
};
}

View file

@ -0,0 +1 @@
{"files":{"Cargo.toml":"e56e1669f5c26818c13c68258f40e6d9156d1410ab3f9330219902f0efbcbc4b","README.md":"cdb9d483d904c1c10c86358dd089a805880d4c7b5ac4316589c74e4bf2cdc870","src/compilation.rs":"ea1a35cb02f32ef4a25204546caa9a3aa3c0e6d225666a8966bac87147328c55","src/lib.rs":"482d132fa15cc582e7826e44924de37659713f8c75db1410df962529545e893c","src/parsing.rs":"d81011c3a7d438d25c1607abf57501320c5ce4c9c3b867d78340947bf10e730a"},"package":null}

View file

@ -0,0 +1,56 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
autobenches = false
autobins = false
autoexamples = false
autolib = false
autotests = false
build = false
categories = ["web-programming"]
description = "Typed routing macros for axum"
edition = "2021"
homepage = "https://github.com/jvdwrf/axum-typed-routing"
keywords = ["axum", "handler", "macro", "routing", "typed"]
license = "MIT OR Apache-2.0"
name = "axum-typed-routing-macros"
readme = "../README.md"
repository = "https://github.com/jvdwrf/axum-typed-routing"
version = "0.2.0"
[lib]
name = "axum_typed_routing_macros"
path = "src/lib.rs"
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
[dependencies.syn]
features = ["full"]
version = "2"
[dev-dependencies]
schemars = "0.8"
[dev-dependencies.aide]
features = ["axum", "axum-json", "axum-query"]
version = "0.14"
[dev-dependencies.axum]
features = []
version = "0.8"
[dev-dependencies.serde]
features = ["derive"]
version = "1.0"

View file

@ -0,0 +1 @@
Macro's for `axum-typed-routing`.

View file

@ -0,0 +1,471 @@
use quote::ToTokens;
use syn::{spanned::Spanned, LitBool, LitInt, Pat, PatType};
use crate::parsing::{OapiOptions, Responses, Security, StrArray};
use self::parsing::PathParam;
use super::*;
pub struct CompiledRoute {
pub method: Method,
#[allow(clippy::type_complexity)]
pub path_params: Vec<(Slash, PathParam)>,
pub query_params: Vec<(Ident, Box<Type>)>,
pub state: Type,
pub route_lit: LitStr,
pub oapi_options: Option<OapiOptions>,
}
impl CompiledRoute {
pub fn to_axum_path_string(&self) -> String {
let mut path = String::new();
for (_slash, param) in &self.path_params {
path.push('/');
match param {
PathParam::Capture(lit, _brace_1, _, _, _brace_2) => {
path.push('{');
path.push_str(&lit.value());
path.push('}');
}
PathParam::WildCard(lit, _brace_1, _, _, _, _brace_2) => {
path.push('{');
path.push('*');
path.push_str(&lit.value());
path.push('}');
}
PathParam::Static(lit) => path.push_str(&lit.value()),
}
// if colon.is_some() {
// path.push(':');
// }
// path.push_str(&ident.value());
}
path
}
/// Removes the arguments in `route` from `args`, and merges them in the output.
pub fn from_route(mut route: Route, function: &ItemFn, with_aide: bool) -> syn::Result<Self> {
if !with_aide && route.oapi_options.is_some() {
return Err(syn::Error::new(
Span::call_site(),
"Use `api_route` instead of `route` to use OpenAPI options",
));
} else if with_aide && route.oapi_options.is_none() {
route.oapi_options = Some(OapiOptions {
summary: None,
description: None,
id: None,
hidden: None,
tags: None,
security: None,
responses: None,
transform: None,
});
}
let sig = &function.sig;
let mut arg_map = sig
.inputs
.iter()
.filter_map(|item| match item {
syn::FnArg::Receiver(_) => None,
syn::FnArg::Typed(pat_type) => Some(pat_type),
})
.filter_map(|pat_type| match &*pat_type.pat {
syn::Pat::Ident(ident) => Some((ident.ident.clone(), pat_type.ty.clone())),
_ => None,
})
.collect::<HashMap<_, _>>();
for (_slash, path_param) in &mut route.path_params {
match path_param {
PathParam::Capture(_lit, _, ident, ty, _) => {
let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| {
syn::Error::new(
ident.span(),
format!("path parameter `{}` not found in function arguments", ident),
)
})?;
*ident = new_ident;
*ty = new_ty;
}
PathParam::WildCard(_lit, _, _star, ident, ty, _) => {
let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| {
syn::Error::new(
ident.span(),
format!("path parameter `{}` not found in function arguments", ident),
)
})?;
*ident = new_ident;
*ty = new_ty;
}
PathParam::Static(_lit) => {}
}
}
let mut query_params = Vec::new();
for ident in route.query_params {
let (ident, ty) = arg_map.remove_entry(&ident).ok_or_else(|| {
syn::Error::new(
ident.span(),
format!(
"query parameter `{}` not found in function arguments",
ident
),
)
})?;
query_params.push((ident, ty));
}
if let Some(options) = route.oapi_options.as_mut() {
options.merge_with_fn(function)
}
Ok(Self {
route_lit: route.route_lit,
method: route.method,
path_params: route.path_params,
query_params,
state: route.state.unwrap_or_else(|| guess_state_type(sig)),
oapi_options: route.oapi_options,
})
}
pub fn path_extractor(&self) -> Option<TokenStream2> {
if !self.path_params.iter().any(|(_, param)| param.captures()) {
return None;
}
let path_iter = self
.path_params
.iter()
.filter_map(|(_slash, path_param)| path_param.capture());
let idents = path_iter.clone().map(|item| item.0);
let types = path_iter.clone().map(|item| item.1);
Some(quote! {
::axum::extract::Path((#(#idents,)*)): ::axum::extract::Path<(#(#types,)*)>,
})
}
pub fn query_extractor(&self) -> Option<TokenStream2> {
if self.query_params.is_empty() {
return None;
}
let idents = self.query_params.iter().map(|item| &item.0);
Some(quote! {
::axum::extract::Query(__QueryParams__ {
#(#idents,)*
}): ::axum::extract::Query<__QueryParams__>,
})
}
pub fn query_params_struct(&self, with_aide: bool) -> Option<TokenStream2> {
match self.query_params.is_empty() {
true => None,
false => {
let idents = self.query_params.iter().map(|item| &item.0);
let types = self.query_params.iter().map(|item| &item.1);
let derive = match with_aide {
true => quote! { #[derive(::serde::Deserialize, ::schemars::JsonSchema)] },
false => quote! { #[derive(::serde::Deserialize)] },
};
Some(quote! {
#derive
struct __QueryParams__ {
#(#idents: #types,)*
}
})
}
}
}
pub fn extracted_idents(&self) -> Vec<Ident> {
let mut idents = Vec::new();
for (_slash, path_param) in &self.path_params {
if let Some((ident, _ty)) = path_param.capture() {
idents.push(ident.clone());
}
// if let Some((_colon, ident, _ty)) = colon {
// idents.push(ident.clone());
// }
}
for (ident, _ty) in &self.query_params {
idents.push(ident.clone());
}
idents
}
/// The arguments not used in the route.
/// Map the identifier to `___arg___{i}: Type`.
pub fn remaining_pattypes_numbered(
&self,
args: &Punctuated<FnArg, Comma>,
) -> Punctuated<PatType, Comma> {
args.iter()
.enumerate()
.filter_map(|(i, item)| {
if let FnArg::Typed(pat_type) = item {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
if self.path_params.iter().any(|(_slash, path_param)| {
if let Some((path_ident, _ty)) = path_param.capture() {
path_ident == &pat_ident.ident
} else {
false
}
}) || self
.query_params
.iter()
.any(|(query_ident, _)| query_ident == &pat_ident.ident)
{
return None;
}
}
let mut new_pat_type = pat_type.clone();
let ident = format_ident!("___arg___{}", i);
new_pat_type.pat = Box::new(parse_quote!(#ident));
Some(new_pat_type)
} else {
unimplemented!("Self type is not supported")
}
})
.collect()
}
pub fn ide_documentation_for_aide_methods(&self) -> TokenStream2 {
let Some(options) = &self.oapi_options else {
return quote! {};
};
let summary = options.summary.as_ref().map(|(ident, _)| {
let method = Ident::new("summary", ident.span());
quote!( let x = x.#method(""); )
});
let description = options.description.as_ref().map(|(ident, _)| {
let method = Ident::new("description", ident.span());
quote!( let x = x.#method(""); )
});
let id = options.id.as_ref().map(|(ident, _)| {
let method = Ident::new("id", ident.span());
quote!( let x = x.#method(""); )
});
let hidden = options.hidden.as_ref().map(|(ident, _)| {
let method = Ident::new("hidden", ident.span());
quote!( let x = x.#method(false); )
});
let tags = options.tags.as_ref().map(|(ident, _)| {
let method = Ident::new("tag", ident.span());
quote!( let x = x.#method(""); )
});
let security = options.security.as_ref().map(|(ident, _)| {
let method = Ident::new("security_requirement_scopes", ident.span());
quote!( let x = x.#method("", [""]); )
});
let responses = options.responses.as_ref().map(|(ident, _)| {
let method = Ident::new("response", ident.span());
quote!( let x = x.#method::<0, String>(); )
});
let transform = options.transform.as_ref().map(|(ident, _)| {
let method = Ident::new("with", ident.span());
quote!( let x = x.#method(|x|x); )
});
quote! {
#[allow(unused)]
#[allow(clippy::no_effect)]
fn ____ide_documentation_for_aide____(x: ::aide::transform::TransformOperation) {
#summary
#description
#id
#hidden
#tags
#security
#responses
#transform
}
}
}
pub fn get_oapi_summary(&self) -> Option<LitStr> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(summary) = &oapi_options.summary {
return Some(summary.1.clone());
}
}
None
}
pub fn get_oapi_description(&self) -> Option<LitStr> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(description) = &oapi_options.description {
return Some(description.1.clone());
}
}
None
}
pub fn get_oapi_hidden(&self) -> Option<LitBool> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(hidden) = &oapi_options.hidden {
return Some(hidden.1.clone());
}
}
None
}
pub fn get_oapi_tags(&self) -> Vec<LitStr> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(tags) = &oapi_options.tags {
return tags.1 .0.clone();
}
}
Vec::new()
}
pub fn get_oapi_id(&self, sig: &Signature) -> Option<LitStr> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(id) = &oapi_options.id {
return Some(id.1.clone());
}
}
Some(LitStr::new(&sig.ident.to_string(), sig.ident.span()))
}
pub fn get_oapi_transform(&self) -> syn::Result<Option<TokenStream2>> {
if let Some(oapi_options) = &self.oapi_options {
if let Some(transform) = &oapi_options.transform {
if transform.1.inputs.len() != 1 {
return Err(syn::Error::new(
transform.1.span(),
"expected a single identifier",
));
}
let pat = transform.1.inputs.first().unwrap();
let body = &transform.1.body;
if let Pat::Ident(pat_ident) = pat {
let ident = &pat_ident.ident;
return Ok(Some(quote! {
let #ident = __op__;
let __op__ = #body;
}));
} else {
return Err(syn::Error::new(
pat.span(),
"expected a single identifier without type",
));
}
}
}
Ok(None)
}
pub fn get_oapi_responses(&self) -> Vec<(LitInt, Type)> {
if let Some(oapi_options) = &self.oapi_options {
if let Some((_ident, Responses(responses))) = &oapi_options.responses {
return responses.clone();
}
}
Default::default()
}
pub fn get_oapi_security(&self) -> Vec<(LitStr, Vec<LitStr>)> {
if let Some(oapi_options) = &self.oapi_options {
if let Some((_ident, Security(security))) = &oapi_options.security {
return security
.iter()
.map(|(scheme, StrArray(scopes))| (scheme.clone(), scopes.clone()))
.collect();
}
}
Default::default()
}
pub(crate) fn to_doc_comments(&self) -> TokenStream2 {
let mut doc = format!(
"# Handler information
- Method: `{}`
- Path: `{}`
- State: `{}`",
self.method.to_axum_method_name(),
self.route_lit.value(),
self.state.to_token_stream(),
);
if let Some(options) = &self.oapi_options {
let summary = options
.summary
.as_ref()
.map(|(_, summary)| format!("\"{}\"", summary.value()))
.unwrap_or("None".to_string());
let description = options
.description
.as_ref()
.map(|(_, description)| format!("\"{}\"", description.value()))
.unwrap_or("None".to_string());
let id = options
.id
.as_ref()
.map(|(_, id)| format!("\"{}\"", id.value()))
.unwrap_or("None".to_string());
let hidden = options
.hidden
.as_ref()
.map(|(_, hidden)| hidden.value().to_string())
.unwrap_or("None".to_string());
let tags = options
.tags
.as_ref()
.map(|(_, tags)| tags.to_string())
.unwrap_or("[]".to_string());
let security = options
.security
.as_ref()
.map(|(_, security)| security.to_string())
.unwrap_or("{}".to_string());
doc = format!(
"{doc}
## OpenAPI
- Summary: `{summary}`
- Description: `{description}`
- Operation id: `{id}`
- Tags: `{tags}`
- Security: `{security}`
- Hidden: `{hidden}`
"
);
}
quote!(
#[doc = #doc]
)
}
}
fn guess_state_type(sig: &syn::Signature) -> Type {
for arg in &sig.inputs {
if let FnArg::Typed(pat_type) = arg {
// Returns `T` if the type of the last segment is exactly `State<T>`.
if let Type::Path(ty) = &*pat_type.ty {
let last_segment = ty.path.segments.last().unwrap();
if last_segment.ident == "State" {
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
if args.args.len() == 1 {
if let GenericArgument::Type(ty) = args.args.first().unwrap() {
return ty.clone();
}
}
}
}
}
}
}
parse_quote! { () }
}

View file

@ -0,0 +1,244 @@
use compilation::CompiledRoute;
use parsing::{Method, Route};
use proc_macro::TokenStream;
use proc_macro2::{Ident, Span, TokenStream as TokenStream2};
use std::collections::HashMap;
use syn::{
parse::{Parse, ParseStream},
punctuated::Punctuated,
token::{Comma, Slash},
FnArg, GenericArgument, ItemFn, LitStr, Meta, PathArguments, Signature, Type,
};
#[macro_use]
extern crate quote;
#[macro_use]
extern crate syn;
mod compilation;
mod parsing;
/// A macro that generates statically-typed routes for axum handlers.
///
/// # Syntax
/// ```ignore
/// #[route(<METHOD> "<PATH>" [with <STATE>])]
/// ```
/// - `METHOD` is the HTTP method, such as `GET`, `POST`, `PUT`, etc.
/// - `PATH` is the path of the route, with optional path parameters and query parameters,
/// e.g. `/item/:id?amount&offset`.
/// - `STATE` is the type of axum-state, passed to the handler. This is optional, and if not
/// specified, the state type is guessed based on the parameters of the handler.
///
/// # Example
/// ```
/// use axum::extract::{State, Json};
/// use axum_typed_routing_macros::route;
///
/// #[route(GET "/item/:id?amount&offset")]
/// async fn item_handler(
/// id: u32,
/// amount: Option<u32>,
/// offset: Option<u32>,
/// State(state): State<String>,
/// Json(json): Json<u32>,
/// ) -> String {
/// todo!("handle request")
/// }
/// ```
///
/// # State type
/// Normally, the state-type is guessed based on the parameters of the function:
/// If the function has a parameter of type `[..]::State<T>`, then `T` is used as the state type.
/// This should work for most cases, however when not sufficient, the state type can be specified
/// explicitly using the `with` keyword:
/// ```ignore
/// #[route(GET "/item/:id?amount&offset" with String)]
/// ```
///
/// # Internals
/// The macro expands to a function with signature `fn() -> (&'static str, axum::routing::MethodRouter<S>)`.
/// The first element of the tuple is the path, and the second is axum's `MethodRouter`.
///
/// The path and query are extracted using axum's `extract::Path` and `extract::Query` extractors, as the first
/// and second parameters of the function. The remaining parameters are the parameters of the handler.
#[proc_macro_attribute]
pub fn route(attr: TokenStream, mut item: TokenStream) -> TokenStream {
match _route(attr, item.clone(), false) {
Ok(tokens) => tokens.into(),
Err(err) => {
let err: TokenStream = err.to_compile_error().into();
item.extend(err);
item
}
}
}
/// Same as [`macro@route`], but with support for OpenApi using `aide`. See [`macro@route`] for more
/// information and examples.
///
/// # Syntax
/// ```ignore
/// #[api_route(<METHOD> "<PATH>" [with <STATE>] [{
/// summary: "<SUMMARY>",
/// description: "<DESCRIPTION>",
/// id: "<ID>",
/// tags: ["<TAG>", ..],
/// hidden: <bool>,
/// security: { <SCHEME>: ["<SCOPE>", ..], .. },
/// responses: { <CODE>: <TYPE>, .. },
/// transform: |op| { .. },
/// }])]
/// ```
/// - `summary` is the OpenApi summary. If not specified, the first line of the function's doc-comments
/// - `description` is the OpenApi description. If not specified, the rest of the function's doc-comments
/// - `id` is the OpenApi operationId. If not specified, the function's name is used.
/// - `tags` are the OpenApi tags.
/// - `hidden` sets whether docs should be hidden for this route.
/// - `security` is the OpenApi security requirements.
/// - `responses` are the OpenApi responses.
/// - `transform` is a closure that takes an `TransformOperation` and returns an `TransformOperation`.
///
/// This may override the other options. (see the crate `aide` for more information).
///
/// # Example
/// ```
/// use axum::extract::{State, Json};
/// use axum_typed_routing_macros::api_route;
///
/// #[api_route(GET "/item/:id?amount&offset" with String {
/// summary: "Get an item",
/// description: "Get an item by id",
/// id: "get-item",
/// tags: ["items"],
/// hidden: false,
/// security: { "bearer": ["read:items"] },
/// responses: { 200: String },
/// transform: |op| op.tag("private"),
/// })]
/// async fn item_handler(
/// id: u32,
/// amount: Option<u32>,
/// offset: Option<u32>,
/// State(state): State<String>,
/// ) -> String {
/// todo!("handle request")
/// }
/// ```
#[proc_macro_attribute]
pub fn api_route(attr: TokenStream, mut item: TokenStream) -> TokenStream {
match _route(attr, item.clone(), true) {
Ok(tokens) => tokens.into(),
Err(err) => {
let err: TokenStream = err.to_compile_error().into();
item.extend(err);
item
}
}
}
fn _route(attr: TokenStream, item: TokenStream, with_aide: bool) -> syn::Result<TokenStream2> {
// Parse the route and function
let route = syn::parse::<Route>(attr)?;
let function = syn::parse::<ItemFn>(item)?;
// Now we can compile the route
let route = CompiledRoute::from_route(route, &function, with_aide)?;
let path_extractor = route.path_extractor();
let query_extractor = route.query_extractor();
let query_params_struct = route.query_params_struct(with_aide);
let state_type = &route.state;
let axum_path = route.to_axum_path_string();
let http_method = route.method.to_axum_method_name();
let remaining_numbered_pats = route.remaining_pattypes_numbered(&function.sig.inputs);
let extracted_idents = route.extracted_idents();
let remaining_numbered_idents = remaining_numbered_pats.iter().map(|pat_type| &pat_type.pat);
let route_docs = route.to_doc_comments();
// Get the variables we need for code generation
let fn_name = &function.sig.ident;
let fn_output = &function.sig.output;
let vis = &function.vis;
let asyncness = &function.sig.asyncness;
let (impl_generics, ty_generics, where_clause) = &function.sig.generics.split_for_impl();
let ty_generics = ty_generics.as_turbofish();
let fn_docs = function
.attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"));
let (aide_ident_docs, inner_fn_call, method_router_ty) = if with_aide {
let http_method = format_ident!("{}_with", http_method);
let summary = route
.get_oapi_summary()
.map(|summary| quote! { .summary(#summary) });
let description = route
.get_oapi_description()
.map(|description| quote! { .description(#description) });
let hidden = route
.get_oapi_hidden()
.map(|hidden| quote! { .hidden(#hidden) });
let tags = route.get_oapi_tags();
let id = route
.get_oapi_id(&function.sig)
.map(|id| quote! { .id(#id) });
let transform = route.get_oapi_transform()?;
let responses = route.get_oapi_responses();
let response_code = responses.iter().map(|response| &response.0);
let response_type = responses.iter().map(|response| &response.1);
let security = route.get_oapi_security();
let schemes = security.iter().map(|sec| &sec.0);
let scopes = security.iter().map(|sec| &sec.1);
(
route.ide_documentation_for_aide_methods(),
quote! {
::aide::axum::routing::#http_method(
__inner__function__ #ty_generics,
|__op__| {
let __op__ = __op__
#summary
#description
#hidden
#id
#(.tag(#tags))*
#(.security_requirement_scopes::<Vec<&'static str>, _>(#schemes, vec![#(#scopes),*]))*
#(.response::<#response_code, #response_type>())*
;
#transform
__op__
}
)
},
quote! { ::aide::axum::routing::ApiMethodRouter },
)
} else {
(
quote!(),
quote! { ::axum::routing::#http_method(__inner__function__ #ty_generics) },
quote! { ::axum::routing::MethodRouter },
)
};
// Generate the code
Ok(quote! {
#(#fn_docs)*
#route_docs
#vis fn #fn_name #impl_generics() -> (&'static str, #method_router_ty<#state_type>) #where_clause {
#query_params_struct
#aide_ident_docs
#asyncness fn __inner__function__ #impl_generics(
#path_extractor
#query_extractor
#remaining_numbered_pats
) #fn_output #where_clause {
#function
#fn_name #ty_generics(#(#extracted_idents,)* #(#remaining_numbered_idents,)* ).await
}
(#axum_path, #inner_fn_call)
}
})
}

View file

@ -0,0 +1,412 @@
use core::panic;
use quote::ToTokens;
use syn::{
token::{Brace, Star},
Attribute, Expr, ExprClosure, Lit, LitBool, LitInt,
};
use super::*;
struct RouteParser {
path_params: Vec<(Slash, PathParam)>,
query_params: Vec<Ident>,
}
impl RouteParser {
fn new(lit: LitStr) -> syn::Result<Self> {
let val = lit.value();
let span = lit.span();
let split_route = val.split('?').collect::<Vec<_>>();
if split_route.len() > 2 {
return Err(syn::Error::new(span, "expected at most one '?'"));
}
let path = split_route[0];
if !path.starts_with('/') {
return Err(syn::Error::new(span, "expected path to start with '/'"));
}
let path = path.strip_prefix('/').unwrap();
let mut path_params = Vec::new();
#[allow(clippy::never_loop)]
for path_param in path.split('/') {
path_params.push((
Slash(span),
PathParam::new(path_param, span, Box::new(parse_quote!(()))),
));
}
let path_param_len = path_params.len();
for (i, (_slash, path_param)) in path_params.iter().enumerate() {
match path_param {
PathParam::WildCard(_, _, _, _, _, _) => {
if i != path_param_len - 1 {
return Err(syn::Error::new(
span,
"wildcard path param must be the last path param",
));
}
}
PathParam::Capture(_, _, _, _, _) => (),
PathParam::Static(lit) => {
if lit.value() == "*" && i != path_param_len - 1 {
return Err(syn::Error::new(
span,
"wildcard path param must be the last path param",
));
}
}
}
}
let mut query_params = Vec::new();
if split_route.len() == 2 {
let query = split_route[1];
for query_param in query.split('&') {
query_params.push(Ident::new(query_param, span));
}
}
Ok(Self {
path_params,
query_params,
})
}
}
pub enum PathParam {
WildCard(LitStr, Brace, Star, Ident, Box<Type>, Brace),
Capture(LitStr, Brace, Ident, Box<Type>, Brace),
Static(LitStr),
}
impl PathParam {
pub fn captures(&self) -> bool {
matches!(self, Self::Capture(..) | Self::WildCard(..))
}
// pub fn lit(&self) -> &LitStr {
// match self {
// Self::Capture(lit, _, _, _) => lit,
// Self::WildCard(lit, _, _, _) => lit,
// Self::Static(lit) => lit,
// }
// }
pub fn capture(&self) -> Option<(&Ident, &Type)> {
match self {
Self::Capture(_, _, ident, ty, _) => Some((ident, ty)),
Self::WildCard(_, _, _, ident, ty, _) => Some((ident, ty)),
_ => None,
}
}
fn new(str: &str, span: Span, ty: Box<Type>) -> Self {
if str.starts_with(':') {
let str = str.strip_prefix(':').unwrap();
Self::Capture(
LitStr::new(str, span),
Brace(span),
Ident::new(str, span),
ty,
Brace(span),
)
} else if str.starts_with('*') && str.len() > 1 {
let str = str.strip_prefix('*').unwrap();
Self::WildCard(
LitStr::new(str, span),
Brace(span),
Star(span),
Ident::new(str, span),
ty,
Brace(span),
)
} else {
Self::Static(LitStr::new(str, span))
}
}
}
pub struct OapiOptions {
pub summary: Option<(Ident, LitStr)>,
pub description: Option<(Ident, LitStr)>,
pub id: Option<(Ident, LitStr)>,
pub hidden: Option<(Ident, LitBool)>,
pub tags: Option<(Ident, StrArray)>,
pub security: Option<(Ident, Security)>,
pub responses: Option<(Ident, Responses)>,
pub transform: Option<(Ident, ExprClosure)>,
}
pub struct Security(pub Vec<(LitStr, StrArray)>);
impl Parse for Security {
fn parse(input: ParseStream) -> syn::Result<Self> {
let inner;
braced!(inner in input);
let mut arr = Vec::new();
while !inner.is_empty() {
let scheme = inner.parse::<LitStr>()?;
let _ = inner.parse::<Token![:]>()?;
let scopes = inner.parse::<StrArray>()?;
let _ = inner.parse::<Token![,]>().ok();
arr.push((scheme, scopes));
}
Ok(Self(arr))
}
}
impl ToString for Security {
fn to_string(&self) -> String {
let mut s = String::new();
s.push('{');
for (i, (scheme, scopes)) in self.0.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&scheme.value());
s.push_str(": ");
s.push_str(&scopes.to_string());
}
s.push('}');
s
}
}
pub struct Responses(pub Vec<(LitInt, Type)>);
impl Parse for Responses {
fn parse(input: ParseStream) -> syn::Result<Self> {
let inner;
braced!(inner in input);
let mut arr = Vec::new();
while !inner.is_empty() {
let status = inner.parse::<LitInt>()?;
let _ = inner.parse::<Token![:]>()?;
let ty = inner.parse::<Type>()?;
let _ = inner.parse::<Token![,]>().ok();
arr.push((status, ty));
}
Ok(Self(arr))
}
}
impl ToString for Responses {
fn to_string(&self) -> String {
let mut s = String::new();
s.push('{');
for (i, (status, ty)) in self.0.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&status.to_string());
s.push_str(": ");
s.push_str(&ty.to_token_stream().to_string());
}
s.push('}');
s
}
}
#[derive(Clone)]
pub struct StrArray(pub Vec<LitStr>);
impl Parse for StrArray {
fn parse(input: ParseStream) -> syn::Result<Self> {
let inner;
bracketed!(inner in input);
let mut arr = Vec::new();
while !inner.is_empty() {
arr.push(inner.parse::<LitStr>()?);
inner.parse::<Token![,]>().ok();
}
Ok(Self(arr))
}
}
impl ToString for StrArray {
fn to_string(&self) -> String {
let mut s = String::new();
s.push('[');
for (i, lit) in self.0.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push('"');
s.push_str(&lit.value());
s.push('"');
}
s.push(']');
s
}
}
impl Parse for OapiOptions {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut this = Self {
summary: None,
description: None,
id: None,
hidden: None,
tags: None,
security: None,
responses: None,
transform: None,
};
while !input.is_empty() {
let ident = input.parse::<Ident>()?;
let _ = input.parse::<Token![:]>()?;
match ident.to_string().as_str() {
"summary" => this.summary = Some((ident, input.parse()?)),
"description" => this.description = Some((ident, input.parse()?)),
"id" => this.id = Some((ident, input.parse()?)),
"hidden" => this.hidden = Some((ident, input.parse()?)),
"tags" => this.tags = Some((ident, input.parse()?)),
"security" => this.security = Some((ident, input.parse()?)),
"responses" => this.responses = Some((ident, input.parse()?)),
"transform" => this.transform = Some((ident, input.parse()?)),
_ => {
return Err(syn::Error::new(
ident.span(),
"unexpected field, expected one of (summary, description, id, hidden, tags, security, responses, transform)",
))
}
}
let _ = input.parse::<Token![,]>().ok();
}
Ok(this)
}
}
impl OapiOptions {
pub fn merge_with_fn(&mut self, function: &ItemFn) {
if self.description.is_none() {
self.description = doc_iter(&function.attrs)
.skip(2)
.map(|item| item.value())
.reduce(|mut acc, item| {
acc.push('\n');
acc.push_str(&item);
acc
})
.map(|item| (parse_quote!(description), parse_quote!(#item)))
}
if self.summary.is_none() {
self.summary = doc_iter(&function.attrs)
.next()
.map(|item| (parse_quote!(summary), item.clone()))
}
if self.id.is_none() {
let id = &function.sig.ident;
self.id = Some((parse_quote!(id), LitStr::new(&id.to_string(), id.span())));
}
}
}
fn doc_iter(attrs: &[Attribute]) -> impl Iterator<Item = &LitStr> + '_ {
attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.map(|attr| {
let Meta::NameValue(meta) = &attr.meta else {
panic!("doc attribute is not a name-value attribute");
};
let Expr::Lit(lit) = &meta.value else {
panic!("doc attribute is not a string literal");
};
let Lit::Str(lit_str) = &lit.lit else {
panic!("doc attribute is not a string literal");
};
lit_str
})
}
pub struct Route {
pub method: Method,
pub path_params: Vec<(Slash, PathParam)>,
pub query_params: Vec<Ident>,
pub state: Option<Type>,
pub route_lit: LitStr,
pub oapi_options: Option<OapiOptions>,
}
impl Parse for Route {
fn parse(input: ParseStream) -> syn::Result<Self> {
let method = input.parse::<Method>()?;
let route_lit = input.parse::<LitStr>()?;
let route_parser = RouteParser::new(route_lit.clone())?;
let state = match input.parse::<kw::with>() {
Ok(_) => Some(input.parse::<Type>()?),
Err(_) => None,
};
let oapi_options = input
.peek(Brace)
.then(|| {
let inner;
braced!(inner in input);
inner.parse::<OapiOptions>()
})
.transpose()?;
Ok(Route {
method,
path_params: route_parser.path_params,
query_params: route_parser.query_params,
state,
route_lit,
oapi_options,
})
}
}
pub enum Method {
Get(Span),
Post(Span),
Put(Span),
Delete(Span),
Head(Span),
Connect(Span),
Options(Span),
Trace(Span),
}
impl Parse for Method {
fn parse(input: ParseStream) -> syn::Result<Self> {
let ident = input.parse::<Ident>()?;
match ident.to_string().to_uppercase().as_str() {
"GET" => Ok(Self::Get(ident.span())),
"POST" => Ok(Self::Post(ident.span())),
"PUT" => Ok(Self::Put(ident.span())),
"DELETE" => Ok(Self::Delete(ident.span())),
"HEAD" => Ok(Self::Head(ident.span())),
"CONNECT" => Ok(Self::Connect(ident.span())),
"OPTIONS" => Ok(Self::Options(ident.span())),
"TRACE" => Ok(Self::Trace(ident.span())),
_ => Err(input
.error("expected one of (GET, POST, PUT, DELETE, HEAD, CONNECT, OPTIONS, TRACE)")),
}
}
}
impl Method {
pub fn to_axum_method_name(&self) -> Ident {
match self {
Self::Get(span) => Ident::new("get", *span),
Self::Post(span) => Ident::new("post", *span),
Self::Put(span) => Ident::new("put", *span),
Self::Delete(span) => Ident::new("delete", *span),
Self::Head(span) => Ident::new("head", *span),
Self::Connect(span) => Ident::new("connect", *span),
Self::Options(span) => Ident::new("options", *span),
Self::Trace(span) => Ident::new("trace", *span),
}
}
}
mod kw {
syn::custom_keyword!(with);
}

View file

@ -0,0 +1 @@
{"files":{"Cargo.toml":"97fb5b77f33d2b60a0b82b39fba7ab48f87effdfac5adb5e3df5018d153f8b4b","examples/aide.rs":"a72124d7923cd4dfa76a98bfc10b163c7037048d52819061898c5cfc19c9e0dd","examples/basic.rs":"ef981bcf041580f073808521738f22b864625779c6df628d546a53dbc2e0c95c","src/lib.rs":"3d5a1c1407e5097fd504985b6fb2665b4d1a140db6ed4b15963d64c781a96320","tests/main.rs":"4bed7ddf18d079122144ef8d5d8960cad1f0b51095e23f8c15b00df543c3c49e"},"package":null}

81
vendor/axum-typed-routing/Cargo.toml vendored Normal file
View file

@ -0,0 +1,81 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
autobenches = false
autobins = false
autoexamples = false
autolib = false
autotests = false
build = false
categories = ["web-programming"]
description = "Typed routing macros for axum"
edition = "2021"
homepage = "https://github.com/jvdwrf/axum-typed-routing"
keywords = ["axum", "handler", "macro", "routing", "typed"]
license = "MIT OR Apache-2.0"
name = "axum-typed-routing"
readme = "../README.md"
repository = "https://github.com/jvdwrf/axum-typed-routing"
version = "0.2.0"
[package.metadata.docs.rs]
features = ["aide"]
[features]
aide = ["dep:aide"]
default = []
[lib]
name = "axum_typed_routing"
path = "src/lib.rs"
[[example]]
name = "aide"
path = "examples/aide.rs"
features = ["aide"]
[[example]]
name = "basic"
path = "examples/basic.rs"
[[test]]
name = "main"
path = "tests/main.rs"
[dependencies]
axum = "0.8"
axum-macros = "0.5"
[dependencies.aide]
features = ["axum"]
optional = true
version = "0.14"
[dependencies.axum-typed-routing-macros]
path = "../axum-typed-routing-macros"
version = "0.2.0"
[dev-dependencies]
json = "0.12"
schemars = "0.8"
[dev-dependencies.axum-test]
features = []
version = "17"
[dev-dependencies.serde]
features = ["derive"]
version = "1"
[dev-dependencies.tokio]
features = ["full"]
version = "1"

View file

@ -0,0 +1,28 @@
#![allow(unused)]
use aide::axum::ApiRouter;
use axum::extract::{Json, State};
use axum_typed_routing::TypedApiRouter;
use axum_typed_routing_macros::api_route;
#[api_route(GET "/item/:id?amount&offset" {
summary: "Get an item",
description: "Get an item by id",
id: "get-item",
tags: ["items"],
hidden: false
})]
async fn item_handler(
id: u32,
amount: Option<u32>,
offset: Option<u32>,
State(state): State<String>,
Json(json): Json<u32>,
) -> String {
todo!("handle request")
}
fn main() {
let router: ApiRouter = ApiRouter::new()
.typed_api_route(item_handler)
.with_state("state".to_string());
}

View file

@ -0,0 +1,20 @@
#![allow(unused)]
use axum::extract::{Json, State};
use axum_typed_routing::{route, TypedRouter};
#[route(GET "/item/:id?amount&offset")]
async fn item_handler(
id: u32,
amount: Option<u32>,
offset: Option<u32>,
State(state): State<String>,
Json(json): Json<u32>,
) -> String {
todo!("handle request")
}
fn main() {
let router: axum::Router = axum::Router::new()
.typed_route(item_handler)
.with_state("state".to_string());
}

125
vendor/axum-typed-routing/src/lib.rs vendored Normal file
View file

@ -0,0 +1,125 @@
//!
//! ## Basic usage
//! The following example demonstrates the basic usage of the library.
//! On top of any regular handler, you can add the [`route`] macro to create a typed route.
//! Any path- or query-parameters in the url will be type-checked at compile-time, and properly
//! extracted into the handler.
//!
//! The following example shows how the path parameter `id`, and query parameters `amount` and
//! `offset` are type-checked and extracted into the handler.
//!
//! ```
#![doc = include_str!("../examples/basic.rs")]
//! ```
//!
//! Some valid url's as get-methods are:
//! - `/item/1?amount=2&offset=3`
//! - `/item/1?amount=2`
//! - `/item/1?offset=3`
//! - `/item/500`
//!
//! By marking the `amount` and `offset` parameters as `Option<T>`, they become optional.
//!
//! ## Example with `aide`
//! When the `aide` feature is enabled, it's possible to automatically generate OpenAPI
//! documentation for the routes. The [`api_route`] macro is used in place of the [`route`] macro.
//!
//! Please read the [`aide`] documentation for more information on usage.
//! ```
#![doc = include_str!("../examples/aide.rs")]
//! ```
use axum::routing::MethodRouter;
type TypedHandler<S = ()> = fn() -> (&'static str, MethodRouter<S>);
pub use axum_typed_routing_macros::route;
/// A trait that allows typed routes, created with the [`route`] macro to
/// be added to an axum router.
///
/// Typed handlers are of the form `fn() -> (&'static str, MethodRouter<S>)`, where
/// `S` is the state type. The first element of the tuple is the path, and the second
/// is the method router.
pub trait TypedRouter: Sized {
/// The state type of the router.
type State: Clone + Send + Sync + 'static;
/// Add a typed route to the router, usually created with the [`route`] macro.
///
/// Typed handlers are of the form `fn() -> (&'static str, MethodRouter<S>)`, where
/// `S` is the state type. The first element of the tuple is the path, and the second
/// is the method router.
fn typed_route(self, handler: TypedHandler<Self::State>) -> Self;
}
impl<S> TypedRouter for axum::Router<S>
where
S: Send + Sync + Clone + 'static,
{
type State = S;
fn typed_route(self, handler: TypedHandler<Self::State>) -> Self {
let (path, method_router) = handler();
self.route(path, method_router)
}
}
#[cfg(feature = "aide")]
pub use aide_support::*;
#[cfg(feature = "aide")]
mod aide_support {
use crate::{TypedHandler, TypedRouter};
use aide::{
axum::{routing::ApiMethodRouter, ApiRouter},
transform::TransformPathItem,
};
type TypedApiHandler<S = ()> = fn() -> (&'static str, ApiMethodRouter<S>);
pub use axum_typed_routing_macros::api_route;
impl<S> TypedRouter for ApiRouter<S>
where
S: Send + Sync + Clone + 'static,
{
type State = S;
fn typed_route(self, handler: TypedHandler<Self::State>) -> Self {
let (path, method_router) = handler();
self.route(path, method_router)
}
}
/// Same as [`TypedRouter`], but with support for `aide`.
pub trait TypedApiRouter: TypedRouter {
/// Same as [`TypedRouter::typed_route`], but with support for `aide`.
fn typed_api_route(self, handler: TypedApiHandler<Self::State>) -> Self;
/// Same as [`TypedApiRouter::typed_api_route`], but with a custom path transform for
/// use with `aide`.
fn typed_api_route_with(
self,
handler: TypedApiHandler<Self::State>,
transform: impl FnOnce(TransformPathItem) -> TransformPathItem,
) -> Self;
}
impl<S> TypedApiRouter for ApiRouter<S>
where
S: Send + Sync + Clone + 'static,
{
fn typed_api_route(self, handler: TypedApiHandler<Self::State>) -> Self {
let (path, method_router) = handler();
self.api_route(path, method_router)
}
fn typed_api_route_with(
self,
handler: TypedApiHandler<Self::State>,
transform: impl FnOnce(TransformPathItem) -> TransformPathItem,
) -> Self {
let (path, method_router) = handler();
self.api_route_with(path, method_router, transform)
}
}
}

232
vendor/axum-typed-routing/tests/main.rs vendored Normal file
View file

@ -0,0 +1,232 @@
#![allow(unused)]
#![allow(clippy::extra_unused_type_parameters)]
use std::net::TcpListener;
use axum::{
extract::{Path, State},
routing::get,
Form, Json,
};
use axum_test::TestServer;
use axum_typed_routing::TypedRouter;
use axum_typed_routing_macros::route;
/// This is a handler that is documented!
#[route(GET "/hello/:id?user_id&name")]
async fn generic_handler_with_complex_options<T: 'static>(
mut id: u32,
user_id: String,
name: String,
State(state): State<String>,
hello: State<String>,
Json(mut json): Json<u32>,
) -> String {
format!("Hello, {id} - {user_id} - {name}!")
}
#[route(POST "/one")]
async fn one(state: State<String>) -> String {
String::from("Hello!")
}
#[route(POST "/two")]
async fn two() -> String {
String::from("Hello!")
}
#[route(GET "/three/:id")]
async fn three(id: u32) -> String {
format!("Hello {id}!")
}
#[route(GET "/four?id")]
async fn four(id: u32) -> String {
format!("Hello {id:?}!")
// String::from("Hello 123!")
}
// Tests that hyphens are allowed in route names
#[route(GET "/foo-bar")]
async fn foo_bar() {}
#[tokio::test]
async fn test_normal() {
let router: axum::Router = axum::Router::new()
.typed_route(generic_handler_with_complex_options::<u32>)
.typed_route(one)
.with_state("state".to_string())
.typed_route(two)
.typed_route(three)
.typed_route(four);
let server = TestServer::new(router).unwrap();
let response = server.post("/one").await;
response.assert_status_ok();
response.assert_text("Hello!");
let response = server.post("/two").await;
response.assert_status_ok();
response.assert_text("Hello!");
let response = server.get("/three/123").await;
response.assert_status_ok();
response.assert_text("Hello 123!");
let response = server.get("/four").add_query_param("id", 123).await;
response.assert_status_ok();
response.assert_text("Hello 123!");
let response = server
.get("/hello/123")
.add_query_param("user_id", 321.to_string())
.add_query_param("name", "John".to_string())
.json(&100)
.await;
response.assert_status_ok();
response.assert_text("Hello, 123 - 321 - John!");
let (path, method_router) = generic_handler_with_complex_options::<u32>();
assert_eq!(path, "/hello/{id}");
}
#[route(GET "/*")]
async fn wildcard() {}
#[route(GET "/*capture")]
async fn wildcard_capture(capture: String) -> Json<String> {
Json(capture)
}
#[route(GET "/")]
async fn root() {}
#[tokio::test]
async fn test_wildcard() {
let router: axum::Router = axum::Router::new().typed_route(wildcard_capture);
let server = TestServer::new(router).unwrap();
let response = server.get("/foo/bar").await;
response.assert_status_ok();
assert_eq!(response.json::<String>(), "foo/bar");
}
#[cfg(feature = "aide")]
mod aide_support {
use super::*;
use aide::{axum::ApiRouter, openapi::OpenApi, transform::TransformOperation};
use axum_typed_routing::TypedApiRouter;
use axum_typed_routing_macros::api_route;
/// get-summary
///
/// get-description
#[api_route(GET "/hello")]
async fn get_hello(state: State<String>) -> String {
String::from("Hello!")
}
/// post-summary
///
/// post-description
#[api_route(POST "/hello")]
async fn post_hello(state: State<String>) -> String {
String::from("Hello!")
}
#[test]
fn test_aide() {
let router: aide::axum::ApiRouter = aide::axum::ApiRouter::new()
.typed_route(one)
.typed_api_route(get_hello)
.with_state("state".to_string());
let (path, method_router) = get_hello();
assert_eq!(path, "/hello");
let (path, method_router) = post_hello();
assert_eq!(path, "/hello");
}
#[test]
fn summary_and_description_are_generated_from_doc_comments() {
let router = ApiRouter::new()
.typed_api_route(get_hello)
.typed_api_route(post_hello);
let mut api = OpenApi::default();
router.finish_api(&mut api);
let get_op = path_item(&api, "/hello").get.as_ref().unwrap();
let post_op = path_item(&api, "/hello").post.as_ref().unwrap();
assert_eq!(get_op.summary, Some(" get-summary".to_string()));
assert_eq!(get_op.description, Some(" get-description".to_string()));
assert!(get_op.tags.is_empty());
assert_eq!(post_op.summary, Some(" post-summary".to_string()));
assert_eq!(post_op.description, Some(" post-description".to_string()));
assert!(post_op.tags.is_empty());
}
/// unused-summary
///
/// unused-description
#[api_route(GET "/hello" {
summary: "MySummary",
description: "MyDescription",
hidden: false,
id: "MyRoute",
tags: ["MyTag1", "MyTag2"],
security: {
"MySecurity1": ["MyScope1", "MyScope2"],
"MySecurity2": [],
},
responses: {
300: String,
},
transform: |x| x.summary("OverriddenSummary"),
})]
async fn get_gello_with_attributes(state: State<String>) -> String {
String::from("Hello!")
}
#[test]
fn generated_from_attributes() {
let router = ApiRouter::new().typed_api_route(get_gello_with_attributes);
let mut api = OpenApi::default();
router.finish_api(&mut api);
let get_op = path_item(&api, "/hello").get.as_ref().unwrap();
assert_eq!(get_op.summary, Some("OverriddenSummary".to_string()));
assert_eq!(get_op.description, Some("MyDescription".to_string()));
assert_eq!(
get_op.tags,
vec!["MyTag1".to_string(), "MyTag2".to_string()]
);
assert_eq!(get_op.operation_id, Some("MyRoute".to_string()));
}
/// summary
///
/// description
/// description
#[api_route(GET "/hello")]
async fn get_gello_without_attributes(state: State<String>) -> String {
String::from("Hello!")
}
fn path_item<'a>(api: &'a OpenApi, path: &str) -> &'a aide::openapi::PathItem {
api.paths
.as_ref()
.unwrap()
.iter()
.find(|(p, _)| *p == path)
.unwrap()
.1
.as_item()
.unwrap()
}
}