diff options
Diffstat (limited to 'src')
57 files changed, 963 insertions, 696 deletions
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<App> { - Router::new().route("/api/boot", get(boot)) -} - -async fn boot(State(app): State<App>, login: Login) -> Result<Json<Boot>, 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<App>, login: Login) -> Result<Response, Internal> { + 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<App> { + 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<App> { - 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<App>, - _: Login, // requires auth, but doesn't actually care who you are - RequestedAt(created_at): RequestedAt, - Json(form): Json<CreateRequest>, -) -> Result<Json<Channel>, 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<App>, - Path(channel): Path<Id>, - RequestedAt(sent_at): RequestedAt, - login: Login, - Json(request): Json<SendRequest>, -) -> Result<StatusCode, SendErrorResponse> { - 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<App>, - Path(channel): Path<Id>, - RequestedAt(deleted_at): RequestedAt, - _: Login, -) -> Result<StatusCode, ErrorResponse> { - 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<App>, + Path(channel): Path<super::PathInfo>, + RequestedAt(deleted_at): RequestedAt, + _: Login, +) -> Result<StatusCode, Error> { + 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<App>, + Path(channel): Path<super::PathInfo>, + RequestedAt(sent_at): RequestedAt, + login: Login, + Json(request): Json<Request>, +) -> Result<StatusCode, Error> { + 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/test/on_send.rs b/src/channel/routes/channel/test.rs index 293cc56..bc02b20 100644 --- a/src/channel/routes/test/on_send.rs +++ b/src/channel/routes/channel/test.rs @@ -1,9 +1,9 @@ use axum::extract::{Json, Path, State}; use futures::stream::StreamExt; +use super::post; use crate::{ channel, - channel::routes, event::{self, Sequenced}, message::{self, app::SendError}, test::fixtures::{self, future::Immediately as _}, @@ -25,14 +25,14 @@ async fn messages_in_order() { ]; for (sent_at, body) in &requests { - let request = routes::SendRequest { body: body.clone() }; + let request = post::Request { body: body.clone() }; - routes::on_send( + post::handler( State(app.clone()), Path(channel.id.clone()), sent_at.clone(), sender.clone(), - Json(request.clone()), + Json(request), ) .await .expect("sending to a valid channel"); @@ -72,10 +72,10 @@ async fn nonexistent_channel() { let sent_at = fixtures::now(); let channel = channel::Id::generate(); - let request = routes::SendRequest { + let request = post::Request { body: fixtures::message::propose(), }; - let routes::SendErrorResponse(error) = routes::on_send( + let post::Error(error) = post::handler( State(app), Path(channel.clone()), sent_at, 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<App> { + 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<App>, + _: Login, // requires auth, but doesn't actually care who you are + RequestedAt(created_at): RequestedAt, + Json(request): Json<Request>, +) -> Result<Json<Channel>, 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/on_create.rs b/src/channel/routes/test.rs index eeecc7f..81f1465 100644 --- a/src/channel/routes/test/on_create.rs +++ b/src/channel/routes/test.rs @@ -1,8 +1,9 @@ use axum::extract::{Json, State}; use futures::stream::StreamExt as _; +use super::post; use crate::{ - channel::{self, app, routes}, + channel::{self, app}, event, test::fixtures::{self, future::Immediately as _}, }; @@ -17,19 +18,15 @@ async fn new_channel() { // 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"); + 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!(request.name, response_channel.name); + assert_eq!(name, response_channel.name); // Verify the semantics @@ -69,20 +66,18 @@ async fn duplicate_name() { // 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"); + 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 request.name == name + 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; @@ -164,7 +164,7 @@ fn started_msg(listener: &net::TcpListener) -> io::Result<String> { #[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/get.rs index de6d248..357845a 100644 --- a/src/event/routes.rs +++ b/src/event/routes/get.rs @@ -1,41 +1,27 @@ use axum::{ extract::State, response::{ + self, sse::{self, Sse}, - IntoResponse, Response, + IntoResponse, }, - 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 _}, + event::{extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, token::{app::ValidateError, extract::Identity}, }; -#[cfg(test)] -mod test; - -pub fn router() -> Router<App> { - Router::new().route("/api/events", get(events)) -} - -#[derive(Default, serde::Deserialize)] -struct EventsQuery { - resume_point: ResumePoint, -} - -async fn events( +pub async fn handler( State(app): State<App>, identity: Identity, last_event_id: Option<LastEventId<Sequence>>, - Query(query): Query<EventsQuery>, -) -> Result<Events<impl Stream<Item = Event> + std::fmt::Debug>, EventsError> { + Query(query): Query<QueryParams>, +) -> Result<Response<impl Stream<Item = Event> + std::fmt::Debug>, Error> { let resume_at = last_event_id .map(LastEventId::into_inner) .or(query.resume_point); @@ -43,17 +29,22 @@ async fn events( let stream = app.events().subscribe(resume_at).await?; let stream = app.tokens().limit_stream(identity.token, stream).await?; - Ok(Events(stream)) + Ok(Response(stream)) +} + +#[derive(Default, serde::Deserialize)] +pub struct QueryParams { + pub resume_point: ResumePoint, } #[derive(Debug)] -struct Events<S>(S); +pub struct Response<S>(pub S); -impl<S> IntoResponse for Events<S> +impl<S> IntoResponse for Response<S> where S: Stream<Item = Event> + Send + 'static, { - fn into_response(self) -> Response { + fn into_response(self) -> response::Response { let Self(stream) = self; let stream = stream.map(sse::Event::try_from); Sse::new(stream) @@ -77,15 +68,15 @@ impl TryFrom<Event> for sse::Event { #[derive(Debug, thiserror::Error)] #[error(transparent)] -pub enum EventsError { - DatabaseError(#[from] sqlx::Error), - ValidateError(#[from] ValidateError), +pub enum Error { + Database(#[from] sqlx::Error), + Validate(#[from] ValidateError), } -impl IntoResponse for EventsError { - fn into_response(self) -> Response { +impl IntoResponse for Error { + fn into_response(self) -> response::Response { match self { - Self::ValidateError(ValidateError::InvalidToken) => Unauthorized.into_response(), + 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<App> { + 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<Summary, Error> { + pub async fn get(&self, invite: &Id) -> Result<Option<Summary>, 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) @@ -92,14 +88,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}")] NotFound(Id), 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<App> { - 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<App>, - RequestedAt(issued_at): RequestedAt, - login: Login, - // Require `{}` as the only valid request for this endpoint. - _: Json<InviteRequest>, -) -> Result<Json<Invite>, Internal> { - let invite = app.invites().create(&login, &issued_at).await?; - Ok(Json(invite)) -} - -async fn invite( - State(app): State<App>, - Path(invite): Path<Id>, -) -> Result<Json<Summary>, 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<App>, - RequestedAt(accepted_at): RequestedAt, - identity: IdentityToken, - Path(invite): Path<Id>, - Json(request): Json<AcceptRequest>, -) -> 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<App>, + Path(invite): Path<super::PathInfo>, +) -> Result<Json<Summary>, Error> { + app.invites() + .get(&invite) + .await? + .map(Json) + .ok_or_else(move || Error::NotFound(invite)) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + #[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<App>, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityToken, + Path(invite): Path<super::PathInfo>, + Json(request): Json<Request>, +) -> 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<App> { + 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<App>, + RequestedAt(issued_at): RequestedAt, + login: Login, + // Require `{}` as the only valid request for this endpoint. + _: Json<Request>, +) -> Result<Json<Invite>, 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<App> { - 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<App>, - RequestedAt(now): RequestedAt, - identity: IdentityToken, - Json(request): Json<LoginRequest>, -) -> 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<App>, - 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<LogoutRequest>, -) -> 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<App>, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(request): Json<Request>, +) -> 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/test/login.rs b/src/login/routes/login/test.rs index 68c92de..d431612 100644 --- a/src/login/routes/test/login.rs +++ b/src/login/routes/login/test.rs @@ -3,7 +3,8 @@ use axum::{ http::StatusCode, }; -use crate::{login::routes, test::fixtures, token::app}; +use super::post; +use crate::{test::fixtures, token::app}; #[tokio::test] async fn correct_credentials() { @@ -16,12 +17,12 @@ async fn correct_credentials() { let identity = fixtures::identity::not_logged_in(); let logged_in_at = fixtures::now(); - let request = routes::LoginRequest { + let request = post::Request { name: name.clone(), password, }; let (identity, status) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); @@ -53,12 +54,12 @@ async fn invalid_name() { let identity = fixtures::identity::not_logged_in(); let logged_in_at = fixtures::now(); let (name, password) = fixtures::login::propose(); - let request = routes::LoginRequest { + let request = post::Request { name: name.clone(), password, }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password"); @@ -78,12 +79,12 @@ async fn incorrect_password() { let logged_in_at = fixtures::now(); let identity = fixtures::identity::not_logged_in(); - let request = routes::LoginRequest { + let request = post::Request { name: login.name, password: fixtures::login::propose_password(), }; - let routes::LoginError(error) = - routes::on_login(State(app.clone()), logged_in_at, identity, Json(request)) + let post::Error(error) = + post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password"); @@ -103,8 +104,8 @@ async fn token_expires() { 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)) + 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"); 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<App>, + RequestedAt(now): RequestedAt, + identity: IdentityToken, + Json(_): Json<Request>, +) -> 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/test/logout.rs b/src/login/routes/logout/test.rs index 611829e..0e70e4c 100644 --- a/src/login/routes/test/logout.rs +++ b/src/login/routes/logout/test.rs @@ -3,7 +3,8 @@ use axum::{ http::StatusCode, }; -use crate::{login::routes, test::fixtures, token::app}; +use super::post; +use crate::{test::fixtures, token::app}; #[tokio::test] async fn successful() { @@ -17,11 +18,11 @@ async fn successful() { // Call the endpoint - let (response_identity, response_status) = routes::on_logout( + let (response_identity, response_status) = post::handler( State(app.clone()), fixtures::now(), identity.clone(), - Json(routes::LogoutRequest {}), + Json::default(), ) .await .expect("logged out with a valid token"); @@ -38,12 +39,7 @@ async fn successful() { .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:#}") - } - } + assert!(matches!(error, app::ValidateError::InvalidToken)); } #[tokio::test] @@ -55,14 +51,9 @@ async fn no_identity() { // 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"); + 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 @@ -79,19 +70,11 @@ async fn invalid_token() { // 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"); + 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, - routes::LogoutError::ValidateError(app::ValidateError::InvalidToken) - )); + 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<App> { + 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/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<App> { - Router::new().route("/api/messages/:message", delete(on_delete)) -} - -async fn on_delete( - State(app): State<App>, - Path(message): Path<message::Id>, - RequestedAt(deleted_at): RequestedAt, - _: Login, -) -> Result<StatusCode, ErrorResponse> { - 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<App>, + Path(message): Path<message::Id>, + RequestedAt(deleted_at): RequestedAt, + _: Login, + ) -> Result<StatusCode, Error> { + 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<App> { + Router::new().route("/api/messages/:message", delete(message::delete::handler)) +} 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<App> { + Router::new().route("/api/setup", post(post::handler)) +} diff --git a/src/setup/routes.rs b/src/setup/routes/post.rs index ff41734..9e6776f 100644 --- a/src/setup/routes.rs +++ b/src/setup/routes/post.rs @@ -2,44 +2,38 @@ 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, + app::App, clock::RequestedAt, error::Internal, login::Password, setup::app, + token::extract::IdentityToken, }; -pub fn router() -> Router<App> { - Router::new().route("/api/setup", post(on_setup)) -} - -#[derive(serde::Deserialize)] -struct SetupRequest { - name: String, - password: Password, -} - -async fn on_setup( +pub async fn handler( State(app): State<App>, RequestedAt(setup_at): RequestedAt, identity: IdentityToken, - Json(request): Json<SetupRequest>, -) -> Result<(IdentityToken, StatusCode), SetupError> { + Json(request): Json<Request>, +) -> Result<(IdentityToken, StatusCode), Error> { let secret = app .setup() .initial(&request.name, &request.password, &setup_at) .await - .map_err(SetupError)?; + .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)] -struct SetupError(app::Error); +pub struct Error(pub app::Error); -impl IntoResponse for SetupError { +impl IntoResponse for Error { fn into_response(self) -> Response { let Self(error) = self; match error { 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<str>) -> Result<Asset, NotFound<String>> { - 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<Asset, Internal> { - // "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<App> { - [ - 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<String>) -> Result<Asset, NotFound<String>> { - Assets::load(path) -} - -async fn root(login: Option<Login>) -> Result<impl IntoResponse, Internal> { - if login.is_none() { - Ok(Redirect::temporary("/login").into_response()) - } else { - Ok(Assets::index()?.into_response()) - } -} - -async fn login() -> Result<impl IntoResponse, Internal> { - Assets::index() -} - -async fn setup(State(app): State<App>) -> Result<impl IntoResponse, Internal> { - if app.setup().completed().await? { - Ok(Redirect::to("/login").into_response()) - } else { - Ok(Assets::index().into_response()) - } -} - -async fn channel( - State(app): State<App>, - login: Option<Login>, - Path(channel): Path<channel::Id>, -) -> Result<impl IntoResponse, Internal> { - 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<App>, - Path(invite): Path<invite::Id>, -) -> Result<impl IntoResponse, Internal> { - 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<E>(pub E); - -impl<E> IntoResponse for NotFound<E> -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<App>, 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<str>) -> Result<Asset, NotFound<String>> { + 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<Asset, Internal> { + // "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<E>(pub E); + +impl<E> IntoResponse for NotFound<E> +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<App>, 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<App>, + login: Option<Login>, + Path(channel): Path<channel::Id>, + ) -> Result<Asset, Error> { + 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<Internal>) -> 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<Login>) -> Result<Asset, Error> { + 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<App>, + Path(invite): Path<invite::Id>, + ) -> Result<Asset, Error> { + 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<Internal>) -> 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<Asset, Internal> { + 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<App> { + [ + 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<String>) -> Result<Asset, NotFound<String>> { + 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<App>) -> Result<Asset, Error> { + 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(), + } + } + } +} |
