diff options
| author | ojacobson <ojacobson@noreply.codeberg.org> | 2025-06-21 04:22:52 +0200 |
|---|---|---|
| committer | ojacobson <ojacobson@noreply.codeberg.org> | 2025-06-21 04:22:52 +0200 |
| commit | cd1dc0dab4b46bc5712070812192d5ce34071470 (patch) | |
| tree | c94f5a42f7e734b81892c1289a1d2b566706ba7c /src/invite/handlers | |
| parent | d84ba5cd09b713fac2f193d5c05af7415ea6742d (diff) | |
| parent | 4e3d5ccac99b24934c972e088cd7eb02bb95df06 (diff) | |
Reorganize and consolidate HTTP routes.
HTTP routes are now defined in a single, unified module, pulling them out of the topical modules they were formerly part of.
This is intended to improve the navigability of the codebase. Previously, finding the handler corresponding to a specific endpoint required prior familiarity, though in practice you could usually guess from topic area. Now, all routes are defined in one place; if you know the path, you can read down the list to find the handler.
Handlers themselves live with the domain they are most appropriately "part of," generally (in this version, universally) in a `handlers` submodule. The handlers themselves have been flattened down; rather than representing a path and a method, they now represent a named operation (which is suspiciously similar to the path in most cases). This means that we no longer have constructs like `crate::ui::routes::ch::channel` - it's now `crate::ui::handlers::channel` instead.
## Disclaimer
I Solemnly Swear I Didn't Change Any Handlers.
## Prior art
I've inadvertently reinvented Django's `urls.py` convention, and I've opted to lean into that.
Merges flatter-routes-reorg into main.
Diffstat (limited to 'src/invite/handlers')
| -rw-r--r-- | src/invite/handlers/accept/mod.rs | 60 | ||||
| -rw-r--r-- | src/invite/handlers/accept/test.rs | 236 | ||||
| -rw-r--r-- | src/invite/handlers/get/mod.rs | 41 | ||||
| -rw-r--r-- | src/invite/handlers/get/test.rs | 65 | ||||
| -rw-r--r-- | src/invite/handlers/issue/mod.rs | 22 | ||||
| -rw-r--r-- | src/invite/handlers/issue/test.rs | 27 | ||||
| -rw-r--r-- | src/invite/handlers/mod.rs | 9 |
7 files changed, 460 insertions, 0 deletions
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<App>, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityCookie, + Path(invite): Path<PathInfo>, + Json(request): Json<Request>, +) -> Result<(IdentityCookie, Json<User>), 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<App>, + Path(invite): Path<PathInfo>, +) -> Result<Json<Summary>, 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<App>, + RequestedAt(issued_at): RequestedAt, + identity: Identity, + _: Json<Request>, +) -> Result<Json<Invite>, 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; |
