etwin_rest 0.12.3

HTTP REST interface for Eternal-Twin
Documentation
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);
    }
  }
}