working example

This commit is contained in:
Jonas Rabenstein 2026-03-05 01:57:37 +01:00
commit e69bcfc23d
18 changed files with 1290 additions and 252 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
*
!.gitignore
!/api/
!/cli/
!Cargo.toml
!/*/src/
!/*/src/**/*.rs

4
Cargo.toml Normal file
View file

@ -0,0 +1,4 @@
[workspace]
resolver = "3"
#members = ["api","cli","schema"]
members = ["api", "cli" , "macros"]

12
api/Cargo.toml Normal file
View file

@ -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"

127
api/src/authentication.rs Normal file
View file

@ -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<String, Error> {
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<String, Error> {
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<String, Error> {
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<str> 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<R>(client: &RestClient, request: R) -> Result<Tokens, Error>
where
R: RestPath<()>,
R: Serialize,
{
let tokens: Response<Tokens> = client.post_capture((), &request).await?;
Ok(tokens.into_inner())
}
pub fn email<'a>(client: &'a RestClient, email: &'a str, password: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Email { email, password })
}
pub fn phone<'a>(client: &'a RestClient, phone: &'a str, password: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Phone { phone, password })
}
pub fn token<'a>(client: &'a RestClient, token: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Token { token })
}

39
api/src/group.rs Normal file
View file

@ -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<Id> for Group {
fn get_path(id: Id) -> std::result::Result<String, Error> {
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;
}

123
api/src/lib.rs Normal file
View file

@ -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<Profile>;
pub mod spond;
pub use spond::Spond;
pub type SpondId = util::Id<Spond>;
pub mod series;
pub use series::Series;
pub type SeriesId = util::Id<Series>;
pub mod group;
pub use group::Group;
pub type GroupId = util::Id<Group>;
pub type MemberId = util::Id<group::Member>;
impl<T: restson::RestPath<util::Id<T>>> util::id::Type for T {
type Type = util::X128;
}
//impl<T: restson::RestPath<(util::Id<T>,)> + serde::de::DeserializeOwned + util::id::Type>
// util::Id<T>
//{
// pub async fn load(&self, client: &restson::RestClient) -> Result<T, restson::Error> {
// 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<impl std::fmt::Display>) {
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<str> for Order {
fn as_ref(&self) -> &str {
match self {
Self::Ascending => "asc",
Self::Descending => "desc",
}
}
}
impl From<bool> 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<MemberId>,
#[serde(default)]
pub declined_ids: Vec<MemberId>,
#[serde(default)]
pub waitinglist_ids: Vec<MemberId>,
#[serde(default)]
pub unanswered_ids: Vec<MemberId>,
#[serde(default)]
pub unconfirmed_ids: Vec<MemberId>,
#[serde(default)]
pub participant_ids: Vec<MemberId>,
#[serde(default)]
pub decline_messages: Map<MemberId, DeclineMessage>,
#[serde(flatten)]
pub unknown: Map<String, serde_json::Value>,
}

44
api/src/profile.rs Normal file
View file

@ -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<Id> for Profile {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("profile/{id}"))
}
}
impl RestPath<()> for Profile {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok("profile".to_string())
}
}
pub async fn identity(client: &RestClient) -> Result<Profile, Error> {
Ok(client.get_with::<_, Profile>((), &[]).await?.into_inner())
}
pub async fn with_id(client: &RestClient, id: Id) -> Result<Profile, Error> {
Ok(client
.get_with::<_, Profile>(id, &[])
.await?
.into_inner())
}

18
api/src/series.rs Normal file
View file

@ -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<Id> for Series {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("series/{id}"))
}
}

242
api/src/spond.rs Normal file
View file

@ -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<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Recipients {
pub group: HashMap<String, serde_json::Value>,
pub guardians: std::vec::Vec<serde_json::Value>,
pub profiles: std::vec::Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
pub id: crate::util::Id<Comment>,
pub children: std::vec::Vec<serde_json::Value>,
pub from_profile_id: ProfileId,
pub reactions: HashMap<String, HashMap<ProfileId, Timestamp>>,
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<SeriesId>,
pub responses: Responses,
pub updated: Timestamp,
pub recipients: Recipients,
pub hidden: bool,
pub comments: Vec<Comment>,
pub auto_accept: bool,
pub start_timestamp: DateTime,
pub end_timestamp: DateTime,
pub creator_id: ProfileId,
pub visibility: Visibility,
pub behalf_of_ids: Vec<MemberId>,
#[serde(flatten)]
pub unknown: HashMap<String, serde_json::Value>,
}
#[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<Output=Result<Responses, Error>> {
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<Output=Result<Responses, Error>> {
self.response(member)
.accepted(true)
.call(client)
}
#[builder]
pub fn decline(&self,
#[builder(start_fn)]
member: MemberId,
#[builder(finish_fn)]
client: &RestClient,
) -> impl Future<Output=Result<Responses, Error>> {
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<Spond>);
impl RestPath<()> for Sponds {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok(String::from("sponds"))
}
}
impl RestPath<Id> for Spond {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("sponds/{id}"))
}
}
impl RestPath<()> for Spond {
fn get_path(_: ()) -> std::result::Result<String, Error> {
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<bool>,
add_profile_info: Option<bool>,
) -> Result<Spond, Error> {
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<bool>,
comments: Option<bool>,
hidden: Option<bool>,
add_profile_info: Option<bool>,
scheduled: Option<bool>,
#[builder(default, into)] order: Order,
#[builder(default = 20)] max: usize,
#[builder(into)] min_end_timestamp: Option<DateTime>,
#[builder(into)] max_end_timestamp: Option<DateTime>,
#[builder(into)] min_start_timestamp: Option<DateTime>,
#[builder(into)] max_start_timestamp: Option<DateTime>,
prev_id: Option<Id>,
series_id: Option<SeriesId>,
) -> Result<Vec<Spond>, 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<Output=Result<Responses, Error>> {
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<Output=Result<Responses, Error>> {
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<Responses, Error> {
#[derive(Debug, serde::Serialize)]
struct Request {
accepted: bool,
}
impl RestPath<(Id, MemberId)> for Request {
fn get_path(args: (Id, MemberId)) -> std::result::Result<String, Error> {
let (spond, member) = args;
Ok(format!("sponds/{spond}/responses/{member}"))
}
}
let request = Request{accepted};
let response: restson::Response<Responses> = client.put_capture((spond, member), &request).await?;
Ok(response.into_inner())
}

41
api/src/user.rs Normal file
View file

@ -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<String, Error> {
Ok(format!("user"))
}
}
impl RestPath<(Id, )> for User {
fn get_path(args: (Id, )) -> std::result::Result<String, Error> {
let (id, ) = args;
Ok(format!("user/{id}"))
}
}
pub async fn identity(client: &RestClient) -> Result<User, Error> {
Ok(client.get_with::<_, User>((), &[]).await?.into_inner())
}
pub async fn with_id(client: &RestClient, id: Id) -> Result<User, Error> {
Ok(client.get_with::<_, User>((id, ), &[]).await?.into_inner())
}

View file

@ -9,11 +9,18 @@ bon = "3.9.0"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "4.5.60", features = ["cargo", "derive", "env" ] } clap = { version = "4.5.60", features = ["cargo", "derive", "env" ] }
env_logger = "0.11.9" env_logger = "0.11.9"
futures = "0.3.32"
http = "1.4.0" http = "1.4.0"
log = "0.4.29"
rand = "0.10.0"
restson = "1.5.0" 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" } 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"] } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
url = "2.5.8" url = "2.5.8"
xdg = "3.0.0"

View file

@ -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<str> for Order {
fn as_ref(&self) -> &str {
match self {
Self::Ascending => "asc",
Self::Descending => "desc",
}
}
}
impl From<bool> 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<DateTime<Utc>>,
// max_end_timestamp=Option<DateTime<Utc>>,
//) -> Get<(), Vec<Spond>> {
//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,
hidden: bool,
add_profile_info: bool,
scheduled: bool,
#[builder(into)] order: Order,
#[builder(default=20)]max: usize,
)
min_end_timestamp: Option<DateTime<Utc>>,
max_end_timestamp: Option<DateTime<Utc>>,
() => "sponds" -> Vec<Spond>);
*/
//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<DateTime<Utc>>,
// max_end_timestamp=Option<DateTime<Utc>>,
//) -> Search {
//}

View file

@ -1,11 +1,7 @@
use clap::{Args, ArgGroup}; use clap::{Args, ArgGroup};
use restson::{RestClient, RestPath, Response}; use restson::RestClient;
use anyhow::{Result, Error}; use anyhow::{Result, Error};
use serde::{ use std::str::{FromStr};
ser::{Serialize, Serializer, SerializeMap},
Deserialize,
};
use chrono::{DateTime, Utc};
#[derive(Args, Debug)] #[derive(Args, Debug)]
#[command(group( #[command(group(
@ -14,10 +10,10 @@ use chrono::{DateTime, Utc};
))] ))]
pub struct Authentication { pub struct Authentication {
#[arg(long)] #[arg(long)]
access: Option<Token>, access: Option<Access>,
#[arg(long)] #[arg(long)]
refresh: Option<Token>, refresh: Option<Refresh>,
#[arg(long)] #[arg(long)]
email: Option<Email>, email: Option<Email>,
@ -26,13 +22,18 @@ pub struct Authentication {
phone: Option<Phone>, phone: Option<Phone>,
} }
fn bearer(mut client: RestClient, token: &str) -> Result<RestClient> {
client.set_header("Authorization", &format!("Bearer {}", token))?;
Ok(client)
}
impl Authentication { impl Authentication {
pub async fn apply(self, client: RestClient) -> Result<RestClient> { pub async fn apply(&self, client: RestClient) -> Result<RestClient> {
let client = match (self.access, self.refresh, self.email, self.phone) { let client = match (self.access.as_ref(), self.refresh.as_ref(), self.email.as_ref(), self.phone.as_ref()) {
(Some(v), None, None, None) => v.apply(client)?, (Some(ref v), None, None, None) => v.apply(client)?,
(None, Some(v), None, None) => Tokens::authenticate(client, v).await?, (None, Some(ref v), None, None) => v.apply(client).await?,
(None, None, Some(v), None) => Tokens::authenticate(client, v).await?, (None, None, Some(ref v), None) => v.apply(client).await?,
(None, None, None, Some(v)) => Tokens::authenticate(client, v).await?, (None, None, None, Some(ref v)) => v.apply(client).await?,
(None, None, None, None) => client, (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()), (a, b, c, d) => anyhow::bail!("invalid authentication: {} + {} + {} + {}", a.is_some(), b.is_some(), c.is_some(), d.is_some()),
}; };
@ -40,57 +41,12 @@ impl Authentication {
} }
} }
mod identifier {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Email; struct WithPassword {
#[derive(Debug, Clone)] value: String,
pub struct Phone;
}
trait Identifier: Clone {
const NAME: &'static str;
type Value: std::str::FromStr<Err=Self::Error> + 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 = <String as std::str::FromStr>::Err;
}
impl Identifier for identifier::Phone {
const NAME: &'static str = "phone";
type Value = String;
type Error = <String as std::str::FromStr>::Err;
}
#[derive(Debug, Clone)]
struct WithPassword<I: Identifier> {
value: I::Value,
password: String, password: String,
} }
impl FromStr for WithPassword {
impl<I: Identifier> Serialize for WithPassword<I> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error>
{
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry(I::NAME, &self.value)?;
map.serialize_entry("password", &self.password)?;
map.end()
}
}
impl<I: Identifier> RestPath<()> for WithPassword<I> {
fn get_path(_: ()) -> std::result::Result<String, restson::Error> {
Ok(String::from("auth2/login"))
}
}
type Email = WithPassword<identifier::Email>;
type Phone = WithPassword<identifier::Phone>;
impl<I: Identifier> std::str::FromStr for WithPassword<I> {
type Err = Error; type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
@ -98,68 +54,74 @@ impl<I: Identifier> std::str::FromStr for WithPassword<I> {
Ok(password) => password, Ok(password) => password,
Err(_) => rpassword::prompt_password("Password: ")?, Err(_) => rpassword::prompt_password("Password: ")?,
}; };
let value = I::Value::from_str(s)?; let value = String::from_str(s)?;
Ok(Self { value, password }) Ok(Self { value, password })
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Clone)]
struct Tokens { struct Email(WithPassword);
#[serde(rename = "accessToken")] impl Email {
access: TokenWithExpiration, async fn apply(&self, client: RestClient) -> Result<RestClient> {
#[serde(rename = "refreshToken")] let tokens = spond_api::authentication::email(&client, &self.0.value, &self.0.password).await?;
refresh: TokenWithExpiration, bearer(client, tokens.access.token.as_ref())
}
impl Tokens {
async fn authenticate<R: serde::Serialize + RestPath<()>>(client: RestClient, request: R) -> Result<RestClient> {
let tokens: Response<Self> = client.post_capture((), &request).await?;
tokens.into_inner().apply(client)
}
fn apply(self, client: RestClient) -> Result<RestClient> {
println!("refresh: {self:?}");
self.access.token.apply(client)
}
}
#[derive(Debug, Deserialize)]
struct TokenWithExpiration {
token: Token,
#[allow(unused)]
expiration: DateTime<Utc>
}
#[derive(Debug, Clone, Deserialize)]
struct Token(String);
impl Serialize for Token {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error>
{
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("token", &self.0)?;
map.end()
} }
} }
impl FromStr for Email {
impl Token { type Err= <WithPassword as FromStr>::Err;
fn apply(self, mut client: RestClient) -> Result<RestClient> {
client.set_header("Authorization", &format!("Bearer {}", self.0))?;
Ok(client)
}
}
impl RestPath<()> for Token {
fn get_path(_: ()) -> std::result::Result<String, restson::Error> {
Ok(String::from("auth2/login/refresh"))
}
}
impl std::str::FromStr for Token {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
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<RestClient> {
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= <WithPassword as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(WithPassword::from_str(s)?))
}
}
#[derive(Debug, Clone)]
struct Access(String);
impl Access {
fn apply(&self, client: RestClient) -> Result<RestClient> {
bearer(client, &self.0)
}
}
impl FromStr for Access {
type Err = std::convert::Infallible; // parsing a String never fails
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Access(s.to_string()))
}
}
#[derive(Debug, Clone)]
struct Refresh(String);
impl Refresh {
async fn apply(&self, client: RestClient) -> Result<RestClient> {
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<Self, Self::Err> {
Ok(Refresh(s.to_string()))
} }
} }

26
cli/src/history.rs Normal file
View file

@ -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<Self> {
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<I>(&mut self, selected: usize, ids: I) -> io::Result<()>
where
I: IntoIterator,
I::Item: Into<u128>,
I::IntoIter: ExactSizeIterator,
}

View file

@ -2,11 +2,10 @@ use clap::Parser;
use restson::RestClient; use restson::RestClient;
use anyhow::Result; use anyhow::Result;
use url::Url; use url::Url;
use spond_api as api;
use xdg::BaseDirectories as xdg;
mod authentication; mod authentication;
mod api;
mod request;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about)] #[command(author, version, about)]
@ -14,27 +13,176 @@ struct Cli {
#[command(flatten)] #[command(flatten)]
authentication: authentication::Authentication, authentication: authentication::Authentication,
#[arg(long, default_value_t)]
seed: Seed,
#[arg(long)]
vip: Option<Vec<api::util::X128>>,
#[arg(long)]
series: Option<api::util::X128>,
#[arg(long)]
heading: Option<String>,
#[arg(long, default_value = "https://api.spond.com/")] #[arg(long, default_value = "https://api.spond.com/")]
base: Url, base: Url,
} }
impl Cli { impl Cli {
pub async fn client(self) -> Result<RestClient> { pub async fn client(&self) -> Result<RestClient> {
let base = self.base.join("/core/v1/")?; let base = self.base.join("/core/v1/")?;
let client = RestClient::new(base.as_str())?; let client = RestClient::new(base.as_str())?;
Ok(self.authentication.apply(client).await?) Ok(self.authentication.apply(client).await?)
} }
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, Clone, Copy)]
struct Spond(serde_json::Value); struct Seed(u64);
#[derive(Debug, serde::Deserialize)] impl Default for Seed {
struct Sponds(Vec<Spond>); fn default() -> Self {
use rand::{rng, RngExt};
Self(rng().random())
}
}
impl restson::RestPath<()> for Sponds { impl std::str::FromStr for Seed {
fn get_path(_: ()) -> std::result::Result<String, restson::Error> { type Err = <u64 as std::str::FromStr>::Err;
Ok(String::from("sponds"))
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
u64::from_str_radix(s, 16).map(Seed)
}
}
impl<T: Into<u64>> From<T> 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<Vec<T>>
where
F: Fn(T) -> W,
W: Into<f64>,
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<Id>(std::collections::HashMap<Id, usize>);
use std::hash::Hash;
impl<Id> Default for Weights<Id>
where
Id: Eq + Hash + Copy,
{
fn default() -> Self {
Self(std::collections::HashMap::default())
}
}
impl<Id> Weights<Id>
where
Id: Eq + Hash + Copy,
{
fn path(serie: api::SeriesId) -> Result<std::path::PathBuf> {
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<Id>) -> 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<Id> Weights<Id>
where
Id: Eq + Hash + Copy + serde::de::DeserializeOwned
{
pub fn load(serie: api::SeriesId) -> Result<Self> {
let path = Self::path(serie)?;
log::debug!("load {path:?}");
let file = std::fs::OpenOptions::new()
.read(true)
.open(path)?;
let data: std::collections::HashMap<Id, usize> = serde_json::from_reader(file)?;
Ok(Self(data))
}
}
impl<Id> Weights<Id>
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<()> { async fn main() -> Result<()> {
env_logger::init(); env_logger::init();
let client = Cli::parse().client().await?; let cli = Cli::parse();
let seed = cli.seed;
// 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 series = cli.series.map(api::SeriesId::new);
if false { let heading = cli.heading.as_ref();
let query = [ let vip: Vec<api::MemberId> = if let Some(ref vip) = cli.vip {
("includeComments", "true"), vip.into_iter().map(|id| api::MemberId::new(*id)).collect()
("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 { } else {
let request = api::sponds() [
.comments(true) 0xEB07B45E45E6449386E70A7411816B6Fu128,
.hidden(false) 0xD05F8574AC544C8DB1A7DC5B6347AA49u128,
.add_profile_info(false) ].map(|x| api::MemberId::new(x.into())).into()
.scheduled(true) };
; let client = cli.client().await?;
for spond in request.call(&client).await? { let client = &client;
println!("{spond:?}");
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::<Vec<_>>();
if reorder.is_empty() {
let update = interested.iter()
.filter(|id| responses.waitinglist_ids.contains(id))
.cloned()
.collect::<Vec<_>>();
break Ok::<Vec<api::MemberId>, 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<Spond>);
//
//impl restson::RestPath<()> for Sponds {
// fn get_path(_: ()) -> std::result::Result<String, restson::Error> {
// 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(()) Ok(())

25
flake.lock generated Normal file
View file

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

64
flake.nix Normal file
View file

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

View file

@ -1,21 +1,67 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; 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}; use syn::{punctuated::Punctuated, token::Comma};
fn extract_path_args(inputs: &Punctuated<syn::FnArg, Comma>) -> Vec<(syn::Ident, syn::Type)> { #[derive(Copy, Clone, PartialEq)]
let mut args = Vec::new(); enum Class {
for arg in inputs { 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<syn::FnArg, Comma>, class: Class, default: Class) -> (Vec<syn::Ident>, Vec<syn::Type>, Vec<proc_macro2::TokenStream>) {
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 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 { 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 { 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 vis = &item_fn.vis;
let generics = &item_fn.sig.generics; let generics = &item_fn.sig.generics;
let path_args = extract_path_args(&item_fn.sig.inputs); let default = Class::Body;
let path_idents: Vec<_> = path_args.iter().map(|(id, _)| id).collect(); let (path_idents, path_types, path_attrs) = extract_args(&item_fn.sig.inputs, Class::Path, default);
let path_types: Vec<_> = path_args.iter().map(|(_, ty)| ty).collect(); 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 { let ret_type = match &item_fn.sig.output {
syn::ReturnType::Default => quote! { () }, syn::ReturnType::Default => quote! { () },
syn::ReturnType::Type(_, ty) => quote! { #ty }, syn::ReturnType::Type(_, ty) => quote! { #ty },
}; };
let queries = query_idents.len();
let expanded = quote! { let expanded = quote! {
#[bon::builder] #[bon::builder]
#vis async fn #fn_name #generics( #vis async fn #fn_name #generics(
#[builder(finish_fn)] client: restson::RestClient, #[builder(finish_fn)] client: &restson::RestClient,
#( #[builder(finish_fn)] #path_idents: #path_types, )* #( #[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> { ) -> Result<#ret_type, restson::Error> {
let path = format!(#path_lit, #( #path_idents = #path_idents, )* ); 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)
} }
}; };