Skip to content

Commit

Permalink
Add support to define mulitple operation methods (#1006)
Browse files Browse the repository at this point in the history
Add support for defining multiple operation methods to 
`#[utoipa::path(...)]` attribte macro as follows.
```rust
 #[utoipa::path(method(head, get), ...)]
 async fn path() {}
```

Remove `CONNECT` http method from supported path operation types since
it actually is not supported by the OpenAPI specification. Also this
commit renames the `PathItemType` `HttpMethod` to more precisely
describe the use case.

Add `#[route(...)]` macro support for `actix-web` library.

Enhance `utoipa-axum` bindings to support multiple operation methods.

### Breaking

This commit renames `PathItemType` to `HttpMethod` to better describe the 
usage. Also this commit removes `Connect` from the `HttpMethod` since it is 
not supported by OpenAPI specification. https://spec.openapis.org/oas/latest.html#path-item-object

Fixes #827
  • Loading branch information
juhaku authored Aug 28, 2024
1 parent a0db8b9 commit 69dfbbc
Show file tree
Hide file tree
Showing 15 changed files with 605 additions and 399 deletions.
2 changes: 1 addition & 1 deletion examples/axum-utoipa-nesting-vendored/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async fn main() -> Result<(), Error> {
let hello_api =
Into::<OpenApiBuilder>::into(HelloApi::openapi()).paths(PathsBuilder::new().path(
"",
PathItem::new(utoipa::openapi::PathItemType::Get, Operation::new()),
PathItem::new(utoipa::openapi::HttpMethod::Get, Operation::new()),
));

let mut doc = ApiDoc::openapi();
Expand Down
67 changes: 30 additions & 37 deletions utoipa-axum/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,39 +51,29 @@
pub mod router;

use core::panic;

use axum::routing::MethodFilter;
use utoipa::openapi::PathItemType;
use utoipa::openapi::HttpMethod;

/// Extends [`utoipa::openapi::path::PathItem`] by providing conversion methods to convert this
/// path item type to a [`axum::routing::MethodFilter`].
pub trait PathItemExt {
/// Convert this path item type ot a [`axum::routing::MethodFilter`].
///
/// Method filter is used with handler registration on [`axum::routing::MethodRouter`].
///
/// # Panics
///
/// [`utoipa::openapi::path::PathItemType::Connect`] will panic because _`axum`_ does not have
/// `CONNECT` type [`axum::routing::MethodFilter`].
fn to_method_filter(&self) -> MethodFilter;
}

impl PathItemExt for PathItemType {
impl PathItemExt for HttpMethod {
fn to_method_filter(&self) -> MethodFilter {
match self {
PathItemType::Get => MethodFilter::GET,
PathItemType::Put => MethodFilter::PUT,
PathItemType::Post => MethodFilter::POST,
PathItemType::Head => MethodFilter::HEAD,
PathItemType::Patch => MethodFilter::PATCH,
PathItemType::Trace => MethodFilter::TRACE,
PathItemType::Delete => MethodFilter::DELETE,
PathItemType::Options => MethodFilter::OPTIONS,
PathItemType::Connect => panic!(
"`CONNECT` not supported, axum does not have `MethodFilter` for connect requests"
),
HttpMethod::Get => MethodFilter::GET,
HttpMethod::Put => MethodFilter::PUT,
HttpMethod::Post => MethodFilter::POST,
HttpMethod::Head => MethodFilter::HEAD,
HttpMethod::Patch => MethodFilter::PATCH,
HttpMethod::Trace => MethodFilter::TRACE,
HttpMethod::Delete => MethodFilter::DELETE,
HttpMethod::Options => MethodFilter::OPTIONS,
}
}
}
Expand Down Expand Up @@ -143,39 +133,42 @@ macro_rules! routes {
use $crate::PathItemExt;
let mut paths = utoipa::openapi::path::Paths::new();
let (path, item, types) = routes!(@resolve_types $handler);
paths.add_path(path, item);
#[allow(unused_mut)]
let mut method_router = types.into_iter().fold(axum::routing::MethodRouter::new(), |router, path_type| {
let mut method_router = types.iter().by_ref().fold(axum::routing::MethodRouter::new(), |router, path_type| {
router.on(path_type.to_method_filter(), $handler)
});
for method_type in types {
paths.add_path(&path, utoipa::openapi::path::PathItem::new(method_type, item.clone()));
}
$( method_router = routes!( method_router: paths: $tail ); )*
(paths, method_router)
}
};
( $router:ident: $paths:ident: $handler:ident $(, $tail:tt)* ) => {
{
let (path, item, types) = routes!(@resolve_types $handler);
$paths.add_path(path, item);
types.into_iter().fold($router, |router, path_type| {
let router = types.iter().by_ref().fold($router, |router, path_type| {
router.on(path_type.to_method_filter(), $handler)
})
});
for method_type in types {
$paths.add_path(&path, utoipa::openapi::path::PathItem::new(method_type, item.clone()));
}
router
}
};
( @resolve_types $handler:ident ) => {
{
use utoipa::{Path, __dev::{PathItemTypes, Tags}};
use utoipa::{Path, __dev::Tags};
$crate::paste! {
let path = [<__path_ $handler>]::path();
let mut path_item = [<__path_ $handler>]::path_item();
let types = [<__path_ $handler>]::path_item_types();
let mut operation = [<__path_ $handler>]::operation();
let types = [<__path_ $handler>]::methods();
let tags = [< __path_ $handler>]::tags();
if !tags.is_empty() {
for (_, operation) in path_item.operations.iter_mut() {
let operation_tags = operation.tags.get_or_insert(Vec::new());
operation_tags.extend(tags.iter().map(ToString::to_string));
}
let operation_tags = operation.tags.get_or_insert(Vec::new());
operation_tags.extend(tags.iter().map(ToString::to_string));
}
(path, path_item, types)
(path, operation, types)
}
}
};
Expand Down Expand Up @@ -261,14 +254,14 @@ mod tests {
.path(
"/",
utoipa::openapi::PathItem::new(
utoipa::openapi::path::PathItemType::Get,
utoipa::openapi::path::HttpMethod::Get,
utoipa::openapi::path::OperationBuilder::new().operation_id(Some("get_user")),
),
)
.path(
"/search",
utoipa::openapi::PathItem::new(
utoipa::openapi::path::PathItemType::Get,
utoipa::openapi::path::HttpMethod::Get,
utoipa::openapi::path::OperationBuilder::new()
.operation_id(Some("search_user")),
),
Expand Down Expand Up @@ -302,15 +295,15 @@ mod tests {
.path(
"/api/customer/",
utoipa::openapi::PathItem::new(
utoipa::openapi::path::PathItemType::Get,
utoipa::openapi::path::HttpMethod::Get,
utoipa::openapi::path::OperationBuilder::new()
.operation_id(Some("get_customer")),
),
)
.path(
"/search",
utoipa::openapi::PathItem::new(
utoipa::openapi::path::PathItemType::Get,
utoipa::openapi::path::HttpMethod::Get,
utoipa::openapi::path::OperationBuilder::new()
.operation_id(Some("search_user")),
),
Expand Down
19 changes: 6 additions & 13 deletions utoipa-gen/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use syn::{Attribute, GenericArgument, Path, PathArguments, PathSegment, Type, Ty

use crate::doc_comment::CommentAttributes;
use crate::schema_type::{SchemaFormat, SchemaTypeInner};
use crate::{as_tokens_or_diagnostics, Diagnostics, OptionExt, ToTokensDiagnostics};
use crate::{as_tokens_or_diagnostics, AttributesExt, Diagnostics, OptionExt, ToTokensDiagnostics};
use crate::{schema_type::SchemaType, Deprecated};

use self::features::{
Expand All @@ -32,18 +32,11 @@ fn is_default(container_rules: &SerdeContainer, field_rule: &SerdeValue) -> bool
/// Find `#[deprecated]` attribute from given attributes. Typically derive type attributes
/// or field attributes of struct.
fn get_deprecated(attributes: &[Attribute]) -> Option<Deprecated> {
attributes.iter().find_map(|attribute| {
if attribute
.path()
.get_ident()
.map(|ident| *ident == "deprecated")
.unwrap_or(false)
{
Some(Deprecated::True)
} else {
None
}
})
if attributes.has_deprecated() {
Some(Deprecated::True)
} else {
None
}
}

/// Check whether field is required based on following rules.
Expand Down
12 changes: 4 additions & 8 deletions utoipa-gen/src/ext.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
use std::borrow::Cow;

#[cfg(feature = "rocket_extras")]
use std::cmp::Ordering;

use proc_macro2::TokenStream;
use quote::{quote, quote_spanned};
use syn::parse_quote;
use syn::spanned::Spanned;
use syn::{punctuated::Punctuated, token::Comma, ItemFn};

use crate::component::{ComponentSchema, ComponentSchemaProps, TypeTree};
use crate::path::{PathOperation, PathTypeTree};
use crate::path::{HttpMethod, PathTypeTree};
use crate::{as_tokens_or_diagnostics, Diagnostics, ToTokensDiagnostics};

#[cfg(feature = "auto_into_responses")]
Expand Down Expand Up @@ -239,7 +236,7 @@ pub enum MacroArg {
impl MacroArg {
/// Get ordering by name
#[cfg(feature = "rocket_extras")]
fn by_name(a: &MacroArg, b: &MacroArg) -> Ordering {
fn by_name(a: &MacroArg, b: &MacroArg) -> std::cmp::Ordering {
a.get_value().name.cmp(&b.get_value().name)
}

Expand All @@ -261,7 +258,7 @@ pub struct ArgValue {

#[cfg_attr(feature = "debug", derive(Debug))]
pub struct ResolvedOperation {
pub path_operation: PathOperation,
pub methods: Vec<HttpMethod>,
pub path: String,
#[allow(unused)] // this is needed only if axum, actix or rocket
pub body: String,
Expand Down Expand Up @@ -324,7 +321,6 @@ impl PathOperationResolver for PathOperations {}
pub mod fn_arg {

use proc_macro2::Ident;
// use proc_macro_error::abort;
#[cfg(any(feature = "actix_extras", feature = "axum_extras"))]
use quote::quote;
use syn::spanned::Spanned;
Expand All @@ -350,10 +346,10 @@ pub mod fn_arg {
Destructed(Vec<&'t Ident>),
}

#[cfg(feature = "rocket_extras")]
impl FnArgType<'_> {
/// Get best effort name `Ident` for the type. For `FnArgType::Tuple` types it will take the first one
/// from `Vec`.
#[cfg(feature = "rocket_extras")]
pub(super) fn get_name(&self) -> &Ident {
match self {
Self::Single(ident) => ident,
Expand Down
105 changes: 79 additions & 26 deletions utoipa-gen/src/ext/actix.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::borrow::Cow;

use proc_macro2::Ident;
use proc_macro2::{Ident, TokenTree};
use regex::{Captures, Regex};
use syn::{parse::Parse, punctuated::Punctuated, token::Comma, ItemFn, LitStr};

use crate::{
component::{TypeTree, ValueType},
ext::ArgValue,
path::PathOperation,
path::HttpMethod,
Diagnostics,
};

Expand Down Expand Up @@ -114,28 +114,36 @@ impl PathOperationResolver for PathOperations {
.attrs
.iter()
.find_map(|attribute| {
if is_valid_request_type(attribute.path().get_ident()) {
match attribute.parse_args::<Path>() {
Ok(path) => {
let path_operation = match PathOperation::from_ident(
attribute.path().get_ident().unwrap(),
) {
Ok(path_operation) => path_operation,
Err(diagnostics) => return Some(Err(diagnostics)),
if is_valid_actix_route_attribute(attribute.path().get_ident()) {
match attribute.parse_args::<Route>() {
Ok(route) => {
let attribute_path = attribute.path().get_ident()
.expect("actix-web route macro must have ident");
let methods: Vec<HttpMethod> = if *attribute_path == "route" {
route.methods.into_iter().map(|method| {
method.to_lowercase().parse::<HttpMethod>()
.expect("Should never fail, validity of HTTP method is checked before parsing")
}).collect()
} else {
// if user used #[connect(...)] macro, return error
match HttpMethod::from_ident(attribute_path) {
Ok(http_method) => { vec![http_method]},
Err(error) => return Some(
Err(
error.help(
format!(r#"If you want operation to be documented and executed on `{method}` try using `#[route(...)]` e.g. `#[route("/path", method = "GET", method = "{method}")]`"#, method = attribute_path.to_string().to_uppercase())
)
)
)
}
};

Some(Ok(ResolvedOperation {
path: path.0,
path_operation,
path: route.path,
methods,
body: String::new(),
}))
}
Err(error) => Some(Err(Into::<Diagnostics>::into(error))),
// Err(error) => abort!(
// error.span(),
// "parse path of path operation attribute: {}",
// error
// ),
}
} else {
None
Expand All @@ -145,22 +153,67 @@ impl PathOperationResolver for PathOperations {
}
}

struct Path(String);
#[cfg_attr(feature = "debug", derive(Debug))]
struct Route {
path: String,
methods: Vec<String>,
}

impl Parse for Path {
impl Parse for Route {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
const ALLOWED_METHODS: [&str; 8] = [
"GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE", "PATCH",
];
// OpenAPI spec does not support CONNECT thus we do not resolve it

enum PrevToken {
Method,
Equals,
}

let path = input.parse::<LitStr>()?.value();
let mut parsed_methods: Vec<String> = Vec::new();

// ignore rest of the tokens from actix-web path attribute macro
input.step(|cursor| {
let mut rest = *cursor;
while let Some((_, next)) = rest.token_tree() {
rest = next;

let mut prev_token: Option<PrevToken> = None;
while let Some((tt, next)) = rest.token_tree() {
match &tt {
TokenTree::Ident(ident) if *ident == "method" => {
prev_token = Some(PrevToken::Method);
rest = next
}
TokenTree::Punct(punct)
if punct.as_char() == '='
&& matches!(prev_token, Some(PrevToken::Method)) =>
{
prev_token = Some(PrevToken::Equals);
rest = next
}
TokenTree::Literal(literal)
if matches!(prev_token, Some(PrevToken::Equals)) =>
{
let value = literal.to_string();
let method = &value[1..value.len() - 1];

if ALLOWED_METHODS.contains(&method) {
parsed_methods.push(String::from(method));
}

prev_token = None;
rest = next;
}
_ => rest = next,
}
}
Ok(((), rest))
})?;

Ok(Self(path))
Ok(Route {
path,
methods: parsed_methods,
})
}
}

Expand Down Expand Up @@ -210,7 +263,7 @@ impl PathResolver for PathOperations {
}

#[inline]
fn is_valid_request_type(ident: Option<&Ident>) -> bool {
matches!(ident, Some(operation) if ["get", "post", "put", "delete", "head", "connect", "options", "trace", "patch"]
fn is_valid_actix_route_attribute(ident: Option<&Ident>) -> bool {
matches!(ident, Some(operation) if ["get", "post", "put", "delete", "head", "connect", "options", "trace", "patch", "route"]
.iter().any(|expected_operation| operation == expected_operation))
}
Loading

0 comments on commit 69dfbbc

Please sign in to comment.