use crate::extract::{Extractor, SESSION_COOKIE};
use crate::RouterApi;
use axum::body::Body;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Extension, Json, Router};
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use etwin_core::auth::{AuthContext, RegisterWithUsernameOptions, RegisterWithVerifiedEmailOptions};
use etwin_core::declare_new_enum;
use etwin_core::dinoparc::{
DinoparcPassword, DinoparcServer, DinoparcUserId, DinoparcUserIdRef, DinoparcUsername, ShortDinoparcUser,
};
use etwin_core::hammerfest::{
HammerfestPassword, HammerfestServer, HammerfestSessionKey, HammerfestUserId, HammerfestUserIdRef,
HammerfestUsername, ShortHammerfestUser,
};
use etwin_core::link::VersionedLink;
use etwin_core::oauth::RfcOauthAccessToken;
use etwin_core::twinoid::{ShortTwinoidUser, TwinoidUserId, TwinoidUserIdRef};
use etwin_core::types::AnyError;
use etwin_core::user::{
CompleteUser, GetUserOptions, GetUserResult, LinkToDinoparcOptions, LinkToDinoparcWithCredentialsOptions,
LinkToDinoparcWithRefOptions, LinkToHammerfestOptions, LinkToHammerfestWithCredentialsOptions,
LinkToHammerfestWithRefOptions, LinkToHammerfestWithSessionKeyOptions, LinkToTwinoidOptions,
LinkToTwinoidWithOauthOptions, LinkToTwinoidWithRefOptions, UnlinkFromDinoparcOptions, UnlinkFromHammerfestOptions,
UnlinkFromTwinoidOptions, UpdateUserOptions, UpdateUserPatch, User, UserFields, UserId,
};
use etwin_services::user::{
LinkToDinoparcError, LinkToHammerfestError, LinkToTwinoidError, UnlinkFromDinoparcError, UnlinkFromHammerfestError,
UnlinkFromTwinoidError,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use thiserror::Error;
pub fn router() -> Router<(), Body> {
Router::new()
.route("/", post(create_user))
.route("/:user_id", get(get_user).patch(update_user))
.route("/:user_id/links/:remote", put(set_link).delete(delete_link))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(untagged)]
enum CreateUserBody {
Email(RegisterWithVerifiedEmailOptions),
Username(RegisterWithUsernameOptions),
}
#[derive(Debug, Error)]
enum CreateUserError {
#[error("internal error")]
Internal(#[from] AnyError),
}
impl IntoResponse for CreateUserError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
}
async fn create_user(
Extension(api): Extension<RouterApi>,
jar: CookieJar,
Json(body): Json<CreateUserBody>,
) -> Result<(StatusCode, CookieJar, Json<User>), CreateUserError> {
let result = match body {
CreateUserBody::Email(options) => api.auth.register_with_verified_email(&options).await?,
CreateUserBody::Username(options) => api.auth.register_with_username(&options).await?,
};
let cookie = Cookie::build(SESSION_COOKIE, result.session.id.to_string())
.path("/")
.http_only(true)
.finish();
Ok((StatusCode::OK, jar.add(cookie), Json(result.user)))
}
#[derive(Debug, Error)]
enum GetUserError {
#[error("user not found")]
NotFound,
#[error("internal error")]
Internal(#[from] AnyError),
}
impl From<etwin_services::user::GetUserError> for GetUserError {
fn from(e: etwin_services::user::GetUserError) -> Self {
use etwin_services::user::GetUserError::*;
match e {
NotFound => Self::NotFound,
Internal(e) => Self::Internal(e),
}
}
}
impl IntoResponse for GetUserError {
fn into_response(self) -> Response {
let status = match &self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
async fn get_user(
Extension(api): Extension<RouterApi>,
acx: Extractor<AuthContext>,
Path(user_id): Path<UserId>,
) -> Result<Json<GetUserResult>, GetUserError> {
let user = api
.user
.get_user(
&acx.value(),
&GetUserOptions {
r#ref: user_id.into(),
fields: UserFields::Complete,
time: None,
},
)
.await?;
Ok(Json(user))
}
#[derive(Debug, Error)]
enum UpdateUserError {
#[error("forbidden")]
Forbidden,
#[error("internal error")]
Internal,
}
impl From<etwin_services::user::UpdateUserError> for UpdateUserError {
fn from(e: etwin_services::user::UpdateUserError) -> Self {
use etwin_services::user::UpdateUserError::*;
match e {
Forbidden => Self::Forbidden,
Raw(_) => Self::Internal,
Internal(_) => Self::Internal,
}
}
}
impl IntoResponse for UpdateUserError {
fn into_response(self) -> Response {
let status = match &self {
Self::Forbidden => StatusCode::FORBIDDEN,
Self::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
async fn update_user(
Extension(api): Extension<RouterApi>,
acx: Extractor<AuthContext>,
Path(user_id): Path<UserId>,
Json(options): Json<UpdateUserPatch>,
) -> Result<Json<CompleteUser>, UpdateUserError> {
let user = api
.user
.update_user(
acx.value(),
UpdateUserOptions {
r#ref: user_id.into(),
patch: options,
},
)
.await?;
Ok(Json(user))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(from = "SerdeLinkableServer", into = "SerdeLinkableServer")]
pub(crate) enum LinkableServer {
Dinoparc(DinoparcServer),
Hammerfest(HammerfestServer),
Twinoid,
}
impl From<SerdeLinkableServer> for LinkableServer {
fn from(server: SerdeLinkableServer) -> Self {
match server {
SerdeLinkableServer::DinoparcCom => Self::Dinoparc(DinoparcServer::DinoparcCom),
SerdeLinkableServer::EnDinoparcCom => Self::Dinoparc(DinoparcServer::EnDinoparcCom),
SerdeLinkableServer::SpDinoparcCom => Self::Dinoparc(DinoparcServer::SpDinoparcCom),
SerdeLinkableServer::HammerfestEs => Self::Hammerfest(HammerfestServer::HammerfestEs),
SerdeLinkableServer::HammerfestFr => Self::Hammerfest(HammerfestServer::HammerfestFr),
SerdeLinkableServer::HfestNet => Self::Hammerfest(HammerfestServer::HfestNet),
SerdeLinkableServer::Twinoid => Self::Twinoid,
}
}
}
impl From<LinkableServer> for SerdeLinkableServer {
fn from(server: LinkableServer) -> Self {
match server {
LinkableServer::Dinoparc(DinoparcServer::DinoparcCom) => Self::DinoparcCom,
LinkableServer::Dinoparc(DinoparcServer::EnDinoparcCom) => Self::EnDinoparcCom,
LinkableServer::Dinoparc(DinoparcServer::SpDinoparcCom) => Self::SpDinoparcCom,
LinkableServer::Hammerfest(HammerfestServer::HammerfestEs) => Self::HammerfestEs,
LinkableServer::Hammerfest(HammerfestServer::HammerfestFr) => Self::HammerfestFr,
LinkableServer::Hammerfest(HammerfestServer::HfestNet) => Self::HfestNet,
LinkableServer::Twinoid => Self::Twinoid,
}
}
}
declare_new_enum!(
enum SerdeLinkableServer {
#[str("dinoparc.com")]
DinoparcCom,
#[str("en.dinoparc.com")]
EnDinoparcCom,
#[str("sp.dinoparc.com")]
SpDinoparcCom,
#[str("hammerfest.fr")]
HammerfestFr,
#[str("hfest.net")]
HfestNet,
#[str("hammerfest.es")]
HammerfestEs,
#[str("twinoid.com")]
Twinoid,
}
pub type ParseError = LinkableServerParseError;
);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(untagged)]
enum SetLinkResult {
Dinoparc(VersionedLink<ShortDinoparcUser>),
Hammerfest(VersionedLink<ShortHammerfestUser>),
Twinoid(VersionedLink<ShortTwinoidUser>),
}
#[derive(Debug, Error)]
enum SetLinkError {
#[error("bad request")]
BadRequest(#[from] serde_json::Error),
#[error("internal error")]
InternalDinoparc(#[from] LinkToDinoparcError),
#[error("internal error")]
InternalHammerfest(#[from] LinkToHammerfestError),
#[error("internal error")]
InternalTwinoid(#[from] LinkToTwinoidError),
}
impl IntoResponse for SetLinkError {
fn into_response(self) -> Response {
let status = match &self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::InternalDinoparc(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalHammerfest(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalTwinoid(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
async fn set_link(
Extension(api): Extension<RouterApi>,
acx: Extractor<AuthContext>,
Path((user_id, remote)): Path<(UserId, LinkableServer)>,
Json(options): Json<JsonValue>,
) -> Result<Json<SetLinkResult>, SetLinkError> {
let acx = acx.value();
let result = match remote {
LinkableServer::Dinoparc(remote) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum LinkToDinoparcBody {
Credentials {
dinoparc_username: DinoparcUsername,
dinoparc_password: DinoparcPassword,
},
Ref {
dinoparc_user_id: DinoparcUserId,
},
}
impl LinkToDinoparcBody {
pub fn into_options(self, user_id: UserId, dinoparc_server: DinoparcServer) -> LinkToDinoparcOptions {
match self {
Self::Credentials {
dinoparc_username,
dinoparc_password,
} => LinkToDinoparcOptions::Credentials(LinkToDinoparcWithCredentialsOptions {
user_id,
dinoparc_server,
dinoparc_username,
dinoparc_password,
}),
Self::Ref { dinoparc_user_id } => LinkToDinoparcOptions::Ref(LinkToDinoparcWithRefOptions {
user_id,
dinoparc_server,
dinoparc_user_id,
}),
}
}
}
let options = serde_json::from_value::<LinkToDinoparcBody>(options)?.into_options(user_id, remote);
SetLinkResult::Dinoparc(api.user.link_to_dinoparc(acx, options).await?)
}
LinkableServer::Hammerfest(remote) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum LinkToHammerfestBody {
Credentials {
hammerfest_username: HammerfestUsername,
hammerfest_password: HammerfestPassword,
},
SessionKey {
hammerfest_session_key: HammerfestSessionKey,
},
Ref {
hammerfest_user_id: HammerfestUserId,
},
}
impl LinkToHammerfestBody {
pub fn into_options(self, user_id: UserId, hammerfest_server: HammerfestServer) -> LinkToHammerfestOptions {
match self {
Self::Credentials {
hammerfest_username,
hammerfest_password,
} => LinkToHammerfestOptions::Credentials(LinkToHammerfestWithCredentialsOptions {
user_id,
hammerfest_server,
hammerfest_username,
hammerfest_password,
}),
Self::SessionKey { hammerfest_session_key } => {
LinkToHammerfestOptions::SessionKey(LinkToHammerfestWithSessionKeyOptions {
user_id,
hammerfest_server,
hammerfest_session_key,
})
}
Self::Ref { hammerfest_user_id } => LinkToHammerfestOptions::Ref(LinkToHammerfestWithRefOptions {
user_id,
hammerfest_server,
hammerfest_user_id,
}),
}
}
}
let options = serde_json::from_value::<LinkToHammerfestBody>(options)?.into_options(user_id, remote);
SetLinkResult::Hammerfest(api.user.link_to_hammerfest(acx, options).await?)
}
LinkableServer::Twinoid => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum LinkToTwinoidBody {
Oauth { access_token: RfcOauthAccessToken },
Ref { twinoid_user_id: TwinoidUserId },
}
impl LinkToTwinoidBody {
pub fn into_options(self, user_id: UserId) -> LinkToTwinoidOptions {
match self {
Self::Oauth { access_token } => {
LinkToTwinoidOptions::Oauth(LinkToTwinoidWithOauthOptions { user_id, access_token })
}
Self::Ref { twinoid_user_id } => LinkToTwinoidOptions::Ref(LinkToTwinoidWithRefOptions {
user_id,
twinoid_user_id,
}),
}
}
}
let options = serde_json::from_value::<LinkToTwinoidBody>(options)?.into_options(user_id);
SetLinkResult::Twinoid(api.user.link_to_twinoid(acx, options).await?)
}
};
Ok(Json(result))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(untagged)]
enum DeleteLinkResult {
Dinoparc(VersionedLink<ShortDinoparcUser>),
Hammerfest(VersionedLink<ShortHammerfestUser>),
Twinoid(VersionedLink<ShortTwinoidUser>),
}
#[derive(Debug, Error)]
enum DeleteLinkError {
#[error("bad request")]
BadRequest(#[from] serde_json::Error),
#[error("internal error")]
InternalDinoparc(#[from] UnlinkFromDinoparcError),
#[error("internal error")]
InternalHammerfest(#[from] UnlinkFromHammerfestError),
#[error("internal error")]
InternalTwinoid(#[from] UnlinkFromTwinoidError),
}
impl IntoResponse for DeleteLinkError {
fn into_response(self) -> Response {
let status = match &self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::InternalDinoparc(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalHammerfest(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalTwinoid(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}
async fn delete_link(
Extension(api): Extension<RouterApi>,
acx: Extractor<AuthContext>,
Path((user_id, remote)): Path<(UserId, LinkableServer)>,
Json(options): Json<JsonValue>,
) -> Result<Json<DeleteLinkResult>, DeleteLinkError> {
let acx = acx.value();
let result = match remote {
LinkableServer::Dinoparc(remote) => {
let options = serde_json::from_value::<DinoparcUserIdRef>(options)?;
let options = UnlinkFromDinoparcOptions {
user_id,
dinoparc_server: remote,
dinoparc_user_id: options.id,
};
DeleteLinkResult::Dinoparc(api.user.unlink_from_dinoparc(acx, options).await?)
}
LinkableServer::Hammerfest(remote) => {
let options = serde_json::from_value::<HammerfestUserIdRef>(options)?;
let options = UnlinkFromHammerfestOptions {
user_id,
hammerfest_server: remote,
hammerfest_user_id: options.id,
};
DeleteLinkResult::Hammerfest(api.user.unlink_from_hammerfest(acx, options).await?)
}
LinkableServer::Twinoid => {
let options = serde_json::from_value::<TwinoidUserIdRef>(options)?;
let options = UnlinkFromTwinoidOptions {
user_id,
twinoid_user_id: options.id,
};
DeleteLinkResult::Twinoid(api.user.unlink_from_twinoid(acx, options).await?)
}
};
Ok(Json(result))
}
#[cfg(test)]
mod tests {
use crate::app;
use crate::clock::RestClock;
use crate::extract::AuthLogger;
use crate::test::{create_api, RouterExt};
use crate::users::LinkableServer;
use axum::http::StatusCode;
use axum::Extension;
use etwin_core::auth::RegisterWithUsernameOptions;
use etwin_core::core::Instant;
use etwin_core::hammerfest::HammerfestServer;
use etwin_core::link::VersionedLinks;
use etwin_core::password::Password;
use etwin_core::user::{UpdateUserPatch, User, UserDisplayNameVersion, UserDisplayNameVersions};
use etwin_log::NoopLogger;
use std::sync::Arc;
#[test]
fn test_deser_linkable_server() {
assert_eq!(
LinkableServer::Hammerfest(HammerfestServer::HammerfestFr),
serde_json::from_str("\"hammerfest.fr\"").expect("hammerfest.fr"),
);
assert_eq!(
LinkableServer::Twinoid,
serde_json::from_str("\"twinoid.com\"").expect("twinoid.com"),
);
}
#[tokio::test]
async fn test_update_user_display_name() {
let api = create_api();
let logger = AuthLogger(Arc::new(NoopLogger));
let router = app(api).layer(Extension(logger));
let mut client = router.client();
{
let req = axum::http::Request::builder()
.method("PUT")
.uri("/api/v1/clock")
.header("Content-Type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&RestClock {
time: Instant::ymd_hms(2021, 1, 1, 0, 0, 0),
})
.unwrap(),
))
.unwrap();
let res = client.send(req).await;
let actual_status = res.status();
assert_eq!(actual_status, StatusCode::OK);
}
let alice = {
let req = axum::http::Request::builder()
.method("POST")
.uri("/api/v1/users")
.header("Content-Type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&RegisterWithUsernameOptions {
username: "alice".parse().unwrap(),
display_name: "Alice".parse().unwrap(),
password: Password::from("aaaaaaaaaa"),
})
.unwrap(),
))
.unwrap();
let res = client.send(req).await;
assert_eq!(res.status(), StatusCode::OK);
let body = hyper::body::to_bytes(res.into_body())
.await
.expect("read body")
.to_vec();
let body: &str = std::str::from_utf8(body.as_slice()).unwrap();
serde_json::from_str::<User>(body).unwrap()
};
{
let req = axum::http::Request::builder()
.method("PUT")
.uri("/api/v1/clock")
.header("Content-Type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&RestClock {
time: Instant::ymd_hms(2021, 1, 31, 0, 0, 0),
})
.unwrap(),
))
.unwrap();
let res = client.send(req).await;
let actual_status = res.status();
assert_eq!(actual_status, StatusCode::OK);
}
{
let req = axum::http::Request::builder()
.method("PATCH")
.uri(format!("/api/v1/users/{}", alice.id))
.header("Content-Type", "application/json")
.body(axum::body::Body::from(
serde_json::to_string(&UpdateUserPatch {
display_name: Some("Allisson".parse().unwrap()),
username: None,
password: None,
})
.unwrap(),
))
.unwrap();
let res = client.send(req).await;
assert_eq!(res.status(), StatusCode::OK);
let body = hyper::body::to_bytes(res.into_body())
.await
.expect("read body")
.to_vec();
let body: &str = std::str::from_utf8(body.as_slice()).unwrap();
let actual: User = serde_json::from_str(body).unwrap();
let expected = User {
id: alice.id,
created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 0),
display_name: UserDisplayNameVersions {
current: UserDisplayNameVersion {
value: "Allisson".parse().unwrap(),
},
},
is_administrator: true,
links: VersionedLinks::default(),
};
assert_eq!(actual, expected);
}
}
}