Compare commits
10 commits
a03c1891c8
...
28a03bde35
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a03bde35 | |||
| 47c353c7da | |||
| b09e8eafbb | |||
| e69bcfc23d | |||
| aaf9781fe8 | |||
| 9a5a5fb9d2 | |||
| f99777bf77 | |||
| 9ab32a7c85 | |||
| 10bbee30db | |||
| a0ddaf89a9 |
27 changed files with 3665 additions and 350 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!/api/
|
||||||
|
!/cli/
|
||||||
|
!Cargo.toml
|
||||||
|
!/*/src/
|
||||||
|
!/*/src/**/
|
||||||
|
!/*/src/**/*.rs
|
||||||
2004
Cargo.lock
generated
Normal file
2004
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
4
Cargo.toml
Normal file
4
Cargo.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
#members = ["api","cli","schema"]
|
||||||
|
members = ["api", "cli" , "macros"]
|
||||||
12
api/Cargo.toml
Normal file
12
api/Cargo.toml
Normal 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"
|
||||||
138
api/src/authentication.rs
Normal file
138
api/src/authentication.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
use restson::{Error, Response, RestClient, RestPath};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[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 crate::util::DateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
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
39
api/src/group.rs
Normal 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;
|
||||||
|
}
|
||||||
120
api/src/lib.rs
Normal file
120
api/src/lib.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
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)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub enum Order {
|
||||||
|
#[default]
|
||||||
|
Ascending,
|
||||||
|
Descending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>,
|
||||||
|
}
|
||||||
41
api/src/profile.rs
Normal file
41
api/src/profile.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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
18
api/src/series.rs
Normal 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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
228
api/src/spond.rs
Normal file
228
api/src/spond.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
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
41
api/src/user.rs
Normal 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())
|
||||||
|
}
|
||||||
33
api/src/util/chrono/datetime.rs
Normal file
33
api/src/util/chrono/datetime.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct DateTime(chrono::DateTime<chrono::Utc>);
|
||||||
|
|
||||||
|
impl Serialize for DateTime {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DateTime {
|
||||||
|
fn default() -> Self {
|
||||||
|
chrono::Utc::now().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DateTime {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.0.to_rfc3339_opts(chrono::SecondsFormat::Secs, true,)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chrono::DateTime<chrono::Utc>> for DateTime {
|
||||||
|
fn from(dt: chrono::DateTime<chrono::Utc>) -> Self {
|
||||||
|
Self(dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
api/src/util/chrono/mod.rs
Normal file
5
api/src/util/chrono/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod datetime;
|
||||||
|
pub use datetime::DateTime;
|
||||||
|
|
||||||
|
mod timestamp;
|
||||||
|
pub use timestamp::Timestamp;
|
||||||
51
api/src/util/chrono/timestamp.rs
Normal file
51
api/src/util/chrono/timestamp.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer}; // for timestamp_millis_opt
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timestamp(chrono::DateTime<chrono::Utc>);
|
||||||
|
|
||||||
|
impl Serialize for Timestamp {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_i64(self.0.timestamp_millis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Timestamp {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let millis = i64::deserialize(deserializer)?;
|
||||||
|
let dt = chrono::Utc
|
||||||
|
.timestamp_millis_opt(millis)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("invalid timestamp"))?;
|
||||||
|
|
||||||
|
Ok(Timestamp(dt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Timestamp {
|
||||||
|
fn default() -> Self {
|
||||||
|
chrono::Utc::now().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Timestamp {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.0.to_rfc3339_opts(chrono::SecondsFormat::Secs, true,)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: Into<chrono::DateTime<chrono::Utc>>> From<I> for Timestamp {
|
||||||
|
fn from(dt: I) -> Self {
|
||||||
|
Self(dt.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
114
api/src/util/id.rs
Normal file
114
api/src/util/id.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
fn short_type_name(full: &str) -> &str {
|
||||||
|
let generic = full.find('<').unwrap_or(full.len());
|
||||||
|
let last_colon = full[..generic].rfind("::").map_or(0, |idx| idx + 2);
|
||||||
|
|
||||||
|
&full[last_colon..]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Type: Sized {
|
||||||
|
type Type: fmt::Display + Copy;
|
||||||
|
|
||||||
|
fn name() -> &'static str {
|
||||||
|
short_type_name(std::any::type_name::<Self>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Id<T: Type>(T::Type);
|
||||||
|
|
||||||
|
impl<T: Type> AsRef<T::Type> for Id<T> {
|
||||||
|
fn as_ref(&self) -> &T::Type {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Type> Copy for Id<T> {}
|
||||||
|
|
||||||
|
impl<T: Type> Clone for Id<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Type> Id<T> {
|
||||||
|
pub fn new(src: T::Type) -> Self {
|
||||||
|
Self(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(
|
||||||
|
&self,
|
||||||
|
client: &restson::RestClient,
|
||||||
|
) -> Result<T, restson::Error>
|
||||||
|
where
|
||||||
|
T: restson::RestPath<Id<T>>,
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{ Ok(client.get_with::<_, T>(*self, &[]).await?.into_inner()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Type> fmt::Debug for Id<T>
|
||||||
|
where
|
||||||
|
T::Type: fmt::Debug,
|
||||||
|
{
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "ID<{}:{}>", T::name(), self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Type> fmt::Display for Id<T> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Serialize for Id<T>
|
||||||
|
where
|
||||||
|
T: Type,
|
||||||
|
T::Type: Serialize,
|
||||||
|
{
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
self.0.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de, T> Deserialize<'de> for Id<T>
|
||||||
|
where
|
||||||
|
T: Type,
|
||||||
|
T::Type: Deserialize<'de>,
|
||||||
|
{
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Self(T::Type::deserialize(deserializer)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> PartialEq for Id<T>
|
||||||
|
where
|
||||||
|
T: Type,
|
||||||
|
T::Type: PartialEq,
|
||||||
|
{
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.0 == other.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Eq for Id<T>
|
||||||
|
where
|
||||||
|
T: Type,
|
||||||
|
T::Type: Eq,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Hash for Id<T>
|
||||||
|
where
|
||||||
|
T: Type,
|
||||||
|
T::Type: Hash,
|
||||||
|
{
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
self.0.hash(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
api/src/util/mod.rs
Normal file
11
api/src/util/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
pub mod chrono;
|
||||||
|
pub use chrono::{DateTime, Timestamp};
|
||||||
|
|
||||||
|
pub mod x128;
|
||||||
|
pub use x128::X128;
|
||||||
|
|
||||||
|
pub mod id;
|
||||||
|
pub use id::Id;
|
||||||
|
|
||||||
|
pub mod visibility;
|
||||||
|
pub use visibility::Visibility;
|
||||||
36
api/src/util/visibility.rs
Normal file
36
api/src/util/visibility.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Visibility {
|
||||||
|
Invitees,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Visibility {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let s = match self {
|
||||||
|
Visibility::Invitees => "INVITEES",
|
||||||
|
Visibility::Unknown(other) => other,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializer.serialize_str(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Visibility {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// Borrow when possible
|
||||||
|
let s = <&str>::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
Ok(match s {
|
||||||
|
"INVITEES" => Visibility::Invitees,
|
||||||
|
other => Visibility::Unknown(other.to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
84
api/src/util/x128.rs
Normal file
84
api/src/util/x128.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Wrapper for u128 that serializes/deserializes as 32-charachter hex
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||||
|
pub struct X128(u128);
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid length: {0}")]
|
||||||
|
Length(usize),
|
||||||
|
|
||||||
|
#[error("parser error: {0}")]
|
||||||
|
Parser(#[from] std::num::ParseIntError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X128 {
|
||||||
|
/// construct a new value
|
||||||
|
pub fn new(value: u128) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// access the inner value explicitely
|
||||||
|
pub fn value(&self) -> u128 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Into<u128>> From<T> for X128 {
|
||||||
|
fn from(src: T) -> Self {
|
||||||
|
Self::new(src.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for X128 {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Error> {
|
||||||
|
match s.len() {
|
||||||
|
32 => u128::from_str_radix(s, 16)
|
||||||
|
.map(Self::new)
|
||||||
|
.map_err(Error::Parser),
|
||||||
|
len => Err(Error::Length(len)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//impl TryFrom<&str> for X128 {
|
||||||
|
// type Error = Error;
|
||||||
|
//
|
||||||
|
// fn try_from(s: &str) -> Result<Self, Error> {
|
||||||
|
// Self::from_str(s)
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
impl Deref for X128 {
|
||||||
|
type Target = u128;
|
||||||
|
|
||||||
|
fn deref(&self) -> &u128 {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for X128 {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{:032X}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for X128 {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for X128 {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
Self::from_str(&s).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tb-rs"
|
name = "tb-spond-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|
@ -9,10 +9,17 @@ 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"
|
||||||
|
serde_qs = "1.0.0"
|
||||||
|
spond-api = { version = "0.1.0", path = "../api" }
|
||||||
|
thiserror = "2.0.18"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
//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(
|
|
||||||
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 {
|
|
||||||
//}
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
use clap::{Args, ArgGroup};
|
use anyhow::{Error, Result};
|
||||||
use restson::{RestClient, RestPath, Response};
|
use clap::{ArgGroup, Args};
|
||||||
use anyhow::{Result, Error};
|
use restson::RestClient;
|
||||||
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,140 +22,119 @@ 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(v), None, None, None) => v.apply(client)?,
|
||||||
(None, Some(v), None, None) => Tokens::authenticate(client, v).await?,
|
(None, Some(v), None, None) => v.apply(client).await?,
|
||||||
(None, None, Some(v), None) => Tokens::authenticate(client, v).await?,
|
(None, None, Some(v), None) => v.apply(client).await?,
|
||||||
(None, None, None, Some(v)) => Tokens::authenticate(client, v).await?,
|
(None, None, None, Some(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()
|
||||||
|
),
|
||||||
};
|
};
|
||||||
Ok(client)
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct WithPassword<I: Identifier> {
|
struct WithPassword {
|
||||||
value: I::Value,
|
value: String,
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
impl FromStr for WithPassword {
|
||||||
impl<I: Identifier> Serialize for WithPassword<I> {
|
type Err = Error;
|
||||||
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> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
let password = match std::env::var("SPOND_PASSWORD") {
|
let password = match std::env::var("SPOND_PASSWORD") {
|
||||||
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 =
|
||||||
refresh: TokenWithExpiration,
|
spond_api::authentication::email(&client, &self.0.value, &self.0.password).await?;
|
||||||
}
|
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
26
cli/src/history.rs
Normal 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,
|
||||||
|
}
|
||||||
327
cli/src/main.rs
327
cli/src/main.rs
|
|
@ -1,12 +1,11 @@
|
||||||
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use restson::RestClient;
|
use restson::RestClient;
|
||||||
use anyhow::Result;
|
use spond_api as api;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
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,173 @@ 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?)
|
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::{RngExt, rng};
|
||||||
|
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<T, F, W>(&self, input: &[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");
|
||||||
|
log::trace!("temporary: {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
|
||||||
|
log::trace!("rename {tmp:?} -> {path:?}");
|
||||||
|
rename(&tmp, &path)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,20 +187,152 @@ 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;
|
||||||
|
let series = cli.series.map(api::SeriesId::new);
|
||||||
|
let heading = cli.heading.as_ref();
|
||||||
|
let vip: Vec<api::MemberId> = if let Some(ref vip) = cli.vip {
|
||||||
|
vip.iter().map(|id| api::MemberId::new(*id)).collect()
|
||||||
|
} else {
|
||||||
|
[
|
||||||
|
0xEB07B45E45E6449386E70A7411816B6Fu128,
|
||||||
|
0xD05F8574AC544C8DB1A7DC5B6347AA49u128,
|
||||||
|
]
|
||||||
|
.map(|x| api::MemberId::new(x.into()))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
let client = cli.client().await?;
|
||||||
|
let client = &client;
|
||||||
|
|
||||||
// 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
|
log::info!("seed: {seed}");
|
||||||
let query = [
|
if let Some(series) = series {
|
||||||
("includeComments", "true"),
|
log::info!("series: {series}");
|
||||||
("includeHidden", "false"),
|
}
|
||||||
("addProfileInfo", "true"),
|
if let Some(heading) = heading {
|
||||||
("scheduled", "true"),
|
log::info!("heading: {heading}");
|
||||||
("order", "asc"),
|
}
|
||||||
("max", "20"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for spond in client.get_with::<_, Sponds>((), &query).await?.into_inner().0 {
|
let now = chrono::Utc::now();
|
||||||
println!("{spond:?}");
|
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);
|
||||||
|
|
||||||
|
if spond.responses.waitinglist_ids.is_empty() {
|
||||||
|
log::info!("nobody on the waiting list");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(&decline)).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(&decline)).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(5)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = reorder(responses).await?;
|
||||||
|
weights.update(&update);
|
||||||
|
} else {
|
||||||
|
weights = Weights::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
log::debug!("{weights:?}");
|
||||||
|
|
||||||
|
if let Some(series) = spond.series_id {
|
||||||
|
weights.store(series)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ macro_rules! get {
|
||||||
// Case 1: no query
|
// Case 1: no query
|
||||||
(
|
(
|
||||||
$name:ident,
|
$name:ident,
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
|
get!($name(), ( $( $arg : $arg_ty ),* ) => $path -> $out);
|
||||||
};
|
};
|
||||||
|
|
@ -11,7 +11,7 @@ macro_rules! get {
|
||||||
// Case 2: empty query ()
|
// Case 2: empty query ()
|
||||||
(
|
(
|
||||||
$name:ident (),
|
$name:ident (),
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
pub fn $name(
|
pub fn $name(
|
||||||
|
|
@ -35,42 +35,30 @@ macro_rules! get {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Case 3: query with flags / mixed types
|
// Case 3: query with optional attributes
|
||||||
(
|
(
|
||||||
$name:ident ( $( $query:tt ),* $(,)? ),
|
$name:ident ( $( $(#[$attr:meta])* $query_ident:ident $(: $query_ty:ty )? ),* $(,)? ),
|
||||||
( $( $arg:ident : $arg_ty:ty ),* ) => $path:literal -> $out:ty $(,)?
|
( $( $arg:ident : $arg_ty:ty ),* $(,)? ) => $path:literal -> $out:ty
|
||||||
) => {
|
) => {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
pub fn $name(
|
pub fn $name(
|
||||||
#[builder(finish_fn)] client: &restson::RestClient,
|
#[builder(finish_fn)] client: &restson::RestClient,
|
||||||
$( #[builder(finish_fn)] $arg: $arg_ty, )*
|
$( #[builder(finish_fn)] $arg: $arg_ty, )*
|
||||||
$(
|
$(
|
||||||
get!(@query_field $query)
|
$(#[$attr])* $query_ident : $crate::get!( @query_type $( $query_ty )? ) ,
|
||||||
)*
|
)*
|
||||||
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
|
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct Query<'a> {
|
|
||||||
$(
|
|
||||||
get!(@query_field_struct $query)
|
|
||||||
)*
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Query<'_> {
|
// Build Vec<(String,String)> dynamically using serde_json::to_string
|
||||||
fn as_pairs(&self) -> Vec<(&str, String)> {
|
let query_pairs: Vec<(String, String)> = vec![
|
||||||
let mut out = Vec::new();
|
|
||||||
$(
|
|
||||||
get!(@push_pair out, self, $query)
|
|
||||||
)*
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = Query {
|
|
||||||
$(
|
$(
|
||||||
get!(@query_field_init $query)
|
{
|
||||||
)*
|
let val = serde_json::to_string(&$query_ident)
|
||||||
};
|
.expect("failed to serialize query param");
|
||||||
|
(stringify!($query_ident).to_string(), val)
|
||||||
|
}
|
||||||
|
),*
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct RP($out);
|
struct RP($out);
|
||||||
|
|
@ -83,28 +71,18 @@ macro_rules! get {
|
||||||
}
|
}
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let result = client.get_with::<_, RP>(( $( $arg ),* ), &query.as_pairs()).await?;
|
// Convert Vec<(String,String)> to Vec<(&str,&str)>
|
||||||
|
let ref_pairs: Vec<(&str, &str)> = query_pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = client.get_with::<_, RP>(( $( $arg ),* ), &ref_pairs).await?;
|
||||||
Ok(result.into_inner().0)
|
Ok(result.into_inner().0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Query helpers
|
( @query_type ) => { Option<bool> };
|
||||||
(@query_field $field:ident) => { $field: Option<bool>, };
|
( @query_type $ty:ty) => { $ty };
|
||||||
(@query_field $field:ident = $ty:ty) => { $field: $ty, };
|
|
||||||
|
|
||||||
(@query_field_struct $field:ident) => { $field: Option<bool>, };
|
|
||||||
(@query_field_struct $field:ident = $ty:ty) => { $field: $ty, };
|
|
||||||
|
|
||||||
(@query_field_init $field:ident) => { $field, };
|
|
||||||
(@query_field_init $field:ident = $ty:ty) => { $field, };
|
|
||||||
|
|
||||||
(@push_pair $vec:ident, $self:ident, $field:ident = $ty:ty) => {
|
|
||||||
$vec.push((stringify!($field), $self.$field.to_string()));
|
|
||||||
};
|
|
||||||
(@push_pair $vec:ident, $self:ident, $field:ident) => {
|
|
||||||
if let Some(v) = &$self.$field {
|
|
||||||
$vec.push((stringify!($field), v.to_string()));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
flake.lock
generated
Normal file
25
flake.lock
generated
Normal 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
|
||||||
|
}
|
||||||
105
flake.nix
Normal file
105
flake.nix
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
{
|
||||||
|
outputs = { nixpkgs, ... }: let
|
||||||
|
spond = { rustPlatform, rustfmt, clippy, pkg-config, openssl, ... }: rustPlatform.buildRustPackage {
|
||||||
|
pname = "tb-spond-rs";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
propagatedBuildInputs = [
|
||||||
|
openssl.dev
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
allpkgs = pkgs: pkgs.extend (_: _: nixpkgs.lib.attrsets.filterAttrs (name: _: name != "default") (packages' pkgs));
|
||||||
|
|
||||||
|
packages = system: packages';
|
||||||
|
packages' = pkgs': let
|
||||||
|
pkgs = allpkgs pkgs';
|
||||||
|
in {
|
||||||
|
default = pkgs.spond;
|
||||||
|
spond = pkgs.callPackage spond {};
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = system: pkgs': let
|
||||||
|
pkgs = allpkgs pkgs';
|
||||||
|
in builtins.mapAttrs (devShell pkgs) (packages 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}'
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosModule = { pkgs, lib, config, ... }: let
|
||||||
|
cli = lib.getExe (packages' pkgs).spond;
|
||||||
|
in {
|
||||||
|
config.systemd.timers."tb-spond-rs" = {
|
||||||
|
description = "[TB] choose who is allowed to participate this week.";
|
||||||
|
timerConfig.OnCalendar = "Sat 18:00:00";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
};
|
||||||
|
config.systemd.services."tb-spond-rs" = {
|
||||||
|
description = "[TB] choose who is allowed to participate this week.";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wants = [ "network.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
ExecStart = lib.escapeShellArgs [
|
||||||
|
cli
|
||||||
|
"--email" "jonas.rabenstein@web.de"
|
||||||
|
"--heading" "Schwimmtraining Donnerstag"
|
||||||
|
];
|
||||||
|
EnvironmentFile=[ "%d/environment" ];
|
||||||
|
User = "tb-spond-rs";
|
||||||
|
Group = "tb-spond-rs";
|
||||||
|
DynamicUser = true;
|
||||||
|
RuntimeDirectory = "tb-spond-rs";
|
||||||
|
StateDirectory = "tb-spond-rs";
|
||||||
|
ProtectSystem = "full";
|
||||||
|
ProtectHome = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
packages = builtins.mapAttrs packages nixpkgs.legacyPackages;
|
||||||
|
devShells = builtins.mapAttrs devShells nixpkgs.legacyPackages;
|
||||||
|
|
||||||
|
nixosModules = {
|
||||||
|
default = nixosModule;
|
||||||
|
spond = nixosModule;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,109 +1,132 @@
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::{quote, ToTokens};
|
use quote::quote;
|
||||||
use syn::{
|
use syn::{parse_macro_input, ItemFn, LitStr};
|
||||||
parse_macro_input, Attribute, FnArg, ItemFn, Lit, Meta, Pat, PatType, Type, spanned::Spanned,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Endpoint macro
|
use syn::{punctuated::Punctuated, token::Comma};
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn endpoint(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
// parse the endpoint attribute
|
|
||||||
let attr = proc_macro2::TokenStream::from(attr);
|
|
||||||
let func = parse_macro_input!(item as ItemFn);
|
|
||||||
|
|
||||||
let vis = &func.vis;
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
let name = &func.sig.ident;
|
enum Class {
|
||||||
let inputs = &func.sig.inputs;
|
Query,
|
||||||
let output = &func.sig.output;
|
Path,
|
||||||
|
Body,
|
||||||
|
}
|
||||||
|
|
||||||
// must be async
|
impl Class {
|
||||||
if func.sig.asyncness.is_none() {
|
fn classify(pat_type: &syn::PatType, default: Self) -> Self {
|
||||||
return syn::Error::new(name.span(), "endpoint function must be async")
|
let mut result = None;
|
||||||
.to_compile_error()
|
for a in pat_type.attrs.iter() {
|
||||||
.into();
|
let class = if a.path().is_ident("path") {
|
||||||
}
|
Some(Self::Path)
|
||||||
|
} else if a.path().is_ident("query") {
|
||||||
// defaults
|
Some(Self::Query)
|
||||||
let mut method = quote! { GET };
|
} else if a.path().is_ident("body") {
|
||||||
let mut path = None;
|
Some(Self::Body)
|
||||||
|
|
||||||
// parse #[endpoint(...)]
|
|
||||||
if !attr.is_empty() {
|
|
||||||
let attr_str = attr.to_string();
|
|
||||||
// simple heuristic: if contains "POST", switch
|
|
||||||
if attr_str.contains("POST") {
|
|
||||||
method = quote! { POST };
|
|
||||||
}
|
|
||||||
// simple heuristic: extract path in quotes
|
|
||||||
if let Some(start) = attr_str.find('"') {
|
|
||||||
if let Some(end) = attr_str[start+1..].find('"') {
|
|
||||||
path = Some(attr_str[start+1..start+1+end].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = match path {
|
|
||||||
Some(p) => p,
|
|
||||||
None => return syn::Error::new(name.span(), "endpoint path must be provided")
|
|
||||||
.to_compile_error()
|
|
||||||
.into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// process arguments
|
|
||||||
let mut client_arg = None;
|
|
||||||
let mut other_args = Vec::new();
|
|
||||||
|
|
||||||
for input in inputs {
|
|
||||||
match input {
|
|
||||||
FnArg::Receiver(_) => continue, // skip self
|
|
||||||
FnArg::Typed(PatType { pat, ty, .. }) => {
|
|
||||||
if let Pat::Ident(ident) = &**pat {
|
|
||||||
if ident.ident == "client" {
|
|
||||||
client_arg = Some((ident.ident.clone(), ty));
|
|
||||||
} else {
|
|
||||||
other_args.push((ident.ident.clone(), ty));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate tokens for function with builder
|
|
||||||
let arg_defs: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, ty)| {
|
|
||||||
// wrap Option<T> with #[builder(default)]
|
|
||||||
if let Type::Path(tp) = ty.as_ref() {
|
|
||||||
let is_option = tp.path.segments.last().map(|seg| seg.ident == "Option").unwrap_or(false);
|
|
||||||
if is_option {
|
|
||||||
quote! { #[builder(default)] #id: #ty }
|
|
||||||
} else {
|
} else {
|
||||||
quote! { #id: #ty }
|
None
|
||||||
|
};
|
||||||
|
if class.is_some() {
|
||||||
|
if result.is_some() && result != class {
|
||||||
|
panic!("can only have one class!");
|
||||||
|
}
|
||||||
|
result = class;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
quote! { #id: #ty }
|
|
||||||
}
|
}
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let client_def = match client_arg {
|
if let Some(result) = result {
|
||||||
Some((id, ty)) => quote! { #[builder(finish_fn)] #id: #ty },
|
result
|
||||||
None => quote! { #[builder(finish_fn)] client: &restson::RestClient },
|
} 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 pat_type.attrs.iter().any(|a| a.path().is_ident(path)) {
|
||||||
|
if Class::classify(pat_type, default) == class
|
||||||
|
&& let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(idents, types, attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_endpoint(attr: TokenStream, item: TokenStream, method: &str) -> TokenStream {
|
||||||
|
let item_fn = parse_macro_input!(item as ItemFn);
|
||||||
|
let path_lit = parse_macro_input!(attr as LitStr);
|
||||||
|
|
||||||
|
let fn_name = &item_fn.sig.ident;
|
||||||
|
let vis = &item_fn.vis;
|
||||||
|
let generics = &item_fn.sig.generics;
|
||||||
|
|
||||||
|
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 call_args: Vec<proc_macro2::TokenStream> = other_args.iter().map(|(id, _)| {
|
let queries = query_idents.len();
|
||||||
quote! { #id }
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let expanded = quote! {
|
let expanded = quote! {
|
||||||
#[bon::builder]
|
#[bon::builder]
|
||||||
#vis fn #name(
|
#vis async fn #fn_name #generics(
|
||||||
#client_def,
|
#[builder(finish_fn)] client: &restson::RestClient,
|
||||||
#(#arg_defs),*
|
#( #[builder(finish_fn)] #path_attrs #path_idents: #path_types, )*
|
||||||
) -> impl std::future::Future<Output = Result<_, restson::Error>> + '_ {
|
#( #query_attrs #query_idents: #query_types, )*
|
||||||
async move {
|
#( #body_attrs #body_idents: #body_types, )*
|
||||||
let result = client.get::<_, serde_json::Value>(#path).await?;
|
) -> Result<#ret_type, restson::Error> {
|
||||||
Ok(result)
|
let path = format!(#path_lit, #( #path_idents = #path_idents, )* );
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
expanded.into()
|
TokenStream::from(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
generate_endpoint(attr, item, "get_with")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
generate_endpoint(attr, item, "post_capture_with")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue