summaryrefslogtreecommitdiffstats
path: root/src/api/users.rs
diff options
context:
space:
mode:
authorToby Vincent <tobyv@tobyvin.dev>2024-04-11 22:44:07 -0500
committerToby Vincent <tobyv@tobyvin.dev>2024-04-11 23:51:19 -0500
commita20f3667a88affa0498e564cea17e9e795162bb8 (patch)
tree29797241f1bce86193a733f8cfdc49121f91ddad /src/api/users.rs
parent61acc1f6e418d1d6947658424115af994a2689dd (diff)
feat: impl auth and modularize routers
Diffstat (limited to 'src/api/users.rs')
-rw-r--r--src/api/users.rs302
1 files changed, 302 insertions, 0 deletions
diff --git a/src/api/users.rs b/src/api/users.rs
new file mode 100644
index 0000000..3eb7e89
--- /dev/null
+++ b/src/api/users.rs
@@ -0,0 +1,302 @@
+use std::str::FromStr;
+
+use axum::{
+ extract::{Path, State},
+ response::IntoResponse,
+ Json,
+};
+use axum_extra::{headers::Authorization, routing::Resource, TypedHeader};
+use serde::{Deserialize, Serialize};
+use sqlx::FromRow;
+use time::OffsetDateTime;
+use uuid::Uuid;
+
+use crate::{auth::AccessClaims, state::AppState, Error};
+
+pub fn router() -> Resource<AppState> {
+ Resource::named("users").create(create).show(show)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
+#[serde(rename_all = "camelCase")]
+pub struct UserSchema {
+ pub uuid: Uuid,
+ pub name: String,
+ pub email: String,
+ pub session_epoch: OffsetDateTime,
+ pub created_at: OffsetDateTime,
+ pub updated_at: OffsetDateTime,
+}
+
+impl Default for UserSchema {
+ fn default() -> Self {
+ let now = time::OffsetDateTime::now_utc();
+ Self {
+ uuid: Default::default(),
+ name: Default::default(),
+ email: Default::default(),
+ session_epoch: now,
+ created_at: now,
+ updated_at: now,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RegisterSchema {
+ pub name: String,
+ pub email: String,
+ pub password: String,
+}
+
+pub async fn create(
+ State(state): State<AppState>,
+ Json(RegisterSchema {
+ name,
+ email,
+ password,
+ }): Json<RegisterSchema>,
+) -> impl IntoResponse {
+ email_address::EmailAddress::from_str(&email)?;
+
+ let exists: Option<bool> =
+ sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 LIMIT 1)")
+ .bind(email.to_ascii_lowercase())
+ .fetch_one(&state.pool)
+ .await?;
+
+ if exists.is_some_and(|b| b) {
+ return Err(Error::EmailExists);
+ }
+
+ let mut transaction = state.pool.begin().await?;
+
+ let user = sqlx::query_as!(
+ UserSchema,
+ "INSERT INTO users (name,email) VALUES ($1, $2) RETURNING *",
+ name,
+ email.to_ascii_lowercase(),
+ )
+ .fetch_one(&mut *transaction)
+ .await?;
+
+ let (parts, _) = crate::auth::create(
+ State(state),
+ TypedHeader(Authorization::basic(&user.uuid.to_string(), &password)),
+ )
+ .await?
+ .into_response()
+ .into_parts();
+
+ transaction.commit().await?;
+ Ok((parts, Json(user)))
+}
+
+pub async fn show(
+ Path(uuid): Path<Uuid>,
+ State(state): State<AppState>,
+ AccessClaims { sub, .. }: AccessClaims,
+) -> Result<impl IntoResponse, Error> {
+ if uuid != sub {
+ return Err(Error::InvalidToken);
+ }
+
+ sqlx::query_as!(
+ UserSchema,
+ "SELECT * FROM users WHERE uuid = $1 LIMIT 1",
+ sub
+ )
+ .fetch_optional(&state.pool)
+ .await?
+ .ok_or_else(|| Error::UserNotFound)
+ .map(Json)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use axum::{
+ body::Body,
+ http::{
+ header::{CONTENT_TYPE, COOKIE},
+ HeaderValue, Request, StatusCode,
+ },
+ Router,
+ };
+
+ use http_body_util::BodyExt;
+ use sqlx::PgPool;
+ use tower::ServiceExt;
+
+ use crate::{
+ auth::AccessClaims,
+ tests::{setup_test_env, TestResult},
+ };
+
+ const UUID: uuid::Uuid = uuid::uuid!("4c14f795-86f0-4361-a02f-0edb966fb145");
+
+ #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
+ async fn test_uuid_ok(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let user = UserSchema {
+ uuid: UUID,
+ name: "Arthur Dent".to_string(),
+ email: "adent@earth.sol".to_string(),
+ ..Default::default()
+ };
+
+ let request = Request::builder()
+ .uri(format!("/users/{UUID}"))
+ .header(COOKIE, HeaderValue::try_from(AccessClaims::new(UUID))?)
+ .body(Body::empty())?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::OK, response.status());
+
+ let body_bytes = response.into_body().collect().await?.to_bytes();
+ let UserSchema {
+ uuid, name, email, ..
+ } = serde_json::from_slice(&body_bytes)?;
+
+ assert_eq!(user.uuid, uuid);
+ assert_eq!(user.name, name);
+ assert_eq!(user.email, email);
+
+ Ok(())
+ }
+
+ #[sqlx::test]
+ async fn test_uuid_not_found(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let request = Request::builder()
+ .uri(format!("/users/{UUID}"))
+ .header(COOKIE, HeaderValue::try_from(AccessClaims::new(UUID))?)
+ .body(Body::empty())?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::NOT_FOUND, response.status());
+
+ Ok(())
+ }
+
+ #[sqlx::test]
+ async fn test_unauthorized_invalid_token_signature(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let request = Request::builder()
+ .uri(format!("/users/{UUID}"))
+ .header(
+ COOKIE,
+ HeaderValue::try_from(AccessClaims::new(uuid::Uuid::new_v4()))?,
+ )
+ .body(Body::empty())?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::UNAUTHORIZED, response.status());
+
+ Ok(())
+ }
+
+ #[sqlx::test]
+ async fn test_unauthorized_invalid_token_format(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let request = Request::builder()
+ .uri(format!("/users/{UUID}"))
+ .header(COOKIE, "token=sadfasdfsdfs")
+ .body(Body::empty())?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::UNAUTHORIZED, response.status());
+
+ Ok(())
+ }
+
+ #[sqlx::test]
+ async fn test_unauthorized_missing_token(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let request = Request::builder()
+ .uri(format!("/users/{UUID}"))
+ .body(Body::empty())?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::UNAUTHORIZED, response.status());
+
+ Ok(())
+ }
+
+ #[sqlx::test]
+ async fn test_create_created(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let user = RegisterSchema {
+ name: "Arthur Dent".to_string(),
+ email: "adent@earth.sol".to_string(),
+ password: "solongandthanksforallthefish".to_string(),
+ };
+
+ let request = Request::builder()
+ .uri("/users")
+ .method("POST")
+ .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
+ .body(Body::from(serde_json::to_vec(&user)?))?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::CREATED, response.status());
+
+ let body_bytes = response.into_body().collect().await?.to_bytes();
+ let UserSchema { name, email, .. } = serde_json::from_slice(&body_bytes)?;
+
+ assert_eq!(user.name, name);
+ assert_eq!(user.email, email);
+
+ Ok(())
+ }
+
+ #[sqlx::test(fixtures(path = "../../fixtures", scripts("users")))]
+ async fn test_create_conflict(pool: PgPool) -> TestResult {
+ setup_test_env();
+
+ let router = Router::new().merge(router()).with_state(AppState { pool });
+
+ let user = RegisterSchema {
+ name: "Arthur Dent".to_string(),
+ email: "adent@earth.sol".to_string(),
+ password: "solongandthanksforallthefish".to_string(),
+ };
+
+ let request = Request::builder()
+ .uri("/users")
+ .method("POST")
+ .header(CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
+ .body(Body::from(serde_json::to_vec(&user)?))?;
+
+ let response = router.oneshot(request).await?;
+
+ assert_eq!(StatusCode::CONFLICT, response.status());
+
+ Ok(())
+ }
+}