From ea74daca4809e4008dd8d01039db9fff3be659d9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 16 Oct 2024 20:14:33 -0400 Subject: Organizational pass on endpoints and routes. --- src/channel/app.rs | 10 +-- src/channel/routes.rs | 121 ----------------------------------- src/channel/routes/channel/delete.rs | 39 +++++++++++ src/channel/routes/channel/mod.rs | 9 +++ src/channel/routes/channel/post.rs | 47 ++++++++++++++ src/channel/routes/channel/test.rs | 94 +++++++++++++++++++++++++++ src/channel/routes/mod.rs | 19 ++++++ src/channel/routes/post.rs | 49 ++++++++++++++ src/channel/routes/test.rs | 83 ++++++++++++++++++++++++ src/channel/routes/test/mod.rs | 2 - src/channel/routes/test/on_create.rs | 88 ------------------------- src/channel/routes/test/on_send.rs | 94 --------------------------- 12 files changed, 342 insertions(+), 313 deletions(-) delete mode 100644 src/channel/routes.rs create mode 100644 src/channel/routes/channel/delete.rs create mode 100644 src/channel/routes/channel/mod.rs create mode 100644 src/channel/routes/channel/post.rs create mode 100644 src/channel/routes/channel/test.rs create mode 100644 src/channel/routes/mod.rs create mode 100644 src/channel/routes/post.rs create mode 100644 src/channel/routes/test.rs delete mode 100644 src/channel/routes/test/mod.rs delete mode 100644 src/channel/routes/test/on_create.rs delete mode 100644 src/channel/routes/test/on_send.rs (limited to 'src/channel') diff --git a/src/channel/app.rs b/src/channel/app.rs index 5d6cada..46eaba8 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -122,7 +122,7 @@ pub enum CreateError { #[error("channel named {0} already exists")] DuplicateName(String), #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), } #[derive(Debug, thiserror::Error)] @@ -130,11 +130,5 @@ pub enum Error { #[error("channel {0} not found")] NotFound(Id), #[error(transparent)] - DatabaseError(#[from] sqlx::Error), -} - -#[derive(Debug, thiserror::Error)] -pub enum InternalError { - #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), } diff --git a/src/channel/routes.rs b/src/channel/routes.rs deleted file mode 100644 index eaf7962..0000000 --- a/src/channel/routes.rs +++ /dev/null @@ -1,121 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, post}, - Router, -}; - -use super::{app, Channel, Id}; -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - login::Login, - message::app::SendError, -}; - -#[cfg(test)] -mod test; - -pub fn router() -> Router { - Router::new() - .route("/api/channels", post(on_create)) - .route("/api/channels/:channel", post(on_send)) - .route("/api/channels/:channel", delete(on_delete)) -} - -#[derive(Clone, serde::Deserialize)] -struct CreateRequest { - name: String, -} - -async fn on_create( - State(app): State, - _: Login, // requires auth, but doesn't actually care who you are - RequestedAt(created_at): RequestedAt, - Json(form): Json, -) -> Result, CreateError> { - let channel = app - .channels() - .create(&form.name, &created_at) - .await - .map_err(CreateError)?; - - Ok(Json(channel)) -} - -#[derive(Debug)] -struct CreateError(app::CreateError); - -impl IntoResponse for CreateError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - duplicate @ app::CreateError::DuplicateName(_) => { - (StatusCode::CONFLICT, duplicate.to_string()).into_response() - } - other => Internal::from(other).into_response(), - } - } -} - -#[derive(Clone, serde::Deserialize)] -struct SendRequest { - body: String, -} - -async fn on_send( - State(app): State, - Path(channel): Path, - RequestedAt(sent_at): RequestedAt, - login: Login, - Json(request): Json, -) -> Result { - app.messages() - .send(&channel, &login, &sent_at, &request.body) - .await?; - - Ok(StatusCode::ACCEPTED) -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -struct SendErrorResponse(#[from] SendError); - -impl IntoResponse for SendErrorResponse { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - not_found @ SendError::ChannelNotFound(_) => NotFound(not_found).into_response(), - other => Internal::from(other).into_response(), - } - } -} - -async fn on_delete( - State(app): State, - Path(channel): Path, - RequestedAt(deleted_at): RequestedAt, - _: Login, -) -> Result { - app.channels().delete(&channel, &deleted_at).await?; - - Ok(StatusCode::ACCEPTED) -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -struct ErrorResponse(#[from] app::Error); - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - not_found @ app::Error::NotFound(_) => { - (StatusCode::NOT_FOUND, not_found.to_string()).into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs new file mode 100644 index 0000000..efac0c0 --- /dev/null +++ b/src/channel/routes/channel/delete.rs @@ -0,0 +1,39 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + channel::app, + clock::RequestedAt, + error::{Internal, NotFound}, + login::Login, +}; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(deleted_at): RequestedAt, + _: Login, +) -> Result { + app.channels().delete(&channel, &deleted_at).await?; + + Ok(StatusCode::ACCEPTED) +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::Error); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::Error::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/channel/mod.rs b/src/channel/routes/channel/mod.rs new file mode 100644 index 0000000..31a9142 --- /dev/null +++ b/src/channel/routes/channel/mod.rs @@ -0,0 +1,9 @@ +use crate::channel::Id; + +pub mod delete; +pub mod post; + +#[cfg(test)] +mod test; + +type PathInfo = Id; diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs new file mode 100644 index 0000000..a71a3a0 --- /dev/null +++ b/src/channel/routes/channel/post.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + login::Login, + message::app::SendError, +}; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(sent_at): RequestedAt, + login: Login, + Json(request): Json, +) -> Result { + app.messages() + .send(&channel, &login, &sent_at, &request.body) + .await?; + + Ok(StatusCode::ACCEPTED) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub body: String, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub SendError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + SendError::ChannelNotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs new file mode 100644 index 0000000..bc02b20 --- /dev/null +++ b/src/channel/routes/channel/test.rs @@ -0,0 +1,94 @@ +use axum::extract::{Json, Path, State}; +use futures::stream::StreamExt; + +use super::post; +use crate::{ + channel, + event::{self, Sequenced}, + message::{self, app::SendError}, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn messages_in_order() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint (twice) + + let requests = vec![ + (fixtures::now(), fixtures::message::propose()), + (fixtures::now(), fixtures::message::propose()), + ]; + + for (sent_at, body) in &requests { + let request = post::Request { body: body.clone() }; + + post::handler( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request), + ) + .await + .expect("sending to a valid channel"); + } + + // Verify the semantics + + let events = app + .events() + .subscribe(None) + .await + .expect("subscribing to a valid channel") + .filter(fixtures::filter::messages()) + .take(requests.len()); + + let events = events.collect::>().immediately().await; + + for ((sent_at, message), event) in requests.into_iter().zip(events) { + assert_eq!(*sent_at, event.at()); + assert!(matches!( + event, + event::Event::Message(message::Event::Sent(event)) + if event.message.sender == sender.id + && event.message.body == message + )); + } +} + +#[tokio::test] +async fn nonexistent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = post::Request { + body: fixtures::message::propose(), + }; + let post::Error(error) = post::handler( + State(app), + Path(channel.clone()), + sent_at, + login, + Json(request), + ) + .await + .expect_err("sending to a nonexistent channel"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} diff --git a/src/channel/routes/mod.rs b/src/channel/routes/mod.rs new file mode 100644 index 0000000..696bd72 --- /dev/null +++ b/src/channel/routes/mod.rs @@ -0,0 +1,19 @@ +use axum::{ + routing::{delete, post}, + Router, +}; + +use crate::app::App; + +mod channel; +mod post; + +#[cfg(test)] +mod test; + +pub fn router() -> Router { + Router::new() + .route("/api/channels", post(post::handler)) + .route("/api/channels/:channel", post(channel::post::handler)) + .route("/api/channels/:channel", delete(channel::delete::handler)) +} diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs new file mode 100644 index 0000000..d694f8b --- /dev/null +++ b/src/channel/routes/post.rs @@ -0,0 +1,49 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + channel::{app, Channel}, + clock::RequestedAt, + error::Internal, + login::Login, +}; + +pub async fn handler( + State(app): State, + _: Login, // requires auth, but doesn't actually care who you are + RequestedAt(created_at): RequestedAt, + Json(request): Json, +) -> Result, Error> { + let channel = app + .channels() + .create(&request.name, &created_at) + .await + .map_err(Error)?; + + Ok(Json(channel)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, +} + +#[derive(Debug)] +pub struct Error(pub app::CreateError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::CreateError::DuplicateName(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs new file mode 100644 index 0000000..81f1465 --- /dev/null +++ b/src/channel/routes/test.rs @@ -0,0 +1,83 @@ +use axum::extract::{Json, State}; +use futures::stream::StreamExt as _; + +use super::post; +use crate::{ + channel::{self, app}, + event, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn new_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::channel::propose(); + let request = post::Request { name: name.clone() }; + let Json(response_channel) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("new channel in an empty app"); + + // Verify the structure of the response + + assert_eq!(name, response_channel.name); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(snapshot + .channels + .iter() + .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id)); + + let mut events = app + .events() + .subscribe(None) + .await + .expect("subscribing never fails") + .filter(fixtures::filter::created()); + + let event = events + .next() + .immediately() + .await + .expect("creation event published"); + + assert!(matches!( + event, + event::Event::Channel(channel::Event::Created(event)) + if event.channel == response_channel + )); +} + +#[tokio::test] +async fn duplicate_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let request = post::Request { + name: channel.name.clone(), + }; + let post::Error(error) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("duplicate channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::DuplicateName(name) if channel.name == name + )); +} diff --git a/src/channel/routes/test/mod.rs b/src/channel/routes/test/mod.rs deleted file mode 100644 index 3e5aa17..0000000 --- a/src/channel/routes/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod on_create; -mod on_send; diff --git a/src/channel/routes/test/on_create.rs b/src/channel/routes/test/on_create.rs deleted file mode 100644 index eeecc7f..0000000 --- a/src/channel/routes/test/on_create.rs +++ /dev/null @@ -1,88 +0,0 @@ -use axum::extract::{Json, State}; -use futures::stream::StreamExt as _; - -use crate::{ - channel::{self, app, routes}, - event, - test::fixtures::{self, future::Immediately as _}, -}; - -#[tokio::test] -async fn new_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let name = fixtures::channel::propose(); - let request = routes::CreateRequest { name }; - let Json(response_channel) = routes::on_create( - State(app.clone()), - creator, - fixtures::now(), - Json(request.clone()), - ) - .await - .expect("new channel in an empty app"); - - // Verify the structure of the response - - assert_eq!(request.name, response_channel.name); - - // Verify the semantics - - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(snapshot - .channels - .iter() - .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id)); - - let mut events = app - .events() - .subscribe(None) - .await - .expect("subscribing never fails") - .filter(fixtures::filter::created()); - - let event = events - .next() - .immediately() - .await - .expect("creation event published"); - - assert!(matches!( - event, - event::Event::Channel(channel::Event::Created(event)) - if event.channel == response_channel - )); -} - -#[tokio::test] -async fn duplicate_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let request = routes::CreateRequest { name: channel.name }; - let routes::CreateError(error) = routes::on_create( - State(app.clone()), - creator, - fixtures::now(), - Json(request.clone()), - ) - .await - .expect_err("duplicate channel name"); - - // Verify the structure of the response - - assert!(matches!( - error, - app::CreateError::DuplicateName(name) if request.name == name - )); -} diff --git a/src/channel/routes/test/on_send.rs b/src/channel/routes/test/on_send.rs deleted file mode 100644 index 293cc56..0000000 --- a/src/channel/routes/test/on_send.rs +++ /dev/null @@ -1,94 +0,0 @@ -use axum::extract::{Json, Path, State}; -use futures::stream::StreamExt; - -use crate::{ - channel, - channel::routes, - event::{self, Sequenced}, - message::{self, app::SendError}, - test::fixtures::{self, future::Immediately as _}, -}; - -#[tokio::test] -async fn messages_in_order() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint (twice) - - let requests = vec![ - (fixtures::now(), fixtures::message::propose()), - (fixtures::now(), fixtures::message::propose()), - ]; - - for (sent_at, body) in &requests { - let request = routes::SendRequest { body: body.clone() }; - - routes::on_send( - State(app.clone()), - Path(channel.id.clone()), - sent_at.clone(), - sender.clone(), - Json(request.clone()), - ) - .await - .expect("sending to a valid channel"); - } - - // Verify the semantics - - let events = app - .events() - .subscribe(None) - .await - .expect("subscribing to a valid channel") - .filter(fixtures::filter::messages()) - .take(requests.len()); - - let events = events.collect::>().immediately().await; - - for ((sent_at, message), event) in requests.into_iter().zip(events) { - assert_eq!(*sent_at, event.at()); - assert!(matches!( - event, - event::Event::Message(message::Event::Sent(event)) - if event.message.sender == sender.id - && event.message.body == message - )); - } -} - -#[tokio::test] -async fn nonexistent_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = routes::SendRequest { - body: fixtures::message::propose(), - }; - let routes::SendErrorResponse(error) = routes::on_send( - State(app), - Path(channel.clone()), - sent_at, - login, - Json(request), - ) - .await - .expect_err("sending to a nonexistent channel"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} -- cgit v1.2.3