add page CRUD

This commit is contained in:
Hamcha 2023-07-07 01:36:48 +02:00
parent 9d6586063d
commit 3d50d2b4b0
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
8 changed files with 239 additions and 26 deletions

View file

@ -0,0 +1,4 @@
-- Remove constraint and index (page_site_slug_unique, page_site_slug_idx)
ALTER TABLE pages DROP CONSTRAINT pages_site_slug_unique;
DROP INDEX IF EXISTS pages_site_slug_unique;

View file

@ -0,0 +1,6 @@
-- Add index/constraint on page site and slug
CREATE UNIQUE INDEX pages_site_slug_idx
ON pages (site, slug);
ALTER TABLE pages
ADD CONSTRAINT pages_site_slug_unique UNIQUE USING INDEX pages_site_slug_idx;

View file

@ -38,11 +38,6 @@ pub struct Page {
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
} }
#[async_trait]
pub trait PageRepository {
async fn get_page_from_url(&self, site: &str, slug: &str) -> Result<Page, Error>;
}
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
#[serde(tag = "kind")] #[serde(tag = "kind")]
pub enum PageBlock { pub enum PageBlock {
@ -77,3 +72,55 @@ pub struct ImageElement {
/// Optional caption to put near the image /// Optional caption to put near the image
pub caption: Option<String>, pub caption: Option<String>,
} }
/// Data to create a new page
#[derive(Deserialize)]
pub struct CreatePageData {
pub slug: String,
pub title: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub blocks: Vec<PageBlock>,
}
/// Data to update a new page
#[derive(Deserialize)]
pub struct UpdatePageData {
pub slug: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub blocks: Option<Vec<PageBlock>>,
}
#[async_trait]
pub trait PageRepository {
/// Get a page from its slug
async fn get_page_from_url(&self, site: &str, slug: &str) -> Result<Page, Error>;
/// Create a new page
async fn create_page(
&self,
owner: &Uuid,
site: &Uuid,
data: CreatePageData,
) -> Result<(), Error>;
/// Update a page
async fn update_page(
&self,
owner: &Uuid,
site: &Uuid,
slug: &str,
data: UpdatePageData,
) -> Result<(), Error>;
/// Delete a page
async fn delete_page(
&self,
owner: &Uuid,
site: &Uuid,
name: &str,
soft_delete: bool,
) -> Result<(), Error>;
}

View file

@ -41,14 +41,15 @@ pub struct APISite {
/// Data required to create a new site /// Data required to create a new site
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateSiteOptions { pub struct CreateSiteData {
pub name: String, pub name: String,
pub title: String, pub title: String,
pub description: Option<String>,
} }
/// Data required to update a site's info /// Data required to update a site's info
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateSiteOptions { pub struct UpdateSiteData {
pub name: Option<String>, pub name: Option<String>,
pub title: Option<String>, pub title: Option<String>,
pub description: Option<String>, pub description: Option<String>,
@ -60,16 +61,19 @@ pub trait SiteRepository {
async fn get_site_by_name(&self, name: &str) -> Result<APISite, Error>; async fn get_site_by_name(&self, name: &str) -> Result<APISite, Error>;
/// Create a new instance of a site and store it /// Create a new instance of a site and store it
async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error>; async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error>;
/// Update an existing site's info /// Update an existing site's info
async fn update_site( async fn update_site(
&self, &self,
owner: &Uuid, owner: &Uuid,
name: &str, name: &str,
options: &UpdateSiteOptions, options: UpdateSiteData,
) -> Result<(), Error>; ) -> Result<(), Error>;
/// Delete a site /// Delete a site
async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>; async fn delete_site(&self, owner: &Uuid, name: &str, soft_delete: bool) -> Result<(), Error>;
/// Resolve a site's name to its ID
async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result<Uuid, Error>;
} }

View file

@ -1,8 +1,11 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::json;
use uuid::Uuid;
use crate::content::{ use crate::content::{
self, self,
page::{Page, PageRepository}, page::{CreatePageData, Page, PageRepository, UpdatePageData},
Error,
}; };
use super::Database; use super::Database;
@ -18,9 +21,95 @@ impl PageRepository for Database {
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
sqlx::Error::RowNotFound => content::Error::NotFound, sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(), _ => e.into(),
})?; })?;
Ok(page) Ok(page)
} }
async fn create_page(
&self,
owner: &Uuid,
site: &Uuid,
data: CreatePageData,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO pages (author, site, slug, title, description, tags, blocks) VALUES ($1, $2, $3, $4, $5, $6, $7)",
owner,
site,
data.slug,
data.title,
data.description.unwrap_or_else(|| "".to_string()),
&data.tags,
json!(data.blocks),
).execute(&self.pool).await.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
Error::IdentifierNotAvailable
}
_ => err.into(),
})?;
Ok(())
}
async fn update_page(
&self,
owner: &Uuid,
site: &Uuid,
page: &str,
data: UpdatePageData,
) -> Result<(), Error> {
let result = sqlx::query!(
"UPDATE pages SET title = COALESCE($1, title), description = COALESCE($2, description), tags = COALESCE($3, tags), blocks = COALESCE($4, blocks) WHERE author = $5 AND site = $6 AND slug = $7 AND deleted_at IS NULL",
data.title,
data.description,
data.tags.as_deref(),
data.blocks.map(|x| json!(x)),
owner,
site,
page
).execute(&self.pool).await.map_err(|err| match err {
sqlx::Error::Database(dberr) if dberr.constraint() == Some("pages_site_slug_unique") => {
Error::IdentifierNotAvailable
}
_ => err.into(),
})?;
if result.rows_affected() == 0 {
return Err(Error::NotFound);
}
Ok(())
}
async fn delete_page(
&self,
owner: &Uuid,
site: &Uuid,
page: &str,
soft_delete: bool,
) -> Result<(), Error> {
let result = if soft_delete {
sqlx::query!(
"UPDATE pages SET deleted_at = NOW() WHERE author = $1 AND site = $2 AND slug = $3 AND deleted_at IS NULL",
owner,
site,
page
).execute(&self.pool).await?
} else {
sqlx::query!(
"DELETE FROM pages WHERE author = $1 AND site = $2 AND slug = $3",
owner,
site,
page
)
.execute(&self.pool)
.await?
};
if result.rows_affected() == 0 {
return Err(Error::NotFound);
}
Ok(())
}
} }

View file

@ -2,7 +2,7 @@ use async_trait::async_trait;
use uuid::Uuid; use uuid::Uuid;
use crate::content::{ use crate::content::{
site::{APISite, CreateSiteOptions, SiteRepository, UpdateSiteOptions}, site::{APISite, CreateSiteData, SiteRepository, UpdateSiteData},
Error, Error,
}; };
@ -24,12 +24,13 @@ impl SiteRepository for Database {
Ok(site) Ok(site)
} }
async fn create_site(&self, owner: &Uuid, options: &CreateSiteOptions) -> Result<(), Error> { async fn create_site(&self, owner: &Uuid, options: CreateSiteData) -> Result<(), Error> {
sqlx::query!( sqlx::query!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)", "INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4)",
options.name, options.name,
owner, owner,
options.title, options.title,
options.description,
) )
.execute(&self.pool) .execute(&self.pool)
.await .await
@ -46,7 +47,7 @@ impl SiteRepository for Database {
&self, &self,
owner: &Uuid, owner: &Uuid,
name: &str, name: &str,
options: &UpdateSiteOptions, options: UpdateSiteData,
) -> Result<(), Error> { ) -> Result<(), Error> {
let result = sqlx::query!( 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", "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",
@ -95,4 +96,19 @@ impl SiteRepository for Database {
Ok(()) Ok(())
} }
async fn get_site_id(&self, name: &str, owner: &Uuid) -> Result<Uuid, Error> {
let record = sqlx::query!(
"SELECT id FROM sites WHERE name = $1 AND owner = $2 AND deleted_at IS NULL",
name,
owner
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(),
})?;
Ok(record.id)
}
} }

View file

@ -4,10 +4,17 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
content::page::PageRepository, database::Database, http::error::ApiError, state::AppState, content::{
page::{CreatePageData, PageRepository, UpdatePageData},
site::SiteRepository,
},
database::Database,
http::{error::ApiError, json::JsonBody, session::RequireUser},
state::AppState,
}; };
async fn get_page<Repo: PageRepository>( async fn get_page<Repo: PageRepository>(
@ -17,15 +24,55 @@ async fn get_page<Repo: PageRepository>(
Ok(Json(repository.get_page_from_url(&site, &slug).await?)) Ok(Json(repository.get_page_from_url(&site, &slug).await?))
} }
async fn create_page<Repo: PageRepository>(//repository: Repo, async fn create_page<Repo: PageRepository + SiteRepository>(
//Path(site): Path<String>, repository: Repo,
//RequireUser(user): RequireUser, Path(site): Path<String>,
RequireUser(user): RequireUser,
JsonBody(page): JsonBody<CreatePageData>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
Ok(Json("todo")) // Resolve site
let site_id = repository.get_site_id(&site, &user.id).await?;
repository.create_page(&user.id, &site_id, page).await?;
Ok(Json(json!({ "ok": true })))
}
async fn update_page<Repo: PageRepository + SiteRepository>(
repository: Repo,
Path((site, slug)): Path<(String, String)>,
RequireUser(user): RequireUser,
JsonBody(page): JsonBody<UpdatePageData>,
) -> Result<impl IntoResponse, ApiError<'static>> {
// Resolve site
let site_id = repository.get_site_id(&site, &user.id).await?;
repository
.update_page(&user.id, &site_id, &slug, page)
.await?;
Ok(Json(json!({ "ok": true })))
}
async fn delete_page<Repo: PageRepository + SiteRepository>(
repository: Repo,
Path((site, slug)): Path<(String, String)>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, ApiError<'static>> {
// Resolve site
let site_id = repository.get_site_id(&site, &user.id).await?;
repository
.delete_page(&user.id, &site_id, &slug, true)
.await?;
Ok(Json(json!({ "ok": true })))
} }
pub fn router() -> Router<Arc<AppState>> { pub fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/:site", post(create_page::<Database>)) .route("/:site", post(create_page::<Database>))
.route("/:site/:slug", get(get_page::<Database>)) .route(
"/:site/:slug",
get(get_page::<Database>)
.put(update_page::<Database>)
.delete(delete_page::<Database>),
)
} }

View file

@ -8,7 +8,7 @@ use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
content::site::{CreateSiteOptions, SiteRepository, UpdateSiteOptions}, content::site::{CreateSiteData, SiteRepository, UpdateSiteData},
database::Database, database::Database,
http::{error::ApiError, json::JsonBody, session::RequireUser}, http::{error::ApiError, json::JsonBody, session::RequireUser},
state::AppState, state::AppState,
@ -24,9 +24,9 @@ async fn get_site<Repo: SiteRepository>(
async fn create_site<Repo: SiteRepository>( async fn create_site<Repo: SiteRepository>(
repository: Repo, repository: Repo,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
JsonBody(options): JsonBody<CreateSiteOptions>, JsonBody(options): JsonBody<CreateSiteData>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
repository.create_site(&user.id, &options).await?; repository.create_site(&user.id, options).await?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
@ -34,9 +34,9 @@ async fn update_site<Repo: SiteRepository>(
repository: Repo, repository: Repo,
Path(name): Path<String>, Path(name): Path<String>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
JsonBody(options): JsonBody<UpdateSiteOptions>, JsonBody(options): JsonBody<UpdateSiteData>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
repository.update_site(&user.id, &name, &options).await?; repository.update_site(&user.id, &name, options).await?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }