Compare commits

..

2 Commits

Author SHA1 Message Date
Natty a29cea6cbc
Dropped the featured note injection column
ci/woodpecker/push/ociImagePush Pipeline was successful Details
2023-08-06 16:41:00 +02:00
Natty fded21f285
WASM SDK implementation 2023-08-06 15:48:28 +02:00
23 changed files with 2612 additions and 567 deletions

1832
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ members = [
"ext_calckey_model",
"fe_calckey",
"magnetar_common",
"magnetar_sdk",
"core"
]
@ -21,6 +22,7 @@ version = "0.2.0"
edition = "2021"
[workspace.dependencies]
async-trait = "0.1"
axum = "0.6"
cached = "0.44"
cfg-if = "1"
@ -29,15 +31,19 @@ dotenvy = "0.15"
futures-core = "0.3"
futures-util = "0.3"
headers = "0.3"
http = "0.2"
hyper = "0.14"
js-sys = "0.3"
log = "0.4"
miette = "5.9"
percent-encoding = "2.2"
redis = "0.23"
sea-orm = "0.11"
sea-orm-migration = "0.11"
reqwest = "0.11"
sea-orm = "0.12"
sea-orm-migration = "0.12"
serde = "1"
serde_json = "1"
serde-wasm-bindgen = "0.5"
strum = "0.25"
tera = { version = "1", default-features = false }
thiserror = "1"
@ -48,8 +54,12 @@ tower = "0.4"
tower-http = "0.4"
tracing = "0.1"
tracing-subscriber = "0.3"
ts-rs = "6"
url = "2.3"
walkdir = "2.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
[dependencies]
magnetar_core = { path = "./core" }
@ -57,6 +67,7 @@ magnetar_common = { path = "./magnetar_common" }
magnetar_webfinger = { path = "./ext_webfinger" }
magnetar_nodeinfo = { path = "./ext_nodeinfo" }
magnetar_calckey_model = { path = "./ext_calckey_model" }
magnetar_sdk = { path = "./magnetar_sdk" }
cached = { workspace = true }
chrono = { workspace = true }
@ -83,3 +94,6 @@ percent-encoding = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
toml = { workspace = true }
[profile.release]
lto = true

View File

@ -48,8 +48,6 @@ pub struct Model {
pub room: Json,
#[sea_orm(column_type = "JsonBinary")]
pub integrations: Json,
#[sea_orm(column_name = "injectFeaturedNote")]
pub inject_featured_note: bool,
#[sea_orm(column_name = "enableWordMute")]
pub enable_word_mute: bool,
#[sea_orm(column_name = "mutedWords", column_type = "JsonBinary")]

View File

@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*;
mod m20220101_000001_bootstrap;
mod m20230729_201733_drop_messaging_integrations;
mod m20230729_212237_user_unique_idx;
mod m20230806_142918_drop_featured_note_option;
pub struct Migrator;
@ -13,6 +14,7 @@ impl MigratorTrait for Migrator {
Box::new(m20220101_000001_bootstrap::Migration),
Box::new(m20230729_201733_drop_messaging_integrations::Migration),
Box::new(m20230729_212237_user_unique_idx::Migration),
Box::new(m20230806_142918_drop_featured_note_option::Migration),
]
}
}

View File

@ -0,0 +1,33 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
ALTER TABLE "user_profile" DROP COLUMN "injectFeaturedNote";
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
ALTER TABLE "user_profile" ADD COLUMN "injectFeaturedNote" BOOLEAN NOT NULL DEFAULT TRUE;
"#,
)
.await?;
Ok(())
}
}

View File

@ -695,7 +695,6 @@ export type Endpoints = {
preventAiLearning?: boolean;
isBot?: boolean;
isCat?: boolean;
injectFeaturedNote?: boolean;
receiveAnnouncementEmail?: boolean;
alwaysMarkNsfw?: boolean;
mutedWords?: string[][];

View File

@ -96,7 +96,6 @@ export type MeDetailed = UserDetailed & {
hasUnreadNotification: boolean;
hasUnreadSpecifiedNotes: boolean;
hideOnlineStatus: boolean;
injectFeaturedNote: boolean;
isDeleted: boolean;
isExplorable: boolean;
mutedWords: string[][];

View File

@ -1,13 +1,5 @@
<template>
<div class="_formRoot">
<FormSwitch
v-model="$i.injectFeaturedNote"
class="_formBlock"
@update:modelValue="onChangeInjectFeaturedNote"
>
{{ i18n.ts.showFeaturedNotesInTimeline }}
</FormSwitch>
<!--
<FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch>
-->
@ -29,27 +21,10 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent } from "vue";
import FormSwitch from "@/components/form/switch.vue";
import FormLink from "@/components/form/link.vue";
import MkButton from "@/components/MkButton.vue";
import * as os from "@/os";
import { popup } from "@/os";
import { defaultStore } from "@/store";
import { $i } from "@/account";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
const reportError = computed(defaultStore.makeGetterSetter("reportError"));
function onChangeInjectFeaturedNote(v) {
os.api("i/update", {
injectFeaturedNote: v,
}).then((i) => {
$i!.injectFeaturedNote = i.injectFeaturedNote;
});
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);

46
magnetar_sdk/Cargo.toml Normal file
View File

@ -0,0 +1,46 @@
[package]
name = "magnetar_sdk"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["rlib", "cdylib"]
[features]
reqwest = ["dep:reqwest"]
[target.'cfg(not(target_arch = "wasm32"))'.features]
default = ["reqwest"]
[target.'cfg(target_arch = "wasm32")'.features]
default = []
[dependencies]
magnetar_sdk_macros = { path = "./macros" }
chrono = { workspace = true, features = ["serde"] }
reqwest = { workspace = true, features = ["json"], optional = true }
http = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono", "chrono-impl"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
serde-wasm-bindgen = { workspace = true }
chrono = { workspace = true, features = ["serde", "wasm-bindgen", "js-sys"] }
js-sys = { workspace = true }
web-sys = { workspace = true, features = [
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Window",
"UrlSearchParams"
] }

View File

@ -0,0 +1,11 @@
[package]
name = "magnetar_sdk_macros"
version.workspace = true
edition.workspace = true
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full", "extra-traits"] }
quote = "1"

View File

@ -0,0 +1,237 @@
use proc_macro::TokenStream;
use std::collections::HashSet;
use syn::parse::Parse;
use syn::punctuated::Punctuated;
use syn::{Expr, ExprLit, ExprPath, Ident, Lit, Meta, MetaNameValue, Token};
struct Field {
name: Ident,
ty: Ident,
}
struct PackInput {
struct_name: syn::Ident,
fields: Vec<Field>,
}
impl Parse for PackInput {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let struct_name = input.parse()?;
let _ = input.parse::<Token![,]>()?;
let mut fields = Vec::new();
loop {
let ty = input.parse()?;
let _alias_keyword = input.parse::<Token![as]>()?;
let name = input.parse()?;
fields.push(Field { name, ty });
if input.is_empty() {
break;
}
let _ = input.parse::<Token![&]>()?;
}
Ok(PackInput {
struct_name,
fields,
})
}
}
#[proc_macro]
pub fn pack(item: TokenStream) -> TokenStream {
let parsed = syn::parse_macro_input!(item as PackInput);
let struct_name = &parsed.struct_name;
let fields = &parsed.fields;
let names = fields.iter().map(|f| &f.name);
let types = fields.iter().map(|f| &f.ty);
let export_path = format!("bindings/packed/{}.ts", struct_name);
let expanded = quote::quote! {
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export, export_to = #export_path)]
pub struct #struct_name {
#(
#[serde(flatten)]
pub #names: #types
),*
}
};
TokenStream::from(expanded)
}
#[proc_macro_derive(Endpoint, attributes(name, endpoint, method, request, response))]
pub fn derive_endpoint(item: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(item as syn::DeriveInput);
let struct_name = input.ident;
assert!(input.generics.params.is_empty(), "generics not supported");
let where_clause = input.generics.where_clause;
assert!(where_clause.is_none(), "where clauses not supported");
let data = match input.data {
syn::Data::Struct(data) => data,
_ => panic!("only unit structs are supported"),
};
assert!(
matches!(data.fields, syn::Fields::Unit),
"only unit structs are supported"
);
let mut name = struct_name.to_string();
let mut endpoint = None;
let mut method = None;
let mut request = None;
let mut response = None;
let mut found = HashSet::new();
let struct_attrs = input.attrs;
for struct_attr in struct_attrs {
if !struct_attr.meta.path().is_ident("endpoint") {
continue;
}
if struct_attr.style != syn::AttrStyle::Outer {
panic!("expected outer attribute");
}
let Meta::List(list) = struct_attr.meta else {
panic!("expected a list of attributes");
};
let nested = list
.parse_args_with(Punctuated::<MetaNameValue, Token![,]>::parse_terminated)
.expect("expected a list of attributes");
for kvp in nested {
let key = kvp
.path
.get_ident()
.expect("expected a single identifier")
.to_owned();
let value = kvp.value;
if found.contains(&key) {
panic!("duplicate attribute: {}", key);
}
match key.to_string().as_ref() {
"name" => {
let Expr::Lit(ExprLit { lit: Lit::Str(new_name), .. }) = value else {
panic!("expected a string literal");
};
name = new_name.value();
}
"endpoint" => {
let Expr::Lit(ExprLit { lit: Lit::Str(endpoint_val), .. }) = value else {
panic!("expected a string literal");
};
endpoint = Some(endpoint_val.value());
}
"method" => {
let Expr::Path(ExprPath { path, .. }) = value else {
panic!("expected a an identifier");
};
method = Some(path);
}
"request" => {
let Expr::Path(ExprPath { path, .. }) = value else {
panic!("expected a an identifier");
};
request = Some(path);
}
"response" => {
let Expr::Path(ExprPath { path, .. }) = value else {
panic!("expected an identifier")
};
response = Some(path);
}
_ => panic!("unknown attribute: {}", key),
}
found.insert(key);
}
}
let endpoint = endpoint.expect("missing endpoint attribute");
let method = method.expect("missing method attribute");
let request = request.expect("missing request attribute");
let response = response.expect("missing response attribute");
let ts_path = format!("bindings/endpoints/{}.ts", name);
let expanded = quote::quote! {
impl Default for #struct_name {
fn default() -> Self {
Self
}
}
impl Endpoint for #struct_name {
const NAME: &'static str = #name;
const ENDPOINT: &'static str = #endpoint;
const METHOD: Method = #method;
type Request = #request;
type Response = #response;
}
#[cfg(test)]
#[test]
fn export_bindings_getuserbyid() {
#struct_name::export().expect("could not export type");
}
impl TS for #struct_name {
const EXPORT_TO: Option<&'static str> = Some(#ts_path);
fn decl() -> String {
format!(
"interface {} {{\n \
endpoint: \"{}\";\n \
method: \"{}\";\n \
request: {};\n \
response: {};\n\
}}
",
Self::name(),
Self::ENDPOINT,
Self::METHOD,
<#struct_name as Endpoint>::Request::name(),
<#struct_name as Endpoint>::Response::name()
)
}
fn name() -> String {
#struct_name::NAME.to_string()
}
fn dependencies() -> Vec<Dependency> {
vec![
Dependency::from_ty::<<#struct_name as Endpoint>::Request>().unwrap(),
Dependency::from_ty::<<#struct_name as Endpoint>::Response>().unwrap(),
]
}
fn transparent() -> bool {
false
}
}
};
TokenStream::from(expanded)
}

View File

@ -0,0 +1,277 @@
use crate::client::ApiClientImpl;
use crate::endpoints::list::match_endpoint;
use crate::endpoints::{Endpoint, ErrorKind, ResponseError};
use crate::Client;
use http::Method;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
pub struct FetchClient {
base_url: String,
application: String,
}
impl FetchClient {
pub fn new(base_url: &str, application: &str) -> Self {
FetchClient {
base_url: base_url.to_string(),
application: application.to_string(),
}
}
}
#[async_trait::async_trait(?Send)]
impl Client for FetchClient {
fn base_url(&self) -> &str {
&self.base_url
}
async fn call<'a, I, O, E>(
&self,
endpoint: &E,
data: &I,
path_params: &impl AsRef<[(&'a str, &'a str)]>,
) -> Result<O, ResponseError>
where
I: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
E: Endpoint<Request = I, Response = O> + Send + 'static,
{
let url = endpoint.template_path(self.base_url(), path_params);
let url = if E::METHOD == Method::GET {
let search = web_sys::UrlSearchParams::new_with_str_sequence_sequence(
&serde_wasm_bindgen::to_value(data).unwrap(),
)
.unwrap();
format!("{}?{}", url, search.to_string())
} else {
url
};
let mut opts = web_sys::RequestInit::new();
opts.method(E::METHOD.as_str());
if E::METHOD != Method::GET {
opts.body(Some(&serde_wasm_bindgen::to_value(data).unwrap()));
}
let headers = web_sys::Headers::new().unwrap();
headers.set("Content-Type", "application/json").unwrap();
headers.set("User-Agent", &self.application).unwrap();
opts.headers(&headers);
let req = web_sys::Request::new_with_str_and_init(&url, &opts).unwrap();
let js_response = wasm_bindgen_futures::JsFuture::from(
web_sys::window()
.ok_or_else(|| ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:NoWindow".to_string(),
message: "No window object".to_string(),
status: None,
})?
.fetch_with_request(&req),
)
.await
.map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:FetchFail".to_string(),
message: format!("{:#?}", e),
status: None,
})?;
let response = web_sys::Response::from(js_response);
let status = response.status();
if (400..=599).contains(&status) {
let body = wasm_bindgen_futures::JsFuture::from(response.json().map_err(|e| {
ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:ResponseErrorPromiseFail".to_string(),
message: format!("{:#?}", e),
status: Some(status),
}
})?)
.await
.map_err(|e| ResponseError {
kind: ErrorKind::ApiError,
code: "FetchClient:ResponseErrorFail".to_string(),
message: format!("{:#?}", e),
status: Some(status),
})?;
let data = serde_wasm_bindgen::from_value::<ResponseError>(body).map_err(|e| {
ResponseError {
kind: ErrorKind::ApiError,
code: "FetchClient:ResponseErrorDeserializeFail".to_string(),
message: format!("{:#?}", e),
status: Some(status),
}
})?;
return Err(data);
} else if (200..=299).contains(&status) {
if status == 204 {
return if let Some(val) = E::default_response() {
Ok(val)
} else {
Err(ResponseError {
kind: ErrorKind::ApiError,
code: "FetchClient:ResponseError204".to_string(),
message: "Response is empty".to_string(),
status: Some(status),
})
};
}
let body = wasm_bindgen_futures::JsFuture::from(response.json().map_err(|e| {
ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:ResponseJsonPromiseFail".to_string(),
message: format!("{:#?}", e),
status: None,
}
})?)
.await
.map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:ResponseJsonFail".to_string(),
message: format!("{:#?}", e),
status: None,
})?;
let data = serde_wasm_bindgen::from_value::<O>(body).map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "FetchClient:ResponseErrorDeserializeFail".to_string(),
message: e.to_string(),
status: None,
})?;
return Ok(data);
}
Err(ResponseError {
kind: ErrorKind::ApiError,
code: "FetchClient:ApiUnknownStatusError".to_string(),
message: response.status().to_string(),
status: Some(response.status()),
})
}
}
#[wasm_bindgen(getter_with_clone)]
pub struct ApiClientOptions {
base_url: String,
application: String,
}
#[wasm_bindgen]
pub struct ApiClient {
client: ApiClientImpl,
}
#[wasm_bindgen]
impl ApiClient {
#[wasm_bindgen(constructor)]
pub fn new(implementation: &str, options: ApiClientOptions) -> Option<ApiClient> {
match implementation {
"fetch" => Some(Self {
client: ApiClientImpl::Fetch(FetchClient::new(
&options.base_url,
&options.application,
)),
}),
#[cfg(feature = "reqwest")]
"reqwest" => Some(Self {
client: ApiClientImpl::Reqwest(crate::client::reqwest::ReqwestClient::new(
&options.base_url,
&options.application,
)),
}),
_ => None,
}
}
async fn call<I: Serialize, E: Endpoint, O: DeserializeOwned>(
&self,
endpoint: &E,
data: &I,
path_params: &[(&str, &str)],
) -> Result<O, ResponseError>
where
I: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
E: Endpoint<Request = I, Response = O> + Send + 'static,
{
match &self.client {
ApiClientImpl::Fetch(client) => {
client.call::<I, O, E>(endpoint, data, &path_params).await
}
#[cfg(feature = "reqwest")]
ApiClientImpl::Reqwest(client) => {
client.call::<I, O, E>(endpoint, data, &path_params).await
}
}
}
}
#[wasm_bindgen(getter_with_clone)]
#[derive(Debug, Clone)]
pub struct EndpointLike {
pub path: String,
pub method: String,
}
#[wasm_bindgen]
pub async fn api_call(
client: &ApiClient,
endpoint_like: &EndpointLike,
data: JsValue,
path_params: JsValue,
) -> Result<JsValue, ResponseError> {
let method = Method::try_from(endpoint_like.method.as_str()).map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "Bindgen::ParseMethod".to_string(),
message: e.to_string(),
status: None,
})?;
let endpoint = match_endpoint(&endpoint_like.path, &method).ok_or_else(|| ResponseError {
kind: ErrorKind::Other,
code: "Bindgen::MatchEndpoint".to_string(),
message: format!(
"No endpoint `{}` with method `{}` found",
endpoint_like.path, method
),
status: None,
})?;
let input = serde_wasm_bindgen::from_value(data).map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "Bindgen::DeserializeInput".to_string(),
message: e.to_string(),
status: None,
})?;
let path_params = serde_wasm_bindgen::from_value::<HashMap<String, String>>(path_params)
.map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "Bindgen::DeserializePathParams".to_string(),
message: e.to_string(),
status: None,
})?;
let path_params_borrow = path_params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect::<Vec<(&str, &str)>>();
serde_wasm_bindgen::to_value(&client.call(&endpoint, &input, &path_params_borrow).await?)
.map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "Bindgen::SerializeOutput".to_string(),
message: e.to_string(),
status: None,
})
}

View File

@ -0,0 +1,12 @@
#[cfg(feature = "reqwest")]
pub mod reqwest;
#[cfg(target_arch = "wasm32")]
pub mod fetch;
pub enum ApiClientImpl {
#[cfg(feature = "reqwest")]
Reqwest(reqwest::ReqwestClient),
#[cfg(target_arch = "wasm32")]
Fetch(fetch::FetchClient),
}

View File

@ -0,0 +1,112 @@
use crate::endpoints::{Endpoint, ErrorKind, ResponseError};
use crate::Client;
use http::header::{CONTENT_TYPE, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
pub struct ReqwestClient {
client: reqwest::Client,
base_url: String,
}
impl ReqwestClient {
pub fn new(base_url: &str, application: &str) -> Self {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str("application/json").unwrap(),
);
headers.insert(USER_AGENT, HeaderValue::from_str(application).unwrap());
let client_builder = reqwest::ClientBuilder::new().default_headers(headers);
#[cfg(not(target_arch = "wasm32"))]
let client_builder = { client_builder.https_only(true) };
let client = client_builder.build().unwrap();
ReqwestClient {
client,
base_url: base_url.to_string(),
}
}
}
#[async_trait::async_trait(?Send)]
impl Client for ReqwestClient {
fn base_url(&self) -> &str {
&self.base_url
}
async fn call<'a, I, O, E>(
&self,
endpoint: &E,
data: &I,
path_params: &impl AsRef<[(&'a str, &'a str)]>,
) -> Result<O, ResponseError>
where
I: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
E: Endpoint<Request = I, Response = O> + Send + 'static,
{
let url = endpoint.template_path(self.base_url(), path_params);
let req = self.client.request(E::METHOD, &url);
let req = if E::METHOD == Method::GET {
req.query(&data)
} else {
req.json(&data)
};
let response = req.send().await.map_err(|e| ResponseError {
kind: ErrorKind::Other,
code: "ReqwestClient:Fail".to_string(),
message: e.to_string(),
status: None,
})?;
let status = response.status();
if status.is_client_error() || status.is_server_error() {
match response.json::<ResponseError>().await {
Ok(res) => Err(res),
Err(e) => Err(ResponseError {
kind: ErrorKind::ApiError,
code: "ReqwestClient:ApiGenericError".to_string(),
message: e.to_string(),
status: Some(status.as_u16()),
}),
}
} else if status.is_success() {
if status == StatusCode::NO_CONTENT.as_u16() {
return if let Some(val) = E::default_response() {
Ok(val)
} else {
Err(ResponseError {
kind: ErrorKind::ApiError,
code: "ReqwestClient:ResponseError204".to_string(),
message: "Response is empty".to_string(),
status: Some(status.as_u16()),
})
};
}
let data = response.json::<O>().await.map_err(|e| ResponseError {
kind: ErrorKind::ApiError,
code: "ReqwestClient:JsonError".to_string(),
message: e.to_string(),
status: Some(status.as_u16()),
})?;
Ok(data)
} else {
Err(ResponseError {
kind: ErrorKind::ApiError,
code: "ReqwestClient:ApiUnknownStatusError".to_string(),
message: status.to_string(),
status: Some(status.as_u16()),
})
}
}
}

View File

@ -0,0 +1,21 @@
use crate::endpoints::Endpoint;
macro_rules! match_from {
(($endpoint:expr, $method:expr) in [$($e:ty,)*]) => {
match ($endpoint, $method) {
$(
(<$e as Endpoint>::ENDPOINT, &<$e as Endpoint>::METHOD) => Some(<$e as Default>::default()),
)*
_ => None,
}
};
}
pub(crate) fn match_endpoint(endpoint: &str, method: &http::Method) -> Option<impl Endpoint> {
match_from!(
(endpoint, method)
in [
crate::endpoints::user::GetUserById,
]
)
}

View File

@ -0,0 +1,57 @@
pub(crate) mod list;
pub mod user;
use http::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter_with_clone))]
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
pub struct ResponseError {
pub kind: ErrorKind,
pub status: Option<u16>,
pub code: String,
pub message: String,
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, TS)]
pub enum ErrorKind {
#[default]
ApiError,
Other,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
pub struct Empty;
impl From<Empty> for () {
fn from(_: Empty) -> Self {}
}
pub trait Endpoint {
const NAME: &'static str;
const ENDPOINT: &'static str;
const METHOD: Method;
type Request: Serialize + DeserializeOwned + Send + Sync + 'static;
type Response: Serialize + DeserializeOwned + Send + Sync + 'static;
fn default_response() -> Option<Self::Response> {
None
}
fn template_path<'a>(&self, base_path: &str, var: &impl AsRef<[(&'a str, &'a str)]>) -> String {
let mut path_suffix = Self::ENDPOINT.to_string();
for (key, value) in var.as_ref() {
path_suffix = path_suffix.replace(&format!(":{}", key), value);
}
format!("{}{}", base_path, path_suffix)
}
}

View File

@ -0,0 +1,24 @@
use crate::endpoints::Endpoint;
use http::Method;
use magnetar_sdk_macros::Endpoint;
use serde::{Deserialize, Serialize};
use ts_rs::{Dependency, TS};
use crate::types::user::PackUserProfileFull;
#[derive(Serialize, Deserialize, TS)]
#[ts(export)]
pub struct UserByIdReq {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Endpoint, Deserialize)]
#[endpoint(
endpoint = "/users/:user_id",
method = Method::GET,
request = UserByIdReq,
response = PackUserProfileFull
)]
pub struct GetUserById;

23
magnetar_sdk/src/lib.rs Normal file
View File

@ -0,0 +1,23 @@
pub mod client;
pub mod endpoints;
pub mod types;
use crate::endpoints::Endpoint;
use endpoints::ResponseError;
use serde::{de::DeserializeOwned, Serialize};
#[async_trait::async_trait(?Send)]
pub trait Client {
fn base_url(&self) -> &str;
async fn call<'a, I, O, E>(
&self,
endpoint: &E,
data: &I,
path_params: &impl AsRef<[(&'a str, &'a str)]>,
) -> Result<O, ResponseError>
where
I: Serialize + DeserializeOwned + Send + 'static,
O: Serialize + DeserializeOwned + Send + 'static,
E: Endpoint<Request = I, Response = O> + Send + 'static;
}

View File

@ -0,0 +1,89 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::types::user::PackUserBase;
use magnetar_sdk_macros::pack;
use crate::types::Id;
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct ImageMeta {
pub width: Option<u64>,
pub height: Option<u64>,
pub orientation: Option<u64>,
pub color: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct DriveFolderBase {
pub name: String,
pub created_at: DateTime<Utc>,
pub comment: Option<String>,
pub file_count: u64,
pub folder_count: u64,
pub parent_id: Option<String>,
pub user_id: String,
}
pack!(PackDriveFolderBase, Id as id & DriveFolderBase as folder);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct DriveFolderParentExt {
pub folder: Box<DriveFolderBase>,
}
pack!(
PackDriveFolderWithParent,
Id as id & DriveFolderBase as folder & DriveFolderParentExt as parent
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct DriveFileBase {
pub name: String,
pub created_at: DateTime<Utc>,
pub size: u64,
pub sha256: String,
pub mime_type: String,
pub image_meta: ImageMeta,
pub url: Option<String>,
pub thumbnail_url: Option<String>,
pub sensitive: bool,
pub comment: Option<String>,
pub folder_id: Option<String>,
pub user_id: String,
}
pack!(PackDriveFileBase, Id as id & DriveFileBase as file);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct DriveFileFolderExt {
pub folder: Box<PackDriveFolderBase>,
}
pack!(
PackDriveFileWithFolder,
Id as id & DriveFileBase as file & DriveFileFolderExt as folder
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct DriveFileUserExt {
pub user: Box<PackUserBase>,
}
pack!(
PackDriveFileWithUser,
Id as id & DriveFileBase as file & DriveFileUserExt as user
);
pack!(
PackDriveFileFull,
Id as id & DriveFileBase as file & DriveFileFolderExt as folder & DriveFileUserExt as user
);

View File

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::types::Id;
use magnetar_sdk_macros::pack;
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct EmojiBase {
pub shortcode: String,
pub url: String,
pub static_url: String,
pub visible_in_picker: bool,
pub category: Option<String>,
}
pack!(PackEmojiBase, Id as id & EmojiBase as emoji);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
#[repr(transparent)]
pub struct EmojiContext(pub Vec<String>);

View File

@ -0,0 +1,44 @@
pub mod drive;
pub mod emoji;
pub mod note;
pub mod user;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
#[repr(transparent)]
pub struct Id {
pub id: String,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub enum NotificationType {
Follow,
Mention,
Reply,
Renote,
Quote,
Reaction,
PollVote,
PollEnded,
FollowRequest,
FollowRequestAccepted,
App,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct NotificationSettings {
pub enabled: Vec<NotificationType>,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub enum FollowVisibility {
Public,
Followers,
Private,
}

View File

@ -0,0 +1,103 @@
use crate::types::emoji::EmojiContext;
use crate::types::user::{PackUserBase, UserBase};
use crate::types::Id;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::types::drive::PackDriveFileBase;
use magnetar_sdk_macros::pack;
#[derive(Copy, Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub enum NoteVisibility {
Public,
Home,
Followers,
Specified,
Direct,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct PollChoice {
pub title: String,
pub votes_count: u64,
pub voted: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct PollBase {
pub expires_at: DateTime<Utc>,
pub expired: bool,
pub multiple_choice: bool,
pub options: Vec<PollChoice>,
}
pack!(PackPollBase, Id as id & PollBase as poll);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct NoteBase {
pub created_at: DateTime<Utc>,
pub cw: Option<String>,
pub uri: Option<String>,
pub url: Option<String>,
pub text: String,
pub visibility: NoteVisibility,
pub user: Box<UserBase>,
pub parent_note_id: Option<String>,
pub renoted_note_id: Option<String>,
pub reply_count: u64,
pub renote_count: u64,
pub hashtags: Vec<String>,
pub reactions: Vec<PackReactionBase>,
pub emojis: EmojiContext,
pub local_only: bool,
}
pack!(PackNoteBase, Id as id & NoteBase as note);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct NoteAttachmentExt {
pub attachments: Vec<PackDriveFileBase>,
pub poll: Option<PollBase>,
}
pack!(
PackNoteWithAttachments,
Id as id & NoteBase as note & NoteAttachmentExt as attachment
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
pub struct NoteDetailExt {
pub poll: Option<PollBase>,
pub parent_note: Option<Box<NoteBase>>,
pub renoted_note: Option<Box<NoteBase>>,
}
pack!(
PackNoteFull,
Id as id & NoteBase as note & NoteAttachmentExt as attachment & NoteDetailExt as detail
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
pub struct ReactionBase {
pub created_at: DateTime<Utc>,
pub user_id: String,
}
pack!(PackReactionBase, Id as id & ReactionBase as reaction);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
pub struct ReactionUserExt {
pub user: Box<PackUserBase>,
}
pack!(
PackReactionWithUser,
Id as id & ReactionBase as reaction & ReactionUserExt as user
);

View File

@ -0,0 +1,187 @@
use crate::types::emoji::EmojiContext;
use crate::types::note::PackNoteBase;
use crate::types::{Id, NotificationSettings};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use magnetar_sdk_macros::pack;
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub enum AvatarDecoration {
None,
CatEars,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub enum SpeechTransform {
None,
Cat,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct ProfileField {
name: String,
value: String,
verified_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserBase {
pub acct: String,
pub username: String,
pub display_name: String,
pub host: Option<String>,
pub speech_transform: SpeechTransform,
pub created_at: DateTime<Utc>,
pub avatar_url: Option<String>,
pub avatar_blurhash: Option<String>,
pub avatar_color: Option<String>,
pub avatar_decoration: AvatarDecoration,
pub admin: bool,
pub moderator: bool,
pub bot: bool,
pub emojis: EmojiContext,
}
pack!(PackUserBase, Id as id & UserBase as user);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserProfileExt {
pub locked: bool,
pub silenced: bool,
pub suspended: bool,
pub description: Option<String>,
pub location: Option<String>,
pub birthday: Option<DateTime<Utc>>,
pub fields: Vec<ProfileField>,
pub follower_count: u64,
pub following_count: u64,
pub note_count: u64,
pub url: Option<String>,
pub moved_to_uri: Option<String>,
pub also_known_as: Option<String>,
pub banner_url: Option<String>,
pub banner_color: Option<String>,
pub banner_blurhash: Option<String>,
pub public_reactions: bool,
}
pack!(
PackUserProfile,
Id as id & UserBase as user & UserProfileExt as profile
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserProfilePinsEx {
pub pinned_notes: Vec<PackNoteBase>,
// pub pinned_page: Option<Page>,
}
pack!(
PackUserProfileFull,
Id as id
& UserBase as user
& UserProfileExt as profile
& UserProfilePinsEx as pins
& UserRelationExt as relation
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserRelationExt {
pub last_fetched_at: Option<DateTime<Utc>>,
pub follows_you: bool,
pub you_follow: bool,
pub blocks_you: bool,
pub you_block: bool,
pub mute: bool,
pub mute_renotes: bool,
}
pack!(
PackUserRelation,
Id as id & UserBase as user & UserRelationExt as relation
);
pack!(
PackUserProfileRelation,
Id as id & UserBase as user & UserProfileExt as profile & UserRelationExt as relation
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserAuthOverviewExt {
pub two_factor_enabled: bool,
pub use_passwordless_login: bool,
pub security_keys: bool,
}
pack!(
PackUserAuthOverview,
Id as id & UserBase as user & UserAuthOverviewExt as auth
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserSelfExt {
pub avatar_id: Option<String>,
pub banner_id: Option<String>,
pub email_announcements: bool,
pub always_mark_sensitive: bool,
pub reject_bot_follow_requests: bool,
pub reject_crawlers: bool,
pub reject_ai_training: bool,
pub has_unread_announcements: bool,
pub has_unread_antenna: bool,
pub has_unread_notifications: bool,
pub has_pending_follow_requests: bool,
pub word_mutes: Vec<String>,
pub instance_mutes: Vec<String>,
pub notification_settings: NotificationSettings,
}
pack!(
PackUserSelf,
Id as id & UserBase as user & UserSelfExt as self_info
);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserDetailExt {
pub uri: Option<String>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct SecurityKeyBase {
pub name: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
pack!(PackSecurityKeyBase, Id as id & SecurityKeyBase as key);
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct UserSecretsExt {
pub email: Option<String>,
pub email_verified: bool,
pub security_keys: Vec<SecurityKeyBase>,
}
pack!(
PackUserSelfAll,
Id as id & UserBase as user & UserSelfExt as self_info & UserSecretsExt as secrets
);