From e69bcfc23d321396be82c6282912e10ac6c43a35 Mon Sep 17 00:00:00 2001 From: Jonas Rabenstein Date: Thu, 5 Mar 2026 01:57:37 +0100 Subject: [PATCH] working example --- .gitignore | 7 + Cargo.toml | 4 + api/Cargo.toml | 12 ++ api/src/authentication.rs | 127 ++++++++++++ api/src/group.rs | 39 ++++ api/src/lib.rs | 123 ++++++++++++ api/src/profile.rs | 44 +++++ api/src/series.rs | 18 ++ api/src/spond.rs | 242 +++++++++++++++++++++++ api/src/user.rs | 41 ++++ cli/Cargo.toml | 9 +- cli/src/api/mod.rs | 103 ---------- cli/src/authentication.rs | 198 ++++++++----------- cli/src/history.rs | 26 +++ cli/src/main.rs | 398 ++++++++++++++++++++++++++++++++++---- flake.lock | 25 +++ flake.nix | 64 ++++++ macros/src/lib.rs | 98 ++++++++-- 18 files changed, 1308 insertions(+), 270 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 api/Cargo.toml create mode 100644 api/src/authentication.rs create mode 100644 api/src/group.rs create mode 100644 api/src/lib.rs create mode 100644 api/src/profile.rs create mode 100644 api/src/series.rs create mode 100644 api/src/spond.rs create mode 100644 api/src/user.rs delete mode 100644 cli/src/api/mod.rs create mode 100644 cli/src/history.rs create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10363bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +* +!.gitignore +!/api/ +!/cli/ +!Cargo.toml +!/*/src/ +!/*/src/**/*.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..67a02f0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +resolver = "3" +#members = ["api","cli","schema"] +members = ["api", "cli" , "macros"] diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..83284c8 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "spond-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +bon = "3.9.0" +chrono = { version = "0.4.44", features = ["serde"] } +restson = "1.5.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" diff --git a/api/src/authentication.rs b/api/src/authentication.rs new file mode 100644 index 0000000..c8f5201 --- /dev/null +++ b/api/src/authentication.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use restson::{RestClient, RestPath, Error, Response}; + +#[derive(Debug, Copy, Clone, Serialize)] +struct Email<'a> { + email: &'a str, + password: &'a str, +} + +impl<'a> RestPath<()> for Email<'a> { + fn get_path(_: ()) -> Result { + Ok(String::from("auth2/login")) + } +} + +#[derive(Debug, Copy, Clone, Serialize)] +struct Phone<'a> { + phone: &'a str, + password: &'a str, +} + +impl<'a> RestPath<()> for Phone<'a> { + fn get_path(_: ()) -> Result { + Ok(String::from("auth2/login")) + } +} + +#[derive(Debug, Copy, Clone, Serialize)] +struct Token<'a> { + token: &'a str, +} + +impl<'a> RestPath<()> for Token<'a> { + fn get_path(_: ()) -> Result { + Ok(String::from("auth2/login/refresh")) + } +} + +pub mod token { + use serde::{Serialize, Deserialize}; + use crate::util::DateTime; + use std::ops::Deref; + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct Token { + token: String, + } + + impl AsRef for Token { + fn as_ref(&self) -> &str { + &self.token + } + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct WithExpiration { + #[serde(flatten)] + pub token: Token, + #[allow(unused)] + pub expiration: DateTime, + } + + #[derive(Debug, Deserialize)] + #[serde(transparent)] + pub struct Access(WithExpiration); + + impl Deref for Access { + type Target = WithExpiration; + + fn deref(&self) -> &WithExpiration { + &self.0 + } + } + + #[derive(Debug, Deserialize)] + #[serde(transparent)] + pub struct Refresh(WithExpiration); + + impl Deref for Refresh { + type Target = WithExpiration; + fn deref(&self) -> &WithExpiration { + &self.0 + } + } + + #[derive(Debug, Deserialize)] + #[serde(transparent)] + pub struct Password(WithExpiration); + + impl Deref for Password { + type Target = WithExpiration; + fn deref(&self) -> &WithExpiration { + &self.0 + } + } +} + +#[derive(Debug, Deserialize)] +pub struct Tokens { + #[serde(rename = "accessToken")] + pub access: token::Access, + #[serde(rename = "refreshToken")] + pub refresh: token::Refresh, + #[serde(rename = "passwordToken")] + pub password: token::Password, +} + +async fn authenticate(client: &RestClient, request: R) -> Result +where + R: RestPath<()>, + R: Serialize, +{ + let tokens: Response = client.post_capture((), &request).await?; + Ok(tokens.into_inner()) +} + +pub fn email<'a>(client: &'a RestClient, email: &'a str, password: &'a str) -> impl Future> + 'a { + authenticate(client, Email { email, password }) +} + +pub fn phone<'a>(client: &'a RestClient, phone: &'a str, password: &'a str) -> impl Future> + 'a { + authenticate(client, Phone { phone, password }) +} + +pub fn token<'a>(client: &'a RestClient, token: &'a str) -> impl Future> + 'a { + authenticate(client, Token { token }) +} diff --git a/api/src/group.rs b/api/src/group.rs new file mode 100644 index 0000000..22b014b --- /dev/null +++ b/api/src/group.rs @@ -0,0 +1,39 @@ +use super::GroupId as Id; +use super::MemberId; +use restson::{Error, RestPath}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Group { + pub id: Id, + + #[serde(flatten)] + unknown: serde_json::Value, +} + +impl std::fmt::Display for Group { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + serde_json::to_string_pretty(&self.unknown).unwrap() + ) + } +} + +impl RestPath for Group { + fn get_path(id: Id) -> std::result::Result { + Ok(format!("groups/{id}")) + } +} + +#[derive(Debug, Deserialize)] +pub struct Member { + pub id: MemberId, + + #[serde(flatten)] + pub unknown: serde_json::Value, +} +impl super::util::id::Type for Member { + type Type = super::util::X128; +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..e71fe94 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,123 @@ +use std::collections::{ + HashMap as Map, + //HashSet as Set, +}; + +pub mod util; + +pub mod authentication; + +pub mod profile; +pub use profile::Profile; +pub type ProfileId = util::Id; + +pub mod spond; +pub use spond::Spond; +pub type SpondId = util::Id; + +pub mod series; +pub use series::Series; +pub type SeriesId = util::Id; + +pub mod group; +pub use group::Group; +pub type GroupId = util::Id; +pub type MemberId = util::Id; + +impl>> util::id::Type for T { + type Type = util::X128; +} + +//impl,)> + serde::de::DeserializeOwned + util::id::Type> +// util::Id +//{ +// pub async fn load(&self, client: &restson::RestClient) -> Result { +// Ok(client.get_with::<_, T>((*self,), &[]).await?.into_inner()) +// } +//} + +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +struct Query<'a>(Vec<(&'a str, String)>); + +impl<'a> Query<'a> { + pub fn add(&mut self, key: &'a str, value: impl std::fmt::Display) { + self.0.push((key, value.to_string())); + } + + pub fn maybe_add(&mut self, key: &'a str, value: Option) { + if let Some(value) = value { + self.add(key, value); + } + } + + fn as_slice(&self) -> Vec<(&'a str, &str)> { + self.0.iter().map(|(k, v)| (*k, v.as_str())).collect() + } +} + +#[derive(Serialize)] +pub enum Order { + Ascending, + Descending, +} + +impl Default for Order { + fn default() -> Self { + Self::Ascending + } +} + +impl std::fmt::Display for Order { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + +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)] +#[serde(rename_all = "camelCase")] +pub struct DeclineMessage { + pub message: String, + pub profile_id: MemberId, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Responses { + #[serde(default)] + pub accepted_ids: Vec, + #[serde(default)] + pub declined_ids: Vec, + #[serde(default)] + pub waitinglist_ids: Vec, + #[serde(default)] + pub unanswered_ids: Vec, + #[serde(default)] + pub unconfirmed_ids: Vec, + #[serde(default)] + pub participant_ids: Vec, + #[serde(default)] + pub decline_messages: Map, + #[serde(flatten)] + pub unknown: Map, +} diff --git a/api/src/profile.rs b/api/src/profile.rs new file mode 100644 index 0000000..eee352e --- /dev/null +++ b/api/src/profile.rs @@ -0,0 +1,44 @@ +use super::ProfileId as Id; +use restson::{Error, RestClient, RestPath}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Profile { + pub id: Id, + + #[serde(flatten)] + unknown: serde_json::Value, +} + +impl std::fmt::Display for Profile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + serde_json::to_string_pretty(&self.unknown).unwrap() + ) + } +} + +impl RestPath for Profile { + fn get_path(id: Id) -> std::result::Result { + Ok(format!("profile/{id}")) + } +} + +impl RestPath<()> for Profile { + fn get_path(_: ()) -> std::result::Result { + Ok("profile".to_string()) + } +} + +pub async fn identity(client: &RestClient) -> Result { + Ok(client.get_with::<_, Profile>((), &[]).await?.into_inner()) +} + +pub async fn with_id(client: &RestClient, id: Id) -> Result { + Ok(client + .get_with::<_, Profile>(id, &[]) + .await? + .into_inner()) +} diff --git a/api/src/series.rs b/api/src/series.rs new file mode 100644 index 0000000..da4c130 --- /dev/null +++ b/api/src/series.rs @@ -0,0 +1,18 @@ +use restson::{Error, RestPath}; +use serde::Deserialize; + +use super::SeriesId as Id; + +#[derive(Debug, Deserialize)] +pub struct Series { + pub id: Id, + + #[serde(flatten)] + pub unknown: serde_json::Value, +} + +impl RestPath for Series { + fn get_path(id: Id) -> std::result::Result { + Ok(format!("series/{id}")) + } +} diff --git a/api/src/spond.rs b/api/src/spond.rs new file mode 100644 index 0000000..f77e3fd --- /dev/null +++ b/api/src/spond.rs @@ -0,0 +1,242 @@ +use super::{ + MemberId, Order, ProfileId, Query, Responses, SeriesId, SpondId as Id, + util::{DateTime, Timestamp, Visibility}, +}; +use restson::{Error, RestClient, RestPath}; +use serde::Deserialize; +use std::collections::HashMap; +use std::future::Future; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Group { + #[serde(flatten)] + pub unknown: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipients { + pub group: HashMap, + pub guardians: std::vec::Vec, + pub profiles: std::vec::Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Comment { + pub id: crate::util::Id, + pub children: std::vec::Vec, + pub from_profile_id: ProfileId, + pub reactions: HashMap>, + pub text: String, + pub timestamp: DateTime, +} + +impl crate::util::id::Type for Comment { + type Type = crate::util::X128; +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Spond { + pub id: Id, + pub heading: String, + pub series_id: Option, + pub responses: Responses, + pub updated: Timestamp, + pub recipients: Recipients, + pub hidden: bool, + pub comments: Vec, + pub auto_accept: bool, + pub start_timestamp: DateTime, + pub end_timestamp: DateTime, + pub creator_id: ProfileId, + pub visibility: Visibility, + pub behalf_of_ids: Vec, + + #[serde(flatten)] + pub unknown: HashMap, +} + +#[bon::bon] +impl Spond { + #[builder] + pub fn response( + &self, + #[builder(start_fn)] + member: MemberId, + #[builder(finish_fn)] + client: &RestClient, + #[builder(default = true)] + accepted: bool, + ) -> impl Future> { + response(self.id) + .member(member) + .accepted(accepted) + .call(client) + } + + #[builder] + pub fn accept(&self, + #[builder(start_fn)] + member: MemberId, + #[builder(finish_fn)] + client: &RestClient, + ) -> impl Future> { + self.response(member) + .accepted(true) + .call(client) + } + + #[builder] + pub fn decline(&self, + #[builder(start_fn)] + member: MemberId, + #[builder(finish_fn)] + client: &RestClient, + ) -> impl Future> { + self.response(member) + .accepted(false) + .call(client) + } +} + +impl std::fmt::Display for Spond { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + serde_json::to_string_pretty(&self.unknown).unwrap() + ) + } +} + +#[derive(Debug, serde::Deserialize)] +struct Sponds(Vec); + +impl RestPath<()> for Sponds { + fn get_path(_: ()) -> std::result::Result { + Ok(String::from("sponds")) + } +} + +impl RestPath for Spond { + fn get_path(id: Id) -> std::result::Result { + Ok(format!("sponds/{id}")) + } +} + +impl RestPath<()> for Spond { + fn get_path(_: ()) -> std::result::Result { + Ok("sponds".to_string()) + } +} + +#[bon::builder] +pub async fn spond( + #[builder(finish_fn)] client: &RestClient, + #[builder(finish_fn)] id: Id, + + // query flags + include_comments: Option, + add_profile_info: Option, +) -> Result { + let mut q = Query::default(); + q.maybe_add("includeComments", include_comments); + q.maybe_add("addProfileInfo", add_profile_info); + + Ok(client + .get_with::<_, Spond>(id, &q.as_slice()) + .await? + .into_inner()) +} + +#[bon::builder] +pub async fn search( + #[builder(finish_fn)] client: &RestClient, + + // query flags + include_comments: Option, + comments: Option, + hidden: Option, + add_profile_info: Option, + scheduled: Option, + #[builder(default, into)] order: Order, + #[builder(default = 20)] max: usize, + #[builder(into)] min_end_timestamp: Option, + #[builder(into)] max_end_timestamp: Option, + #[builder(into)] min_start_timestamp: Option, + #[builder(into)] max_start_timestamp: Option, + prev_id: Option, + series_id: Option, +) -> Result, Error> { + let mut q = Query::default(); + q.maybe_add("seriesId", series_id); + q.maybe_add("includeComments", include_comments); + q.maybe_add("comments", comments); + q.maybe_add("addProfileInfo", add_profile_info); + q.maybe_add("scheduled", scheduled); + q.maybe_add("hidden", hidden); + q.maybe_add("minEndTimestamp", min_end_timestamp); + q.maybe_add("maxEndTimestamp", max_end_timestamp); + q.maybe_add("minStartTimestamp", min_start_timestamp); + q.maybe_add("maxStartTimestamp", max_start_timestamp); + q.maybe_add("prevId", prev_id); + q.add("order", order); + q.add("max", max); + + Ok(client + .get_with::<_, Sponds>((), &q.as_slice()) + .await? + .into_inner() + .0) +} + +#[bon::builder] +pub fn decline( + #[builder(start_fn)] spond: Id, + #[builder(finish_fn)] client: &RestClient, + member: MemberId, +) -> impl std::future::Future> { + response(spond) + .member(member) + .accepted(false) + .call(client) +} + +#[bon::builder] +pub fn accept( + #[builder(start_fn)] spond: Id, + #[builder(finish_fn)] client: &RestClient, + member: MemberId, +) -> impl std::future::Future> { + response(spond) + .member(member) + .accepted(true) + .call(client) +} + +#[bon::builder] +pub async fn response( + #[builder(start_fn)] spond: Id, + #[builder(finish_fn)] client: &RestClient, + member: MemberId, + accepted: bool, +) -> Result { + #[derive(Debug, serde::Serialize)] + struct Request { + accepted: bool, + } + + impl RestPath<(Id, MemberId)> for Request { + fn get_path(args: (Id, MemberId)) -> std::result::Result { + let (spond, member) = args; + Ok(format!("sponds/{spond}/responses/{member}")) + } + } + + let request = Request{accepted}; + let response: restson::Response = client.put_capture((spond, member), &request).await?; + Ok(response.into_inner()) +} diff --git a/api/src/user.rs b/api/src/user.rs new file mode 100644 index 0000000..c41e186 --- /dev/null +++ b/api/src/user.rs @@ -0,0 +1,41 @@ +use serde::Deserialize; +use restson::{RestClient, RestPath, Error}; + +use super::{ + UserId as Id, +}; + +#[derive(Debug, Deserialize)] +pub struct User { + pub id: Id, + + #[serde(flatten)] + unknown: serde_json::Value, +} + +impl std::fmt::Display for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", serde_json::to_string_pretty(&self.unknown).unwrap()) + } +} + +impl RestPath<()> for User { + fn get_path(_: ()) -> std::result::Result { + Ok(format!("user")) + } +} + +impl RestPath<(Id, )> for User { + fn get_path(args: (Id, )) -> std::result::Result { + let (id, ) = args; + Ok(format!("user/{id}")) + } +} + +pub async fn identity(client: &RestClient) -> Result { + Ok(client.get_with::<_, User>((), &[]).await?.into_inner()) +} + +pub async fn with_id(client: &RestClient, id: Id) -> Result { + Ok(client.get_with::<_, User>((id, ), &[]).await?.into_inner()) +} diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 389ce79..4797ca4 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,11 +9,18 @@ bon = "3.9.0" chrono = { version = "0.4.44", features = ["serde"] } clap = { version = "4.5.60", features = ["cargo", "derive", "env" ] } env_logger = "0.11.9" +futures = "0.3.32" http = "1.4.0" +log = "0.4.29" +rand = "0.10.0" 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" } +serde_qs = "1.0.0" +spond-api = { version = "0.1.0", path = "../api" } +thiserror = "2.0.18" +#spond-macros = { version = "0.1.0", path = "../macros" } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } url = "2.5.8" +xdg = "3.0.0" diff --git a/cli/src/api/mod.rs b/cli/src/api/mod.rs deleted file mode 100644 index be81c07..0000000 --- a/cli/src/api/mod.rs +++ /dev/null @@ -1,103 +0,0 @@ -//use bon::Builder; -use chrono::{DateTime,Utc}; -use serde::{Serialize, Deserialize}; -//use restson::{RestClient, RestPath, Error}; -// -#[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, - -} -// -//#[bon::builder] -//#[builder(on(bool, default=false))] -//fn sponds( -// 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>, -//) -> Get<(), Vec> { - -//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] -//#[builder(on(bool, default=false))] -//fn sponds( -// 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>, -//) -> Search { -//} diff --git a/cli/src/authentication.rs b/cli/src/authentication.rs index 2f0c42b..49a6378 100644 --- a/cli/src/authentication.rs +++ b/cli/src/authentication.rs @@ -1,11 +1,7 @@ use clap::{Args, ArgGroup}; -use restson::{RestClient, RestPath, Response}; +use restson::RestClient; use anyhow::{Result, Error}; -use serde::{ - ser::{Serialize, Serializer, SerializeMap}, - Deserialize, -}; -use chrono::{DateTime, Utc}; +use std::str::{FromStr}; #[derive(Args, Debug)] #[command(group( @@ -14,10 +10,10 @@ use chrono::{DateTime, Utc}; ))] pub struct Authentication { #[arg(long)] - access: Option, + access: Option, #[arg(long)] - refresh: Option, + refresh: Option, #[arg(long)] email: Option, @@ -26,13 +22,18 @@ pub struct Authentication { phone: Option, } +fn bearer(mut client: RestClient, token: &str) -> Result { + client.set_header("Authorization", &format!("Bearer {}", token))?; + Ok(client) +} + impl Authentication { - pub async fn apply(self, client: RestClient) -> Result { - let client = match (self.access, self.refresh, self.email, self.phone) { - (Some(v), None, None, None) => v.apply(client)?, - (None, Some(v), None, None) => Tokens::authenticate(client, v).await?, - (None, None, Some(v), None) => Tokens::authenticate(client, v).await?, - (None, None, None, Some(v)) => Tokens::authenticate(client, v).await?, + pub async fn apply(&self, client: RestClient) -> Result { + let client = match (self.access.as_ref(), self.refresh.as_ref(), self.email.as_ref(), self.phone.as_ref()) { + (Some(ref v), None, None, None) => v.apply(client)?, + (None, Some(ref v), None, None) => v.apply(client).await?, + (None, None, Some(ref v), None) => v.apply(client).await?, + (None, None, None, Some(ref v)) => v.apply(client).await?, (None, None, None, None) => client, (a, b, c, d) => anyhow::bail!("invalid authentication: {} + {} + {} + {}", a.is_some(), b.is_some(), c.is_some(), d.is_some()), }; @@ -40,126 +41,87 @@ impl Authentication { } } -mod identifier { - #[derive(Debug, Clone)] - pub struct Email; - #[derive(Debug, Clone)] - pub struct Phone; -} - -trait Identifier: Clone { - const NAME: &'static str; - type Value: std::str::FromStr + std::fmt::Debug + Clone + serde::Serialize; - type Error: std::error::Error + Send + Sync + 'static; -} - -impl Identifier for identifier::Email { - const NAME: &'static str = "email"; - type Value = String; - type Error = ::Err; -} - -impl Identifier for identifier::Phone { - const NAME: &'static str = "phone"; - type Value = String; - type Error = ::Err; -} - #[derive(Debug, Clone)] -struct WithPassword { - value: I::Value, +struct WithPassword { + value: String, password: String, } - -impl Serialize for WithPassword { - fn serialize(&self, serializer: S) -> Result - { - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry(I::NAME, &self.value)?; - map.serialize_entry("password", &self.password)?; - map.end() - } -} - -impl RestPath<()> for WithPassword { - fn get_path(_: ()) -> std::result::Result { - Ok(String::from("auth2/login")) - } -} - -type Email = WithPassword; -type Phone = WithPassword; - -impl std::str::FromStr for WithPassword { - type Err= Error; +impl FromStr for WithPassword { + type Err = Error; fn from_str(s: &str) -> Result { let password = match std::env::var("SPOND_PASSWORD") { Ok(password) => password, Err(_) => rpassword::prompt_password("Password: ")?, }; - let value = I::Value::from_str(s)?; + let value = String::from_str(s)?; Ok(Self { value, password }) } } -#[derive(Debug, Deserialize)] -struct Tokens { - #[serde(rename = "accessToken")] - access: TokenWithExpiration, - #[serde(rename = "refreshToken")] - refresh: TokenWithExpiration, -} - -impl Tokens { - async fn authenticate>(client: RestClient, request: R) -> Result { - let tokens: Response = client.post_capture((), &request).await?; - tokens.into_inner().apply(client) - } - - fn apply(self, client: RestClient) -> Result { - println!("refresh: {self:?}"); - self.access.token.apply(client) - } - -} - -#[derive(Debug, Deserialize)] -struct TokenWithExpiration { - token: Token, - #[allow(unused)] - expiration: DateTime -} - -#[derive(Debug, Clone, Deserialize)] -struct Token(String); - -impl Serialize for Token { - fn serialize(&self, serializer: S) -> Result - { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("token", &self.0)?; - map.end() +#[derive(Debug, Clone)] +struct Email(WithPassword); +impl Email { + async fn apply(&self, client: RestClient) -> Result { + let tokens = spond_api::authentication::email(&client, &self.0.value, &self.0.password).await?; + bearer(client, tokens.access.token.as_ref()) } } - -impl Token { - fn apply(self, mut client: RestClient) -> Result { - client.set_header("Authorization", &format!("Bearer {}", self.0))?; - Ok(client) - } -} - -impl RestPath<()> for Token { - fn get_path(_: ()) -> std::result::Result { - Ok(String::from("auth2/login/refresh")) - } -} - -impl std::str::FromStr for Token { - type Err = std::convert::Infallible; +impl FromStr for Email { + type Err= ::Err; fn from_str(s: &str) -> Result { - Ok(Self(s.to_string())) + Ok(Self(WithPassword::from_str(s)?)) + } +} + +#[derive(Debug, Clone)] +struct Phone(WithPassword); +impl Phone { + async fn apply(&self, client: RestClient) -> Result { + let tokens = spond_api::authentication::phone(&client, &self.0.value, &self.0.password).await?; + bearer(client, tokens.access.token.as_ref()) + } +} +impl FromStr for Phone { + type Err= ::Err; + + fn from_str(s: &str) -> Result { + Ok(Self(WithPassword::from_str(s)?)) + } +} + +#[derive(Debug, Clone)] +struct Access(String); + +impl Access { + fn apply(&self, client: RestClient) -> Result { + bearer(client, &self.0) + } +} + +impl FromStr for Access { + type Err = std::convert::Infallible; // parsing a String never fails + + fn from_str(s: &str) -> Result { + Ok(Access(s.to_string())) + } +} + +#[derive(Debug, Clone)] +struct Refresh(String); + +impl Refresh { + async fn apply(&self, client: RestClient) -> Result { + let tokens = spond_api::authentication::token(&client, &self.0).await?; + bearer(client, tokens.access.token.as_ref()) + } +} + +impl FromStr for Refresh { + type Err = std::convert::Infallible; // parsing a String never fails + + fn from_str(s: &str) -> Result { + Ok(Refresh(s.to_string())) } } diff --git a/cli/src/history.rs b/cli/src/history.rs new file mode 100644 index 0000000..73e5888 --- /dev/null +++ b/cli/src/history.rs @@ -0,0 +1,26 @@ +use std::fd::{File, OpenOptions}; +use std::io::{BufReader, BufWriter, Read, Write}; +use xdg::BaseDirectories; + +pub struct History(File); + +impl History { + pub fn open(serie: spond_api::SeriesId) -> std::io::Result { + let xdg_dirs = BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")); + let path = xdg_dirs.place_state_file(format!("{}.bin", serie))?; + + let file = OpenOptions::new() + .create(true) + .append(true) + .read(true) + .open(&path)?; + + Ok(Self(file)) + } + + pub fn append(&mut self, selected: usize, ids: I) -> io::Result<()> + where + I: IntoIterator, + I::Item: Into, + I::IntoIter: ExactSizeIterator, +} diff --git a/cli/src/main.rs b/cli/src/main.rs index f964c60..2471d48 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,11 +2,10 @@ use clap::Parser; use restson::RestClient; use anyhow::Result; use url::Url; +use spond_api as api; +use xdg::BaseDirectories as xdg; mod authentication; -mod api; - -mod request; #[derive(Parser, Debug)] #[command(author, version, about)] @@ -14,27 +13,176 @@ struct Cli { #[command(flatten)] authentication: authentication::Authentication, + #[arg(long, default_value_t)] + seed: Seed, + + #[arg(long)] + vip: Option>, + + #[arg(long)] + series: Option, + + #[arg(long)] + heading: Option, + #[arg(long, default_value = "https://api.spond.com/")] base: Url, } impl Cli { - pub async fn client(self) -> Result { + pub async fn client(&self) -> Result { let base = self.base.join("/core/v1/")?; let client = RestClient::new(base.as_str())?; Ok(self.authentication.apply(client).await?) } } -#[derive(Debug, serde::Deserialize)] -struct Spond(serde_json::Value); +#[derive(Debug, Clone, Copy)] +struct Seed(u64); -#[derive(Debug, serde::Deserialize)] -struct Sponds(Vec); +impl Default for Seed { + fn default() -> Self { + use rand::{rng, RngExt}; + Self(rng().random()) + } +} -impl restson::RestPath<()> for Sponds { - fn get_path(_: ()) -> std::result::Result { - Ok(String::from("sponds")) +impl std::str::FromStr for Seed { + type Err = ::Err; + + fn from_str(s: &str) -> std::result::Result { + u64::from_str_radix(s, 16).map(Seed) + } +} + +impl> From for Seed { + fn from(seed: T) -> Self { + Self(seed.into()) + } +} + +impl std::fmt::Display for Seed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:016X}", self.0) + } +} + +impl Seed { + pub fn shuffle<'a, T, F, W>(&self, input: &'a [T], weight: F) -> Result> + where + F: Fn(T) -> W, + W: Into, + T: Copy + { + use rand::{SeedableRng, rngs::StdRng}; + let len = input.len(); + + let sample = rand::seq::index::sample_weighted( + &mut StdRng::seed_from_u64(self.0), + len, + |idx| weight(input[idx]), + len, + )?; + + log::debug!("sample: {:?}", sample); + Ok(sample.into_iter().map(move |idx| input[idx]).collect()) + } +} + + +#[derive(Debug)] +struct Weights(std::collections::HashMap); + +use std::hash::Hash; + +impl Default for Weights +where + Id: Eq + Hash + Copy, +{ + fn default() -> Self { + Self(std::collections::HashMap::default()) + } +} + +impl Weights +where + Id: Eq + Hash + Copy, +{ + fn path(serie: api::SeriesId) -> Result { + let dirs = xdg::with_prefix(env!("CARGO_PKG_NAME")); + Ok(dirs.place_state_file(format!("{serie}.json"))?) + } + + pub fn update(&mut self, keep: &[Id]) -> &mut Self { + // remove keys not in keep + self.0.retain(|key, _| keep.contains(key)); + + // adjust weights + for &key in keep { + let val = self.0.entry(key).or_insert(0); + *val = val.saturating_add(1); + } + + self + } + + pub fn weight(&self, index: impl Into) -> f64 { + let extra = self.0.get(&index.into()).copied().unwrap_or(0); + let sum = 1f64 + (extra as f64); + if sum.is_infinite() { f64::MAX } else { sum } + } +} + +impl Weights +where + Id: Eq + Hash + Copy + serde::de::DeserializeOwned +{ + pub fn load(serie: api::SeriesId) -> Result { + let path = Self::path(serie)?; + log::debug!("load {path:?}"); + let file = std::fs::OpenOptions::new() + .read(true) + .open(path)?; + + let data: std::collections::HashMap = serde_json::from_reader(file)?; + Ok(Self(data)) + } +} + + +impl Weights +where + Id: Eq + Hash + Copy + serde::Serialize +{ + pub fn store(&self, serie: api::SeriesId) -> Result<()> { + use std::fs::{File, rename}; + use std::io::{BufWriter, Write}; + + + let path = Self::path(serie)?; + log::debug!("store {path:?}"); + let tmp = path.with_extension("json.tmp"); + + // create temporary file + let file = File::create(&tmp)?; + let mut writer = BufWriter::new(file); + + // write data to temporary file + serde_json::to_writer_pretty(&mut writer, &self.0)?; + + // flush write buffer + writer.flush()?; + + // sync to disc + writer.get_ref().sync_all()?; + + // close file + drop(writer); + + // atomic replace old file + rename(&tmp, &path)?; + + Ok(()) } } @@ -42,32 +190,212 @@ impl restson::RestPath<()> for Sponds { async fn main() -> Result<()> { env_logger::init(); - 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 - 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:?}"); - } + let cli = Cli::parse(); + let seed = cli.seed; + let series = cli.series.map(api::SeriesId::new); + let heading = cli.heading.as_ref(); + let vip: Vec = if let Some(ref vip) = cli.vip { + vip.into_iter().map(|id| api::MemberId::new(*id)).collect() } else { - let request = api::sponds() - .comments(true) - .hidden(false) - .add_profile_info(false) - .scheduled(true) - ; - for spond in request.call(&client).await? { - println!("{spond:?}"); + [ + 0xEB07B45E45E6449386E70A7411816B6Fu128, + 0xD05F8574AC544C8DB1A7DC5B6347AA49u128, + ].map(|x| api::MemberId::new(x.into())).into() + }; + let client = cli.client().await?; + let client = &client; + + log::info!("seed: {seed}"); + if let Some(series) = series { + log::info!("series: {series}"); + } + if let Some(heading) = heading { + log::info!("heading: {heading}"); + } + + if true { + let now = chrono::Utc::now(); + let sponds = api::spond::search() + .include_comments(true) + .order(api::Order::Ascending) + .max(1000) + .min_start_timestamp(now) + .max_end_timestamp(now + chrono::Duration::weeks(1)) + .call(client).await?; + + for spond in sponds.iter() + .filter(|spond| { + let result = series.is_none_or(|series| spond.series_id.is_some_and(|remote| remote == series)) + && heading.is_none_or(|heading| spond.heading == *heading); + log::trace!("{}: {:?} == {:?} => {:?}", spond.heading, spond.series_id, series, result); + result + }) + { + log::debug!("{:?}", spond.responses); + + let spond = &spond; + let decline = |id: &api::MemberId| { + log::info!("remove {0}", *id); + spond.decline(*id).call(client) + }; + let accept = |id: &api::MemberId| { + log::info!("accept {0}", *id); + spond.accept(*id).call(client) + }; + + let mut weights = spond.series_id.and_then(|series| Weights::load(series).ok()).unwrap_or_else(Weights::default); + log::info!("{weights:?}"); + + let (vip, interested) = { + let mut r = (Vec::new(), Vec::new()); + for id in spond.responses.accepted_ids.iter() + .chain(spond.responses.waitinglist_ids.iter()) { + (if vip.contains(id) { &mut r.0 } else { &mut r.1 }).push(*id); + } + (r.0, seed.shuffle(&r.1, |idx| weights.weight(idx))?) + }; + + // remove all registered participants + let results = futures::future::join_all(interested.iter().map(|id|decline(id))).await; + log::debug!("{results:?}"); + + // register them in order + let mut responses = None; + for id in interested.iter() { + responses = Some(accept(id).await?); + } + + if let Some(responses) = responses { + log::debug!("{responses:?}"); + + let reorder = |mut responses: api::Responses| async move { + // someone might have been registered right now + let mut extra = Vec::new(); + loop { + log::debug!("vip: {vip:?}"); + log::debug!("interested: {interested:?}"); + log::debug!("extra: {extra:?}"); + let reorder = responses.accepted_ids.iter() + .chain(responses.waitinglist_ids.iter()) + .filter(|id| !(vip.contains(*id) || interested.contains(*id) || extra.contains(*id))) + .cloned() + .collect::>(); + if reorder.is_empty() { + let update = interested.iter() + .filter(|id| responses.waitinglist_ids.contains(id)) + .cloned() + .collect::>(); + break Ok::, anyhow::Error>(update); + } + let futures = futures::future::join_all(reorder.iter().map(|id|decline(id))).await; + log::debug!("{futures:?}"); + + for id in reorder.into_iter() { + responses = accept(&id).await?; + extra.push(id); + } + + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + } + }; + + let update = reorder(responses).await?; + weights.update(&update); + } else { + weights = Weights::default(); + }; + + log::debug!("{weights:?}"); + + if let Some(series) = spond.series_id { + let _ = weights.store(series)?; + } } + + + //for member in spond.responses.accepted_ids.iter() + // .chain(spond.responses.waitinglist_ids.iter()) { + // let result = map.insert(member, 1); + // println!("{:?}: {:?}", member, result); + //} + //println!("{:?}", map); + //println!("{:?}", &spond.responses); + //let response = spond.response(member) + // .accepted(false) + // .call(&client) + // .await?; + //println!("{:?}", &response); + } else if true { + let profile = api::profile::identity(&client).await; + if let Ok(profile) = profile { + println!("profile: {:?}: {profile}", &profile.id); + } + } else if false { + //let query = [ + // ("includeSponds", "true"), + //]; + //let series = client.get_with::<_, Series>((0xCCBE049C31DA4FB691158E3FBC2DFBC8u128,), &query).await?.into_inner().0; + let now = api::util::DateTime::default(); + let sponds = api::spond::search() + .include_comments(true) + .order(api::Order::Ascending) + .max(100) + .series_id(api::SeriesId::new(0x9333BDD4135E48BEAE88F1C3006A5FC0u128.into())) + .min_end_timestamp(now) + .min_start_timestamp(now) + .call(&client).await?; + for spond in sponds.iter() { + println!("{spond:?}"); + //let spond = api::spond().call(&client, api::SpondId::new(0xF131CD46F80A42B9909D8E7F4018D8E1u128.into())).await?; + //println!("{spond:?}"); + } + //} else if true { + // + //#[derive(Debug, serde::Deserialize, serde::Serialize)] + //struct Spond(serde_json::Value); + // + //#[derive(Debug, serde::Deserialize, serde::Serialize)] + //struct Sponds(Vec); + // + //impl restson::RestPath<()> for Sponds { + // fn get_path(_: ()) -> std::result::Result { + // Ok(String::from("sponds")) + // } + //} + // let now = &DateTime::default(); + // log::info!("{now:?} | {}", now.to_string()); + // let query = [ + // ("includeComments", "true"), + // //("includeHidden", "true"), + // ("addProfileInfo", "true"), + // ("hidden", "true"), + // ("scheduled", "true"), + // ("order", "asc"), + // ("max", "20"), + // ("heading", "Schwimmtraining Donnerstag"), + // ("seriesId", "CCBE049C31DA4FB691158E3FBC2DFBC8u128"), + // ("minStartTimestamp", &now.to_string()), + // ]; + // + // for spond in client.get_with::<_, api::spond::Sponds>((), &query).await?.into_inner().0 { + // //match spond.0 { + // // serde_json::Value::Object(map) => { + // // println!("{:?}", map); + // // }, + // // _ => {}, + // //}; + // println!("{}", serde_json::to_string_pretty(&spond).unwrap()); + // } + //} else { + // let request = api::sponds() + // .add_profile_info(false) + // .comments(true) + // .hidden(false) + // .scheduled(true) + // ; + // for spond in request.call(&client).await? { + // println!("{spond:?}"); + // } } Ok(()) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dc8a583 --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772615108, + "narHash": "sha256-lC0KbklwgeSqS+sTkaYpnSYr/HDeVMzYUZqV/dT31Lo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0c39f3b5a9a234421d4ad43ab9c7cf64840172d0", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4130f54 --- /dev/null +++ b/flake.nix @@ -0,0 +1,64 @@ +{ + outputs = { nixpkgs, ... }: let + spond = { rustPlatform, rustfmt, clippy, pkg-config, openssl, ... }: rustPlatform.buildRustPackage { + pname = "spond"; + version = "0.0.0"; + + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ + pkg-config + ]; + propagatedBuildInputs = [ + openssl.dev + ]; + }; + + allpkgs = system: pkgs: pkgs.extend (_: _: nixpkgs.lib.attrsets.filterAttrs (name: _: name != "default") (packages system pkgs)); + + packages = system: pkgs': let + pkgs = allpkgs system pkgs'; + in { + default = pkgs.spond; + spond = pkgs.callPackage spond {}; + }; + + devShells = system: pkgs': let + pkgs = allpkgs system pkgs'; + in builtins.mapAttrs (devShell pkgs) (packages system pkgs'); + + devShell = pkgs: name: pkg: pkgs.mkShell { + buildInputs = with pkgs; [ + cargo + cargo-bloat + cargo-machete + cargo-workspaces + cargo-unused-features + cargo-udeps + cargo-audit + cargo-diet + cargo-duplicates + cargo-expand + cargo-flamegraph + clippy + lldb + gdb + + (python3.withPackages (py: [ py.pyyaml ])) + + rustc + rustfmt + openssl.dev + ] ++ pkg.buildInputs; + + nativeBuildInputs = pkg.nativeBuildInputs; + + shellHook = '' + printf 'Dev shell for %s ready!\n' '${pkg.name}' + ''; + }; + in { + packages = builtins.mapAttrs packages nixpkgs.legacyPackages; + devShells = builtins.mapAttrs devShells nixpkgs.legacyPackages; + }; +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 995f029..4296daa 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,21 +1,67 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, ItemFn, FnArg, Pat, PatType, PatIdent, Type, LitStr}; +use syn::{parse_macro_input, ItemFn, LitStr}; use syn::{punctuated::Punctuated, token::Comma}; -fn extract_path_args(inputs: &Punctuated) -> Vec<(syn::Ident, syn::Type)> { - let mut args = Vec::new(); - for arg in inputs { +#[derive(Copy, Clone, PartialEq)] +enum Class { + Query, + Path, + Body, +} + +impl Class { + fn classify(pat_type: &syn::PatType, default: Self) -> Self { + let mut result = None; + for a in pat_type.attrs.iter() { + let class = if a.path().is_ident("path") { + Some(Self::Path) + } else if a.path().is_ident("query") { + Some(Self::Query) + } else if a.path().is_ident("body") { + Some(Self::Body) + } else { + None + }; + if class.is_some() { + if result.is_some() && result != class { + panic!("can only have one class!"); + } + result = class; + } + } + + if let Some(result) = result { + result + } else { + default + } + } +} + +fn extract_args(inputs: &Punctuated, class: Class, default: Class) -> (Vec, Vec, Vec) { + let mut idents = Vec::new(); + let mut types = Vec::new(); + let mut attrs = Vec::new(); + for arg in inputs.iter().skip(1) { if let syn::FnArg::Typed(pat_type) = arg { - if pat_type.attrs.iter().any(|a| a.path().is_ident("path")) { + //if pat_type.attrs.iter().any(|a| a.path().is_ident(path)) { + if Class::classify(&pat_type, default) == class { if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { - args.push((pat_ident.ident.clone(), (*pat_type.ty).clone())); + idents.push(pat_ident.ident.clone()); + types.push((*pat_type.ty).clone()); + let meta = pat_type.attrs.iter() + .filter(|a| !["path", "query", "body"].iter().any(|p| a.path().is_ident(p))); + let meta = quote! { + #( #meta )* + }; + attrs.push(meta); } } } } - args + (idents, types, attrs) } fn generate_endpoint(attr: TokenStream, item: TokenStream, method: &str) -> TokenStream { @@ -26,24 +72,50 @@ fn generate_endpoint(attr: TokenStream, item: TokenStream, method: &str) -> Toke let vis = &item_fn.vis; let generics = &item_fn.sig.generics; - let path_args = extract_path_args(&item_fn.sig.inputs); - let path_idents: Vec<_> = path_args.iter().map(|(id, _)| id).collect(); - let path_types: Vec<_> = path_args.iter().map(|(_, ty)| ty).collect(); + let default = Class::Body; + let (path_idents, path_types, path_attrs) = extract_args(&item_fn.sig.inputs, Class::Path, default); + let (query_idents, query_types, query_attrs) = extract_args(&item_fn.sig.inputs, Class::Query, default); + let (body_idents, body_types, body_attrs) = extract_args(&item_fn.sig.inputs, Class::Body, default); let ret_type = match &item_fn.sig.output { syn::ReturnType::Default => quote! { () }, syn::ReturnType::Type(_, ty) => quote! { #ty }, }; + let queries = query_idents.len(); let expanded = quote! { #[bon::builder] #vis async fn #fn_name #generics( - #[builder(finish_fn)] client: restson::RestClient, - #( #[builder(finish_fn)] #path_idents: #path_types, )* + #[builder(finish_fn)] client: &restson::RestClient, + #( #[builder(finish_fn)] #path_attrs #path_idents: #path_types, )* + #( #query_attrs #query_idents: #query_types, )* + #( #body_attrs #body_idents: #body_types, )* ) -> Result<#ret_type, restson::Error> { let path = format!(#path_lit, #( #path_idents = #path_idents, )* ); - todo!("Replace this with client.{} call", #method) + let mut query = Vec::with_capacity(#queries); + + + + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Q { + #( + #query_attrs #query_idents: #query_types, + )* + } + let q = Q { #( #query_idents, )* }; + let s = serde_qs::to_string(&q).expect("serde_qs serialization"); + for pair in s.split('&') { + let mut kv = pair.splitn(2, '='); + match (kv.next(), kv.next()) { + (Some(k), Some(v)) => query.push((k, v)), + (Some(k), None) => query.push((k, "")), + _ => panic!("should never happen!"), + } + } + + todo!("client.{}({path}, {query:?})", #method) } };