add page CRUD
This commit is contained in:
parent
9d6586063d
commit
3d50d2b4b0
8 changed files with 239 additions and 26 deletions
4
migrations/20230706231224_add-slug-constraint.down.sql
Normal file
4
migrations/20230706231224_add-slug-constraint.down.sql
Normal 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;
|
6
migrations/20230706231224_add-slug-constraint.up.sql
Normal file
6
migrations/20230706231224_add-slug-constraint.up.sql
Normal 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;
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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})))
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue