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/boot/routes.rs | 27 ------- src/boot/routes/get.rs | 24 +++++++ src/boot/routes/mod.rs | 11 +++ src/boot/routes/test.rs | 7 +- 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 ------------------------ src/cli.rs | 2 +- src/error.rs | 17 +++-- src/event/routes.rs | 92 ------------------------ src/event/routes/get.rs | 83 ++++++++++++++++++++++ src/event/routes/mod.rs | 11 +++ src/event/routes/test.rs | 29 ++++---- src/invite/app.rs | 16 +---- src/invite/routes.rs | 97 ------------------------- src/invite/routes/invite/get.rs | 39 ++++++++++ src/invite/routes/invite/mod.rs | 4 ++ src/invite/routes/invite/post.rs | 51 +++++++++++++ src/invite/routes/mod.rs | 16 +++++ src/invite/routes/post.rs | 17 +++++ src/login/app.rs | 4 +- src/login/routes.rs | 97 ------------------------- src/login/routes/login/mod.rs | 4 ++ src/login/routes/login/post.rs | 51 +++++++++++++ src/login/routes/login/test.rs | 129 +++++++++++++++++++++++++++++++++ src/login/routes/logout/mod.rs | 4 ++ src/login/routes/logout/post.rs | 47 ++++++++++++ src/login/routes/logout/test.rs | 80 +++++++++++++++++++++ src/login/routes/mod.rs | 12 ++++ src/login/routes/test/login.rs | 128 --------------------------------- src/login/routes/test/logout.rs | 97 ------------------------- src/login/routes/test/mod.rs | 2 - src/message/app.rs | 4 +- src/message/routes.rs | 46 ------------ src/message/routes/message.rs | 43 +++++++++++ src/message/routes/mod.rs | 9 +++ src/setup/routes.rs | 50 ------------- src/setup/routes/mod.rs | 9 +++ src/setup/routes/post.rs | 44 ++++++++++++ src/token/app.rs | 6 +- src/ui.rs | 134 ----------------------------------- src/ui/assets.rs | 43 +++++++++++ src/ui/error.rs | 18 +++++ src/ui/middleware.rs | 15 ++++ src/ui/mod.rs | 6 ++ src/ui/routes/ch/channel.rs | 61 ++++++++++++++++ src/ui/routes/ch/mod.rs | 1 + src/ui/routes/get.rs | 30 ++++++++ src/ui/routes/invite/invite.rs | 55 ++++++++++++++ src/ui/routes/invite/mod.rs | 4 ++ src/ui/routes/login.rs | 11 +++ src/ui/routes/mod.rs | 26 +++++++ src/ui/routes/path.rs | 12 ++++ src/ui/routes/setup.rs | 43 +++++++++++ 63 files changed, 1395 insertions(+), 1128 deletions(-) delete mode 100644 src/boot/routes.rs create mode 100644 src/boot/routes/get.rs create mode 100644 src/boot/routes/mod.rs 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 delete mode 100644 src/event/routes.rs create mode 100644 src/event/routes/get.rs create mode 100644 src/event/routes/mod.rs delete mode 100644 src/invite/routes.rs create mode 100644 src/invite/routes/invite/get.rs create mode 100644 src/invite/routes/invite/mod.rs create mode 100644 src/invite/routes/invite/post.rs create mode 100644 src/invite/routes/mod.rs create mode 100644 src/invite/routes/post.rs delete mode 100644 src/login/routes.rs create mode 100644 src/login/routes/login/mod.rs create mode 100644 src/login/routes/login/post.rs create mode 100644 src/login/routes/login/test.rs create mode 100644 src/login/routes/logout/mod.rs create mode 100644 src/login/routes/logout/post.rs create mode 100644 src/login/routes/logout/test.rs create mode 100644 src/login/routes/mod.rs delete mode 100644 src/login/routes/test/login.rs delete mode 100644 src/login/routes/test/logout.rs delete mode 100644 src/login/routes/test/mod.rs delete mode 100644 src/message/routes.rs create mode 100644 src/message/routes/message.rs create mode 100644 src/message/routes/mod.rs delete mode 100644 src/setup/routes.rs create mode 100644 src/setup/routes/mod.rs create mode 100644 src/setup/routes/post.rs delete mode 100644 src/ui.rs create mode 100644 src/ui/assets.rs create mode 100644 src/ui/error.rs create mode 100644 src/ui/middleware.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/routes/ch/channel.rs create mode 100644 src/ui/routes/ch/mod.rs create mode 100644 src/ui/routes/get.rs create mode 100644 src/ui/routes/invite/invite.rs create mode 100644 src/ui/routes/invite/mod.rs create mode 100644 src/ui/routes/login.rs create mode 100644 src/ui/routes/mod.rs create mode 100644 src/ui/routes/path.rs create mode 100644 src/ui/routes/setup.rs (limited to 'src') diff --git a/src/boot/routes.rs b/src/boot/routes.rs deleted file mode 100644 index 80f70bd..0000000 --- a/src/boot/routes.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::{ - extract::{Json, State}, - routing::get, - Router, -}; - -use super::Snapshot; -use crate::{app::App, error::Internal, login::Login}; - -#[cfg(test)] -mod test; - -pub fn router() -> Router { - Router::new().route("/api/boot", get(boot)) -} - -async fn boot(State(app): State, login: Login) -> Result, Internal> { - let snapshot = app.boot().snapshot().await?; - Ok(Boot { login, snapshot }.into()) -} - -#[derive(serde::Serialize)] -struct Boot { - login: Login, - #[serde(flatten)] - snapshot: Snapshot, -} diff --git a/src/boot/routes/get.rs b/src/boot/routes/get.rs new file mode 100644 index 0000000..737b479 --- /dev/null +++ b/src/boot/routes/get.rs @@ -0,0 +1,24 @@ +use axum::{ + extract::{Json, State}, + response::{self, IntoResponse}, +}; + +use crate::{app::App, boot::Snapshot, error::Internal, login::Login}; + +pub async fn handler(State(app): State, login: Login) -> Result { + let snapshot = app.boot().snapshot().await?; + Ok(Response { login, snapshot }) +} + +#[derive(serde::Serialize)] +pub struct Response { + pub login: Login, + #[serde(flatten)] + pub snapshot: Snapshot, +} + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + Json(self).into_response() + } +} diff --git a/src/boot/routes/mod.rs b/src/boot/routes/mod.rs new file mode 100644 index 0000000..e4d5ac8 --- /dev/null +++ b/src/boot/routes/mod.rs @@ -0,0 +1,11 @@ +use axum::{routing::get, Router}; + +use crate::app::App; + +mod get; +#[cfg(test)] +mod test; + +pub fn router() -> Router { + Router::new().route("/api/boot", get(get::handler)) +} diff --git a/src/boot/routes/test.rs b/src/boot/routes/test.rs index 5f2ba6f..4023753 100644 --- a/src/boot/routes/test.rs +++ b/src/boot/routes/test.rs @@ -1,12 +1,13 @@ -use axum::extract::{Json, State}; +use axum::extract::State; -use crate::{boot::routes, test::fixtures}; +use super::get; +use crate::test::fixtures; #[tokio::test] async fn returns_identity() { let app = fixtures::scratch_app().await; let login = fixtures::login::fictitious(); - let Json(response) = routes::boot(State(app), login.clone()) + let response = get::handler(State(app), login.clone()) .await .expect("boot always succeeds"); 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 - )); -} diff --git a/src/cli.rs b/src/cli.rs index ade61ae..0659851 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -164,7 +164,7 @@ fn started_msg(listener: &net::TcpListener) -> io::Result { #[error(transparent)] pub enum Error { /// Failure due to `io::Error`. See [`io::Error`]. - IoError(#[from] io::Error), + Io(#[from] io::Error), /// Failure due to a database initialization error. See [`db::Error`]. Database(#[from] db::Error), } diff --git a/src/error.rs b/src/error.rs index 85573d4..f3399c6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,15 +28,20 @@ where } } +impl fmt::Display for Internal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(id, _) = self; + writeln!(f, "internal server error")?; + writeln!(f, "error id: {id}")?; + Ok(()) + } +} + impl IntoResponse for Internal { fn into_response(self) -> Response { - let Self(id, error) = self; + let Self(id, error) = &self; eprintln!("hi: [{id}] {error}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("internal server error\nerror id: {id}"), - ) - .into_response() + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() } } diff --git a/src/event/routes.rs b/src/event/routes.rs deleted file mode 100644 index de6d248..0000000 --- a/src/event/routes.rs +++ /dev/null @@ -1,92 +0,0 @@ -use axum::{ - extract::State, - response::{ - sse::{self, Sse}, - IntoResponse, Response, - }, - routing::get, - Router, -}; -use axum_extra::extract::Query; -use futures::stream::{Stream, StreamExt as _}; - -use super::{extract::LastEventId, Event}; -use crate::{ - app::App, - error::{Internal, Unauthorized}, - event::{ResumePoint, Sequence, Sequenced as _}, - token::{app::ValidateError, extract::Identity}, -}; - -#[cfg(test)] -mod test; - -pub fn router() -> Router { - Router::new().route("/api/events", get(events)) -} - -#[derive(Default, serde::Deserialize)] -struct EventsQuery { - resume_point: ResumePoint, -} - -async fn events( - State(app): State, - identity: Identity, - last_event_id: Option>, - Query(query): Query, -) -> Result + std::fmt::Debug>, EventsError> { - let resume_at = last_event_id - .map(LastEventId::into_inner) - .or(query.resume_point); - - let stream = app.events().subscribe(resume_at).await?; - let stream = app.tokens().limit_stream(identity.token, stream).await?; - - Ok(Events(stream)) -} - -#[derive(Debug)] -struct Events(S); - -impl IntoResponse for Events -where - S: Stream + Send + 'static, -{ - fn into_response(self) -> Response { - let Self(stream) = self; - let stream = stream.map(sse::Event::try_from); - Sse::new(stream) - .keep_alive(sse::KeepAlive::default()) - .into_response() - } -} - -impl TryFrom for sse::Event { - type Error = serde_json::Error; - - fn try_from(event: Event) -> Result { - let id = serde_json::to_string(&event.sequence())?; - let data = serde_json::to_string_pretty(&event)?; - - let event = Self::default().id(id).data(data); - - Ok(event) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum EventsError { - DatabaseError(#[from] sqlx::Error), - ValidateError(#[from] ValidateError), -} - -impl IntoResponse for EventsError { - fn into_response(self) -> Response { - match self { - Self::ValidateError(ValidateError::InvalidToken) => Unauthorized.into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/event/routes/get.rs b/src/event/routes/get.rs new file mode 100644 index 0000000..357845a --- /dev/null +++ b/src/event/routes/get.rs @@ -0,0 +1,83 @@ +use axum::{ + extract::State, + response::{ + self, + sse::{self, Sse}, + IntoResponse, + }, +}; +use axum_extra::extract::Query; +use futures::stream::{Stream, StreamExt as _}; + +use crate::{ + app::App, + error::{Internal, Unauthorized}, + event::{extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, + token::{app::ValidateError, extract::Identity}, +}; + +pub async fn handler( + State(app): State, + identity: Identity, + last_event_id: Option>, + Query(query): Query, +) -> Result + std::fmt::Debug>, Error> { + let resume_at = last_event_id + .map(LastEventId::into_inner) + .or(query.resume_point); + + let stream = app.events().subscribe(resume_at).await?; + let stream = app.tokens().limit_stream(identity.token, stream).await?; + + Ok(Response(stream)) +} + +#[derive(Default, serde::Deserialize)] +pub struct QueryParams { + pub resume_point: ResumePoint, +} + +#[derive(Debug)] +pub struct Response(pub S); + +impl IntoResponse for Response +where + S: Stream + Send + 'static, +{ + fn into_response(self) -> response::Response { + let Self(stream) = self; + let stream = stream.map(sse::Event::try_from); + Sse::new(stream) + .keep_alive(sse::KeepAlive::default()) + .into_response() + } +} + +impl TryFrom for sse::Event { + type Error = serde_json::Error; + + fn try_from(event: Event) -> Result { + let id = serde_json::to_string(&event.sequence())?; + let data = serde_json::to_string_pretty(&event)?; + + let event = Self::default().id(id).data(data); + + Ok(event) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Database(#[from] sqlx::Error), + Validate(#[from] ValidateError), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::Validate(ValidateError::InvalidToken) => Unauthorized.into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/event/routes/mod.rs b/src/event/routes/mod.rs new file mode 100644 index 0000000..57ab9db --- /dev/null +++ b/src/event/routes/mod.rs @@ -0,0 +1,11 @@ +use axum::{routing::get, Router}; + +use crate::app::App; + +mod get; +#[cfg(test)] +mod test; + +pub fn router() -> Router { + Router::new().route("/api/events", get(get::handler)) +} diff --git a/src/event/routes/test.rs b/src/event/routes/test.rs index 209a016..249f5c2 100644 --- a/src/event/routes/test.rs +++ b/src/event/routes/test.rs @@ -5,8 +5,9 @@ use futures::{ stream::{self, StreamExt as _}, }; +use super::get; use crate::{ - event::{routes, Sequenced as _}, + event::Sequenced as _, test::fixtures::{self, future::Immediately as _}, }; @@ -23,7 +24,7 @@ async fn includes_historical_message() { let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; - let routes::Events(events) = routes::events(State(app), subscriber, None, Query::default()) + let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -50,8 +51,8 @@ async fn includes_live_message() { let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; - let routes::Events(events) = - routes::events(State(app.clone()), subscriber, None, Query::default()) + let get::Response(events) = + get::handler(State(app.clone()), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -96,7 +97,7 @@ async fn includes_multiple_channels() { let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; - let routes::Events(events) = routes::events(State(app), subscriber, None, Query::default()) + let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -134,7 +135,7 @@ async fn sequential_messages() { let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; - let routes::Events(events) = routes::events(State(app), subscriber, None, Query::default()) + let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -182,7 +183,7 @@ async fn resumes_from() { let resume_at = { // First subscription - let routes::Events(events) = routes::events( + let get::Response(events) = get::handler( State(app.clone()), subscriber.clone(), None, @@ -204,7 +205,7 @@ async fn resumes_from() { }; // Resume after disconnect - let routes::Events(resumed) = routes::events( + let get::Response(resumed) = get::handler( State(app), subscriber, Some(resume_at.into()), @@ -264,7 +265,7 @@ async fn serial_resume() { ]; // First subscription - let routes::Events(events) = routes::events( + let get::Response(events) = get::handler( State(app.clone()), subscriber.clone(), None, @@ -302,7 +303,7 @@ async fn serial_resume() { ]; // Second subscription - let routes::Events(events) = routes::events( + let get::Response(events) = get::handler( State(app.clone()), subscriber.clone(), Some(resume_at.into()), @@ -340,7 +341,7 @@ async fn serial_resume() { ]; // Third subscription - let routes::Events(events) = routes::events( + let get::Response(events) = get::handler( State(app.clone()), subscriber.clone(), Some(resume_at.into()), @@ -380,8 +381,8 @@ async fn terminates_on_token_expiry() { let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::ancient()).await; - let routes::Events(events) = - routes::events(State(app.clone()), subscriber, None, Query::default()) + let get::Response(events) = + get::handler(State(app.clone()), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -427,7 +428,7 @@ async fn terminates_on_logout() { let subscriber = fixtures::identity::from_token(&app, &subscriber_token, &fixtures::now()).await; - let routes::Events(events) = routes::events( + let get::Response(events) = get::handler( State(app.clone()), subscriber.clone(), None, diff --git a/src/invite/app.rs b/src/invite/app.rs index 6800d72..4162470 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -31,13 +31,9 @@ impl<'a> Invites<'a> { Ok(invite) } - pub async fn get(&self, invite: &Id) -> Result { + pub async fn get(&self, invite: &Id) -> Result, sqlx::Error> { let mut tx = self.db.begin().await?; - let invite = tx - .invites() - .summary(invite) - .await - .not_found(|| Error::NotFound(invite.clone()))?; + let invite = tx.invites().summary(invite).await.optional()?; tx.commit().await?; Ok(invite) @@ -91,14 +87,6 @@ impl<'a> Invites<'a> { } } -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("invite not found: {0}")] - NotFound(Id), - #[error(transparent)] - Database(#[from] sqlx::Error), -} - #[derive(Debug, thiserror::Error)] pub enum AcceptError { #[error("invite not found: {0}")] diff --git a/src/invite/routes.rs b/src/invite/routes.rs deleted file mode 100644 index 977fe9b..0000000 --- a/src/invite/routes.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post}, - Router, -}; - -use super::{app, Id, Invite, Summary}; -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - login::{Login, Password}, - token::extract::IdentityToken, -}; - -pub fn router() -> Router { - Router::new() - .route("/api/invite", post(on_invite)) - .route("/api/invite/:invite", get(invite)) - .route("/api/invite/:invite", post(on_accept)) -} - -#[derive(serde::Deserialize)] -struct InviteRequest {} - -async fn on_invite( - State(app): State, - RequestedAt(issued_at): RequestedAt, - login: Login, - // Require `{}` as the only valid request for this endpoint. - _: Json, -) -> Result, Internal> { - let invite = app.invites().create(&login, &issued_at).await?; - Ok(Json(invite)) -} - -async fn invite( - State(app): State, - Path(invite): Path, -) -> Result, InviteError> { - app.invites() - .get(&invite) - .await - .map(Json) - .map_err(InviteError) -} - -struct InviteError(app::Error); - -impl IntoResponse for InviteError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - error @ app::Error::NotFound(_) => NotFound(error).into_response(), - other => Internal::from(other).into_response(), - } - } -} - -#[derive(serde::Deserialize)] -struct AcceptRequest { - name: String, - password: Password, -} - -async fn on_accept( - State(app): State, - RequestedAt(accepted_at): RequestedAt, - identity: IdentityToken, - Path(invite): Path, - Json(request): Json, -) -> Result<(IdentityToken, StatusCode), AcceptError> { - let secret = app - .invites() - .accept(&invite, &request.name, &request.password, &accepted_at) - .await - .map_err(AcceptError)?; - let identity = identity.set(secret); - Ok((identity, StatusCode::NO_CONTENT)) -} - -struct AcceptError(app::AcceptError); - -impl IntoResponse for AcceptError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - error @ app::AcceptError::NotFound(_) => NotFound(error).into_response(), - error @ app::AcceptError::DuplicateLogin(_) => { - (StatusCode::CONFLICT, error.to_string()).into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/invite/routes/invite/get.rs b/src/invite/routes/invite/get.rs new file mode 100644 index 0000000..c8b52f1 --- /dev/null +++ b/src/invite/routes/invite/get.rs @@ -0,0 +1,39 @@ +use axum::{ + extract::{Json, Path, State}, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + error::{Internal, NotFound}, + invite::{Id, Summary}, +}; + +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 { + #[allow(clippy::match_wildcard_for_single_variants)] + match self { + Self::NotFound(_) => NotFound(self).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/invite/routes/invite/mod.rs b/src/invite/routes/invite/mod.rs new file mode 100644 index 0000000..04593fd --- /dev/null +++ b/src/invite/routes/invite/mod.rs @@ -0,0 +1,4 @@ +pub mod get; +pub mod post; + +type PathInfo = crate::invite::Id; diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs new file mode 100644 index 0000000..12c2e21 --- /dev/null +++ b/src/invite/routes/invite/post.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + invite::app, + login::Password, + token::extract::IdentityToken, +}; + +pub async fn handler( + State(app): State, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityToken, + Path(invite): Path, + Json(request): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + let secret = app + .invites() + .accept(&invite, &request.name, &request.password, &accepted_at) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, StatusCode::NO_CONTENT)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, + pub password: Password, +} + +pub struct Error(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::DuplicateLogin(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/invite/routes/mod.rs b/src/invite/routes/mod.rs new file mode 100644 index 0000000..dae20ba --- /dev/null +++ b/src/invite/routes/mod.rs @@ -0,0 +1,16 @@ +use axum::{ + routing::{get, post}, + Router, +}; + +use crate::app::App; + +mod invite; +mod post; + +pub fn router() -> Router { + Router::new() + .route("/api/invite", post(post::handler)) + .route("/api/invite/:invite", get(invite::get::handler)) + .route("/api/invite/:invite", post(invite::post::handler)) +} diff --git a/src/invite/routes/post.rs b/src/invite/routes/post.rs new file mode 100644 index 0000000..80b1c27 --- /dev/null +++ b/src/invite/routes/post.rs @@ -0,0 +1,17 @@ +use axum::extract::{Json, State}; + +use crate::{app::App, clock::RequestedAt, error::Internal, invite::Invite, login::Login}; + +pub async fn handler( + State(app): State, + RequestedAt(issued_at): RequestedAt, + login: Login, + // Require `{}` as the only valid request for this endpoint. + _: Json, +) -> Result, Internal> { + let invite = app.invites().create(&login, &issued_at).await?; + Ok(Json(invite)) +} + +#[derive(Default, serde::Deserialize)] +pub struct Request {} diff --git a/src/login/app.rs b/src/login/app.rs index bb1419b..b6f7e1c 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -39,6 +39,6 @@ impl<'a> Logins<'a> { #[derive(Debug, thiserror::Error)] #[error(transparent)] pub enum CreateError { - DatabaseError(#[from] sqlx::Error), - PasswordHashError(#[from] password_hash::Error), + Database(#[from] sqlx::Error), + PasswordHash(#[from] password_hash::Error), } diff --git a/src/login/routes.rs b/src/login/routes.rs deleted file mode 100644 index 6579ae6..0000000 --- a/src/login/routes.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::post, - Router, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, Unauthorized}, - login::Password, - token::{app, extract::IdentityToken}, -}; - -#[cfg(test)] -mod test; - -pub fn router() -> Router { - Router::new() - .route("/api/auth/login", post(on_login)) - .route("/api/auth/logout", post(on_logout)) -} - -#[derive(serde::Deserialize)] -struct LoginRequest { - name: String, - password: Password, -} - -async fn on_login( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityToken, - Json(request): Json, -) -> Result<(IdentityToken, StatusCode), LoginError> { - let token = app - .tokens() - .login(&request.name, &request.password, &now) - .await - .map_err(LoginError)?; - let identity = identity.set(token); - Ok((identity, StatusCode::NO_CONTENT)) -} - -#[derive(Debug)] -struct LoginError(app::LoginError); - -impl IntoResponse for LoginError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - // not error::Unauthorized due to differing messaging - (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} - -#[derive(serde::Deserialize)] -struct LogoutRequest {} - -async fn on_logout( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityToken, - // This forces the only valid request to be `{}`, and not the infinite - // variation allowed when there's no body extractor. - Json(LogoutRequest {}): Json, -) -> Result<(IdentityToken, StatusCode), LogoutError> { - if let Some(secret) = identity.secret() { - let (token, _) = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&token).await?; - } - - let identity = identity.clear(); - Ok((identity, StatusCode::NO_CONTENT)) -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -enum LogoutError { - ValidateError(#[from] app::ValidateError), - DatabaseError(#[from] sqlx::Error), -} - -impl IntoResponse for LogoutError { - fn into_response(self) -> Response { - match self { - Self::ValidateError(app::ValidateError::InvalidToken) => Unauthorized.into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/login/routes/login/mod.rs b/src/login/routes/login/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/login/routes/login/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs new file mode 100644 index 0000000..e33acad --- /dev/null +++ b/src/login/routes/login/post.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::Internal, + login::Password, + token::{app, extract::IdentityToken}, +}; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(request): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + let token = app + .tokens() + .login(&request.name, &request.password, &now) + .await + .map_err(Error)?; + let identity = identity.set(token); + Ok((identity, StatusCode::NO_CONTENT)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + // not error::Unauthorized due to differing messaging + (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/routes/login/test.rs b/src/login/routes/login/test.rs new file mode 100644 index 0000000..d431612 --- /dev/null +++ b/src/login/routes/login/test.rs @@ -0,0 +1,129 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use super::post; +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn correct_credentials() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = post::Request { + name: name.clone(), + password, + }; + let (identity, status) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + assert_eq!(StatusCode::NO_CONTENT, status); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let validated_at = fixtures::now(); + let (_, validated_login) = app + .tokens() + .validate(&secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(name, validated_login.name); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::login::propose(); + let request = post::Request { + name: name.clone(), + password, + }; + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn incorrect_password() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::identity::not_logged_in(); + let request = post::Request { + name: login.name, + password: fixtures::login::propose_password(), + }; + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::identity::not_logged_in(); + let request = post::Request { name, password }; + let (identity, _) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let expired_at = fixtures::now(); + app.tokens() + .expire(&expired_at) + .await + .expect("expiring tokens never fails"); + + let verified_at = fixtures::now(); + let error = app + .tokens() + .validate(&secret, &verified_at) + .await + .expect_err("validating an expired token"); + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/routes/logout/mod.rs b/src/login/routes/logout/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/login/routes/logout/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/login/routes/logout/post.rs b/src/login/routes/logout/post.rs new file mode 100644 index 0000000..6b7a62a --- /dev/null +++ b/src/login/routes/logout/post.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, Unauthorized}, + token::{app, extract::IdentityToken}, +}; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(_): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + if let Some(secret) = identity.secret() { + let (token, _) = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&token).await?; + } + + let identity = identity.clear(); + Ok((identity, StatusCode::NO_CONTENT)) +} + +// This forces the only valid request to be `{}`, and not the infinite +// variation allowed when there's no body extractor. +#[derive(Default, serde::Deserialize)] +pub struct Request {} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::ValidateError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::ValidateError::InvalidToken => Unauthorized.into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/login/routes/logout/test.rs b/src/login/routes/logout/test.rs new file mode 100644 index 0000000..0e70e4c --- /dev/null +++ b/src/login/routes/logout/test.rs @@ -0,0 +1,80 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use super::post; +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let login = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let identity = fixtures::identity::logged_in(&app, &login, &now).await; + let secret = fixtures::identity::secret(&identity); + + // Call the endpoint + + let (response_identity, response_status) = post::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + Json::default(), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + + assert!(response_identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, response_status); + + // Verify the semantics + + let error = app + .tokens() + .validate(&secret, &now) + .await + .expect_err("secret is invalid"); + assert!(matches!(error, app::ValidateError::InvalidToken)); +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::not_logged_in(); + let (identity, status) = post::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect("logged out with no token"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, status); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::identity::fictitious(); + let post::Error(error) = post::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect_err("logged out with an invalid token"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/login/routes/mod.rs b/src/login/routes/mod.rs new file mode 100644 index 0000000..8cb8852 --- /dev/null +++ b/src/login/routes/mod.rs @@ -0,0 +1,12 @@ +use axum::{routing::post, Router}; + +use crate::app::App; + +mod login; +mod logout; + +pub fn router() -> Router { + Router::new() + .route("/api/auth/login", post(login::post::handler)) + .route("/api/auth/logout", post(logout::post::handler)) +} diff --git a/src/login/routes/test/login.rs b/src/login/routes/test/login.rs deleted file mode 100644 index 68c92de..0000000 --- a/src/login/routes/test/login.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; - -use crate::{login::routes, test::fixtures, token::app}; - -#[tokio::test] -async fn correct_credentials() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let logged_in_at = fixtures::now(); - let request = routes::LoginRequest { - name: name.clone(), - password, - }; - let (identity, status) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - - // Verify the return value's basic structure - - assert_eq!(StatusCode::NO_CONTENT, status); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let validated_at = fixtures::now(); - let (_, validated_login) = app - .tokens() - .validate(&secret, &validated_at) - .await - .expect("identity secret is valid"); - - assert_eq!(name, validated_login.name); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let logged_in_at = fixtures::now(); - let (name, password) = fixtures::login::propose(); - let request = routes::LoginRequest { - name: name.clone(), - password, - }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn incorrect_password() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::now(); - let identity = fixtures::identity::not_logged_in(); - let request = routes::LoginRequest { - name: login.name, - password: fixtures::login::propose_password(), - }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn token_expires() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::ancient(); - let identity = fixtures::identity::not_logged_in(); - let request = routes::LoginRequest { name, password }; - let (identity, _) = routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let expired_at = fixtures::now(); - app.tokens() - .expire(&expired_at) - .await - .expect("expiring tokens never fails"); - - let verified_at = fixtures::now(); - let error = app - .tokens() - .validate(&secret, &verified_at) - .await - .expect_err("validating an expired token"); - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/login/routes/test/logout.rs b/src/login/routes/test/logout.rs deleted file mode 100644 index 611829e..0000000 --- a/src/login/routes/test/logout.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; - -use crate::{login::routes, test::fixtures, token::app}; - -#[tokio::test] -async fn successful() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let now = fixtures::now(); - let login = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let identity = fixtures::identity::logged_in(&app, &login, &now).await; - let secret = fixtures::identity::secret(&identity); - - // Call the endpoint - - let (response_identity, response_status) = routes::on_logout( - State(app.clone()), - fixtures::now(), - identity.clone(), - Json(routes::LogoutRequest {}), - ) - .await - .expect("logged out with a valid token"); - - // Verify the return value's basic structure - - assert!(response_identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, response_status); - - // Verify the semantics - - let error = app - .tokens() - .validate(&secret, &now) - .await - .expect_err("secret is invalid"); - match error { - app::ValidateError::InvalidToken => (), // should be invalid - other @ app::ValidateError::DatabaseError(_) => { - panic!("expected ValidateError::InvalidToken, got {other:#}") - } - } -} - -#[tokio::test] -async fn no_identity() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::not_logged_in(); - let (identity, status) = routes::on_logout( - State(app), - fixtures::now(), - identity, - Json(routes::LogoutRequest {}), - ) - .await - .expect("logged out with no token"); - - // Verify the return value's basic structure - - assert!(identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, status); -} - -#[tokio::test] -async fn invalid_token() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::identity::fictitious(); - let error = routes::on_logout( - State(app), - fixtures::now(), - identity, - Json(routes::LogoutRequest {}), - ) - .await - .expect_err("logged out with an invalid token"); - - // Verify the return value's basic structure - - assert!(matches!( - error, - routes::LogoutError::ValidateError(app::ValidateError::InvalidToken) - )); -} diff --git a/src/login/routes/test/mod.rs b/src/login/routes/test/mod.rs deleted file mode 100644 index 90522c4..0000000 --- a/src/login/routes/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod login; -mod logout; diff --git a/src/message/app.rs b/src/message/app.rs index 3385af2..c1bcde6 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -98,7 +98,7 @@ pub enum SendError { #[error("channel {0} not found")] ChannelNotFound(channel::Id), #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), } #[derive(Debug, thiserror::Error)] @@ -108,5 +108,5 @@ pub enum DeleteError { #[error("message {0} not found")] NotFound(Id), #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), } diff --git a/src/message/routes.rs b/src/message/routes.rs deleted file mode 100644 index e21c674..0000000 --- a/src/message/routes.rs +++ /dev/null @@ -1,46 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::delete, - Router, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - login::Login, - message::{self, app::DeleteError}, -}; - -pub fn router() -> Router { - Router::new().route("/api/messages/:message", delete(on_delete)) -} - -async fn on_delete( - State(app): State, - Path(message): Path, - RequestedAt(deleted_at): RequestedAt, - _: Login, -) -> Result { - app.messages().delete(&message, &deleted_at).await?; - - Ok(StatusCode::ACCEPTED) -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -struct ErrorResponse(#[from] DeleteError); - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - not_found @ (DeleteError::ChannelNotFound(_) | DeleteError::NotFound(_)) => { - NotFound(not_found).into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/message/routes/message.rs b/src/message/routes/message.rs new file mode 100644 index 0000000..059b8c1 --- /dev/null +++ b/src/message/routes/message.rs @@ -0,0 +1,43 @@ +pub mod delete { + use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + }; + + use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + login::Login, + message::{self, app::DeleteError}, + }; + + pub async fn handler( + State(app): State, + Path(message): Path, + RequestedAt(deleted_at): RequestedAt, + _: Login, + ) -> Result { + app.messages().delete(&message, &deleted_at).await?; + + Ok(StatusCode::ACCEPTED) + } + + #[derive(Debug, thiserror::Error)] + #[error(transparent)] + pub struct Error(#[from] pub DeleteError); + + impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + DeleteError::ChannelNotFound(_) | DeleteError::NotFound(_) => { + NotFound(error).into_response() + } + other => Internal::from(other).into_response(), + } + } + } +} diff --git a/src/message/routes/mod.rs b/src/message/routes/mod.rs new file mode 100644 index 0000000..dfe8628 --- /dev/null +++ b/src/message/routes/mod.rs @@ -0,0 +1,9 @@ +use axum::{routing::delete, Router}; + +use crate::app::App; + +mod message; + +pub fn router() -> Router { + Router::new().route("/api/messages/:message", delete(message::delete::handler)) +} diff --git a/src/setup/routes.rs b/src/setup/routes.rs deleted file mode 100644 index ff41734..0000000 --- a/src/setup/routes.rs +++ /dev/null @@ -1,50 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::post, - Router, -}; - -use super::app; -use crate::{ - app::App, clock::RequestedAt, error::Internal, login::Password, token::extract::IdentityToken, -}; - -pub fn router() -> Router { - Router::new().route("/api/setup", post(on_setup)) -} - -#[derive(serde::Deserialize)] -struct SetupRequest { - name: String, - password: Password, -} - -async fn on_setup( - State(app): State, - RequestedAt(setup_at): RequestedAt, - identity: IdentityToken, - Json(request): Json, -) -> Result<(IdentityToken, StatusCode), SetupError> { - let secret = app - .setup() - .initial(&request.name, &request.password, &setup_at) - .await - .map_err(SetupError)?; - let identity = identity.set(secret); - Ok((identity, StatusCode::NO_CONTENT)) -} - -#[derive(Debug)] -struct SetupError(app::Error); - -impl IntoResponse for SetupError { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/setup/routes/mod.rs b/src/setup/routes/mod.rs new file mode 100644 index 0000000..e1e1711 --- /dev/null +++ b/src/setup/routes/mod.rs @@ -0,0 +1,9 @@ +use axum::{routing::post, Router}; + +use crate::app::App; + +mod post; + +pub fn router() -> Router { + Router::new().route("/api/setup", post(post::handler)) +} diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs new file mode 100644 index 0000000..9e6776f --- /dev/null +++ b/src/setup/routes/post.rs @@ -0,0 +1,44 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, clock::RequestedAt, error::Internal, login::Password, setup::app, + token::extract::IdentityToken, +}; + +pub async fn handler( + State(app): State, + RequestedAt(setup_at): RequestedAt, + identity: IdentityToken, + Json(request): Json, +) -> Result<(IdentityToken, StatusCode), Error> { + let secret = app + .setup() + .initial(&request.name, &request.password, &setup_at) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, StatusCode::NO_CONTENT)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, + pub password: Password, +} + +#[derive(Debug)] +pub struct Error(pub app::Error); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/token/app.rs b/src/token/app.rs index 15fd858..cb5d75f 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -158,9 +158,9 @@ pub enum LoginError { #[error("invalid login")] Rejected, #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), #[error(transparent)] - PasswordHashError(#[from] password_hash::Error), + PasswordHash(#[from] password_hash::Error), } #[derive(Debug, thiserror::Error)] @@ -168,7 +168,7 @@ pub enum ValidateError { #[error("invalid token")] InvalidToken, #[error(transparent)] - DatabaseError(#[from] sqlx::Error), + Database(#[from] sqlx::Error), } #[derive(Debug)] diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 91d0eb8..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,134 +0,0 @@ -use axum::{ - extract::{Path, Request, State}, - http::{header, StatusCode}, - middleware::{self, Next}, - response::{IntoResponse, Redirect, Response}, - routing::get, - Router, -}; -use mime_guess::Mime; -use rust_embed::EmbeddedFile; - -use crate::{app::App, channel, error::Internal, invite, login::Login}; - -#[derive(rust_embed::Embed)] -#[folder = "target/ui"] -struct Assets; - -impl Assets { - fn load(path: impl AsRef) -> Result> { - let path = path.as_ref(); - let mime = mime_guess::from_path(path).first_or_octet_stream(); - - Self::get(path) - .map(|file| Asset(mime, file)) - .ok_or(NotFound(format!("not found: {path}"))) - } - - fn index() -> Result { - // "not found" in this case really is an internal error, as it should - // never happen. `index.html` is a known-valid path. - Ok(Self::load("index.html")?) - } -} - -pub fn router(app: &App) -> Router { - [ - Router::new() - .route("/*path", get(asset)) - .route("/setup", get(setup)), - Router::new() - .route("/", get(root)) - .route("/login", get(login)) - .route("/ch/:channel", get(channel)) - .route("/invite/:invite", get(invite)) - .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), - ] - .into_iter() - .fold(Router::default(), Router::merge) -} - -async fn asset(Path(path): Path) -> Result> { - Assets::load(path) -} - -async fn root(login: Option) -> Result { - if login.is_none() { - Ok(Redirect::temporary("/login").into_response()) - } else { - Ok(Assets::index()?.into_response()) - } -} - -async fn login() -> Result { - Assets::index() -} - -async fn setup(State(app): State) -> Result { - if app.setup().completed().await? { - Ok(Redirect::to("/login").into_response()) - } else { - Ok(Assets::index().into_response()) - } -} - -async fn channel( - State(app): State, - login: Option, - Path(channel): Path, -) -> Result { - if login.is_none() { - Ok(Redirect::temporary("/").into_response()) - } else if app.channels().get(&channel).await?.is_none() { - Ok(NotFound(Assets::index()?).into_response()) - } else { - Ok(Assets::index()?.into_response()) - } -} - -async fn invite( - State(app): State, - Path(invite): Path, -) -> Result { - match app.invites().get(&invite).await { - Ok(_) => Ok(Assets::index()?.into_response()), - Err(invite::app::Error::NotFound(_)) => Ok(NotFound(Assets::index()?).into_response()), - Err(other) => Err(Internal::from(other)), - } -} - -struct Asset(Mime, EmbeddedFile); - -impl IntoResponse for Asset { - fn into_response(self) -> Response { - let Self(mime, file) = self; - ( - StatusCode::OK, - [(header::CONTENT_TYPE, mime.as_ref())], - file.data, - ) - .into_response() - } -} - -#[derive(Debug, thiserror::Error)] -#[error("{0}")] -struct NotFound(pub E); - -impl IntoResponse for NotFound -where - E: IntoResponse, -{ - fn into_response(self) -> Response { - let Self(response) = self; - (StatusCode::NOT_FOUND, response).into_response() - } -} - -pub async fn setup_required(State(app): State, request: Request, next: Next) -> Response { - match app.setup().completed().await { - Ok(true) => next.run(request).await, - Ok(false) => Redirect::to("/setup").into_response(), - Err(error) => Internal::from(error).into_response(), - } -} diff --git a/src/ui/assets.rs b/src/ui/assets.rs new file mode 100644 index 0000000..342ba59 --- /dev/null +++ b/src/ui/assets.rs @@ -0,0 +1,43 @@ +use axum::{ + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use mime_guess::Mime; +use rust_embed::EmbeddedFile; + +use crate::{error::Internal, ui::error::NotFound}; + +#[derive(rust_embed::Embed)] +#[folder = "target/ui"] +pub struct Assets; + +impl Assets { + pub fn load(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let mime = mime_guess::from_path(path).first_or_octet_stream(); + + Self::get(path) + .map(|file| Asset(mime, file)) + .ok_or(NotFound(format!("not found: {path}"))) + } + + pub fn index() -> Result { + // "not found" in this case really is an internal error, as it should + // never happen. `index.html` is a known-valid path. + Ok(Self::load("index.html")?) + } +} + +pub struct Asset(Mime, EmbeddedFile); + +impl IntoResponse for Asset { + fn into_response(self) -> Response { + let Self(mime, file) = self; + ( + StatusCode::OK, + [(header::CONTENT_TYPE, mime.as_ref())], + file.data, + ) + .into_response() + } +} diff --git a/src/ui/error.rs b/src/ui/error.rs new file mode 100644 index 0000000..2dc627f --- /dev/null +++ b/src/ui/error.rs @@ -0,0 +1,18 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct NotFound(pub E); + +impl IntoResponse for NotFound +where + E: IntoResponse, +{ + fn into_response(self) -> Response { + let Self(response) = self; + (StatusCode::NOT_FOUND, response).into_response() + } +} diff --git a/src/ui/middleware.rs b/src/ui/middleware.rs new file mode 100644 index 0000000..f60ee1c --- /dev/null +++ b/src/ui/middleware.rs @@ -0,0 +1,15 @@ +use axum::{ + extract::{Request, State}, + middleware::Next, + response::{IntoResponse, Redirect, Response}, +}; + +use crate::{app::App, error::Internal}; + +pub async fn setup_required(State(app): State, request: Request, next: Next) -> Response { + match app.setup().completed().await { + Ok(true) => next.run(request).await, + Ok(false) => Redirect::to("/setup").into_response(), + Err(error) => Internal::from(error).into_response(), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..c145382 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,6 @@ +mod assets; +mod error; +mod middleware; +mod routes; + +pub use self::routes::router; diff --git a/src/ui/routes/ch/channel.rs b/src/ui/routes/ch/channel.rs new file mode 100644 index 0000000..353d000 --- /dev/null +++ b/src/ui/routes/ch/channel.rs @@ -0,0 +1,61 @@ +pub mod get { + use axum::{ + extract::{Path, State}, + response::{self, IntoResponse, Redirect}, + }; + + use crate::{ + app::App, + channel, + error::Internal, + login::Login, + ui::{ + assets::{Asset, Assets}, + error::NotFound, + }, + }; + + pub async fn handler( + State(app): State, + login: Option, + Path(channel): Path, + ) -> Result { + login.ok_or(Error::NotLoggedIn)?; + app.channels() + .get(&channel) + .await + .map_err(Error::internal)? + .ok_or(Error::NotFound)?; + + Assets::index().map_err(Error::Internal) + } + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("requested channel not found")] + NotFound, + #[error("not logged in")] + NotLoggedIn, + #[error("{0}")] + Internal(Internal), + } + + impl Error { + fn internal(err: impl Into) -> Self { + Self::Internal(err.into()) + } + } + + impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotFound => match Assets::index() { + Ok(asset) => NotFound(asset).into_response(), + Err(internal) => internal.into_response(), + }, + Self::NotLoggedIn => Redirect::temporary("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } + } +} diff --git a/src/ui/routes/ch/mod.rs b/src/ui/routes/ch/mod.rs new file mode 100644 index 0000000..ff02972 --- /dev/null +++ b/src/ui/routes/ch/mod.rs @@ -0,0 +1 @@ +pub mod channel; diff --git a/src/ui/routes/get.rs b/src/ui/routes/get.rs new file mode 100644 index 0000000..97737e1 --- /dev/null +++ b/src/ui/routes/get.rs @@ -0,0 +1,30 @@ +use axum::response::{self, IntoResponse, Redirect}; + +use crate::{ + error::Internal, + login::Login, + ui::assets::{Asset, Assets}, +}; + +pub async fn handler(login: Option) -> Result { + login.ok_or(Error::NotLoggedIn)?; + + Assets::index().map_err(Error::Internal) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not logged in")] + NotLoggedIn, + #[error("{0}")] + Internal(Internal), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotLoggedIn => Redirect::temporary("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/routes/invite/invite.rs b/src/ui/routes/invite/invite.rs new file mode 100644 index 0000000..06e5792 --- /dev/null +++ b/src/ui/routes/invite/invite.rs @@ -0,0 +1,55 @@ +pub mod get { + use axum::{ + extract::{Path, State}, + response::{self, IntoResponse}, + }; + + use crate::{ + app::App, + error::Internal, + invite, + ui::{ + assets::{Asset, Assets}, + error::NotFound, + }, + }; + + pub async fn handler( + State(app): State, + Path(invite): Path, + ) -> Result { + app.invites() + .get(&invite) + .await + .map_err(Error::internal)? + .ok_or(Error::NotFound)?; + + Assets::index().map_err(Error::Internal) + } + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("invite not found")] + NotFound, + #[error("{0}")] + Internal(Internal), + } + + impl Error { + fn internal(err: impl Into) -> Self { + Self::Internal(err.into()) + } + } + + impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotFound => match Assets::index() { + Ok(asset) => NotFound(asset).into_response(), + Err(internal) => internal.into_response(), + }, + Self::Internal(error) => error.into_response(), + } + } + } +} diff --git a/src/ui/routes/invite/mod.rs b/src/ui/routes/invite/mod.rs new file mode 100644 index 0000000..50af8be --- /dev/null +++ b/src/ui/routes/invite/mod.rs @@ -0,0 +1,4 @@ +// In this case, the first redundant `invite` is a literal path segment, and the +// second `invite` reflects a placeholder. +#[allow(clippy::module_inception)] +pub mod invite; diff --git a/src/ui/routes/login.rs b/src/ui/routes/login.rs new file mode 100644 index 0000000..81a874c --- /dev/null +++ b/src/ui/routes/login.rs @@ -0,0 +1,11 @@ +pub mod get { + use crate::{ + error::Internal, + ui::assets::{Asset, Assets}, + }; + + #[allow(clippy::unused_async)] + pub async fn handler() -> Result { + Assets::index() + } +} diff --git a/src/ui/routes/mod.rs b/src/ui/routes/mod.rs new file mode 100644 index 0000000..72d9a4a --- /dev/null +++ b/src/ui/routes/mod.rs @@ -0,0 +1,26 @@ +use axum::{middleware, routing::get, Router}; + +use crate::{app::App, ui::middleware::setup_required}; + +mod ch; +mod get; +mod invite; +mod login; +mod path; +mod setup; + +pub fn router(app: &App) -> Router { + [ + Router::new() + .route("/*path", get(path::get::handler)) + .route("/setup", get(setup::get::handler)), + Router::new() + .route("/", get(get::handler)) + .route("/login", get(login::get::handler)) + .route("/ch/:channel", get(ch::channel::get::handler)) + .route("/invite/:invite", get(invite::invite::get::handler)) + .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), + ] + .into_iter() + .fold(Router::default(), Router::merge) +} diff --git a/src/ui/routes/path.rs b/src/ui/routes/path.rs new file mode 100644 index 0000000..2e9a657 --- /dev/null +++ b/src/ui/routes/path.rs @@ -0,0 +1,12 @@ +pub mod get { + use axum::extract::Path; + + use crate::ui::{ + assets::{Asset, Assets}, + error::NotFound, + }; + + pub async fn handler(Path(path): Path) -> Result> { + Assets::load(path) + } +} diff --git a/src/ui/routes/setup.rs b/src/ui/routes/setup.rs new file mode 100644 index 0000000..649cc5f --- /dev/null +++ b/src/ui/routes/setup.rs @@ -0,0 +1,43 @@ +pub mod get { + use axum::{ + extract::State, + response::{self, IntoResponse, Redirect}, + }; + + use crate::{ + app::App, + error::Internal, + ui::assets::{Asset, Assets}, + }; + + pub async fn handler(State(app): State) -> Result { + if app + .setup() + .completed() + .await + .map_err(Internal::from) + .map_err(Error::Internal)? + { + Err(Error::SetupCompleted) + } else { + Assets::index().map_err(Error::Internal) + } + } + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("setup already completed")] + SetupCompleted, + #[error("{0}")] + Internal(Internal), + } + + impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::SetupCompleted => Redirect::to("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } + } +} -- cgit v1.2.3