api_impl/
lib.rs

1//! Generate a modern ergonomic GitHub REST API.
2
3#![allow(clippy::tabs_in_doc_comments)]
4#![deny(missing_docs)]
5
6// proc-macro
7use proc_macro::TokenStream;
8// crates.io
9use convert_case::{Case, Casing};
10use syn::{
11	parse::{Parse, ParseStream},
12	punctuated::Punctuated,
13	*,
14};
15
16#[derive(Debug)]
17enum ApiProperty {
18	Category(String),
19	Method(String),
20	Accept(String),
21	Uri(String),
22}
23impl Parse for ApiProperty {
24	fn parse(input: ParseStream) -> Result<Self> {
25		let name = input.parse::<Ident>()?.to_string();
26		let value = if input.peek(Token![=]) {
27			input.parse::<Token![=]>()?;
28
29			if input.peek(LitStr) {
30				input.parse::<LitStr>()?.value()
31			} else {
32				panic!("expect a `LitStr` here");
33			}
34		} else {
35			panic!("expect a `Token![=]` here")
36		};
37
38		Ok(match name.as_str() {
39			"category" => ApiProperty::Category(value),
40			"method" => ApiProperty::Method(value),
41			"accept" => ApiProperty::Accept(value),
42			"uri" => ApiProperty::Uri(value),
43			property => panic!(
44				"expect one of the [\"category\", \"method\", \"accept\", \"uri\"] but found {property:?}"
45			),
46		})
47	}
48}
49
50/// Generate a modern ergonomic GitHub REST API.
51///
52/// # Example
53/// ```ignore
54/// use githuber::prelude::*;
55///
56/// #[api_impl::api]
57/// #[properties(
58/// 	category = "repos",
59/// 	method = "GET",
60/// 	accept = "application/vnd.github+json",
61/// 	uri = "/orgs/{}/repos"
62/// )]
63/// pub struct ListOrganizationRepositories<'a> {
64/// 	#[path_param]
65/// 	pub org: &'a str,
66/// 	pub r#type: Option<&'a str>,
67/// 	pub sort: Option<&'a str>,
68/// 	pub direction: Option<&'a str>,
69/// 	pub per_page: Option<u8>,
70/// 	pub page: Option<u16>,
71/// }
72///
73/// #[api_impl::api]
74/// #[properties(
75/// 	category = "repos",
76/// 	method = "POST",
77/// 	accept = "application/vnd.github+json",
78/// 	uri = "/orgs/{}/repos"
79/// )]
80/// pub struct CreateAnOrganizationRepository<'a> {
81/// 	#[path_param]
82/// 	pub org: &'a str,
83/// 	#[payload_ess_param]
84/// 	pub name: &'a str,
85/// 	pub description: Option<&'a str>,
86/// 	pub homepage: Option<&'a str>,
87/// 	pub private: Option<bool>,
88/// 	pub visibility: Option<&'a str>,
89/// 	pub has_issues: Option<bool>,
90/// 	pub has_projects: Option<bool>,
91/// 	pub has_wiki: Option<bool>,
92/// 	pub has_downloads: Option<bool>,
93/// 	pub is_template: Option<bool>,
94/// 	pub team_id: Option<u8>,
95/// 	pub auto_init: Option<bool>,
96/// 	pub gitignore_template: Option<&'a str>,
97/// 	pub license_template: Option<&'a str>,
98/// 	pub allow_squash_merge: Option<bool>,
99/// 	pub allow_rebase_merge: Option<bool>,
100/// 	pub allow_auto_merge: Option<bool>,
101/// 	pub delete_branch_on_merge: Option<bool>,
102/// 	pub use_squash_pr_title_as_default: Option<bool>,
103/// 	pub squash_merge_commit_title: Option<&'a str>,
104/// 	pub squash_merge_commit_message: Option<&'a str>,
105/// 	pub merge_commit_title: Option<&'a str>,
106/// 	pub merge_commit_message: Option<&'a str>,
107/// }
108/// ```
109#[proc_macro_attribute]
110pub fn api(_: TokenStream, input: TokenStream) -> TokenStream {
111	let api_struct = syn::parse_macro_input!(input as ItemStruct);
112
113	// #[cfg(feature = "debug")]
114	// dbg!(&api_struct);
115	// #[cfg(feature = "debug")]
116	// dbg!(&api_struct.fields);
117
118	let api_attrs = api_struct.attrs;
119
120	// #[cfg(feature = "debug")]
121	// dbg!(&api_attrs);
122
123	let api_name = api_struct.ident;
124	let mut api_doc = String::new();
125	let mut api_method = String::new();
126	let mut api_accept = String::new();
127	let mut api_uri = String::new();
128
129	api_attrs
130		.into_iter()
131		.filter(|attr| attr.path.is_ident("properties"))
132		.flat_map(|attr| {
133			attr.parse_args_with(Punctuated::<ApiProperty, Token![,]>::parse_terminated)
134				.unwrap()
135				.into_iter()
136		})
137		.for_each(|property| match property {
138			ApiProperty::Category(category) =>
139				api_doc = format!(
140					" - <https://docs.github.com/en/rest/{category}/{category}#{}>",
141					api_name.to_string().to_case(Case::Kebab)
142				),
143			ApiProperty::Method(method) => api_method = method,
144			ApiProperty::Accept(accept) => api_accept = accept,
145			ApiProperty::Uri(uri) => api_uri = format!("{{}}{uri}"),
146		});
147
148	let api_vis = api_struct.vis;
149	let api_generics = api_struct.generics;
150	let mut api_path_params = Vec::new();
151	let mut api_path_params_tys = Vec::new();
152	let mut api_payload_ess_params = Vec::new();
153	let mut api_payload_ess_params_tys = Vec::new();
154	let mut api_payload_opt_params = Vec::new();
155	let mut api_payload_opt_params_tys = Vec::new();
156
157	{
158		let Fields::Named(fields) = api_struct.fields else {
159			panic!("expect a `Fields::Named` here");
160		};
161
162		fields.named.into_iter().for_each(|field| {
163			if field.attrs.is_empty() {
164				let Type::Path(path) = field.ty else { panic!("expect a `Path` here"); };
165
166				if &path.path.segments[0].ident.to_string() == "Option" {
167					api_payload_opt_params.push(field.ident);
168
169					let PathArguments::AngleBracketed(args) = &path.path.segments[0].arguments else {
170						panic!("expect a `PathArguments::AngleBracketed` here");
171					};
172					let GenericArgument::Type(ty) = &args.args[0] else { panic!("expect a `GenericArgument::Type` here"); };
173
174					api_payload_opt_params_tys.push(ty.to_owned());
175				} else {
176					panic!("expect an `Option` here");
177				}
178			} else {
179				match field.attrs[0].path.get_ident().unwrap().to_string().as_str() {
180					"path_param" => {
181						api_path_params.push(field.ident);
182						api_path_params_tys.push(field.ty);
183					},
184					"payload_ess_param" => {
185						api_payload_ess_params.push(field.ident);
186						api_payload_ess_params_tys.push(field.ty);
187					},
188					ident => panic!(
189						"expect one of the [\"path_param\", \"payload_ess_param\"] but found {ident:?}"
190					),
191				}
192			}
193		});
194	}
195
196	let api_method = quote::format_ident!("{}", api_method.to_case(Case::Pascal));
197	let get_names = |params: &[Option<Ident>]| {
198		params
199			.iter()
200			.map(|field| {
201				field.as_ref().map(|field| field.to_string().trim_start_matches("r#").to_owned())
202			})
203			.collect::<Vec<_>>()
204	};
205	let api_payload_ess_params_names = get_names(&api_payload_ess_params);
206	let api_payload_opt_params_names = get_names(&api_payload_opt_params);
207	let api_name_snake_case = quote::format_ident!("{}", api_name.to_string().to_case(Case::Snake));
208
209	quote::quote! {
210		/// GitHub reference(s):
211		#[doc = #api_doc]
212		#[derive(Debug, Clone, PartialEq, Eq)]
213		#api_vis struct #api_name #api_generics {
214			#(
215				#[allow(missing_docs)]
216				#api_vis #api_path_params: #api_path_params_tys,
217			)*
218			#(
219				#[allow(missing_docs)]
220				#api_vis #api_payload_ess_params: #api_payload_ess_params_tys,
221			)*
222			#(
223				#[allow(missing_docs)]
224				#api_vis #api_payload_opt_params: Option<#api_payload_opt_params_tys>,
225			)*
226		}
227		impl #api_generics #api_name #api_generics {
228			#[doc = concat!(
229				"Build a [`",
230				stringify!(#api_name),
231				"`] instance."
232			)]
233			#api_vis fn new(
234				#(#api_path_params: #api_path_params_tys,)*
235				#(#api_payload_ess_params: #api_payload_ess_params_tys,)*
236			) -> Self {
237				Self {
238					#(#api_path_params,)*
239					#(#api_payload_ess_params,)*
240					#(#api_payload_opt_params: None,)*
241				}
242			}
243
244			#(
245				#[doc = concat!(
246					"Set a new [`",
247					stringify!(#api_path_params),
248					"`](",
249					stringify!(#api_name),
250					"#structfield.",
251					stringify!(#api_path_params),
252					")."
253				)]
254				#api_vis fn #api_path_params(
255					mut self,
256					#api_path_params: #api_path_params_tys
257				) -> Self {
258					self.#api_path_params = #api_path_params;
259
260					self
261				}
262			)*
263			#(
264				#[doc = concat!(
265					"Set a new [`",
266					stringify!(#api_payload_ess_params),
267					"`](",
268					stringify!(#api_name),
269					"#structfield.",
270					stringify!(#api_payload_ess_params),
271					")."
272				)]
273				#api_vis fn #api_payload_ess_params(
274					mut self,
275					#api_payload_ess_params: #api_payload_ess_params_tys
276				) -> Self {
277					self.#api_payload_ess_params = #api_payload_ess_params;
278
279					self
280				}
281			)*
282			#(
283				#[doc = concat!(
284					"Set a new [`",
285					stringify!(#api_payload_opt_params),
286					"`](",
287					stringify!(#api_name),
288					"#structfield.",
289					stringify!(#api_payload_opt_params),
290					")."
291				)]
292				#api_vis fn #api_payload_opt_params(
293					mut self,
294					#api_payload_opt_params: #api_payload_opt_params_tys
295				) -> Self {
296					self.#api_payload_opt_params = Some(#api_payload_opt_params);
297
298					self
299				}
300			)*
301		}
302		impl #api_generics Api for #api_name #api_generics {
303			const ACCEPT: &'static str = #api_accept;
304
305			fn api(&self) -> String {
306				format!(
307					#api_uri,
308					Self::BASE_URI,
309					#(self.#api_path_params,)*
310				)
311			}
312		}
313		impl #api_generics ApiExt for #api_name #api_generics {
314			const METHOD: Method = Method::#api_method;
315
316			fn payload_params(&self) -> Vec<(&'static str, String)> {
317				let mut payload_params = vec![
318					#((
319						#api_payload_ess_params_names,
320						self.#api_payload_ess_params.to_string()
321					),)*
322				];
323
324				#(
325					if let Some(#api_payload_opt_params) = self.#api_payload_opt_params {
326						payload_params.push((
327							#api_payload_opt_params_names,
328							#api_payload_opt_params.to_string()
329						));
330					}
331				)*
332
333				payload_params
334			}
335		}
336		#[doc = concat!(
337			"Build a [`",
338			stringify!(#api_name),
339			"`] instance."
340		)]
341		#api_vis fn #api_name_snake_case #api_generics(
342			#(#api_path_params: #api_path_params_tys,)*
343			#(#api_payload_ess_params: #api_payload_ess_params_tys,)*
344		) -> #api_name #api_generics {
345			#api_name::new(
346				#(#api_path_params,)*
347				#(#api_payload_ess_params,)*
348			)
349		}
350	}
351	.into()
352}