summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-18 22:49:38 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-20 16:42:44 -0400
commit22348bfa35f009e62abe2f30863e0434079a1fe2 (patch)
treec5b5b5e660a1ee2a05785f4669102c1023b6e7b0 /src
parentaafdeb9ffaf9a993ca4462b3422667e04469b2e3 (diff)
Remove the HTML client, and expose a JSON API.
This API structure fell out of a conversation with Kit. Described loosely: kit: ok kit: Here's what I'm picturing in a client kit: list channels, make-new-channel, zero to one active channels, post-to-active. kit: login/sign-up, logout owen: you will likely also want "am I logged in" here kit: sure, whoami
Diffstat (limited to 'src')
-rw-r--r--src/app.rs5
-rw-r--r--src/channel/app.rs32
-rw-r--r--src/channel/routes.rs68
-rw-r--r--src/cli.rs4
-rw-r--r--src/events.rs4
-rw-r--r--src/index/app.rs36
-rw-r--r--src/index/mod.rs3
-rw-r--r--src/index/routes.rs82
-rw-r--r--src/index/templates.rs127
-rw-r--r--src/lib.rs1
-rw-r--r--src/login/routes.rs73
-rw-r--r--src/repo/channel.rs9
12 files changed, 139 insertions, 305 deletions
diff --git a/src/app.rs b/src/app.rs
index 1177c5e..0823a0c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2,7 +2,6 @@ use sqlx::sqlite::SqlitePool;
use crate::{
channel::app::{Broadcaster, Channels},
- index::app::Index,
login::app::Logins,
};
@@ -20,10 +19,6 @@ impl App {
}
impl App {
- pub const fn index(&self) -> Index {
- Index::new(&self.db)
- }
-
pub const fn logins(&self) -> Logins {
Logins::new(&self.db)
}
diff --git a/src/channel/app.rs b/src/channel/app.rs
index 2f37878..8ae0c3c 100644
--- a/src/channel/app.rs
+++ b/src/channel/app.rs
@@ -31,13 +31,17 @@ impl<'a> Channels<'a> {
Self { db, broadcaster }
}
- pub async fn create(&self, name: &str) -> Result<(), InternalError> {
+ pub async fn create(&self, name: &str) -> Result<Channel, CreateError> {
let mut tx = self.db.begin().await?;
- let channel = tx.channels().create(name).await?;
- self.broadcaster.register_channel(&channel);
+ let channel = tx
+ .channels()
+ .create(name)
+ .await
+ .map_err(|err| CreateError::from_duplicate_name(err, name))?;
+ self.broadcaster.register_channel(&channel.id);
tx.commit().await?;
- Ok(())
+ Ok(channel)
}
pub async fn all(&self) -> Result<Vec<Channel>, InternalError> {
@@ -122,6 +126,26 @@ impl<'a> Channels<'a> {
}
#[derive(Debug, thiserror::Error)]
+pub enum CreateError {
+ #[error("channel named {0} already exists")]
+ DuplicateName(String),
+ #[error(transparent)]
+ DatabaseError(#[from] sqlx::Error),
+}
+
+impl CreateError {
+ fn from_duplicate_name(error: sqlx::Error, name: &str) -> Self {
+ if let Some(error) = error.as_database_error() {
+ if error.is_unique_violation() {
+ return Self::DuplicateName(name.into());
+ }
+ }
+
+ Self::from(error)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
pub enum InternalError {
#[error(transparent)]
DatabaseError(#[from] sqlx::Error),
diff --git a/src/channel/routes.rs b/src/channel/routes.rs
index 847e0b4..383ec58 100644
--- a/src/channel/routes.rs
+++ b/src/channel/routes.rs
@@ -1,23 +1,43 @@
use axum::{
- extract::{Form, Path, State},
+ extract::{Json, Path, State},
http::StatusCode,
- response::{IntoResponse, Redirect, Response},
- routing::post,
+ response::{IntoResponse, Response},
+ routing::{get, post},
Router,
};
-use super::app::EventsError;
+use super::app::{self, EventsError};
use crate::{
app::App,
clock::RequestedAt,
error::InternalError,
- repo::{channel, login::Login},
+ repo::{
+ channel::{self, Channel},
+ login::Login,
+ },
};
pub fn router() -> Router<App> {
Router::new()
- .route("/create", post(on_create))
- .route("/:channel/send", post(on_send))
+ .route("/api/channels", get(list_channels))
+ .route("/api/channels", post(on_create))
+ .route("/api/channels/:channel", post(on_send))
+}
+
+async fn list_channels(State(app): State<App>, _: Login) -> Result<Channels, InternalError> {
+ let channels = app.channels().all().await?;
+ let response = Channels(channels);
+
+ Ok(response)
+}
+
+struct Channels(Vec<Channel>);
+
+impl IntoResponse for Channels {
+ fn into_response(self) -> Response {
+ let Self(channels) = self;
+ Json(channels).into_response()
+ }
}
#[derive(serde::Deserialize)]
@@ -28,11 +48,29 @@ struct CreateRequest {
async fn on_create(
State(app): State<App>,
_: Login, // requires auth, but doesn't actually care who you are
- Form(form): Form<CreateRequest>,
-) -> Result<impl IntoResponse, InternalError> {
- app.channels().create(&form.name).await?;
+ Json(form): Json<CreateRequest>,
+) -> Result<Json<Channel>, CreateError> {
+ let channel = app
+ .channels()
+ .create(&form.name)
+ .await
+ .map_err(CreateError)?;
- Ok(Redirect::to("/"))
+ Ok(Json(channel))
+}
+
+struct CreateError(app::CreateError);
+
+impl IntoResponse for CreateError {
+ fn into_response(self) -> Response {
+ let Self(error) = self;
+ match error {
+ duplicate @ app::CreateError::DuplicateName(_) => {
+ (StatusCode::BAD_REQUEST, duplicate.to_string()).into_response()
+ }
+ other => InternalError::from(other).into_response(),
+ }
+ }
}
#[derive(serde::Deserialize)]
@@ -45,15 +83,15 @@ async fn on_send(
RequestedAt(sent_at): RequestedAt,
State(app): State<App>,
login: Login,
- Form(form): Form<SendRequest>,
-) -> Result<impl IntoResponse, ErrorResponse> {
+ Json(form): Json<SendRequest>,
+) -> Result<StatusCode, ErrorResponse> {
app.channels()
.send(&login, &channel, &form.message, &sent_at)
.await
// Could impl `From` here, but it's more code and this is used once.
.map_err(ErrorResponse)?;
- Ok(Redirect::to(&format!("/{}", channel)))
+ Ok(StatusCode::ACCEPTED)
}
struct ErrorResponse(EventsError);
@@ -65,7 +103,7 @@ impl IntoResponse for ErrorResponse {
not_found @ EventsError::ChannelNotFound(_) => {
(StatusCode::NOT_FOUND, not_found.to_string()).into_response()
}
- EventsError::DatabaseError(error) => InternalError::from(error).into_response(),
+ other => InternalError::from(other).into_response(),
}
}
}
diff --git a/src/cli.rs b/src/cli.rs
index cc49001..9d0606d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net;
-use crate::{app::App, channel, clock, events, index, login};
+use crate::{app::App, channel, clock, events, login};
pub type Result<T> = std::result::Result<T, Error>;
@@ -66,7 +66,7 @@ impl Args {
fn routers() -> Router<App> {
[channel::router(), events::router(), login::router()]
.into_iter()
- .fold(index::routes::router(), Router::merge)
+ .fold(Router::default(), Router::merge)
}
fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
diff --git a/src/events.rs b/src/events.rs
index 2f5e145..9cbb0a3 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -22,7 +22,7 @@ use crate::{
};
pub fn router() -> Router<App> {
- Router::new().route("/events", get(on_events))
+ Router::new().route("/api/events", get(on_events))
}
#[derive(serde::Deserialize)]
@@ -104,7 +104,7 @@ impl IntoResponse for ErrorResponse {
}
fn to_sse_event(event: ChannelEvent<broadcast::Message>) -> Result<sse::Event, serde_json::Error> {
- let data = serde_json::to_string(&event)?;
+ let data = serde_json::to_string_pretty(&event)?;
let event = sse::Event::default()
.id(event
.message
diff --git a/src/index/app.rs b/src/index/app.rs
deleted file mode 100644
index 4f234ee..0000000
--- a/src/index/app.rs
+++ /dev/null
@@ -1,36 +0,0 @@
-use sqlx::sqlite::SqlitePool;
-
-use crate::repo::{
- channel::{self, Channel, Provider as _},
- error::NotFound as _,
-};
-
-pub struct Index<'a> {
- db: &'a SqlitePool,
-}
-
-impl<'a> Index<'a> {
- pub const fn new(db: &'a SqlitePool) -> Self {
- Self { db }
- }
-
- pub async fn channel(&self, channel: &channel::Id) -> Result<Channel, Error> {
- let mut tx = self.db.begin().await?;
- let channel = tx
- .channels()
- .by_id(channel)
- .await
- .not_found(|| Error::ChannelNotFound(channel.clone()))?;
- tx.commit().await?;
-
- Ok(channel)
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum Error {
- #[error("channel {0} not found")]
- ChannelNotFound(channel::Id),
- #[error(transparent)]
- DatabaseError(#[from] sqlx::Error),
-}
diff --git a/src/index/mod.rs b/src/index/mod.rs
deleted file mode 100644
index 0d89a50..0000000
--- a/src/index/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod app;
-pub mod routes;
-mod templates;
diff --git a/src/index/routes.rs b/src/index/routes.rs
deleted file mode 100644
index 37f6dc9..0000000
--- a/src/index/routes.rs
+++ /dev/null
@@ -1,82 +0,0 @@
-use axum::{
- extract::{Path, State},
- http::{header, StatusCode},
- response::{IntoResponse, Response},
- routing::get,
- Router,
-};
-use maud::Markup;
-
-use super::{app, templates};
-use crate::{
- app::App,
- error::InternalError,
- repo::{channel, login::Login},
-};
-
-async fn index(State(app): State<App>, login: Option<Login>) -> Result<Markup, InternalError> {
- match login {
- None => Ok(templates::unauthenticated()),
- Some(login) => index_authenticated(app, login).await,
- }
-}
-
-async fn index_authenticated(app: App, login: Login) -> Result<Markup, InternalError> {
- let channels = app.channels().all().await?;
-
- Ok(templates::authenticated(login, &channels))
-}
-
-#[derive(rust_embed::Embed)]
-#[folder = "js"]
-struct Js;
-
-async fn js(Path(path): Path<String>) -> impl IntoResponse {
- let mime = mime_guess::from_path(&path).first_or_octet_stream();
-
- match Js::get(&path) {
- Some(file) => (
- StatusCode::OK,
- [(header::CONTENT_TYPE, mime.as_ref())],
- file.data,
- )
- .into_response(),
- None => (StatusCode::NOT_FOUND, "").into_response(),
- }
-}
-
-async fn channel(
- State(app): State<App>,
- _: Login,
- Path(channel): Path<channel::Id>,
-) -> Result<Markup, ChannelError> {
- let channel = app
- .index()
- .channel(&channel)
- .await
- // impl From would work here, but it'd take more code.
- .map_err(ChannelError)?;
- Ok(templates::channel(&channel))
-}
-
-#[derive(Debug)]
-struct ChannelError(app::Error);
-
-impl IntoResponse for ChannelError {
- fn into_response(self) -> Response {
- let Self(error) = self;
- match error {
- not_found @ app::Error::ChannelNotFound(_) => {
- (StatusCode::NOT_FOUND, not_found.to_string()).into_response()
- }
- app::Error::DatabaseError(error) => InternalError::from(error).into_response(),
- }
- }
-}
-
-pub fn router() -> Router<App> {
- Router::new()
- .route("/", get(index))
- .route("/js/*path", get(js))
- .route("/:channel", get(channel))
-}
diff --git a/src/index/templates.rs b/src/index/templates.rs
deleted file mode 100644
index d56972c..0000000
--- a/src/index/templates.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-use maud::{html, Markup, DOCTYPE};
-
-use crate::repo::{channel::Channel, login::Login};
-
-pub fn authenticated<'c>(login: Login, channels: impl IntoIterator<Item = &'c Channel>) -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi" }
- }
- body {
- section {
- (channel_list(channels))
- (create_channel())
- }
- section {
- (logout_form(&login.name))
- }
- }
- }
-}
-
-fn channel_list<'c>(channels: impl IntoIterator<Item = &'c Channel>) -> Markup {
- html! {
- ul {
- @for channel in channels {
- (channel_list_entry(&channel))
- }
- }
- }
-}
-
-fn channel_list_entry(channel: &Channel) -> Markup {
- html! {
- li {
- a href=(format!("/{}", channel.id)) {
- (channel.name) " (" (channel.id) ")"
- }
- }
- }
-}
-
-fn create_channel() -> Markup {
- html! {
- form action="/create" method="post" {
- label {
- "name"
- input name="name" type="text" {}
- }
- button {
- "start channel"
- }
- }
- }
-}
-
-fn logout_form(name: &str) -> Markup {
- html! {
- form action="/logout" method="post" {
- button { "bye, " (name) }
- }
- }
-}
-
-pub fn unauthenticated() -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi" }
- }
- body {
- (login_form())
- }
- }
-}
-
-fn login_form() -> Markup {
- html! {
- form action="/login" method="post" {
- label {
- "login"
- input name="name" type="text" {}
- }
- label {
- "password"
- input name="password" type="password" {}
- }
- button { "hi" }
- }
- }
-}
-
-pub fn channel(channel: &Channel) -> Markup {
- html! {
- (DOCTYPE)
- head {
- title { "hi - " (channel.name) }
- script src="/js/channel.js" {}
- template id="message" {
- p {
- span.sender { "(sender)" }
- ": "
- span.message { "(message)" }
- " (at "
- span.sent_at { "(sent_at)" }
- ")" }
- }
- meta name="channel" content=(channel.id) {}
- link rel="events" href=(format!("/events?channel={}", channel.id)) {}
- }
- body {
- section class="messages" {}
- section {
- form action=(format!("/{}/send", channel.id)) method="post" {
- label {
- "message"
- input name="message" type="text" autofocus {}
- }
- button { "send" }
- }
- }
- section {
- a href="/" { "back" }
- }
- }
- }
-}
diff --git a/src/lib.rs b/src/lib.rs
index 8b6a78f..609142a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,7 +6,6 @@ mod error;
mod events;
mod header;
mod id;
-mod index;
mod login;
mod password;
mod repo;
diff --git a/src/login/routes.rs b/src/login/routes.rs
index 1ed61ce..3052147 100644
--- a/src/login/routes.rs
+++ b/src/login/routes.rs
@@ -1,19 +1,35 @@
use axum::{
- extract::{Form, State},
+ extract::{Json, State},
http::StatusCode,
- response::{IntoResponse, Redirect, Response},
- routing::post,
+ response::{IntoResponse, Response},
+ routing::{get, post},
Router,
};
-use crate::{app::App, clock::RequestedAt, error::InternalError};
+use crate::{app::App, clock::RequestedAt, error::InternalError, repo::login::Login};
use super::{app, extract::IdentityToken};
pub fn router() -> Router<App> {
Router::new()
- .route("/login", post(on_login))
- .route("/logout", post(on_logout))
+ .route("/api/boot", get(boot))
+ .route("/api/auth/login", post(on_login))
+ .route("/api/auth/logout", post(on_logout))
+}
+
+async fn boot(login: Login) -> Boot {
+ Boot { login }
+}
+
+#[derive(serde::Serialize)]
+struct Boot {
+ login: Login,
+}
+
+impl IntoResponse for Boot {
+ fn into_response(self) -> Response {
+ Json(self).into_response()
+ }
}
#[derive(serde::Deserialize)]
@@ -26,24 +42,15 @@ async fn on_login(
State(app): State<App>,
RequestedAt(now): RequestedAt,
identity: IdentityToken,
- Form(form): Form<LoginRequest>,
-) -> Result<LoginSuccess, LoginError> {
+ Json(request): Json<LoginRequest>,
+) -> Result<(IdentityToken, StatusCode), LoginError> {
let token = app
.logins()
- .login(&form.name, &form.password, now)
+ .login(&request.name, &request.password, now)
.await
.map_err(LoginError)?;
let identity = identity.set(&token);
- Ok(LoginSuccess(identity))
-}
-
-struct LoginSuccess(IdentityToken);
-
-impl IntoResponse for LoginSuccess {
- fn into_response(self) -> Response {
- let Self(identity) = self;
- (identity, Redirect::to("/")).into_response()
- }
+ Ok((identity, StatusCode::NO_CONTENT))
}
struct LoginError(app::LoginError);
@@ -55,21 +62,39 @@ impl IntoResponse for LoginError {
app::LoginError::Rejected => {
(StatusCode::UNAUTHORIZED, "invalid name or password").into_response()
}
- app::LoginError::DatabaseError(error) => InternalError::from(error).into_response(),
- app::LoginError::PasswordHashError(error) => InternalError::from(error).into_response(),
+ other => InternalError::from(other).into_response(),
}
}
}
+#[derive(serde::Deserialize)]
+struct LogoutRequest {}
+
async fn on_logout(
State(app): State<App>,
identity: IdentityToken,
-) -> Result<impl IntoResponse, InternalError> {
+ // 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() {
- app.logins().logout(secret).await?;
+ app.logins().logout(secret).await.map_err(LogoutError)?;
}
let identity = identity.clear();
+ Ok((identity, StatusCode::NO_CONTENT))
+}
+
+struct LogoutError(app::ValidateError);
- Ok((identity, Redirect::to("/")))
+impl IntoResponse for LogoutError {
+ fn into_response(self) -> Response {
+ let Self(error) = self;
+ match error {
+ error @ app::ValidateError::InvalidToken => {
+ (StatusCode::UNAUTHORIZED, error.to_string()).into_response()
+ }
+ other => InternalError::from(other).into_response(),
+ }
+ }
}
diff --git a/src/repo/channel.rs b/src/repo/channel.rs
index 8e3a471..95516d2 100644
--- a/src/repo/channel.rs
+++ b/src/repo/channel.rs
@@ -16,7 +16,7 @@ impl<'c> Provider for Transaction<'c, Sqlite> {
pub struct Channels<'t>(&'t mut SqliteConnection);
-#[derive(Debug)]
+#[derive(Debug, serde::Serialize)]
pub struct Channel {
pub id: Id,
pub name: String,
@@ -24,15 +24,16 @@ pub struct Channel {
impl<'c> Channels<'c> {
/// Create a new channel.
- pub async fn create(&mut self, name: &str) -> Result<Id, sqlx::Error> {
+ pub async fn create(&mut self, name: &str) -> Result<Channel, sqlx::Error> {
let id = Id::generate();
- let channel = sqlx::query_scalar!(
+ let channel = sqlx::query_as!(
+ Channel,
r#"
insert
into channel (id, name)
values ($1, $2)
- returning id as "id: Id"
+ returning id as "id: Id", name
"#,
id,
name,