restson
This commit is contained in:
commit
a0d3c6cf9c
8 changed files with 2415 additions and 0 deletions
1894
cli/Cargo.lock
generated
Normal file
1894
cli/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
cli/Cargo.toml
Normal file
18
cli/Cargo.toml
Normal 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
78
cli/src/api/mod.rs
Normal 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
165
cli/src/authentication.rs
Normal 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
62
cli/src/main.rs
Normal 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
111
cli/src/request/get.rs
Normal 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
2
cli/src/request/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod get;
|
||||
pub mod post;
|
||||
85
cli/src/request/post.rs
Normal file
85
cli/src/request/post.rs
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue