From 4e3d5ccac99b24934c972e088cd7eb02bb95df06 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 17 Jun 2025 02:11:45 -0400 Subject: Handlers are _named operations_, which can be exposed via routes. Each domain module that exposes handlers does so through a `handlers` child module, ideally as a top-level symbol that can be plugged directly into Axum's `MethodRouter`. Modules could make exceptions to this - kill the doctrinaire inside yourself, after all - but none of the API modules that actually exist need such exceptions, and consistency is useful. The related details of request types, URL types, response types, errors, &c &c are then organized into modules under `handlers`, along with their respective tests. --- src/invite/handlers/accept/mod.rs | 60 ++++++++++ src/invite/handlers/accept/test.rs | 236 +++++++++++++++++++++++++++++++++++++ src/invite/handlers/get/mod.rs | 41 +++++++ src/invite/handlers/get/test.rs | 65 ++++++++++ src/invite/handlers/issue/mod.rs | 22 ++++ src/invite/handlers/issue/test.rs | 27 +++++ src/invite/handlers/mod.rs | 9 ++ 7 files changed, 460 insertions(+) create mode 100644 src/invite/handlers/accept/mod.rs create mode 100644 src/invite/handlers/accept/test.rs create mode 100644 src/invite/handlers/get/mod.rs create mode 100644 src/invite/handlers/get/test.rs create mode 100644 src/invite/handlers/issue/mod.rs create mode 100644 src/invite/handlers/issue/test.rs create mode 100644 src/invite/handlers/mod.rs (limited to 'src/invite/handlers') diff --git a/src/invite/handlers/accept/mod.rs b/src/invite/handlers/accept/mod.rs new file mode 100644 index 0000000..0d5f08a --- /dev/null +++ b/src/invite/handlers/accept/mod.rs @@ -0,0 +1,60 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + invite::{app, handlers::PathInfo}, + name::Name, + token::extract::IdentityCookie, + user::{Password, User}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityCookie, + Path(invite): Path, + Json(request): Json, +) -> Result<(IdentityCookie, Json), Error> { + let (login, secret) = app + .invites() + .accept(&invite, &request.name, &request.password, &accepted_at) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Json(login))) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(pub app::AcceptError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::AcceptError::NotFound(_) => NotFound(error).into_response(), + app::AcceptError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } + app::AcceptError::DuplicateLogin(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs new file mode 100644 index 0000000..cb13900 --- /dev/null +++ b/src/invite/handlers/accept/test.rs @@ -0,0 +1,236 @@ +use axum::extract::{Json, Path, State}; + +use crate::{invite::app::AcceptError, name::Name, test::fixtures}; + +#[tokio::test] +async fn valid_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let (identity, Json(response)) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect("accepting a valid invite succeeds"); + + // Verify the response + + assert!(identity.secret().is_some()); + assert_eq!(name, response.name); + + // Verify that the issued token is valid + + let secret = identity + .secret() + .expect("newly-issued identity has a token secret"); + let (_, login) = app + .tokens() + .validate(&secret, &fixtures::now()) + .await + .expect("newly-issued identity cookie is valid"); + assert_eq!(response, login); + + // Verify that the given credentials can log in + + let (login, _) = app + .tokens() + .login(&name, &password, &fixtures::now()) + .await + .expect("credentials given on signup are valid"); + assert_eq!(response, login); +} + +#[tokio::test] +async fn nonexistent_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let invite = fixtures::invite::fictitious(); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite)); +} + +#[tokio::test] +async fn expired_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + app.invites() + .expire(&fixtures::now()) + .await + .expect("expiring invites never fails"); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); +} + +#[tokio::test] +async fn accepted_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + let (name, password) = fixtures::user::propose(); + app.invites() + .accept(&invite.id, &name, &password, &fixtures::now()) + .await + .expect("accepting a valid invite succeeds"); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); +} + +#[tokio::test] +async fn conflicting_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + let existing_name = Name::from("rijksmuseum"); + app.users() + .create( + &existing_name, + &fixtures::user::propose_password(), + &fixtures::now(), + ) + .await + .expect("creating a user in an empty environment succeeds"); + + // Call the endpoint + + let conflicting_name = Name::from("r\u{0133}ksmuseum"); + let password = fixtures::user::propose_password(); + + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: conflicting_name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!( + matches!(error, AcceptError::DuplicateLogin(error_name) if error_name == conflicting_name) + ); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::user::propose_invalid_name(); + let password = fixtures::user::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect_err("using an invalid name should fail"); + + // Verify the response + + assert!(matches!(error, AcceptError::InvalidName(error_name) if name == error_name)); +} diff --git a/src/invite/handlers/get/mod.rs b/src/invite/handlers/get/mod.rs new file mode 100644 index 0000000..bb72586 --- /dev/null +++ b/src/invite/handlers/get/mod.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::{Json, Path, State}, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + error::{Internal, NotFound}, + invite::{Id, Summary, handlers::PathInfo}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + Path(invite): Path, +) -> Result, Error> { + app.invites() + .get(&invite) + .await? + .map(Json) + .ok_or_else(move || Error::NotFound(invite)) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::NotFound(_) => NotFound(self).into_response(), + Self::Database(_) => Internal::from(self).into_response(), + } + } +} diff --git a/src/invite/handlers/get/test.rs b/src/invite/handlers/get/test.rs new file mode 100644 index 0000000..0f2f725 --- /dev/null +++ b/src/invite/handlers/get/test.rs @@ -0,0 +1,65 @@ +use axum::extract::{Json, Path, State}; + +use crate::test::fixtures; + +#[tokio::test] +async fn valid_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call endpoint + + let Json(response) = super::handler(State(app), Path(invite.id)) + .await + .expect("get for an existing invite succeeds"); + + // Verify response + + assert_eq!(issuer.name.display(), &response.issuer); + assert_eq!(invite.issued_at, response.issued_at); +} + +#[tokio::test] +async fn nonexistent_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call endpoint + + let invite = fixtures::invite::fictitious(); + let error = super::handler(State(app), Path(invite.clone())) + .await + .expect_err("get for a nonexistent invite fails"); + + // Verify response + + assert!(matches!(error, super::Error::NotFound(error_id) if invite == error_id)); +} + +#[tokio::test] +async fn expired_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + app.invites() + .expire(&fixtures::now()) + .await + .expect("expiring invites never fails"); + + // Call endpoint + + let error = super::handler(State(app), Path(invite.id.clone())) + .await + .expect_err("get for an expired invite fails"); + + // Verify response + + assert!(matches!(error, super::Error::NotFound(error_id) if invite.id == error_id)); +} diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs new file mode 100644 index 0000000..6085f7a --- /dev/null +++ b/src/invite/handlers/issue/mod.rs @@ -0,0 +1,22 @@ +use axum::extract::{Json, State}; + +use crate::{ + app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(issued_at): RequestedAt, + identity: Identity, + _: Json, +) -> Result, Internal> { + let invite = app.invites().issue(&identity.user, &issued_at).await?; + Ok(Json(invite)) +} + +// Require `{}` as the only valid request for this endpoint. +#[derive(Default, serde::Deserialize)] +pub struct Request {} diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs new file mode 100644 index 0000000..2bf5400 --- /dev/null +++ b/src/invite/handlers/issue/test.rs @@ -0,0 +1,27 @@ +use axum::extract::{Json, State}; + +use crate::test::fixtures; + +#[tokio::test] +async fn create_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::identity::create(&app, &fixtures::now()).await; + let issued_at = fixtures::now(); + + // Call the endpoint + + let Json(invite) = super::handler( + State(app), + issued_at.clone(), + issuer.clone(), + Json(super::Request {}), + ) + .await + .expect("creating an invite always succeeds"); + + // Verify the response + assert_eq!(issuer.user.id, invite.issuer); + assert_eq!(&*issued_at, &invite.issued_at); +} diff --git a/src/invite/handlers/mod.rs b/src/invite/handlers/mod.rs new file mode 100644 index 0000000..07a2bbe --- /dev/null +++ b/src/invite/handlers/mod.rs @@ -0,0 +1,9 @@ +mod accept; +mod get; +mod issue; + +type PathInfo = crate::invite::Id; + +pub use accept::handler as accept; +pub use get::handler as get; +pub use issue::handler as issue; -- cgit v1.2.3