From 58bf5062409f2c4d6ebceb24a635810aacb19554 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 6 Jul 2023 20:08:55 +0200 Subject: [PATCH] kept you waiting huh? (I'm sorry i forgor) --- Cargo.lock | 1 + Cargo.toml | 1 + src/auth/mod.rs | 1 - src/auth/session.rs | 7 -- src/auth/user.rs | 7 -- src/content/mod.rs | 14 ++++ src/{content.rs => content/page.rs} | 31 +++------ src/content/site.rs | 75 ++++++++++++++++++++ src/database/mod.rs | 19 ++++++ src/database/page.rs | 26 +++++++ src/database/site.rs | 98 +++++++++++++++++++++++++++ src/http/error.rs | 19 +++++- src/http/{extract.rs => json.rs} | 0 src/http/mod.rs | 4 +- src/http/repository.rs | 20 ++++++ src/{auth/http.rs => http/session.rs} | 8 ++- src/main.rs | 3 +- src/routes/auth.rs | 11 ++- src/routes/posts.rs | 35 +++++----- src/routes/sites.rs | 83 ++++++++--------------- 20 files changed, 342 insertions(+), 121 deletions(-) create mode 100644 src/content/mod.rs rename src/{content.rs => content/page.rs} (72%) create mode 100644 src/content/site.rs create mode 100644 src/database/mod.rs create mode 100644 src/database/page.rs create mode 100644 src/database/site.rs rename src/http/{extract.rs => json.rs} (100%) create mode 100644 src/http/repository.rs rename src/{auth/http.rs => http/session.rs} (97%) diff --git a/Cargo.lock b/Cargo.lock index defa29a..05edb3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -761,6 +761,7 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", + "async-trait", "axum", "axum-macros", "chrono", diff --git a/Cargo.toml b/Cargo.toml index bff2068..c3850ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ anyhow = "1.0" argon2 = { version = "0.5", features = ["std", "alloc"] } url = "2.4" thiserror = "1.0" +async-trait = "0.1" [profile.dev.package.sqlx-macros] opt-level = 3 \ No newline at end of file diff --git a/src/auth/mod.rs b/src/auth/mod.rs index ae26496..3a1b4ad 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,3 @@ pub mod hash; -pub mod http; pub mod session; pub mod user; diff --git a/src/auth/session.rs b/src/auth/session.rs index 181bdfb..ab8ebdb 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -137,13 +137,6 @@ impl Session { )) } - pub async fn user(self: &Self, pool: &Pool) -> Result> { - match User::get_id(pool, self.actor).await? { - Some(user) => Ok(user), - None => Err(USER_NOT_FOUND), - } - } - pub async fn destroy(self: Self, pool: &Pool) -> Result<()> { sqlx::query!("DELETE FROM sessions WHERE id = $1", self.id) .execute(pool) diff --git a/src/auth/user.rs b/src/auth/user.rs index ca6ded5..5dd7c35 100644 --- a/src/auth/user.rs +++ b/src/auth/user.rs @@ -76,13 +76,6 @@ impl User { }) } - pub async fn get_id(pool: &Pool, id: Uuid) -> Result> { - Ok(sqlx::query_as("SELECT * FROM users WHERE id = $1") - .bind(id) - .fetch_optional(pool) - .await?) - } - pub async fn find(pool: &Pool, name: &str) -> Result> { Ok(sqlx::query_as("SELECT * FROM users WHERE name = $1") .bind(name) diff --git a/src/content/mod.rs b/src/content/mod.rs new file mode 100644 index 0000000..054fbfc --- /dev/null +++ b/src/content/mod.rs @@ -0,0 +1,14 @@ +pub mod page; +pub mod site; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Resource not found")] + NotFound, + + #[error("Resource identifier not available")] + IdentifierNotAvailable, + + #[error("Database error: {0}")] + DatabaseError(#[from] sqlx::Error), +} diff --git a/src/content.rs b/src/content/page.rs similarity index 72% rename from src/content.rs rename to src/content/page.rs index f00a348..107551b 100644 --- a/src/content.rs +++ b/src/content/page.rs @@ -1,30 +1,10 @@ -use chrono::{serde::*, DateTime, NaiveDateTime, Utc}; +use async_trait::async_trait; +use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::{types::Json, FromRow}; use uuid::Uuid; -#[derive(Deserialize, Serialize, FromRow)] -pub struct Site { - /// Site internal ID - pub id: Uuid, - - /// Site owner (user) - pub owner: Uuid, - - /// Site name (unique per instance, shows up in URLs) - pub name: String, - - /// Site's displayed name - pub title: String, - - /// Site description (like a user's bio) - pub description: Option, - - /// Times - pub created_at: NaiveDateTime, - pub modified_at: Option, - pub deleted_at: Option, -} +use super::Error; #[derive(Deserialize, Serialize, FromRow)] pub struct Page { @@ -58,6 +38,11 @@ pub struct Page { pub deleted_at: Option, } +#[async_trait] +pub trait PageRepository { + async fn get_page_from_url(&self, site: &str, slug: &str) -> Result; +} + #[derive(Deserialize, Serialize)] #[serde(tag = "kind")] pub enum PageBlock { diff --git a/src/content/site.rs b/src/content/site.rs new file mode 100644 index 0000000..2b50af2 --- /dev/null +++ b/src/content/site.rs @@ -0,0 +1,75 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +use super::Error; + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Site { + /// Site internal ID + pub id: Uuid, + + /// Site owner (user) + pub owner: Uuid, + + /// Site name (unique per instance, shows up in URLs) + pub name: String, + + /// Site's displayed name + pub title: String, + + /// Site description (like a user's bio) + pub description: Option, + + /// Times + pub created_at: NaiveDateTime, + pub modified_at: Option, + pub deleted_at: Option, +} + +/// More useful version of Site for showing to users +#[derive(Serialize)] +pub struct APISite { + pub name: String, + pub owner: String, + pub title: String, + pub description: Option, + pub created_at: NaiveDateTime, +} + +/// Data required to create a new site +#[derive(Deserialize)] +pub struct CreateSiteOptions { + pub name: String, + pub title: String, +} + +/// Data required to update a site's info +#[derive(Deserialize)] +pub struct UpdateSiteOptions { + pub name: Option, + pub title: Option, + pub description: Option, +} + +#[async_trait] +pub trait SiteRepository { + /// Retrieve site info from site name/slug + async fn get_site_by_name(&self, name: &str) -> Result; + + /// Create a new instance of a site and store it + async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error>; + + /// Update an existing site's info + async fn update_site( + &self, + owner: &Uuid, + name: &str, + options: &UpdateSiteOptions, + ) -> Result<(), Error>; + + /// Delete a site + async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>; +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..f05b914 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,19 @@ +use sqlx::PgPool; +use std::sync::Arc; + +use crate::state::AppState; + +pub mod page; +pub mod site; + +pub struct Repository { + pool: PgPool, +} + +impl From<&Arc> for Repository { + fn from(state: &Arc) -> Self { + Repository { + pool: state.database.clone(), + } + } +} diff --git a/src/database/page.rs b/src/database/page.rs new file mode 100644 index 0000000..183b91b --- /dev/null +++ b/src/database/page.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; + +use crate::content::{ + self, + page::{Page, PageRepository}, +}; + +use super::Repository; + +#[async_trait] +impl PageRepository for Repository { + async fn get_page_from_url(&self, site: &str, slug: &str) -> Result { + let page_query = sqlx::query_as( + "SELECT pages.* FROM pages JOIN sites ON site = sites.id WHERE slug = $1 AND name = $2 AND pages.deleted_at IS NULL AND sites.deleted_at IS NULL") + .bind(slug) + .bind(site); + let page: Page = page_query + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => content::Error::NotFound, + _ => e.into(), + })?; + Ok(page) + } +} diff --git a/src/database/site.rs b/src/database/site.rs new file mode 100644 index 0000000..0f5d1ba --- /dev/null +++ b/src/database/site.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::content::{ + site::{APISite, CreateSiteOptions, SiteRepository, UpdateSiteOptions}, + Error, +}; + +use super::Repository; + +#[async_trait] +impl SiteRepository for Repository { + async fn get_site_by_name(&self, name: &str) -> Result { + let site = sqlx::query_as!( + APISite, + "SELECT sites.name, users.name as owner, title, description, sites.created_at FROM sites JOIN users ON sites.owner = users.id WHERE sites.name = $1 AND sites.deleted_at IS NULL", + name + ).fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => Error::NotFound, + _ => e.into(), + })?; + Ok(site) + } + + async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error> { + sqlx::query!( + "INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)", + options.name, + owner, + options.title, + ) + .execute(&self.pool) + .await + .map_err(|err| match err { + sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => { + Error::IdentifierNotAvailable + } + _ => err.into(), + })?; + Ok(()) + } + + async fn update_site( + &self, + owner: &Uuid, + name: &str, + options: &UpdateSiteOptions, + ) -> Result<(), Error> { + let result = sqlx::query!( + "UPDATE sites SET name = COALESCE($1, name), title = COALESCE($2, title), description = COALESCE($3, description), modified_at = NOW() WHERE name = $4 AND owner = $5 AND deleted_at IS NULL", + options.name, + options.title, + options.description, + name, + owner, + ).execute(&self.pool).await.map_err(|err| match err { + sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => { + Error::IdentifierNotAvailable + } + _ => err.into(), + })?; + + if result.rows_affected() == 0 { + return Err(Error::NotFound.into()); + } + + Ok(()) + } + + async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error> { + let result = if soft_delete { + sqlx::query!( + // soft delete + "UPDATE sites SET deleted_at = NOW() WHERE name = $1 AND owner = $2 AND deleted_at IS NULL", + name, owner + ) + .execute(&self.pool) + .await? + } else { + sqlx::query!( + // hard delete + "DELETE FROM sites WHERE name = $1 AND owner = $2", + name, + owner, + ) + .execute(&self.pool) + .await? + }; + + if result.rows_affected() == 0 { + return Err(Error::NotFound.into()); + } + + Ok(()) + } +} diff --git a/src/http/error.rs b/src/http/error.rs index 3fcad56..8c75d60 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -7,12 +7,19 @@ use axum::{ use serde_json::json; use thiserror::Error; +use crate::content; + // Generic errors -pub const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError { +const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError { status: StatusCode::NOT_FOUND, code: "not-found", message: "resource not found", }; +const ERR_NOT_AVAILABLE: ApiError<'static> = ApiError::ClientError { + status: StatusCode::CONFLICT, + code: "id-not-available", + message: "the chosen identifier is not available", +}; #[derive(Error, Debug)] pub enum ApiError<'a> { @@ -67,3 +74,13 @@ impl IntoResponse for ApiError<'_> { } } } + +impl From for ApiError<'_> { + fn from(err: content::Error) -> Self { + match err { + content::Error::NotFound => ERR_NOT_FOUND, + content::Error::IdentifierNotAvailable => ERR_NOT_AVAILABLE, + content::Error::DatabaseError(err) => err.into(), + } + } +} diff --git a/src/http/extract.rs b/src/http/json.rs similarity index 100% rename from src/http/extract.rs rename to src/http/json.rs diff --git a/src/http/mod.rs b/src/http/mod.rs index b8b47f5..d35b8ea 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,2 +1,4 @@ pub mod error; -pub mod extract; +pub mod json; +pub mod repository; +pub mod session; diff --git a/src/http/repository.rs b/src/http/repository.rs new file mode 100644 index 0000000..86d8a5e --- /dev/null +++ b/src/http/repository.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use axum::{extract::FromRequestParts, http::request::Parts}; + +use crate::{database::Repository, state::AppState}; + +use super::error::ApiError; + +#[async_trait] +impl FromRequestParts> for Repository { + type Rejection = ApiError<'static>; + + async fn from_request_parts( + _parts: &mut Parts, + state: &Arc, + ) -> Result { + Ok(state.into()) + } +} diff --git a/src/auth/http.rs b/src/http/session.rs similarity index 97% rename from src/auth/http.rs rename to src/http/session.rs index fb1f3e3..30d75f8 100644 --- a/src/auth/http.rs +++ b/src/http/session.rs @@ -16,9 +16,11 @@ use cookie::Cookie; use std::sync::Arc; use uuid::Uuid; -use crate::{http::error::ApiError, state::AppState}; - -use super::{session::Session, user::User}; +use crate::{ + auth::{session::Session, user::User}, + http::error::ApiError, + state::AppState, +}; pub const INVALID_SESSION: ApiError = ApiError::ClientError { status: StatusCode::UNAUTHORIZED, diff --git a/src/main.rs b/src/main.rs index 09ee95c..a4455d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ mod auth; mod content; +mod database; mod http; mod roles; mod routes; mod state; -use crate::{auth::http::refresh_sessions, state::Config}; +use crate::{http::session::refresh_sessions, state::Config}; use anyhow::Result; use axum::{middleware, Server}; use figment::{ diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1488a6a..6a81e2b 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -11,13 +11,12 @@ use serde_json::json; use std::sync::Arc; use crate::{ - auth::{ - hash::verify, - http::{RequireSession, RequireUser}, - session::Session, - user::User, + auth::{hash::verify, session::Session, user::User}, + http::{ + error::ApiError, + json::JsonBody, + session::{RequireSession, RequireUser}, }, - http::{error::ApiError, extract::JsonBody}, state::AppState, }; diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 530fe24..c92328e 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -1,36 +1,35 @@ use axum::{ extract::{Path, State}, response::IntoResponse, - routing::get, + routing::{get, post}, Json, Router, }; use std::sync::Arc; use crate::{ - content::Page, - http::error::{ApiError, ERR_NOT_FOUND}, + content::page::PageRepository, + database::Repository, + http::{error::ApiError, session::RequireUser}, state::AppState, }; async fn get_page( - State(state): State>, + repository: Repository, Path((site, slug)): Path<(String, String)>, ) -> Result> { - let page_query = sqlx::query_as( - "SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2", - ) - .bind(slug) - .bind(site); - let page: Page = page_query - .fetch_one(&state.database) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => ERR_NOT_FOUND, - _ => e.into(), - })?; - Ok(Json(page)) + Ok(Json(repository.get_page_from_url(&site, &slug).await?)) +} + +async fn create_page( + State(state): State>, + Path(site): Path, + RequireUser(user): RequireUser, +) -> Result> { + Ok(Json("todo")) } pub fn router() -> Router> { - Router::new().route("/:site/:slug", get(get_page)) + Router::new() + .route("/:site", post(create_page)) + .route("/:site/:slug", get(get_page)) } diff --git a/src/routes/sites.rs b/src/routes/sites.rs index 2628d91..1028c7b 100644 --- a/src/routes/sites.rs +++ b/src/routes/sites.rs @@ -1,79 +1,56 @@ use axum::{ - extract::{Path, State}, - http::StatusCode, + extract::Path, response::IntoResponse, routing::{get, post}, Json, Router, }; -use serde::Deserialize; use serde_json::json; use std::sync::Arc; use crate::{ - auth::http::RequireUser, - http::{ - error::{ApiError, ERR_NOT_FOUND}, - extract::JsonBody, - }, + content::site::{CreateSiteOptions, SiteRepository, UpdateSiteOptions}, + database::Repository, + http::{error::ApiError, json::JsonBody, session::RequireUser}, state::AppState, }; async fn get_site( - State(state): State>, + repository: Repository, Path(name): Path, ) -> Result> { - let site = sqlx::query!( - "SELECT sites.id, sites.name, users.name as owner, title, description, sites.created_at FROM sites JOIN users ON sites.owner = users.id WHERE sites.name = $1 ", - name - ).fetch_one(&state.database) - .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => ERR_NOT_FOUND, - _ => e.into(), - })?; - Ok(Json(json!({ - "name": site.name, - "owner": site.owner, - "title": site.title, - "description": site.description, - "created_at": site.created_at, - }))) -} - -#[derive(Deserialize)] -struct CreateSiteRequest { - name: String, - title: String, + Ok(Json(repository.get_site_by_name(&name).await?)) } async fn create_site( - State(state): State>, + repository: Repository, RequireUser(user): RequireUser, - JsonBody(payload): JsonBody, + JsonBody(options): JsonBody, ) -> Result> { - sqlx::query!( - "INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)", - payload.name, - user.id, - payload.title, - ) - .execute(&state.database) - .await - .map_err(|err| match err { - sqlx::Error::Database(dberr) if dberr.constraint() == Some("sites_name_key") => { - ApiError::ClientError { - status: StatusCode::CONFLICT, - code: "name-not-available", - message: "the chosen name is not available", - } - } - _ => err.into(), - })?; - Ok(Json(json!({"ok": true, "url": "test"}))) + repository.create_site(&user.id, &options).await?; + Ok(Json(json!({"ok": true}))) +} + +async fn update_site( + repository: Repository, + Path(name): Path, + RequireUser(user): RequireUser, + JsonBody(options): JsonBody, +) -> Result> { + repository.update_site(&user.id, &name, &options).await?; + Ok(Json(json!({"ok": true}))) +} + +async fn delete_site( + repository: Repository, + Path(name): Path, + RequireUser(user): RequireUser, +) -> Result> { + repository.delete_site(&user.id, &name, true).await?; + Ok(Json(json!({"ok": true}))) } pub fn router() -> Router> { Router::new() .route("/", post(create_site)) - .route("/:name", get(get_site)) + .route("/:name", get(get_site).put(update_site).delete(delete_site)) }