From 58e6496558a01052537c5272169aea3e79ccbc8e Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 24 Oct 2025 16:51:07 -0400 Subject: Raise minimum Rust version to 1.90. We've made stylistic changes that follow from Rust 1.86 through 1.89 anyways, and I'm not at all confident we aren't using APIs that only exist in those versions. --- Cargo.toml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5b861a8..04c3f6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,21 +2,20 @@ name = "pilcrow" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.90" authors = [ - "Owen Jacobson ", - "Kit La Touche ", + "Owen Jacobson ", + "Kit La Touche ", ] [package.metadata.deb] maintainer = "Owen Jacobson " maintainer-scripts = "debian" assets = [ - # Binaries - ["target/release/pilcrow", "/usr/bin/pilcrow", "755"], - - # Configuration - ["debian/default", "/etc/default/pilcrow", "644"], + # Binaries + ["target/release/pilcrow", "/usr/bin/pilcrow", "755"], + # Configuration + ["debian/default", "/etc/default/pilcrow", "644"], ] [package.metadata.deb.systemd-units] -- cgit v1.2.3 From 2d05e6fb933d8c33078232b3bdfbc2aa05106d80 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:07:53 -0400 Subject: Make `Boot` a freestanding app type, rather than a view of `crate::app::App`'s internals. In the course of working on web push, I determined that we probably need to make `App` generic over the web push client we're using, so that tests can use a dummy client while the real app uses a client created at startup and maintained over the life of the program's execution. The most direct implementation of that is to render App as `App

`, where the parameter is occupied by the specific web push client type in use. However, doing this requires refactoring at _every_ site that mentions `App`, including every handler, even though the vast majority of those sites will not be concerned with web push. I reviewed a few options with @wlonk: * Accept the type parameter and apply it everywhere, as the cost of supporting web push. * Hard-code the use of a specific web push client. * Insulate handlers &c from `App` via provider traits, mimicing what we do for repository provider traits today. * Treat each app type as a freestanding state in its own right, so that only push-related components need to consider push clients (as far as is feasible). This is a prototype towards that last point, using a simple app component (boot) as a testbed. `FromRef` allows handlers that take a `Boot` to be used in routes that provide an `App`, so this is a contained change. However, the structure of `FromRef` prevents `Boot` from carrying any lifetime narrower than `'static`, so it now holds clones of the state fields it acquires from App, instead of references. This is fine - that's just a database pool, and sqlx's pool type is designed to be shared via cloning. From : > Cloning Pool is cheap as it is simply a reference-counted handle to the inner pool state. --- src/app.rs | 11 +++++++++-- src/boot/app.rs | 8 ++++---- src/boot/handlers/boot/mod.rs | 9 ++++++--- src/boot/handlers/boot/test.rs | 16 ++++++++-------- src/test/fixtures/boot.rs | 11 ++++++++--- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index e61672f..e33ee39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use axum::extract::FromRef; use sqlx::sqlite::SqlitePool; #[cfg(test)] @@ -33,8 +34,8 @@ impl App { } impl App { - pub const fn boot(&self) -> Boot<'_> { - Boot::new(&self.db) + pub fn boot(&self) -> Boot { + Boot::new(self.db.clone()) } pub const fn conversations(&self) -> Conversations<'_> { @@ -70,3 +71,9 @@ impl App { Users::new(&self.db, &self.events) } } + +impl FromRef for Boot { + fn from_ref(app: &App) -> Self { + app.boot() + } +} diff --git a/src/boot/app.rs b/src/boot/app.rs index 0ed5d1b..840243e 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -10,12 +10,12 @@ use crate::{ user::{self, repo::Provider as _}, }; -pub struct Boot<'a> { - db: &'a SqlitePool, +pub struct Boot { + db: SqlitePool, } -impl<'a> Boot<'a> { - pub const fn new(db: &'a SqlitePool) -> Self { +impl Boot { + pub const fn new(db: SqlitePool) -> Self { Self { db } } diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs index 3e022b1..5ff7802 100644 --- a/src/boot/handlers/boot/mod.rs +++ b/src/boot/handlers/boot/mod.rs @@ -7,15 +7,18 @@ use axum::{ use serde::Serialize; use crate::{ - app::App, boot::Snapshot, error::Internal, event::Heartbeat, login::Login, + boot::{Snapshot, app::Boot}, + error::Internal, + event::Heartbeat, + login::Login, token::extract::Identity, }; #[cfg(test)] mod test; -pub async fn handler(State(app): State, identity: Identity) -> Result { - let snapshot = app.boot().snapshot().await?; +pub async fn handler(State(boot): State, identity: Identity) -> Result { + let snapshot = boot.snapshot().await?; let heartbeat = Heartbeat::TIMEOUT; Ok(Response { diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs index cb50442..a9891eb 100644 --- a/src/boot/handlers/boot/test.rs +++ b/src/boot/handlers/boot/test.rs @@ -8,7 +8,7 @@ async fn returns_identity() { let app = fixtures::scratch_app().await; let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer.clone()) + let response = super::handler(State(app.boot()), viewer.clone()) .await .expect("boot always succeeds"); @@ -21,7 +21,7 @@ async fn includes_users() { let spectator = fixtures::user::create(&app, &fixtures::now()).await; let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -42,7 +42,7 @@ async fn includes_conversations() { let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -65,7 +65,7 @@ async fn includes_messages() { let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -94,7 +94,7 @@ async fn includes_expired_messages() { .expect("expiry never fails"); let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -136,7 +136,7 @@ async fn includes_deleted_messages() { .expect("deleting valid message succeeds"); let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -175,7 +175,7 @@ async fn includes_expired_conversations() { .expect("expiry never fails"); let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); @@ -214,7 +214,7 @@ async fn includes_deleted_conversations() { .expect("deleting a valid conversation succeeds"); let viewer = fixtures::identity::fictitious(); - let response = super::handler(State(app), viewer) + let response = super::handler(State(app.boot()), viewer) .await .expect("boot always succeeds"); diff --git a/src/test/fixtures/boot.rs b/src/test/fixtures/boot.rs index 120726f..7421512 100644 --- a/src/test/fixtures/boot.rs +++ b/src/test/fixtures/boot.rs @@ -1,7 +1,12 @@ -use crate::{app::App, event::Sequence}; +use axum::extract::FromRef; -pub async fn resume_point(app: &App) -> Sequence { - app.boot() +use crate::{boot::app::Boot, event::Sequence}; + +pub async fn resume_point(app: &App) -> Sequence +where + Boot: FromRef, +{ + Boot::from_ref(app) .snapshot() .await .expect("boot always succeeds") -- cgit v1.2.3 From 398bc79a3f1f4316e6cc33b6e6bce133c1db3e4c Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:29:36 -0400 Subject: Convert the `Conversations` component into a freestanding struct. Unlike the previous example, this involves cloning an event broadcaster, as well. This is, per the documentation, how the type may be used. From : > The Sender can be cloned to send to the same channel from multiple points in the process or it can be used concurrently from an `Arc`. The language is less firm than the language sqlx uses for its pool, but the intent is clear enough, and it works in practice. --- src/app.rs | 10 ++++- src/conversation/app.rs | 10 ++--- src/conversation/handlers/create/mod.rs | 8 ++-- src/conversation/handlers/create/test.rs | 66 +++++++++++++++++++++----------- src/conversation/handlers/delete/mod.rs | 9 ++--- src/conversation/handlers/delete/test.rs | 12 +++--- src/test/fixtures/conversation.rs | 11 ++++-- src/ui/handlers/conversation.rs | 7 ++-- 8 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/app.rs b/src/app.rs index e33ee39..3f3885a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,8 +38,8 @@ impl App { Boot::new(self.db.clone()) } - pub const fn conversations(&self) -> Conversations<'_> { - Conversations::new(&self.db, &self.events) + pub fn conversations(&self) -> Conversations { + Conversations::new(self.db.clone(), self.events.clone()) } pub const fn events(&self) -> Events<'_> { @@ -77,3 +77,9 @@ impl FromRef for Boot { app.boot() } } + +impl FromRef for Conversations { + fn from_ref(app: &App) -> Self { + app.conversations() + } +} diff --git a/src/conversation/app.rs b/src/conversation/app.rs index 26886af..2b62e77 100644 --- a/src/conversation/app.rs +++ b/src/conversation/app.rs @@ -15,13 +15,13 @@ use crate::{ name::{self, Name}, }; -pub struct Conversations<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Conversations { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Conversations<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Conversations { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } diff --git a/src/conversation/handlers/create/mod.rs b/src/conversation/handlers/create/mod.rs index 18eca1f..2b7fa39 100644 --- a/src/conversation/handlers/create/mod.rs +++ b/src/conversation/handlers/create/mod.rs @@ -5,9 +5,8 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, - conversation::{Conversation, app}, + conversation::{Conversation, app, app::Conversations}, error::Internal, name::Name, token::extract::Identity, @@ -17,13 +16,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(conversations): State, _: Identity, // requires auth, but doesn't actually care who you are RequestedAt(created_at): RequestedAt, Json(request): Json, ) -> Result { - let conversation = app - .conversations() + let conversation = conversations .create(&request.name, &created_at) .await .map_err(Error)?; diff --git a/src/conversation/handlers/create/test.rs b/src/conversation/handlers/create/test.rs index bc05b00..380bb13 100644 --- a/src/conversation/handlers/create/test.rs +++ b/src/conversation/handlers/create/test.rs @@ -22,10 +22,14 @@ async fn new_conversation() { let name = fixtures::conversation::propose(); let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creating a conversation in an empty app succeeds"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creating a conversation in an empty app succeeds"); // Verify the structure of the response @@ -77,10 +81,14 @@ async fn duplicate_name() { let request = super::Request { name: conversation.name.clone(), }; - let super::Error(error) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate conversation name should fail the request"); + let super::Error(error) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response @@ -110,10 +118,14 @@ async fn conflicting_canonical_name() { let request = super::Request { name: conflicting_name.clone(), }; - let super::Error(error) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate conversation name should fail the request"); + let super::Error(error) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response @@ -135,7 +147,7 @@ async fn invalid_name() { let name = fixtures::conversation::propose_invalid_name(); let request = super::Request { name: name.clone() }; let super::Error(error) = crate::conversation::handlers::create::handler( - State(app.clone()), + State(app.conversations()), creator, fixtures::now(), Json(request), @@ -163,7 +175,7 @@ async fn name_reusable_after_delete() { let request = super::Request { name: name.clone() }; let super::Response(response) = super::handler( - State(app.clone()), + State(app.conversations()), creator.clone(), fixtures::now(), Json(request), @@ -181,10 +193,14 @@ async fn name_reusable_after_delete() { // Call the endpoint (second time) let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original conversation deleted"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creation succeeds after original conversation deleted"); // Verify the structure of the response @@ -212,7 +228,7 @@ async fn name_reusable_after_expiry() { let request = super::Request { name: name.clone() }; let super::Response(_) = super::handler( - State(app.clone()), + State(app.conversations()), creator.clone(), fixtures::ancient(), Json(request), @@ -230,10 +246,14 @@ async fn name_reusable_after_expiry() { // Call the endpoint (second time) let request = super::Request { name: name.clone() }; - let super::Response(response) = - super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original conversation expired"); + let super::Response(response) = super::handler( + State(app.conversations()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect("creation succeeds after original conversation expired"); // Verify the structure of the response diff --git a/src/conversation/handlers/delete/mod.rs b/src/conversation/handlers/delete/mod.rs index 272165a..231e433 100644 --- a/src/conversation/handlers/delete/mod.rs +++ b/src/conversation/handlers/delete/mod.rs @@ -5,9 +5,8 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, - conversation::{self, app, handlers::PathInfo}, + conversation::{self, app, app::Conversations, handlers::PathInfo}, error::{Internal, NotFound}, token::extract::Identity, }; @@ -16,14 +15,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(conversations): State, Path(conversation): Path, RequestedAt(deleted_at): RequestedAt, _: Identity, ) -> Result { - app.conversations() - .delete(&conversation, &deleted_at) - .await?; + conversations.delete(&conversation, &deleted_at).await?; Ok(Response { id: conversation }) } diff --git a/src/conversation/handlers/delete/test.rs b/src/conversation/handlers/delete/test.rs index 2718d3b..e9e882a 100644 --- a/src/conversation/handlers/delete/test.rs +++ b/src/conversation/handlers/delete/test.rs @@ -14,7 +14,7 @@ pub async fn valid_conversation() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let response = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -52,7 +52,7 @@ pub async fn invalid_conversation_id() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let conversation = fixtures::conversation::fictitious(); let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.clone()), fixtures::now(), deleter, @@ -81,7 +81,7 @@ pub async fn conversation_deleted() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -110,7 +110,7 @@ pub async fn conversation_expired() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -144,7 +144,7 @@ pub async fn conversation_purged() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, @@ -170,7 +170,7 @@ pub async fn conversation_not_empty() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.conversations()), Path(conversation.id.clone()), fixtures::now(), deleter, diff --git a/src/test/fixtures/conversation.rs b/src/test/fixtures/conversation.rs index fb2f58d..f0d8c8c 100644 --- a/src/test/fixtures/conversation.rs +++ b/src/test/fixtures/conversation.rs @@ -1,3 +1,4 @@ +use axum::extract::FromRef; use faker_rand::{ en_us::{addresses::CityName, names::FullName}, faker_impl_from_templates, @@ -6,15 +7,17 @@ use faker_rand::{ use rand; use crate::{ - app::App, clock::RequestedAt, - conversation::{self, Conversation}, + conversation::{self, Conversation, app::Conversations}, name::Name, }; -pub async fn create(app: &App, created_at: &RequestedAt) -> Conversation { +pub async fn create(app: &App, created_at: &RequestedAt) -> Conversation +where + Conversations: FromRef, +{ let name = propose(); - app.conversations() + Conversations::from_ref(app) .create(&name, created_at) .await .expect("should always succeed if the conversation is actually new") diff --git a/src/ui/handlers/conversation.rs b/src/ui/handlers/conversation.rs index f1bb319..2ff090c 100644 --- a/src/ui/handlers/conversation.rs +++ b/src/ui/handlers/conversation.rs @@ -4,8 +4,7 @@ use axum::{ }; use crate::{ - app::App, - conversation::{self, app}, + conversation::{self, app, app::Conversations}, error::Internal, token::extract::Identity, ui::{ @@ -15,12 +14,12 @@ use crate::{ }; pub async fn handler( - State(app): State, + State(conversations): State, identity: Option, Path(conversation): Path, ) -> Result { let _ = identity.ok_or(Error::NotLoggedIn)?; - app.conversations() + conversations .get(&conversation) .await .map_err(Error::from)?; -- cgit v1.2.3 From 6de7402a002791c6216b12a40e74af9c8ab82c02 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:33:30 -0400 Subject: Convert the `Events` app component into a freestanding struct. This one doesn't need a FromRef impl at this time, as it's only ever used in a handler that also uses other components and so will need to continue receiving `App`. However, there's little reason not to make the implementatino of the `Events` struct consistent. --- src/app.rs | 4 ++-- src/event/app.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3f3885a..0b560dd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,8 +42,8 @@ impl App { Conversations::new(self.db.clone(), self.events.clone()) } - pub const fn events(&self) -> Events<'_> { - Events::new(&self.db, &self.events) + pub fn events(&self) -> Events { + Events::new(self.db.clone(), self.events.clone()) } pub const fn invites(&self) -> Invites<'_> { diff --git a/src/event/app.rs b/src/event/app.rs index 7359bfb..8fa760a 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -13,13 +13,13 @@ use crate::{ user::{self, repo::Provider as _}, }; -pub struct Events<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Events { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Events<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Events { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } -- cgit v1.2.3 From f305e487d619f1d993d11d728c8cf7261bf3b371 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:41:22 -0400 Subject: Convert `Invites` into a freestanding component. --- src/app.rs | 10 ++++++++-- src/invite/app.rs | 12 ++++++------ src/invite/handlers/accept/mod.rs | 8 +++----- src/invite/handlers/accept/test.rs | 12 ++++++------ src/invite/handlers/get/mod.rs | 7 +++---- src/invite/handlers/get/test.rs | 6 +++--- src/invite/handlers/issue/mod.rs | 9 ++++++--- src/invite/handlers/issue/test.rs | 2 +- src/test/fixtures/invite.rs | 12 ++++++++---- src/ui/handlers/invite.rs | 6 +++--- 10 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0b560dd..2d6d62b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,8 +46,8 @@ impl App { Events::new(self.db.clone(), self.events.clone()) } - pub const fn invites(&self) -> Invites<'_> { - Invites::new(&self.db, &self.events) + pub fn invites(&self) -> Invites { + Invites::new(self.db.clone(), self.events.clone()) } pub const fn logins(&self) -> Logins<'_> { @@ -83,3 +83,9 @@ impl FromRef for Conversations { app.conversations() } } + +impl FromRef for Invites { + fn from_ref(app: &App) -> Self { + app.invites() + } +} diff --git a/src/invite/app.rs b/src/invite/app.rs index 6684d03..6f58d0a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -17,13 +17,13 @@ use crate::{ }, }; -pub struct Invites<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Invites { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Invites<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Invites { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } @@ -88,7 +88,7 @@ impl<'a> Invites<'a> { tx.tokens().create(&token, &secret).await?; tx.commit().await?; - stored.publish(self.events); + stored.publish(&self.events); Ok(secret) } diff --git a/src/invite/handlers/accept/mod.rs b/src/invite/handlers/accept/mod.rs index cdf385f..8bdaa51 100644 --- a/src/invite/handlers/accept/mod.rs +++ b/src/invite/handlers/accept/mod.rs @@ -5,11 +5,10 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::{Internal, NotFound}, - invite::{app, handlers::PathInfo}, + invite::{app, app::Invites, handlers::PathInfo}, name::Name, password::Password, token::extract::IdentityCookie, @@ -19,14 +18,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(invites): State, RequestedAt(accepted_at): RequestedAt, identity: IdentityCookie, Path(invite): Path, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .invites() + let secret = invites .accept(&invite, &request.name, &request.password, &accepted_at) .await .map_err(Error)?; diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs index 283ec76..446dbf9 100644 --- a/src/invite/handlers/accept/test.rs +++ b/src/invite/handlers/accept/test.rs @@ -24,7 +24,7 @@ async fn valid_invite() { password: password.clone(), }; let (identity, Empty) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id), @@ -67,7 +67,7 @@ async fn nonexistent_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.clone()), @@ -103,7 +103,7 @@ async fn expired_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -140,7 +140,7 @@ async fn accepted_invite() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -183,7 +183,7 @@ async fn conflicting_name() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id.clone()), @@ -217,7 +217,7 @@ async fn invalid_name() { password: password.clone(), }; let super::Error(error) = super::handler( - State(app.clone()), + State(app.invites()), fixtures::now(), identity, Path(invite.id), diff --git a/src/invite/handlers/get/mod.rs b/src/invite/handlers/get/mod.rs index bb72586..d5fd9c2 100644 --- a/src/invite/handlers/get/mod.rs +++ b/src/invite/handlers/get/mod.rs @@ -4,19 +4,18 @@ use axum::{ }; use crate::{ - app::App, error::{Internal, NotFound}, - invite::{Id, Summary, handlers::PathInfo}, + invite::{Id, Summary, app::Invites, handlers::PathInfo}, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(invites): State, Path(invite): Path, ) -> Result, Error> { - app.invites() + invites .get(&invite) .await? .map(Json) diff --git a/src/invite/handlers/get/test.rs b/src/invite/handlers/get/test.rs index 0f2f725..a08c510 100644 --- a/src/invite/handlers/get/test.rs +++ b/src/invite/handlers/get/test.rs @@ -12,7 +12,7 @@ async fn valid_invite() { // Call endpoint - let Json(response) = super::handler(State(app), Path(invite.id)) + let Json(response) = super::handler(State(app.invites()), Path(invite.id)) .await .expect("get for an existing invite succeeds"); @@ -31,7 +31,7 @@ async fn nonexistent_invite() { // Call endpoint let invite = fixtures::invite::fictitious(); - let error = super::handler(State(app), Path(invite.clone())) + let error = super::handler(State(app.invites()), Path(invite.clone())) .await .expect_err("get for a nonexistent invite fails"); @@ -55,7 +55,7 @@ async fn expired_invite() { // Call endpoint - let error = super::handler(State(app), Path(invite.id.clone())) + let error = super::handler(State(app.invites()), Path(invite.id.clone())) .await .expect_err("get for an expired invite fails"); diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs index 4ac74cc..0549c78 100644 --- a/src/invite/handlers/issue/mod.rs +++ b/src/invite/handlers/issue/mod.rs @@ -1,19 +1,22 @@ use axum::extract::{Json, State}; use crate::{ - app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, + clock::RequestedAt, + error::Internal, + invite::{Invite, app::Invites}, + token::extract::Identity, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(invites): State, RequestedAt(issued_at): RequestedAt, identity: Identity, _: Json, ) -> Result, Internal> { - let invite = app.invites().issue(&identity.login, &issued_at).await?; + let invite = invites.issue(&identity.login, &issued_at).await?; Ok(Json(invite)) } diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs index 4421705..dc89243 100644 --- a/src/invite/handlers/issue/test.rs +++ b/src/invite/handlers/issue/test.rs @@ -13,7 +13,7 @@ async fn create_invite() { // Call the endpoint let Json(invite) = super::handler( - State(app), + State(app.invites()), issued_at.clone(), issuer.clone(), Json(super::Request {}), diff --git a/src/test/fixtures/invite.rs b/src/test/fixtures/invite.rs index 654d1b4..5a5d4d0 100644 --- a/src/test/fixtures/invite.rs +++ b/src/test/fixtures/invite.rs @@ -1,12 +1,16 @@ +use axum::extract::FromRef; + use crate::{ - app::App, clock::DateTime, - invite::{self, Invite}, + invite::{self, Invite, app::Invites}, login::Login, }; -pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite { - app.invites() +pub async fn issue(app: &App, issuer: &Login, issued_at: &DateTime) -> Invite +where + Invites: FromRef, +{ + Invites::from_ref(app) .issue(issuer, issued_at) .await .expect("issuing invites never fails") diff --git a/src/ui/handlers/invite.rs b/src/ui/handlers/invite.rs index 0f9580a..edd6dc1 100644 --- a/src/ui/handlers/invite.rs +++ b/src/ui/handlers/invite.rs @@ -4,9 +4,9 @@ use axum::{ }; use crate::{ - app::App, error::Internal, invite, + invite::app::Invites, ui::{ assets::{Asset, Assets}, error::NotFound, @@ -14,10 +14,10 @@ use crate::{ }; pub async fn handler( - State(app): State, + State(invites): State, Path(invite): Path, ) -> Result { - app.invites() + invites .get(&invite) .await .map_err(Error::internal)? -- cgit v1.2.3 From 38ac83aef9667f1a4fe86e03e53565376081179f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 17:47:30 -0400 Subject: Convert `Logins` into a freestanding component. --- src/app.rs | 10 ++++++++-- src/login/app.rs | 10 +++++----- src/login/handlers/login/mod.rs | 14 +++++++++----- src/login/handlers/login/test.rs | 8 ++++---- src/login/handlers/password/mod.rs | 8 +++----- src/login/handlers/password/test.rs | 2 +- src/test/fixtures/cookie.rs | 14 +++++++++----- 7 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2d6d62b..793bdab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,8 +50,8 @@ impl App { Invites::new(self.db.clone(), self.events.clone()) } - pub const fn logins(&self) -> Logins<'_> { - Logins::new(&self.db, &self.token_events) + pub fn logins(&self) -> Logins { + Logins::new(self.db.clone(), self.token_events.clone()) } pub const fn messages(&self) -> Messages<'_> { @@ -89,3 +89,9 @@ impl FromRef for Invites { app.invites() } } + +impl FromRef for Logins { + fn from_ref(app: &App) -> Self { + app.logins() + } +} diff --git a/src/login/app.rs b/src/login/app.rs index e471000..a2f9636 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -9,13 +9,13 @@ use crate::{ token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _}, }; -pub struct Logins<'a> { - db: &'a SqlitePool, - token_events: &'a Broadcaster, +pub struct Logins { + db: SqlitePool, + token_events: Broadcaster, } -impl<'a> Logins<'a> { - pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { +impl Logins { + pub const fn new(db: SqlitePool, token_events: Broadcaster) -> Self { Self { db, token_events } } diff --git a/src/login/handlers/login/mod.rs b/src/login/handlers/login/mod.rs index 6591984..2ce8a67 100644 --- a/src/login/handlers/login/mod.rs +++ b/src/login/handlers/login/mod.rs @@ -5,21 +5,25 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::Internal, login::app, name::Name, - password::Password, token::extract::IdentityCookie, + clock::RequestedAt, + empty::Empty, + error::Internal, + login::{app, app::Logins}, + name::Name, + password::Password, + token::extract::IdentityCookie, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(logins): State, RequestedAt(now): RequestedAt, identity: IdentityCookie, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .logins() + let secret = logins .with_password(&request.name, &request.password, &now) .await .map_err(Error)?; diff --git a/src/login/handlers/login/test.rs b/src/login/handlers/login/test.rs index f3911d0..7bb56b6 100644 --- a/src/login/handlers/login/test.rs +++ b/src/login/handlers/login/test.rs @@ -22,7 +22,7 @@ async fn correct_credentials() { password, }; let (identity, Empty) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); @@ -52,7 +52,7 @@ async fn invalid_name() { password, }; let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password fails"); @@ -77,7 +77,7 @@ async fn incorrect_password() { password: fixtures::user::propose_password(), }; let super::Error(error) = - super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + super::handler(State(app.logins()), logged_in_at, identity, Json(request)) .await .expect_err("logged in with an incorrect password"); @@ -98,7 +98,7 @@ async fn token_expires() { let logged_in_at = fixtures::ancient(); let identity = fixtures::cookie::not_logged_in(); let request = super::Request { name, password }; - let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + let (identity, _) = super::handler(State(app.logins()), 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/handlers/password/mod.rs b/src/login/handlers/password/mod.rs index 94c7fb4..8b82605 100644 --- a/src/login/handlers/password/mod.rs +++ b/src/login/handlers/password/mod.rs @@ -5,11 +5,10 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::Internal, - login::app, + login::{app, app::Logins}, password::Password, token::extract::{Identity, IdentityCookie}, }; @@ -18,14 +17,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(logins): State, RequestedAt(now): RequestedAt, identity: Identity, cookie: IdentityCookie, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .logins() + let secret = logins .change_password(&identity.login, &request.password, &request.to, &now) .await .map_err(Error)?; diff --git a/src/login/handlers/password/test.rs b/src/login/handlers/password/test.rs index ba2f28f..61d5b5a 100644 --- a/src/login/handlers/password/test.rs +++ b/src/login/handlers/password/test.rs @@ -21,7 +21,7 @@ async fn password_change() { to: to.clone(), }; let (new_cookie, Empty) = super::handler( - State(app.clone()), + State(app.logins()), fixtures::now(), identity.clone(), cookie.clone(), diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs index 7dc5083..0b5ec9b 100644 --- a/src/test/fixtures/cookie.rs +++ b/src/test/fixtures/cookie.rs @@ -1,21 +1,25 @@ +use axum::extract::FromRef; use uuid::Uuid; use crate::{ - app::App, clock::RequestedAt, name::Name, password::Password, token::extract::IdentityCookie, + clock::RequestedAt, login::app::Logins, name::Name, password::Password, + token::extract::IdentityCookie, }; pub fn not_logged_in() -> IdentityCookie { IdentityCookie::new() } -pub async fn logged_in( +pub async fn logged_in( app: &App, credentials: &(Name, Password), now: &RequestedAt, -) -> IdentityCookie { +) -> IdentityCookie +where + Logins: FromRef, +{ let (name, password) = credentials; - let secret = app - .logins() + let secret = Logins::from_ref(app) .with_password(name, password, now) .await .expect("should succeed given known-valid credentials"); -- cgit v1.2.3 From c2a3a010c67776b9a459d7ba0930630ff25a3a51 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 18:03:36 -0400 Subject: Convert the `Messages` component to a freestanding struct. --- src/app.rs | 10 ++++++++-- src/conversation/handlers/send/mod.rs | 11 ++++++----- src/conversation/handlers/send/test.rs | 6 +++--- src/message/app.rs | 10 +++++----- src/message/handlers/delete/mod.rs | 10 ++++++---- src/message/handlers/delete/test.rs | 12 ++++++------ src/test/fixtures/message.rs | 13 ++++++++----- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/app.rs b/src/app.rs index 793bdab..74e1070 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,8 +54,8 @@ impl App { Logins::new(self.db.clone(), self.token_events.clone()) } - pub const fn messages(&self) -> Messages<'_> { - Messages::new(&self.db, &self.events) + pub fn messages(&self) -> Messages { + Messages::new(self.db.clone(), self.events.clone()) } pub const fn setup(&self) -> Setup<'_> { @@ -95,3 +95,9 @@ impl FromRef for Logins { app.logins() } } + +impl FromRef for Messages { + fn from_ref(app: &App) -> Self { + app.messages() + } +} diff --git a/src/conversation/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs index c8be59c..ff63652 100644 --- a/src/conversation/handlers/send/mod.rs +++ b/src/conversation/handlers/send/mod.rs @@ -5,11 +5,13 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, conversation::handlers::PathInfo, error::{Internal, NotFound}, - message::{Body, Message, app::SendError}, + message::{ + Body, Message, + app::{Messages, SendError}, + }, token::extract::Identity, }; @@ -17,14 +19,13 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(messages): State, Path(conversation): Path, RequestedAt(sent_at): RequestedAt, identity: Identity, Json(request): Json, ) -> Result { - let message = app - .messages() + let message = messages .send(&conversation, &identity.login, &sent_at, &request.body) .await?; diff --git a/src/conversation/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index 8863090..013aaa4 100644 --- a/src/conversation/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -28,7 +28,7 @@ async fn messages_in_order() { let request = super::Request { body: body.clone() }; let _ = super::handler( - State(app.clone()), + State(app.messages()), Path(conversation.id.clone()), sent_at.clone(), sender.clone(), @@ -75,7 +75,7 @@ async fn nonexistent_conversation() { body: fixtures::message::propose(), }; let super::Error(error) = super::handler( - State(app), + State(app.messages()), Path(conversation.clone()), sent_at, sender, @@ -112,7 +112,7 @@ async fn deleted_conversation() { body: fixtures::message::propose(), }; let super::Error(error) = super::handler( - State(app), + State(app.messages()), Path(conversation.id.clone()), sent_at, sender, diff --git a/src/message/app.rs b/src/message/app.rs index 647152e..cbcbff9 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -13,13 +13,13 @@ use crate::{ user::{self, repo::Provider as _}, }; -pub struct Messages<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Messages { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Messages<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Messages { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs index 3e9a212..c09a752 100644 --- a/src/message/handlers/delete/mod.rs +++ b/src/message/handlers/delete/mod.rs @@ -5,10 +5,12 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, error::{Internal, NotFound}, - message::{self, app::DeleteError}, + message::{ + self, + app::{DeleteError, Messages}, + }, token::extract::Identity, }; @@ -16,12 +18,12 @@ use crate::{ mod test; pub async fn handler( - State(app): State, + State(messages): State, Path(message): Path, RequestedAt(deleted_at): RequestedAt, identity: Identity, ) -> Result { - app.messages() + messages .delete(&identity.login, &message, &deleted_at) .await?; diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index 05d9344..198728b 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -16,7 +16,7 @@ pub async fn delete_message() { // Send the request let response = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -52,7 +52,7 @@ pub async fn delete_invalid_message_id() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let message = fixtures::message::fictitious(); let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.clone()), fixtures::now(), deleter, @@ -83,7 +83,7 @@ pub async fn delete_deleted() { // Send the request let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -114,7 +114,7 @@ pub async fn delete_expired() { // Send the request let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), sender, @@ -150,7 +150,7 @@ pub async fn delete_purged() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), deleter, @@ -176,7 +176,7 @@ pub async fn delete_not_sender() { let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( - State(app.clone()), + State(app.messages()), Path(message.id.clone()), fixtures::now(), deleter.clone(), diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 92ac1f5..0bd0b7a 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -1,22 +1,25 @@ +use axum::extract::FromRef; use faker_rand::lorem::Paragraphs; use crate::{ - app::App, clock::RequestedAt, conversation::Conversation, login::Login, - message::{self, Body, Message}, + message::{self, Body, Message, app::Messages}, }; -pub async fn send( +pub async fn send( app: &App, conversation: &Conversation, sender: &Login, sent_at: &RequestedAt, -) -> Message { +) -> Message +where + Messages: FromRef, +{ let body = propose(); - app.messages() + Messages::from_ref(app) .send(&conversation.id, sender, sent_at, &body) .await .expect("should succeed if the conversation exists") -- cgit v1.2.3 From d66728889105f6f1ef5113d9ceb223e362df0008 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 18:15:25 -0400 Subject: Convert the `Setup` component into a freestanding struct. The changes to the setup-requiring middleware are probably more general than was strictly needed, but they will make it work with anything that can provide a `Setup` component rather than being bolted to `App` specifically, which feels tidier. --- src/app.rs | 10 ++++++++-- src/setup/app.rs | 13 +++++++------ src/setup/handlers/setup/mod.rs | 14 +++++++++----- src/setup/handlers/setup/test.rs | 6 +++--- src/setup/required.rs | 31 ++++++++++++++++++------------- src/ui/handlers/setup.rs | 7 +++---- 6 files changed, 48 insertions(+), 33 deletions(-) diff --git a/src/app.rs b/src/app.rs index 74e1070..202d542 100644 --- a/src/app.rs +++ b/src/app.rs @@ -58,8 +58,8 @@ impl App { Messages::new(self.db.clone(), self.events.clone()) } - pub const fn setup(&self) -> Setup<'_> { - Setup::new(&self.db, &self.events) + pub fn setup(&self) -> Setup { + Setup::new(self.db.clone(), self.events.clone()) } pub const fn tokens(&self) -> Tokens<'_> { @@ -101,3 +101,9 @@ impl FromRef for Messages { app.messages() } } + +impl FromRef for Setup { + fn from_ref(app: &App) -> Self { + app.setup() + } +} diff --git a/src/setup/app.rs b/src/setup/app.rs index 2a8ec30..9539406 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -10,13 +10,14 @@ use crate::{ user::create::{self, Create}, }; -pub struct Setup<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +#[derive(Clone)] +pub struct Setup { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Setup<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Setup { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } @@ -41,7 +42,7 @@ impl<'a> Setup<'a> { tx.tokens().create(&token, &secret).await?; tx.commit().await?; - stored.publish(self.events); + stored.publish(&self.events); Ok(secret) } diff --git a/src/setup/handlers/setup/mod.rs b/src/setup/handlers/setup/mod.rs index fe24798..2977da8 100644 --- a/src/setup/handlers/setup/mod.rs +++ b/src/setup/handlers/setup/mod.rs @@ -5,21 +5,25 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::Internal, name::Name, password::Password, - setup::app, token::extract::IdentityCookie, + clock::RequestedAt, + empty::Empty, + error::Internal, + name::Name, + password::Password, + setup::{app, app::Setup}, + token::extract::IdentityCookie, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(setup): State, RequestedAt(setup_at): RequestedAt, identity: IdentityCookie, Json(request): Json, ) -> Result<(IdentityCookie, Empty), Error> { - let secret = app - .setup() + let secret = setup .initial(&request.name, &request.password, &setup_at) .await .map_err(Error)?; diff --git a/src/setup/handlers/setup/test.rs b/src/setup/handlers/setup/test.rs index 283fe8b..670c111 100644 --- a/src/setup/handlers/setup/test.rs +++ b/src/setup/handlers/setup/test.rs @@ -20,7 +20,7 @@ async fn fresh_instance() { password: password.clone(), }; let (identity, Empty) = - super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + super::handler(State(app.setup()), fixtures::now(), identity, Json(request)) .await .expect("setup in a fresh app succeeds"); @@ -43,7 +43,7 @@ async fn login_exists() { let (name, password) = fixtures::user::propose(); let request = super::Request { name, password }; let super::Error(error) = - super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + super::handler(State(app.setup()), fixtures::now(), identity, Json(request)) .await .expect_err("setup in a populated app fails"); @@ -68,7 +68,7 @@ async fn invalid_name() { password: password.clone(), }; let super::Error(error) = - super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + super::handler(State(app.setup()), fixtures::now(), identity, Json(request)) .await .expect_err("setup with an invalid name fails"); diff --git a/src/setup/required.rs b/src/setup/required.rs index a2aed18..e475381 100644 --- a/src/setup/required.rs +++ b/src/setup/required.rs @@ -4,26 +4,29 @@ use std::{ }; use axum::{ - extract::Request, + extract::{FromRef, Request}, http::StatusCode, response::{IntoResponse, Response}, }; use tower::{Layer, Service}; -use crate::{app::App, error::Internal}; +use crate::{error::Internal, setup::app::Setup}; #[derive(Clone)] -pub struct Required(pub App); +pub struct Required(pub App); -impl Required { - pub fn with_fallback(self, fallback: F) -> WithFallback { +impl Required { + pub fn with_fallback(self, fallback: F) -> WithFallback { let Self(app) = self; WithFallback { app, fallback } } } -impl Layer for Required { - type Service = Middleware; +impl Layer for Required +where + Self: Clone, +{ + type Service = Middleware; fn layer(&self, inner: S) -> Self::Service { let Self(app) = self.clone(); @@ -36,16 +39,16 @@ impl Layer for Required { } #[derive(Clone)] -pub struct WithFallback { +pub struct WithFallback { app: App, fallback: F, } -impl Layer for WithFallback +impl Layer for WithFallback where Self: Clone, { - type Service = Middleware; + type Service = Middleware; fn layer(&self, inner: S) -> Self::Service { let Self { app, fallback } = self.clone(); @@ -58,17 +61,19 @@ where } #[derive(Clone)] -pub struct Middleware { +pub struct Middleware { inner: S, app: App, fallback: F, } -impl Service for Middleware +impl Service for Middleware where + Setup: FromRef, Self: Clone, S: Service + Send + 'static, S::Future: Send, + App: Send + 'static, F: IntoResponse + Clone + Send + 'static, { type Response = S::Response; @@ -87,7 +92,7 @@ where } = self.clone(); Box::pin(async move { - match app.setup().completed().await { + match Setup::from_ref(&app).completed().await { Ok(true) => inner.call(req).await, Ok(false) => Ok(fallback.into_response()), Err(error) => Ok(Internal::from(error).into_response()), diff --git a/src/ui/handlers/setup.rs b/src/ui/handlers/setup.rs index 49821cf..5707765 100644 --- a/src/ui/handlers/setup.rs +++ b/src/ui/handlers/setup.rs @@ -4,14 +4,13 @@ use axum::{ }; use crate::{ - app::App, error::Internal, + setup::app::Setup, ui::assets::{Asset, Assets}, }; -pub async fn handler(State(app): State) -> Result { - if app - .setup() +pub async fn handler(State(setup): State) -> Result { + if setup .completed() .await .map_err(Internal::from) -- cgit v1.2.3 From be21b088f0d1b591cbd8dcfed1e06f2742a524d0 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 18:23:34 -0400 Subject: Convert the `Tokens` component into a freestanding struct. As with the `Setup` component, I've generalized the associated middleware across anything that can provide a `Tokens`, where possible. --- src/app.rs | 10 ++++++++-- src/login/handlers/logout/mod.rs | 9 ++++----- src/login/handlers/logout/test.rs | 25 +++++++++++++++++-------- src/test/verify/identity.rs | 26 +++++++++++++++++++------- src/test/verify/token.rs | 29 ++++++++++++++++++----------- src/token/app.rs | 10 +++++----- src/token/extract/identity.rs | 24 +++++++++++++++++------- 7 files changed, 88 insertions(+), 45 deletions(-) diff --git a/src/app.rs b/src/app.rs index 202d542..6f6e8ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -62,8 +62,8 @@ impl App { Setup::new(self.db.clone(), self.events.clone()) } - pub const fn tokens(&self) -> Tokens<'_> { - Tokens::new(&self.db, &self.token_events) + pub fn tokens(&self) -> Tokens { + Tokens::new(self.db.clone(), self.token_events.clone()) } #[cfg(test)] @@ -107,3 +107,9 @@ impl FromRef for Setup { app.setup() } } + +impl FromRef for Tokens { + fn from_ref(app: &App) -> Self { + app.tokens() + } +} diff --git a/src/login/handlers/logout/mod.rs b/src/login/handlers/logout/mod.rs index 73efe73..ce4cb1a 100644 --- a/src/login/handlers/logout/mod.rs +++ b/src/login/handlers/logout/mod.rs @@ -4,25 +4,24 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, empty::Empty, error::{Internal, Unauthorized}, - token::{app, extract::IdentityCookie}, + token::{app, app::Tokens, extract::IdentityCookie}, }; #[cfg(test)] mod test; pub async fn handler( - State(app): State, + State(tokens): State, RequestedAt(now): RequestedAt, identity: IdentityCookie, Json(_): Json, ) -> Result<(IdentityCookie, Empty), Error> { if let Some(secret) = identity.secret() { - let identity = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&identity.token).await?; + let identity = tokens.validate(&secret, &now).await?; + tokens.logout(&identity.token).await?; } let identity = identity.clear(); diff --git a/src/login/handlers/logout/test.rs b/src/login/handlers/logout/test.rs index e7b7dd4..18744ed 100644 --- a/src/login/handlers/logout/test.rs +++ b/src/login/handlers/logout/test.rs @@ -18,7 +18,7 @@ async fn successful() { // Call the endpoint let (response_identity, Empty) = super::handler( - State(app.clone()), + State(app.tokens()), fixtures::now(), identity.clone(), Json::default(), @@ -42,9 +42,14 @@ async fn no_identity() { // Call the endpoint let identity = fixtures::cookie::not_logged_in(); - let (identity, Empty) = super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect("logged out with no token succeeds"); + let (identity, Empty) = super::handler( + State(app.tokens()), + fixtures::now(), + identity, + Json::default(), + ) + .await + .expect("logged out with no token succeeds"); // Verify the return value's basic structure @@ -60,10 +65,14 @@ async fn invalid_token() { // Call the endpoint let identity = fixtures::cookie::fictitious(); - let super::Error(error) = - super::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect_err("logged out with an invalid token fails"); + let super::Error(error) = super::handler( + State(app.tokens()), + fixtures::now(), + identity, + Json::default(), + ) + .await + .expect_err("logged out with an invalid token fails"); // Verify the return value's basic structure diff --git a/src/test/verify/identity.rs b/src/test/verify/identity.rs index 8e2d36e..fba2a4d 100644 --- a/src/test/verify/identity.rs +++ b/src/test/verify/identity.rs @@ -1,31 +1,43 @@ +use axum::extract::FromRef; + use crate::{ - app::App, login::Login, name::Name, test::{fixtures, verify}, - token::{app::ValidateError, extract::IdentityCookie}, + token::{ + app::{Tokens, ValidateError}, + extract::IdentityCookie, + }, }; -pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) { +pub async fn valid_for_name(app: &App, identity: &IdentityCookie, name: &Name) +where + Tokens: FromRef, +{ let secret = identity .secret() .expect("identity cookie must be set to be valid"); verify::token::valid_for_name(app, &secret, name).await; } -pub async fn valid_for_login(app: &App, identity: &IdentityCookie, login: &Login) { +pub async fn valid_for_login(app: &App, identity: &IdentityCookie, login: &Login) +where + Tokens: FromRef, +{ let secret = identity .secret() .expect("identity cookie must be set to be valid"); verify::token::valid_for_login(app, &secret, login).await; } -pub async fn invalid(app: &App, identity: &IdentityCookie) { +pub async fn invalid(app: &App, identity: &IdentityCookie) +where + Tokens: FromRef, +{ let secret = identity .secret() .expect("identity cookie must be set to be invalid"); - let validate_err = app - .tokens() + let validate_err = Tokens::from_ref(app) .validate(&secret, &fixtures::now()) .await .expect_err("identity cookie secret must be invalid"); diff --git a/src/test/verify/token.rs b/src/test/verify/token.rs index adc4397..1b61a19 100644 --- a/src/test/verify/token.rs +++ b/src/test/verify/token.rs @@ -1,32 +1,39 @@ +use axum::extract::FromRef; + use crate::{ - app::App, login::Login, name::Name, test::fixtures, - token::{Secret, app}, + token::{Secret, app, app::Tokens}, }; -pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) { - let identity = app - .tokens() +pub async fn valid_for_name(app: &App, secret: &Secret, name: &Name) +where + Tokens: FromRef, +{ + let identity = Tokens::from_ref(app) .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); assert_eq!(name, &identity.login.name); } -pub async fn valid_for_login(app: &App, secret: &Secret, login: &Login) { - let identity = app - .tokens() +pub async fn valid_for_login(app: &App, secret: &Secret, login: &Login) +where + Tokens: FromRef, +{ + let identity = Tokens::from_ref(app) .validate(secret, &fixtures::now()) .await .expect("provided secret is valid"); assert_eq!(login, &identity.login); } -pub async fn invalid(app: &App, secret: &Secret) { - let error = app - .tokens() +pub async fn invalid(app: &App, secret: &Secret) +where + Tokens: FromRef, +{ + let error = Tokens::from_ref(app) .validate(secret, &fixtures::now()) .await .expect_err("provided secret is invalid"); diff --git a/src/token/app.rs b/src/token/app.rs index 1d68f32..332473d 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -12,13 +12,13 @@ use super::{ }; use crate::{clock::DateTime, db::NotFound as _, name}; -pub struct Tokens<'a> { - db: &'a SqlitePool, - token_events: &'a Broadcaster, +pub struct Tokens { + db: SqlitePool, + token_events: Broadcaster, } -impl<'a> Tokens<'a> { - pub const fn new(db: &'a SqlitePool, token_events: &'a Broadcaster) -> Self { +impl Tokens { + pub const fn new(db: SqlitePool, token_events: Broadcaster) -> Self { Self { db, token_events } } diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index bee4e31..5c004ef 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -1,16 +1,18 @@ use axum::{ - extract::{FromRequestParts, OptionalFromRequestParts, State}, + extract::{FromRef, FromRequestParts, OptionalFromRequestParts, State}, http::request::Parts, response::{IntoResponse, Response}, }; use super::IdentityCookie; use crate::{ - app::App, clock::RequestedAt, error::{Internal, Unauthorized}, login::Login, - token::{Token, app::ValidateError}, + token::{ + Token, + app::{Tokens, ValidateError}, + }, }; #[derive(Clone, Debug)] @@ -19,7 +21,11 @@ pub struct Identity { pub login: Login, } -impl FromRequestParts for Identity { +impl FromRequestParts for Identity +where + Tokens: FromRef, + App: Send + Sync, +{ type Rejection = LoginError; async fn from_request_parts(parts: &mut Parts, state: &App) -> Result { @@ -28,8 +34,8 @@ impl FromRequestParts for Identity { let secret = cookie.secret().ok_or(LoginError::Unauthorized)?; - let app = State::::from_request_parts(parts, state).await?; - app.tokens() + let tokens = State::::from_request_parts(parts, state).await?; + tokens .validate(&secret, &used_at) .await .map_err(|err| match err { @@ -39,7 +45,11 @@ impl FromRequestParts for Identity { } } -impl OptionalFromRequestParts for Identity { +impl OptionalFromRequestParts for Identity +where + Tokens: FromRef, + App: Send + Sync, +{ type Rejection = LoginError; async fn from_request_parts( -- cgit v1.2.3 From 9bd5ea4b4292761b0b2f823c887f114459c80d8f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 27 Oct 2025 18:26:41 -0400 Subject: Convert the `Users` component into a freestanding struct. Because `Users` is test-only and is not used in any endpoints, it doesn't need a FromRef impl. --- src/app.rs | 4 ++-- src/user/app.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6f6e8ba..ad19bc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,8 +67,8 @@ impl App { } #[cfg(test)] - pub const fn users(&self) -> Users<'_> { - Users::new(&self.db, &self.events) + pub fn users(&self) -> Users { + Users::new(self.db.clone(), self.events.clone()) } } diff --git a/src/user/app.rs b/src/user/app.rs index 0d6046c..891e3b9 100644 --- a/src/user/app.rs +++ b/src/user/app.rs @@ -3,13 +3,13 @@ use sqlx::sqlite::SqlitePool; use super::create::{self, Create}; use crate::{clock::DateTime, event::Broadcaster, login::Login, name::Name, password::Password}; -pub struct Users<'a> { - db: &'a SqlitePool, - events: &'a Broadcaster, +pub struct Users { + db: SqlitePool, + events: Broadcaster, } -impl<'a> Users<'a> { - pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { +impl Users { + pub const fn new(db: SqlitePool, events: Broadcaster) -> Self { Self { db, events } } @@ -27,7 +27,7 @@ impl<'a> Users<'a> { tx.commit().await?; let login = stored.login().to_owned(); - stored.publish(self.events); + stored.publish(&self.events); Ok(login) } -- cgit v1.2.3 From dc7074bbf39aff895ba52abd5e7b7e9bb643cf27 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 28 Oct 2025 01:41:13 -0400 Subject: Convert the last stray tests to be generic over components deriveable from an App. There are a few places in the test fixtures that still call `App` methods directly, as they call `app.users()` (which, as per previous commits, has no `FromRef` impl). --- src/test/fixtures/identity.rs | 21 ++++++++++++++++----- src/test/verify/login.rs | 23 +++++++++++++++-------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 93e4a38..20929f9 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -1,11 +1,15 @@ +use axum::extract::FromRef; + use crate::{ app::App, clock::RequestedAt, + login::app::Logins, name::Name, password::Password, test::fixtures, token::{ Token, + app::Tokens, extract::{Identity, IdentityCookie}, }, }; @@ -15,23 +19,30 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Identity { logged_in(app, &credentials, created_at).await } -pub async fn from_cookie( +pub async fn from_cookie( app: &App, cookie: &IdentityCookie, validated_at: &RequestedAt, -) -> Identity { +) -> Identity +where + Tokens: FromRef, +{ let secret = cookie.secret().expect("identity token has a secret"); - app.tokens() + Tokens::from_ref(app) .validate(&secret, validated_at) .await .expect("always validates newly-issued secret") } -pub async fn logged_in( +pub async fn logged_in( app: &App, credentials: &(Name, Password), issued_at: &RequestedAt, -) -> Identity { +) -> Identity +where + Tokens: FromRef, + Logins: FromRef, +{ let secret = fixtures::cookie::logged_in(app, credentials, issued_at).await; from_cookie(app, &secret, issued_at).await } diff --git a/src/test/verify/login.rs b/src/test/verify/login.rs index ae2e91e..aad01bc 100644 --- a/src/test/verify/login.rs +++ b/src/test/verify/login.rs @@ -1,23 +1,30 @@ +use axum::extract::FromRef; + use crate::{ - app::App, - login::app::LoginError, + login::app::{LoginError, Logins}, name::Name, password::Password, test::{fixtures, verify}, + token::app::Tokens, }; -pub async fn valid_login(app: &App, name: &Name, password: &Password) { - let secret = app - .logins() +pub async fn valid_login(app: &App, name: &Name, password: &Password) +where + Logins: FromRef, + Tokens: FromRef, +{ + let secret = Logins::from_ref(app) .with_password(name, password, &fixtures::now()) .await .expect("login credentials expected to be valid"); verify::token::valid_for_name(&app, &secret, &name).await; } -pub async fn invalid_login(app: &App, name: &Name, password: &Password) { - let error = app - .logins() +pub async fn invalid_login(app: &App, name: &Name, password: &Password) +where + Logins: FromRef, +{ + let error = Logins::from_ref(app) .with_password(name, password, &fixtures::now()) .await .expect_err("login credentials expected not to be valid"); -- cgit v1.2.3