This commit is contained in:
Jonas Rabenstein 2026-02-27 05:52:35 +01:00
commit a0ddaf89a9
5 changed files with 197 additions and 168 deletions

View file

@ -14,5 +14,6 @@ restson = "1.5.0"
rpassword = "7.4.0" rpassword = "7.4.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
spond-macros = { version = "0.1.0", path = "../macros" }
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
url = "2.5.8" url = "2.5.8"

View file

@ -1,32 +1,34 @@
//use bon::Builder; //use bon::Builder;
//use chrono::{DateTime,Utc}; use chrono::{DateTime,Utc};
use serde::{Serialize, Deserialize};
//use restson::{RestClient, RestPath, Error}; //use restson::{RestClient, RestPath, Error};
// //
//pub enum Order { #[derive(Serialize)]
// Ascending, pub enum Order {
// Descending, Ascending,
//} Descending,
// }
//impl AsRef<str> for Order {
// fn as_ref(&self) -> &str { impl AsRef<str> for Order {
// match self { fn as_ref(&self) -> &str {
// Self::Ascending => "asc", match self {
// Self::Descending => "desc", Self::Ascending => "asc",
// } Self::Descending => "desc",
// } }
//} }
// }
//impl From<bool> for Order {
// fn from(ascending: bool) -> Self { impl From<bool> for Order {
// if ascending { fn from(ascending: bool) -> Self {
// Self::Ascending if ascending {
// } else { Self::Ascending
// Self::Descending } else {
// } Self::Descending
// } }
//} }
// }
#[derive(serde::Deserialize)]
#[derive(Debug, Deserialize)]
pub struct Spond { pub struct Spond {
id: String, id: String,
@ -46,29 +48,42 @@ pub struct Spond {
// min_end_timestamp=Option<DateTime<Utc>>, // min_end_timestamp=Option<DateTime<Utc>>,
// max_end_timestamp=Option<DateTime<Utc>>, // max_end_timestamp=Option<DateTime<Utc>>,
//) -> Get<(), Vec<Spond>> { //) -> Get<(), Vec<Spond>> {
//
//}
//
//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<DateTime<Utc>>,
// max_end_timestamp: Option<DateTime<Utc>>,
// ),
// () => "sponds" -> Vec<Spond>);
#[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<bool>,
#[query] hidden: Option<bool>,
#[body] min_end_timestamp: Option<DateTime<Utc>>,
) -> serde_json::Value {
result
}
/*
crate::post!(post(
comments: bool, comments: bool,
hidden: bool, hidden: bool,
add_profile_info: bool, add_profile_info: bool,
scheduled: bool, scheduled: bool,
#[builder(into)] order: Order, #[builder(into)] order: Order,
#[builder(default=20)]max: usize, #[builder(default=20)]max: usize,
), )
min_end_timestamp: Option<DateTime<Utc>>, min_end_timestamp: Option<DateTime<Utc>>,
max_end_timestamp: Option<DateTime<Utc>>, max_end_timestamp: Option<DateTime<Utc>>,
() => "sponds" -> Vec<Spond>); () => "sponds" -> Vec<Spond>);
*/
//impl Search { //impl Search {
// with_comments( // with_comments(
//#[bon::builder] //#[bon::builder]

View file

@ -45,17 +45,29 @@ async fn main() -> Result<()> {
let client = Cli::parse().client().await?; 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 // 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 = [ if false {
("includeComments", "true"), let query = [
("includeHidden", "false"), ("includeComments", "true"),
("addProfileInfo", "true"), ("includeHidden", "false"),
("scheduled", "true"), ("addProfileInfo", "true"),
("order", "asc"), ("scheduled", "true"),
("max", "20"), ("order", "asc"),
]; ("max", "20"),
];
for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 {
println!("{spond:?}"); 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(()) Ok(())

View file

@ -3,7 +3,7 @@ macro_rules! get {
// Case 1: no query // Case 1: no query
( (
$name:ident, $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); get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
}; };
@ -11,7 +11,7 @@ macro_rules! get {
// Case 2: empty query () // Case 2: empty query ()
( (
$name:ident (), $name:ident (),
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)? ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
) => { ) => {
#[bon::builder] #[bon::builder]
pub fn $name( 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 ),* $(,)? ), $name:ident ( $( $(#[$attr:meta])* $query_ident:ident $(: $query_ty:ty )? ),* $(,)? ),
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)? ( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
) => { ) => {
#[bon::builder] #[bon::builder]
pub fn $name( pub fn $name(
#[builder(finish_fn)] client: &restson::RestClient, #[builder(finish_fn)] client: &restson::RestClient,
$( #[builder(finish_fn)] $arg: $arg_ty, )* $( #[builder(finish_fn)] $arg: $arg_ty, )*
$( $(
get!(@query_field $query) $(#[$attr])* $query_ident : $crate::get!( @query_type $( $query_ty )? ) ,
)* )*
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ { ) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Query<'a> {
$(
get!(@query_field_struct $query)
)*
}
impl Query<'_> { // Build Vec<(String,String)> dynamically using serde_json::to_string
fn as_pairs(&self) -> Vec<(&str, String)> { let query_pairs: Vec<(String, String)> = vec![
let mut out = Vec::new();
$(
get!(@push_pair out, self, $query)
)*
out
}
}
let query = Query {
$( $(
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)] #[derive(serde::Deserialize)]
struct RP($out); struct RP($out);
@ -83,28 +71,18 @@ macro_rules! get {
} }
async move { 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) Ok(result.into_inner().0)
} }
} }
}; };
// Query helpers ( @query_type ) => { Option<bool> };
(@query_field $field:ident) => { $field: Option<bool>, }; ( @query_type $ty:ty) => { $ty };
(@query_field $field:ident = $ty:ty) => { $field: $ty, };
(@query_field_struct $field:ident) => { $field: Option<bool>, };
(@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()));
}
};
} }

View file

@ -1,109 +1,132 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::{quote, ToTokens}; use quote::quote;
use syn::{ 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<Self> {
let content;
syn::parenthesized!(content in input);
let mut args = Vec::new();
while !content.is_empty() {
let ident: syn::Ident = content.parse()?;
content.parse::<syn::Token![:]>()?;
let ty: syn::Type = content.parse()?;
args.push((ident, ty));
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
}
}
input.parse::<syn::Token![:]>()?;
let path: LitStr = input.parse()?;
Ok(EndpointPath { args, path })
}
}
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
// parse the endpoint attribute let path_args = parse_macro_input!(attr as EndpointPath);
let attr = proc_macro2::TokenStream::from(attr);
let func = parse_macro_input!(item as ItemFn); let func = parse_macro_input!(item as ItemFn);
let vis = &func.vis; let vis = &func.vis;
let name = &func.sig.ident; let name = &func.sig.ident;
let inputs = &func.sig.inputs;
let output = &func.sig.output; let output = &func.sig.output;
// must be async // Separate query/body args
if func.sig.asyncness.is_none() { let mut query_fields = Vec::new();
return syn::Error::new(name.span(), "endpoint function must be async") let mut body_fields = Vec::new();
.to_compile_error()
.into();
}
// defaults for arg in &func.sig.inputs {
let mut method = quote! { GET }; if let FnArg::Typed(PatType { pat, ty, attrs, .. }) = arg {
let mut path = None; let pat_ident = match &**pat {
Pat::Ident(pi) => &pi.ident,
_ => continue,
};
// parse #[endpoint(...)] if attrs.iter().any(|a| a.path().is_ident("query")) {
if !attr.is_empty() { query_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone()));
let attr_str = attr.to_string(); } else if attrs.iter().any(|a| a.path().is_ident("body")) {
// simple heuristic: if contains "POST", switch body_fields.push((pat_ident.clone(), (*ty).clone(), attrs.clone()));
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());
} }
} }
} }
let path = match path { // Path args
Some(p) => p, let path_idents: Vec<_> = path_args.args.iter().map(|(i, _)| i).collect();
None => return syn::Error::new(name.span(), "endpoint path must be provided") let path_types: Vec<_> = path_args.args.iter().map(|(_, t)| t).collect();
.to_compile_error() let path_fmt = &path_args.path;
.into(),
};
// process arguments // Build query serialization
let mut client_arg = None; let query_pairs = query_fields.iter().map(|(ident, _, _)| {
let mut other_args = Vec::new(); quote! {
if let Some(v) = &#ident {
for input in inputs { query_pairs.push((stringify!(#ident), v.to_string()));
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));
}
}
} }
} }
} });
// generate tokens for function with builder // Build body serialization
let arg_defs: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, ty)| { let body_pairs = body_fields.iter().map(|(ident, _, _)| {
// wrap Option<T> with #[builder(default)] quote! {
if let Type::Path(tp) = ty.as_ref() { body_map.insert(stringify!(#ident).to_string(), serde_json::to_value(&#ident)?);
let is_option = tp.path.segments.last().map(|seg| seg.ident == "Option").unwrap_or(false); }
if is_option { });
quote! { #[builder(default)] #id: #ty }
// 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<Type>
if tp.path.segments.last().unwrap().ident == "Option" {
quote! { #[builder(default)] #ident: #ty }
} else { } else {
quote! { #id: #ty } quote! { #ident: #ty }
} }
} else { } else {
quote! { #id: #ty } quote! { #ident: #ty }
} }
}).collect(); });
let client_def = match client_arg { let body_sig = body_fields.iter().map(|(ident, ty, _attrs)| {
Some((id, ty)) => quote! { #[builder(finish_fn)] #id: #ty }, quote! { #ident: #ty }
None => quote! { #[builder(finish_fn)] client: &restson::RestClient }, });
};
let call_args: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, _)| {
quote! { #id }
}).collect();
let expanded = quote! { let expanded = quote! {
#[bon::builder] #[bon::builder]
#vis fn #name( #vis fn #name(
#client_def, #[builder(finish_fn)]
#(#arg_defs),* client: &restson::RestClient,
) -> impl std::future::Future<Output = Result<_, restson::Error>> + '_ { #( #[builder(finish_fn)] #path_idents: #path_types, )*
#( #query_sig, )*
#( #body_sig, )*
) -> impl std::future::Future<Output = Result<serde_json::Value, restson::Error>> + '_ {
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 { async move {
let result = client.get::<_, serde_json::Value>(#path).await?; if body_map.is_empty() {
Ok(result) client.request_with(#method, &path, &query_pairs, &()).await
} else {
client.request_with(#method, &path, &query_pairs, &body_map).await
}
} }
} }
}; };
expanded.into() TokenStream::from(expanded)
} }