add openapi

This commit is contained in:
Hamcha 2023-07-21 15:59:03 +02:00
parent 38debc2554
commit bac09e5778
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
14 changed files with 697 additions and 66 deletions

243
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.6" version = "0.7.6"
@ -24,6 +30,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.15" version = "0.2.15"
@ -289,6 +304,15 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484"
[[package]]
name = "crc32fast"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.8" version = "0.3.8"
@ -422,6 +446,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "flate2"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -687,6 +721,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown 0.12.3", "hashbrown 0.12.3",
"serde",
] ]
[[package]] [[package]]
@ -795,6 +830,8 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"utoipa",
"utoipa-swagger-ui",
"uuid", "uuid",
] ]
@ -825,12 +862,31 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
dependencies = [
"adler",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "0.8.8" version = "0.8.8"
@ -1025,6 +1081,30 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.60" version = "1.0.60"
@ -1115,6 +1195,35 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "regex"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.16.20" version = "0.16.20"
@ -1130,6 +1239,41 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "rust-embed"
version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"shellexpand",
"syn 2.0.18",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "7.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
dependencies = [
"sha2",
"walkdir",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.37.22" version = "0.37.22"
@ -1177,6 +1321,15 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@ -1285,6 +1438,15 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shellexpand"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
dependencies = [
"dirs",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.1" version = "1.4.1"
@ -1778,6 +1940,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "unicase"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.13" version = "0.3.13"
@ -1828,6 +1999,47 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utoipa"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ae74ef183fae36d650f063ae7bde1cacbe1cd7e72b617cbe1e985551878b98"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ea8ac818da7e746a63285594cce8a96f5e00ee31994e655bd827569cb8b137b"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.18",
"uuid",
]
[[package]]
name = "utoipa-swagger-ui"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4602d7100d3cfd8a086f30494e68532402ab662fa366c9d201d677e33cee138d"
dependencies = [
"axum",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"utoipa",
"zip",
]
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.4.0" version = "1.4.0"
@ -1851,6 +2063,16 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -1981,6 +2203,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -2076,3 +2307,15 @@ name = "yansi"
version = "0.5.1" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]

View file

@ -24,6 +24,8 @@ thiserror = "1.0"
async-trait = "0.1" async-trait = "0.1"
tower-http = { version = "0.4", features = ["cors"] } tower-http = { version = "0.4", features = ["cors"] }
dotenv_rs = "0.16" dotenv_rs = "0.16"
utoipa = { version = "3", features = ["axum_extras", "uuid", "chrono", "preserve_order"] }
utoipa-swagger-ui = { version = "3", features = ["axum"]}
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

View file

@ -1,13 +1,14 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use super::Error; use super::Error;
pub const DEFAULT_COLLECTIONS: [(&str, &str); 2] = [("blog", "Blog"), ("pages", "Pages")]; pub const DEFAULT_COLLECTIONS: [(&str, &str); 2] = [("blog", "Blog"), ("pages", "Pages")];
#[derive(Debug, Deserialize, Serialize, FromRow)] #[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)]
pub struct Collection { pub struct Collection {
/// Collection ID /// Collection ID
pub id: Uuid, pub id: Uuid,
@ -25,7 +26,7 @@ pub struct Collection {
pub parent: Option<Uuid>, pub parent: Option<Uuid>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct CollectionData { pub struct CollectionData {
pub id: Uuid, pub id: Uuid,
pub slug: String, pub slug: String,

View file

@ -2,6 +2,7 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow}; use sqlx::{types::Json, FromRow};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::cursor::{AsCursor, CursorList}; use crate::cursor::{AsCursor, CursorList};
@ -46,7 +47,7 @@ pub struct Post {
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug, ToSchema)]
#[serde(tag = "kind")] #[serde(tag = "kind")]
pub enum PostBlock { pub enum PostBlock {
MarkupV1 { MarkupV1 {
@ -63,7 +64,7 @@ pub enum PostBlock {
} }
/// Picture inside a gallery /// Picture inside a gallery
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug, ToSchema)]
pub struct ImageElement { pub struct ImageElement {
/// URL of the picture /// URL of the picture
pub url: String, pub url: String,
@ -82,43 +83,90 @@ pub struct ImageElement {
} }
/// Data to create a new post /// Data to create a new post
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreatePostData { pub struct CreatePostData {
/// URL-friendly short code for the post
pub slug: String, pub slug: String,
/// Post title
pub title: String, pub title: String,
/// Post description (for SEO/content)
pub description: Option<String>, pub description: Option<String>,
/// Post tags (for internal search)
pub tags: Vec<String>, pub tags: Vec<String>,
/// Post blocks (content)
pub blocks: Vec<PostBlock>, pub blocks: Vec<PostBlock>,
/// Collections the post belongs to
pub collections: Vec<Uuid>, pub collections: Vec<Uuid>,
/// Is the post published?
pub published: bool, pub published: bool,
} }
/// Data to update a new post /// Data to update a new post
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct UpdatePostData { pub struct UpdatePostData {
/// URL-friendly short code for the post
pub slug: Option<String>, pub slug: Option<String>,
/// Post title
pub title: Option<String>, pub title: Option<String>,
/// Post description (for SEO/content)
pub description: Option<String>, pub description: Option<String>,
/// Post tags (for internal search)
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Post blocks (content)
pub blocks: Option<Vec<PostBlock>>, pub blocks: Option<Vec<PostBlock>>,
/// Collections the post belongs to
pub collections: Option<Vec<Uuid>>, pub collections: Option<Vec<Uuid>>,
/// Is the post published?
pub published: Option<bool>, pub published: Option<bool>,
} }
/// Post with site and author dereferenced for convenience /// Post with site and author dereferenced for convenience
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct PostWithAuthor { pub struct PostWithAuthor {
pub author_display_name: Option<String>, // Author display name
pub author_display_name: String,
// Author username
pub author_username: String, pub author_username: String,
pub slug: String,
pub title: String, // Creation date
pub description: Option<String>,
pub tags: Vec<String>,
pub blocks: Json<Vec<PostBlock>>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
// Last modified time
pub modified_at: Option<NaiveDateTime>, pub modified_at: Option<NaiveDateTime>,
pub published: bool,
/// URL-friendly short code for the post
pub slug: String,
/// Post title
pub title: String,
/// Post description (for SEO/content)
pub description: Option<String>,
/// Post tags (for internal search)
pub tags: Vec<String>,
/// Post blocks (content)
pub blocks: Vec<PostBlock>,
/// Collections the post belongs to
pub collections: Vec<Uuid>, pub collections: Vec<Uuid>,
/// Is the post published?
pub published: bool,
} }
impl AsCursor<NaiveDateTime> for PostWithAuthor { impl AsCursor<NaiveDateTime> for PostWithAuthor {

View file

@ -2,6 +2,7 @@ use async_trait::async_trait;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use super::{collection::CollectionData, Error}; use super::{collection::CollectionData, Error};
@ -33,32 +34,57 @@ pub struct Site {
} }
/// More useful version of Site for showing to users /// More useful version of Site for showing to users
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct SiteData { pub struct SiteData {
/// Site name (unique per instance, shows up in URLs)
pub name: String, pub name: String,
/// Site owner (user)
pub owner: String, pub owner: String,
pub owner_display_name: Option<String>, pub owner_display_name: Option<String>,
/// Site's displayed name
pub title: String, pub title: String,
/// Site description (like a user's bio)
pub description: Option<String>, pub description: Option<String>,
// Creation time
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
// Collections in the site
pub collections: Vec<CollectionData>, pub collections: Vec<CollectionData>,
// Default collection (for homepage)
pub default_collection: Option<Uuid>, pub default_collection: Option<Uuid>,
} }
/// Data required to create a new site /// Data required to create a new site
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreateSiteData { pub struct CreateSiteData {
/// Site name (unique per instance, shows up in URLs)
pub name: String, pub name: String,
/// Site's displayed name
pub title: String, pub title: String,
/// Site description (like a user's bio)
pub description: Option<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, ToSchema)]
pub struct UpdateSiteData { pub struct UpdateSiteData {
/// Site name (unique per instance, shows up in URLs)
pub name: Option<String>, pub name: Option<String>,
/// Site's displayed name
pub title: Option<String>, pub title: Option<String>,
/// Site description (like a user's bio)
pub description: Option<String>, pub description: Option<String>,
// Default collection (for homepage)
pub default_collection: Option<Uuid>, pub default_collection: Option<Uuid>,
} }

View file

@ -25,8 +25,7 @@ impl PostRepository for Database {
from: Option<NaiveDateTime>, from: Option<NaiveDateTime>,
limit: i64, limit: i64,
) -> Result<CursorList<PostWithAuthor, NaiveDateTime>, Error> { ) -> Result<CursorList<PostWithAuthor, NaiveDateTime>, Error> {
let posts = sqlx::query_as!( let posts = sqlx::query!(
PostWithAuthor,
r#"SELECT r#"SELECT
COALESCE(users.display_name,users.name) as author_display_name, COALESCE(users.display_name,users.name) as author_display_name,
users.name as author_username, users.name as author_username,
@ -59,7 +58,27 @@ impl PostRepository for Database {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(CursorList::new(posts, limit as usize)) Ok(CursorList::new(
posts
.into_iter()
.map(|record| PostWithAuthor {
author_display_name: record
.author_display_name
.unwrap_or_else(|| record.author_username.clone()),
author_username: record.author_username,
slug: record.slug,
title: record.title,
description: record.description,
tags: record.tags,
blocks: record.blocks.0,
created_at: record.created_at,
modified_at: record.modified_at,
published: record.published,
collections: record.collections,
})
.collect(),
limit as usize,
))
} }
async fn get_post_from_url( async fn get_post_from_url(
@ -67,8 +86,7 @@ impl PostRepository for Database {
site: &str, site: &str,
slug: &str, slug: &str,
) -> Result<PostWithAuthor, content::Error> { ) -> Result<PostWithAuthor, content::Error> {
let post = sqlx::query_as!( let post = sqlx::query!(
PostWithAuthor,
r#"SELECT r#"SELECT
COALESCE(users.display_name,users.name) as author_display_name, COALESCE(users.display_name,users.name) as author_display_name,
users.name as author_username, users.name as author_username,
@ -100,7 +118,21 @@ impl PostRepository for Database {
sqlx::Error::RowNotFound => Error::NotFound, sqlx::Error::RowNotFound => Error::NotFound,
_ => e.into(), _ => e.into(),
})?; })?;
Ok(post) Ok(PostWithAuthor {
author_display_name: post
.author_display_name
.unwrap_or_else(|| post.author_username.clone()),
author_username: post.author_username,
slug: post.slug,
title: post.title,
description: post.description,
tags: post.tags,
blocks: post.blocks.0,
created_at: post.created_at,
modified_at: post.modified_at,
published: post.published,
collections: post.collections,
})
} }
async fn create_post( async fn create_post(

View file

@ -2,9 +2,11 @@ use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::post; use axum::routing::post;
use axum::Json; use axum::Json;
use axum::{extract::State, Router}; use axum::Router;
use serde::Serialize;
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use utoipa::ToSchema;
use crate::auth::user::UserRepository; use crate::auth::user::UserRepository;
use crate::database::Database; use crate::database::Database;
@ -12,7 +14,24 @@ use crate::{
auth::hash::random, builtins::ROLE_SUPERADMIN, http::error::ApiError, state::AppState, auth::hash::random, builtins::ROLE_SUPERADMIN, http::error::ApiError, state::AppState,
}; };
async fn bootstrap<Repo: UserRepository>(repository: Repo) -> impl IntoResponse { #[derive(Serialize, ToSchema)]
pub(super) struct BootstrapInfo {
/// Admin username
pub username: String,
/// Admin password
pub password: String,
}
/// Bootstrap the instance by creating a admin user
#[utoipa::path(
post,
path = "/admin/bootstrap",
responses(
(status = 200, description = "List of collections fetched", body = [CollectionData])
)
)]
pub(super) async fn bootstrap<Repo: UserRepository>(repository: Repo) -> impl IntoResponse {
// Only allow this request if the user table is completely empty! // Only allow this request if the user table is completely empty!
if !repository.has_no_users().await? { if !repository.has_no_users().await? {
return Err(ApiError::Client { return Err(ApiError::Client {
@ -22,16 +41,16 @@ async fn bootstrap<Repo: UserRepository>(repository: Repo) -> impl IntoResponse
}); });
} }
let username = "admin"; let username = "admin".to_string();
let password = random(); let password = random();
repository repository
.create_user(username, &password, &[ROLE_SUPERADMIN].to_vec()) .create_user(&username, &password, &[ROLE_SUPERADMIN].to_vec())
.await?; .await?;
Ok(Json(json!({"username": username, "password": password}))) Ok(Json(json!(BootstrapInfo { username, password })))
} }
pub fn router() -> Router<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new().route("/bootstrap", post(bootstrap::<Database>)) Router::new().route("/bootstrap", post(bootstrap::<Database>))
} }

View file

@ -5,10 +5,11 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use chrono::Duration; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use utoipa::ToSchema;
use crate::{ use crate::{
auth::{ auth::{
@ -25,13 +26,37 @@ use crate::{
state::AppState, state::AppState,
}; };
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
struct LoginRequest { pub(super) struct LoginRequest {
/// Account username
pub username: String, pub username: String,
/// Account password
pub password: String, pub password: String,
} }
async fn login<Repo: UserRepository + SessionRepository>( #[derive(Serialize, ToSchema)]
pub(super) struct LoginResponse {
/// Session token
pub session_token: String,
/// Session expiration date
pub expires_at: chrono::DateTime<Utc>,
}
/// Sign in an account
#[utoipa::path(
post,
path = "/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Signed in successfully", body = LoginResponse)
),
security(
()
)
)]
pub(super) async fn login<Repo: UserRepository + SessionRepository>(
repository: Repo, repository: Repo,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
JsonBody(payload): JsonBody<LoginRequest>, JsonBody(payload): JsonBody<LoginRequest>,
@ -61,8 +86,10 @@ async fn login<Repo: UserRepository + SessionRepository>(
.await?; .await?;
let token = session.token(); let token = session.token();
let mut response: Response = let mut response: Response = Json(json!(LoginResponse {
Json(json!({ "session_token": token, "expires_at": session.expires_at.and_utc() })) session_token: token,
expires_at: session.expires_at.and_utc()
}))
.into_response(); .into_response();
response.headers_mut().insert( response.headers_mut().insert(
@ -76,11 +103,27 @@ async fn login<Repo: UserRepository + SessionRepository>(
Ok(response) Ok(response)
} }
async fn me(RequireUser(user): RequireUser) -> Result<String, ApiError<'static>> { /// Returns info about the logged-in user
#[utoipa::path(
get,
path = "/auth/me",
responses(
(status = 200, description = "User info found", body = String)
)
)]
pub(super) async fn me(RequireUser(user): RequireUser) -> Result<String, ApiError<'static>> {
Ok(user.name) Ok(user.name)
} }
async fn logout<Repo: SessionRepository>( /// Sign out and invalidate the session
#[utoipa::path(
post,
path = "/auth/logout",
responses(
(status = 200, description = "Signed out successfully")
)
)]
pub(super) async fn logout<Repo: SessionRepository>(
repository: Repo, repository: Repo,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireSession(session): RequireSession, RequireSession(session): RequireSession,
@ -97,7 +140,7 @@ async fn logout<Repo: SessionRepository>(
Ok(response) Ok(response)
} }
pub fn router() -> Router<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/login", post(login::<Database>)) .route("/login", post(login::<Database>))
.route("/logout", post(logout::<Database>)) .route("/logout", post(logout::<Database>))

View file

@ -1,11 +1,9 @@
use axum::{ use axum::{
extract::{Path, Query}, extract::{Path, Query},
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::get,
Json, Router, Json, Router,
}; };
use chrono::NaiveDateTime;
use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@ -16,17 +14,30 @@ use crate::{
state::AppState, state::AppState,
}; };
use super::pagination::PaginationQuery;
const DEFAULT_PAGE_SIZE: i64 = 10; const DEFAULT_PAGE_SIZE: i64 = 10;
async fn create_collection<Repo: CollectionRepository>(repository: Repo) { pub(super) async fn create_collection<Repo: CollectionRepository>(repository: Repo) {
todo!() todo!()
} }
async fn get_collection<Repo: CollectionRepository>(repository: Repo) { pub(super) async fn get_collection<Repo: CollectionRepository>(repository: Repo) {
todo!() todo!()
} }
async fn list_collections_for_site<Repo: SiteRepository + CollectionRepository>( /// List all collections in a site
#[utoipa::path(
get,
path = "/collections/{site}",
params(
("site" = String, Path, description = "Site slug")
),
responses(
(status = 200, description = "List of collections fetched", body = [CollectionData])
)
)]
pub(super) async fn list_collections_for_site<Repo: SiteRepository + CollectionRepository>(
repository: Repo, repository: Repo,
Path(site): Path<String>, Path(site): Path<String>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
@ -35,21 +46,28 @@ async fn list_collections_for_site<Repo: SiteRepository + CollectionRepository>(
Ok(Json(repository.list_collections(&site_id).await?)) Ok(Json(repository.list_collections(&site_id).await?))
} }
async fn update_collection<Repo: CollectionRepository>(repository: Repo) { pub(super) async fn update_collection<Repo: CollectionRepository>(repository: Repo) {
todo!() todo!()
} }
async fn delete_collection<Repo: CollectionRepository>(repository: Repo) { pub(super) async fn delete_collection<Repo: CollectionRepository>(repository: Repo) {
todo!() todo!()
} }
#[derive(Deserialize, Default)] /// List posts within a collection (paginated)
struct PaginationQuery { #[utoipa::path(
before: Option<NaiveDateTime>, get,
limit: Option<i64>, path = "/collections/{site}/{id}/posts",
} params(
("site" = String, Path, description = "Site slug"),
async fn list_posts_in_collection<Repo: SiteRepository + PostRepository>( ("id" = Uuid, Path, description = "Collection ID"),
PaginationQuery
),
responses(
(status = 200, description = "List of collections fetched", body = [CollectionData])
)
)]
pub(super) async fn list_posts_in_collection<Repo: SiteRepository + PostRepository>(
repository: Repo, repository: Repo,
Path((site, id)): Path<(String, Uuid)>, Path((site, id)): Path<(String, Uuid)>,
query: Query<PaginationQuery>, query: Query<PaginationQuery>,
@ -68,7 +86,7 @@ async fn list_posts_in_collection<Repo: SiteRepository + PostRepository>(
Ok(Json(results)) Ok(Json(results))
} }
pub fn router() -> Router<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route( .route(
"/:site", "/:site",

View file

@ -1,11 +1,17 @@
use axum::Router; use axum::Router;
use std::sync::Arc; use std::sync::Arc;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::state::AppState; use crate::state::AppState;
use self::openapi::ApiDoc;
mod admin; mod admin;
mod auth; mod auth;
mod collections; mod collections;
mod openapi;
mod pagination;
mod posts; mod posts;
mod sites; mod sites;
@ -16,4 +22,5 @@ pub fn create_router() -> Router<Arc<AppState>> {
.nest("/posts", posts::router()) .nest("/posts", posts::router())
.nest("/sites", sites::router()) .nest("/sites", sites::router())
.nest("/collections", collections::router()) .nest("/collections", collections::router())
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
} }

77
src/routes/openapi.rs Normal file
View file

@ -0,0 +1,77 @@
use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
Modify, OpenApi,
};
use crate::content::{
collection::CollectionData,
post::{CreatePostData, ImageElement, PostBlock, PostWithAuthor, UpdatePostData},
site::{CreateSiteData, SiteData, UpdateSiteData},
};
use super::{
admin::{self, BootstrapInfo},
auth, collections, posts, sites,
};
#[derive(OpenApi)]
#[openapi(
info(description = "Cenere content backend"),
paths(
// Authentication routes
auth::login,
auth::logout,
auth::me,
// Post routes
posts::get_post,
posts::create_post,
posts::update_post,
posts::delete_post,
// Site routes
sites::get_site,
sites::create_site,
sites::update_site,
sites::delete_site,
// Collection routes
collections::list_collections_for_site,
collections::list_posts_in_collection,
// Admin routes
admin::bootstrap
),
components(
schemas(
// Post data
PostWithAuthor,PostBlock,ImageElement,
CreatePostData,UpdatePostData,
// Site data
SiteData, CreateSiteData, UpdateSiteData,
// Collection data
CollectionData,
// Admin data
BootstrapInfo
)
),
modifiers(&SessionAddon),
tags(
(name = "posts", description = "Operation on posts within a site"),
(name = "sites", description = "Operation on sites"),
(name = "auth", description = "Session management")
)
)]
pub struct ApiDoc;
struct SessionAddon;
impl Modify for SessionAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"session",
SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::with_description(
"session",
"Session ID obtained via login or other authenticated calls",
))),
)
}
}
}

12
src/routes/pagination.rs Normal file
View file

@ -0,0 +1,12 @@
use chrono::NaiveDateTime;
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize, Default, IntoParams)]
pub(super) struct PaginationQuery {
/// Cursor position (date-based)
pub before: Option<NaiveDateTime>,
/// How many items to return at most
pub limit: Option<i64>,
}

View file

@ -22,7 +22,20 @@ use crate::{
state::AppState, state::AppState,
}; };
async fn get_post<Repo: PostRepository>( /// Retrieve post by site and post slug
#[utoipa::path(
get,
path = "/posts/{site}/{slug}",
params(
("site" = String, Path, description = "Site slug"),
("slug" = String, Path, description = "Post slug")
),
responses(
(status = 200, description = "Post found", body = PostWithAuthor),
(status = 404, description = "Post not found")
)
)]
pub(super) async fn get_post<Repo: PostRepository>(
repository: Repo, repository: Repo,
OptionalUser(user): OptionalUser, OptionalUser(user): OptionalUser,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
@ -46,7 +59,21 @@ async fn get_post<Repo: PostRepository>(
Err(Error::NotFound.into()) Err(Error::NotFound.into())
} }
async fn create_post<Repo: PostRepository + SiteRepository>( /// Create a new post
#[utoipa::path(
post,
path = "/posts/{site}",
params(
("site" = String, Path, description = "Site slug"),
),
request_body = CreatePostData,
responses(
(status = 200, description = "Post created successfully"),
(status = 403, description = "You don't have the necessary permissions to create posts on this site"),
(status = 409, description = "The chosen post identifier/slug is not available")
)
)]
pub(super) async fn create_post<Repo: PostRepository + SiteRepository>(
repository: Repo, repository: Repo,
Path(site): Path<String>, Path(site): Path<String>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
@ -63,7 +90,22 @@ async fn create_post<Repo: PostRepository + SiteRepository>(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
async fn update_post<Repo: PostRepository + SiteRepository>( /// Update an existing post
#[utoipa::path(
put,
path = "/posts/{site}/{slug}",
params(
("site" = String, Path, description = "Site slug"),
("slug" = String, Path, description = "Post slug")
),
request_body = UpdatePostData,
responses(
(status = 200, description = "Post updated successfully"),
(status = 403, description = "You don't have the necessary permissions to update posts on this site"),
(status = 404, description = "Post not found")
)
)]
pub(super) async fn update_post<Repo: PostRepository + SiteRepository>(
repository: Repo, repository: Repo,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
@ -82,7 +124,21 @@ async fn update_post<Repo: PostRepository + SiteRepository>(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
async fn delete_post<Repo: PostRepository + SiteRepository>( /// Delete an existing post
#[utoipa::path(
delete,
path = "/posts/{site}/{slug}",
params(
("site" = String, Path, description = "Site slug"),
("slug" = String, Path, description = "Post slug")
),
responses(
(status = 200, description = "Post deleted successfully"),
(status = 403, description = "You don't have the necessary permissions to delete posts on this site"),
(status = 404, description = "Post not found")
)
)]
pub(super) async fn delete_post<Repo: PostRepository + SiteRepository>(
repository: Repo, repository: Repo,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
@ -100,7 +156,7 @@ async fn delete_post<Repo: PostRepository + SiteRepository>(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
pub fn router() -> Router<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/:site", post(create_post::<Database>)) .route("/:site", post(create_post::<Database>))
.route( .route(

View file

@ -17,14 +17,36 @@ use crate::{
state::AppState, state::AppState,
}; };
async fn get_site<Repo: SiteRepository>( /// Retrieve site info/metadata
#[utoipa::path(
get,
path = "/sites/{site}",
params(
("site" = String, Path, description = "Site slug")
),
responses(
(status = 200, description = "Site info found", body = SiteData),
(status = 404, description = "Site not found")
)
)]
pub(super) async fn get_site<Repo: SiteRepository>(
repository: Repo, repository: Repo,
Path(name): Path<String>, Path(name): Path<String>,
) -> Result<impl IntoResponse, ApiError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
Ok(Json(repository.get_site_by_name(&name).await?)) Ok(Json(repository.get_site_by_name(&name).await?))
} }
async fn create_site<Repo: SiteRepository + CollectionRepository>( /// Create a new site
#[utoipa::path(
post,
path = "/sites",
request_body = CreateSiteData,
responses(
(status = 200, description = "Site created"),
(status = 409, description = "The chosen site identifier/slug is not available")
)
)]
pub(super) async fn create_site<Repo: SiteRepository + CollectionRepository>(
repository: Repo, repository: Repo,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
JsonBody(options): JsonBody<CreateSiteData>, JsonBody(options): JsonBody<CreateSiteData>,
@ -37,7 +59,20 @@ async fn create_site<Repo: SiteRepository + CollectionRepository>(
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn update_site<Repo: SiteRepository>( /// Update an existing site's info
#[utoipa::path(
put,
path = "/sites/{site}",
params(
("site" = String, Path, description = "Site slug")
),
request_body = UpdateSiteData,
responses(
(status = 200, description = "Site updated"),
(status = 404, description = "Site not found")
)
)]
pub(super) async fn update_site<Repo: SiteRepository>(
repository: Repo, repository: Repo,
Path(name): Path<String>, Path(name): Path<String>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
@ -47,7 +82,19 @@ async fn update_site<Repo: SiteRepository>(
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn delete_site<Repo: SiteRepository>( /// Delete an existing site
#[utoipa::path(
delete,
path = "/sites/{site}",
params(
("site" = String, Path, description = "Site slug")
),
responses(
(status = 200, description = "Site deleted"),
(status = 404, description = "Site not found")
)
)]
pub(super) async fn delete_site<Repo: SiteRepository>(
repository: Repo, repository: Repo,
Path(name): Path<String>, Path(name): Path<String>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
@ -56,7 +103,7 @@ async fn delete_site<Repo: SiteRepository>(
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
pub fn router() -> Router<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/", post(create_site::<Database>)) .route("/", post(create_site::<Database>))
.route( .route(