utoipa_gen/openapi/
info.rs

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