diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f9349f9..389ce79 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,5 +14,6 @@ restson = "1.5.0" rpassword = "7.4.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +spond-macros = { version = "0.1.0", path = "../macros" } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } url = "2.5.8" diff --git a/cli/src/api/mod.rs b/cli/src/api/mod.rs index 34d9acb..be81c07 100644 --- a/cli/src/api/mod.rs +++ b/cli/src/api/mod.rs @@ -1,32 +1,34 @@ //use bon::Builder; -//use chrono::{DateTime,Utc}; +use chrono::{DateTime,Utc}; +use serde::{Serialize, Deserialize}; //use restson::{RestClient, RestPath, Error}; // -//pub enum Order { -// Ascending, -// Descending, -//} -// -//impl AsRef for Order { -// fn as_ref(&self) -> &str { -// match self { -// Self::Ascending => "asc", -// Self::Descending => "desc", -// } -// } -//} -// -//impl From for Order { -// fn from(ascending: bool) -> Self { -// if ascending { -// Self::Ascending -// } else { -// Self::Descending -// } -// } -//} -// -#[derive(serde::Deserialize)] +#[derive(Serialize)] +pub enum Order { + Ascending, + Descending, +} + +impl AsRef for Order { + fn as_ref(&self) -> &str { + match self { + Self::Ascending => "asc", + Self::Descending => "desc", + } + } +} + +impl From for Order { + fn from(ascending: bool) -> Self { + if ascending { + Self::Ascending + } else { + Self::Descending + } + } +} + +#[derive(Debug, Deserialize)] pub struct Spond { id: String, @@ -46,29 +48,42 @@ pub struct Spond { // min_end_timestamp=Option>, // max_end_timestamp=Option>, //) -> Get<(), Vec> { -// -//} -// -//struct Sponds(Query); -// -//async pub fn sponds( -// #[builder(finish_fn)] -// client: &restson::RestClient, E -// -// -crate::get!(search( +//crate::get!(sponds( +// comments: bool, +// hidden: bool, +// add_profile_info: bool, +// scheduled, +// #[builder(into, default=Order::Ascending)] order: Order, +// #[builder(default=20)]max: usize, +// min_end_timestamp: Option>, +// max_end_timestamp: Option>, +// ), +// () => "sponds" -> Vec); + +#[spond_macros::endpoint((id:u128):"/spond/{id:032X}/info", (eid:u128, uid:u128): "/spond/{eid:032X}/response/{uid:032X}")] +pub async fn sponds(result: serde_json::Value, + #[query] comments: Option, + #[query] hidden: Option, + #[body] min_end_timestamp: Option>, +) -> serde_json::Value { + result +} + + +/* +crate::post!(post( comments: bool, hidden: bool, add_profile_info: bool, scheduled: bool, #[builder(into)] order: Order, #[builder(default=20)]max: usize, - ), + ) min_end_timestamp: Option>, max_end_timestamp: Option>, () => "sponds" -> Vec); - +*/ //impl Search { // with_comments( //#[bon::builder] diff --git a/cli/src/main.rs b/cli/src/main.rs index 452bc17..f964c60 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -45,17 +45,29 @@ async fn main() -> Result<()> { let client = Cli::parse().client().await?; // https://api.spond.com/core/v1/sponds?includeComments=true&includeHidden=false&addProfileInfo=true&scheduled=true&order=asc&max=20&prevId=F94829E35A9B4A48A042646C8B658B01&minStartTimestamp=2026-04-11T09:45:00Z&minEndTimestamp=2026-02-26T23:00:00.001Z - let query = [ - ("includeComments", "true"), - ("includeHidden", "false"), - ("addProfileInfo", "true"), - ("scheduled", "true"), - ("order", "asc"), - ("max", "20"), - ]; - - for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 { - println!("{spond:?}"); + if false { + let query = [ + ("includeComments", "true"), + ("includeHidden", "false"), + ("addProfileInfo", "true"), + ("scheduled", "true"), + ("order", "asc"), + ("max", "20"), + ]; + + for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 { + println!("{spond:?}"); + } + } else { + let request = api::sponds() + .comments(true) + .hidden(false) + .add_profile_info(false) + .scheduled(true) + ; + for spond in request.call(&client).await? { + println!("{spond:?}"); + } } Ok(()) diff --git a/cli/src/request/get.rs b/cli/src/request/get.rs index 0c9ff3b..4817767 100644 --- a/cli/src/request/get.rs +++ b/cli/src/request/get.rs @@ -3,7 +3,7 @@ macro_rules! get { // Case 1: no query ( $name:ident, - ( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)? + ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty ) => { get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out); }; @@ -11,7 +11,7 @@ macro_rules! get { // Case 2: empty query () ( $name:ident (), - ( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)? + ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty ) => { #[bon::builder] pub fn $name( @@ -35,42 +35,30 @@ macro_rules! get { } }; - // Case 3: query with flags / mixed types + // Case 3: query with optional attributes ( - $name:ident ( $( $query:tt ),* $(,)? ), - ( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)? + $name:ident ( $( $(#[$attr:meta])* $query_ident:ident $(: $query_ty:ty )? ),* $(,)? ), + ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty ) => { #[bon::builder] pub fn $name( #[builder(finish_fn)] client: &restson::RestClient, $( #[builder(finish_fn)] $arg: $arg_ty, )* $( - get!(@query_field $query) + $(#[$attr])* $query_ident : $crate::get!( @query_type $( $query_ty )? ) , )* ) -> impl std::future::Future> + '_ { - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct Query<'a> { - $( - get!(@query_field_struct $query) - )* - } - impl Query<'_> { - fn as_pairs(&self) -> Vec<(&str, String)> { - let mut out = Vec::new(); - $( - get!(@push_pair out, self, $query) - )* - out - } - } - - let query = Query { + // Build Vec<(String,String)> dynamically using serde_json::to_string + let query_pairs: Vec<(String, String)> = vec![ $( - get!(@query_field_init $query) - )* - }; + { + let val = serde_json::to_string(&$query_ident) + .expect("failed to serialize query param"); + (stringify!($query_ident).to_string(), val) + } + ),* + ]; #[derive(serde::Deserialize)] struct RP($out); @@ -83,28 +71,18 @@ macro_rules! get { } async move { - let result = client.get_with::<_, RP>(( $( $arg ),* ), &query.as_pairs()).await?; + // Convert Vec<(String,String)> to Vec<(&str,&str)> + let ref_pairs: Vec<(&str, &str)> = query_pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let result = client.get_with::<_, RP>(( $( $arg ),* ), &ref_pairs).await?; Ok(result.into_inner().0) } } }; - // Query helpers - (@query_field $field:ident) => { $field: Option, }; - (@query_field $field:ident = $ty:ty) => { $field: $ty, }; - - (@query_field_struct $field:ident) => { $field: Option, }; - (@query_field_struct $field:ident = $ty:ty) => { $field: $ty, }; - - (@query_field_init $field:ident) => { $field, }; - (@query_field_init $field:ident = $ty:ty) => { $field, }; - - (@push_pair $vec:ident, $self:ident, $field:ident = $ty:ty) => { - $vec.push((stringify!($field), $self.$field.to_string())); - }; - (@push_pair $vec:ident, $self:ident, $field:ident) => { - if let Some(v) = &$self.$field { - $vec.push((stringify!($field), v.to_string())); - } - }; + ( @query_type ) => { Option }; + ( @query_type $ty:ty) => { $ty }; } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4b489f0..93fe0cb 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,109 +1,132 @@ use proc_macro::TokenStream; -use quote::{quote, ToTokens}; +use quote::quote; use syn::{ - parse_macro_input, Attribute, FnArg, ItemFn, Lit, Meta, Pat, PatType, Type, spanned::Spanned, + parse_macro_input, FnArg, ItemFn, LitStr, Pat, PatType, Type, Attribute, spanned::Spanned, }; -/// Endpoint macro +/// Path args parser for `#[endpoint((x:u128,y:String): "/path/{x}/{y}")]` +struct EndpointPath { + args: Vec<(syn::Ident, syn::Type)>, + path: LitStr, +} + +impl syn::parse::Parse for EndpointPath { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::parenthesized!(content in input); + + let mut args = Vec::new(); + while !content.is_empty() { + let ident: syn::Ident = content.parse()?; + content.parse::()?; + let ty: syn::Type = content.parse()?; + args.push((ident, ty)); + + if content.peek(syn::Token![,]) { + content.parse::()?; + } + } + + input.parse::()?; + let path: LitStr = input.parse()?; + + Ok(EndpointPath { args, path }) + } +} + #[proc_macro_attribute] pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream { - // parse the endpoint attribute - let attr = proc_macro2::TokenStream::from(attr); + let path_args = parse_macro_input!(attr as EndpointPath); let func = parse_macro_input!(item as ItemFn); let vis = &func.vis; let name = &func.sig.ident; - let inputs = &func.sig.inputs; let output = &func.sig.output; - // must be async - if func.sig.asyncness.is_none() { - return syn::Error::new(name.span(), "endpoint function must be async") - .to_compile_error() - .into(); - } + // Separate query/body args + let mut query_fields = Vec::new(); + let mut body_fields = Vec::new(); - // defaults - let mut method = quote! { GET }; - let mut path = None; + for arg in &func.sig.inputs { + if let FnArg::Typed(PatType { pat, ty, attrs, .. }) = arg { + let pat_ident = match &**pat { + Pat::Ident(pi) => &pi.ident, + _ => continue, + }; - // parse #[endpoint(...)] - if !attr.is_empty() { - let attr_str = attr.to_string(); - // simple heuristic: if contains "POST", switch - if attr_str.contains("POST") { - method = quote! { POST }; - } - // simple heuristic: extract path in quotes - if let Some(start) = attr_str.find('"') { - if let Some(end) = attr_str[start+1..].find('"') { - path = Some(attr_str[start+1..start+1+end].to_string()); + if attrs.iter().any(|a| a.path().is_ident("query")) { + query_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone())); + } else if attrs.iter().any(|a| a.path().is_ident("body")) { + body_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone())); } } } - let path = match path { - Some(p) => p, - None => return syn::Error::new(name.span(), "endpoint path must be provided") - .to_compile_error() - .into(), - }; + // Path args + let path_idents: Vec<_> = path_args.args.iter().map(|(i, _)| i).collect(); + let path_types: Vec<_> = path_args.args.iter().map(|(_, t)| t).collect(); + let path_fmt = &path_args.path; - // process arguments - let mut client_arg = None; - let mut other_args = Vec::new(); - - for input in inputs { - match input { - FnArg::Receiver(_) => continue, // skip self - FnArg::Typed(PatType { pat, ty, .. }) => { - if let Pat::Ident(ident) = &**pat { - if ident.ident == "client" { - client_arg = Some((ident.ident.clone(), ty)); - } else { - other_args.push((ident.ident.clone(), ty)); - } - } + // Build query serialization + let query_pairs = query_fields.iter().map(|(ident, _, _)| { + quote! { + if let Some(v) = &#ident { + query_pairs.push((stringify!(#ident), v.to_string())); } } - } + }); - // generate tokens for function with builder - let arg_defs: Vec = other_args.iter().map(|(id, ty)| { - // wrap Option with #[builder(default)] - if let Type::Path(tp) = ty.as_ref() { - let is_option = tp.path.segments.last().map(|seg| seg.ident == "Option").unwrap_or(false); - if is_option { - quote! { #[builder(default)] #id: #ty } + // Build body serialization + let body_pairs = body_fields.iter().map(|(ident, _, _)| { + quote! { + body_map.insert(stringify!(#ident).to_string(), serde_json::to_value(&#ident)?); + } + }); + + // Determine method + let method = if body_fields.is_empty() { quote! { GET } } else { quote! { POST } }; + + // Expand query/body fields for function signature + let query_sig = query_fields.iter().map(|(ident, ty, _attrs)| { + if let Type::Path(tp) = &**ty { // <-- dereference the Box + if tp.path.segments.last().unwrap().ident == "Option" { + quote! { #[builder(default)] #ident: #ty } } else { - quote! { #id: #ty } + quote! { #ident: #ty } } } else { - quote! { #id: #ty } + quote! { #ident: #ty } } - }).collect(); + }); - let client_def = match client_arg { - Some((id, ty)) => quote! { #[builder(finish_fn)] #id: #ty }, - None => quote! { #[builder(finish_fn)] client: &restson::RestClient }, - }; - - let call_args: Vec = other_args.iter().map(|(id, _)| { - quote! { #id } - }).collect(); + let body_sig = body_fields.iter().map(|(ident, ty, _attrs)| { + quote! { #ident: #ty } + }); let expanded = quote! { #[bon::builder] #vis fn #name( - #client_def, - #(#arg_defs),* - ) -> impl std::future::Future> + '_ { + #[builder(finish_fn)] + client: &restson::RestClient, + #( #[builder(finish_fn)] #path_idents: #path_types, )* + #( #query_sig, )* + #( #body_sig, )* + ) -> impl std::future::Future> + '_ { + let mut path = format!(#path_fmt, #( #path_idents = #path_idents ),*); + let mut query_pairs = Vec::new(); + #( #query_pairs )* + let mut body_map = serde_json::Map::new(); + #( #body_pairs )* + async move { - let result = client.get::<_, serde_json::Value>(#path).await?; - Ok(result) + if body_map.is_empty() { + client.request_with(#method, &path, &query_pairs, &()).await + } else { + client.request_with(#method, &path, &query_pairs, &body_map).await + } } } }; - expanded.into() + TokenStream::from(expanded) }