This commit is contained in:
Jonas Rabenstein 2026-02-27 03:28:09 +01:00
commit a0d3c6cf9c
8 changed files with 2415 additions and 0 deletions

1894
cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
cli/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "tb-rs"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
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"
http = "1.4.0"
restson = "1.5.0"
rpassword = "7.4.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
url = "2.5.8"

78
cli/src/api/mod.rs Normal file
View file

@ -0,0 +1,78 @@
//use bon::Builder;
//use chrono::{DateTime,Utc};
//use restson::{RestClient, RestPath, Error};
//
//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(serde::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>> {
//
//}
//
//struct Sponds(Query);
//
//async pub fn sponds(
// #[builder(finish_fn)]
// client: &restson::RestClient, E
//
//
crate::get!(search(), () => "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 {
//}

165
cli/src/authentication.rs Normal file
View file

@ -0,0 +1,165 @@
use clap::{Args, ArgGroup};
use restson::{RestClient, RestPath, Response};
use anyhow::{Result, Error};
use serde::{
ser::{Serialize, Serializer, SerializeMap},
Deserialize,
};
use chrono::{DateTime, Utc};
#[derive(Args, Debug)]
#[command(group(
ArgGroup::new("authentication")
.args(["access", "refresh", "email", "phone"])
))]
pub struct Authentication {
#[arg(long)]
access: Option<Token>,
#[arg(long)]
refresh: Option<Token>,
#[arg(long)]
email: Option<Email>,
#[arg(long)]
phone: Option<Phone>,
}
impl Authentication {
pub async fn apply(self, client: RestClient) -> Result<RestClient> {
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?,
(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()),
};
Ok(client)
}
}
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<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,
}
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;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let password = match std::env::var("SPOND_PASSWORD") {
Ok(password) => password,
Err(_) => rpassword::prompt_password("Password: ")?,
};
let value = I::Value::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<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 Token {
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> {
Ok(Self(s.to_string()))
}
}

62
cli/src/main.rs Normal file
View file

@ -0,0 +1,62 @@
use clap::Parser;
use restson::RestClient;
use anyhow::Result;
use url::Url;
mod authentication;
mod api;
mod request;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Cli {
#[command(flatten)]
authentication: authentication::Authentication,
#[arg(long, default_value = "https://api.spond.com/")]
base: Url,
}
impl Cli {
pub async fn client(self) -> Result<RestClient> {
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, serde::Deserialize)]
struct Sponds(Vec<Spond>);
impl restson::RestPath<()> for Sponds {
fn get_path(_: ()) -> std::result::Result<String, restson::Error> {
Ok(String::from("sponds"))
}
}
#[tokio::main]
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
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:?}");
}
Ok(())
}

111
cli/src/request/get.rs Normal file
View file

@ -0,0 +1,111 @@
/// GET macro
#[macro_export]
macro_rules! get {
// Case 1: no query
(
$name:ident,
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
) => {
get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
};
// Case 2: empty query ()
(
$name:ident (),
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
) => {
#[bon::builder]
pub fn $name(
#[builder(finish_fn)] client: &restson::RestClient,
$( #[builder(finish_fn)] $arg: $arg_ty ),*
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
#[derive(serde::Deserialize)]
struct RP($out);
impl restson::RestPath<( $( $arg_ty ),* )> for RP {
fn get_path(args: ( $( $arg_ty ),* )) -> Result<String, restson::Error> {
let ( $( $arg ),* ) = args;
Ok(format!($path, $( $arg = $arg ),* ))
}
}
async move {
let result = client.get_with::<_, RP>(( $( $arg ),* ), &[]).await?;
Ok(result.into_inner().0)
}
}
};
// Case 3: query with flags / mixed types
(
$name:ident ( $( $query:tt ),* $(,)? ),
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
) => {
#[bon::builder]
pub fn $name(
#[builder(finish_fn)] client: &restson::RestClient,
$( #[builder(finish_fn)] $arg: $arg_ty, )*
$(
get!(@query_field $query)
)*
) -> 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<'_> {
fn as_pairs(&self) -> Vec<(&str, String)> {
let mut out = Vec::new();
$(
get!(@push_pair out, self, $query)
)*
out
}
}
let query = Query {
$(
get!(@query_field_init $query)
)*
};
#[derive(serde::Deserialize)]
struct RP($out);
impl restson::RestPath<( $( $arg_ty ),* )> for RP {
fn get_path(args: ( $( $arg_ty ),* )) -> Result<String, restson::Error> {
let ( $( $arg ),* ) = args;
Ok(format!($path, $( $arg = $arg ),* ))
}
}
async move {
let result = client.get_with::<_, RP>(&query.as_pairs(), ( $( $arg ),* )).await?;
Ok(result.0)
}
}
};
// Query helpers
(@query_field $field:ident) => { $field: Option<bool>, };
(@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()));
}
};
}

2
cli/src/request/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod get;
pub mod post;

85
cli/src/request/post.rs Normal file
View file

@ -0,0 +1,85 @@
/// Generate a POST function
#[macro_export]
macro_rules! post {
(
$name:ident ( $( $query:tt ),* $(,)? ),
$( $body:ident = $body_ty:ty ),* $(,)?,
( $( $arg:ident : $arg_ty:ty ),* ) => $path:expr $(,)?
) => {
#[bon::builder]
pub fn $name(
#[builder(finish_fn)]
client: &restson::Client,
$( #[builder(finish_fn)] $arg: $arg_ty, )*
$(
post!(@query_field $query)
)*
$(
$body: $body_ty,
)*
) -> impl std::future::Future<Output = Result<(), restson::Error>> + '_
{
// Query struct
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Query<'a> {
$(
post!(@query_field_struct $query)
)*
}
// Body struct
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
$(
$body: $body_ty,
)*
}
let query = Query {
$(
post!(@query_field_init $query)
)*
};
let body = Body {
$(
$body,
)*
};
impl restson::RestPath<( $( $arg_ty ),* )> for Body<'_> {
fn get_url($( $arg: $arg_ty ),*) -> Result<String, restson::Error> {
Ok(format!($path, $( $arg = $arg ),* ))
}
}
client.post_capture_with::<_, _, _>(&body, &query, ( $( $arg ),* ))
}
};
// Helper: parse query field plain vs typed
(@query_field $field:ident) => {
$field: Option<bool>,
};
(@query_field $field:ident = $ty:ty) => {
$field: $ty,
};
// Helper: struct fields
(@query_field_struct $field:ident) => {
$field: Option<bool>,
};
(@query_field_struct $field:ident = $ty:ty) => {
$field: $ty,
};
// Helper: initialize query fields
(@query_field_init $field:ident) => {
$field,
};
(@query_field_init $field:ident = $ty:ty) => {
$field,
};
}