salvo-oapi-macros 0.95.0

Macros for salvo-oapi
Documentation
use proc_macro2::Ident;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, ExprPath, parenthesized};

use crate::operation::request_body::RequestBodyAttr;
use crate::parameter::StructParameter;
use crate::response::ResponseTuple;
use crate::security_requirement::SecurityRequirementsAttr;
use crate::{Array, Parameter, Response, Token, parse_utils};

#[derive(Default, Debug)]
pub(crate) struct EndpointAttr<'p> {
    pub(crate) request_body: Option<RequestBodyAttr<'p>>,
    pub(crate) responses: Vec<Response<'p>>,
    pub(crate) status_codes: Vec<Expr>,
    pub(crate) operation_id: Option<Expr>,
    pub(crate) tags: Option<Vec<Expr>>,
    pub(crate) parameters: Vec<Parameter<'p>>,
    pub(crate) security: Option<Array<'p, SecurityRequirementsAttr>>,

    pub(crate) doc_comments: Option<Vec<String>>,
    pub(crate) deprecated: Option<bool>,
    pub(crate) description: Option<parse_utils::LitStrOrExpr>,
    pub(crate) summary: Option<parse_utils::LitStrOrExpr>,
}

impl Parse for EndpointAttr<'_> {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        const EXPECTED_ATTRIBUTE_MESSAGE: &str = "unexpected identifier, expected any of: operation_id, request_body, response, responses, status_code, status_codes, parameter, parameters, tag, tags, security, description, summary";
        let mut attr = EndpointAttr::default();

        while !input.is_empty() {
            let ident = input.parse::<Ident>().map_err(|error| {
                syn::Error::new(
                    error.span(),
                    format!("{EXPECTED_ATTRIBUTE_MESSAGE}, {error}"),
                )
            })?;
            match &*ident.to_string() {
                "operation_id" => {
                    attr.operation_id =
                        Some(parse_utils::parse_next(input, || Expr::parse(input))?);
                }
                "request_body" => {
                    attr.request_body = Some(input.parse::<RequestBodyAttr>()?);
                }
                "responses" => {
                    let responses;
                    parenthesized!(responses in input);
                    let responses = Punctuated::<Response, Token![,]>::parse_terminated(&responses)
                        .map(|punctuated| punctuated.into_iter().collect::<Vec<Response>>())?;
                    attr.responses.extend(responses);
                }
                "response" => {
                    attr.responses.push(parse_response_alias(input)?);
                }
                "status_codes" => {
                    let status_codes;
                    parenthesized!(status_codes in input);
                    let status_codes =
                        Punctuated::<Expr, Token![,]>::parse_terminated(&status_codes)
                            .map(|punctuated| punctuated.into_iter().collect::<Vec<Expr>>())?;
                    attr.status_codes.extend(status_codes);
                }
                "status_code" => {
                    if input.peek(Token![=]) {
                        attr.status_codes
                            .push(parse_utils::parse_next(input, || Expr::parse(input))?);
                    } else {
                        let status_code;
                        parenthesized!(status_code in input);
                        attr.status_codes.push(status_code.parse::<Expr>()?);
                        if !status_code.is_empty() {
                            return Err(status_code.error(
                                "`status_code(...)` accepts one status code; use `status_codes(...)` for a list",
                            ));
                        }
                    }
                }
                "parameters" => {
                    let parameters;
                    parenthesized!(parameters in input);
                    let parameters =
                        Punctuated::<Parameter, Token![,]>::parse_terminated(&parameters)
                            .map(|punctuated| punctuated.into_iter().collect::<Vec<Parameter>>())?;
                    attr.parameters.extend(parameters);
                }
                "parameter" => {
                    attr.parameters.push(parse_parameter_alias(input)?);
                }
                "tags" => {
                    let tags;
                    parenthesized!(tags in input);
                    let parsed: Punctuated<Expr, Token![,]> =
                        Punctuated::<Expr, Token![,]>::parse_terminated(&tags)?;
                    attr.tags.get_or_insert_with(Vec::new).extend(parsed);
                }
                "tag" => {
                    let tag;
                    parenthesized!(tag in input);
                    attr.tags
                        .get_or_insert_with(Vec::new)
                        .push(tag.parse::<Expr>()?);
                    if !tag.is_empty() {
                        return Err(
                            tag.error("`tag(...)` accepts one tag; use `tags(...)` for a list")
                        );
                    }
                }
                "security" => {
                    let security;
                    parenthesized!(security in input);
                    attr.security = Some(parse_utils::parse_groups(&security)?)
                }
                "description" => {
                    attr.description = Some(parse_utils::parse_next_lit_str_or_expr(input)?)
                }
                "summary" => attr.summary = Some(parse_utils::parse_next_lit_str_or_expr(input)?),
                _ => {
                    return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE_MESSAGE));
                }
            }

            if !input.is_empty() {
                input.parse::<Token![,]>()?;
            }
        }

        Ok(attr)
    }
}

fn parse_response_alias<'p>(input: ParseStream) -> syn::Result<Response<'p>> {
    let response;
    parenthesized!(response in input);

    let fork = response.fork();
    if let Ok(path) = fork.parse::<ExprPath>()
        && fork.is_empty()
    {
        response.parse::<ExprPath>()?;
        return Ok(Response::ToResponses(path));
    }

    let tuple = response.parse::<ResponseTuple>()?;
    if !response.is_empty() {
        return Err(
            response.error("`response(...)` accepts one response; use `responses(...)` for a list")
        );
    }
    Ok(Response::Tuple(Box::new(tuple)))
}

fn parse_parameter_alias<'p>(input: ParseStream) -> syn::Result<Parameter<'p>> {
    let fork = input.fork();
    let parameter_input;
    parenthesized!(parameter_input in fork);
    let path_fork = parameter_input.fork();
    if let Ok(path) = path_fork.parse::<ExprPath>()
        && path_fork.is_empty()
    {
        let consumed;
        parenthesized!(consumed in input);
        consumed.parse::<ExprPath>()?;
        return Ok(Parameter::Struct(StructParameter { path }));
    }

    input.parse::<Parameter>()
}

#[cfg(test)]
mod tests {
    use syn::parse_str;

    use super::*;

    #[test]
    fn test_parse_operation_id() {
        let input = "operation_id = \"test_operation\"";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.operation_id.is_some());
    }

    #[test]
    fn test_parse_request_body() {
        let input = "request_body = Pet";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.request_body.is_some());
    }

    #[test]
    fn test_parse_responses() {
        let input = "responses((status_code = 200))";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.responses.len(), 1);
    }

    #[test]
    fn test_parse_response_alias() {
        let input = "response(status_code = 200)";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.responses.len(), 1);
    }

    #[test]
    fn test_parse_response_alias_to_responses_path() {
        let input = "response(MyResponses)";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.responses.len(), 1);
        assert!(matches!(attr.responses[0], Response::ToResponses(_)));
    }

    #[test]
    fn test_parse_status_codes() {
        let input = "status_codes(200, 404)";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.status_codes.len(), 2);
    }

    #[test]
    fn test_parse_status_code_alias() {
        let input = "status_code(200), status_code = 404";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.status_codes.len(), 2);
    }

    #[test]
    #[ignore]
    fn test_parse_parameters() {
        let input = "parameters((\"id\" in path,))";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.parameters.len(), 1);
    }

    #[test]
    fn test_parse_parameter_alias() {
        let input = r#"parameter("id" = String, Path, description = "Pet id")"#;
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.parameters.len(), 1);
    }

    #[test]
    fn test_parse_parameter_alias_to_parameters_path() {
        let input = "parameter(MyParameters)";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.parameters.len(), 1);
        assert!(matches!(attr.parameters[0], Parameter::Struct(_)));
    }

    #[test]
    fn test_parse_tags() {
        let input = "tags(\"pet\", \"store\")";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.tags.unwrap().len(), 2);
    }

    #[test]
    fn test_parse_tag_alias() {
        let input = r#"tag("pet"), tag("store")"#;
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert_eq!(attr.tags.unwrap().len(), 2);
    }

    #[test]
    fn test_parse_singular_then_plural_aliases_append() {
        let input = r#"
            response(status_code = 404),
            responses((status_code = 200)),
            status_code(201),
            status_codes(202, 203),
            parameter("id" = String, Path),
            parameters(MyParameters),
            tag("pet"),
            tags("store", "admin")
        "#;
        let attr = parse_str::<EndpointAttr>(input).unwrap();

        assert_eq!(attr.responses.len(), 2);
        assert_eq!(attr.status_codes.len(), 3);
        assert_eq!(attr.parameters.len(), 2);
        assert_eq!(attr.tags.unwrap().len(), 3);
    }

    #[test]
    fn test_parse_security() {
        let input = "security((\"petstore_auth\" = [\"write:pets\", \"read:pets\"]))";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.security.is_some());
    }

    #[test]
    fn test_parse_description() {
        let input = "description = \"test description\"";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.description.is_some());
    }

    #[test]
    fn test_parse_summary() {
        let input = "summary = \"test summary\"";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.summary.is_some());
    }

    #[test]
    fn test_parse_request_body_ref() {
        use crate::operation::PathType;
        // ref(...) with a Rust type path should parse correctly.
        // Regression test for #994 where `ref` keyword caused parse failures.
        let result = parse_str::<PathType>("ref(my_schemas::PetSchema)");
        assert!(
            result.is_ok(),
            "PathType should parse ref(path): {:?}",
            result.err()
        );
    }

    #[test]
    fn test_parse_request_body_ref_in_endpoint() {
        // Full endpoint attribute with ref() syntax
        let input = "request_body = ref(PetSchema)";
        let attr = parse_str::<EndpointAttr>(input).unwrap();
        assert!(attr.request_body.is_some());
    }

    #[test]
    fn test_request_body_ref_generates_to_schema() {
        use proc_macro2::TokenStream;

        use crate::shared::TryToTokens;

        // Regression: `request_body = ref(Type)` previously generated a call to
        // `Schema::to_schema`, but `Schema` is an enum without that method, so the
        // documented feature failed to compile. It must use the `ToSchema` trait
        // (matching the response `ref(...)` path).
        let attr = parse_str::<EndpointAttr>("request_body = ref(PetSchema)").unwrap();
        let body = attr.request_body.expect("request_body parsed");
        let mut tokens = TokenStream::new();
        if body.try_to_tokens(&mut tokens).is_err() {
            panic!("request_body = ref(...) code generation failed");
        }
        let generated = tokens.to_string();
        assert!(
            generated.contains("ToSchema"),
            "request_body = ref(...) should call the ToSchema trait: {generated}"
        );
        assert!(
            !generated.contains("schema :: Schema"),
            "request_body = ref(...) must not reference the Schema enum: {generated}"
        );
    }
}