summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-10-16 20:14:33 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-10-16 20:14:33 -0400
commitea74daca4809e4008dd8d01039db9fff3be659d9 (patch)
tree5972cabf646e8d5e635e9e2a176bff56c178461a
parent56e16e29db55dae84549229d24b971f8bcf7da21 (diff)
Organizational pass on endpoints and routes.
-rw-r--r--src/boot/routes.rs27
-rw-r--r--src/boot/routes/get.rs24
-rw-r--r--src/boot/routes/mod.rs11
-rw-r--r--src/boot/routes/test.rs7
-rw-r--r--src/channel/app.rs10
-rw-r--r--src/channel/routes.rs121
-rw-r--r--src/channel/routes/channel/delete.rs39
-rw-r--r--src/channel/routes/channel/mod.rs9
-rw-r--r--src/channel/routes/channel/post.rs47
-rw-r--r--src/channel/routes/channel/test.rs (renamed from src/channel/routes/test/on_send.rs)12
-rw-r--r--src/channel/routes/mod.rs19
-rw-r--r--src/channel/routes/post.rs49
-rw-r--r--src/channel/routes/test.rs (renamed from src/channel/routes/test/on_create.rs)37
-rw-r--r--src/channel/routes/test/mod.rs2
-rw-r--r--src/cli.rs2
-rw-r--r--src/error.rs17
-rw-r--r--src/event/routes/get.rs (renamed from src/event/routes.rs)51
-rw-r--r--src/event/routes/mod.rs11
-rw-r--r--src/event/routes/test.rs29
-rw-r--r--src/invite/app.rs16
-rw-r--r--src/invite/routes.rs97
-rw-r--r--src/invite/routes/invite/get.rs39
-rw-r--r--src/invite/routes/invite/mod.rs4
-rw-r--r--src/invite/routes/invite/post.rs51
-rw-r--r--src/invite/routes/mod.rs16
-rw-r--r--src/invite/routes/post.rs17
-rw-r--r--src/login/app.rs4
-rw-r--r--src/login/routes.rs97
-rw-r--r--src/login/routes/login/mod.rs4
-rw-r--r--src/login/routes/login/post.rs51
-rw-r--r--src/login/routes/login/test.rs (renamed from src/login/routes/test/login.rs)23
-rw-r--r--src/login/routes/logout/mod.rs4
-rw-r--r--src/login/routes/logout/post.rs47
-rw-r--r--src/login/routes/logout/test.rs (renamed from src/login/routes/test/logout.rs)41
-rw-r--r--src/login/routes/mod.rs12
-rw-r--r--src/login/routes/test/mod.rs2
-rw-r--r--src/message/app.rs4
-rw-r--r--src/message/routes.rs46
-rw-r--r--src/message/routes/message.rs43
-rw-r--r--src/message/routes/mod.rs9
-rw-r--r--src/setup/routes/mod.rs9
-rw-r--r--src/setup/routes/post.rs (renamed from src/setup/routes.rs)34
-rw-r--r--src/token/app.rs6
-rw-r--r--src/ui.rs134
-rw-r--r--src/ui/assets.rs43
-rw-r--r--src/ui/error.rs18
-rw-r--r--src/ui/middleware.rs15
-rw-r--r--src/ui/mod.rs6
-rw-r--r--src/ui/routes/ch/channel.rs61
-rw-r--r--src/ui/routes/ch/mod.rs1
-rw-r--r--src/ui/routes/get.rs30
-rw-r--r--src/ui/routes/invite/invite.rs55
-rw-r--r--src/ui/routes/invite/mod.rs4
-rw-r--r--src/ui/routes/login.rs11
-rw-r--r--src/ui/routes/mod.rs26
-rw-r--r--src/ui/routes/path.rs12
-rw-r--r--src/ui/routes/setup.rs43
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;
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<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(),
+ }
+ }
+ }
+}