Compare commits
No commits in common. "main" and "tb" have entirely different histories.
71 changed files with 5647 additions and 2200 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!/api/
|
||||||
|
!/cli/
|
||||||
|
!Cargo.toml
|
||||||
|
!/*/src/
|
||||||
|
!/*/src/**/
|
||||||
|
!/*/src/**/*.rs
|
||||||
1952
Cargo.lock
generated
Normal file
1952
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" ]
|
||||||
|
|
@ -1,29 +1,12 @@
|
||||||
[package]
|
[package]
|
||||||
name = "spond"
|
name = "spond-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
|
||||||
reqwest = [ "dep:reqwest", "reqwest/json" ]
|
|
||||||
log = [ "tracing/log" ]
|
|
||||||
tracing = [ ]
|
|
||||||
cookies = [ "reqwest?/cookies" ]
|
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.89"
|
|
||||||
bon = "3.9.0"
|
bon = "3.9.0"
|
||||||
bytes = "1.11.1"
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
restson = "1.5.0"
|
||||||
#macro = { path = "../macro" }
|
|
||||||
reqwest = { version = "0.13.2", optional = true }
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
#spond-auth-login = { version = "0.1.0", path = "../login" }
|
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.49.0" }
|
|
||||||
tracing = { version = "0.1.44", features = [] }
|
|
||||||
url = "2.5.8"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1.49.0", features = ["macros"] }
|
|
||||||
|
|
|
||||||
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 })
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct Email {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Email {
|
|
||||||
pub fn new(email: &str, password: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
email: email.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl super::Credentials for Email {}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
mod email;
|
|
||||||
mod phone;
|
|
||||||
|
|
||||||
pub use email::Email;
|
|
||||||
pub use phone::Phone;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
Method,
|
|
||||||
authentication::Tokens,
|
|
||||||
traits::{authentication::login::Credentials, endpoint},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Login;
|
|
||||||
|
|
||||||
impl<C: Credentials> endpoint::Public<C> for Login {
|
|
||||||
const METHOD: Method = Method::POST;
|
|
||||||
type Schema = Tokens;
|
|
||||||
|
|
||||||
fn path(&self, _: &C) -> &str {
|
|
||||||
"core/v1/auth2/login"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct Phone {
|
|
||||||
phone: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Phone {
|
|
||||||
pub fn new(phone: &str, password: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
phone: phone.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl super::Credentials for Phone {}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
pub mod login;
|
|
||||||
pub mod token;
|
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use token::Token;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Tokens {
|
|
||||||
pub access_token: token::Access,
|
|
||||||
pub refresh_token: token::Refresh,
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
use crate::traits::Request;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use std::ops::Deref;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Access(Expire);
|
|
||||||
|
|
||||||
impl Deref for Access {
|
|
||||||
type Target = Expire;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Expire {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct Refresh(Expire);
|
|
||||||
|
|
||||||
impl Deref for Refresh {
|
|
||||||
type Target = Expire;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Expire {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct Token {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for Token {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct Expire {
|
|
||||||
#[serde(flatten)]
|
|
||||||
token: Token,
|
|
||||||
#[serde(skip_serializing)]
|
|
||||||
expiration: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Expire {
|
|
||||||
pub fn expired(&self) -> bool {
|
|
||||||
Utc::now() <= self.expiration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for Expire {
|
|
||||||
type Target = Token;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Token {
|
|
||||||
&self.token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trait Factory: Serialize {}
|
|
||||||
impl Factory for Refresh {}
|
|
||||||
impl Factory for Token {}
|
|
||||||
|
|
||||||
impl<R: Factory + Request> crate::traits::endpoint::Public<R> for R
|
|
||||||
{
|
|
||||||
const METHOD: crate::Method = crate::Method::POST;
|
|
||||||
type Schema = super::Tokens;
|
|
||||||
|
|
||||||
fn path(&self, _: &R) -> &str {
|
|
||||||
"auth2/refresh"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
pub struct True;
|
|
||||||
pub struct False;
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub mod api {
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub type Refresh<Transfer, Decode> = Public<Transfer, Decode>;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Private<Transfer: std::error::Error, Decode: std::error::Error> {
|
|
||||||
#[error("endpoint url")]
|
|
||||||
Url(url::ParseError),
|
|
||||||
|
|
||||||
#[error("json decode")]
|
|
||||||
Decode(Decode),
|
|
||||||
|
|
||||||
#[error("unauthorized")]
|
|
||||||
Unauthorized,
|
|
||||||
|
|
||||||
#[error("token refresh")] // TODO: serde_json::Error -> Schema::...?
|
|
||||||
Refresh(Refresh<Transfer, serde_json::Error>),
|
|
||||||
|
|
||||||
#[error("send error")]
|
|
||||||
Send(Transfer),
|
|
||||||
|
|
||||||
#[error("receive error")]
|
|
||||||
Receive(Transfer),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Public<Transfer: std::error::Error, Decode: std::error::Error> {
|
|
||||||
#[error("endpoint url")]
|
|
||||||
Url(url::ParseError),
|
|
||||||
|
|
||||||
#[error("json decode")]
|
|
||||||
Decode(Decode),
|
|
||||||
|
|
||||||
#[error("send error")]
|
|
||||||
Send(Transfer),
|
|
||||||
|
|
||||||
#[error("receive error")]
|
|
||||||
Receive(Transfer),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Id {
|
|
||||||
#[error("invalid length")]
|
|
||||||
Length(usize),
|
|
||||||
|
|
||||||
#[error("invalid symbol")]
|
|
||||||
Parser(#[from] std::num::ParseIntError),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Authorization {
|
|
||||||
#[error("unauthorized")]
|
|
||||||
Unauthorized,
|
|
||||||
}
|
|
||||||
|
|
||||||
//#[derive(Debug, Error)]
|
|
||||||
//pub enum Authentication<A: std::error::Error, B: std::error::Error> {
|
|
||||||
// #[error("unauthorized")]
|
|
||||||
// Public(A),
|
|
||||||
//
|
|
||||||
// #[error("authorized")]
|
|
||||||
// Private(B),
|
|
||||||
//}
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::{
|
|
||||||
fmt,
|
|
||||||
str::FromStr,
|
|
||||||
ops::Deref,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
utils::X128,
|
|
||||||
traits::id::Marker,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
pub struct Id<T: Marker> {
|
|
||||||
#[serde(flatten)]
|
|
||||||
id: X128,
|
|
||||||
#[serde(skip)]
|
|
||||||
_marker: std::marker::PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> Id<T> {
|
|
||||||
pub fn new(value: impl Into<X128>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.into(),
|
|
||||||
_marker: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> Deref for Id<T> {
|
|
||||||
type Target = X128;
|
|
||||||
|
|
||||||
fn deref(&self) -> &X128 {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> fmt::Display for Id<T> {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> FromStr for Id<T> {
|
|
||||||
type Err = <X128 as FromStr>::Err;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
X128::from_str(s).map(Self::new)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
126
api/src/lib.rs
126
api/src/lib.rs
|
|
@ -1,20 +1,120 @@
|
||||||
|
use std::collections::{
|
||||||
|
HashMap as Map,
|
||||||
|
//HashSet as Set,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
pub mod authentication;
|
pub mod authentication;
|
||||||
pub mod authorization;
|
|
||||||
pub mod error;
|
|
||||||
pub mod id;
|
|
||||||
pub mod schema;
|
|
||||||
pub mod traits;
|
|
||||||
|
|
||||||
mod utils;
|
pub mod profile;
|
||||||
|
pub use profile::Profile;
|
||||||
|
pub type ProfileId = util::Id<Profile>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub mod spond;
|
||||||
pub enum Method {
|
pub use spond::Spond;
|
||||||
GET,
|
pub type SpondId = util::Id<Spond>;
|
||||||
POST,
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use id::Id;
|
//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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(feature = "reqwest")]
|
impl std::fmt::Display for Order {
|
||||||
pub mod reqwest;
|
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())
|
||||||
|
}
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use crate::{
|
|
||||||
authentication,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
use crate::traits::{
|
|
||||||
client,
|
|
||||||
endpoint,
|
|
||||||
Request,
|
|
||||||
Schema,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Public {
|
|
||||||
client: reqwest::Client,
|
|
||||||
base: url::Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Private {
|
|
||||||
public: Public,
|
|
||||||
tokens: tokio::sync::RwLock<authentication::Tokens>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Private {
|
|
||||||
async fn current_token(&self) -> authentication::Token {
|
|
||||||
let tokens = self.tokens.read().await;
|
|
||||||
let token: &authentication::Token = (&*tokens).into();
|
|
||||||
token.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_tokens(&self) -> Result<authentication::Token, error::api::Refresh<reqwest::Error, serde_json::Error>> {
|
|
||||||
let mut tokens = self.tokens.write().await;
|
|
||||||
*tokens = (*tokens).refresh(self).await?;
|
|
||||||
let token: &authentication::Token = (&*tokens).into();
|
|
||||||
Ok(token.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Client = Private;
|
|
||||||
|
|
||||||
|
|
||||||
type AuthorizedError<S: Schema<R>, R: Request> = error::api::Private<reqwest::Error, S::Error>;
|
|
||||||
type UnauthorizedError<S: Schema<R>, R: Request> = error::api::Public<reqwest::Error, S::Error>;
|
|
||||||
|
|
||||||
async fn authorized<R, S>(client: &reqwest::Client, method: reqwest::Method, base: &url::Url, path: &str, request: &R, token: &authentication::Token) -> Result<S, AuthorizedError<S, R>>
|
|
||||||
where
|
|
||||||
R: Request + Sync,
|
|
||||||
S: Schema<R>,
|
|
||||||
{
|
|
||||||
let url = base.join(path).map_err(AuthorizedError::<S, R>::Url)?;
|
|
||||||
|
|
||||||
let response = client.request(method, url)
|
|
||||||
.json(request)
|
|
||||||
.bearer_auth(token.as_ref())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(AuthorizedError::<S, R>::Send)?;
|
|
||||||
|
|
||||||
let body = match response.status() {
|
|
||||||
_ => response.text_with_charset("utf-8")
|
|
||||||
.await
|
|
||||||
.map_err(AuthorizedError::<S, R>::Receive)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
S::deserialize(request, &body).map_err(AuthorizedError::<S, R>::Decode)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unauthorized<R, S>(client: &reqwest::Client, method: reqwest::Method, base: &url::Url, path: &str, request: &R) -> Result<S, UnauthorizedError<S, R>>
|
|
||||||
where
|
|
||||||
R: Request + Sync,
|
|
||||||
S: Schema<R>,
|
|
||||||
{
|
|
||||||
let url = base.join(path).map_err(UnauthorizedError::<S, R>::Url)?;
|
|
||||||
|
|
||||||
let response = client.request(method, url)
|
|
||||||
.json(request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(UnauthorizedError::<S, R>::Send)?;
|
|
||||||
|
|
||||||
let body = match response.status() {
|
|
||||||
_ => response.text_with_charset("utf-8")
|
|
||||||
.await
|
|
||||||
.map_err(UnauthorizedError::<S, R>::Receive)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
S::deserialize(request, &body).map_err(UnauthorizedError::<S, R>::Decode)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Public for Public {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(&self, endpoint: &E, request: &R) -> Result<E::Schema, error::api::Public<reqwest::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
unauthorized::<R, E::Schema>(&self.client, E::METHOD.into(), &self.base, endpoint.path(request), &request).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Public for Private {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(&self, endpoint: &E, request: &R) -> Result<E::Schema, error::api::Public<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
self.public.execute::<E, R>(endpoint, request).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Private for Private {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(&self, endpoint: &E, request: &R) -> Result<E::Schema, error::api::Private<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Private<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
let token = self.current_token().await;
|
|
||||||
match authorized::<R, E::Schema>(&self.public.client, E::METHOD.into(), &self.public.base, endpoint.path(request), &request, &token).await {
|
|
||||||
Err(AuthorizedError::<E::Schema, R>::Unauthorized) if token.expired() => {
|
|
||||||
let token = self.refresh_tokens().await.map_err(AuthorizedError::<E::Schema, R>::Refresh)?;
|
|
||||||
authorized::<R, E::Schema>(&self.public.client, E::METHOD.into(), &self.public.base, endpoint.path(request), &request, &token).await
|
|
||||||
},
|
|
||||||
result => result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
use crate::Method;
|
|
||||||
pub use reqwest;
|
|
||||||
|
|
||||||
impl From<Method> for reqwest::Method {
|
|
||||||
fn from(method: Method) -> Self {
|
|
||||||
match method {
|
|
||||||
Method::GET => reqwest::Method::GET,
|
|
||||||
Method::POST => reqwest::Method::POST,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
use crate::traits::{Request, Schema, client, endpoint};
|
|
||||||
use crate::{authentication, error};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Public {
|
|
||||||
client: reqwest::Client,
|
|
||||||
base: url::Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Public {
|
|
||||||
pub fn new() -> Result<Self, reqwest::Error> {
|
|
||||||
let base = url::Url::parse("https://api.spond.com").unwrap();
|
|
||||||
Self::new_with_base(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_base(base: url::Url) -> Result<Self, reqwest::Error> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.redirect(reqwest::redirect::Policy::limited(1));
|
|
||||||
#[cfg(feature = "cookies")]
|
|
||||||
let client = client.cookie_store(true);
|
|
||||||
#[cfg(feature = "tracing")]
|
|
||||||
let client = client.connection_verbose(true);
|
|
||||||
|
|
||||||
let client = client
|
|
||||||
//.user_agent("...")
|
|
||||||
//.read_timeout(std::time::Duration)
|
|
||||||
//.connect_timeout(std::time::Duration)
|
|
||||||
.build()?;
|
|
||||||
Ok(Self { client, base })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Authenticator for Public {
|
|
||||||
type Private = Private;
|
|
||||||
|
|
||||||
fn with_tokens(&self, tokens: authentication::Tokens) -> Self::Private {
|
|
||||||
Private {
|
|
||||||
public: self.clone(),
|
|
||||||
tokens: tokio::sync::RwLock::new(tokens),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Private {
|
|
||||||
public: Public,
|
|
||||||
tokens: tokio::sync::RwLock<authentication::Tokens>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Private {
|
|
||||||
async fn current_token(&self) -> authentication::token::Access {
|
|
||||||
let tokens = self.tokens.read().await;
|
|
||||||
tokens.access_token.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_tokens(
|
|
||||||
&self,
|
|
||||||
) -> Result<authentication::token::Access, error::api::Refresh<reqwest::Error, serde_json::Error>> {
|
|
||||||
use client::Public;
|
|
||||||
let mut tokens = self.tokens.write().await;
|
|
||||||
*tokens = self
|
|
||||||
.public
|
|
||||||
.execute(&tokens.refresh_token, &tokens.refresh_token)
|
|
||||||
.await?;
|
|
||||||
Ok((*tokens).access_token.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Client = Private;
|
|
||||||
|
|
||||||
type AuthorizedError<S, R> = error::api::Private<reqwest::Error, <S as Schema<R>>::Error>;
|
|
||||||
type UnauthorizedError<S, R> = error::api::Public<reqwest::Error, <S as Schema<R>>::Error>;
|
|
||||||
|
|
||||||
#[tracing::instrument]
|
|
||||||
async fn authorized<R, S>(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
method: reqwest::Method,
|
|
||||||
base: &url::Url,
|
|
||||||
path: &str,
|
|
||||||
request: &R,
|
|
||||||
token: &authentication::token::Access,
|
|
||||||
) -> Result<S, AuthorizedError<S, R>>
|
|
||||||
where
|
|
||||||
R: Request + Sync,
|
|
||||||
S: Schema<R>,
|
|
||||||
{
|
|
||||||
let url = base.join(path).map_err(AuthorizedError::<S, R>::Url)?;
|
|
||||||
|
|
||||||
let builder = client
|
|
||||||
.request(method, url)
|
|
||||||
.json(request)
|
|
||||||
.bearer_auth(token.as_ref());
|
|
||||||
tracing::debug!("request: {builder:?}");
|
|
||||||
|
|
||||||
let response = builder
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(AuthorizedError::<S, R>::Send)?;
|
|
||||||
tracing::debug!("response: {response:?}");
|
|
||||||
|
|
||||||
let body = match response.status() {
|
|
||||||
_ => response
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(AuthorizedError::<S, R>::Receive)?,
|
|
||||||
};
|
|
||||||
tracing::debug!("body: {body:?}");
|
|
||||||
|
|
||||||
let result = S::deserialize(request, &body).map_err(AuthorizedError::<S, R>::Decode)?;
|
|
||||||
tracing::debug!("result: {result:?}");
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument]
|
|
||||||
async fn unauthorized<R, S>(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
method: reqwest::Method,
|
|
||||||
base: &url::Url,
|
|
||||||
path: &str,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<S, UnauthorizedError<S, R>>
|
|
||||||
where
|
|
||||||
R: Request + Sync,
|
|
||||||
S: Schema<R>,
|
|
||||||
{
|
|
||||||
let url = base.join(path).map_err(UnauthorizedError::<S, R>::Url)?;
|
|
||||||
|
|
||||||
let builder = client
|
|
||||||
.request(method, url)
|
|
||||||
.json(request);
|
|
||||||
tracing::debug!("request: {builder:?}");
|
|
||||||
let response = builder
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(UnauthorizedError::<S, R>::Send)?;
|
|
||||||
tracing::debug!("response: {response:?}");
|
|
||||||
|
|
||||||
let body = match response.status() {
|
|
||||||
_ => response
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(UnauthorizedError::<S, R>::Receive)?,
|
|
||||||
};
|
|
||||||
tracing::debug!("body: {body:?}");
|
|
||||||
|
|
||||||
let result = S::deserialize(request, &body).map_err(UnauthorizedError::<S, R>::Decode)?;
|
|
||||||
tracing::debug!("result: {result:?}");
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Public for Public {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(
|
|
||||||
&self,
|
|
||||||
endpoint: &E,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<E::Schema, error::api::Public<reqwest::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
unauthorized::<R, E::Schema>(
|
|
||||||
&self.client,
|
|
||||||
E::METHOD.into(),
|
|
||||||
&self.base,
|
|
||||||
endpoint.path(request),
|
|
||||||
&request,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Public for Private {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(
|
|
||||||
&self,
|
|
||||||
endpoint: &E,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<E::Schema, error::api::Public<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
self.public.execute::<E, R>(endpoint, request).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl client::Private for Private {
|
|
||||||
type Error = reqwest::Error;
|
|
||||||
|
|
||||||
async fn execute<E, R>(
|
|
||||||
&self,
|
|
||||||
endpoint: &E,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<E::Schema, error::api::Private<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Private<R> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
let token = self.current_token().await;
|
|
||||||
match authorized::<R, E::Schema>(
|
|
||||||
&self.public.client,
|
|
||||||
E::METHOD.into(),
|
|
||||||
&self.public.base,
|
|
||||||
endpoint.path(request),
|
|
||||||
&request,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(AuthorizedError::<E::Schema, R>::Unauthorized) if token.expired() => {
|
|
||||||
let token = self
|
|
||||||
.refresh_tokens()
|
|
||||||
.await
|
|
||||||
.map_err(AuthorizedError::<E::Schema, R>::Refresh)?;
|
|
||||||
authorized::<R, E::Schema>(
|
|
||||||
&self.public.client,
|
|
||||||
E::METHOD.into(),
|
|
||||||
&self.public.base,
|
|
||||||
endpoint.path(request),
|
|
||||||
&request,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
result => result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pub mod sponds;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// TODO: use crate::Id;
|
|
||||||
use crate::traits;
|
|
||||||
|
|
||||||
// {
|
|
||||||
// "heading" : "Kraftraum putzen",
|
|
||||||
// "id" : "D678340FAA6341058E368AE2FB6082CA",
|
|
||||||
// "series" : false,
|
|
||||||
// "startTime" : "2026-11-07T08:00:00Z",
|
|
||||||
// "unanswered" : true,
|
|
||||||
// "updated" : 1770872490567
|
|
||||||
// }
|
|
||||||
#[derive(serde::Deserialize, Debug)]
|
|
||||||
pub struct Spond {
|
|
||||||
pub id: crate::utils::X128,
|
|
||||||
pub updated: crate::utils::Timestamp,
|
|
||||||
pub start_time: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub heading: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub unanswered: bool,
|
|
||||||
pub series: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Debug, Default)]
|
|
||||||
pub struct Sponds(Vec<Spond>);
|
|
||||||
|
|
||||||
impl std::ops::Deref for Sponds {
|
|
||||||
type Target = [Spond];
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, Debug)]
|
|
||||||
pub struct Upcoming;
|
|
||||||
|
|
||||||
impl traits::endpoint::Private<Upcoming> for Sponds {
|
|
||||||
const METHOD: crate::Method = crate::Method::GET;
|
|
||||||
type Schema = Self;
|
|
||||||
|
|
||||||
fn path(&self, _: &Upcoming) -> &str {
|
|
||||||
"/core/v1/sponds/upcoming"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_upcoming_schema() {
|
|
||||||
const DATA: &[u8] = include_bytes!("./sponds_upcoming.json");
|
|
||||||
let bytes = bytes::Bytes::from_static(DATA);
|
|
||||||
let v: Sponds = <Sponds as super::traits::Schema<Upcoming>>::deserialize(&Upcoming, &bytes).expect("VALID JSON");
|
|
||||||
println!("{v:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
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())
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pub trait Credentials: super::super::Request {}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod login;
|
|
||||||
pub use login::Credentials;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
pub trait Authorization: private::Seal {}
|
|
||||||
|
|
||||||
mod private {
|
|
||||||
use crate::authorization::{False, True};
|
|
||||||
pub trait Seal {}
|
|
||||||
|
|
||||||
impl Seal for True {}
|
|
||||||
impl Seal for False {}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: private::Seal> Authorization for T {}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
use super::{Authorization, Endpoint, Request, Schema, endpoint};
|
|
||||||
use crate::{authorization, authentication, error};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
pub trait Client: Public + Private {}
|
|
||||||
|
|
||||||
impl<C: Public + Private> Client for C {}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Handler<E: Endpoint<R, A>, R: Request, A: Authorization> {
|
|
||||||
type Error: std::error::Error;
|
|
||||||
|
|
||||||
async fn execute(&self, endpoint: &E, request: &R) -> Result<E::Schema, Self::Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Authenticator: Public {
|
|
||||||
type Private: Private;
|
|
||||||
|
|
||||||
fn with_tokens(&self, tokens: authentication::Tokens) -> Self::Private;
|
|
||||||
|
|
||||||
async fn authenticate<E, R>(&self, endpoint: &E, request: &R) -> Result<Self::Private, error::api::Public<<Self as Public>::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R, Schema=authentication::Tokens> + Sync,
|
|
||||||
R: Request + Sync,
|
|
||||||
{
|
|
||||||
let tokens = self.execute(endpoint, request).await?;
|
|
||||||
Ok(self.with_tokens(tokens))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Public {
|
|
||||||
type Error: std::error::Error + Send;
|
|
||||||
|
|
||||||
async fn execute<E, R>(
|
|
||||||
&self,
|
|
||||||
endpoint: &E,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<E::Schema, error::api::Public<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Public<R> + Sync,
|
|
||||||
R: Request + Sync;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<P: Public + Sync, E: endpoint::Public<R> + Sync, R: Request + Sync>
|
|
||||||
Handler<E, R, authorization::False> for P
|
|
||||||
{
|
|
||||||
type Error = error::api::Public<P::Error, <E::Schema as Schema<R>>::Error>;
|
|
||||||
|
|
||||||
async fn execute(&self, endpoint: &E, request: &R) -> Result<E::Schema, Self::Error> {
|
|
||||||
self.execute(endpoint, request).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Private {
|
|
||||||
type Error: std::error::Error + Send;
|
|
||||||
|
|
||||||
async fn execute<E, R>(
|
|
||||||
&self,
|
|
||||||
endpoint: &E,
|
|
||||||
request: &R,
|
|
||||||
) -> Result<E::Schema, error::api::Private<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: endpoint::Private<R> + Sync,
|
|
||||||
R: Request + Sync;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<P: Private + Sync, E: endpoint::Private<R> + Sync, R: Request + Sync>
|
|
||||||
Handler<E, R, authorization::True> for P
|
|
||||||
{
|
|
||||||
type Error = error::api::Private<P::Error, <E::Schema as Schema<R>>::Error>;
|
|
||||||
|
|
||||||
async fn execute(&self, endpoint: &E, request: &R) -> Result<E::Schema, Self::Error> {
|
|
||||||
self.execute(endpoint, request).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
use super::{Authorization, Request, Schema};
|
|
||||||
use crate::{Method, authorization};
|
|
||||||
|
|
||||||
pub trait Endpoint<R: Request, A: Authorization>: private::Seal<R, A> {
|
|
||||||
const METHOD: Method;
|
|
||||||
type Schema: Schema<R>;
|
|
||||||
|
|
||||||
fn path(&self, request: &R) -> &str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Endpoint with required authorization
|
|
||||||
pub trait Private<R: Request> {
|
|
||||||
const METHOD: Method;
|
|
||||||
type Schema: Schema<R>;
|
|
||||||
|
|
||||||
fn path(&self, request: &R) -> &str;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Private<R>, R: Request> Endpoint<R, authorization::True> for T {
|
|
||||||
const METHOD: Method = T::METHOD;
|
|
||||||
type Schema = T::Schema;
|
|
||||||
|
|
||||||
fn path(&self, request: &R) -> &str {
|
|
||||||
self.path(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Endpoint without required authorization
|
|
||||||
pub trait Public<R: Request> {
|
|
||||||
const METHOD: Method;
|
|
||||||
type Schema: Schema<R>;
|
|
||||||
|
|
||||||
fn path(&self, request: &R) -> &str;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Public<R>, R: Request> Endpoint<R, authorization::False> for T {
|
|
||||||
const METHOD: Method = T::METHOD;
|
|
||||||
type Schema = T::Schema;
|
|
||||||
|
|
||||||
fn path(&self, request: &R) -> &str {
|
|
||||||
self.path(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod private {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Seal the Endpoint dependency
|
|
||||||
pub trait Seal<R: Request, A: Authorization> {}
|
|
||||||
|
|
||||||
impl<R: Request, P: Private<R>> Seal<R, authorization::True> for P {}
|
|
||||||
impl<R: Request, P: Public<R>> Seal<R, authorization::False> for P {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
use super::{
|
|
||||||
client::Handler,
|
|
||||||
Schema,
|
|
||||||
Endpoint,
|
|
||||||
};
|
|
||||||
use crate::{
|
|
||||||
Id,
|
|
||||||
authorization,
|
|
||||||
};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Marker: Schema<Id<Self>> + Endpoint<Id<Self>, authorization::True, Schema=Self> + std::fmt::Debug {
|
|
||||||
async fn resolve<H: Handler<Self, Id<Self>, authorization::True> + Sync>(&self, id: &Id<Self>, handler: &H) -> Result<Self, H::Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<T: Schema<Id<T>> + Endpoint<Id<T>, authorization::True, Schema=T> + Sync + std::fmt::Debug> Marker for T {
|
|
||||||
async fn resolve<H: Handler<Self, Id<Self>, authorization::True> + Sync>(&self, id: &Id<Self>, handler: &H) -> Result<Self, H::Error> {
|
|
||||||
handler.execute(&self, id).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
pub mod authentication;
|
|
||||||
pub mod authorization;
|
|
||||||
pub mod client;
|
|
||||||
pub mod endpoint;
|
|
||||||
pub mod id;
|
|
||||||
pub mod request;
|
|
||||||
pub mod schema;
|
|
||||||
|
|
||||||
pub use authentication::Credentials;
|
|
||||||
pub use authorization::Authorization;
|
|
||||||
pub use client::Client;
|
|
||||||
pub use endpoint::Endpoint;
|
|
||||||
pub use request::Request;
|
|
||||||
pub use schema::Schema;
|
|
||||||
|
|
||||||
//#[cfg(test)]
|
|
||||||
//mod test;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
pub trait Request: Serialize + std::fmt::Debug {}
|
|
||||||
|
|
||||||
impl<T: Serialize + std::fmt::Debug> Request for T {}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
use super::Request;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
|
|
||||||
pub trait Schema<R: Request>: std::fmt::Debug + Send + Sized {
|
|
||||||
type Error: std::error::Error + Send;
|
|
||||||
|
|
||||||
fn deserialize(request: &R, response: &Bytes) -> Result<Self, Self::Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DeserializeOwned + Send + std::fmt::Debug, R: Request> Schema<R> for T {
|
|
||||||
type Error = serde_json::Error;
|
|
||||||
|
|
||||||
fn deserialize(_: &R, response: &Bytes) -> Result<Self, Self::Error> {
|
|
||||||
serde_json::from_slice(&response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
struct Private;
|
|
||||||
struct Endpoint;
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
struct Request;
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Schema;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl super::client::Private for Private {
|
|
||||||
type Error = error::Private;
|
|
||||||
|
|
||||||
async fn execute<E, R>(&self, _: &E, _: &R) -> Result<E::Schema, error::api::Private<Self::Error, <E::Schema as Schema<R>>::Error>>
|
|
||||||
where
|
|
||||||
E: super::endpoint::Private<R>,
|
|
||||||
R: super::Request + Sync,
|
|
||||||
{
|
|
||||||
Err(error::Private::Unauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl super::endpoint::Private<Request> for Endpoint {
|
|
||||||
const METHOD: reqwest::Method = reqwest::Method::GET;
|
|
||||||
type Schema = Schema;
|
|
||||||
|
|
||||||
fn path(&self, _: &Request) -> &str {
|
|
||||||
"/core/v1/endpoint/private"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod error {
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Private {
|
|
||||||
#[error("unauthorized")]
|
|
||||||
Unauthorized,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn private_endpoint() {
|
|
||||||
use super::client::Private;
|
|
||||||
let client = Private;
|
|
||||||
let request = Request;
|
|
||||||
let endpoint = Endpoint;
|
|
||||||
let result = match client.execute(&endpoint, &request).await {
|
|
||||||
Err(error::Private::Unauthorized) => false,
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
assert_eq!(result, true)
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
mod x128;
|
mod datetime;
|
||||||
pub use x128::X128;
|
pub use datetime::DateTime;
|
||||||
|
|
||||||
mod timestamp;
|
mod timestamp;
|
||||||
pub use timestamp::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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use serde::{Serialize, Deserialize, Serializer, Deserializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Wrapper for u128 that serializes/deserializes as 32-charachter hex
|
/// Wrapper for u128 that serializes/deserializes as 32-charachter hex
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||||
|
|
@ -28,6 +28,12 @@ impl X128 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: Into<u128>> From<T> for X128 {
|
||||||
|
fn from(src: T) -> Self {
|
||||||
|
Self::new(src.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromStr for X128 {
|
impl FromStr for X128 {
|
||||||
type Err = Error;
|
type Err = Error;
|
||||||
|
|
||||||
|
|
@ -41,13 +47,13 @@ impl FromStr for X128 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&str> for X128 {
|
//impl TryFrom<&str> for X128 {
|
||||||
type Error = Error;
|
// type Error = Error;
|
||||||
|
//
|
||||||
fn try_from(s: &str) -> Result<Self, Error> {
|
// fn try_from(s: &str) -> Result<Self, Error> {
|
||||||
Self::from_str(s)
|
// Self::from_str(s)
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
impl Deref for X128 {
|
impl Deref for X128 {
|
||||||
type Target = u128;
|
type Target = u128;
|
||||||
|
|
@ -59,7 +65,7 @@ impl Deref for X128 {
|
||||||
|
|
||||||
impl fmt::Display for X128 {
|
impl fmt::Display for X128 {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{:032x}", self.0)
|
write!(f, "{:032X}", self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct Timestamp(u64);
|
|
||||||
1894
cli/Cargo.lock
generated
Normal file
1894
cli/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
25
cli/Cargo.toml
Normal file
25
cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "tb-spond-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"
|
||||||
|
futures = "0.3.32"
|
||||||
|
http = "1.4.0"
|
||||||
|
log = "0.4.29"
|
||||||
|
rand = "0.10.0"
|
||||||
|
restson = "1.5.0"
|
||||||
|
rpassword = "7.4.0"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
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"] }
|
||||||
|
url = "2.5.8"
|
||||||
|
xdg = "3.0.0"
|
||||||
140
cli/src/authentication.rs
Normal file
140
cli/src/authentication.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use clap::{ArgGroup, Args};
|
||||||
|
use restson::RestClient;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
#[command(group(
|
||||||
|
ArgGroup::new("authentication")
|
||||||
|
.args(["access", "refresh", "email", "phone"])
|
||||||
|
))]
|
||||||
|
pub struct Authentication {
|
||||||
|
#[arg(long)]
|
||||||
|
access: Option<Access>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
refresh: Option<Refresh>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
email: Option<Email>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
phone: Option<Phone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer(mut client: RestClient, token: &str) -> Result<RestClient> {
|
||||||
|
client.set_header("Authorization", &format!("Bearer {}", token))?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Authentication {
|
||||||
|
pub async fn apply(&self, client: RestClient) -> Result<RestClient> {
|
||||||
|
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)?,
|
||||||
|
(None, Some(v), None, None) => v.apply(client).await?,
|
||||||
|
(None, None, Some(v), None) => v.apply(client).await?,
|
||||||
|
(None, None, None, Some(v)) => v.apply(client).await?,
|
||||||
|
(None, None, None, None) => client,
|
||||||
|
(a, b, c, d) => anyhow::bail!(
|
||||||
|
"invalid authentication: {} + {} + {} + {}",
|
||||||
|
a.is_some(),
|
||||||
|
b.is_some(),
|
||||||
|
c.is_some(),
|
||||||
|
d.is_some()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct WithPassword {
|
||||||
|
value: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
impl FromStr for WithPassword {
|
||||||
|
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 = String::from_str(s)?;
|
||||||
|
Ok(Self { value, password })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Email(WithPassword);
|
||||||
|
impl Email {
|
||||||
|
async fn apply(&self, client: RestClient) -> Result<RestClient> {
|
||||||
|
let tokens =
|
||||||
|
spond_api::authentication::email(&client, &self.0.value, &self.0.password).await?;
|
||||||
|
bearer(client, tokens.access.token.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromStr for Email {
|
||||||
|
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 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,
|
||||||
|
}
|
||||||
339
cli/src/main.rs
Normal file
339
cli/src/main.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use restson::RestClient;
|
||||||
|
use spond_api as api;
|
||||||
|
use url::Url;
|
||||||
|
use xdg::BaseDirectories as xdg;
|
||||||
|
|
||||||
|
mod authentication;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(flatten)]
|
||||||
|
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/")]
|
||||||
|
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())?;
|
||||||
|
self.authentication.apply(client).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Seed(u64);
|
||||||
|
|
||||||
|
impl Default for Seed {
|
||||||
|
fn default() -> Self {
|
||||||
|
use rand::{RngExt, rng};
|
||||||
|
Self(rng().random())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Seed {
|
||||||
|
type Err = <u64 as std::str::FromStr>::Err;
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
log::info!("seed: {seed}");
|
||||||
|
if let Some(series) = series {
|
||||||
|
log::info!("series: {series}");
|
||||||
|
}
|
||||||
|
if let Some(heading) = heading {
|
||||||
|
log::info!("heading: {heading}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let sponds = api::spond::search()
|
||||||
|
.include_comments(true)
|
||||||
|
.order(api::Order::Ascending)
|
||||||
|
.max(1000)
|
||||||
|
.min_start_timestamp(now)
|
||||||
|
.max_end_timestamp(now + chrono::Duration::weeks(1))
|
||||||
|
.call(client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for spond in sponds.iter().filter(|spond| {
|
||||||
|
let result = series
|
||||||
|
.is_none_or(|series| spond.series_id.is_some_and(|remote| remote == series))
|
||||||
|
&& heading.is_none_or(|heading| spond.heading == *heading);
|
||||||
|
log::trace!(
|
||||||
|
"{}: {:?} == {:?} => {:?}",
|
||||||
|
spond.heading,
|
||||||
|
spond.series_id,
|
||||||
|
series,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}) {
|
||||||
|
log::debug!("{:?}", spond.responses);
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
88
cli/src/request/get.rs
Normal file
88
cli/src/request/get.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#[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 optional attributes
|
||||||
|
(
|
||||||
|
$name:ident ( $( $(#[$attr:meta])* $query_ident:ident $(: $query_ty:ty )? ),* $(,)? ),
|
||||||
|
( $( $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, )*
|
||||||
|
$(
|
||||||
|
$(#[$attr])* $query_ident : $crate::get!( @query_type $( $query_ty )? ) ,
|
||||||
|
)*
|
||||||
|
) -> impl std::future::Future<Output = Result<$out, restson::Error>> + '_ {
|
||||||
|
|
||||||
|
// Build Vec<(String,String)> dynamically using serde_json::to_string
|
||||||
|
let query_pairs: Vec<(String, String)> = vec![
|
||||||
|
$(
|
||||||
|
{
|
||||||
|
let val = serde_json::to_string(&$query_ident)
|
||||||
|
.expect("failed to serialize query param");
|
||||||
|
(stringify!($query_ident).to_string(), val)
|
||||||
|
}
|
||||||
|
),*
|
||||||
|
];
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
( @query_type ) => { Option<bool> };
|
||||||
|
( @query_type $ty:ty) => { $ty };
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770843696,
|
"lastModified": 1772615108,
|
||||||
"narHash": "sha256-LovWTGDwXhkfCOmbgLVA10bvsi/P8eDDpRudgk68HA8=",
|
"narHash": "sha256-lC0KbklwgeSqS+sTkaYpnSYr/HDeVMzYUZqV/dT31Lo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "2343bbb58f99267223bc2aac4fc9ea301a155a16",
|
"rev": "0c39f3b5a9a234421d4ad43ab9c7cf64840172d0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
60
flake.nix
60
flake.nix
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
outputs = { nixpkgs, ... }: let
|
outputs = { nixpkgs, ... }: let
|
||||||
spond = { rustPlatform, rustfmt, clippy, pkg-config, openssl, ... }: rustPlatform.buildRustPackage {
|
spond = { rustPlatform, rustfmt, clippy, pkg-config, openssl, ... }: rustPlatform.buildRustPackage {
|
||||||
pname = "spond";
|
pname = "tb-spond-rs";
|
||||||
version = "0.0.0";
|
version = "0.1.0";
|
||||||
|
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
|
@ -14,22 +14,24 @@
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
allpkgs = system: pkgs: pkgs.extend (_: _: nixpkgs.lib.attrsets.filterAttrs (name: _: name != "default") (packages system pkgs));
|
allpkgs = pkgs: pkgs.extend (_: _: nixpkgs.lib.attrsets.filterAttrs (name: _: name != "default") (packages' pkgs));
|
||||||
|
|
||||||
packages = system: pkgs': let
|
packages = system: packages';
|
||||||
pkgs = allpkgs system pkgs';
|
packages' = pkgs': let
|
||||||
|
pkgs = allpkgs pkgs';
|
||||||
in {
|
in {
|
||||||
default = pkgs.spond;
|
default = pkgs.spond;
|
||||||
spond = pkgs.callPackage spond {};
|
spond = pkgs.callPackage spond {};
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells = system: pkgs': let
|
devShells = system: pkgs': let
|
||||||
pkgs = allpkgs system pkgs';
|
pkgs = allpkgs pkgs';
|
||||||
in builtins.mapAttrs (devShell pkgs) (packages system pkgs');
|
in builtins.mapAttrs (devShell pkgs) (packages' pkgs');
|
||||||
|
|
||||||
devShell = pkgs: name: pkg: pkgs.mkShell {
|
devShell = pkgs: name: pkg: pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
cargo
|
cargo
|
||||||
|
cargo-bloat
|
||||||
cargo-machete
|
cargo-machete
|
||||||
cargo-workspaces
|
cargo-workspaces
|
||||||
cargo-unused-features
|
cargo-unused-features
|
||||||
|
|
@ -37,13 +39,17 @@
|
||||||
cargo-audit
|
cargo-audit
|
||||||
cargo-diet
|
cargo-diet
|
||||||
cargo-duplicates
|
cargo-duplicates
|
||||||
|
cargo-expand
|
||||||
cargo-flamegraph
|
cargo-flamegraph
|
||||||
clippy
|
clippy
|
||||||
|
lldb
|
||||||
|
gdb
|
||||||
|
|
||||||
(python3.withPackages (py: [ py.pyyaml ]))
|
(python3.withPackages (py: [ py.pyyaml ]))
|
||||||
|
|
||||||
rustc
|
rustc
|
||||||
rustfmt
|
rustfmt
|
||||||
|
openssl.dev
|
||||||
] ++ pkg.buildInputs;
|
] ++ pkg.buildInputs;
|
||||||
|
|
||||||
nativeBuildInputs = pkg.nativeBuildInputs;
|
nativeBuildInputs = pkg.nativeBuildInputs;
|
||||||
|
|
@ -52,8 +58,48 @@
|
||||||
printf 'Dev shell for %s ready!\n' '${pkg.name}'
|
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 {
|
in {
|
||||||
packages = builtins.mapAttrs packages nixpkgs.legacyPackages;
|
packages = builtins.mapAttrs packages nixpkgs.legacyPackages;
|
||||||
devShells = builtins.mapAttrs devShells nixpkgs.legacyPackages;
|
devShells = builtins.mapAttrs devShells nixpkgs.legacyPackages;
|
||||||
|
|
||||||
|
nixosModules = {
|
||||||
|
default = nixosModule;
|
||||||
|
spond = nixosModule;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
macros/Cargo.toml
Normal file
12
macros/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "spond-macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proc-macro2 = "1.0.106"
|
||||||
|
quote = "1.0.44"
|
||||||
|
syn = { version = "2.0.117", features = ["extra-traits", "full"] }
|
||||||
132
macros/src/lib.rs
Normal file
132
macros/src/lib.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::quote;
|
||||||
|
use syn::{parse_macro_input, ItemFn, LitStr};
|
||||||
|
|
||||||
|
use syn::{punctuated::Punctuated, token::Comma};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
|
enum Class {
|
||||||
|
Query,
|
||||||
|
Path,
|
||||||
|
Body,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Class {
|
||||||
|
fn classify(pat_type: &syn::PatType, default: Self) -> Self {
|
||||||
|
let mut result = None;
|
||||||
|
for a in pat_type.attrs.iter() {
|
||||||
|
let class = if a.path().is_ident("path") {
|
||||||
|
Some(Self::Path)
|
||||||
|
} else if a.path().is_ident("query") {
|
||||||
|
Some(Self::Query)
|
||||||
|
} else if a.path().is_ident("body") {
|
||||||
|
Some(Self::Body)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if class.is_some() {
|
||||||
|
if result.is_some() && result != class {
|
||||||
|
panic!("can only have one class!");
|
||||||
|
}
|
||||||
|
result = class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(result) = result {
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_args(inputs: &Punctuated<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 queries = query_idents.len();
|
||||||
|
let expanded = quote! {
|
||||||
|
#[bon::builder]
|
||||||
|
#vis async fn #fn_name #generics(
|
||||||
|
#[builder(finish_fn)] client: &restson::RestClient,
|
||||||
|
#( #[builder(finish_fn)] #path_attrs #path_idents: #path_types, )*
|
||||||
|
#( #query_attrs #query_idents: #query_types, )*
|
||||||
|
#( #body_attrs #body_idents: #body_types, )*
|
||||||
|
) -> Result<#ret_type, restson::Error> {
|
||||||
|
let path = format!(#path_lit, #( #path_idents = #path_idents, )* );
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
231
turnerbund/:w
231
turnerbund/:w
|
|
@ -1,231 +0,0 @@
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
|
||||||
pub use tracing::{error, warn, info, debug, trace};
|
|
||||||
|
|
||||||
#[cfg(feature="disabled")]
|
|
||||||
mod disabled {
|
|
||||||
|
|
||||||
//mod select;
|
|
||||||
//use select::WeightedSet;
|
|
||||||
mod api;
|
|
||||||
mod utils;
|
|
||||||
mod id;
|
|
||||||
mod user;
|
|
||||||
|
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
|
||||||
pub use tracing::{error, warn, info, debug, trace};
|
|
||||||
|
|
||||||
use rand::prelude::*;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum NoError {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
|
||||||
struct UserID(u128);
|
|
||||||
|
|
||||||
impl std::fmt::Display for UserID {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "UserID({})", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Participants(Vec<(UserID, usize)>);
|
|
||||||
|
|
||||||
trait Event {
|
|
||||||
fn registered(&self, uid: UserID) -> bool;
|
|
||||||
fn participated(&self, uid: UserID) -> bool { self.registered(uid) }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MockEvent {
|
|
||||||
id: usize,
|
|
||||||
users: HashMap<UserID, bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockEvent {
|
|
||||||
fn new(id: usize, registrants: HashSet<UserID>, participants: HashSet<UserID>) -> Self {
|
|
||||||
let users = registrants.into_iter()
|
|
||||||
.map(|uid| (uid, participants.contains(&uid)))
|
|
||||||
.collect();
|
|
||||||
Self { id, users }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for MockEvent {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "MockEvent{}({:?})", self.id, self.users)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event for &MockEvent {
|
|
||||||
fn registered(&self, uid: UserID) -> bool {
|
|
||||||
self.users.get(&uid).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn participated(&self, uid: UserID) -> bool {
|
|
||||||
self.users.get(&uid).is_some_and(|participated|*participated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Participants {
|
|
||||||
pub fn add(mut self, uid: UserID) -> Self {
|
|
||||||
self.0.push((uid, 1));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select<H>(mut self, history: H, rng: &mut impl Rng, count: usize)
|
|
||||||
-> HashSet<UserID>
|
|
||||||
where
|
|
||||||
H: Iterator,
|
|
||||||
<H as Iterator>::Item: Event,
|
|
||||||
{
|
|
||||||
for event in history {
|
|
||||||
let mut modified = false;
|
|
||||||
for item in self.0.iter_mut() {
|
|
||||||
if event.registered(item.0) && !event.participated(item.0) {
|
|
||||||
modified = true;
|
|
||||||
item.1 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !modified {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{:?}", self.0);
|
|
||||||
|
|
||||||
self.0.sample_weighted(rng, count, |item: &'_ (_, usize)| item.1 as f64)
|
|
||||||
.unwrap()
|
|
||||||
.map(|item| item.0)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect<'a, I: spond::auth::Identifier + From<&'a str>>(id: &'a str) -> Result<spond::Api, spond::error::Api> {
|
|
||||||
let pw = std::env::var("SPOND_PASSWORD").expect("a password is required in SPOND_PASSWORD");
|
|
||||||
let id: I = id.into();
|
|
||||||
let auth = spond::auth::Login::new(id, pw);
|
|
||||||
let client = spond::Api::new(auth).await?;
|
|
||||||
Ok(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let filter = EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(fmt::layer().pretty())
|
|
||||||
.with(filter)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
error!("Error Message");
|
|
||||||
warn!("Warn Message");
|
|
||||||
info!("Info Message");
|
|
||||||
debug!("Debug Message");
|
|
||||||
trace!("Trace Message");
|
|
||||||
|
|
||||||
let client = if let Ok(email) = std::env::var("SPOND_EMAIL") {
|
|
||||||
connect::<spond::auth::Email>(&email).await?
|
|
||||||
} else if let Ok(phone) = std::env::var("SPOND_PHONE") {
|
|
||||||
connect::<spond::auth::Phone>(&phone).await?
|
|
||||||
} else {
|
|
||||||
panic!("no credentials provided");
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = client;
|
|
||||||
|
|
||||||
|
|
||||||
let users = (0..25).map(UserID).collect::<Vec<UserID>>();
|
|
||||||
let mut events = Vec::new();
|
|
||||||
|
|
||||||
for id in 0..5 {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
let want = users.iter().filter(|_| (&mut rng).random_bool(0.75)).map(|id|id.clone()).collect::<HashSet<UserID>>();
|
|
||||||
|
|
||||||
let mut participants = Participants::default();
|
|
||||||
for uid in want.iter() {
|
|
||||||
participants = participants.add(*uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
let participants = participants.select(events.iter().rev(), &mut rng, 12);
|
|
||||||
let event = MockEvent::new(id, want, participants);
|
|
||||||
println!("{event}");
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
for uid in users.into_iter() {
|
|
||||||
let (registered, participated) = events.iter()
|
|
||||||
.fold((0, 0), |(registered, participated), event| {
|
|
||||||
let registered = registered + if event.registered(uid) { 1 } else { 0 };
|
|
||||||
let participated = participated + if event.participated(uid) { 1 } else { 0 };
|
|
||||||
(registered, participated)
|
|
||||||
});
|
|
||||||
|
|
||||||
println!("{uid}: ({participated}/{registered}) {:.2}%",
|
|
||||||
(participated as f64) / (registered as f64) * 100f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tracing_setup(filter: &str) {
|
|
||||||
let filter = EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| EnvFilter::new(filter));
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(fmt::layer().pretty())
|
|
||||||
.with(filter)
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect<A: spond::traits::client::Authenticator + Sync, C: spond::traits::authentication::login::Credentials + Sync>(authenticator: A, credentials: C) -> Result<A::Private, spond::error::api::Public<<A as spond::traits::client::Public>::Error, <spond::authentication::Tokens as spond::traits::Schema<C>>::Error>> {
|
|
||||||
let auth = spond::authentication::Login;
|
|
||||||
|
|
||||||
authenticator.authenticate(&auth, &credentials).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Spond()
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct Sponds(Vec<Spond>);
|
|
||||||
|
|
||||||
impl spond::traits::endpoint::Private<SpondsQuery> for Sponds {
|
|
||||||
const METHOD: spond::Method = spond::Method::GET;
|
|
||||||
type Schema = Self;
|
|
||||||
|
|
||||||
fn path(&self, _: &SpodsW
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Seralize)]
|
|
||||||
struct SpondsQuery;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
tracing_setup("info");
|
|
||||||
|
|
||||||
#[cfg(feature="disabled")]
|
|
||||||
disabled::main().await;
|
|
||||||
|
|
||||||
error!("Error Message");
|
|
||||||
warn!("Warn Message");
|
|
||||||
info!("Info Message");
|
|
||||||
debug!("Debug Message");
|
|
||||||
trace!("Trace Message");
|
|
||||||
|
|
||||||
let pw = std::env::var("SPOND_PASSWORD").expect("a password is required in SPOND_PASSWORD");
|
|
||||||
let client = spond::reqwest::Public::new().expect("public client");
|
|
||||||
let client = if let Ok(email) = std::env::var("SPOND_EMAIL") {
|
|
||||||
connect(client, spond::authentication::login::Email::new(&email, &pw)).await
|
|
||||||
} else if let Ok(phone) = std::env::var("SPOND_PHONE") {
|
|
||||||
connect(client, spond::authentication::login::Phone::new(&phone, &pw)).await
|
|
||||||
} else {
|
|
||||||
panic!("no credentials provided");
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = client.execute(;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "turnerbund"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
disabled=[]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
spond = { path = "../api", features = [ "reqwest" ] }
|
|
||||||
anyhow = "1.0.101"
|
|
||||||
async-trait = "0.1.89"
|
|
||||||
bon = "3.9.0"
|
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
|
||||||
http = "1.4.0"
|
|
||||||
rand = "0.10.0"
|
|
||||||
#reqwest = { version = "0.13.2", features = ["json", "zstd", "brotli", "gzip", "deflate"] }
|
|
||||||
reqwest-middleware = { version = "0.5.1", features = ["json", "http2"] }
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
|
||||||
serde_json = "1.0.149"
|
|
||||||
thiserror = "2.0.18"
|
|
||||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "tracing"] }
|
|
||||||
tracing = { version = "0.1.44", features = ["log"] }
|
|
||||||
tracing-subscriber = { version = "0.3.22", features = ["serde", "json", "tracing", "env-filter", "chrono"] }
|
|
||||||
url = "2.5.8"
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
use super::{
|
|
||||||
Request,
|
|
||||||
error,
|
|
||||||
token,
|
|
||||||
auth,
|
|
||||||
};
|
|
||||||
use crate::utils::Bool;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
trait Interface {
|
|
||||||
fn client(&self) -> &reqwest::Client;
|
|
||||||
fn url(&self, endpoint: &str) -> Result<reqwest::Url, url::ParseError>;
|
|
||||||
fn current_token(&self) -> Option<std::sync::Arc<token::Token>>;
|
|
||||||
async fn refresh_token(&self, api: Api) -> Result<Option<std::sync::Arc<token::Token>>, error::Token>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Internal<T: token::Provider> {
|
|
||||||
client: reqwest::Client,
|
|
||||||
base: reqwest::Url,
|
|
||||||
token_provider: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: token::Provider> Internal<T> {
|
|
||||||
fn new(client: reqwest::Client, base: reqwest::Url, token_provider: T)
|
|
||||||
-> std::sync::Arc<Self> {
|
|
||||||
std::sync::Arc::new(Self { client, base, token_provider })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, fields(endpoint=request.endpoint(), request, token))]
|
|
||||||
async fn call<R: Request + Sized>(&self, request: &R, token: Option<&token::Token>) -> Result<R::Response, error::Api>
|
|
||||||
{
|
|
||||||
let url = self.base.join(request.endpoint()).map_err(error::Api::Url)?;
|
|
||||||
let builder = self.client.request(R::METHOD, url);
|
|
||||||
let builder = if let Some(token) = token {
|
|
||||||
token.r#use(builder)
|
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = builder.json(request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(error::Api::Reqwest)?;
|
|
||||||
|
|
||||||
match response.status() {
|
|
||||||
reqwest::StatusCode::OK => (),
|
|
||||||
status => return Err(error::Api::Http(status)),
|
|
||||||
}
|
|
||||||
let result: R::Response = response.json().await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn deserialize<R: Request>(response: reqwest::Response) -> Result<R::Response, error::Api>
|
|
||||||
{
|
|
||||||
let response: R::Response = response.json().await.map_err(error::Api::Reqwest)?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, fields(url, request, token))]
|
|
||||||
async fn call<R: Request>(client: &reqwest::Client, url: reqwest::Url, request: &R, token: Option<&token::Token>) -> Result<R::Response, error::Api> {
|
|
||||||
let builder = client.request(R::METHOD, url);
|
|
||||||
let builder = if let Some(token) = token {
|
|
||||||
builder.bearer_auth(token.as_ref())
|
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
};
|
|
||||||
let response = builder.json(request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(error::Api::Reqwest)?;
|
|
||||||
|
|
||||||
match response.status() {
|
|
||||||
reqwest::StatusCode::OK =>
|
|
||||||
deserialize::<R>(response).await,
|
|
||||||
status => Err(error::Api::Http(status)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl<T: token::Provider> Interface for Internal<T> {
|
|
||||||
fn client(&self) -> &reqwest::Client {
|
|
||||||
&self.client
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url(&self, endpoint: &str) -> Result<reqwest::Url, url::ParseError> {
|
|
||||||
self.base.join(endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_token(&self) -> Option<std::sync::Arc<token::Token>> {
|
|
||||||
self.token_provider.current()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_token(&self, api: Api) -> Result<Option<std::sync::Arc<token::Token>>, error::Token> {
|
|
||||||
self.token_provider.refresh(api).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct NoToken;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl token::Provider for NoToken {
|
|
||||||
fn current(&self) -> Option<std::sync::Arc<token::Token>> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh(&self, _api: Api) -> Result<Option<std::sync::Arc<token::Token>>, error::Token> {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Api(std::sync::Arc<dyn Interface + Sync + Send>);
|
|
||||||
|
|
||||||
impl Api {
|
|
||||||
pub async fn new<A: auth::Provider>(auth: A) -> Result<Self, error::Api>
|
|
||||||
{
|
|
||||||
Self::new_with_base("https://api.spond.com", auth).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn new_with_base<A: auth::Provider>(base: &str, auth: A) -> Result<Self, error::Api> {
|
|
||||||
let base = reqwest::Url::parse(base)?;
|
|
||||||
let host = base.host_str().unwrap().to_owned();
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
//.https_only(true)
|
|
||||||
.redirect(reqwest::redirect::Policy::limited(1))
|
|
||||||
.retry(reqwest::retry::for_host(host)
|
|
||||||
.max_retries_per_request(2)
|
|
||||||
.classify_fn(|req_rep| {
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
match req_rep.status() {
|
|
||||||
Some(StatusCode::UNAUTHORIZED) => req_rep.retryable(),
|
|
||||||
_ => req_rep.success(),
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
// TODO
|
|
||||||
//.cookie_store(true)
|
|
||||||
.user_agent("spond-selection-bot")
|
|
||||||
.read_timeout(std::time::Duration::from_secs(5))
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(15))
|
|
||||||
.connection_verbose(true)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let api = Api(Internal::new(client.clone(), base.clone(), NoToken));
|
|
||||||
let provider = auth.authenticate(api).await?;
|
|
||||||
|
|
||||||
tracing::info!("{:?}", provider);
|
|
||||||
let api = Api(Internal::new(client, base, provider));
|
|
||||||
Ok(api)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn call<R: Request + Sized>(&self, request: &R) -> Result<R::Response, error::Api>
|
|
||||||
{
|
|
||||||
//async fn call<C: Call + ?Sized>(&self, api: &Api, call: &C, request: &C::Request) -> Result<C::Response, error::Call::<C>> {
|
|
||||||
// let mut token = self.token_provider.current();
|
|
||||||
// loop {
|
|
||||||
// match self.common.call(call, request, &token) {
|
|
||||||
// Err(error::Call::<C>::Http(reqwest::StatusCode::UNAUTHORIZED)) if token.is_some_and(|token|token.expired())
|
|
||||||
// => token = self.token_provider.refresh(api).map_err(error::Call::<C>::Token)?,
|
|
||||||
// result => return result,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
let client = self.0.client();
|
|
||||||
|
|
||||||
if R::Authorization::VALUE == false {
|
|
||||||
let url = self.0.url(request.endpoint())?;
|
|
||||||
call(client, url, request, None).await
|
|
||||||
} else {
|
|
||||||
let mut current = self.0.current_token();
|
|
||||||
loop {
|
|
||||||
use std::ops::Deref;
|
|
||||||
let token = if let Some(ref token) = current {
|
|
||||||
Some(token.deref())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let url = self.0.url(request.endpoint())?;
|
|
||||||
current = match call(client, url, request, token).await {
|
|
||||||
Err(error::Api::Http(reqwest::StatusCode::UNAUTHORIZED)) if current.is_some_and(|t|t.expired())
|
|
||||||
=> self.0.refresh_token(self.clone()).await?,
|
|
||||||
result => break result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn mock() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use thiserror::Error;
|
|
||||||
use crate::utils::False;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
Request,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
|
|
||||||
use serde::{
|
|
||||||
Deserialize,
|
|
||||||
Serialize,
|
|
||||||
Serializer,
|
|
||||||
ser::SerializeStruct,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Error {
|
|
||||||
//#[error(transparent)]
|
|
||||||
//Provider(P)
|
|
||||||
#[error("invalid credentials")]
|
|
||||||
Credentials,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Provider {
|
|
||||||
type TokenProvider: super::token::Provider + 'static;
|
|
||||||
|
|
||||||
async fn authenticate(&self, api: super::Api) -> Result<Self::TokenProvider, error::Api>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Identifier: Sync + Send + Serialize + std::fmt::Debug {
|
|
||||||
const KEY: &'static str;
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! transparent_identifier {
|
|
||||||
($name:ident, $key:expr) => {
|
|
||||||
transparent_string!($name);
|
|
||||||
|
|
||||||
impl Identifier for $name {
|
|
||||||
const KEY: &'static str = $key;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! transparent_string {
|
|
||||||
($name:ident) => {
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct $name(String);
|
|
||||||
|
|
||||||
impl From<&str> for $name {
|
|
||||||
fn from(s: &str) -> Self {
|
|
||||||
s.to_string().into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for $name {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<&'a $name> for &'a str {
|
|
||||||
fn from(id: &'a $name) -> Self {
|
|
||||||
&id.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<$name> for String {
|
|
||||||
fn from(id: $name) -> Self {
|
|
||||||
id.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
transparent_string!(Password);
|
|
||||||
transparent_identifier!(Email, "email");
|
|
||||||
transparent_identifier!(Phone, "phone");
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Login<I: Identifier> {
|
|
||||||
identifier: I,
|
|
||||||
password: Password,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I: Identifier> Login<I> {
|
|
||||||
pub fn new<P: Into<Password>>(identifier: I, password: P) -> Self {
|
|
||||||
Self {
|
|
||||||
identifier: identifier,
|
|
||||||
password: password.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I: Identifier> Serialize for Login<I> {
|
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
let mut state = serializer.serialize_struct("Login", 2)?;
|
|
||||||
state.serialize_field(I::KEY, &self.identifier)?;
|
|
||||||
state.serialize_field("password", &self.password)?;
|
|
||||||
state.end()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<I: Identifier> Provider for Login<I> {
|
|
||||||
type TokenProvider = TokenProvider;
|
|
||||||
|
|
||||||
async fn authenticate(&self, api: super::Api) -> Result<Self::TokenProvider, error::Api> {
|
|
||||||
Ok(api.call(self).await?.into())
|
|
||||||
//todo!("implement me")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Tokens {
|
|
||||||
access_token: super::token::Token,
|
|
||||||
refresh_token: super::token::Token,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct TokenProvider {
|
|
||||||
access: std::sync::RwLock<std::sync::Arc<super::token::Token>>,
|
|
||||||
refresh: std::sync::Mutex<super::token::Token>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Tokens> for TokenProvider {
|
|
||||||
fn from(tokens: Tokens) -> Self {
|
|
||||||
use std::sync::{Mutex,RwLock, Arc};
|
|
||||||
Self {
|
|
||||||
access: RwLock::new(Arc::new(tokens.access_token)),
|
|
||||||
refresh: Mutex::new(tokens.refresh_token),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl super::token::Provider for TokenProvider {
|
|
||||||
fn current(&self) -> Option<std::sync::Arc<super::token::Token>> {
|
|
||||||
Some(self.access.read().ok()?.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh(&self, api: super::Api) -> Result<Option<std::sync::Arc<super::token::Token>>, super::error::Token> {
|
|
||||||
// TODO
|
|
||||||
let _ = api;
|
|
||||||
Err(super::error::Token::Expired(chrono::TimeDelta::seconds(-5)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I: Identifier> Request for Login<I> {
|
|
||||||
const METHOD: super::reqwest::Method = super::reqwest::Method::POST;
|
|
||||||
type Response = Tokens;
|
|
||||||
type Authorization = False;
|
|
||||||
|
|
||||||
fn endpoint(&self) -> &str {
|
|
||||||
"core/v1/auth2/login"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
mod serialize {
|
|
||||||
use super::super::*;
|
|
||||||
use serde_json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn email_login() {
|
|
||||||
let login = Login::<Email>::new("user@spond.com", "secret");
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&login).unwrap();
|
|
||||||
assert_eq!(json, r#"{"email":"user@spond.com","password":"secret"}"#)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn phone_login() {
|
|
||||||
let login = Login::<Phone>::new("+1234567890", "secret");
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&login).unwrap();
|
|
||||||
assert_eq!(json, r#"{"phone":"+1234567890","password":"secret"}"#)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
|
|
||||||
pub trait Method {
|
|
||||||
const HTTP: http::Method;
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! method {
|
|
||||||
($name:ident) => {
|
|
||||||
struct $name;
|
|
||||||
|
|
||||||
impl Method for $name {
|
|
||||||
const HTTP = http::Method::$name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
method!(GET);
|
|
||||||
method!(POST);
|
|
||||||
|
|
||||||
pub trait Endpoint<M: Method>: Serialize {
|
|
||||||
type Result: Deserialize;
|
|
||||||
|
|
||||||
fn path(&self) -> &str;
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
pub trait Error: std::error::Error + std::fmt::Debug + Send + Sync + 'static {}
|
|
||||||
impl <E: std::error::Error + std::fmt::Debug + Send + Sync + 'static> Error for E {}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum Api {
|
|
||||||
#[error("reqwest")]
|
|
||||||
Reqwest(#[from] reqwest::Error),
|
|
||||||
|
|
||||||
#[error("encode")]
|
|
||||||
Encode(serde_json::Error),
|
|
||||||
|
|
||||||
#[error("decode")]
|
|
||||||
Decode(serde_json::Error),
|
|
||||||
|
|
||||||
#[error("url")]
|
|
||||||
Url(#[from] url::ParseError),
|
|
||||||
|
|
||||||
#[error("token")]
|
|
||||||
Token(#[from] Token),
|
|
||||||
|
|
||||||
#[error("http")]
|
|
||||||
Http(reqwest::StatusCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum Token {
|
|
||||||
#[error("expired token")]
|
|
||||||
Expired(chrono::TimeDelta),
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
pub mod auth;
|
|
||||||
pub mod token;
|
|
||||||
pub mod error;
|
|
||||||
pub mod request;
|
|
||||||
mod api;
|
|
||||||
pub use api::Api;
|
|
||||||
pub use request::{
|
|
||||||
Request,
|
|
||||||
};
|
|
||||||
|
|
||||||
use reqwest_middleware::reqwest;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum Error<Auth: std::error::Error + Send + Sync + 'static> {
|
|
||||||
#[error("unspecified")]
|
|
||||||
Unspecified,
|
|
||||||
|
|
||||||
#[error("reqwest error")]
|
|
||||||
Reqwest(#[from] reqwest::Error),
|
|
||||||
|
|
||||||
#[error("middleware error")]
|
|
||||||
Middleware(anyhow::Error),
|
|
||||||
|
|
||||||
#[error("url parser error")]
|
|
||||||
UrlParseError(#[from] url::ParseError),
|
|
||||||
|
|
||||||
#[error("http status code")]
|
|
||||||
Http(reqwest::StatusCode),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
Custom(Auth)
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
use crate::utils::Bool;
|
|
||||||
|
|
||||||
trait Authorization: Bool {}
|
|
||||||
|
|
||||||
impl<T: Bool> Authorization for T {}
|
|
||||||
|
|
||||||
pub trait Request: Sized + std::fmt::Debug + serde::Serialize {
|
|
||||||
const METHOD: reqwest::Method = reqwest::Method::GET;
|
|
||||||
type Authorization: Authorization;
|
|
||||||
type Response: for<'a> serde::Deserialize<'a> + Sized;
|
|
||||||
|
|
||||||
fn endpoint(&self) -> &str;
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
|
||||||
pub struct Token {
|
|
||||||
token: String,
|
|
||||||
expiration: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for Token {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Token {
|
|
||||||
pub fn expired(&self) -> bool {
|
|
||||||
self.expiration < chrono::Utc::now()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn expired_in(&self, offset: chrono::TimeDelta) -> bool {
|
|
||||||
self.time_delta() < offset
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn time_delta(&self) -> chrono::TimeDelta {
|
|
||||||
self.expiration - chrono::Utc::now()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn r#use(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
||||||
builder.bearer_auth(&self.token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Provider: std::fmt::Debug + Send + Sync {
|
|
||||||
fn current(&self) -> Option<std::sync::Arc<Token>>;
|
|
||||||
|
|
||||||
async fn refresh(&self, api: super::Api) -> Result<Option<std::sync::Arc<Token>>, super::error::Token>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
use reqwest_middleware::reqwest;
|
|
||||||
use reqwest_middleware::ClientWithMiddleware as Backend;
|
|
||||||
|
|
||||||
struct Client {
|
|
||||||
backend: Backend,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
use serde::{Serialize, Deserialize, Serializer, Deserializer};
|
|
||||||
use serde::de;
|
|
||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use super::api::Api;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ParseError {
|
|
||||||
Length(usize),
|
|
||||||
IntError(std::num::ParseIntError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<std::num::ParseIntError> for ParseError {
|
|
||||||
fn from(e: std::num::ParseIntError) -> Self {
|
|
||||||
Self::IntError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ParseError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Length(len) => write!(f, "Id strig must be 32 characters, got {}", len),
|
|
||||||
Self::IntError(e) => write!(f, "{}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Marker {
|
|
||||||
async fn resolve(id: Id<Self>, api: Api) -> Result<Self, super::api::error::Api>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub struct Id<T: Marker + ?Sized>(u128, std::marker::PhantomData<T>);
|
|
||||||
|
|
||||||
impl<T: Marker> Id<T> {
|
|
||||||
pub fn new(value: u128) -> Self {
|
|
||||||
Self(value, std::marker::PhantomData)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value(&self) -> u128 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> fmt::Display for Id<T> {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{:032X}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> FromStr for Id<T> {
|
|
||||||
type Err = ParseError;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s.len() {
|
|
||||||
32 => Ok(Self::new(u128::from_str_radix(s, 16)?)),
|
|
||||||
len => Err(ParseError::Length(len)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Marker> Serialize for Id<T> {
|
|
||||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
||||||
serializer.serialize_str(&self.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de, T: Marker> Deserialize<'de> for Id<T> {
|
|
||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
||||||
String::deserialize(deserializer)?
|
|
||||||
.parse()
|
|
||||||
.map_err(de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
|
||||||
pub use tracing::{error, warn, info, debug, trace};
|
|
||||||
|
|
||||||
fn tracing_setup(filter: &str) {
|
|
||||||
let filter = EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| EnvFilter::new(filter));
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(fmt::layer().pretty())
|
|
||||||
.with(filter)
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect<A: spond::traits::client::Authenticator + Sync, C: spond::traits::authentication::login::Credentials + Sync>(authenticator: A, credentials: C) -> Result<A::Private, spond::error::api::Public<<A as spond::traits::client::Public>::Error, <spond::authentication::Tokens as spond::traits::Schema<C>>::Error>> {
|
|
||||||
let auth = spond::authentication::Login;
|
|
||||||
|
|
||||||
authenticator.authenticate(&auth, &credentials).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
tracing_setup("info");
|
|
||||||
|
|
||||||
#[cfg(feature="disabled")]
|
|
||||||
disabled::main().await;
|
|
||||||
|
|
||||||
error!("Error Message");
|
|
||||||
warn!("Warn Message");
|
|
||||||
info!("Info Message");
|
|
||||||
debug!("Debug Message");
|
|
||||||
trace!("Trace Message");
|
|
||||||
|
|
||||||
let pw = std::env::var("SPOND_PASSWORD").expect("a password is required in SPOND_PASSWORD");
|
|
||||||
let client = spond::reqwest::Public::new().expect("public client");
|
|
||||||
let client = if let Ok(email) = std::env::var("SPOND_EMAIL") {
|
|
||||||
connect(client, spond::authentication::login::Email::new(&email, &pw)).await
|
|
||||||
} else if let Ok(phone) = std::env::var("SPOND_PHONE") {
|
|
||||||
connect(client, spond::authentication::login::Phone::new(&phone, &pw)).await
|
|
||||||
} else {
|
|
||||||
panic!("no credentials provided");
|
|
||||||
}?;
|
|
||||||
|
|
||||||
|
|
||||||
use spond::traits::client::Private;
|
|
||||||
|
|
||||||
let _ = client.execute(&spond::schema::sponds::Sponds::default(), &spond::schema::sponds::Upcoming).await?;
|
|
||||||
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
use rand::prelude::*;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, PartialOrd, Ord)]
|
|
||||||
struct UserID(u128);
|
|
||||||
|
|
||||||
impl std::fmt::Display for UserID {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "UserID({})", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Participants(Vec<(UserID, usize)>);
|
|
||||||
|
|
||||||
trait Event {
|
|
||||||
fn registered(&self, uid: UserID) -> bool;
|
|
||||||
fn participated(&self, uid: UserID) -> bool { self.registered(uid) }
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Participants {
|
|
||||||
pub fn add(mut self, uid: UserID) -> Self {
|
|
||||||
self.0.push((uid, 1));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select<I: Event>(mut self, history: IntoIterator<Item=I>, rng: &mut impl Rng, count: usize)
|
|
||||||
-> HashSet<UserID>
|
|
||||||
{
|
|
||||||
for event in history {
|
|
||||||
let mut modified = false;
|
|
||||||
for item in self.0.iter_mut() {
|
|
||||||
if event.registered(item.0) && !event.participated(item.0) {
|
|
||||||
modified = true;
|
|
||||||
item.1 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !modified {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{:?}", self.0);
|
|
||||||
|
|
||||||
self.0.sample_weighted(rng, count, |item: &'_ (_, usize)| item.1 as f64)
|
|
||||||
.unwrap()
|
|
||||||
.map(|item| item.0)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature="disabled")]
|
|
||||||
mod disabled {
|
|
||||||
|
|
||||||
//mod select;
|
|
||||||
//use select::WeightedSet;
|
|
||||||
|
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
|
||||||
pub use tracing::{error, warn, info, debug, trace};
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum NoError {}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MockEvent {
|
|
||||||
id: usize,
|
|
||||||
users: HashMap<UserID, bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockEvent {
|
|
||||||
fn new(id: usize, registrants: HashSet<UserID>, participants: HashSet<UserID>) -> Self {
|
|
||||||
let users = registrants.into_iter()
|
|
||||||
.map(|uid| (uid, participants.contains(&uid)))
|
|
||||||
.collect();
|
|
||||||
Self { id, users }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for MockEvent {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "MockEvent{}({:?})", self.id, self.users)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event for &MockEvent {
|
|
||||||
fn registered(&self, uid: UserID) -> bool {
|
|
||||||
self.users.get(&uid).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn participated(&self, uid: UserID) -> bool {
|
|
||||||
self.users.get(&uid).is_some_and(|participated|*participated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect<'a, I: spond::auth::Identifier + From<&'a str>>(id: &'a str) -> Result<spond::Api, spond::error::Api> {
|
|
||||||
let pw = std::env::var("SPOND_PASSWORD").expect("a password is required in SPOND_PASSWORD");
|
|
||||||
let id: I = id.into();
|
|
||||||
let auth = spond::auth::Login::new(id, pw);
|
|
||||||
let client = spond::Api::new(auth).await?;
|
|
||||||
Ok(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let filter = EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(fmt::layer().pretty())
|
|
||||||
.with(filter)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
error!("Error Message");
|
|
||||||
warn!("Warn Message");
|
|
||||||
info!("Info Message");
|
|
||||||
debug!("Debug Message");
|
|
||||||
trace!("Trace Message");
|
|
||||||
|
|
||||||
let client = if let Ok(email) = std::env::var("SPOND_EMAIL") {
|
|
||||||
connect::<spond::auth::Email>(&email).await?
|
|
||||||
} else if let Ok(phone) = std::env::var("SPOND_PHONE") {
|
|
||||||
connect::<spond::auth::Phone>(&phone).await?
|
|
||||||
} else {
|
|
||||||
panic!("no credentials provided");
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = client;
|
|
||||||
|
|
||||||
|
|
||||||
let users = (0..25).map(UserID).collect::<Vec<UserID>>();
|
|
||||||
let mut events = Vec::new();
|
|
||||||
|
|
||||||
for id in 0..5 {
|
|
||||||
let mut rng = rand::rng();
|
|
||||||
let want = users.iter().filter(|_| (&mut rng).random_bool(0.75)).map(|id|id.clone()).collect::<HashSet<UserID>>();
|
|
||||||
|
|
||||||
let mut participants = Participants::default();
|
|
||||||
for uid in want.iter() {
|
|
||||||
participants = participants.add(*uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
let participants = participants.select(events.iter().rev(), &mut rng, 12);
|
|
||||||
let event = MockEvent::new(id, want, participants);
|
|
||||||
println!("{event}");
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
for uid in users.into_iter() {
|
|
||||||
let (registered, participated) = events.iter()
|
|
||||||
.fold((0, 0), |(registered, participated), event| {
|
|
||||||
let registered = registered + if event.registered(uid) { 1 } else { 0 };
|
|
||||||
let participated = participated + if event.participated(uid) { 1 } else { 0 };
|
|
||||||
(registered, participated)
|
|
||||||
});
|
|
||||||
|
|
||||||
println!("{uid}: ({participated}/{registered}) {:.2}%",
|
|
||||||
(participated as f64) / (registered as f64) * 100f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
use super::Key;
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Candidate<T> {
|
|
||||||
item: T,
|
|
||||||
key: Key,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Candidate<T> {
|
|
||||||
pub fn new_with_key(item: T, key: Key) -> Self {
|
|
||||||
Self { item, key }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new<R, F, W>(item: T, rng: R, weight: W) -> Option<Self>
|
|
||||||
where
|
|
||||||
R: FnOnce() -> F,
|
|
||||||
F: Into<f64>,
|
|
||||||
W: Into<f64>,
|
|
||||||
{
|
|
||||||
Key::new(rng, weight).map(|key|Self::new_with_key(item, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn item(self) -> T {
|
|
||||||
self.item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Ord for Candidate<T> {
|
|
||||||
fn cmp(&self, other: &Self) -> Ordering {
|
|
||||||
// Reverse for max-heap: largest key is "greatest"
|
|
||||||
other.key.partial_cmp(&self.key).unwrap().reverse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> PartialOrd for Candidate<T> {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> PartialEq for Candidate<T> {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.key == other.key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Eq for Candidate<T> {}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// Key newtype wrapping a f64 in the range [0.0,1.0]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
|
||||||
pub struct Key(f64);
|
|
||||||
|
|
||||||
impl Key {
|
|
||||||
pub fn new<R, F, W>(rng: R, weight: W) -> Option<Self>
|
|
||||||
where
|
|
||||||
R: FnOnce() -> F,
|
|
||||||
F: Into<f64>,
|
|
||||||
W: Into<f64>,
|
|
||||||
{
|
|
||||||
let weight = weight.into();
|
|
||||||
|
|
||||||
if weight.is_finite() && weight > 0.0 {
|
|
||||||
let u = -rng().into().ln();
|
|
||||||
Some(Key((u / weight).recip()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for Key {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
// we are always in range [0.0,1.0]
|
|
||||||
self.0.partial_cmp(&other.0).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for Key {}
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
struct Profile;
|
|
||||||
struct User;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
trait BoolValue {}
|
|
||||||
|
|
||||||
pub trait Bool: BoolValue {
|
|
||||||
const VALUE: bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct True;
|
|
||||||
pub struct False;
|
|
||||||
|
|
||||||
impl BoolValue for True {}
|
|
||||||
impl BoolValue for False {}
|
|
||||||
|
|
||||||
impl Bool for True {
|
|
||||||
const VALUE: bool = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bool for False {
|
|
||||||
const VALUE: bool = false;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue