From bac09e577899e5c68c8c74ea5d325aa024dd72d6 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Fri, 21 Jul 2023 15:59:03 +0200 Subject: [PATCH] add openapi --- Cargo.lock | 243 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/content/collection.rs | 5 +- src/content/post.rs | 72 +++++++++-- src/content/site.rs | 32 ++++- src/database/post.rs | 44 ++++++- src/routes/admin.rs | 31 ++++- src/routes/auth.rs | 65 ++++++++-- src/routes/collections.rs | 50 +++++--- src/routes/mod.rs | 7 ++ src/routes/openapi.rs | 77 ++++++++++++ src/routes/pagination.rs | 12 ++ src/routes/posts.rs | 66 ++++++++++- src/routes/sites.rs | 57 ++++++++- 14 files changed, 697 insertions(+), 66 deletions(-) create mode 100644 src/routes/openapi.rs create mode 100644 src/routes/pagination.rs diff --git a/Cargo.lock b/Cargo.lock index ea32c3c..db65606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "ahash" version = "0.7.6" @@ -24,6 +30,15 @@ dependencies = [ "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]] name = "allocator-api2" version = "0.2.15" @@ -289,6 +304,15 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "crossbeam-queue" version = "0.3.8" @@ -422,6 +446,16 @@ dependencies = [ "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]] name = "fnv" version = "1.0.7" @@ -687,6 +721,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -795,6 +830,8 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "utoipa", + "utoipa-swagger-ui", "uuid", ] @@ -825,12 +862,31 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "mio" version = "0.8.8" @@ -1025,6 +1081,30 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro2" version = "1.0.60" @@ -1115,6 +1195,35 @@ dependencies = [ "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]] name = "ring" version = "0.16.20" @@ -1130,6 +1239,41 @@ dependencies = [ "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]] name = "rustix" version = "0.37.22" @@ -1177,6 +1321,15 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "scopeguard" version = "1.1.0" @@ -1285,6 +1438,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4" +dependencies = [ + "dirs", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1778,6 +1940,15 @@ dependencies = [ "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]] name = "unicode-bidi" version = "0.3.13" @@ -1828,6 +1999,47 @@ dependencies = [ "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]] name = "uuid" version = "1.4.0" @@ -1851,6 +2063,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "want" version = "0.3.1" @@ -1981,6 +2203,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2076,3 +2307,15 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/Cargo.toml b/Cargo.toml index d1849ed..3353758 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ thiserror = "1.0" async-trait = "0.1" tower-http = { version = "0.4", features = ["cors"] } 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] opt-level = 3 \ No newline at end of file diff --git a/src/content/collection.rs b/src/content/collection.rs index ba2b779..0e9e84a 100644 --- a/src/content/collection.rs +++ b/src/content/collection.rs @@ -1,13 +1,14 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use uuid::Uuid; use super::Error; 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 { /// Collection ID pub id: Uuid, @@ -25,7 +26,7 @@ pub struct Collection { pub parent: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct CollectionData { pub id: Uuid, pub slug: String, diff --git a/src/content/post.rs b/src/content/post.rs index 72f69ad..64ea236 100644 --- a/src/content/post.rs +++ b/src/content/post.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::{types::Json, FromRow}; +use utoipa::ToSchema; use uuid::Uuid; use crate::cursor::{AsCursor, CursorList}; @@ -46,7 +47,7 @@ pub struct Post { pub deleted_at: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] #[serde(tag = "kind")] pub enum PostBlock { MarkupV1 { @@ -63,7 +64,7 @@ pub enum PostBlock { } /// Picture inside a gallery -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] pub struct ImageElement { /// URL of the picture pub url: String, @@ -82,43 +83,90 @@ pub struct ImageElement { } /// Data to create a new post -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct CreatePostData { + /// URL-friendly short code for the post pub slug: String, + + /// Post title pub title: String, + + /// Post description (for SEO/content) pub description: Option, + + /// Post tags (for internal search) pub tags: Vec, + + /// Post blocks (content) pub blocks: Vec, + + /// Collections the post belongs to pub collections: Vec, + + /// Is the post published? pub published: bool, } /// Data to update a new post -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct UpdatePostData { + /// URL-friendly short code for the post pub slug: Option, + + /// Post title pub title: Option, + + /// Post description (for SEO/content) pub description: Option, + + /// Post tags (for internal search) pub tags: Option>, + + /// Post blocks (content) pub blocks: Option>, + + /// Collections the post belongs to pub collections: Option>, + + /// Is the post published? pub published: Option, } /// Post with site and author dereferenced for convenience -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct PostWithAuthor { - pub author_display_name: Option, + // Author display name + pub author_display_name: String, + + // Author username pub author_username: String, - pub slug: String, - pub title: String, - pub description: Option, - pub tags: Vec, - pub blocks: Json>, + + // Creation date pub created_at: NaiveDateTime, + + // Last modified time pub modified_at: Option, - 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, + + /// Post tags (for internal search) + pub tags: Vec, + + /// Post blocks (content) + pub blocks: Vec, + + /// Collections the post belongs to pub collections: Vec, + + /// Is the post published? + pub published: bool, } impl AsCursor for PostWithAuthor { diff --git a/src/content/site.rs b/src/content/site.rs index acb30be..de3a25f 100644 --- a/src/content/site.rs +++ b/src/content/site.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use uuid::Uuid; use super::{collection::CollectionData, Error}; @@ -33,32 +34,57 @@ pub struct Site { } /// More useful version of Site for showing to users -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct SiteData { + /// Site name (unique per instance, shows up in URLs) pub name: String, + + /// Site owner (user) pub owner: String, pub owner_display_name: Option, + + /// Site's displayed name pub title: String, + + /// Site description (like a user's bio) pub description: Option, + + // Creation time pub created_at: NaiveDateTime, + + // Collections in the site pub collections: Vec, + + // Default collection (for homepage) pub default_collection: Option, } /// Data required to create a new site -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct CreateSiteData { + /// 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, } /// Data required to update a site's info -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct UpdateSiteData { + /// Site name (unique per instance, shows up in URLs) pub name: Option, + + /// Site's displayed name pub title: Option, + + /// Site description (like a user's bio) pub description: Option, + + // Default collection (for homepage) pub default_collection: Option, } diff --git a/src/database/post.rs b/src/database/post.rs index b2aedde..42b872e 100644 --- a/src/database/post.rs +++ b/src/database/post.rs @@ -25,8 +25,7 @@ impl PostRepository for Database { from: Option, limit: i64, ) -> Result, Error> { - let posts = sqlx::query_as!( - PostWithAuthor, + let posts = sqlx::query!( r#"SELECT COALESCE(users.display_name,users.name) as author_display_name, users.name as author_username, @@ -59,7 +58,27 @@ impl PostRepository for Database { .fetch_all(&self.pool) .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( @@ -67,8 +86,7 @@ impl PostRepository for Database { site: &str, slug: &str, ) -> Result { - let post = sqlx::query_as!( - PostWithAuthor, + let post = sqlx::query!( r#"SELECT COALESCE(users.display_name,users.name) as author_display_name, users.name as author_username, @@ -100,7 +118,21 @@ impl PostRepository for Database { sqlx::Error::RowNotFound => Error::NotFound, _ => 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( diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 057a0f3..75d7ee2 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -2,9 +2,11 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::post; use axum::Json; -use axum::{extract::State, Router}; +use axum::Router; +use serde::Serialize; use serde_json::json; use std::sync::Arc; +use utoipa::ToSchema; use crate::auth::user::UserRepository; use crate::database::Database; @@ -12,7 +14,24 @@ use crate::{ auth::hash::random, builtins::ROLE_SUPERADMIN, http::error::ApiError, state::AppState, }; -async fn bootstrap(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(repository: Repo) -> impl IntoResponse { // Only allow this request if the user table is completely empty! if !repository.has_no_users().await? { return Err(ApiError::Client { @@ -22,16 +41,16 @@ async fn bootstrap(repository: Repo) -> impl IntoResponse }); } - let username = "admin"; + let username = "admin".to_string(); let password = random(); repository - .create_user(username, &password, &[ROLE_SUPERADMIN].to_vec()) + .create_user(&username, &password, &[ROLE_SUPERADMIN].to_vec()) .await?; - Ok(Json(json!({"username": username, "password": password}))) + Ok(Json(json!(BootstrapInfo { username, password }))) } -pub fn router() -> Router> { +pub(super) fn router() -> Router> { Router::new().route("/bootstrap", post(bootstrap::)) } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 75b2358..926f771 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -5,10 +5,11 @@ use axum::{ routing::{get, post}, Json, Router, }; -use chrono::Duration; -use serde::Deserialize; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; +use utoipa::ToSchema; use crate::{ auth::{ @@ -25,13 +26,37 @@ use crate::{ state::AppState, }; -#[derive(Deserialize)] -struct LoginRequest { +#[derive(Deserialize, ToSchema)] +pub(super) struct LoginRequest { + /// Account username pub username: String, + + /// Account password pub password: String, } -async fn login( +#[derive(Serialize, ToSchema)] +pub(super) struct LoginResponse { + /// Session token + pub session_token: String, + + /// Session expiration date + pub expires_at: chrono::DateTime, +} + +/// 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( repository: Repo, State(state): State>, JsonBody(payload): JsonBody, @@ -61,9 +86,11 @@ async fn login( .await?; let token = session.token(); - let mut response: Response = - Json(json!({ "session_token": token, "expires_at": session.expires_at.and_utc() })) - .into_response(); + let mut response: Response = Json(json!(LoginResponse { + session_token: token, + expires_at: session.expires_at.and_utc() + })) + .into_response(); response.headers_mut().insert( SET_COOKIE, @@ -76,11 +103,27 @@ async fn login( Ok(response) } -async fn me(RequireUser(user): RequireUser) -> Result> { +/// 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> { Ok(user.name) } -async fn logout( +/// Sign out and invalidate the session +#[utoipa::path( + post, + path = "/auth/logout", + responses( + (status = 200, description = "Signed out successfully") + ) +)] +pub(super) async fn logout( repository: Repo, State(state): State>, RequireSession(session): RequireSession, @@ -97,7 +140,7 @@ async fn logout( Ok(response) } -pub fn router() -> Router> { +pub(super) fn router() -> Router> { Router::new() .route("/login", post(login::)) .route("/logout", post(logout::)) diff --git a/src/routes/collections.rs b/src/routes/collections.rs index 71a9687..5c3b173 100644 --- a/src/routes/collections.rs +++ b/src/routes/collections.rs @@ -1,11 +1,9 @@ use axum::{ extract::{Path, Query}, response::IntoResponse, - routing::{get, post}, + routing::get, Json, Router, }; -use chrono::NaiveDateTime; -use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; @@ -16,17 +14,30 @@ use crate::{ state::AppState, }; +use super::pagination::PaginationQuery; + const DEFAULT_PAGE_SIZE: i64 = 10; -async fn create_collection(repository: Repo) { +pub(super) async fn create_collection(repository: Repo) { todo!() } -async fn get_collection(repository: Repo) { +pub(super) async fn get_collection(repository: Repo) { todo!() } -async fn list_collections_for_site( +/// 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( repository: Repo, Path(site): Path, ) -> Result> { @@ -35,21 +46,28 @@ async fn list_collections_for_site( Ok(Json(repository.list_collections(&site_id).await?)) } -async fn update_collection(repository: Repo) { +pub(super) async fn update_collection(repository: Repo) { todo!() } -async fn delete_collection(repository: Repo) { +pub(super) async fn delete_collection(repository: Repo) { todo!() } -#[derive(Deserialize, Default)] -struct PaginationQuery { - before: Option, - limit: Option, -} - -async fn list_posts_in_collection( +/// List posts within a collection (paginated) +#[utoipa::path( + get, + path = "/collections/{site}/{id}/posts", + params( + ("site" = String, Path, description = "Site slug"), + ("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( repository: Repo, Path((site, id)): Path<(String, Uuid)>, query: Query, @@ -68,7 +86,7 @@ async fn list_posts_in_collection( Ok(Json(results)) } -pub fn router() -> Router> { +pub(super) fn router() -> Router> { Router::new() .route( "/:site", diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2dd0222..f33e5a0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,11 +1,17 @@ use axum::Router; use std::sync::Arc; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; use crate::state::AppState; +use self::openapi::ApiDoc; + mod admin; mod auth; mod collections; +mod openapi; +mod pagination; mod posts; mod sites; @@ -16,4 +22,5 @@ pub fn create_router() -> Router> { .nest("/posts", posts::router()) .nest("/sites", sites::router()) .nest("/collections", collections::router()) + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) } diff --git a/src/routes/openapi.rs b/src/routes/openapi.rs new file mode 100644 index 0000000..b6b0f3a --- /dev/null +++ b/src/routes/openapi.rs @@ -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", + ))), + ) + } + } +} diff --git a/src/routes/pagination.rs b/src/routes/pagination.rs new file mode 100644 index 0000000..61bdaa3 --- /dev/null +++ b/src/routes/pagination.rs @@ -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, + + /// How many items to return at most + pub limit: Option, +} diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 371fd46..264ae5f 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -22,7 +22,20 @@ use crate::{ state::AppState, }; -async fn get_post( +/// 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( repository: Repo, OptionalUser(user): OptionalUser, Path((site, slug)): Path<(String, String)>, @@ -46,7 +59,21 @@ async fn get_post( Err(Error::NotFound.into()) } -async fn create_post( +/// 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( repository: Repo, Path(site): Path, RequireUser(user): RequireUser, @@ -63,7 +90,22 @@ async fn create_post( Ok(Json(json!({ "ok": true }))) } -async fn update_post( +/// 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( repository: Repo, Path((site, slug)): Path<(String, String)>, RequireUser(user): RequireUser, @@ -82,7 +124,21 @@ async fn update_post( Ok(Json(json!({ "ok": true }))) } -async fn delete_post( +/// 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( repository: Repo, Path((site, slug)): Path<(String, String)>, RequireUser(user): RequireUser, @@ -100,7 +156,7 @@ async fn delete_post( Ok(Json(json!({ "ok": true }))) } -pub fn router() -> Router> { +pub(super) fn router() -> Router> { Router::new() .route("/:site", post(create_post::)) .route( diff --git a/src/routes/sites.rs b/src/routes/sites.rs index cf23c34..ac5cb6d 100644 --- a/src/routes/sites.rs +++ b/src/routes/sites.rs @@ -17,14 +17,36 @@ use crate::{ state::AppState, }; -async fn get_site( +/// 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( repository: Repo, Path(name): Path, ) -> Result> { Ok(Json(repository.get_site_by_name(&name).await?)) } -async fn create_site( +/// 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( repository: Repo, RequireUser(user): RequireUser, JsonBody(options): JsonBody, @@ -37,7 +59,20 @@ async fn create_site( Ok(Json(json!({"ok": true}))) } -async fn update_site( +/// 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( repository: Repo, Path(name): Path, RequireUser(user): RequireUser, @@ -47,7 +82,19 @@ async fn update_site( Ok(Json(json!({"ok": true}))) } -async fn delete_site( +/// 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( repository: Repo, Path(name): Path, RequireUser(user): RequireUser, @@ -56,7 +103,7 @@ async fn delete_site( Ok(Json(json!({"ok": true}))) } -pub fn router() -> Router> { +pub(super) fn router() -> Router> { Router::new() .route("/", post(create_site::)) .route(