add: apps rust sdk

This commit is contained in:
trisua 2025-07-19 15:31:06 -04:00
parent f05074ffc5
commit fe1e53c47a
11 changed files with 461 additions and 11 deletions

View file

@ -2,6 +2,16 @@
name = "tetratto-core"
version = "11.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
license.workspace = true
homepage.workspace = true
[features]
database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"]
types = ["dep:totp-rs", "dep:paste", "dep:bitflags"]
sdk = ["types", "dep:reqwest"]
default = ["database", "types", "sdk"]
[dependencies]
pathbufd = "0.1.4"
@ -10,17 +20,20 @@ toml = "0.9.2"
tetratto-shared = { path = "../shared" }
tetratto-l10n = { path = "../l10n" }
serde_json = "1.0.140"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] }
reqwest = { version = "0.12.22", features = ["json"] }
bitflags = "2.9.1"
async-recursion = "1.1.1"
md-5 = "0.10.6"
base16ct = { version = "0.2.0", features = ["alloc"] }
base64 = "0.22.1"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true }
bitflags = { version = "2.9.1", optional = true }
async-recursion = { version = "1.1.1", optional = true }
md-5 = { version = "0.10.6", optional = true }
base16ct = { version = "0.2.0", features = ["alloc"], optional = true }
base64 = { version = "0.22.1", optional = true }
emojis = "0.7.0"
regex = "1.11.1"
oiseau = { version = "0.1.2", default-features = false, features = [
"postgres",
"redis",
] }
paste = "1.0.15"
], optional = true }
paste = { version = "1.0.15", optional = true }
[dev-dependencies]
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }

View file

@ -0,0 +1,65 @@
extern crate tetratto_core;
use tetratto_core::{
model::apps::{AppDataSelectMode, AppDataSelectQuery, AppDataQueryResult},
sdk::{DataClient, SimplifiedQuery},
};
use std::env::var;
// mirror of https://trisua.com/t/tetratto/src/branch/master/example/app_sdk_test.js ... but in rust
#[tokio::main]
pub async fn main() {
let client = DataClient::new(
Some("http://localhost:4118".to_string()),
var("APP_API_KEY").unwrap(),
);
println!("data used: {}", client.get_app().await.unwrap().data_used);
// record insert
client
.insert("rust_test".to_string(), "Hello, world!".to_string())
.await
.unwrap();
println!("record created");
println!("data used: {}", client.get_app().await.unwrap().data_used);
// testing record query then delete
let record = match client
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs("rust_test".to_string()),
mode: AppDataSelectMode::One(0),
})
.await
.unwrap()
{
AppDataQueryResult::One(x) => x,
AppDataQueryResult::Many(_) => unreachable!(),
};
println!("{:?}", record);
client
.update(record.id, "Hello, world! 1".to_string())
.await
.unwrap();
println!("record updated");
println!("data used: {}", client.get_app().await.unwrap().data_used);
let record = match client
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs("rust_test".to_string()),
mode: AppDataSelectMode::One(0),
})
.await
.unwrap()
{
AppDataQueryResult::One(x) => x,
AppDataQueryResult::Many(_) => unreachable!(),
};
println!("{:?}", record);
client.remove(record.id).await.unwrap();
println!("record deleted");
println!("data used: {}", client.get_app().await.unwrap().data_used);
}

View file

@ -1,6 +1,13 @@
#[cfg(feature = "types")]
pub mod config;
#[cfg(feature = "database")]
pub mod database;
#[cfg(feature = "types")]
pub mod model;
#[cfg(feature = "sdk")]
pub mod sdk;
#[cfg(feature = "database")]
pub use database::DataManager;
#[cfg(feature = "database")]
pub use oiseau::cache;

313
crates/core/src/sdk.rs Normal file
View file

@ -0,0 +1,313 @@
use crate::model::{
apps::{
AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery, ThirdPartyApp,
},
ApiReturn, Error, Result,
};
use reqwest::{
multipart::{Form, Part},
Client as HttpClient,
};
pub use reqwest::Method;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
macro_rules! api_return_ok {
($ret:ty, $res:ident) => {
match $res.json::<ApiReturn<$ret>>().await {
Ok(x) => {
if x.ok {
Ok(x.payload)
} else {
Err(Error::MiscError(x.message))
}
}
Err(e) => Err(Error::MiscError(e.to_string())),
}
};
}
/// A simplified app data query which matches what the API endpoint actually requires.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimplifiedQuery {
pub query: AppDataSelectQuery,
pub mode: AppDataSelectMode,
}
/// The data client is used to access an app's data storage capabilities.
#[derive(Debug, Clone)]
pub struct DataClient {
/// The HTTP client associated with this client.
pub http: HttpClient,
/// The app's API key. You can retrieve this from the web dashboard.
pub api_key: String,
/// The origin of the Tetratto server. When creating with [`DataClient::new`],
/// you can provide `None` to use `https://tetratto.com`.
pub host: String,
}
impl DataClient {
/// Create a new [`DataClient`].
pub fn new(host: Option<String>, api_key: String) -> Self {
Self {
http: HttpClient::new(),
api_key,
host: host.unwrap_or("https://tetratto.com".to_string()),
}
}
/// Get the current app using the provided API key.
///
/// # Usage
/// ```rust
/// let client = DataClient::new("https://tetratto.com".to_string(), "...".to_string());
/// let app = client.get_app().await.expect("failed to get app");
/// ```
pub async fn get_app(&self) -> Result<ThirdPartyApp> {
match self
.http
.get(format!("{}/api/v1/app_data/app", self.host))
.header("Atto-Secret-Key", &self.api_key)
.send()
.await
{
Ok(x) => api_return_ok!(ThirdPartyApp, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Query the app's data.
pub async fn query(&self, query: &SimplifiedQuery) -> Result<AppDataQueryResult> {
match self
.http
.post(format!("{}/api/v1/app_data/query", self.host))
.header("Atto-Secret-Key", &self.api_key)
.json(&query)
.send()
.await
{
Ok(x) => api_return_ok!(AppDataQueryResult, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Insert a key, value pair into the app's data.
pub async fn insert(&self, key: String, value: String) -> Result<String> {
match self
.http
.post(format!("{}/api/v1/app_data", self.host))
.header("Atto-Secret-Key", &self.api_key)
.json(&serde_json::Value::Object({
let mut map = serde_json::Map::new();
map.insert("key".to_string(), serde_json::Value::String(key));
map.insert("value".to_string(), serde_json::Value::String(value));
map
}))
.send()
.await
{
Ok(x) => api_return_ok!(String, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Update a record's value given its ID and the new value.
pub async fn update(&self, id: usize, value: String) -> Result<()> {
match self
.http
.post(format!("{}/api/v1/app_data/{id}/value", self.host))
.header("Atto-Secret-Key", &self.api_key)
.json(&serde_json::Value::Object({
let mut map = serde_json::Map::new();
map.insert("value".to_string(), serde_json::Value::String(value));
map
}))
.send()
.await
{
Ok(x) => api_return_ok!((), x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Delete a row from the app's data by its `id`.
pub async fn remove(&self, id: usize) -> Result<()> {
match self
.http
.delete(format!("{}/api/v1/app_data/{id}", self.host))
.header("Atto-Secret-Key", &self.api_key)
.send()
.await
{
Ok(x) => api_return_ok!((), x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Delete row(s) from the app's data by a query.
pub async fn remove_query(&self, query: &AppDataQuery) -> Result<()> {
match self
.http
.delete(format!("{}/api/v1/app_data/query", self.host))
.header("Atto-Secret-Key", &self.api_key)
.json(&query)
.send()
.await
{
Ok(x) => api_return_ok!((), x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
}
/// The state of the [`ApiClient`].
#[derive(Debug, Clone, Default)]
pub struct ApiClientState {
/// The token you received from an app grant request.
pub user_token: String,
/// The verifier you received from an app grant request.
pub user_verifier: String,
/// The ID of the user this client is connecting to.
pub user_id: usize,
/// The ID of the app that is being used for user grants.
///
/// You can get this from the web dashboard.
pub app_id: usize,
}
/// The API client is used to manage authentication flow and send requests on behalf of a user.
///
/// This client assumes you already have the required information for the given user.
/// If you don't, try using the JS SDK to extract this information.
#[derive(Debug, Clone)]
pub struct ApiClient {
/// The HTTP client associated with this client.
pub http: HttpClient,
/// The general state of the client. Will be updated whenever you refresh the user's token.
pub state: ApiClientState,
/// The origin of the Tetratto server. When creating with [`ApiClient::new`],
/// you can provide `None` to use `https://tetratto.com`.
pub host: String,
}
impl ApiClient {
/// Create a new [`ApiClient`].
pub fn new(host: Option<String>, state: ApiClientState) -> Self {
Self {
http: HttpClient::new(),
state,
host: host.unwrap_or("https://tetratto.com".to_string()),
}
}
/// Refresh the client's user_token.
pub async fn refresh_token(&mut self) -> Result<String> {
match self
.http
.post(format!(
"{}/api/v1/auth/user/{}/grants/{}/refresh",
self.host, self.state.user_id, self.state.app_id
))
.header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token))
.json(&serde_json::Value::Object({
let mut map = serde_json::Map::new();
map.insert(
"verifier".to_string(),
serde_json::Value::String(self.state.user_verifier.to_owned()),
);
map
}))
.send()
.await
{
Ok(x) => {
let ret = api_return_ok!(String, x)?;
self.state.user_token = ret.clone();
Ok(ret)
}
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Send a simple JSON request to the given endpoint.
pub async fn request<T, B>(
&self,
route: String,
method: Method,
body: Option<&B>,
) -> Result<ApiReturn<T>>
where
T: Serialize + DeserializeOwned,
B: Serialize + ?Sized,
{
if let Some(body) = body {
match self
.http
.request(method, format!("{}/api/v1/auth/{route}", self.host))
.header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token))
.json(&body)
.send()
.await
{
Ok(x) => api_return_ok!(ApiReturn<T>, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
} else {
match self
.http
.request(method, format!("{}/api/v1/auth/{route}", self.host))
.header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token))
.send()
.await
{
Ok(x) => api_return_ok!(ApiReturn<T>, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
}
/// Send a JSON request with attachments to the given endpoint.
///
/// This type of request is only required for routes which use JsonMultipart,
/// such as `POST /api/v1/posts` (`create_post`).
///
/// Method is locked to `POST` for this type of request.
pub async fn request_attachments<T, B>(
&self,
route: String,
attachments: Vec<Vec<u8>>,
body: &B,
) -> Result<ApiReturn<T>>
where
T: Serialize + DeserializeOwned,
B: Serialize + ?Sized,
{
let mut multipart_body = Form::new();
// add attachments
for v in attachments.clone() {
// the file name doesn't matter
multipart_body = multipart_body.part(String::new(), Part::bytes(v));
}
drop(attachments);
// add json
multipart_body = multipart_body.part(
String::new(),
Part::text(serde_json::to_string(body).unwrap()),
);
// ...
match self
.http
.post(format!("{}/api/v1/auth/{route}", self.host))
.header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token))
.multipart(multipart_body)
.send()
.await
{
Ok(x) => api_return_ok!(ApiReturn<T>, x),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
}