From 955598dce9aeb5626654c72b0ef94850123fa8ac Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Sun, 14 Sep 2025 23:27:25 +0300 Subject: Add openapi specs and docs --- .zed/tasks.json | 4 +- Cargo.toml | 7 +- Scalar.html | 19 +++++ src/api.rs | 56 ++++++++++++ src/error.rs | 91 -------------------- src/error/client.rs | 33 ++++++++ src/error/mod.rs | 50 +++++++++++ src/error/server.rs | 24 ++++++ src/extract/auth.rs | 10 +-- src/extract/json.rs | 4 +- src/main.rs | 11 ++- src/response.rs | 54 ------------ src/response/error.rs | 69 +++++++++++++++ src/response/mod.rs | 70 +++++++++++++++ src/response/success.rs | 33 ++++++++ src/routers/account.rs | 221 ++++++++++++++++++++++++++++++++++-------------- src/routers/mod.rs | 9 +- 17 files changed, 539 insertions(+), 226 deletions(-) create mode 100644 Scalar.html create mode 100644 src/api.rs delete mode 100644 src/error.rs create mode 100644 src/error/client.rs create mode 100644 src/error/mod.rs create mode 100644 src/error/server.rs delete mode 100644 src/response.rs create mode 100644 src/response/error.rs create mode 100644 src/response/mod.rs create mode 100644 src/response/success.rs diff --git a/.zed/tasks.json b/.zed/tasks.json index ee4c082..c11d9cc 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -65,7 +65,7 @@ "env": { "SECRET": "secret", - "SERVER_BIND": "0.0.0.0:8080", + "SERVER_BIND": "0.0.0.0:8888", "DATABASE_URL": "postgres://itmo_queue:itmo_queue@localhost/itmo_queue" }, @@ -83,7 +83,7 @@ "env": { "SECRET": "secret", - "SERVER_BIND": "0.0.0.0:8080", + "SERVER_BIND": "0.0.0.0:8888", "DATABASE_URL": "postgres://itmo_queue:itmo_queue@localhost/itmo_queue" }, diff --git a/Cargo.toml b/Cargo.toml index 3dc810d..4c8f11f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itmo_queue_server" -version = "0.1.0" +version = "1.0.0" edition = "2024" publish = false @@ -18,9 +18,12 @@ jsonwebtoken = "9.3.1" migration = { version = "0.1.0", path = "migration" } sea-orm = { version = "1.1.14", features = ["sqlx-postgres", "runtime-tokio-rustls"] } serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.143" +serde_json = { version = "1.0.143", features = ["preserve_order"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tower = "0.5.2" tower-http = { version = "0.6.6", features = ["trace"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" +utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "preserve_order", "preserve_path_order"] } +utoipa-axum = "0.2.0" +utoipa-scalar = { version = "0.3.0", features = ["axum"] } diff --git a/Scalar.html b/Scalar.html new file mode 100644 index 0000000..f5280a1 --- /dev/null +++ b/Scalar.html @@ -0,0 +1,19 @@ + + + + Scalar + + + + + + + + + diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..23fb74b --- /dev/null +++ b/src/api.rs @@ -0,0 +1,56 @@ +use utoipa::{ + Modify, OpenApi, + openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, +}; + +use crate::ErrorResponse; + +pub mod tags { + pub const ACCOUNT: &str = "Account"; +} + +struct AuthModifier; + +impl Modify for AuthModifier { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + } +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "ITMO queue server API", + description = "Queuing service for labs works", + license( + name = "AGPL-3.0", + url="https://www.gnu.org/licenses/agpl-3.0.en.html#license-text" + ), + ), + servers( + ( + url = "http://localhost:{port}/", + description = "Local server", + variables(("port" = (default = "8888", description="Server port"))) + ), + (url = "https://очередь.псж.онлайн/api/v1/", description = "Production server"), + ), + tags( + (name=tags::ACCOUNT, description="Account management methods") + ), + components( + schemas(ErrorResponse) + ), + modifiers(&AuthModifier) +)] +pub struct AppOpenApi; diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 1eb54ef..0000000 --- a/src/error.rs +++ /dev/null @@ -1,91 +0,0 @@ -use axum::{ - extract::rejection::JsonRejection, - response::{IntoResponse, Response}, -}; -use axum_extra::typed_header::TypedHeaderRejection; - -use crate::response::{ErrorResponse, FailResponse, SuccessResponse}; - -pub type ApiResult = Result, ApiError>; - -pub enum ApiError { - // 400 - BadJsonBody(String), - BadAuthTokenHeader(String), - UserAlreadyExists { username: String }, - InvalidPassword, - NotAuthorized, - // 500 - Database(String), - PasswordHash(String), - InternalJwt(String), -} - -impl From for ApiError { - fn from(value: JsonRejection) -> Self { - Self::BadJsonBody(value.body_text()) - } -} - -impl From for ApiError { - fn from(value: TypedHeaderRejection) -> Self { - Self::BadAuthTokenHeader(value.to_string()) - } -} - -impl From for ApiError { - fn from(value: sea_orm::DbErr) -> Self { - Self::Database(value.to_string()) - } -} - -impl From for ApiError { - fn from(value: argon2::password_hash::Error) -> Self { - Self::PasswordHash(value.to_string()) - } -} - -impl ToString for ApiError { - fn to_string(&self) -> String { - match self { - // 400 - ApiError::BadJsonBody(..) => "BadJsonBody", - ApiError::BadAuthTokenHeader(..) => "BadAuthTokenHeader", - ApiError::UserAlreadyExists { .. } => "UserAlreadyExists", - ApiError::InvalidPassword => "InvalidPassword", - ApiError::NotAuthorized => "NotAuthorized", - // 500 - ApiError::Database(..) => "Database", - ApiError::PasswordHash(..) => "PasswordHash", - ApiError::InternalJwt(..) => "InternalJwt", - } - .to_string() - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let kind = self.to_string(); - match self { - // 400 - ApiError::BadJsonBody(msg) => FailResponse(kind, msg).into_response(), - ApiError::BadAuthTokenHeader(msg) => FailResponse(kind, msg).into_response(), - ApiError::UserAlreadyExists { username } => FailResponse( - kind, - format!("user with username `{}` already exists", username), - ) - .into_response(), - ApiError::InvalidPassword => { - FailResponse(kind, "password is invalid".to_string()).into_response() - } - ApiError::NotAuthorized => { - FailResponse(kind, "user is not authorized".to_string()).into_response() - } - - // 500 - ApiError::Database(msg) => ErrorResponse(kind, msg).into_response(), - ApiError::PasswordHash(msg) => ErrorResponse(kind, msg).into_response(), - ApiError::InternalJwt(msg) => ErrorResponse(kind, msg).into_response(), - } - } -} diff --git a/src/error/client.rs b/src/error/client.rs new file mode 100644 index 0000000..980e3d2 --- /dev/null +++ b/src/error/client.rs @@ -0,0 +1,33 @@ +pub enum ClientError { + BadJsonBody(String), + BadAuthTokenHeader(String), + UserAlreadyExists { username: String }, + InvalidPassword, + NotAuthorized, +} + +impl ClientError { + pub fn kind(&self) -> String { + match self { + Self::BadJsonBody(..) => "BadJsonBody", + Self::BadAuthTokenHeader(..) => "BadAuthTokenHeader", + Self::UserAlreadyExists { .. } => "UserAlreadyExists", + Self::InvalidPassword => "InvalidPassword", + Self::NotAuthorized => "NotAuthorized", + } + .to_string() + } + + pub fn into_message(self) -> String { + match self { + Self::BadJsonBody(msg) => msg, + Self::BadAuthTokenHeader(msg) => msg, + Self::UserAlreadyExists { username } => { + format!("user with username `{}` already exists", username) + } + Self::InvalidPassword => "password is invalid".to_string(), + + Self::NotAuthorized => "user is not authorized".to_string(), + } + } +} diff --git a/src/error/mod.rs b/src/error/mod.rs new file mode 100644 index 0000000..55d7250 --- /dev/null +++ b/src/error/mod.rs @@ -0,0 +1,50 @@ +mod client; +mod server; + +pub use client::ClientError; +pub use server::ServerError; + +use argon2::password_hash::Error as PasswordHashError; +use axum::extract::rejection::JsonRejection; +use axum_extra::typed_header::TypedHeaderRejection; +use sea_orm::DbErr; + +pub enum ApiError { + Client(ClientError), + Server(ServerError), +} + +impl From for ApiError { + fn from(value: ClientError) -> Self { + Self::Client(value) + } +} +impl From for ApiError { + fn from(value: ServerError) -> Self { + Self::Server(value) + } +} + +impl From for ApiError { + fn from(value: JsonRejection) -> Self { + Self::Client(ClientError::BadJsonBody(value.body_text())) + } +} + +impl From for ApiError { + fn from(value: TypedHeaderRejection) -> Self { + Self::Client(ClientError::BadAuthTokenHeader(value.to_string())) + } +} + +impl From for ApiError { + fn from(value: DbErr) -> Self { + Self::Server(ServerError::Database(value.to_string())) + } +} + +impl From for ApiError { + fn from(value: PasswordHashError) -> Self { + Self::Server(ServerError::PasswordHash(value.to_string())) + } +} diff --git a/src/error/server.rs b/src/error/server.rs new file mode 100644 index 0000000..e67714d --- /dev/null +++ b/src/error/server.rs @@ -0,0 +1,24 @@ +pub enum ServerError { + Database(String), + PasswordHash(String), + Token(String), +} + +impl ServerError { + pub fn kind(&self) -> String { + match self { + Self::Database(..) => "Database", + Self::PasswordHash(..) => "PasswordHash", + Self::Token(..) => "Token", + } + .to_string() + } + + pub fn into_message(self) -> String { + match self { + Self::Database(msg) => msg, + Self::PasswordHash(msg) => msg, + Self::Token(msg) => msg, + } + } +} diff --git a/src/extract/auth.rs b/src/extract/auth.rs index 1feb985..c603ee7 100644 --- a/src/extract/auth.rs +++ b/src/extract/auth.rs @@ -4,12 +4,12 @@ use entity::users; use headers::authorization::{Authorization, Bearer}; use sea_orm::EntityTrait; -use crate::{ApiError, AppState, validate_jwt}; +use crate::{AppState, ClientError, ErrorResponse, validate_jwt}; pub struct Auth(pub users::Model); impl FromRequestParts for Auth { - type Rejection = ApiError; + type Rejection = ErrorResponse; async fn from_request_parts( parts: &mut Parts, @@ -19,15 +19,15 @@ impl FromRequestParts for Auth { TypedHeader::>::from_request_parts(parts, state).await?; let jwt_claims = validate_jwt(token_header.token(), &state.secret) - .map_err(|_| ApiError::NotAuthorized)?; + .map_err(|_| ClientError::NotAuthorized)?; let user = users::Entity::find_by_id(jwt_claims.sub) .one(&state.db) .await? - .ok_or(ApiError::NotAuthorized)?; + .ok_or(ClientError::NotAuthorized)?; if jwt_claims.iat < user.password_issue_date.and_utc().timestamp() { - return Err(ApiError::NotAuthorized); + return Err(ClientError::NotAuthorized.into()); } Ok(Auth(user)) diff --git a/src/extract/json.rs b/src/extract/json.rs index 751df71..aaf8623 100644 --- a/src/extract/json.rs +++ b/src/extract/json.rs @@ -1,6 +1,6 @@ use axum::extract::{FromRequest, Request, rejection::JsonRejection}; -use crate::error::ApiError; +use crate::ErrorResponse; pub struct ApiJson(pub T); @@ -9,7 +9,7 @@ where axum::Json: FromRequest, S: Send + Sync, { - type Rejection = ApiError; + type Rejection = ErrorResponse; #[inline] async fn from_request(req: Request, state: &S) -> Result { diff --git a/src/main.rs b/src/main.rs index 4ab5aeb..fe2589a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod api; mod auth; mod error; mod extract; @@ -5,9 +6,10 @@ mod response; mod routers; mod state; +pub use api::{AppOpenApi, tags}; pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password}; -pub use error::{ApiError, ApiResult}; -pub use response::{ErrorResponse, FailResponse, SuccessResponse}; +pub use error::{ApiError, ClientError, ServerError}; +pub use response::{ApiResult, ErrorResponse, GlobalResponses, SuccessResponse}; pub use state::AppState; use axum::Router; @@ -16,6 +18,7 @@ use tokio::net::TcpListener; use tower_http::trace::TraceLayer; use tracing::info; use tracing_subscriber::EnvFilter; +use utoipa_scalar::{Scalar, Servable}; fn env(name: &str) -> String { std::env::var(name).expect(format!("{} must be set", name).as_str()) @@ -28,10 +31,12 @@ async fn listener() -> TcpListener { async fn router() -> Router { let db = Database::connect(env("DATABASE_URL")).await.unwrap(); let secret = env("SECRET"); + let (router, api) = routers::router().split_for_parts(); - routers::router() + router .layer(TraceLayer::new_for_http()) .with_state(AppState { db, secret }) + .merge(Scalar::with_url("/docs", api)) } #[tokio::main] diff --git a/src/response.rs b/src/response.rs deleted file mode 100644 index a633570..0000000 --- a/src/response.rs +++ /dev/null @@ -1,54 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Serialize; -use serde_json::json; - -pub struct SuccessResponse(pub T); -pub struct FailResponse(pub String, pub String); -pub struct ErrorResponse(pub String, pub String); - -impl IntoResponse for SuccessResponse -where - T: Serialize, -{ - fn into_response(self) -> Response { - ( - StatusCode::OK, - axum::Json(json!({ - "status": "success", - "data": self.0 - })), - ) - .into_response() - } -} - -impl IntoResponse for FailResponse { - fn into_response(self) -> Response { - ( - StatusCode::BAD_REQUEST, - axum::Json(json!({ - "status": "fail", - "kind": self.0, - "message": self.1 - })), - ) - .into_response() - } -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(json!({ - "status": "error", - "kind": self.0, - "message": self.1 - })), - ) - .into_response() - } -} diff --git a/src/response/error.rs b/src/response/error.rs new file mode 100644 index 0000000..db39da8 --- /dev/null +++ b/src/response/error.rs @@ -0,0 +1,69 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::ApiError; + +#[derive(Serialize, ToSchema)] +#[schema(examples("fail or error"))] +enum ErrorStatus { + #[serde(rename = "fail")] + Fail, + #[serde(rename = "error")] + Error, +} + +#[derive(Serialize, ToSchema)] +pub struct ErrorResponse { + status: ErrorStatus, + #[schema(examples("SomeErrorKind", "NotAuthorized", "Database"))] + kind: String, + #[schema(examples("some error text"))] + message: String, +} + +impl ErrorResponse { + pub fn fail(kind: impl Into, message: impl Into) -> Self { + Self { + status: ErrorStatus::Fail, + kind: kind.into(), + message: message.into(), + } + } + + pub fn error(kind: impl Into, message: impl Into) -> Self { + Self { + status: ErrorStatus::Error, + kind: kind.into(), + message: message.into(), + } + } +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + ( + match self.status { + ErrorStatus::Fail => StatusCode::BAD_REQUEST, + ErrorStatus::Error => StatusCode::INTERNAL_SERVER_ERROR, + }, + axum::Json(self), + ) + .into_response() + } +} + +impl From for ErrorResponse +where + T: Into, +{ + fn from(value: T) -> Self { + match value.into() { + ApiError::Client(e) => Self::fail(e.kind(), e.into_message()), + ApiError::Server(e) => Self::fail(e.kind(), e.into_message()), + } + } +} diff --git a/src/response/mod.rs b/src/response/mod.rs new file mode 100644 index 0000000..166bc13 --- /dev/null +++ b/src/response/mod.rs @@ -0,0 +1,70 @@ +mod error; +mod success; + +pub use error::ErrorResponse; +use serde_json::json; +pub use success::SuccessResponse; + +use std::collections::BTreeMap; + +use utoipa::{ + IntoResponses, ToSchema, + openapi::{ + ContentBuilder, RefOr, ResponseBuilder, ResponsesBuilder, example::ExampleBuilder, + response::Response, schema::RefBuilder, + }, +}; + +pub type ApiResult = Result, ErrorResponse>; + +pub struct GlobalResponses; + +impl IntoResponses for GlobalResponses { + fn responses() -> BTreeMap> { + ResponsesBuilder::new() + .response( + "400", + ResponseBuilder::new() + .content( + "application/json", + ContentBuilder::new() + .schema(Some( + RefBuilder::new() + .ref_location_from_schema_name(ErrorResponse::name()), + )) + .examples_from_iter([( + "Fail", + ExampleBuilder::new().value(Some(json!(ErrorResponse::fail( + "SomeFailKind", + "some fail message" + )))), + )]) + .build(), + ) + .description("General response for invalid request"), + ) + .response( + "500", + ResponseBuilder::new() + .content( + "application/json", + ContentBuilder::new() + .schema(Some( + RefBuilder::new() + .ref_location_from_schema_name(ErrorResponse::name()), + )) + .examples_from_iter([( + "Error", + ExampleBuilder::new().value(Some(json!(ErrorResponse::error( + "SomeErrorKind", + "some error message" + )))), + )]) + .build(), + ) + .description("General response when a server error occurs"), + ) + .build() + .into() + } +} diff --git a/src/response/success.rs b/src/response/success.rs new file mode 100644 index 0000000..c2ec4e5 --- /dev/null +++ b/src/response/success.rs @@ -0,0 +1,33 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +enum SuccessStatus { + #[serde(rename = "success")] + Success, +} + +#[derive(Serialize, ToSchema)] +pub struct SuccessResponse { + status: SuccessStatus, + data: T, +} + +impl SuccessResponse { + pub fn ok(data: T) -> Self { + Self { + status: SuccessStatus::Success, + data, + } + } +} + +impl IntoResponse for SuccessResponse { + fn into_response(self) -> Response { + (StatusCode::OK, axum::Json(self)).into_response() + } +} diff --git a/src/routers/account.rs b/src/routers/account.rs index 1498b73..0d6d4c1 100644 --- a/src/routers/account.rs +++ b/src/routers/account.rs @@ -1,8 +1,4 @@ -use axum::{ - Router, - extract::State, - routing::{delete, get, post, put}, -}; +use axum::extract::State; use chrono::{DateTime, Duration, Utc}; use entity::users::{self}; use sea_orm::{ @@ -10,29 +6,104 @@ use sea_orm::{ QueryFilter, }; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ - ApiError, ApiResult, AppState, JwtClaims, SuccessResponse, create_jwt, create_password, + ApiResult, AppState, ClientError, GlobalResponses, JwtClaims, ServerError, SuccessResponse, + create_jwt, create_password, extract::{ApiJson, Auth}, + tags::ACCOUNT, validate_password, }; -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] +#[schema(description = "Account information")] struct Account { + #[schema(examples(1))] id: i64, + #[schema(examples("john_doe", "ivanov_ivan"))] username: String, + #[schema(examples("John", "Иван"))] first_name: String, + #[schema(examples("Doe", "Иванов"))] last_name: String, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] +#[schema(description = "Authorization token information")] struct Token { token: String, expired_at: DateTime, } +#[derive(Deserialize, ToSchema)] +#[schema(description = "Account register data")] +struct RegisterRequest { + #[schema(examples("john_doe", "ivanov_ivan"))] + username: String, + #[schema(examples("secret-password"))] + password: String, + #[schema(examples("John", "Иван"))] + first_name: String, + #[schema(examples("Doe", "Иванов"))] + last_name: String, +} + +#[derive(Deserialize, ToSchema, Default)] +#[serde(rename_all = "UPPERCASE")] +#[schema(description = "Account token life time")] +enum TokenLifetime { + Day = 1, + #[default] + Week = 7, + Month = 31, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Account login data")] +struct LoginRequest { + #[schema(examples("john_doe", "ivanov_ivan"))] + username: String, + #[schema(examples("secret-password"))] + password: String, + #[serde(default)] + #[schema(default = "WEEK")] + token_lifetime: TokenLifetime, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Change account password data")] +struct ChangePasswordRequest { + #[schema(examples("secret-password"))] + old_password: String, + #[schema(examples("super-secret-password"))] + new_password: String, +} + +#[derive(Deserialize, ToSchema)] +#[schema(description = "Account delete data")] +struct DeleteUserRequest { + #[schema(examples("secret-password"))] + password: String, +} + +#[utoipa::path( + get, + path = "/me", + tag = ACCOUNT, + summary = "Get me", + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with your account data" + ), + GlobalResponses + ), + security(("auth" = [])), +)] async fn me(Auth(user): Auth) -> ApiResult { - return Ok(SuccessResponse(Account { + return Ok(SuccessResponse::ok(Account { id: user.id, username: user.username, first_name: user.first_name, @@ -40,14 +111,20 @@ async fn me(Auth(user): Auth) -> ApiResult { })); } -#[derive(Deserialize)] -struct RegisterRequest { - username: String, - password: String, - first_name: String, - last_name: String, -} - +#[utoipa::path( + post, + path = "/register", + tag = ACCOUNT, + summary = "Register", + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with created account data" + ), + GlobalResponses + ), + request_body = RegisterRequest +)] async fn register( State(state): State, ApiJson(req): ApiJson, @@ -59,9 +136,10 @@ async fn register( .is_some(); if user_exists { - return Err(ApiError::UserAlreadyExists { + return Err(ClientError::UserAlreadyExists { username: req.username, - }); + } + .into()); } let user = users::ActiveModel { @@ -75,7 +153,7 @@ async fn register( .insert(&state.db) .await?; - Ok(SuccessResponse(Account { + Ok(SuccessResponse::ok(Account { id: user.id, username: user.username, first_name: user.first_name, @@ -83,22 +161,20 @@ async fn register( })) } -#[derive(Deserialize, Default)] -enum TokenLifetime { - Day = 1, - #[default] - Week = 7, - Month = 31, -} - -#[derive(Deserialize)] -struct LoginRequest { - username: String, - password: String, - #[serde(default)] - token_lifetime: TokenLifetime, -} - +#[utoipa::path( + post, + path = "/login", + tag = ACCOUNT, + summary = "Login", + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with auth token data" + ), + GlobalResponses + ), + request_body = LoginRequest +)] async fn login( State(state): State, ApiJson(req): ApiJson, @@ -107,10 +183,10 @@ async fn login( .filter(users::Column::Username.eq(&req.username)) .one(&state.db) .await? - .ok_or(ApiError::InvalidPassword)?; + .ok_or(ClientError::InvalidPassword)?; if !validate_password(&req.password, &user.password_hash)? { - return Err(ApiError::InvalidPassword); + return Err(ClientError::InvalidPassword.into()); } let expired_at = Utc::now() + Duration::days(req.token_lifetime as i64); @@ -123,24 +199,33 @@ async fn login( }, &state.secret, ) - .map_err(|e| ApiError::InternalJwt(e.to_string()))?; + .map_err(|e| ServerError::Token(e.to_string()))?; - Ok(SuccessResponse(Token { token, expired_at })) -} - -#[derive(Deserialize)] -struct ChangePasswordRequest { - old_password: String, - new_password: String, + Ok(SuccessResponse::ok(Token { token, expired_at })) } +#[utoipa::path( + put, + path = "/change/password", + tag = ACCOUNT, + summary = "Change password", + request_body = ChangePasswordRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with changed account data" + ), + GlobalResponses + ), + security(("auth" = [])) +)] async fn change_password( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { if !validate_password(&req.old_password, &user.password_hash)? { - return Err(ApiError::InvalidPassword); + return Err(ClientError::InvalidPassword.into()); } let mut active_user = user.into_active_model(); @@ -148,7 +233,7 @@ async fn change_password( active_user.password_issue_date = Set(Utc::now().naive_utc()); let user = active_user.update(&state.db).await?; - Ok(SuccessResponse(Account { + Ok(SuccessResponse::ok(Account { id: user.id, username: user.username, first_name: user.first_name, @@ -156,23 +241,33 @@ async fn change_password( })) } -#[derive(Deserialize)] -struct DeleteUserRequest { - password: String, -} - -async fn delete_account( +#[utoipa::path( + delete, + path = "/delete", + tag = ACCOUNT, + summary = "Delete", + request_body = DeleteUserRequest, + responses( + ( + status = 200, body = SuccessResponse, + description = "Success response with deleted account data" + ), + GlobalResponses + ), + security(("auth" = [])) +)] +async fn delete( State(state): State, Auth(user): Auth, ApiJson(req): ApiJson, ) -> ApiResult { if !validate_password(&req.password, &user.password_hash)? { - return Err(ApiError::InvalidPassword); + return Err(ClientError::InvalidPassword.into()); } user.clone().delete(&state.db).await?; - Ok(SuccessResponse(Account { + Ok(SuccessResponse::ok(Account { id: user.id, username: user.username, first_name: user.first_name, @@ -180,11 +275,11 @@ async fn delete_account( })) } -pub(crate) fn router() -> Router { - Router::new() - .route("/me", get(me)) - .route("/register", post(register)) - .route("/login", post(login)) - .route("/change_password", put(change_password)) - .route("/delete", delete(delete_account)) +pub(crate) fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(me)) + .routes(routes!(register)) + .routes(routes!(login)) + .routes(routes!(change_password)) + .routes(routes!(delete)) } diff --git a/src/routers/mod.rs b/src/routers/mod.rs index b57f71d..4623f8e 100644 --- a/src/routers/mod.rs +++ b/src/routers/mod.rs @@ -1,9 +1,10 @@ mod account; -use axum::Router; +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; -use crate::state::AppState; +use crate::{AppOpenApi, AppState}; -pub(crate) fn router() -> Router { - Router::new().nest("/account", account::router()) +pub(crate) fn router() -> OpenApiRouter { + OpenApiRouter::with_openapi(AppOpenApi::openapi()).nest("/account", account::router()) } -- cgit v1.2.3