diff --git a/.gitignore b/.gitignore index 66046e11..9befdf41 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ **/*.rs.bk Cargo.lock /.idea -.vscode \ No newline at end of file +.vscode +.DS_Store diff --git a/rocket-okapi-codegen/src/openapi_attr/doc_attr.rs b/rocket-okapi-codegen/src/doc_attr.rs similarity index 96% rename from rocket-okapi-codegen/src/openapi_attr/doc_attr.rs rename to rocket-okapi-codegen/src/doc_attr.rs index bfd6f202..676c390c 100644 --- a/rocket-okapi-codegen/src/openapi_attr/doc_attr.rs +++ b/rocket-okapi-codegen/src/doc_attr.rs @@ -31,7 +31,7 @@ fn merge_description_lines(doc: &str) -> Option { none_if_empty(desc) } -fn get_doc(attrs: &[Attribute]) -> Option { +pub fn get_doc(attrs: &[Attribute]) -> Option { let doc = attrs .iter() .filter_map(|attr| { diff --git a/rocket-okapi-codegen/src/lib.rs b/rocket-okapi-codegen/src/lib.rs index 31a3b7e9..cc49523c 100644 --- a/rocket-okapi-codegen/src/lib.rs +++ b/rocket-okapi-codegen/src/lib.rs @@ -9,9 +9,11 @@ //! - `#[derive(OpenApiFromRequest)]`: Implement `OpenApiFromRequest` trait for a given struct. //! +mod doc_attr; mod openapi_attr; mod openapi_spec; mod parse_routes; +mod responder_derive; use proc_macro::TokenStream; use quote::quote; @@ -85,7 +87,7 @@ pub fn openapi_spec(input: TokenStream) -> TokenStream { .into() } -/// Derive marco for the `OpenApiFromRequest` trait. +/// Derive macro for the `OpenApiFromRequest` trait. /// /// This derive trait is a very simple implementation for anything that does not /// require any other special headers or parameters to be validated. @@ -136,6 +138,21 @@ pub fn open_api_from_request_derive(input: TokenStream) -> TokenStream { gen.into() } +/// Derive for the [`OpenApiResponderInner`](rocket_okapi::response::OpenApiResponderInner) trait. +/// +/// Derive is fully compatible with the syntax of the +/// [`#[response]`](https://api.rocket.rs/v0.5/rocket/derive.Responder#field-attribute) attribute +/// from Rocket and does not require any code changes. +#[proc_macro_derive(OpenApiResponder, attributes(responder))] +pub fn open_api_responder_derive(input: TokenStream) -> TokenStream { + let ast: syn::DeriveInput = syn::parse(input).unwrap(); + + match responder_derive::derive(ast) { + Ok(v) => v.into(), + Err(err) => err.write_errors().into(), + } +} + fn get_add_operation_fn_name(route_fn_name: &Ident) -> Ident { Ident::new( &format!("okapi_add_operation_for_{}_", route_fn_name), diff --git a/rocket-okapi-codegen/src/openapi_attr/mod.rs b/rocket-okapi-codegen/src/openapi_attr/mod.rs index 67d88fe0..8a4e4685 100644 --- a/rocket-okapi-codegen/src/openapi_attr/mod.rs +++ b/rocket-okapi-codegen/src/openapi_attr/mod.rs @@ -1,7 +1,6 @@ -mod doc_attr; mod route_attr; -use crate::get_add_operation_fn_name; +use crate::{doc_attr::get_title_and_desc_from_doc, get_add_operation_fn_name}; use darling::FromMeta; use proc_macro::TokenStream; use proc_macro2::Span; @@ -295,7 +294,7 @@ fn create_route_operation_fn( .replace("..>", "}") .replace('>', "}"); let method = Ident::new(&to_pascal_case_string(route.method), Span::call_site()); - let (title, desc) = doc_attr::get_title_and_desc_from_doc(&route_fn.attrs); + let (title, desc) = get_title_and_desc_from_doc(&route_fn.attrs); let title = match title { Some(x) => quote!(Some(#x.to_owned())), None => quote!(None), diff --git a/rocket-okapi-codegen/src/responder_derive/mod.rs b/rocket-okapi-codegen/src/responder_derive/mod.rs new file mode 100644 index 00000000..961f2ad0 --- /dev/null +++ b/rocket-okapi-codegen/src/responder_derive/mod.rs @@ -0,0 +1,113 @@ +mod response_attr; + +use crate::doc_attr::get_doc; +use darling::{FromMeta, Result}; +use proc_macro2::TokenStream; +use quote::quote; +use response_attr::ResponseAttribute; +use syn::{parse_quote, Attribute, DeriveInput, Fields, GenericParam, Generics}; + +pub fn derive(input: DeriveInput) -> Result { + let responses_variants: Vec<(Vec, Fields)> = match input.data { + syn::Data::Struct(syn::DataStruct { fields, .. }) => { + [(input.attrs.clone(), fields)].to_vec() + } + syn::Data::Enum(syn::DataEnum { variants, .. }) => { + variants.into_iter().map(|v| (v.attrs, v.fields)).collect() + } + syn::Data::Union(_) => { + return Err(darling::Error::custom("unions are not supported").with_span(&input)); + } + }; + + let name = input.ident; + + let generics = add_trait_bound(input.generics); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + let responses = responses_variants + .into_iter() + .filter_map(|(variant_attrs, variant_fields)| { + variant_to_responses(&input.attrs, variant_attrs, variant_fields).transpose() + }) + .collect::>>()?; + + Ok(quote! { + impl #impl_generics ::rocket_okapi::response::OpenApiResponderInner for #name #ty_generics #where_clause { + fn responses( + gen: &mut ::rocket_okapi::gen::OpenApiGenerator, + ) -> ::rocket_okapi::Result<::rocket_okapi::okapi::openapi3::Responses> { + let mut responses = ::rocket_okapi::okapi::openapi3::Responses::default(); + #( + responses.responses.extend({ #responses }.responses); + )* + Ok(responses) + } + } + }) +} + +fn add_trait_bound(mut generics: Generics) -> Generics { + for param in generics.params.iter_mut() { + if let GenericParam::Type(param) = param { + param.bounds.push(parse_quote!( + ::rocket_okapi::response::OpenApiResponderInner + )); + } + } + generics +} + +fn variant_to_responses( + entity_attrs: &[Attribute], + attrs: Vec, + fields: Fields, +) -> Result> { + let response_attribute = get_response_attr_or_default(&attrs)?; + + let field = fields + .into_iter() + .next() + .ok_or(darling::Error::custom("need at least one field"))?; + let response_type = &field.ty; + + let status = response_attribute.status; + let set_status = + status.map(|status| quote! { ::rocket_okapi::util::set_status_code(&mut r, #status)?; }); + + let content_type = response_attribute.content_type; + let set_content_type = + content_type.map(|ct| quote! { ::rocket_okapi::util::set_content_type(&mut r, #ct)?; }); + + let description = get_doc(&field.attrs) + .or_else(|| get_doc(&attrs)) + .or_else(|| get_doc(entity_attrs)); + let set_description = + description.map(|doc| quote! { ::rocket_okapi::util::set_description(&mut r, #doc)?; }); + + Ok(Some(quote! { + let mut r = <#response_type as ::rocket_okapi::response::OpenApiResponderInner>::responses(gen)?; + #set_status + #set_content_type + #set_description + r + })) +} + +fn get_response_attr_or_default(attrs: &[Attribute]) -> Result { + let mut attrs = attrs.iter().filter(|a| a.path.is_ident("response")); + + let Some(attr) = attrs.next() else { + return Ok(ResponseAttribute::default()); + }; + + if let Some(second_attr) = attrs.next() { + return Err( + darling::Error::custom("`response` attribute may be specified only once") + .with_span(second_attr), + ); + } + + let meta = attr.parse_meta()?; + ResponseAttribute::from_meta(&meta) +} diff --git a/rocket-okapi-codegen/src/responder_derive/response_attr.rs b/rocket-okapi-codegen/src/responder_derive/response_attr.rs new file mode 100644 index 00000000..0174b98d --- /dev/null +++ b/rocket-okapi-codegen/src/responder_derive/response_attr.rs @@ -0,0 +1,63 @@ +use darling::FromMeta; +use quote::ToTokens; + +/// This structure documents all the properties that can be used in +/// the `#[response]` attribute. For example: `#[response(status = 404)]` +#[derive(Default, FromMeta)] +#[darling(default)] +pub struct ResponseAttribute { + /// Status code of the response. By default, depends on the field type + pub status: Option, + + /// Content type of the response. By default, depends on the field type + pub content_type: Option, +} + +/// Derive macro wrapper for the Rocket's Status type. +/// Implements [`darling::FromMeta`] and [`quote::ToTokens`]. +pub struct Status(pub rocket_http::Status); + +impl Default for Status { + fn default() -> Self { + Self(rocket_http::Status::Ok) + } +} + +impl FromMeta for Status { + fn from_value(value: &syn::Lit) -> darling::Result { + let syn::Lit::Int(int) = value else { + return Err(darling::Error::unexpected_lit_type(value)); + }; + + let code = int.base10_parse::()?; + if code < 100 || code >= 600 { + return Err(darling::Error::custom("status must be in range [100, 599]").with_span(int)); + } + + Ok(Self(rocket_http::Status::new(code))) + } +} + +impl ToTokens for Status { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + self.0.code.to_tokens(tokens); + } +} + +/// Derive macro wrapper for the Rocket's ContentType type. +/// Implements [`darling::FromMeta`] and [`quote::ToTokens`]. +pub struct ContentType(pub rocket_http::ContentType); + +impl FromMeta for ContentType { + fn from_string(value: &str) -> darling::Result { + rocket_http::ContentType::parse_flexible(value) + .map(Self) + .ok_or_else(|| darling::Error::unsupported_format(value)) + } +} + +impl ToTokens for ContentType { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + self.0.to_string().to_tokens(tokens); + } +} diff --git a/rocket-okapi/src/util.rs b/rocket-okapi/src/util.rs index b19a8c9b..e6e3396f 100644 --- a/rocket-okapi/src/util.rs +++ b/rocket-okapi/src/util.rs @@ -123,6 +123,15 @@ pub fn set_content_type(responses: &mut Responses, content_type: impl ToString) Ok(()) } +/// Replaces the description for all responses. +pub fn set_description(responses: &mut Responses, description: impl ToString) -> Result<()> { + for ref mut resp_refor in responses.responses.values_mut() { + let response = ensure_not_ref(resp_refor)?; + response.description = description.to_string(); + } + Ok(()) +} + /// Adds a `Response` to a `Responses` object with the given status code, Content-Type and `SchemaObject`. pub fn add_schema_response( responses: &mut Responses,