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::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Extension, Json, Router};
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use cookie::time::OffsetDateTime;
use cookie::Expiration;
use etwin_core::auth::{AuthContext, RawUserCredentials};
use etwin_core::dinoparc::DinoparcCredentials;
use etwin_core::hammerfest::HammerfestCredentials;
use etwin_core::twinoid::TwinoidCredentials;
use etwin_core::types::AnyError;
use etwin_core::user::User;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use thiserror::Error;

pub fn router() -> Router<(), Body> {
  Router::new().route("/self", get(get_self).put(set_self).delete(delete_self))
}

async fn get_self(acx: Extractor<AuthContext>) -> Json<AuthContext> {
  Json(acx.value())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
struct SetSelfQuery {
  method: Option<SetSelfMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
enum SetSelfMethod {
  Dinoparc,
  Etwin,
  Hammerfest,
  Twinoid,
}

#[derive(Debug, Error)]
enum SetSelfError {
  #[error("bad request")]
  BadRequest(#[from] serde_json::Error),
  #[error("internal error")]
  Internal(#[from] AnyError),
}

impl IntoResponse for SetSelfError {
  fn into_response(self) -> Response {
    let status = match &self {
      Self::BadRequest(_) => StatusCode::BAD_REQUEST,
      Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
    };
    (status, Json(json!({ "error": self.to_string() }))).into_response()
  }
}

async fn set_self(
  Extension(api): Extension<RouterApi>,
  Query(query): Query<SetSelfQuery>,
  jar: CookieJar,
  Json(body): Json<JsonValue>,
) -> Result<(CookieJar, Json<User>), SetSelfError> {
  let user_and_session = match query.method {
    Some(SetSelfMethod::Dinoparc) => {
      let credentials: DinoparcCredentials = serde_json::from_value(body)?;
      api.auth.register_or_login_with_dinoparc(&credentials).await?
    }
    None | Some(SetSelfMethod::Etwin) => {
      let credentials: RawUserCredentials = serde_json::from_value(body)?;
      api.auth.raw_login_with_credentials(&credentials).await?
    }
    Some(SetSelfMethod::Hammerfest) => {
      let credentials: HammerfestCredentials = serde_json::from_value(body)?;
      api.auth.register_or_login_with_hammerfest(&credentials).await?
    }
    Some(SetSelfMethod::Twinoid) => {
      let credentials: TwinoidCredentials = serde_json::from_value(body)?;
      api.auth.register_or_login_with_twinoid(&credentials).await?
    }
  };
  let cookie = Cookie::build(SESSION_COOKIE, user_and_session.session.id.to_string())
    .path("/")
    .http_only(true)
    .finish();
  Ok((jar.add(cookie), Json(user_and_session.user)))
}

async fn delete_self(jar: CookieJar) -> (CookieJar, Json<AuthContext>) {
  let cookie = Cookie::build(SESSION_COOKIE, "".to_string())
    .path("/")
    .http_only(true)
    .expires(Expiration::DateTime(OffsetDateTime::UNIX_EPOCH))
    .finish();
  (jar.add(cookie), Json(AuthContext::guest()))
}

#[cfg(test)]
mod tests {
  use crate::app;
  use crate::clock::RestClock;
  use crate::dev::hammerfest_client::UpsertHammerfestUserBody;
  use crate::extract::AuthLogger;
  use crate::test::{create_api, RouterExt};
  use axum::http::StatusCode;
  use axum::Extension;
  use etwin_core::auth::{AuthContext, RawUserCredentials, RegisterWithUsernameOptions};
  use etwin_core::core::Instant;
  use etwin_core::hammerfest::{HammerfestCredentials, HammerfestServer};
  use etwin_core::link::VersionedLinks;
  use etwin_core::password::Password;
  use etwin_core::user::{User, UserDisplayNameVersion, UserDisplayNameVersions};
  use etwin_log::NoopLogger;
  use std::sync::Arc;

  #[tokio::test]
  async fn test_register_a_user_and_authenticate_back() {
    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 mut client = router.client();
    {
      let req = axum::http::Request::builder()
        .method("GET")
        .uri("/api/v1/auth/self")
        .header("Content-Type", "application/json")
        .body(axum::body::Body::empty())
        .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: AuthContext = serde_json::from_str(body).unwrap();
      let expected = AuthContext::guest();
      assert_eq!(actual, expected);
    }
    {
      let req = axum::http::Request::builder()
        .method("PUT")
        .uri("/api/v1/auth/self?method=Etwin")
        .header("Content-Type", "application/json")
        .body(axum::body::Body::from(
          serde_json::to_string(&RawUserCredentials {
            login: "alice".to_string(),
            password: b"aaaaaaaaaa".as_slice().into(),
          })
          .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: "Alice".parse().unwrap(),
          },
        },
        is_administrator: true,
        links: VersionedLinks::default(),
      };
      assert_eq!(actual, expected);
    }
  }

  #[tokio::test]
  async fn test_register_with_hammerfest() {
    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/hammerfest_client/users")
        .header("Content-Type", "application/json")
        .body(axum::body::Body::from(
          serde_json::to_string(&UpsertHammerfestUserBody {
            server: HammerfestServer::HammerfestFr,
            id: "123".parse().unwrap(),
            username: "alicehf".parse().unwrap(),
            password: "alicehf".parse().unwrap(),
          })
          .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("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 mut client = router.client();
    {
      let req = axum::http::Request::builder()
        .method("PUT")
        .uri("/api/v1/auth/self?method=Hammerfest")
        .header("Content-Type", "application/json")
        .body(axum::body::Body::from(
          serde_json::to_string(&HammerfestCredentials {
            server: HammerfestServer::HammerfestFr,
            username: "alicehf".parse().unwrap(),
            password: "alicehf".parse().unwrap(),
          })
          .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: actual.id,
        created_at: Instant::ymd_hms(2021, 1, 1, 0, 0, 0),
        display_name: UserDisplayNameVersions {
          current: UserDisplayNameVersion {
            value: "alicehf".parse().unwrap(),
          },
        },
        is_administrator: true,
        links: VersionedLinks {
          dinoparc_com: Default::default(),
          en_dinoparc_com: Default::default(),
          hammerfest_es: Default::default(),
          hammerfest_fr: actual.links.hammerfest_fr.clone(),
          hfest_net: Default::default(),
          sp_dinoparc_com: Default::default(),
          twinoid: Default::default(),
        },
      };
      assert_eq!(actual, expected);
    }
  }
}