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 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}