working example

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

12
api/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "spond-api"
version = "0.1.0"
edition = "2024"
[dependencies]
bon = "3.9.0"
chrono = { version = "0.4.44", features = ["serde"] }
restson = "1.5.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0.18"

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

@ -0,0 +1,127 @@
use serde::{Deserialize, Serialize};
use restson::{RestClient, RestPath, Error, Response};
#[derive(Debug, Copy, Clone, Serialize)]
struct Email<'a> {
email: &'a str,
password: &'a str,
}
impl<'a> RestPath<()> for Email<'a> {
fn get_path(_: ()) -> Result<String, Error> {
Ok(String::from("auth2/login"))
}
}
#[derive(Debug, Copy, Clone, Serialize)]
struct Phone<'a> {
phone: &'a str,
password: &'a str,
}
impl<'a> RestPath<()> for Phone<'a> {
fn get_path(_: ()) -> Result<String, Error> {
Ok(String::from("auth2/login"))
}
}
#[derive(Debug, Copy, Clone, Serialize)]
struct Token<'a> {
token: &'a str,
}
impl<'a> RestPath<()> for Token<'a> {
fn get_path(_: ()) -> Result<String, Error> {
Ok(String::from("auth2/login/refresh"))
}
}
pub mod token {
use serde::{Serialize, Deserialize};
use crate::util::DateTime;
use std::ops::Deref;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Token {
token: String,
}
impl AsRef<str> for Token {
fn as_ref(&self) -> &str {
&self.token
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct WithExpiration {
#[serde(flatten)]
pub token: Token,
#[allow(unused)]
pub expiration: DateTime,
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct Access(WithExpiration);
impl Deref for Access {
type Target = WithExpiration;
fn deref(&self) -> &WithExpiration {
&self.0
}
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct Refresh(WithExpiration);
impl Deref for Refresh {
type Target = WithExpiration;
fn deref(&self) -> &WithExpiration {
&self.0
}
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub struct Password(WithExpiration);
impl Deref for Password {
type Target = WithExpiration;
fn deref(&self) -> &WithExpiration {
&self.0
}
}
}
#[derive(Debug, Deserialize)]
pub struct Tokens {
#[serde(rename = "accessToken")]
pub access: token::Access,
#[serde(rename = "refreshToken")]
pub refresh: token::Refresh,
#[serde(rename = "passwordToken")]
pub password: token::Password,
}
async fn authenticate<R>(client: &RestClient, request: R) -> Result<Tokens, Error>
where
R: RestPath<()>,
R: Serialize,
{
let tokens: Response<Tokens> = client.post_capture((), &request).await?;
Ok(tokens.into_inner())
}
pub fn email<'a>(client: &'a RestClient, email: &'a str, password: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Email { email, password })
}
pub fn phone<'a>(client: &'a RestClient, phone: &'a str, password: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Phone { phone, password })
}
pub fn token<'a>(client: &'a RestClient, token: &'a str) -> impl Future<Output=Result<Tokens, Error>> + 'a {
authenticate(client, Token { token })
}

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

@ -0,0 +1,39 @@
use super::GroupId as Id;
use super::MemberId;
use restson::{Error, RestPath};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Group {
pub id: Id,
#[serde(flatten)]
unknown: serde_json::Value,
}
impl std::fmt::Display for Group {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
serde_json::to_string_pretty(&self.unknown).unwrap()
)
}
}
impl RestPath<Id> for Group {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("groups/{id}"))
}
}
#[derive(Debug, Deserialize)]
pub struct Member {
pub id: MemberId,
#[serde(flatten)]
pub unknown: serde_json::Value,
}
impl super::util::id::Type for Member {
type Type = super::util::X128;
}

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

@ -0,0 +1,123 @@
use std::collections::{
HashMap as Map,
//HashSet as Set,
};
pub mod util;
pub mod authentication;
pub mod profile;
pub use profile::Profile;
pub type ProfileId = util::Id<Profile>;
pub mod spond;
pub use spond::Spond;
pub type SpondId = util::Id<Spond>;
pub mod series;
pub use series::Series;
pub type SeriesId = util::Id<Series>;
pub mod group;
pub use group::Group;
pub type GroupId = util::Id<Group>;
pub type MemberId = util::Id<group::Member>;
impl<T: restson::RestPath<util::Id<T>>> util::id::Type for T {
type Type = util::X128;
}
//impl<T: restson::RestPath<(util::Id<T>,)> + serde::de::DeserializeOwned + util::id::Type>
// util::Id<T>
//{
// pub async fn load(&self, client: &restson::RestClient) -> Result<T, restson::Error> {
// Ok(client.get_with::<_, T>((*self,), &[]).await?.into_inner())
// }
//}
use serde::{Deserialize, Serialize};
#[derive(Default)]
struct Query<'a>(Vec<(&'a str, String)>);
impl<'a> Query<'a> {
pub fn add(&mut self, key: &'a str, value: impl std::fmt::Display) {
self.0.push((key, value.to_string()));
}
pub fn maybe_add(&mut self, key: &'a str, value: Option<impl std::fmt::Display>) {
if let Some(value) = value {
self.add(key, value);
}
}
fn as_slice(&self) -> Vec<(&'a str, &str)> {
self.0.iter().map(|(k, v)| (*k, v.as_str())).collect()
}
}
#[derive(Serialize)]
pub enum Order {
Ascending,
Descending,
}
impl Default for Order {
fn default() -> Self {
Self::Ascending
}
}
impl std::fmt::Display for Order {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_ref())
}
}
impl AsRef<str> for Order {
fn as_ref(&self) -> &str {
match self {
Self::Ascending => "asc",
Self::Descending => "desc",
}
}
}
impl From<bool> for Order {
fn from(ascending: bool) -> Self {
if ascending {
Self::Ascending
} else {
Self::Descending
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeclineMessage {
pub message: String,
pub profile_id: MemberId,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Responses {
#[serde(default)]
pub accepted_ids: Vec<MemberId>,
#[serde(default)]
pub declined_ids: Vec<MemberId>,
#[serde(default)]
pub waitinglist_ids: Vec<MemberId>,
#[serde(default)]
pub unanswered_ids: Vec<MemberId>,
#[serde(default)]
pub unconfirmed_ids: Vec<MemberId>,
#[serde(default)]
pub participant_ids: Vec<MemberId>,
#[serde(default)]
pub decline_messages: Map<MemberId, DeclineMessage>,
#[serde(flatten)]
pub unknown: Map<String, serde_json::Value>,
}

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

@ -0,0 +1,44 @@
use super::ProfileId as Id;
use restson::{Error, RestClient, RestPath};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Profile {
pub id: Id,
#[serde(flatten)]
unknown: serde_json::Value,
}
impl std::fmt::Display for Profile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
serde_json::to_string_pretty(&self.unknown).unwrap()
)
}
}
impl RestPath<Id> for Profile {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("profile/{id}"))
}
}
impl RestPath<()> for Profile {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok("profile".to_string())
}
}
pub async fn identity(client: &RestClient) -> Result<Profile, Error> {
Ok(client.get_with::<_, Profile>((), &[]).await?.into_inner())
}
pub async fn with_id(client: &RestClient, id: Id) -> Result<Profile, Error> {
Ok(client
.get_with::<_, Profile>(id, &[])
.await?
.into_inner())
}

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

@ -0,0 +1,18 @@
use restson::{Error, RestPath};
use serde::Deserialize;
use super::SeriesId as Id;
#[derive(Debug, Deserialize)]
pub struct Series {
pub id: Id,
#[serde(flatten)]
pub unknown: serde_json::Value,
}
impl RestPath<Id> for Series {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("series/{id}"))
}
}

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

@ -0,0 +1,242 @@
use super::{
MemberId, Order, ProfileId, Query, Responses, SeriesId, SpondId as Id,
util::{DateTime, Timestamp, Visibility},
};
use restson::{Error, RestClient, RestPath};
use serde::Deserialize;
use std::collections::HashMap;
use std::future::Future;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Group {
#[serde(flatten)]
pub unknown: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Recipients {
pub group: HashMap<String, serde_json::Value>,
pub guardians: std::vec::Vec<serde_json::Value>,
pub profiles: std::vec::Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
pub id: crate::util::Id<Comment>,
pub children: std::vec::Vec<serde_json::Value>,
pub from_profile_id: ProfileId,
pub reactions: HashMap<String, HashMap<ProfileId, Timestamp>>,
pub text: String,
pub timestamp: DateTime,
}
impl crate::util::id::Type for Comment {
type Type = crate::util::X128;
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Spond {
pub id: Id,
pub heading: String,
pub series_id: Option<SeriesId>,
pub responses: Responses,
pub updated: Timestamp,
pub recipients: Recipients,
pub hidden: bool,
pub comments: Vec<Comment>,
pub auto_accept: bool,
pub start_timestamp: DateTime,
pub end_timestamp: DateTime,
pub creator_id: ProfileId,
pub visibility: Visibility,
pub behalf_of_ids: Vec<MemberId>,
#[serde(flatten)]
pub unknown: HashMap<String, serde_json::Value>,
}
#[bon::bon]
impl Spond {
#[builder]
pub fn response(
&self,
#[builder(start_fn)]
member: MemberId,
#[builder(finish_fn)]
client: &RestClient,
#[builder(default = true)]
accepted: bool,
) -> impl Future<Output=Result<Responses, Error>> {
response(self.id)
.member(member)
.accepted(accepted)
.call(client)
}
#[builder]
pub fn accept(&self,
#[builder(start_fn)]
member: MemberId,
#[builder(finish_fn)]
client: &RestClient,
) -> impl Future<Output=Result<Responses, Error>> {
self.response(member)
.accepted(true)
.call(client)
}
#[builder]
pub fn decline(&self,
#[builder(start_fn)]
member: MemberId,
#[builder(finish_fn)]
client: &RestClient,
) -> impl Future<Output=Result<Responses, Error>> {
self.response(member)
.accepted(false)
.call(client)
}
}
impl std::fmt::Display for Spond {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
serde_json::to_string_pretty(&self.unknown).unwrap()
)
}
}
#[derive(Debug, serde::Deserialize)]
struct Sponds(Vec<Spond>);
impl RestPath<()> for Sponds {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok(String::from("sponds"))
}
}
impl RestPath<Id> for Spond {
fn get_path(id: Id) -> std::result::Result<String, Error> {
Ok(format!("sponds/{id}"))
}
}
impl RestPath<()> for Spond {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok("sponds".to_string())
}
}
#[bon::builder]
pub async fn spond(
#[builder(finish_fn)] client: &RestClient,
#[builder(finish_fn)] id: Id,
// query flags
include_comments: Option<bool>,
add_profile_info: Option<bool>,
) -> Result<Spond, Error> {
let mut q = Query::default();
q.maybe_add("includeComments", include_comments);
q.maybe_add("addProfileInfo", add_profile_info);
Ok(client
.get_with::<_, Spond>(id, &q.as_slice())
.await?
.into_inner())
}
#[bon::builder]
pub async fn search(
#[builder(finish_fn)] client: &RestClient,
// query flags
include_comments: Option<bool>,
comments: Option<bool>,
hidden: Option<bool>,
add_profile_info: Option<bool>,
scheduled: Option<bool>,
#[builder(default, into)] order: Order,
#[builder(default = 20)] max: usize,
#[builder(into)] min_end_timestamp: Option<DateTime>,
#[builder(into)] max_end_timestamp: Option<DateTime>,
#[builder(into)] min_start_timestamp: Option<DateTime>,
#[builder(into)] max_start_timestamp: Option<DateTime>,
prev_id: Option<Id>,
series_id: Option<SeriesId>,
) -> Result<Vec<Spond>, Error> {
let mut q = Query::default();
q.maybe_add("seriesId", series_id);
q.maybe_add("includeComments", include_comments);
q.maybe_add("comments", comments);
q.maybe_add("addProfileInfo", add_profile_info);
q.maybe_add("scheduled", scheduled);
q.maybe_add("hidden", hidden);
q.maybe_add("minEndTimestamp", min_end_timestamp);
q.maybe_add("maxEndTimestamp", max_end_timestamp);
q.maybe_add("minStartTimestamp", min_start_timestamp);
q.maybe_add("maxStartTimestamp", max_start_timestamp);
q.maybe_add("prevId", prev_id);
q.add("order", order);
q.add("max", max);
Ok(client
.get_with::<_, Sponds>((), &q.as_slice())
.await?
.into_inner()
.0)
}
#[bon::builder]
pub fn decline(
#[builder(start_fn)] spond: Id,
#[builder(finish_fn)] client: &RestClient,
member: MemberId,
) -> impl std::future::Future<Output=Result<Responses, Error>> {
response(spond)
.member(member)
.accepted(false)
.call(client)
}
#[bon::builder]
pub fn accept(
#[builder(start_fn)] spond: Id,
#[builder(finish_fn)] client: &RestClient,
member: MemberId,
) -> impl std::future::Future<Output=Result<Responses, Error>> {
response(spond)
.member(member)
.accepted(true)
.call(client)
}
#[bon::builder]
pub async fn response(
#[builder(start_fn)] spond: Id,
#[builder(finish_fn)] client: &RestClient,
member: MemberId,
accepted: bool,
) -> Result<Responses, Error> {
#[derive(Debug, serde::Serialize)]
struct Request {
accepted: bool,
}
impl RestPath<(Id, MemberId)> for Request {
fn get_path(args: (Id, MemberId)) -> std::result::Result<String, Error> {
let (spond, member) = args;
Ok(format!("sponds/{spond}/responses/{member}"))
}
}
let request = Request{accepted};
let response: restson::Response<Responses> = client.put_capture((spond, member), &request).await?;
Ok(response.into_inner())
}

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

@ -0,0 +1,41 @@
use serde::Deserialize;
use restson::{RestClient, RestPath, Error};
use super::{
UserId as Id,
};
#[derive(Debug, Deserialize)]
pub struct User {
pub id: Id,
#[serde(flatten)]
unknown: serde_json::Value,
}
impl std::fmt::Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string_pretty(&self.unknown).unwrap())
}
}
impl RestPath<()> for User {
fn get_path(_: ()) -> std::result::Result<String, Error> {
Ok(format!("user"))
}
}
impl RestPath<(Id, )> for User {
fn get_path(args: (Id, )) -> std::result::Result<String, Error> {
let (id, ) = args;
Ok(format!("user/{id}"))
}
}
pub async fn identity(client: &RestClient) -> Result<User, Error> {
Ok(client.get_with::<_, User>((), &[]).await?.into_inner())
}
pub async fn with_id(client: &RestClient, id: Id) -> Result<User, Error> {
Ok(client.get_with::<_, User>((id, ), &[]).await?.into_inner())
}