Skip to content

Commit

Permalink
Add derive tags to openapi (#43)
Browse files Browse the repository at this point in the history
* Add derive tags and external docs to OpenApi trait with external docs
* Add unit test
* Add documentation
* Polish few parse operations and improve error messages
  • Loading branch information
juhaku authored Mar 15, 2022
1 parent 8428224 commit cc3e794
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 54 deletions.
12 changes: 12 additions & 0 deletions src/openapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ impl OpenApi {
self
}

/// Add list of [`Tag`]s to [`OpenApi`].
///
/// This operation consumes self and is expected to be chained after [`OpenApi::new`].
/// It accepts one argument with anything that implements [`IntoIterator`] for [`Tag`].
///
/// Method returns self for chaining more operations.
pub fn with_tags<I: IntoIterator<Item = Tag>>(mut self, tags: I) -> Self {
self.tags = Some(tags.into_iter().collect());

self
}

#[cfg(feature = "serde_json")]
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
Expand Down
5 changes: 5 additions & 0 deletions src/openapi/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize};

use super::external_docs::ExternalDocs;

/// Implements [OpenAPI Tag Object][tag].
///
/// Tag can be used to provide additional metadata for tags used by path operations.
///
/// [tag]: https://spec.openapis.org/oas/latest.html#tag-object
#[non_exhaustive]
#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
Expand Down
24 changes: 24 additions & 0 deletions tests/openapi_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,27 @@ fn derive_openapi_with_security_requirement() {
"security.[2].token_jwt" = "[]", "jwt_token auth scopes"
}
}

#[test]
fn derive_openapi_tags() {
#[derive(OpenApi)]
#[openapi(tags(
(name = "random::api", description = "this is random api description"),
(name = "pets::api", description = "api all about pets", external_docs(
url = "http://localhost", description = "Find more about pets")
)
))]
struct ApiDoc;

let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap();

assert_value! {doc=>
"tags.[0].name" = r###""random::api""###, "Tags random_api name"
"tags.[0].description" = r###""this is random api description""###, "Tags random_api description"
"tags.[0].externalDocs" = r###"null"###, "Tags random_api external docs"
"tags.[1].name" = r###""pets::api""###, "Tags pets_api name"
"tags.[1].description" = r###""api all about pets""###, "Tags pets_api description"
"tags.[1].externalDocs.url" = r###""http://localhost""###, "Tags pets_api external docs url"
"tags.[1].externalDocs.description" = r###""Find more about pets""###, "Tags pets_api external docs description"
}
}
98 changes: 88 additions & 10 deletions utoipa-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,10 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
/// See the [trait documentation][modify] for more details.
/// * **security** List of [`SecurityRequirement`][security]s global to all operations.
/// See more details in [`#[utoipa::path(...)]`][path] [attribute macro security options][path_security].
/// * **tags** List of [`Tag`][tags] which must match the tag _**path operation**_. By default
/// the tag is derived from path given to **handlers** list or if undefined then `crate` is used by default.
/// Alternatively the tag name can be given to path operation via [`#[utoipa::path(...)]`][path] macro.
/// Tag can be used to define extra information for the api to produce richer documentation.
///
/// OpenApi derive macro will also derive [`Info`][info] for OpenApi specification using Cargo
/// environment variables.
Expand All @@ -508,7 +512,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
/// * env `CARGO_PKG_VERSION` map to info `version`
/// * env `CARGO_PKG_DESCRIPTION` map info `description`
/// * env `CARGO_PKG_AUTHORS` map to contact `name` and `email` **only first author will be used**
/// * env `CARGO_PKG_LICENSE` map to info `licence`
/// * env `CARGO_PKG_LICENSE` map to info `license`
///
/// # Examples
///
Expand Down Expand Up @@ -548,7 +552,11 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
/// (),
/// ("my_auth" = ["read:items", "edit:items"]),
/// ("token_jwt" = [])
/// ]
/// ],
/// tags(
/// (name = "pets::api", description = "All about pets",
/// external_docs(url = "http://more.about.pets.api", description = "Find out more"))
/// )
/// )]
/// struct ApiDoc;
/// ```
Expand All @@ -560,6 +568,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
/// [info]: openapi/info/struct.Info.html
/// [security]: openapi/security/struct.SecurityRequirement.html
/// [path_security]: attr.path.html#security-requirement-attributes
/// [tags]: openapi/tag/struct.Tag.html
pub fn openapi(input: TokenStream) -> TokenStream {
let DeriveInput { attrs, ident, .. } = syn::parse_macro_input!(input);

Expand All @@ -577,6 +586,7 @@ pub fn openapi(input: TokenStream) -> TokenStream {

/// Tokenizes slice or Vec of tokenizable items as array either with reference (`&[...]`)
/// or without correctly to OpenAPI JSON.
#[cfg_attr(feature = "debug", derive(Debug))]
enum Array<T>
where
T: Sized + ToTokens,
Expand Down Expand Up @@ -751,6 +761,60 @@ impl Parse for Type {
}
}

#[derive(Default)]
#[cfg_attr(feature = "debug", derive(Debug))]
struct ExternalDocs {
url: String,
description: Option<String>,
}

impl Parse for ExternalDocs {
fn parse(input: ParseStream) -> syn::Result<Self> {
const EXPECTED_ATTRIBUTE: &str =
"unexpected attribute, expected one of: url or description";

let mut external_docs = ExternalDocs::default();

while !input.is_empty() {
let ident = input.parse::<Ident>().map_err(|error| {
syn::Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
})?;
let attribute_name = &*ident.to_string();

match attribute_name {
"url" => {
external_docs.url = parse_utils::parse_next_literal_str(input)?;
}
"description" => {
external_docs.description = Some(parse_utils::parse_next_literal_str(input)?);
}
_ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)),
}

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

Ok(external_docs)
}
}

impl ToTokens for ExternalDocs {
fn to_tokens(&self, tokens: &mut TokenStream2) {
let url = &self.url;
tokens.extend(quote! {
utoipa::openapi::external_docs::ExternalDocs::new(#url)
});

if let Some(ref description) = self.description {
tokens.extend(quote! {
.with_description(#description)
});
}
}
}

#[cfg_attr(feature = "debug", derive(Debug))]
enum Example {
String(TokenStream2),
Expand All @@ -773,23 +837,37 @@ mod parse_utils {
use proc_macro2::{Group, Ident, TokenStream};
use proc_macro_error::{abort, ResultExt};
use quote::ToTokens;
use syn::{parse::ParseStream, Error, LitBool, LitStr, Token};
use syn::{
parse::{Parse, ParseStream},
punctuated::Punctuated,
token::Comma,
Error, LitBool, LitStr, Token,
};

use crate::Example;

pub fn parse_next<T: Sized>(input: ParseStream, next: impl FnOnce() -> T) -> T {
input
.parse::<Token![=]>()
.expect_or_abort("expected equals token (=) before value assigment");
.expect_or_abort("expected equals token before value assigment");
next()
}

pub fn parse_next_lit_str(input: ParseStream, error_message: &str) -> String {
parse_next(input, || {
input
.parse::<LitStr>()
.expect_or_abort(error_message)
.value()
pub fn parse_next_literal_str(input: ParseStream) -> Result<String, Error> {
Ok(parse_next(input, || input.parse::<LitStr>())?.value())
}

pub fn parse_group<T, R>(input: ParseStream) -> Result<R, Error>
where
T: Sized,
T: Parse,
R: FromIterator<T>,
{
Punctuated::<Group, Comma>::parse_terminated(input).and_then(|groups| {
groups
.into_iter()
.map(|group| syn::parse2::<T>(group.stream()))
.collect::<Result<R, Error>>()
})
}

Expand Down
110 changes: 92 additions & 18 deletions utoipa-gen/src/openapi.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use proc_macro2::Ident;
use proc_macro_error::ResultExt;
use syn::{
bracketed,
bracketed, parenthesized,
parse::{Parse, ParseStream},
punctuated::Punctuated,
token::{And, Comma},
Expand All @@ -14,6 +14,7 @@ use quote::{format_ident, quote, quote_spanned, ToTokens};
use crate::{
parse_utils,
security_requirement::{self, SecurityRequirementAttr},
Array, ExternalDocs,
};

mod info;
Expand All @@ -27,6 +28,7 @@ pub struct OpenApiAttr {
components: Vec<Component>,
modifiers: Punctuated<Modifier, Comma>,
security: Option<Vec<SecurityRequirementAttr>>,
tags: Option<Array<Tag>>,
}

#[cfg_attr(feature = "debug", derive(Debug))]
Expand All @@ -35,6 +37,15 @@ struct Component {
generics: Generics,
}

impl Component {
fn has_lifetime_generics(&self) -> bool {
self.generics
.params
.iter()
.any(|generic| matches!(generic, GenericParam::Lifetime(_)))
}
}

#[cfg_attr(feature = "debug", derive(Debug))]
struct Modifier {
and: And,
Expand All @@ -60,12 +71,67 @@ impl Parse for Modifier {
}
}

impl Component {
fn has_lifetime_generics(&self) -> bool {
self.generics
.params
.iter()
.any(|generic| matches!(generic, GenericParam::Lifetime(_)))
#[derive(Default)]
#[cfg_attr(feature = "debug", derive(Debug))]
struct Tag {
name: String,
description: Option<String>,
external_docs: Option<ExternalDocs>,
}

impl Parse for Tag {
fn parse(input: ParseStream) -> syn::Result<Self> {
const EXPECTED_ATTRIBUTE: &str =
"unexpected token, expected one of: name, description or external_docs";

let mut tag = Tag::default();

while !input.is_empty() {
let ident = input.parse::<Ident>().map_err(|error| {
syn::Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
})?;
let attribute_name = &*ident.to_string();

match attribute_name {
"name" => tag.name = parse_utils::parse_next_literal_str(input)?,
"description" => {
tag.description = Some(parse_utils::parse_next_literal_str(input)?)
}
"external_docs" => {
let content;
parenthesized!(content in input);
tag.external_docs = Some(content.parse::<ExternalDocs>()?);
}
_ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)),
}

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

Ok(tag)
}
}

impl ToTokens for Tag {
fn to_tokens(&self, tokens: &mut TokenStream) {
let name = &self.name;
tokens.extend(quote! {
utoipa::openapi::tag::Tag::new(#name)
});

if let Some(ref description) = self.description {
tokens.extend(quote! {
.with_description(#description)
});
}

if let Some(ref external_docs) = self.external_docs {
tokens.extend(quote! {
.with_external_docs(#external_docs)
});
}
}
}

Expand All @@ -78,12 +144,14 @@ pub fn parse_openapi_attributes_from_attributes(attrs: &[Attribute]) -> Option<O

impl Parse for OpenApiAttr {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
const EXPECTED_ATTRIBUTE: &str =
"unexpected attribute, expected one of: handlers, components, modifiers, security or tags";
let mut openapi = OpenApiAttr::default();

loop {
let ident = input
.parse::<Ident>()
.expect_or_abort("unaparseable OpenApi, expected Ident");
let ident = input.parse::<Ident>().map_err(|error| {
Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
})?;
let attribute = &*ident.to_string();

match attribute {
Expand All @@ -103,14 +171,13 @@ impl Parse for OpenApiAttr {
.collect::<Vec<_>>(),
)
}
"tags" => {
let tags;
parenthesized!(tags in input);
openapi.tags = Some(parse_utils::parse_group::<Tag, Array<Tag>>(&tags)?);
}
_ => {
return Err(Error::new(
ident.span(),
format!(
"unexpected attribute: {}, expected: handlers, components, modifiers, security",
ident
),
));
return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE));
}
}

Expand Down Expand Up @@ -245,13 +312,20 @@ impl ToTokens for OpenApi {
None
};

let tags = self.0.tags.as_ref().map(|tags| {
quote! {
.with_tags(#tags)
}
});

tokens.extend(quote! {
impl utoipa::OpenApi for #ident {
fn openapi() -> utoipa::openapi::OpenApi {
use utoipa::{Component, Path};
let mut openapi = utoipa::openapi::OpenApi::new(#info, #path_items)
.with_components(#components)
#securities;
#securities
#tags;

let _mods: [&dyn utoipa::Modify; #modifiers_len] = [#modifiers];
_mods.iter().for_each(|modifier| modifier.modify(&mut openapi));
Expand Down
Loading

0 comments on commit cc3e794

Please sign in to comment.