Skip to content

Commit

Permalink
Wip allow expression as macro arg (#762)
Browse files Browse the repository at this point in the history
This PR adds support for expression arguments as macro argument in
addition to literal string args.
```rust
 const FOOBAR: &str = "/api/v1/prefix";
 #[utoipa::path(
     context_path = FOOBAR,
     get,
     path = "/items",
     responses(
         (status = 200, description = "success response")
     ),
 )]
 fn get_string() -> String {
     "string".to_string()
 }
```

Currently only `context_path` supports expression argument in addition
to the literal string.

This is a breaking change that changes the `Path` trait implementation to 
one seen below. Previously the `path()` fn had return type of `&'static str`.
```rust
pub trait Path {
    fn path() -> String;

    fn path_item(default_tag: Option<&str>) -> openapi::path::PathItem;
}
```
  • Loading branch information
juhaku authored Oct 5, 2023
1 parent 164e0d3 commit a235161
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 18 deletions.
49 changes: 46 additions & 3 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,8 +1330,8 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
let resolved_path = PathOperations::resolve_path(
&resolved_operation
.as_mut()
.map(|operation| mem::take(&mut operation.path))
.or_else(|| path_attribute.path.as_ref().map(String::to_string)), // cannot use mem take because we need this later
.map(|operation| mem::take(&mut operation.path).to_string())
.or_else(|| path_attribute.path.as_ref().map(|path| path.to_string())), // cannot use mem take because we need this later
);

#[cfg(any(
Expand Down Expand Up @@ -2757,17 +2757,44 @@ impl<T> OptionExt<T> for Option<T> {

/// Parsing utils
mod parse_utils {
use std::fmt::Display;

use proc_macro2::{Group, Ident, TokenStream};
use quote::ToTokens;
use syn::{
parenthesized,
parse::{Parse, ParseStream},
punctuated::Punctuated,
token::Comma,
Error, LitBool, LitStr, Token,
Error, Expr, LitBool, LitStr, Token,
};

use crate::ResultExt;

#[cfg_attr(feature = "debug", derive(Debug))]
pub enum Value {
LitStr(LitStr),
Expr(Expr),
}

impl ToTokens for Value {
fn to_tokens(&self, tokens: &mut TokenStream) {
match self {
Self::LitStr(str) => str.to_tokens(tokens),
Self::Expr(expr) => expr.to_tokens(tokens),
}
}
}

impl Display for Value {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LitStr(str) => write!(f, "{str}", str = str.value()),
Self::Expr(expr) => write!(f, "{expr}", expr = expr.into_token_stream()),
}
}
}

pub fn parse_next<T: Sized>(input: ParseStream, next: impl FnOnce() -> T) -> T {
input
.parse::<Token![=]>()
Expand All @@ -2779,6 +2806,22 @@ mod parse_utils {
Ok(parse_next(input, || input.parse::<LitStr>())?.value())
}

pub fn parse_next_literal_str_or_expr(input: ParseStream) -> syn::Result<Value> {
parse_next(input, || {
if input.peek(LitStr) {
Ok::<Value, Error>(Value::LitStr(input.parse::<LitStr>()?))
} else {
Ok(Value::Expr(input.parse::<Expr>()?))
}
})
.map_err(|error| {
syn::Error::new(
error.span(),
format!("expected literal string or expression argument: {error}"),
)
})
}

pub fn parse_groups<T, R>(input: ParseStream) -> syn::Result<R>
where
T: Sized,
Expand Down
43 changes: 29 additions & 14 deletions utoipa-gen/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ pub struct PathAttr<'p> {
path_operation: Option<PathOperation>,
request_body: Option<RequestBody<'p>>,
responses: Vec<Response<'p>>,
pub(super) path: Option<String>,
pub(super) path: Option<parse_utils::Value>,
operation_id: Option<Expr>,
tag: Option<String>,
tag: Option<parse_utils::Value>,
params: Vec<Parameter<'p>>,
security: Option<Array<'p, SecurityRequirementAttr>>,
context_path: Option<String>,
context_path: Option<parse_utils::Value>,
}

impl<'p> PathAttr<'p> {
Expand Down Expand Up @@ -112,7 +112,7 @@ impl Parse for PathAttr<'_> {
Some(parse_utils::parse_next(input, || Expr::parse(input))?);
}
"path" => {
path_attr.path = Some(parse_utils::parse_next_literal_str(input)?);
path_attr.path = Some(parse_utils::parse_next_literal_str_or_expr(input)?);
}
"request_body" => {
path_attr.request_body =
Expand All @@ -133,15 +133,16 @@ impl Parse for PathAttr<'_> {
.map(|punctuated| punctuated.into_iter().collect::<Vec<Parameter>>())?;
}
"tag" => {
path_attr.tag = Some(parse_utils::parse_next_literal_str(input)?);
path_attr.tag = Some(parse_utils::parse_next_literal_str_or_expr(input)?);
}
"security" => {
let security;
parenthesized!(security in input);
path_attr.security = Some(parse_utils::parse_groups(&security)?)
}
"context_path" => {
path_attr.context_path = Some(parse_utils::parse_next_literal_str(input)?)
path_attr.context_path =
Some(parse_utils::parse_next_literal_str_or_expr(input)?)
}
_ => {
// any other case it is expected to be path operation
Expand Down Expand Up @@ -304,12 +305,12 @@ impl<'p> ToTokens for Path<'p> {
help = "Did you define the #[utoipa::path(...)] over function?"
}
});
let tag = &*self
let tag = self
.path_attr
.tag
.as_ref()
.map(ToOwned::to_owned)
.unwrap_or_default();
.map(ToTokens::to_token_stream)
.unwrap_or_else(|| quote!(""));
let path_operation = self
.path_attr
.path_operation
Expand All @@ -334,7 +335,8 @@ impl<'p> ToTokens for Path<'p> {
.path_attr
.path
.as_ref()
.or(self.path.as_ref())
.map(|path| path.to_token_stream())
.or(Some(self.path.to_token_stream()))
.unwrap_or_else(|| {
#[cfg(any(feature = "actix_extras", feature = "rocket_extras"))]
let help =
Expand All @@ -345,7 +347,7 @@ impl<'p> ToTokens for Path<'p> {

abort! {
Span::call_site(), "path is not defined for path";
help = r###"Did you forget to define it in #[utoipa::path(path = "...")]"###;
help = r#"Did you forget to define it in #[utoipa::path(path = "...")]"#;
help =? help
}
});
Expand All @@ -354,8 +356,21 @@ impl<'p> ToTokens for Path<'p> {
.path_attr
.context_path
.as_ref()
.map(|context_path| format!("{context_path}{path}"))
.unwrap_or_else(|| path.to_string());
.map(|context_path| {
let context_path = context_path.to_token_stream();
let context_path_tokens = quote! {
format!("{}{}",
#context_path.to_string().replace('"', ""),
#path.to_string().replace('"', "")
)
};
context_path_tokens
})
.unwrap_or_else(|| {
quote! {
#path.to_string().replace('"', "")
}
});

let operation: Operation = Operation {
deprecated: &self.deprecated,
Expand All @@ -377,7 +392,7 @@ impl<'p> ToTokens for Path<'p> {
pub struct #path_struct;

impl utoipa::Path for #path_struct {
fn path() -> &'static str {
fn path() -> String {
#path_with_context_path
}

Expand Down
115 changes: 115 additions & 0 deletions utoipa-gen/tests/path_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,3 +1923,118 @@ fn derive_into_params_with_serde_skip_serializing() {
])
)
}

#[test]
fn derive_path_with_const_expression_context_path() {
const FOOBAR: &str = "/api/v1/prefix";

#[utoipa::path(
context_path = FOOBAR,
get,
path = "/items",
responses(
(status = 200, description = "success response")
),
)]
#[allow(unused)]
fn get_items() -> String {
"".to_string()
}

let operation = test_api_fn_doc! {
get_items,
operation: get,
path: "/api/v1/prefix/items"
};

assert_ne!(operation, Value::Null);
}

#[test]
fn derive_path_with_const_expression_reference_context_path() {
const FOOBAR: &str = "/api/v1/prefix";

#[utoipa::path(
context_path = &FOOBAR,
get,
path = "/items",
responses(
(status = 200, description = "success response")
),
)]
#[allow(unused)]
fn get_items() -> String {
"".to_string()
}

let operation = test_api_fn_doc! {
get_items,
operation: get,
path: "/api/v1/prefix/items"
};

assert_ne!(operation, Value::Null);
}

#[test]
fn derive_path_with_const_expression() {
const FOOBAR: &str = "/items";

#[utoipa::path(
get,
path = FOOBAR,
responses(
(status = 200, description = "success response")
),
)]
#[allow(unused)]
fn get_items() -> String {
"".to_string()
}

let operation = test_api_fn_doc! {
get_items,
operation: get,
path: "/items"
};

assert_ne!(operation, Value::Null);
}

#[test]
fn derive_path_with_tag_constant() {
const TAG: &str = "mytag";

#[utoipa::path(
get,
tag = TAG,
path = "/items",
responses(
(status = 200, description = "success response")
),
)]
#[allow(unused)]
fn get_items() -> String {
"".to_string()
}

let operation = test_api_fn_doc! {
get_items,
operation: get,
path: "/items"
};

assert_ne!(operation, Value::Null);
assert_json_eq!(
&operation,
json!({
"operationId": "get_items",
"responses": {
"200": {
"description": "success response",
},
},
"tags": ["mytag"]
})
);
}
2 changes: 1 addition & 1 deletion utoipa/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ impl<'__s, K: PartialSchema, V: ToSchema<'__s>> PartialSchema for Option<HashMap
///
/// [derive]: attr.path.html
pub trait Path {
fn path() -> &'static str;
fn path() -> String;

fn path_item(default_tag: Option<&str>) -> openapi::path::PathItem;
}
Expand Down

0 comments on commit a235161

Please sign in to comment.