diff --git a/Cargo.lock b/Cargo.lock index adb74cc..42c142d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "utoipa", "uuid", ] @@ -476,6 +477,10 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-rapidoc", + "utoipa-redoc", + "utoipa-swagger-ui", ] [[package]] @@ -1344,6 +1349,7 @@ checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1866,6 +1872,30 @@ dependencies = [ "elliptic-curve", ] +[[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.81" @@ -2073,6 +2103,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb78f46d0066053d16d4ca7b898e9343bc3530f71c61d5ad84cd404ada068745" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91ac2a3c6c0520a3fb3dd89321177c3c692937c4eb21893378219da10c44fc8" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.60", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f69089032567ffff4eada41c573fc43ff466c7db7c5688b2e7969584345581" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2181,6 +2245,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[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.2.0" @@ -2986,6 +3059,71 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utoipa" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "272ebdfbc99111033031d2f10e018836056e4d2c8e2acda76450ec7974269fa7" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c9f4d08338c1bfa70dde39412a040a884c6f318b3d09aaaf3437a1e52027fc" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.60", +] + +[[package]] +name = "utoipa-rapidoc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e79a75396496e4fe41359375a67e8fcb9c28444cd1cb5e8ac54f47684e64290" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-redoc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ab3f28191a1881d747bc7c5205a2e51ce5647c97944ac67c23a3f4f1afae10" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.8.0" @@ -3015,6 +3153,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3175,6 +3323,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3365,6 +3522,18 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +[[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", +] + [[package]] name = "zstd" version = "0.13.1" diff --git a/chat_core/Cargo.toml b/chat_core/Cargo.toml index a968a78..269adc6 100644 --- a/chat_core/Cargo.toml +++ b/chat_core/Cargo.toml @@ -14,5 +14,6 @@ sqlx = { workspace = true } tokio = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } -tracing.workspace = true +tracing = { workspace = true } +utoipa = { version = "4.2.0", features = ["axum_extras", "chrono"] } uuid = { version = "1.8.0", features = ["v7", "serde"] } diff --git a/chat_core/src/lib.rs b/chat_core/src/lib.rs index 872c6aa..8a7be84 100644 --- a/chat_core/src/lib.rs +++ b/chat_core/src/lib.rs @@ -7,8 +7,9 @@ use serde::{Deserialize, Serialize}; use sqlx::FromRow; pub use utils::*; +use utoipa::ToSchema; -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, FromRow, ToSchema, Serialize, Deserialize, PartialEq)] pub struct User { pub id: i64, pub ws_id: i64, @@ -20,7 +21,7 @@ pub struct User { pub created_at: DateTime, } -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, FromRow, ToSchema, Serialize, Deserialize, PartialEq)] pub struct Workspace { pub id: i64, pub name: String, @@ -28,14 +29,14 @@ pub struct Workspace { pub created_at: DateTime, } -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, FromRow, ToSchema, Serialize, Deserialize, PartialEq)] pub struct ChatUser { pub id: i64, pub fullname: String, pub email: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, sqlx::Type)] +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize, PartialEq, PartialOrd, sqlx::Type)] #[sqlx(type_name = "chat_type", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum ChatType { @@ -45,7 +46,7 @@ pub enum ChatType { PublicChannel, } -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, FromRow, ToSchema, Serialize, Deserialize, PartialEq)] pub struct Chat { pub id: i64, pub ws_id: i64, @@ -55,7 +56,7 @@ pub struct Chat { pub created_at: DateTime, } -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, FromRow, ToSchema, Serialize, Deserialize, PartialEq)] pub struct Message { pub id: i64, pub chat_id: i64, diff --git a/chat_server/Cargo.toml b/chat_server/Cargo.toml index fd5eb52..895e7dc 100644 --- a/chat_server/Cargo.toml +++ b/chat_server/Cargo.toml @@ -30,6 +30,10 @@ tower = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +utoipa = { version = "4.2.0", features = ["axum_extras", "chrono"] } +utoipa-swagger-ui = { version = "6.0.0", features = ["axum"] } +utoipa-redoc = { version = "3.0.0", features = ["axum"] } +utoipa-rapidoc = { version = "3.0.0", features = ["axum"] } [dev-dependencies] chat-server = { workspace = true, features = ["test-util"] } diff --git a/chat_server/src/error.rs b/chat_server/src/error.rs index 8b94cfa..d2f8195 100644 --- a/chat_server/src/error.rs +++ b/chat_server/src/error.rs @@ -3,8 +3,9 @@ use axum::response::Json; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, ToSchema, Serialize, Deserialize)] pub struct ErrorOutput { pub error: String, } diff --git a/chat_server/src/handlers/auth.rs b/chat_server/src/handlers/auth.rs index 705c0bf..b3ddac2 100644 --- a/chat_server/src/handlers/auth.rs +++ b/chat_server/src/handlers/auth.rs @@ -4,12 +4,25 @@ use crate::{ }; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, ToSchema, Deserialize)] pub struct AuthOutput { token: String, } +#[utoipa::path( + post, + path = "/api/signup", + responses( + (status = 200, description = "User created", body = AuthOutput), + ) +)] +/// Create a new user in the chat system with email and password. +/// +/// - If the email already exists, it will return 409. +/// - Otherwise, it will return 201 with a token. +/// - If the workspace doesn't exist, it will create one. pub(crate) async fn signup_handler( State(state): State, Json(input): Json, @@ -20,6 +33,13 @@ pub(crate) async fn signup_handler( Ok((StatusCode::CREATED, body)) } +#[utoipa::path( + post, + path = "/api/signin", + responses( + (status = 200, description = "User signed in", body = AuthOutput), + ) +)] pub(crate) async fn signin_handler( State(state): State, Json(input): Json, diff --git a/chat_server/src/handlers/chat.rs b/chat_server/src/handlers/chat.rs index cf56ae1..33d83d1 100644 --- a/chat_server/src/handlers/chat.rs +++ b/chat_server/src/handlers/chat.rs @@ -7,6 +7,16 @@ use axum::{ }; use chat_core::User; +#[utoipa::path( + get, + path = "/api/chats", + responses( + (status = 200, description = "List of chats", body = Vec), + ), + security( + ("token" = []) + ) +)] pub(crate) async fn list_chat_handler( Extension(user): Extension, State(state): State, @@ -15,6 +25,16 @@ pub(crate) async fn list_chat_handler( Ok((StatusCode::OK, Json(chat))) } +#[utoipa::path( + post, + path = "/api/chats", + responses( + (status = 201, description = "Chat created", body = Chat), + ), + security( + ("token" = []) + ) +)] pub(crate) async fn create_chat_handler( Extension(user): Extension, State(state): State, @@ -24,6 +44,20 @@ pub(crate) async fn create_chat_handler( Ok((StatusCode::CREATED, Json(chat))) } +#[utoipa::path( + get, + path = "/api/chats/{id}", + params( + ("id" = u64, Path, description = "Chat id") + ), + responses( + (status = 200, description = "Chat found", body = Chat), + (status = 404, description = "Chat not found", body = ErrorOutput), + ), + security( + ("token" = []) + ) +)] pub(crate) async fn get_chat_handler( State(state): State, Path(id): Path, diff --git a/chat_server/src/handlers/messages.rs b/chat_server/src/handlers/messages.rs index cff7b2f..5c81493 100644 --- a/chat_server/src/handlers/messages.rs +++ b/chat_server/src/handlers/messages.rs @@ -21,6 +21,22 @@ pub(crate) async fn send_message_handler( Ok((StatusCode::CREATED, Json(msg))) } +#[utoipa::path( + get, + path = "/api/chats/{id}/messages", + params( + ("id" = u64, Path, description = "Chat id"), + ListMessages + + ), + responses( + (status = 200, description = "List of messages", body = Vec), + (status = 400, description = "Invalid input", body = ErrorOutput), + ), + security( + ("token" = []) + ) +)] pub(crate) async fn list_message_handler( State(state): State, Path(id): Path, diff --git a/chat_server/src/lib.rs b/chat_server/src/lib.rs index c8e0b3f..916ce22 100644 --- a/chat_server/src/lib.rs +++ b/chat_server/src/lib.rs @@ -3,6 +3,7 @@ mod error; mod handlers; mod middlewares; mod models; +mod openapi; use anyhow::Context; use chat_core::{ @@ -11,6 +12,7 @@ use chat_core::{ }; use handlers::*; use middlewares::verify_chat; +use openapi::OpenApiRouter; use sqlx::PgPool; use std::{fmt, ops::Deref, sync::Arc}; use tokio::fs; @@ -63,6 +65,7 @@ pub async fn get_router(state: AppState) -> Result { .route("/signup", post(signup_handler)); let app = Router::new() + .openapi() .route("/", get(index_handler)) .nest("/api", api) .with_state(state); diff --git a/chat_server/src/models/chat.rs b/chat_server/src/models/chat.rs index 5370be1..003bb90 100644 --- a/chat_server/src/models/chat.rs +++ b/chat_server/src/models/chat.rs @@ -1,8 +1,9 @@ use crate::{AppError, AppState}; use chat_core::{Chat, ChatType}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, ToSchema, Serialize, Deserialize)] pub struct CreateChat { pub name: Option, pub members: Vec, diff --git a/chat_server/src/models/messages.rs b/chat_server/src/models/messages.rs index 79b5697..513b465 100644 --- a/chat_server/src/models/messages.rs +++ b/chat_server/src/models/messages.rs @@ -2,14 +2,15 @@ use crate::{AppError, AppState, ChatFile}; use chat_core::Message; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use utoipa::{IntoParams, ToSchema}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize)] pub struct CreateMessage { pub content: String, pub files: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, IntoParams, ToSchema, Serialize, Deserialize)] pub struct ListMessages { pub last_id: Option, pub limit: u64, diff --git a/chat_server/src/models/user.rs b/chat_server/src/models/user.rs index a96bb8e..92b3d8d 100644 --- a/chat_server/src/models/user.rs +++ b/chat_server/src/models/user.rs @@ -6,16 +6,22 @@ use argon2::{ use chat_core::{ChatUser, User}; use serde::{Deserialize, Serialize}; use std::mem; +use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize)] +/// create a user with email and password +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize)] pub struct CreateUser { + /// Full name of the user pub fullname: String, + /// Email of the user pub email: String, + /// Workspace name - if not exists, create one pub workspace: String, + /// Password of the user pub password: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize)] pub struct SigninUser { pub email: String, pub password: String, diff --git a/chat_server/src/openapi.rs b/chat_server/src/openapi.rs new file mode 100644 index 0000000..56bfe3f --- /dev/null +++ b/chat_server/src/openapi.rs @@ -0,0 +1,58 @@ +use crate::handlers::*; +use crate::{ + AppState, CreateChat, CreateMessage, CreateUser, ErrorOutput, ListMessages, SigninUser, +}; +use axum::Router; +use chat_core::{Chat, ChatType, ChatUser, Message, User, Workspace}; +use utoipa::{ + openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_rapidoc::RapiDoc; +use utoipa_redoc::{Redoc, Servable}; +use utoipa_swagger_ui::SwaggerUi; + +pub(crate) trait OpenApiRouter { + fn openapi(self) -> Self; +} + +#[derive(OpenApi)] +#[openapi( + paths( + signup_handler, + signin_handler, + list_chat_handler, + create_chat_handler, + get_chat_handler, + list_message_handler, + ), + components( + schemas(User, Chat, ChatType, ChatUser, Message, Workspace, SigninUser, CreateUser, CreateChat, CreateMessage, ListMessages, AuthOutput, ErrorOutput), + ), + modifiers(&SecurityAddon), + tags( + (name = "chat", description = "Chat related operations"), + ) + )] +pub(crate) struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "token", + SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build()), + ) + } + } +} + +impl OpenApiRouter for Router { + fn openapi(self) -> Self { + self.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .merge(Redoc::with_url("/redoc", ApiDoc::openapi())) + .merge(RapiDoc::new("/api-docs/openapi.json").path("/rapidoc")) + } +}