Initial commit

This commit is contained in:
Aleksandr 2025-09-19 18:31:17 +03:00
commit bd9b07052b
81 changed files with 5516 additions and 0 deletions

33
http/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "viendesu-http"
version = "0.1.0"
edition = "2024"
[features]
default = []
client = ["dep:reqwest"]
server = [
"dep:axum",
"dep:http-body-util",
"dep:tower-http",
"dep:serde_urlencoded",
]
[dependencies]
viendesu-core.workspace = true
http = "1.3.1"
serde.workspace = true
serde_with.workspace = true
serde_json.workspace = true
futures.workspace = true
eva.workspace = true
eyre.workspace = true
axum = { optional = true, version = "0.8.4", default-features = false, features = ["http2", "ws", "macros"] }
http-body-util = { version = "0.1.3", optional = true }
tower-http = { optional = true, version = "0.6.6", default-features = false, features = ["limit"] }
serde_urlencoded = { version = "0.7.1", optional = true }
reqwest = { optional = true, version = "0.12.23", default-features = false }

15
http/src/client/auth.rs Normal file
View file

@ -0,0 +1,15 @@
use viendesu_core::errors::AuxResult;
use super::*;
impl AuthenticationMut for Hidden<'_> {
async fn authenticate(&mut self, session: session::Token) -> AuxResult<()> {
self.0.session = Some(session);
Ok(())
}
fn clear(&mut self) {
self.0.session = None;
}
}

View file

@ -0,0 +1,23 @@
use super::*;
use viendesu_core::requests::authors::{create, get, update};
use crate::requests::authors as requests;
impl AuthorsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, |get::Args { author }| {
(format_compact!("/authors/{author}"), requests::Get {})
})
}
}
impl AuthorsMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Update>())
}
}

25
http/src/client/boards.rs Normal file
View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::boards::{create, delete, edit, get};
use crate::requests::boards as requests;
impl BoardsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
}
impl BoardsMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
}

25
http/src/client/games.rs Normal file
View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::games::{create, get, search, update};
use crate::requests::games as requests;
impl GamesRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err> {
self.do_call(Method::POST, todo::<_, requests::Search>())
}
}
impl GamesMut for Hidden<'_> {
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Update>())
}
}

View file

@ -0,0 +1,25 @@
use super::*;
use viendesu_core::requests::messages::{delete, edit, get, post};
use crate::requests::messages as requests;
impl MessagesRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
}
impl MessagesMut for Hidden<'_> {
fn post(&mut self) -> impl CallStep<post::Args, Ok = post::Ok, Err = post::Err> {
self.do_call(Method::POST, todo::<_, requests::Post>())
}
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
}

165
http/src/client/mod.rs Normal file
View file

@ -0,0 +1,165 @@
use std::sync::Arc;
use eva::{
error::ShitHappens,
str::{CompactString, format_compact},
};
use http::Method;
use viendesu_core::{
errors,
requests::Response,
service::{
CallStep, Session, SessionMaker,
authors::{AuthorsMut, AuthorsRef},
authz::AuthenticationMut,
boards::{BoardsMut, BoardsRef},
games::{GamesMut, GamesRef},
messages::{MessagesMut, MessagesRef},
threads::{ThreadsMut, ThreadsRef},
users::{UsersMut, UsersRef},
},
types::session,
};
use crate::{format::Format, requests::Request};
mod boards;
mod messages;
mod threads;
mod auth;
mod authors;
mod games;
mod users;
struct DoRequest<'c, P> {
client: &'c mut HttpClient,
method: Method,
map_payload: P,
}
impl<'c, P, C, H> CallStep<C> for DoRequest<'c, P>
where
C: Send + Sync,
P: Send + Sync + FnMut(C) -> (CompactString, H),
H: Request,
{
type Ok = H::Response;
type Err = H::Error;
async fn call(&mut self, req: C) -> Response<Self::Ok, Self::Err> {
let (path, request) = (self.map_payload)(req);
self.client
.do_request(self.method.clone(), &path, request)
.await
}
}
struct Hidden<'a>(&'a mut HttpClient);
fn todo<C, O>() -> impl Send + Sync + FnMut(C) -> (CompactString, O) {
|_| todo!()
}
impl<'t> Hidden<'t> {
fn do_call<'this, P>(&'this mut self, method: Method, map_payload: P) -> DoRequest<'this, P> {
DoRequest {
client: self.0,
method,
map_payload,
}
}
}
macro_rules! fwd {
($($method:ident -> $Ret:ident;)*) => {$(
fn $method(&mut self) -> impl $Ret {
Hidden(self)
}
)*};
}
pub struct HttpClient {
options: Arc<ClientOptions>,
client: reqwest::Client,
session: Option<session::Token>,
}
impl HttpClient {
async fn do_request<R>(
&self,
method: Method,
path: &str,
request: R,
) -> Response<R::Response, R::Error>
where
R: Request,
{
todo!()
}
}
impl Session for HttpClient {
fwd! {
users -> UsersRef;
users_mut -> UsersMut;
authors -> AuthorsRef;
authors_mut -> AuthorsMut;
games -> GamesRef;
games_mut -> GamesMut;
boards -> BoardsMut;
threads -> ThreadsMut;
messages -> MessagesMut;
authentication_mut -> AuthenticationMut;
}
}
pub struct ClientOptions {
pub format: Format,
pub endpoint: String,
}
#[derive(Clone)]
pub struct HttpService {
options: Arc<ClientOptions>,
inner: reqwest::Client,
}
impl SessionMaker for HttpService {
type Session = HttpClient;
async fn make_session(&self) -> errors::AuxResult<Self::Session> {
Ok(HttpClient {
options: Arc::clone(&self.options),
client: self.inner.clone(),
session: None,
})
}
}
impl HttpService {
pub fn new(options: ClientOptions) -> Self {
let inner = reqwest::Client::builder()
.user_agent("Makise/HTTPP")
.default_headers({
use reqwest::header;
let mut headers = header::HeaderMap::new();
headers.insert(
"Accept",
header::HeaderValue::from_static(options.format.mime_type()),
);
headers
})
.build()
.shit_happens();
Self {
inner,
options: Arc::new(options),
}
}
}

View file

@ -0,0 +1,29 @@
use super::*;
use viendesu_core::requests::threads::{create, delete, edit, get, search};
use crate::requests::threads as requests;
impl ThreadsRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, todo::<_, requests::Get>())
}
fn search(&mut self) -> impl CallStep<search::Args, Ok = search::Ok, Err = search::Err> {
self.do_call(Method::POST, todo::<_, requests::Search>())
}
}
impl ThreadsMut for Hidden<'_> {
fn delete(&mut self) -> impl CallStep<delete::Args, Ok = delete::Ok, Err = delete::Err> {
self.do_call(Method::DELETE, todo::<_, requests::Delete>())
}
fn edit(&mut self) -> impl CallStep<edit::Args, Ok = edit::Ok, Err = edit::Err> {
self.do_call(Method::PATCH, todo::<_, requests::Edit>())
}
fn create(&mut self) -> impl CallStep<create::Args, Ok = create::Ok, Err = create::Err> {
self.do_call(Method::POST, todo::<_, requests::Create>())
}
}

74
http/src/client/users.rs Normal file
View file

@ -0,0 +1,74 @@
use super::*;
use eva::str::format_compact;
use viendesu_core::requests::users::{check_auth, confirm_sign_up, get, sign_in, sign_up, update};
use crate::requests::users as requests;
impl UsersRef for Hidden<'_> {
fn get(&mut self) -> impl CallStep<get::Args, Ok = get::Ok, Err = get::Err> {
self.do_call(Method::GET, |get::Args { user }| match user {
Some(u) => (format_compact!("/users/{u}"), requests::Get {}),
None => ("/users/me".into(), requests::Get {}),
})
}
fn check_auth(
&mut self,
) -> impl CallStep<check_auth::Args, Ok = check_auth::Ok, Err = check_auth::Err> {
self.do_call(Method::GET, |check_auth::Args {}| {
("/users/check_auth".into(), requests::CheckAuth {})
})
}
}
impl UsersMut for Hidden<'_> {
fn confirm_sign_up(
&mut self,
) -> impl CallStep<confirm_sign_up::Args, Ok = confirm_sign_up::Ok, Err = confirm_sign_up::Err>
{
self.do_call(Method::POST, todo::<_, requests::ConfirmSignUp>())
}
fn update(&mut self) -> impl CallStep<update::Args, Ok = update::Ok, Err = update::Err> {
self.do_call(Method::PATCH, |update::Args { user, update }| match user {
Some(u) => (
format_compact!("/users/{u}"),
requests::Update::from(update),
),
None => ("/users/me".into(), requests::Update::from(update)),
})
}
fn sign_up(&mut self) -> impl CallStep<sign_up::Args, Ok = sign_up::Ok, Err = sign_up::Err> {
self.do_call(
Method::POST,
|sign_up::Args {
nickname,
email,
display_name,
password,
}| {
(
"/users/sign_up".into(),
requests::SignUp {
nickname,
email,
password,
display_name,
},
)
},
)
}
fn sign_in(&mut self) -> impl CallStep<sign_in::Args, Ok = sign_in::Ok, Err = sign_in::Err> {
self.do_call(Method::POST, |sign_in::Args { nickname, password }| {
(
"/users/sign_in".into(),
requests::SignIn { nickname, password },
)
})
}
}

63
http/src/format.rs Normal file
View file

@ -0,0 +1,63 @@
use eva::{data, error::ShitHappens};
use eyre::Context;
#[data(copy, display("got unsupported mime type"), error)]
pub struct UnknownMimeType;
#[data(copy, display("{}", self.mime_type()))]
#[derive(Default)]
pub enum Format {
#[default]
Json,
}
impl Format {
pub fn from_mime_type(mime: &str) -> Result<Self, UnknownMimeType> {
use Format::*;
use UnknownMimeType as E;
match mime {
"application/json" => Ok(Json),
_ => Err(E),
}
}
pub const fn mime_type(self) -> &'static str {
use Format::*;
match self {
Json => "application/json",
}
}
}
#[data]
#[derive(Default)]
pub struct DumpParams {
pub pretty: bool,
}
impl Format {
pub fn dump<T: ?Sized>(self, params: DumpParams, what: &T, dst: &mut Vec<u8>)
where
T: serde::Serialize,
{
match self {
Self::Json => if params.pretty {
serde_json::to_writer_pretty(dst, what)
} else {
serde_json::to_writer(dst, what)
}
.shit_happens(),
}
}
pub fn load<'de, T>(&self, buf: &'de [u8]) -> eyre::Result<T>
where
T: serde::Deserialize<'de>,
{
match self {
Self::Json => serde_json::from_slice(buf).wrap_err("failed to deserialize JSON"),
}
}
}

7
http/src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "server")]
pub mod server;
pub mod format;
pub mod requests;

View file

@ -0,0 +1,53 @@
use eva::data;
use viendesu_core::{
errors,
requests::authors as reqs,
types::{Patch, author, file, user},
};
use crate::requests::status_code;
#[serde_with::apply(Patch => #[serde(default)])]
#[data]
pub struct Update {
pub title: Patch<author::Title>,
pub description: Patch<Option<author::Description>>,
pub pfp: Patch<Option<file::Id>>,
pub slug: Patch<author::Slug>,
pub verified: Patch<bool>,
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
#[data]
pub struct Create {
pub title: author::Title,
pub slug: author::Slug,
pub description: Option<author::Description>,
pub owner: Option<user::Id>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => OK);
status_code::map!(reqs::create::Err => [NotFound, AlreadyExists, NoSuchUser]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
const _: () = {
use errors::authors::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(AlreadyExists => BAD_REQUEST);
};

View file

@ -0,0 +1,56 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::boards as reqs,
types::{Patch, board, message},
};
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
#[data]
pub struct Edit {
pub text: Patch<message::Text>,
pub slug: Patch<Option<board::Slug>>,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Create {
pub slug: board::Slug,
pub initial_message: message::Text,
pub by: Option<message::ById>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => OK);
status_code::map!(reqs::create::Err => [AlreadyExists]);
const _: () = {
use errors::boards::*;
use status_code::direct;
direct!(AlreadyExists => BAD_REQUEST);
direct!(NotFound => NOT_FOUND);
};

View file

@ -0,0 +1,81 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::games as reqs,
types::{Patch, author, file, game},
};
#[data]
#[serde_with::apply(Patch => #[serde(default)])]
pub struct Update {
pub title: Patch<game::Title>,
pub description: Patch<Option<game::Description>>,
pub slug: Patch<game::Slug>,
pub thumbnail: Patch<Option<file::Id>>,
pub genres: Patch<game::Genres>,
pub badges: Patch<game::Badges>,
pub tags: Patch<game::Tags>,
pub screenshots: Patch<game::Screenshots>,
pub published: Patch<bool>,
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
#[data]
pub struct Search {
pub query: Option<game::SearchQuery>,
pub author: Option<author::Selector>,
#[serde(default)]
pub include: reqs::search::Condition,
#[serde(default)]
pub exclude: reqs::search::Condition,
#[serde(default)]
pub order: reqs::search::Order,
#[serde(default)]
pub sort_by: reqs::search::SortBy,
pub limit: Option<reqs::search::Limit>,
}
impl_req!(Search => [reqs::search::Ok; reqs::search::Err]);
status_code::direct!(reqs::search::Ok => OK);
status_code::map!(reqs::search::Err => [NoSuchAuthor]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound, NoSuchAuthor]);
#[data]
pub struct Create {
pub title: game::Title,
pub description: Option<game::Description>,
pub thumbnail: Option<file::Id>,
pub author: author::Id,
pub slug: Option<game::Slug>,
pub vndb: Option<game::VndbId>,
pub release_date: Option<game::ReleaseDate>,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => CREATED);
status_code::map!(reqs::create::Err => [AlreadyTaken, NoSuchAuthor]);
const _: () = {
use errors::games::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(NotAnOwner => FORBIDDEN);
direct!(AlreadyTaken => BAD_REQUEST);
};

View file

@ -0,0 +1,53 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::messages as reqs,
types::{message, thread},
};
#[data]
pub struct Edit {
pub text: message::Text,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Post {
pub thread: thread::Selector,
pub text: message::Text,
}
impl_req!(Post => [reqs::post::Ok; reqs::post::Err]);
status_code::direct!(reqs::post::Ok => OK);
status_code::map!(reqs::post::Err => [NoSuchThread]);
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
const _: () = {
use errors::messages::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
};

32
http/src/requests/mod.rs Normal file
View file

@ -0,0 +1,32 @@
pub mod status_code;
pub trait Request: Send + Sync + 'static + for<'de> serde::Deserialize<'de> {
type Response: IsResponse;
type Error: IsResponse;
}
eva::trait_set! {
pub trait IsResponse = status_code::HasStatusCode + serde::Serialize + Send + Sync + 'static;
}
macro_rules! impl_req {
($Input:ty => [$Ok:ty; $Err:ty]) => {
const _: () = {
use $crate::requests::Request;
impl Request for $Input {
type Response = $Ok;
type Error = $Err;
}
};
};
}
pub mod users;
pub mod authors;
pub mod games;
pub mod boards;
pub mod messages;
pub mod threads;

View file

@ -0,0 +1,81 @@
use http::status::StatusCode;
use viendesu_core::errors;
pub trait HasStatusCode {
fn status_code(&self) -> StatusCode;
}
impl<O: HasStatusCode, E: HasStatusCode> HasStatusCode for Result<O, E> {
fn status_code(&self) -> StatusCode {
match self {
Ok(o) => o.status_code(),
Err(e) => e.status_code(),
}
}
}
impl<S: HasStatusCode> HasStatusCode for errors::Generic<S> {
fn status_code(&self) -> StatusCode {
match self {
Self::Aux(aux) => aux.status_code(),
Self::Spec(spec) => spec.status_code(),
}
}
}
impl HasStatusCode for errors::Aux {
fn status_code(&self) -> StatusCode {
use StatusCode as C;
use errors::Aux::*;
match self {
Unauthenticated => C::UNAUTHORIZED,
InvalidRole(e) => e.status_code(),
InvalidSession(e) => e.status_code(),
Captcha(..) | Deserialization(..) => C::BAD_REQUEST,
Db(..) | InternalError(..) | ObjectStore(..) | Mail(..) => C::INTERNAL_SERVER_ERROR,
}
}
}
macro_rules! map {
($Ty:ty => [$($Item:ident),* $(,)?]) => {
const _: () = {
use $crate::requests::status_code::HasStatusCode;
use ::http::status::StatusCode;
impl HasStatusCode for $Ty {
fn status_code(&self) -> StatusCode {
match *self {$(
Self::$Item(ref e) => e.status_code(),
)*}
}
}
};
};
}
pub(crate) use map;
macro_rules! direct {
($($ty:ty => $code:ident),* $(,)?) => {$(
const _: () = {
use $crate::requests::status_code::HasStatusCode;
use ::http::status::StatusCode;
impl HasStatusCode for $ty {
fn status_code(&self) -> StatusCode {
StatusCode::$code
}
}
};
)*};
}
pub(crate) use direct;
direct! {
errors::auth::InvalidRole => FORBIDDEN,
errors::auth::InvalidSession => BAD_REQUEST,
}

View file

@ -0,0 +1,66 @@
use eva::data;
use crate::requests::status_code;
use viendesu_core::{
errors,
requests::threads as reqs,
types::{Patch, board, message, thread},
};
#[data]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::direct!(reqs::get::Ok => OK);
status_code::map!(reqs::get::Err => [NotFound]);
#[data]
pub struct Delete {}
impl_req!(Delete => [reqs::delete::Ok; reqs::delete::Err]);
status_code::direct!(reqs::delete::Ok => OK);
status_code::map!(reqs::delete::Err => [NotFound]);
#[data]
pub struct Edit {
pub text: Patch<message::Text>,
}
impl_req!(Edit => [reqs::edit::Ok; reqs::edit::Err]);
status_code::direct!(reqs::edit::Ok => OK);
status_code::map!(reqs::edit::Err => [NotFound, NotAnOwner]);
#[data]
pub struct Search {
#[serde(default)]
pub limit: reqs::search::Limit,
pub after: Option<thread::Id>,
}
impl_req!(Search => [reqs::search::Ok; reqs::search::Err]);
status_code::direct!(reqs::search::Ok => OK);
status_code::map!(reqs::search::Err => []);
#[data]
pub struct Create {
pub board: board::Selector,
pub initial_message: message::Text,
}
impl_req!(Create => [reqs::create::Ok; reqs::create::Err]);
status_code::direct!(reqs::create::Ok => CREATED);
status_code::map!(reqs::create::Err => [NoSuchBoard]);
const _: () = {
use errors::threads::*;
use status_code::direct;
direct!(NotAnOwner => FORBIDDEN);
direct!(NotFound => NOT_FOUND);
};

102
http/src/requests/users.rs Normal file
View file

@ -0,0 +1,102 @@
use eva::data;
use viendesu_core::{
errors,
requests::users as reqs,
types::{Patch, file, user as core},
};
use super::status_code;
#[data]
pub struct CheckAuth {}
impl_req!(CheckAuth => [reqs::check_auth::Ok; reqs::check_auth::Err]);
status_code::direct!(reqs::check_auth::Ok => OK);
status_code::map!(reqs::check_auth::Err => []);
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct Get {}
impl_req!(Get => [reqs::get::Ok; reqs::get::Err]);
status_code::map!(reqs::get::Err => [NotFound]);
status_code::direct!(reqs::get::Ok => OK);
#[data]
pub struct SignIn {
pub nickname: core::Nickname,
pub password: core::Password,
}
impl_req!(SignIn => [reqs::sign_in::Ok; reqs::sign_in::Err]);
status_code::map!(reqs::sign_in::Err => [NotFound, InvalidPassword, MustCompleteSignUp]);
status_code::direct!(reqs::sign_in::Ok => OK);
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct ConfirmSignUp {}
impl_req!(ConfirmSignUp => [reqs::confirm_sign_up::Ok; reqs::confirm_sign_up::Err]);
status_code::direct!(reqs::confirm_sign_up::Ok => OK);
status_code::map!(reqs::confirm_sign_up::Err => [NotFoundOrCompleted, InvalidSignUpToken]);
#[data]
pub struct SignUp {
pub nickname: core::Nickname,
pub email: core::Email,
pub password: core::Password,
pub display_name: Option<core::DisplayName>,
}
impl_req!(SignUp => [reqs::sign_up::Ok; reqs::sign_up::Err]);
status_code::direct!(reqs::sign_up::Ok => CREATED);
status_code::map!(reqs::sign_up::Err => [AlreadyTaken]);
#[serde_with::apply(
Patch => #[serde(default)]
)]
#[data]
#[non_exhaustive]
#[derive(Default)]
pub struct Update {
pub nickname: Patch<core::Nickname>,
pub display_name: Patch<Option<core::DisplayName>>,
pub bio: Patch<Option<core::Bio>>,
pub password: Patch<core::Password>,
pub role: Patch<core::Role>,
pub pfp: Patch<Option<file::Id>>,
pub email: Patch<core::Email>,
}
impl From<reqs::update::Update> for Update {
fn from(u: reqs::update::Update) -> Self {
Self {
nickname: u.nickname,
display_name: u.display_name,
bio: u.bio,
password: u.password,
role: u.role,
pfp: u.pfp,
email: u.email,
}
}
}
impl_req!(Update => [reqs::update::Ok; reqs::update::Err]);
status_code::direct!(reqs::update::Ok => OK);
status_code::map!(reqs::update::Err => [NotFound]);
const _: () = {
use errors::users::*;
use status_code::direct;
direct!(NotFound => NOT_FOUND);
direct!(InvalidPassword => FORBIDDEN);
direct!(MustCompleteSignUp => FORBIDDEN);
direct!(AlreadyTaken => BAD_REQUEST);
direct!(NotFoundOrCompleted => BAD_REQUEST);
direct!(InvalidSignUpToken => BAD_REQUEST);
};

21
http/src/server/config.rs Normal file
View file

@ -0,0 +1,21 @@
use std::net::SocketAddr;
use eva::data;
#[data]
pub struct Http {
pub enable: bool,
pub listen: SocketAddr,
}
#[data]
pub struct Ssl {
pub enable: bool,
pub listen: SocketAddr,
}
#[data]
pub struct Config {
pub unencrypted: Option<Http>,
pub ssl: Option<Ssl>,
}

View file

@ -0,0 +1,85 @@
use crate::{
format::Format,
server::{
request::{ServerRequest, extract},
response,
},
};
use axum::{
RequestExt,
extract::Request as AxumRequest,
http::{Method, request::Parts},
response::Response as AxumResponse,
};
use futures::StreamExt;
use serde::Deserialize;
use viendesu_core::errors::{Aux, AuxResult};
#[non_exhaustive]
pub struct Context<R: ServerRequest> {
pub request: R,
pub parts: Parts,
pub response_format: Format,
}
impl<R: ServerRequest> Context<R> {
pub async fn path<P>(&mut self) -> AuxResult<P>
where
P: for<'de> Deserialize<'de> + Send + 'static,
{
extract::path(&mut self.parts).await
}
pub fn query<'this, T: serde::Deserialize<'this>>(&'this self) -> AuxResult<T> {
extract::query(&self.parts)
}
}
pub async fn load_args<R: ServerRequest>(req: AxumRequest) -> Result<Context<R>, AxumResponse> {
let (parts, body) = req.with_limited_body().into_parts();
let response_format =
extract::response_format(&parts).map_err(|e| response::err(Format::default(), e))?;
let request_format =
extract::request_format(&parts).map_err(|e| response::err(Format::default(), e))?;
let request: R = if parts.method == Method::GET {
let query = parts.uri.query().unwrap_or("");
serde_urlencoded::from_str(query)
.map_err(|e| Aux::Deserialization(format!("failed to decode query: {e}")))
.map_err(|e| response::err(response_format, e))?
} else {
let content_length =
extract::content_length(&parts).map_err(|e| response::err(response_format, e))?;
let mut data_stream = body.into_data_stream();
let mut buffer = Vec::with_capacity(content_length);
while let Some(frame) = data_stream.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => {
return Err(response::err(
response_format,
Aux::Deserialization(format!("failed to read body frame: {e}")),
));
}
};
buffer.extend_from_slice(&frame);
}
request_format
.load(&buffer)
.map_err(|e| Aux::Deserialization(format!("failed to deserialize body: {e}")))
.map_err(|e| response::err(response_format, e))?
};
Ok(Context {
request,
parts,
response_format,
})
}

250
http/src/server/handler.rs Normal file
View file

@ -0,0 +1,250 @@
use eva::fut::Fut;
use std::{marker::PhantomData, sync::Arc};
use axum::{
Router as AxumRouter,
extract::Request as AxumRequest,
response::Response as AxumResponse,
routing::{MethodFilter, method_routing},
};
use viendesu_core::{errors as core_errors, service::SessionOf};
use crate::{
format::Format,
server::{
State, Types,
context::{Context, load_args},
request::{ServerRequest, extract},
response,
},
};
struct Inner<R: ServerRequest, Cx> {
make_context: Cx,
method: MethodFilter,
_phantom: PhantomData<R>,
}
pub struct RouterScope<T: Types> {
router: AxumRouter,
state: State<T>,
}
impl<T: Types> RouterScope<T> {
pub fn root(state: State<T>) -> Self {
Self {
router: AxumRouter::new(),
state,
}
}
pub fn include(self, f: impl FnOnce(RouterScope<T>) -> RouterScope<T>) -> RouterScope<T> {
f(self)
}
pub fn route<R: ServerRequest, M, Cx>(
self,
path: &str,
handler: FinishedHandler<R, M, T, Cx>,
) -> RouterScope<T>
where
M: MakeRequest<T, R>,
Cx: MakeContext<R>,
{
let Self { mut router, state } = self;
router = router.route(
path,
method_routing::on(
handler.inner.method,
handler.into_axum_handler(state.clone()),
),
);
Self { router, state }
}
pub fn nest(
self,
path: &str,
f: impl FnOnce(RouterScope<T>) -> RouterScope<T>,
) -> RouterScope<T> {
let nested = f(RouterScope {
router: AxumRouter::new(),
state: self.state.clone(),
});
let Self { mut router, state } = self;
router = router.nest(path, nested.router);
Self { router, state }
}
pub fn into_axum(self) -> AxumRouter {
self.router
}
}
pub struct FinishedHandler<R: ServerRequest, M, T: Types, Cx> {
inner: Inner<R, Cx>,
make_request: M,
_phantom: PhantomData<T>,
}
impl<R: ServerRequest, M, T: Types, Cx: MakeContext<R>> FinishedHandler<R, M, T, Cx> {
fn into_axum_handler(self, state: State<T>) -> impl AxumHandler
where
M: MakeRequest<T, R>,
{
let Self {
inner,
make_request,
_phantom: _,
} = self;
let captures = Arc::new((make_request, inner, state));
move |req: AxumRequest| {
let captures = captures.clone();
async move {
let (make_request, inner, state) = Arc::as_ref(&captures);
let Inner {
make_context,
method: _,
_phantom: _,
} = inner;
let (parts, body) = req.into_parts();
let context = make_context(AxumRequest::from_parts(parts, body)).await?;
let resp_format = context.response_format;
let session = state
.make_session()
.await
.map_err(|e| response::err(resp_format, e))?;
match make_request(session, context).await {
Ok(r) => Ok(response::ok(resp_format, r)),
Err(e) => Err(response::err(resp_format, e)),
}
}
}
}
}
pub fn get<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::get(load_args::<R>).exec(make_request)
}
pub fn post<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::post(load_args::<R>).exec(make_request)
}
pub fn patch<R, T, M>(make_request: M) -> FinishedHandler<R, M, T, impl MakeContext<R>>
where
R: ServerRequest,
T: Types,
M: MakeRequest<T, R>,
{
Handler::patch(load_args::<R>).exec(make_request)
}
pub struct Handler<R: ServerRequest, Cx>(Inner<R, Cx>);
impl<R: ServerRequest, Cx: MakeContext<R>> Handler<R, Cx> {
pub fn get(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::GET,
_phantom: PhantomData,
})
}
pub fn post(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::POST,
_phantom: PhantomData,
})
}
pub fn patch(make_context: Cx) -> Handler<R, Cx> {
Self(Inner {
make_context,
method: MethodFilter::PATCH,
_phantom: PhantomData,
})
}
pub fn exec<T, M>(self, make_request: M) -> FinishedHandler<R, M, T, Cx>
where
T: Types,
M: MakeRequest<T, R>,
{
let Self(inner) = self;
FinishedHandler {
inner,
make_request,
_phantom: PhantomData,
}
}
}
// == Complex trait magic ==
pub trait AxumHandler: Send + Sync + 'static + Clone + Fn(AxumRequest) -> Self::AFut {
#[doc(hidden)]
type AFut: Fut<Output = Result<AxumResponse, AxumResponse>>;
}
impl<H, F> AxumHandler for H
where
H: Clone + Send + Sync + 'static + Fn(AxumRequest) -> F,
F: Fut<Output = Result<AxumResponse, AxumResponse>>,
{
type AFut = F;
}
pub trait Captures<T: ?Sized> {}
impl<T: ?Sized, U: ?Sized> Captures<T> for U {}
pub trait MakeContext<R: ServerRequest>:
Send + Sync + 'static + Fn(AxumRequest) -> Self::MkFut
{
type MkFut: Fut<Output = Result<Context<R>, AxumResponse>>;
}
impl<R: ServerRequest, H, F> MakeContext<R> for H
where
H: Send + Sync + 'static + Fn(AxumRequest) -> F,
F: Fut<Output = Result<Context<R>, AxumResponse>>,
{
type MkFut = F;
}
pub trait MakeRequest<T: Types, R: ServerRequest>:
Send + Sync + 'static + Fn(SessionOf<T::Service>, Context<R>) -> Self::HFut
{
type HFut: Fut<Output = core_errors::Result<R::Response, R::Error>>
+ Captures<SessionOf<T::Service>>;
}
impl<T, R, F, H> MakeRequest<T, R> for H
where
H: Send + Sync + 'static + Fn(SessionOf<T::Service>, Context<R>) -> F,
F: Fut<Output = core_errors::Result<R::Response, R::Error>> + Captures<SessionOf<T::Service>>,
T: Types,
R: ServerRequest,
{
type HFut = F;
}

35
http/src/server/mod.rs Normal file
View file

@ -0,0 +1,35 @@
use eva::perfect_derive;
use viendesu_core::{
errors::AuxResult,
service::{Service, SessionMaker, SessionOf},
};
pub mod config;
mod context;
mod handler;
mod request;
mod response;
mod routes;
pub trait Types: Send + Sync + 'static {
type Service: Service + Clone;
}
pub fn make_router<T: Types>(service: T::Service) -> axum::Router {
let scope = handler::RouterScope::root(State::<T> { service });
routes::make(scope).into_axum()
}
#[perfect_derive(Clone)]
struct State<T: Types> {
service: T::Service,
}
impl<T: Types> State<T> {
async fn make_session(&self) -> AuxResult<SessionOf<T::Service>> {
self.service.make_session().await
}
}

View file

@ -0,0 +1,26 @@
use eva::{data, trait_set};
use axum::http::request::Parts;
use viendesu_core::errors::AuxResult;
use crate::{format::Format, requests::Request};
pub mod extract;
#[data]
pub struct MetaInfo {
pub request_format: Format,
}
impl MetaInfo {
pub fn gather(parts: &Parts) -> AuxResult<Self> {
let request_format = extract::request_format(parts)?;
Ok(Self { request_format })
}
}
trait_set! {
pub trait ServerRequest = Request;
}

View file

@ -0,0 +1,132 @@
use axum::{
extract::{FromRequest, FromRequestParts, Path, Request},
http::{HeaderValue, request::Parts},
};
use eva::bytes::Bytes;
use viendesu_core::{
errors::{Aux, AuxResult},
types::{captcha, session},
};
use crate::format::Format;
pub async fn read_body<R: serde::de::DeserializeOwned>(
format: Format,
request: Request,
) -> AuxResult<R> {
let raw_body = read_raw_body(request).await?;
format
.load(&raw_body)
.map_err(|e| Aux::Deserialization(format!("failed to deserialize body: {e:#}")))
}
pub fn raw_query(parts: &Parts) -> &str {
parts.uri.query().unwrap_or("")
}
pub fn query<'de, T: serde::de::Deserialize<'de>>(parts: &'de Parts) -> AuxResult<T> {
let raw = raw_query(parts);
serde_urlencoded::from_str(raw)
.map_err(|e| Aux::Deserialization(format!("failed to decode query string: {e}")))
}
pub async fn read_raw_body(request: Request) -> AuxResult<Bytes> {
match Bytes::from_request(request, &()).await {
Ok(r) => Ok(r),
Err(e) => Err(Aux::Deserialization(format!("failed to read body: {e}"))),
}
}
pub fn session_token(parts: &Parts) -> AuxResult<Option<session::Token>> {
let Some(val) = str_header(parts, "authorization")? else {
return Ok(None);
};
let Some((scheme, rest)) = val.split_once(' ') else {
return Err(Aux::Deserialization(format!(
"invalid Authorization header format, expected `<scheme> <rest>`"
)));
};
match scheme {
"Bearer" => rest
.parse()
.map(Some)
.map_err(|e| Aux::Deserialization(format!("invalid session token: {e}"))),
_ => Err(Aux::Deserialization(format!(
"scheme {scheme:?} is not supported"
))),
}
}
pub fn captcha(parts: &Parts) -> AuxResult<Option<captcha::Token>> {
let Some(raw_token) = str_header(parts, "x-challenge")? else {
return Ok(None);
};
match raw_token.parse() {
Ok(r) => Ok(Some(r)),
Err(e) => Err(Aux::Deserialization(format!(
"invalid X-Challenge header: {e}"
))),
}
}
pub fn str_header<'p>(parts: &'p Parts, header: &str) -> AuxResult<Option<&'p str>> {
let Some(value) = raw_header(parts, header) else {
return Ok(None);
};
let value = value.to_str().map_err(|e| {
Aux::Deserialization(format!(
"failed to decode UTF-8 content of header {header:?}: {e}"
))
})?;
Ok(Some(value))
}
pub fn raw_header<'p>(parts: &'p Parts, header: &str) -> Option<&'p HeaderValue> {
parts.headers.get(header)
}
pub fn request_format(parts: &Parts) -> AuxResult<Format> {
let Some(raw) = str_header(parts, "content-type")? else {
return Err(Aux::Deserialization(
"`Content-Type` header is required".into(),
));
};
Format::from_mime_type(raw)
.map_err(|e| Aux::Deserialization(format!("failed to parse `Content-Type` header: {e}")))
}
pub fn response_format(parts: &Parts) -> AuxResult<Format> {
let Some(raw) = str_header(parts, "accept")? else {
return Ok(Format::Json);
};
Format::from_mime_type(raw)
.map_err(|e| Aux::Deserialization(format!("failed to parse `Accept` header: {e}")))
}
pub fn content_length(parts: &Parts) -> AuxResult<usize> {
let Some(raw) = str_header(parts, "content-length")? else {
return Ok(0);
};
let content_length: usize = raw
.parse()
.map_err(|e| Aux::Deserialization(format!("failed to decode content length: {e}")))?;
Ok(content_length)
}
pub async fn path<T>(parts: &mut Parts) -> AuxResult<T>
where
T: serde::de::DeserializeOwned + Send + 'static,
{
let response = Path::<T>::from_request_parts(parts, &()).await;
match response {
Ok(Path(r)) => Ok(r),
Err(rej) => Err(Aux::Deserialization(format!("failed to parse path: {rej}"))),
}
}

View file

@ -0,0 +1,64 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response as AxumResponse},
};
use serde::Serialize;
use crate::format::{DumpParams, Format};
use crate::requests::{IsResponse, status_code::HasStatusCode};
macro_rules! header {
($name:expr => $value:expr) => {
(
axum::http::HeaderName::from_static($name),
axum::http::HeaderValue::from_static($value),
)
};
}
#[track_caller]
pub fn err<E: IsResponse>(format: Format, error: E) -> AxumResponse {
#[derive(Serialize)]
struct Failure<E> {
pub error: E,
}
impl<T: HasStatusCode> HasStatusCode for Failure<T> {
fn status_code(&self) -> StatusCode {
self.error.status_code()
}
}
respond(format, Failure { error })
}
#[track_caller]
pub fn ok<O: IsResponse>(format: Format, ok: O) -> AxumResponse {
#[derive(Serialize)]
struct Success<T> {
ok: T,
}
impl<T: HasStatusCode> HasStatusCode for Success<T> {
fn status_code(&self) -> StatusCode {
self.ok.status_code()
}
}
respond(format, Success { ok })
}
#[track_caller]
pub fn respond<O: IsResponse>(format: Format, res: O) -> AxumResponse {
let status_code = res.status_code();
let headers = [
header!("Server" => "Kurisu-desu"),
header!("Content-Type" => format.mime_type()),
];
let mut body = Vec::with_capacity(128);
let dump_params = DumpParams { pretty: false };
format.dump(dump_params, &res, &mut body);
(status_code, headers, body).into_response()
}

167
http/src/server/routes.rs Normal file
View file

@ -0,0 +1,167 @@
use crate::server::{
Types,
context::Context as Ctx,
handler::{RouterScope, get, patch, post},
};
use viendesu_core::{
service::{
CallStep, Session, SessionOf as SessionOfService,
users::{UsersMut, UsersRef},
},
types::user,
};
type SessionOf<T> = SessionOfService<<T as Types>::Service>;
pub fn make<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
.nest("/users", users)
.nest("/authors", authors)
.nest("/games", games)
.nest("/boards", boards)
.nest("/threads", threads)
.nest("/messages", messages)
}
// == Routes ==
fn users<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
use crate::requests::users::{CheckAuth, ConfirmSignUp, Get, SignIn, SignUp, Update};
use viendesu_core::requests::users::{
check_auth, confirm_sign_up, get, sign_in, sign_up, update,
};
fn convert_update(u: Update) -> update::Update {
update::Update {
nickname: u.nickname,
display_name: u.display_name,
bio: u.bio,
password: u.password,
role: u.role,
pfp: u.pfp,
email: u.email,
}
}
router
.route(
"/{sel}",
get(async |mut session: SessionOf<T>, mut ctx: Ctx<Get>| {
let selector: user::Selector = ctx.path().await?;
session
.users()
.get()
.call(get::Args {
user: Some(selector),
})
.await
}),
)
.route(
"/check_auth",
get(async |mut session: SessionOf<T>, _ctx: Ctx<CheckAuth>| {
session.users().check_auth().call(check_auth::Args {}).await
}),
)
.route(
"/me",
get(async |mut session: SessionOf<T>, _ctx: Ctx<Get>| {
session.users().get().call(get::Args { user: None }).await
}),
)
.route(
"/sign_in",
post(async |mut session: SessionOf<T>, ctx: Ctx<SignIn>| {
session
.users_mut()
.sign_in()
.call(sign_in::Args {
nickname: ctx.request.nickname,
password: ctx.request.password,
})
.await
}),
)
.route(
"/sign_up",
post(async |mut session: SessionOf<T>, ctx: Ctx<SignUp>| {
let SignUp {
nickname,
email,
password,
display_name,
} = ctx.request;
session
.users_mut()
.sign_up()
.call(sign_up::Args {
nickname,
email,
display_name,
password,
})
.await
}),
)
.route(
"/{sel}",
patch(async |mut session: SessionOf<T>, mut ctx: Ctx<Update>| {
let user: user::Selector = ctx.path().await?;
session
.users_mut()
.update()
.call(update::Args {
user: Some(user),
update: convert_update(ctx.request),
})
.await
}),
)
.route(
"/me",
patch(async |mut session: SessionOf<T>, ctx: Ctx<Update>| {
session
.users_mut()
.update()
.call(update::Args {
user: None,
update: convert_update(ctx.request),
})
.await
}),
)
.route(
"/{user}/confirm/{token}",
post(
async |mut session: SessionOf<T>, mut ctx: Ctx<ConfirmSignUp>| {
let (user, token) = ctx.path().await?;
session
.users_mut()
.confirm_sign_up()
.call(confirm_sign_up::Args { user, token })
.await
},
),
)
}
fn authors<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn games<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn boards<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn threads<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}
fn messages<T: Types>(router: RouterScope<T>) -> RouterScope<T> {
router
}

1
http/tests/it/main.rs Normal file
View file

@ -0,0 +1 @@