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>,
}
#[async_trait]
pub trait PageRepository {
async fn get_page_from_url(&self, site: &str, slug: &str) -> Result<Page, Error>;
}
#[derive(Deserialize, Serialize)]
#[serde(tag = "kind")]
pub enum PageBlock {
@ -77,3 +72,55 @@ pub struct ImageElement {
/// Optional caption to put near the image
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
#[derive(Deserialize)]
pub struct CreateSiteOptions {
pub struct CreateSiteData {
pub name: String,
pub title: String,
pub description: Option<String>,
}
/// Data required to update a site's info
#[derive(Deserialize)]
pub struct UpdateSiteOptions {
pub struct UpdateSiteData {
pub name: Option<String>,
pub title: 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>;
/// 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
async fn update_site(
&self,
owner: &Uuid,
name: &str,
options: &UpdateSiteOptions,
options: UpdateSiteData,
) -> Result<(), Error>;
/// Delete a site
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 serde_json::json;
use uuid::Uuid;
use crate::content::{
self,
page::{Page, PageRepository},
page::{CreatePageData, Page, PageRepository, UpdatePageData},
Error,
};
use super::Database;
@ -18,9 +21,95 @@ impl PageRepository for Database {
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => content::Error::NotFound,
sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(),
})?;
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 crate::content::{
site::{APISite, CreateSiteOptions, SiteRepository, UpdateSiteOptions},
site::{APISite, CreateSiteData, SiteRepository, UpdateSiteData},
Error,
};
@ -24,12 +24,13 @@ impl SiteRepository for Database {
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!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)",
"INSERT INTO sites (name, owner, title, description) VALUES ($1, $2, $3, $4)",
options.name,
owner,
options.title,
options.description,
)
.execute(&self.pool)
.await
@ -46,7 +47,7 @@ impl SiteRepository for Database {
&self,
owner: &Uuid,
name: &str,
options: &UpdateSiteOptions,
options: UpdateSiteData,
) -> 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",
@ -95,4 +96,19 @@ impl SiteRepository for Database {
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},
Json, Router,
};
use serde_json::json;
use std::sync::Arc;
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>(
@ -17,15 +24,55 @@ async fn get_page<Repo: PageRepository>(
Ok(Json(repository.get_page_from_url(&site, &slug).await?))
}
async fn create_page<Repo: PageRepository>(//repository: Repo,
//Path(site): Path<String>,
//RequireUser(user): RequireUser,
async fn create_page<Repo: PageRepository + SiteRepository>(
repository: Repo,
Path(site): Path<String>,
RequireUser(user): RequireUser,
JsonBody(page): JsonBody<CreatePageData>,
) -> 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>> {
Router::new()
.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 crate::{
content::site::{CreateSiteOptions, SiteRepository, UpdateSiteOptions},
content::site::{CreateSiteData, SiteRepository, UpdateSiteData},
database::Database,
http::{error::ApiError, json::JsonBody, session::RequireUser},
state::AppState,
@ -24,9 +24,9 @@ async fn get_site<Repo: SiteRepository>(
async fn create_site<Repo: SiteRepository>(
repository: Repo,
RequireUser(user): RequireUser,
JsonBody(options): JsonBody<CreateSiteOptions>,
JsonBody(options): JsonBody<CreateSiteData>,
) -> Result<impl IntoResponse, ApiError<'static>> {
repository.create_site(&user.id, &options).await?;
repository.create_site(&user.id, options).await?;
Ok(Json(json!({"ok": true})))
}
@ -34,9 +34,9 @@ async fn update_site<Repo: SiteRepository>(
repository: Repo,
Path(name): Path<String>,
RequireUser(user): RequireUser,
JsonBody(options): JsonBody<UpdateSiteOptions>,
JsonBody(options): JsonBody<UpdateSiteData>,
) -> 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})))
}