utoipa_gen/openapi/
info.rs

1use std::borrow::Cow;
2use std::io;
3
4use proc_macro2::{Group, Ident, TokenStream as TokenStream2};
5use quote::{quote, ToTokens};
6use syn::parse::Parse;
7use syn::token::Comma;
8use syn::{parenthesized, Error, LitStr, Token};
9
10use crate::parse_utils;
11
12#[derive(Clone)]
13#[cfg_attr(feature = "debug", derive(Debug))]
14pub(super) enum Str {
15    String(String),
16    IncludeStr(TokenStream2),
17}
18
19impl Parse for Str {
20    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
21        if input.peek(LitStr) {
22            Ok(Self::String(input.parse::<LitStr>()?.value()))
23        } else {
24            let include_str = input.parse::<Ident>()?;
25            let bang = input.parse::<Option<Token![!]>>()?;
26            if include_str != "include_str" || bang.is_none() {
27                return Err(Error::new(
28                    include_str.span(),
29                    "unexpected token, expected either literal string or include_str!(...)",
30                ));
31            }
32            Ok(Self::IncludeStr(input.parse::<Group>()?.stream()))
33        }
34    }
35}
36
37impl ToTokens for Str {
38    fn to_tokens(&self, tokens: &mut TokenStream2) {
39        match self {
40            Self::String(str) => str.to_tokens(tokens),
41            Self::IncludeStr(include_str) => tokens.extend(quote! { include_str!(#include_str) }),
42        }
43    }
44}
45
46#[derive(Default, Clone)]
47#[cfg_attr(feature = "debug", derive(Debug))]
48pub(super) struct Info<'i> {
49    title: Option<String>,
50    version: Option<String>,
51    description: Option<Str>,
52    license: Option<License<'i>>,
53    contact: Option<Contact<'i>>,
54}
55
56impl Info<'_> {
57    /// Construct new [`Info`] from _`cargo`_ env variables such as
58    /// * `CARGO_PGK_NAME`
59    /// * `CARGO_PGK_VERSION`
60    /// * `CARGO_PGK_DESCRIPTION`
61    /// * `CARGO_PGK_AUTHORS`
62    /// * `CARGO_PGK_LICENSE`
63    fn from_env() -> Self {
64        let name = std::env::var("CARGO_PKG_NAME").ok();
65        let version = std::env::var("CARGO_PKG_VERSION").ok();
66        let description = std::env::var("CARGO_PKG_DESCRIPTION").ok().map(Str::String);
67        let contact = std::env::var("CARGO_PKG_AUTHORS")
68            .ok()
69            .and_then(|authors| Contact::try_from(authors).ok())
70            .and_then(|contact| {
71                if contact.name.is_none() && contact.email.is_none() && contact.url.is_none() {
72                    None
73                } else {
74                    Some(contact)
75                }
76            });
77        let license = std::env::var("CARGO_PKG_LICENSE").ok().map(License::from);
78
79        Info {
80            title: name,
81            version,
82            description,
83            contact,
84            license,
85        }
86    }
87}
88
89impl Parse for Info<'_> {
90    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
91        let mut info = Info::default();
92
93        while !input.is_empty() {
94            let ident = input.parse::<Ident>()?;
95            let attribute_name = &*ident.to_string();
96
97            match attribute_name {
98                "title" => {
99                    info.title =
100                        Some(parse_utils::parse_next(input, || input.parse::<LitStr>())?.value())
101                }
102                "version" => {
103                    info.version =
104                        Some(parse_utils::parse_next(input, || input.parse::<LitStr>())?.value())
105                }
106                "description" => {
107                    info.description =
108                        Some(parse_utils::parse_next(input, || input.parse::<Str>())?)
109                }
110                "license" => {
111                    let license_stream;
112                    parenthesized!(license_stream in input);
113                    info.license = Some(license_stream.parse()?)
114                }
115                "contact" => {
116                    let contact_stream;
117                    parenthesized!(contact_stream in input);
118                    info.contact = Some(contact_stream.parse()?)
119                }
120                _ => {
121                    return Err(Error::new(ident.span(), format!("unexpected attribute: {attribute_name}, expected one of: title, version, description, license, contact")));
122                }
123            }
124            if !input.is_empty() {
125                input.parse::<Comma>()?;
126            }
127        }
128
129        Ok(info)
130    }
131}
132
133impl ToTokens for Info<'_> {
134    fn to_tokens(&self, tokens: &mut TokenStream2) {
135        let title = self.title.as_ref().map(|title| quote! { .title(#title) });
136        let version = self
137            .version
138            .as_ref()
139            .map(|version| quote! { .version(#version) });
140        let description = self
141            .description
142            .as_ref()
143            .map(|description| quote! { .description(Some(#description)) });
144        let license = self
145            .license
146            .as_ref()
147            .map(|license| quote! { .license(Some(#license)) });
148        let contact = self
149            .contact
150            .as_ref()
151            .map(|contact| quote! { .contact(Some(#contact)) });
152
153        tokens.extend(quote! {
154            utoipa::openapi::InfoBuilder::new()
155                #title
156                #version
157                #description
158                #license
159                #contact
160        })
161    }
162}
163
164#[derive(Default, Clone)]
165#[cfg_attr(feature = "debug", derive(Debug))]
166pub(super) struct License<'l> {
167    name: Cow<'l, str>,
168    url: Option<Cow<'l, str>>,
169}
170
171impl Parse for License<'_> {
172    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
173        let mut license = License::default();
174
175        while !input.is_empty() {
176            let ident = input.parse::<Ident>()?;
177            let attribute_name = &*ident.to_string();
178
179            match attribute_name {
180                "name" => {
181                    license.name = Cow::Owned(
182                        parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
183                    )
184                }
185                "url" => {
186                    license.url = Some(Cow::Owned(
187                        parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
188                    ))
189                }
190                _ => {
191                    return Err(Error::new(
192                        ident.span(),
193                        format!(
194                            "unexpected attribute: {attribute_name}, expected one of: name, url"
195                        ),
196                    ));
197                }
198            }
199            if !input.is_empty() {
200                input.parse::<Comma>()?;
201            }
202        }
203
204        Ok(license)
205    }
206}
207
208impl ToTokens for License<'_> {
209    fn to_tokens(&self, tokens: &mut TokenStream2) {
210        let name = &self.name;
211        let url = self.url.as_ref().map(|url| quote! { .url(Some(#url))});
212
213        tokens.extend(quote! {
214            utoipa::openapi::info::LicenseBuilder::new()
215                .name(#name)
216                #url
217                .build()
218        })
219    }
220}
221
222impl From<String> for License<'_> {
223    fn from(string: String) -> Self {
224        License {
225            name: Cow::Owned(string),
226            ..Default::default()
227        }
228    }
229}
230
231#[derive(Default, Clone)]
232#[cfg_attr(feature = "debug", derive(Debug))]
233pub(super) struct Contact<'c> {
234    name: Option<Cow<'c, str>>,
235    email: Option<Cow<'c, str>>,
236    url: Option<Cow<'c, str>>,
237}
238
239impl Parse for Contact<'_> {
240    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
241        let mut contact = Contact::default();
242
243        while !input.is_empty() {
244            let ident = input.parse::<Ident>()?;
245            let attribute_name = &*ident.to_string();
246
247            match attribute_name {
248                "name" => {
249                    contact.name = Some(Cow::Owned(
250                        parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
251                    ))
252                }
253                "email" => {
254                    contact.email = Some(Cow::Owned(
255                        parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
256                    ))
257                }
258                "url" => {
259                    contact.url = Some(Cow::Owned(
260                        parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
261                    ))
262                }
263                _ => {
264                    return Err(Error::new(
265                        ident.span(),
266                        format!("unexpected attribute: {attribute_name}, expected one of: name, email, url"),
267                    ));
268                }
269            }
270            if !input.is_empty() {
271                input.parse::<Comma>()?;
272            }
273        }
274
275        Ok(contact)
276    }
277}
278
279impl ToTokens for Contact<'_> {
280    fn to_tokens(&self, tokens: &mut TokenStream2) {
281        let name = self.name.as_ref().map(|name| quote! { .name(Some(#name)) });
282        let email = self
283            .email
284            .as_ref()
285            .map(|email| quote! { .email(Some(#email)) });
286        let url = self.url.as_ref().map(|url| quote! { .url(Some(#url)) });
287
288        tokens.extend(quote! {
289            utoipa::openapi::info::ContactBuilder::new()
290                #name
291                #email
292                #url
293                .build()
294        })
295    }
296}
297
298impl TryFrom<String> for Contact<'_> {
299    type Error = io::Error;
300
301    fn try_from(value: String) -> Result<Self, Self::Error> {
302        if let Some((name, email)) = get_parsed_author(value.split(':').next()) {
303            let non_empty = |value: &str| -> Option<Cow<'static, str>> {
304                if !value.is_empty() {
305                    Some(Cow::Owned(value.to_string()))
306                } else {
307                    None
308                }
309            };
310            Ok(Contact {
311                name: non_empty(name),
312                email: non_empty(email),
313                ..Default::default()
314            })
315        } else {
316            Err(io::Error::new(
317                io::ErrorKind::Other,
318                format!("invalid contact: {value}"),
319            ))
320        }
321    }
322}
323
324pub(super) fn impl_info(parsed: Option<Info>) -> Info {
325    let mut info = Info::from_env();
326
327    if let Some(parsed) = parsed {
328        if parsed.title.is_some() {
329            info.title = parsed.title;
330        }
331
332        if parsed.description.is_some() {
333            info.description = parsed.description;
334        }
335
336        if parsed.license.is_some() {
337            info.license = parsed.license;
338        }
339
340        if parsed.contact.is_some() {
341            info.contact = parsed.contact;
342        }
343
344        if parsed.version.is_some() {
345            info.version = parsed.version;
346        }
347    }
348
349    info
350}
351
352fn get_parsed_author(author: Option<&str>) -> Option<(&str, &str)> {
353    author.map(|author| {
354        let mut author_iter = author.split('<');
355
356        let name = author_iter.next().unwrap_or_default();
357        let mut email = author_iter.next().unwrap_or_default();
358        if !email.is_empty() {
359            email = &email[..email.len() - 1];
360        }
361
362        (name.trim_end(), email)
363    })
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn parse_author_with_email_success() {
372        let author = "Tessu Tester <tessu@steps.com>";
373
374        if let Some((name, email)) = get_parsed_author(Some(author)) {
375            assert_eq!(
376                name, "Tessu Tester",
377                "expected name {} != {}",
378                "Tessu Tester", name
379            );
380            assert_eq!(
381                email, "tessu@steps.com",
382                "expected email {} != {}",
383                "tessu@steps.com", email
384            );
385        } else {
386            panic!("Expected Some(Tessu Tester, tessu@steps.com), but was none")
387        }
388    }
389
390    #[test]
391    fn parse_author_only_name() {
392        let author = "Tessu Tester";
393
394        if let Some((name, email)) = get_parsed_author(Some(author)) {
395            assert_eq!(
396                name, "Tessu Tester",
397                "expected name {} != {}",
398                "Tessu Tester", name
399            );
400            assert_eq!(email, "", "expected email {} != {}", "", email);
401        } else {
402            panic!("Expected Some(Tessu Tester, ), but was none")
403        }
404    }
405
406    #[test]
407    fn contact_from_only_name() {
408        let author = "Suzy Lin";
409        let contanct = Contact::try_from(author.to_string()).unwrap();
410
411        assert!(contanct.name.is_some(), "Suzy should have name");
412        assert!(contanct.email.is_none(), "Suzy should not have email");
413    }
414
415    #[test]
416    fn contact_from_name_and_email() {
417        let author = "Suzy Lin <suzy@lin.com>";
418        let contanct = Contact::try_from(author.to_string()).unwrap();
419
420        assert!(contanct.name.is_some(), "Suzy should have name");
421        assert!(contanct.email.is_some(), "Suzy should have email");
422    }
423
424    #[test]
425    fn contact_from_empty() {
426        let author = "";
427        let contact = Contact::try_from(author.to_string()).unwrap();
428
429        assert!(contact.name.is_none(), "Contat name should be empty");
430        assert!(contact.email.is_none(), "Contat email should be empty");
431    }
432}