From d84041d6e3c49904ef57d5fe7f5d17a01620035e Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Fri, 31 Jan 2025 14:30:08 -0800 Subject: [PATCH] set up /say endpoint --- migrations/2024-11-25-232658_init/up.sql | 8 +- .../2025-01-31-045014_messages/down.sql | 1 + migrations/2025-01-31-045014_messages/up.sql | 8 ++ src/api_keys.rs | 38 ++++++-- src/app_error.rs | 2 +- src/main.rs | 2 + src/messages.rs | 42 +++++++++ src/projects.rs | 19 ++-- src/router.rs | 34 ++++--- src/schema.rs | 13 +++ src/teams.rs | 23 +++-- src/users.rs | 58 +++++++++--- src/v0_router.rs | 89 +++++++++++++++++++ templates/nav.html | 3 + templates/new-team.html | 2 +- templates/projects.html | 39 ++++++-- templates/teams.html | 46 +++++----- 17 files changed, 345 insertions(+), 82 deletions(-) create mode 100644 migrations/2025-01-31-045014_messages/down.sql create mode 100644 migrations/2025-01-31-045014_messages/up.sql create mode 100644 src/messages.rs create mode 100644 src/v0_router.rs diff --git a/migrations/2024-11-25-232658_init/up.sql b/migrations/2024-11-25-232658_init/up.sql index 8a92256..6432d10 100644 --- a/migrations/2024-11-25-232658_init/up.sql +++ b/migrations/2024-11-25-232658_init/up.sql @@ -28,12 +28,16 @@ CREATE INDEX ON team_memberships (user_id); CREATE TABLE api_keys ( id UUID NOT NULL PRIMARY KEY, - team_id UUID NOT NULL REFERENCES teams(id) + team_id UUID NOT NULL REFERENCES teams(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ ); CREATE INDEX ON api_Keys (team_id); CREATE TABLE projects ( id UUID NOT NULL PRIMARY KEY, team_id UUID NOT NULL REFERENCES teams(id), - name TEXT NOT NULL + name TEXT NOT NULL, + UNIQUE (team_id, name) ); +CREATE INDEX ON projects(team_id); diff --git a/migrations/2025-01-31-045014_messages/down.sql b/migrations/2025-01-31-045014_messages/down.sql new file mode 100644 index 0000000..cbe8189 --- /dev/null +++ b/migrations/2025-01-31-045014_messages/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS messages; diff --git a/migrations/2025-01-31-045014_messages/up.sql b/migrations/2025-01-31-045014_messages/up.sql new file mode 100644 index 0000000..902a1a3 --- /dev/null +++ b/migrations/2025-01-31-045014_messages/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY NOT NULL, + project_id UUID NOT NULL REFERENCES projects (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + message TEXT NOT NULL +); +CREATE INDEX ON messages (project_id); +CREATE INDEX ON messages (created_at); diff --git a/src/api_keys.rs b/src/api_keys.rs index 98736b1..8d3e5de 100644 --- a/src/api_keys.rs +++ b/src/api_keys.rs @@ -1,30 +1,34 @@ +use chrono::{DateTime, Utc}; use deadpool_diesel::postgres::Connection; -use diesel::prelude::*; +use diesel::{ + dsl::{auto_type, AsSelect}, + pg::Pg, + prelude::*, +}; use uuid::Uuid; -use crate::{app_error::AppError, schema::api_keys::dsl::*, teams::Team}; +use crate::{app_error::AppError, schema::api_keys, teams::Team}; #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] -#[diesel(table_name = crate::schema::api_keys)] +#[diesel(table_name = api_keys)] #[diesel(belongs_to(Team))] pub struct ApiKey { pub id: Uuid, pub team_id: Uuid, + pub last_used_at: Option>, } impl ApiKey { - pub async fn generate_for_team( - db_conn: &Connection, - key_team_id: Uuid, - ) -> Result { + pub async fn generate_for_team(db_conn: &Connection, team_id: Uuid) -> Result { let api_key = Self { + team_id, id: Uuid::new_v4(), - team_id: key_team_id, + last_used_at: None, }; let api_key_copy = api_key.clone(); db_conn .interact(move |conn| { - diesel::insert_into(api_keys) + diesel::insert_into(api_keys::table) .values(api_key_copy) .execute(conn) }) @@ -32,4 +36,20 @@ impl ApiKey { .unwrap()?; Ok(api_key) } + + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = ApiKey::as_select(); + api_keys::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_id(id: Uuid) -> _ { + api_keys::id.eq(id) + } + + #[auto_type(no_type_alias)] + pub fn with_team(team_id: Uuid) -> _ { + api_keys::team_id.eq(team_id) + } } diff --git a/src/app_error.rs b/src/app_error.rs index 88274a4..c65a06a 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -15,7 +15,7 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { match self { Self::InternalServerError(err) => { - tracing::error!("Application error: {}", err); + tracing::error!("Application error: {:?}", err); (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response() } Self::ForbiddenError(client_message) => { diff --git a/src/main.rs b/src/main.rs index dbd5545..9f6d7dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app_state; mod auth; mod csrf; mod guards; +mod messages; mod projects; mod router; mod schema; @@ -12,6 +13,7 @@ mod settings; mod team_memberships; mod teams; mod users; +mod v0_router; use tracing_subscriber::EnvFilter; diff --git a/src/messages.rs b/src/messages.rs new file mode 100644 index 0000000..4d95e41 --- /dev/null +++ b/src/messages.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use diesel::{ + dsl::{auto_type, AsSelect}, + pg::Pg, + prelude::*, +}; +use uuid::Uuid; + +use crate::{projects::Project, schema::messages}; + +#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] +#[diesel(table_name = messages)] +#[diesel(belongs_to(Project))] +pub struct Message { + pub id: Uuid, + pub project_id: Uuid, + pub created_at: DateTime, + pub message: String, +} + +impl Message { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = Message::as_select(); + messages::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_project(project_id: Uuid) -> _ { + messages::project_id.eq(project_id) + } + + #[auto_type(no_type_alias)] + pub fn values_now(project_id: Uuid, message: String) -> _ { + let id: Uuid = Uuid::now_v7(); + ( + messages::id.eq(id), + messages::project_id.eq(project_id), + messages::message.eq(message), + ) + } +} diff --git a/src/projects.rs b/src/projects.rs index 0c29710..8dad9ca 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -5,13 +5,10 @@ use diesel::{ }; use uuid::Uuid; -use crate::{ - schema::{self, projects::dsl::*}, - teams::Team, -}; +use crate::{schema::projects, teams::Team}; #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] -#[diesel(table_name = schema::projects)] +#[diesel(table_name = projects)] #[diesel(belongs_to(Team))] pub struct Project { pub id: Uuid, @@ -23,6 +20,16 @@ impl Project { #[auto_type(no_type_alias)] pub fn all() -> _ { let select: AsSelect = Project::as_select(); - projects.select(select) + projects::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_team(team_id: Uuid) -> _ { + projects::team_id.eq(team_id) + } + + #[auto_type(no_type_alias)] + pub fn with_name(name: String) -> _ { + projects::name.eq(name) } } diff --git a/src/router.rs b/src/router.rs index 6f7fbcb..8715967 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use askama_axum::Template; use axum::{ extract::{Path, State}, @@ -28,6 +29,7 @@ use crate::{ team_memberships::TeamMembership, teams::Team, users::{CurrentUser, User}, + v0_router, }; pub fn new_router(state: AppState) -> Router<()> { @@ -36,6 +38,7 @@ pub fn new_router(state: AppState) -> Router<()> { format!("{}", base_path).as_str(), Router::new() .route("/", get(landing_page)) + .nest("/v0", v0_router::new_router(state.clone())) .route("/teams", get(teams_page)) .route("/teams/{team_id}", get(team_page)) .route("/teams/{team_id}/projects", get(projects_page)) @@ -64,17 +67,13 @@ async fn teams_page( DbConn(conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { - let current_user_id = current_user.id.clone(); - let teams_of_current_user = conn - .interact(move |conn| { - schema::team_memberships::table - .inner_join(schema::teams::table) - .filter(schema::team_memberships::user_id.eq(current_user_id)) - .select(Team::as_select()) - .load(conn) - }) + let team_memberships_query = current_user.clone().team_memberships(); + let teams: Vec = conn + .interact(move |conn| team_memberships_query.load(conn)) .await - .unwrap()?; + .unwrap() + .context("failed to load team memberships") + .map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())?; #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { @@ -86,7 +85,7 @@ async fn teams_page( ResponseTemplate { base_path, current_user, - teams: teams_of_current_user, + teams, } .render()?, )) @@ -129,6 +128,7 @@ async fn new_team_page( CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; + #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { @@ -197,16 +197,14 @@ async fn projects_page( ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; - let team_id = team.id.clone(); - let api_keys = db_conn + let api_keys_query = team.clone().api_keys(); + let (api_keys, projects) = db_conn .interact(move |conn| { - schema::api_keys::table - .filter(schema::api_keys::team_id.eq(team_id)) - .select(ApiKey::as_select()) - .load(conn) + diesel::QueryResult::Ok((api_keys_query.load(conn)?, Project::all().load(conn)?)) }) .await .unwrap()?; + #[derive(Template)] #[template(path = "projects.html")] struct ResponseTemplate { @@ -223,9 +221,9 @@ async fn projects_page( base_path, csrf_token, current_user, + projects, team, keys: api_keys, - projects: vec![], } .render()?, ) diff --git a/src/schema.rs b/src/schema.rs index 57b0f71..a2c001c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -4,6 +4,8 @@ diesel::table! { api_keys (id) { id -> Uuid, team_id -> Uuid, + created_at -> Timestamptz, + last_used_at -> Nullable, } } @@ -24,6 +26,15 @@ diesel::table! { } } +diesel::table! { + messages (id) { + id -> Uuid, + project_id -> Uuid, + created_at -> Timestamptz, + message -> Text, + } +} + diesel::table! { projects (id) { id -> Uuid, @@ -57,6 +68,7 @@ diesel::table! { diesel::joinable!(api_keys -> teams (team_id)); diesel::joinable!(csrf_tokens -> users (user_id)); +diesel::joinable!(messages -> projects (project_id)); diesel::joinable!(projects -> teams (team_id)); diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> users (user_id)); @@ -65,6 +77,7 @@ diesel::allow_tables_to_appear_in_same_query!( api_keys, browser_sessions, csrf_tokens, + messages, projects, team_memberships, teams, diff --git a/src/teams.rs b/src/teams.rs index 32423fb..673d78d 100644 --- a/src/teams.rs +++ b/src/teams.rs @@ -1,14 +1,17 @@ use diesel::{ - dsl::{AsSelect, Select}, + dsl::{auto_type, AsSelect, Eq}, pg::Pg, prelude::*, }; use uuid::Uuid; -use crate::schema::teams::dsl::*; +use crate::{ + api_keys::ApiKey, + schema::{api_keys, teams}, +}; #[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] -#[diesel(table_name = crate::schema::teams)] +#[diesel(table_name = teams)] #[diesel(check_for_backend(Pg))] pub struct Team { pub id: Uuid, @@ -16,7 +19,17 @@ pub struct Team { } impl Team { - pub fn all() -> Select> { - teams.select(Team::as_select()) + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = Team::as_select(); + teams::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn api_keys(self) -> _ { + let all: diesel::dsl::Select> = ApiKey::all(); + let id: Uuid = self.id; + let filter: Eq = ApiKey::with_team(id); + all.filter(filter) } } diff --git a/src/users.rs b/src/users.rs index f34149c..1e178ed 100644 --- a/src/users.rs +++ b/src/users.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use axum::{ extract::FromRequestParts, http::request::Parts, @@ -7,17 +8,24 @@ use axum::{ use diesel::{ associations::Identifiable, deserialize::Queryable, - dsl::{insert_into, AsSelect, Eq, Select}, + dsl::{auto_type, insert_into, AsSelect, Eq, Select}, pg::Pg, prelude::*, Selectable, }; use uuid::Uuid; -use crate::{app_error::AppError, app_state::AppState, auth::AuthInfo, schema::users::dsl::*}; +use crate::{ + app_error::AppError, + app_state::AppState, + auth::AuthInfo, + schema::{team_memberships, teams, users}, + team_memberships::TeamMembership, + teams::Team, +}; #[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] -#[diesel(table_name = crate::schema::users)] +#[diesel(table_name = users)] #[diesel(check_for_backend(Pg))] pub struct User { pub id: Uuid, @@ -26,12 +34,24 @@ pub struct User { } impl User { - pub fn all() -> Select> { - users.select(User::as_select()) + pub fn all() -> Select> { + users::table.select(User::as_select()) } - pub fn with_uid(uid_value: &str) -> Eq { - uid.eq(uid_value) + pub fn with_uid(uid_value: &str) -> Eq { + users::uid.eq(uid_value) + } + + #[auto_type(no_type_alias)] + pub fn team_memberships(self) -> _ { + let user_id: Uuid = self.id.clone(); + let user_id_filter: Eq = + TeamMembership::with_user_id(user_id); + let select: AsSelect<(TeamMembership, Team), Pg> = <(TeamMembership, Team)>::as_select(); + team_memberships::table + .inner_join(teams::table) + .filter(user_id_filter) + .select(select) } } @@ -58,21 +78,37 @@ impl FromRequestParts for CurrentUser { let maybe_current_user = User::all() .filter(User::with_uid(&auth_info.sub)) .first(conn) - .optional()?; + .optional() + .context("failed to load maybe_current_user")?; if let Some(current_user) = maybe_current_user { return Ok(current_user); } let new_user = User { id: Uuid::now_v7(), - uid: auth_info.sub, + uid: auth_info.sub.clone(), email: auth_info.email, }; - insert_into(users) + match insert_into(users::table) .values(new_user) - .on_conflict(uid) + .on_conflict(users::uid) .do_nothing() .returning(User::as_returning()) .get_result(conn) + { + QueryResult::Err(diesel::result::Error::NotFound) => { + tracing::debug!("detected race to insert current user record"); + User::all() + .filter(User::with_uid(&auth_info.sub)) + .first(conn) + .context( + "failed to load record after detecting race to insert current user", + ) + } + QueryResult::Err(err) => { + Err(err).context("failed to insert current user record") + } + QueryResult::Ok(result) => Ok(result), + } }) .await .unwrap() diff --git a/src/v0_router.rs b/src/v0_router.rs new file mode 100644 index 0000000..10e8d0f --- /dev/null +++ b/src/v0_router.rs @@ -0,0 +1,89 @@ +use anyhow::Context; +use axum::{ + extract::Query, + response::{IntoResponse, Json}, + routing::get, + Router, +}; +use diesel::{dsl::insert_into, prelude::*, update}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + api_keys::ApiKey, + app_error::AppError, + app_state::{AppState, DbConn}, + messages::Message, + projects::Project, + schema::{api_keys, messages, projects}, +}; + +pub fn new_router(state: AppState) -> Router { + Router::new().route("/say", get(say_get)).with_state(state) +} + +#[derive(Deserialize)] +struct SayQuery { + key: Uuid, + project: String, + message: String, +} + +async fn say_get( + DbConn(db_conn): DbConn, + Query(query): Query, +) -> Result { + let key = query.key.clone(); + let maybe_api_key = db_conn + .interact(move |conn| { + update(api_keys::table.filter(ApiKey::with_id(key))) + .set(api_keys::last_used_at.eq(diesel::dsl::now)) + .returning(ApiKey::as_returning()) + .get_result(conn) + .optional() + }) + .await + .unwrap() + .context("unable to get API key")?; + let api_key = match maybe_api_key { + Some(api_key) => api_key, + None => return Err(AppError::ForbiddenError("key not accepted".to_string())), + }; + let project_name = query.project.to_lowercase(); + let project = db_conn + .interact(move |conn| { + insert_into(projects::table) + .values(( + projects::id.eq(Uuid::now_v7()), + projects::team_id.eq(api_key.team_id.clone()), + projects::name.eq(project_name.clone()), + )) + .on_conflict((projects::team_id, projects::name)) + .do_nothing() + .execute(conn)?; + // It would be nice to merge these two database operations into one, + // but it's not trivial to do so without faking an update; refer to: + // https://stackoverflow.com/a/42217872 + Project::all() + .filter(Project::with_team(api_key.team_id)) + .filter(Project::with_name(project_name)) + .first(conn) + }) + .await + .unwrap() + .context("unable to get project")?; + db_conn + .interact(move |conn| { + insert_into(messages::table) + .values(Message::values_now(project.id, query.message)) + .execute(conn) + }) + .await + .unwrap() + .context("unable to insert message")?; + #[derive(Serialize)] + struct ResponseBody { + ok: bool, + } + Ok(Json(ResponseBody { ok: true })) +} diff --git a/templates/nav.html b/templates/nav.html index b2f6b02..0ffddb8 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -20,6 +20,9 @@ +