diff options
| author | Tolmachev Igor <me@igorek.dev> | 2025-09-14 23:27:25 +0300 |
|---|---|---|
| committer | Tolmachev Igor <me@igorek.dev> | 2025-09-14 23:27:25 +0300 |
| commit | 955598dce9aeb5626654c72b0ef94850123fa8ac (patch) | |
| tree | 4fb161c2e67fdc161ebbca5ced271b6e7724dc30 /src | |
| parent | 39bf8397949ea2738ac3dfc934fcc3f07a6b0b66 (diff) | |
| download | queue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.tar.gz queue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.zip | |
Add openapi specs and docs
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.rs | 56 | ||||
| -rw-r--r-- | src/error.rs | 91 | ||||
| -rw-r--r-- | src/error/client.rs | 33 | ||||
| -rw-r--r-- | src/error/mod.rs | 50 | ||||
| -rw-r--r-- | src/error/server.rs | 24 | ||||
| -rw-r--r-- | src/extract/auth.rs | 10 | ||||
| -rw-r--r-- | src/extract/json.rs | 4 | ||||
| -rw-r--r-- | src/main.rs | 11 | ||||
| -rw-r--r-- | src/response.rs | 54 | ||||
| -rw-r--r-- | src/response/error.rs | 69 | ||||
| -rw-r--r-- | src/response/mod.rs | 70 | ||||
| -rw-r--r-- | src/response/success.rs | 33 | ||||
| -rw-r--r-- | src/routers/account.rs | 221 | ||||
| -rw-r--r-- | src/routers/mod.rs | 9 |
14 files changed, 513 insertions, 222 deletions
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 @@ | |||
| 1 | use utoipa::{ | ||
| 2 | Modify, OpenApi, | ||
| 3 | openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, | ||
| 4 | }; | ||
| 5 | |||
| 6 | use crate::ErrorResponse; | ||
| 7 | |||
| 8 | pub mod tags { | ||
| 9 | pub const ACCOUNT: &str = "Account"; | ||
| 10 | } | ||
| 11 | |||
| 12 | struct AuthModifier; | ||
| 13 | |||
| 14 | impl Modify for AuthModifier { | ||
| 15 | fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { | ||
| 16 | if let Some(components) = openapi.components.as_mut() { | ||
| 17 | components.add_security_scheme( | ||
| 18 | "auth", | ||
| 19 | SecurityScheme::Http( | ||
| 20 | HttpBuilder::new() | ||
| 21 | .scheme(HttpAuthScheme::Bearer) | ||
| 22 | .bearer_format("JWT") | ||
| 23 | .build(), | ||
| 24 | ), | ||
| 25 | ) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | #[derive(OpenApi)] | ||
| 31 | #[openapi( | ||
| 32 | info( | ||
| 33 | title = "ITMO queue server API", | ||
| 34 | description = "Queuing service for labs works", | ||
| 35 | license( | ||
| 36 | name = "AGPL-3.0", | ||
| 37 | url="https://www.gnu.org/licenses/agpl-3.0.en.html#license-text" | ||
| 38 | ), | ||
| 39 | ), | ||
| 40 | servers( | ||
| 41 | ( | ||
| 42 | url = "http://localhost:{port}/", | ||
| 43 | description = "Local server", | ||
| 44 | variables(("port" = (default = "8888", description="Server port"))) | ||
| 45 | ), | ||
| 46 | (url = "https://очередь.псж.онлайн/api/v1/", description = "Production server"), | ||
| 47 | ), | ||
| 48 | tags( | ||
| 49 | (name=tags::ACCOUNT, description="Account management methods") | ||
| 50 | ), | ||
| 51 | components( | ||
| 52 | schemas(ErrorResponse) | ||
| 53 | ), | ||
| 54 | modifiers(&AuthModifier) | ||
| 55 | )] | ||
| 56 | 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 @@ | |||
| 1 | use axum::{ | ||
| 2 | extract::rejection::JsonRejection, | ||
| 3 | response::{IntoResponse, Response}, | ||
| 4 | }; | ||
| 5 | use axum_extra::typed_header::TypedHeaderRejection; | ||
| 6 | |||
| 7 | use crate::response::{ErrorResponse, FailResponse, SuccessResponse}; | ||
| 8 | |||
| 9 | pub type ApiResult<T> = Result<SuccessResponse<T>, ApiError>; | ||
| 10 | |||
| 11 | pub enum ApiError { | ||
| 12 | // 400 | ||
| 13 | BadJsonBody(String), | ||
| 14 | BadAuthTokenHeader(String), | ||
| 15 | UserAlreadyExists { username: String }, | ||
| 16 | InvalidPassword, | ||
| 17 | NotAuthorized, | ||
| 18 | // 500 | ||
| 19 | Database(String), | ||
| 20 | PasswordHash(String), | ||
| 21 | InternalJwt(String), | ||
| 22 | } | ||
| 23 | |||
| 24 | impl From<JsonRejection> for ApiError { | ||
| 25 | fn from(value: JsonRejection) -> Self { | ||
| 26 | Self::BadJsonBody(value.body_text()) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | impl From<TypedHeaderRejection> for ApiError { | ||
| 31 | fn from(value: TypedHeaderRejection) -> Self { | ||
| 32 | Self::BadAuthTokenHeader(value.to_string()) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | impl From<sea_orm::DbErr> for ApiError { | ||
| 37 | fn from(value: sea_orm::DbErr) -> Self { | ||
| 38 | Self::Database(value.to_string()) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | impl From<argon2::password_hash::Error> for ApiError { | ||
| 43 | fn from(value: argon2::password_hash::Error) -> Self { | ||
| 44 | Self::PasswordHash(value.to_string()) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | impl ToString for ApiError { | ||
| 49 | fn to_string(&self) -> String { | ||
| 50 | match self { | ||
| 51 | // 400 | ||
| 52 | ApiError::BadJsonBody(..) => "BadJsonBody", | ||
| 53 | ApiError::BadAuthTokenHeader(..) => "BadAuthTokenHeader", | ||
| 54 | ApiError::UserAlreadyExists { .. } => "UserAlreadyExists", | ||
| 55 | ApiError::InvalidPassword => "InvalidPassword", | ||
| 56 | ApiError::NotAuthorized => "NotAuthorized", | ||
| 57 | // 500 | ||
| 58 | ApiError::Database(..) => "Database", | ||
| 59 | ApiError::PasswordHash(..) => "PasswordHash", | ||
| 60 | ApiError::InternalJwt(..) => "InternalJwt", | ||
| 61 | } | ||
| 62 | .to_string() | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | impl IntoResponse for ApiError { | ||
| 67 | fn into_response(self) -> Response { | ||
| 68 | let kind = self.to_string(); | ||
| 69 | match self { | ||
| 70 | // 400 | ||
| 71 | ApiError::BadJsonBody(msg) => FailResponse(kind, msg).into_response(), | ||
| 72 | ApiError::BadAuthTokenHeader(msg) => FailResponse(kind, msg).into_response(), | ||
| 73 | ApiError::UserAlreadyExists { username } => FailResponse( | ||
| 74 | kind, | ||
| 75 | format!("user with username `{}` already exists", username), | ||
| 76 | ) | ||
| 77 | .into_response(), | ||
| 78 | ApiError::InvalidPassword => { | ||
| 79 | FailResponse(kind, "password is invalid".to_string()).into_response() | ||
| 80 | } | ||
| 81 | ApiError::NotAuthorized => { | ||
| 82 | FailResponse(kind, "user is not authorized".to_string()).into_response() | ||
| 83 | } | ||
| 84 | |||
| 85 | // 500 | ||
| 86 | ApiError::Database(msg) => ErrorResponse(kind, msg).into_response(), | ||
| 87 | ApiError::PasswordHash(msg) => ErrorResponse(kind, msg).into_response(), | ||
| 88 | ApiError::InternalJwt(msg) => ErrorResponse(kind, msg).into_response(), | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
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 @@ | |||
| 1 | pub enum ClientError { | ||
| 2 | BadJsonBody(String), | ||
| 3 | BadAuthTokenHeader(String), | ||
| 4 | UserAlreadyExists { username: String }, | ||
| 5 | InvalidPassword, | ||
| 6 | NotAuthorized, | ||
| 7 | } | ||
| 8 | |||
| 9 | impl ClientError { | ||
| 10 | pub fn kind(&self) -> String { | ||
| 11 | match self { | ||
| 12 | Self::BadJsonBody(..) => "BadJsonBody", | ||
| 13 | Self::BadAuthTokenHeader(..) => "BadAuthTokenHeader", | ||
| 14 | Self::UserAlreadyExists { .. } => "UserAlreadyExists", | ||
| 15 | Self::InvalidPassword => "InvalidPassword", | ||
| 16 | Self::NotAuthorized => "NotAuthorized", | ||
| 17 | } | ||
| 18 | .to_string() | ||
| 19 | } | ||
| 20 | |||
| 21 | pub fn into_message(self) -> String { | ||
| 22 | match self { | ||
| 23 | Self::BadJsonBody(msg) => msg, | ||
| 24 | Self::BadAuthTokenHeader(msg) => msg, | ||
| 25 | Self::UserAlreadyExists { username } => { | ||
| 26 | format!("user with username `{}` already exists", username) | ||
| 27 | } | ||
| 28 | Self::InvalidPassword => "password is invalid".to_string(), | ||
| 29 | |||
| 30 | Self::NotAuthorized => "user is not authorized".to_string(), | ||
| 31 | } | ||
| 32 | } | ||
| 33 | } | ||
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 @@ | |||
| 1 | mod client; | ||
| 2 | mod server; | ||
| 3 | |||
| 4 | pub use client::ClientError; | ||
| 5 | pub use server::ServerError; | ||
| 6 | |||
| 7 | use argon2::password_hash::Error as PasswordHashError; | ||
| 8 | use axum::extract::rejection::JsonRejection; | ||
| 9 | use axum_extra::typed_header::TypedHeaderRejection; | ||
| 10 | use sea_orm::DbErr; | ||
| 11 | |||
| 12 | pub enum ApiError { | ||
| 13 | Client(ClientError), | ||
| 14 | Server(ServerError), | ||
| 15 | } | ||
| 16 | |||
| 17 | impl From<ClientError> for ApiError { | ||
| 18 | fn from(value: ClientError) -> Self { | ||
| 19 | Self::Client(value) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | impl From<ServerError> for ApiError { | ||
| 23 | fn from(value: ServerError) -> Self { | ||
| 24 | Self::Server(value) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | impl From<JsonRejection> for ApiError { | ||
| 29 | fn from(value: JsonRejection) -> Self { | ||
| 30 | Self::Client(ClientError::BadJsonBody(value.body_text())) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | impl From<TypedHeaderRejection> for ApiError { | ||
| 35 | fn from(value: TypedHeaderRejection) -> Self { | ||
| 36 | Self::Client(ClientError::BadAuthTokenHeader(value.to_string())) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | impl From<DbErr> for ApiError { | ||
| 41 | fn from(value: DbErr) -> Self { | ||
| 42 | Self::Server(ServerError::Database(value.to_string())) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | impl From<PasswordHashError> for ApiError { | ||
| 47 | fn from(value: PasswordHashError) -> Self { | ||
| 48 | Self::Server(ServerError::PasswordHash(value.to_string())) | ||
| 49 | } | ||
| 50 | } | ||
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 @@ | |||
| 1 | pub enum ServerError { | ||
| 2 | Database(String), | ||
| 3 | PasswordHash(String), | ||
| 4 | Token(String), | ||
| 5 | } | ||
| 6 | |||
| 7 | impl ServerError { | ||
| 8 | pub fn kind(&self) -> String { | ||
| 9 | match self { | ||
| 10 | Self::Database(..) => "Database", | ||
| 11 | Self::PasswordHash(..) => "PasswordHash", | ||
| 12 | Self::Token(..) => "Token", | ||
| 13 | } | ||
| 14 | .to_string() | ||
| 15 | } | ||
| 16 | |||
| 17 | pub fn into_message(self) -> String { | ||
| 18 | match self { | ||
| 19 | Self::Database(msg) => msg, | ||
| 20 | Self::PasswordHash(msg) => msg, | ||
| 21 | Self::Token(msg) => msg, | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
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; | |||
| 4 | use headers::authorization::{Authorization, Bearer}; | 4 | use headers::authorization::{Authorization, Bearer}; |
| 5 | use sea_orm::EntityTrait; | 5 | use sea_orm::EntityTrait; |
| 6 | 6 | ||
| 7 | use crate::{ApiError, AppState, validate_jwt}; | 7 | use crate::{AppState, ClientError, ErrorResponse, validate_jwt}; |
| 8 | 8 | ||
| 9 | pub struct Auth(pub users::Model); | 9 | pub struct Auth(pub users::Model); |
| 10 | 10 | ||
| 11 | impl FromRequestParts<AppState> for Auth { | 11 | impl FromRequestParts<AppState> for Auth { |
| 12 | type Rejection = ApiError; | 12 | type Rejection = ErrorResponse; |
| 13 | 13 | ||
| 14 | async fn from_request_parts( | 14 | async fn from_request_parts( |
| 15 | parts: &mut Parts, | 15 | parts: &mut Parts, |
| @@ -19,15 +19,15 @@ impl FromRequestParts<AppState> for Auth { | |||
| 19 | TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await?; | 19 | TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await?; |
| 20 | 20 | ||
| 21 | let jwt_claims = validate_jwt(token_header.token(), &state.secret) | 21 | let jwt_claims = validate_jwt(token_header.token(), &state.secret) |
| 22 | .map_err(|_| ApiError::NotAuthorized)?; | 22 | .map_err(|_| ClientError::NotAuthorized)?; |
| 23 | 23 | ||
| 24 | let user = users::Entity::find_by_id(jwt_claims.sub) | 24 | let user = users::Entity::find_by_id(jwt_claims.sub) |
| 25 | .one(&state.db) | 25 | .one(&state.db) |
| 26 | .await? | 26 | .await? |
| 27 | .ok_or(ApiError::NotAuthorized)?; | 27 | .ok_or(ClientError::NotAuthorized)?; |
| 28 | 28 | ||
| 29 | if jwt_claims.iat < user.password_issue_date.and_utc().timestamp() { | 29 | if jwt_claims.iat < user.password_issue_date.and_utc().timestamp() { |
| 30 | return Err(ApiError::NotAuthorized); | 30 | return Err(ClientError::NotAuthorized.into()); |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | Ok(Auth(user)) | 33 | 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 @@ | |||
| 1 | use axum::extract::{FromRequest, Request, rejection::JsonRejection}; | 1 | use axum::extract::{FromRequest, Request, rejection::JsonRejection}; |
| 2 | 2 | ||
| 3 | use crate::error::ApiError; | 3 | use crate::ErrorResponse; |
| 4 | 4 | ||
| 5 | pub struct ApiJson<T>(pub T); | 5 | pub struct ApiJson<T>(pub T); |
| 6 | 6 | ||
| @@ -9,7 +9,7 @@ where | |||
| 9 | axum::Json<T>: FromRequest<S, Rejection = JsonRejection>, | 9 | axum::Json<T>: FromRequest<S, Rejection = JsonRejection>, |
| 10 | S: Send + Sync, | 10 | S: Send + Sync, |
| 11 | { | 11 | { |
| 12 | type Rejection = ApiError; | 12 | type Rejection = ErrorResponse; |
| 13 | 13 | ||
| 14 | #[inline] | 14 | #[inline] |
| 15 | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { | 15 | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { |
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 @@ | |||
| 1 | mod api; | ||
| 1 | mod auth; | 2 | mod auth; |
| 2 | mod error; | 3 | mod error; |
| 3 | mod extract; | 4 | mod extract; |
| @@ -5,9 +6,10 @@ mod response; | |||
| 5 | mod routers; | 6 | mod routers; |
| 6 | mod state; | 7 | mod state; |
| 7 | 8 | ||
| 9 | pub use api::{AppOpenApi, tags}; | ||
| 8 | pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password}; | 10 | pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password}; |
| 9 | pub use error::{ApiError, ApiResult}; | 11 | pub use error::{ApiError, ClientError, ServerError}; |
| 10 | pub use response::{ErrorResponse, FailResponse, SuccessResponse}; | 12 | pub use response::{ApiResult, ErrorResponse, GlobalResponses, SuccessResponse}; |
| 11 | pub use state::AppState; | 13 | pub use state::AppState; |
| 12 | 14 | ||
| 13 | use axum::Router; | 15 | use axum::Router; |
| @@ -16,6 +18,7 @@ use tokio::net::TcpListener; | |||
| 16 | use tower_http::trace::TraceLayer; | 18 | use tower_http::trace::TraceLayer; |
| 17 | use tracing::info; | 19 | use tracing::info; |
| 18 | use tracing_subscriber::EnvFilter; | 20 | use tracing_subscriber::EnvFilter; |
| 21 | use utoipa_scalar::{Scalar, Servable}; | ||
| 19 | 22 | ||
| 20 | fn env(name: &str) -> String { | 23 | fn env(name: &str) -> String { |
| 21 | std::env::var(name).expect(format!("{} must be set", name).as_str()) | 24 | std::env::var(name).expect(format!("{} must be set", name).as_str()) |
| @@ -28,10 +31,12 @@ async fn listener() -> TcpListener { | |||
| 28 | async fn router() -> Router { | 31 | async fn router() -> Router { |
| 29 | let db = Database::connect(env("DATABASE_URL")).await.unwrap(); | 32 | let db = Database::connect(env("DATABASE_URL")).await.unwrap(); |
| 30 | let secret = env("SECRET"); | 33 | let secret = env("SECRET"); |
| 34 | let (router, api) = routers::router().split_for_parts(); | ||
| 31 | 35 | ||
| 32 | routers::router() | 36 | router |
| 33 | .layer(TraceLayer::new_for_http()) | 37 | .layer(TraceLayer::new_for_http()) |
| 34 | .with_state(AppState { db, secret }) | 38 | .with_state(AppState { db, secret }) |
| 39 | .merge(Scalar::with_url("/docs", api)) | ||
| 35 | } | 40 | } |
| 36 | 41 | ||
| 37 | #[tokio::main] | 42 | #[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 @@ | |||
| 1 | use axum::{ | ||
| 2 | http::StatusCode, | ||
| 3 | response::{IntoResponse, Response}, | ||
| 4 | }; | ||
| 5 | use serde::Serialize; | ||
| 6 | use serde_json::json; | ||
| 7 | |||
| 8 | pub struct SuccessResponse<T>(pub T); | ||
| 9 | pub struct FailResponse(pub String, pub String); | ||
| 10 | pub struct ErrorResponse(pub String, pub String); | ||
| 11 | |||
| 12 | impl<T> IntoResponse for SuccessResponse<T> | ||
| 13 | where | ||
| 14 | T: Serialize, | ||
| 15 | { | ||
| 16 | fn into_response(self) -> Response { | ||
| 17 | ( | ||
| 18 | StatusCode::OK, | ||
| 19 | axum::Json(json!({ | ||
| 20 | "status": "success", | ||
| 21 | "data": self.0 | ||
| 22 | })), | ||
| 23 | ) | ||
| 24 | .into_response() | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | impl IntoResponse for FailResponse { | ||
| 29 | fn into_response(self) -> Response { | ||
| 30 | ( | ||
| 31 | StatusCode::BAD_REQUEST, | ||
| 32 | axum::Json(json!({ | ||
| 33 | "status": "fail", | ||
| 34 | "kind": self.0, | ||
| 35 | "message": self.1 | ||
| 36 | })), | ||
| 37 | ) | ||
| 38 | .into_response() | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | impl IntoResponse for ErrorResponse { | ||
| 43 | fn into_response(self) -> Response { | ||
| 44 | ( | ||
| 45 | StatusCode::INTERNAL_SERVER_ERROR, | ||
| 46 | axum::Json(json!({ | ||
| 47 | "status": "error", | ||
| 48 | "kind": self.0, | ||
| 49 | "message": self.1 | ||
| 50 | })), | ||
| 51 | ) | ||
| 52 | .into_response() | ||
| 53 | } | ||
| 54 | } | ||
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 @@ | |||
| 1 | use axum::{ | ||
| 2 | http::StatusCode, | ||
| 3 | response::{IntoResponse, Response}, | ||
| 4 | }; | ||
| 5 | use serde::Serialize; | ||
| 6 | use utoipa::ToSchema; | ||
| 7 | |||
| 8 | use crate::ApiError; | ||
| 9 | |||
| 10 | #[derive(Serialize, ToSchema)] | ||
| 11 | #[schema(examples("fail or error"))] | ||
| 12 | enum ErrorStatus { | ||
| 13 | #[serde(rename = "fail")] | ||
| 14 | Fail, | ||
| 15 | #[serde(rename = "error")] | ||
| 16 | Error, | ||
| 17 | } | ||
| 18 | |||
| 19 | #[derive(Serialize, ToSchema)] | ||
| 20 | pub struct ErrorResponse { | ||
| 21 | status: ErrorStatus, | ||
| 22 | #[schema(examples("SomeErrorKind", "NotAuthorized", "Database"))] | ||
| 23 | kind: String, | ||
| 24 | #[schema(examples("some error text"))] | ||
| 25 | message: String, | ||
| 26 | } | ||
| 27 | |||
| 28 | impl ErrorResponse { | ||
| 29 | pub fn fail(kind: impl Into<String>, message: impl Into<String>) -> Self { | ||
| 30 | Self { | ||
| 31 | status: ErrorStatus::Fail, | ||
| 32 | kind: kind.into(), | ||
| 33 | message: message.into(), | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | pub fn error(kind: impl Into<String>, message: impl Into<String>) -> Self { | ||
| 38 | Self { | ||
| 39 | status: ErrorStatus::Error, | ||
| 40 | kind: kind.into(), | ||
| 41 | message: message.into(), | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | impl IntoResponse for ErrorResponse { | ||
| 47 | fn into_response(self) -> Response { | ||
| 48 | ( | ||
| 49 | match self.status { | ||
| 50 | ErrorStatus::Fail => StatusCode::BAD_REQUEST, | ||
| 51 | ErrorStatus::Error => StatusCode::INTERNAL_SERVER_ERROR, | ||
| 52 | }, | ||
| 53 | axum::Json(self), | ||
| 54 | ) | ||
| 55 | .into_response() | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | impl<T> From<T> for ErrorResponse | ||
| 60 | where | ||
| 61 | T: Into<ApiError>, | ||
| 62 | { | ||
| 63 | fn from(value: T) -> Self { | ||
| 64 | match value.into() { | ||
| 65 | ApiError::Client(e) => Self::fail(e.kind(), e.into_message()), | ||
| 66 | ApiError::Server(e) => Self::fail(e.kind(), e.into_message()), | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
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 @@ | |||
| 1 | mod error; | ||
| 2 | mod success; | ||
| 3 | |||
| 4 | pub use error::ErrorResponse; | ||
| 5 | use serde_json::json; | ||
| 6 | pub use success::SuccessResponse; | ||
| 7 | |||
| 8 | use std::collections::BTreeMap; | ||
| 9 | |||
| 10 | use utoipa::{ | ||
| 11 | IntoResponses, ToSchema, | ||
| 12 | openapi::{ | ||
| 13 | ContentBuilder, RefOr, ResponseBuilder, ResponsesBuilder, example::ExampleBuilder, | ||
| 14 | response::Response, schema::RefBuilder, | ||
| 15 | }, | ||
| 16 | }; | ||
| 17 | |||
| 18 | pub type ApiResult<T> = Result<SuccessResponse<T>, ErrorResponse>; | ||
| 19 | |||
| 20 | pub struct GlobalResponses; | ||
| 21 | |||
| 22 | impl IntoResponses for GlobalResponses { | ||
| 23 | fn responses() -> BTreeMap<String, RefOr<Response>> { | ||
| 24 | ResponsesBuilder::new() | ||
| 25 | .response( | ||
| 26 | "400", | ||
| 27 | ResponseBuilder::new() | ||
| 28 | .content( | ||
| 29 | "application/json", | ||
| 30 | ContentBuilder::new() | ||
| 31 | .schema(Some( | ||
| 32 | RefBuilder::new() | ||
| 33 | .ref_location_from_schema_name(ErrorResponse::name()), | ||
| 34 | )) | ||
| 35 | .examples_from_iter([( | ||
| 36 | "Fail", | ||
| 37 | ExampleBuilder::new().value(Some(json!(ErrorResponse::fail( | ||
| 38 | "SomeFailKind", | ||
| 39 | "some fail message" | ||
| 40 | )))), | ||
| 41 | )]) | ||
| 42 | .build(), | ||
| 43 | ) | ||
| 44 | .description("General response for invalid request"), | ||
| 45 | ) | ||
| 46 | .response( | ||
| 47 | "500", | ||
| 48 | ResponseBuilder::new() | ||
| 49 | .content( | ||
| 50 | "application/json", | ||
| 51 | ContentBuilder::new() | ||
| 52 | .schema(Some( | ||
| 53 | RefBuilder::new() | ||
| 54 | .ref_location_from_schema_name(ErrorResponse::name()), | ||
| 55 | )) | ||
| 56 | .examples_from_iter([( | ||
| 57 | "Error", | ||
| 58 | ExampleBuilder::new().value(Some(json!(ErrorResponse::error( | ||
| 59 | "SomeErrorKind", | ||
| 60 | "some error message" | ||
| 61 | )))), | ||
| 62 | )]) | ||
| 63 | .build(), | ||
| 64 | ) | ||
| 65 | .description("General response when a server error occurs"), | ||
| 66 | ) | ||
| 67 | .build() | ||
| 68 | .into() | ||
| 69 | } | ||
| 70 | } | ||
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 @@ | |||
| 1 | use axum::{ | ||
| 2 | http::StatusCode, | ||
| 3 | response::{IntoResponse, Response}, | ||
| 4 | }; | ||
| 5 | use serde::Serialize; | ||
| 6 | use utoipa::ToSchema; | ||
| 7 | |||
| 8 | #[derive(Serialize, ToSchema)] | ||
| 9 | enum SuccessStatus { | ||
| 10 | #[serde(rename = "success")] | ||
| 11 | Success, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Serialize, ToSchema)] | ||
| 15 | pub struct SuccessResponse<T> { | ||
| 16 | status: SuccessStatus, | ||
| 17 | data: T, | ||
| 18 | } | ||
| 19 | |||
| 20 | impl<T> SuccessResponse<T> { | ||
| 21 | pub fn ok(data: T) -> Self { | ||
| 22 | Self { | ||
| 23 | status: SuccessStatus::Success, | ||
| 24 | data, | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | impl<T: Serialize> IntoResponse for SuccessResponse<T> { | ||
| 30 | fn into_response(self) -> Response { | ||
| 31 | (StatusCode::OK, axum::Json(self)).into_response() | ||
| 32 | } | ||
| 33 | } | ||
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 @@ | |||
| 1 | use axum::{ | 1 | use axum::extract::State; |
| 2 | Router, | ||
| 3 | extract::State, | ||
| 4 | routing::{delete, get, post, put}, | ||
| 5 | }; | ||
| 6 | use chrono::{DateTime, Duration, Utc}; | 2 | use chrono::{DateTime, Duration, Utc}; |
| 7 | use entity::users::{self}; | 3 | use entity::users::{self}; |
| 8 | use sea_orm::{ | 4 | use sea_orm::{ |
| @@ -10,29 +6,104 @@ use sea_orm::{ | |||
| 10 | QueryFilter, | 6 | QueryFilter, |
| 11 | }; | 7 | }; |
| 12 | use serde::{Deserialize, Serialize}; | 8 | use serde::{Deserialize, Serialize}; |
| 9 | use utoipa::ToSchema; | ||
| 10 | use utoipa_axum::{router::OpenApiRouter, routes}; | ||
| 13 | 11 | ||
| 14 | use crate::{ | 12 | use crate::{ |
| 15 | ApiError, ApiResult, AppState, JwtClaims, SuccessResponse, create_jwt, create_password, | 13 | ApiResult, AppState, ClientError, GlobalResponses, JwtClaims, ServerError, SuccessResponse, |
| 14 | create_jwt, create_password, | ||
| 16 | extract::{ApiJson, Auth}, | 15 | extract::{ApiJson, Auth}, |
| 16 | tags::ACCOUNT, | ||
| 17 | validate_password, | 17 | validate_password, |
| 18 | }; | 18 | }; |
| 19 | 19 | ||
| 20 | #[derive(Serialize)] | 20 | #[derive(Serialize, ToSchema)] |
| 21 | #[schema(description = "Account information")] | ||
| 21 | struct Account { | 22 | struct Account { |
| 23 | #[schema(examples(1))] | ||
| 22 | id: i64, | 24 | id: i64, |
| 25 | #[schema(examples("john_doe", "ivanov_ivan"))] | ||
| 23 | username: String, | 26 | username: String, |
| 27 | #[schema(examples("John", "Иван"))] | ||
| 24 | first_name: String, | 28 | first_name: String, |
| 29 | #[schema(examples("Doe", "Иванов"))] | ||
| 25 | last_name: String, | 30 | last_name: String, |
| 26 | } | 31 | } |
| 27 | 32 | ||
| 28 | #[derive(Serialize)] | 33 | #[derive(Serialize, ToSchema)] |
| 34 | #[schema(description = "Authorization token information")] | ||
| 29 | struct Token { | 35 | struct Token { |
| 30 | token: String, | 36 | token: String, |
| 31 | expired_at: DateTime<Utc>, | 37 | expired_at: DateTime<Utc>, |
| 32 | } | 38 | } |
| 33 | 39 | ||
| 40 | #[derive(Deserialize, ToSchema)] | ||
| 41 | #[schema(description = "Account register data")] | ||
| 42 | struct RegisterRequest { | ||
| 43 | #[schema(examples("john_doe", "ivanov_ivan"))] | ||
| 44 | username: String, | ||
| 45 | #[schema(examples("secret-password"))] | ||
| 46 | password: String, | ||
| 47 | #[schema(examples("John", "Иван"))] | ||
| 48 | first_name: String, | ||
| 49 | #[schema(examples("Doe", "Иванов"))] | ||
| 50 | last_name: String, | ||
| 51 | } | ||
| 52 | |||
| 53 | #[derive(Deserialize, ToSchema, Default)] | ||
| 54 | #[serde(rename_all = "UPPERCASE")] | ||
| 55 | #[schema(description = "Account token life time")] | ||
| 56 | enum TokenLifetime { | ||
| 57 | Day = 1, | ||
| 58 | #[default] | ||
| 59 | Week = 7, | ||
| 60 | Month = 31, | ||
| 61 | } | ||
| 62 | |||
| 63 | #[derive(Deserialize, ToSchema)] | ||
| 64 | #[schema(description = "Account login data")] | ||
| 65 | struct LoginRequest { | ||
| 66 | #[schema(examples("john_doe", "ivanov_ivan"))] | ||
| 67 | username: String, | ||
| 68 | #[schema(examples("secret-password"))] | ||
| 69 | password: String, | ||
| 70 | #[serde(default)] | ||
| 71 | #[schema(default = "WEEK")] | ||
| 72 | token_lifetime: TokenLifetime, | ||
| 73 | } | ||
| 74 | |||
| 75 | #[derive(Deserialize, ToSchema)] | ||
| 76 | #[schema(description = "Change account password data")] | ||
| 77 | struct ChangePasswordRequest { | ||
| 78 | #[schema(examples("secret-password"))] | ||
| 79 | old_password: String, | ||
| 80 | #[schema(examples("super-secret-password"))] | ||
| 81 | new_password: String, | ||
| 82 | } | ||
| 83 | |||
| 84 | #[derive(Deserialize, ToSchema)] | ||
| 85 | #[schema(description = "Account delete data")] | ||
| 86 | struct DeleteUserRequest { | ||
| 87 | #[schema(examples("secret-password"))] | ||
| 88 | password: String, | ||
| 89 | } | ||
| 90 | |||
| 91 | #[utoipa::path( | ||
| 92 | get, | ||
| 93 | path = "/me", | ||
| 94 | tag = ACCOUNT, | ||
| 95 | summary = "Get me", | ||
| 96 | responses( | ||
| 97 | ( | ||
| 98 | status = 200, body = SuccessResponse<Account>, | ||
| 99 | description = "Success response with your account data" | ||
| 100 | ), | ||
| 101 | GlobalResponses | ||
| 102 | ), | ||
| 103 | security(("auth" = [])), | ||
| 104 | )] | ||
| 34 | async fn me(Auth(user): Auth) -> ApiResult<Account> { | 105 | async fn me(Auth(user): Auth) -> ApiResult<Account> { |
| 35 | return Ok(SuccessResponse(Account { | 106 | return Ok(SuccessResponse::ok(Account { |
| 36 | id: user.id, | 107 | id: user.id, |
| 37 | username: user.username, | 108 | username: user.username, |
| 38 | first_name: user.first_name, | 109 | first_name: user.first_name, |
| @@ -40,14 +111,20 @@ async fn me(Auth(user): Auth) -> ApiResult<Account> { | |||
| 40 | })); | 111 | })); |
| 41 | } | 112 | } |
| 42 | 113 | ||
| 43 | #[derive(Deserialize)] | 114 | #[utoipa::path( |
| 44 | struct RegisterRequest { | 115 | post, |
| 45 | username: String, | 116 | path = "/register", |
| 46 | password: String, | 117 | tag = ACCOUNT, |
| 47 | first_name: String, | 118 | summary = "Register", |
| 48 | last_name: String, | 119 | responses( |
| 49 | } | 120 | ( |
| 50 | 121 | status = 200, body = SuccessResponse<Account>, | |
| 122 | description = "Success response with created account data" | ||
| 123 | ), | ||
| 124 | GlobalResponses | ||
| 125 | ), | ||
| 126 | request_body = RegisterRequest | ||
| 127 | )] | ||
| 51 | async fn register( | 128 | async fn register( |
| 52 | State(state): State<AppState>, | 129 | State(state): State<AppState>, |
| 53 | ApiJson(req): ApiJson<RegisterRequest>, | 130 | ApiJson(req): ApiJson<RegisterRequest>, |
| @@ -59,9 +136,10 @@ async fn register( | |||
| 59 | .is_some(); | 136 | .is_some(); |
| 60 | 137 | ||
| 61 | if user_exists { | 138 | if user_exists { |
| 62 | return Err(ApiError::UserAlreadyExists { | 139 | return Err(ClientError::UserAlreadyExists { |
| 63 | username: req.username, | 140 | username: req.username, |
| 64 | }); | 141 | } |
| 142 | .into()); | ||
| 65 | } | 143 | } |
| 66 | 144 | ||
| 67 | let user = users::ActiveModel { | 145 | let user = users::ActiveModel { |
| @@ -75,7 +153,7 @@ async fn register( | |||
| 75 | .insert(&state.db) | 153 | .insert(&state.db) |
| 76 | .await?; | 154 | .await?; |
| 77 | 155 | ||
| 78 | Ok(SuccessResponse(Account { | 156 | Ok(SuccessResponse::ok(Account { |
| 79 | id: user.id, | 157 | id: user.id, |
| 80 | username: user.username, | 158 | username: user.username, |
| 81 | first_name: user.first_name, | 159 | first_name: user.first_name, |
| @@ -83,22 +161,20 @@ async fn register( | |||
| 83 | })) | 161 | })) |
| 84 | } | 162 | } |
| 85 | 163 | ||
| 86 | #[derive(Deserialize, Default)] | 164 | #[utoipa::path( |
| 87 | enum TokenLifetime { | 165 | post, |
| 88 | Day = 1, | 166 | path = "/login", |
| 89 | #[default] | 167 | tag = ACCOUNT, |
| 90 | Week = 7, | 168 | summary = "Login", |
| 91 | Month = 31, | 169 | responses( |
| 92 | } | 170 | ( |
| 93 | 171 | status = 200, body = SuccessResponse<Token>, | |
| 94 | #[derive(Deserialize)] | 172 | description = "Success response with auth token data" |
| 95 | struct LoginRequest { | 173 | ), |
| 96 | username: String, | 174 | GlobalResponses |
| 97 | password: String, | 175 | ), |
| 98 | #[serde(default)] | 176 | request_body = LoginRequest |
| 99 | token_lifetime: TokenLifetime, | 177 | )] |
| 100 | } | ||
| 101 | |||
| 102 | async fn login( | 178 | async fn login( |
| 103 | State(state): State<AppState>, | 179 | State(state): State<AppState>, |
| 104 | ApiJson(req): ApiJson<LoginRequest>, | 180 | ApiJson(req): ApiJson<LoginRequest>, |
| @@ -107,10 +183,10 @@ async fn login( | |||
| 107 | .filter(users::Column::Username.eq(&req.username)) | 183 | .filter(users::Column::Username.eq(&req.username)) |
| 108 | .one(&state.db) | 184 | .one(&state.db) |
| 109 | .await? | 185 | .await? |
| 110 | .ok_or(ApiError::InvalidPassword)?; | 186 | .ok_or(ClientError::InvalidPassword)?; |
| 111 | 187 | ||
| 112 | if !validate_password(&req.password, &user.password_hash)? { | 188 | if !validate_password(&req.password, &user.password_hash)? { |
| 113 | return Err(ApiError::InvalidPassword); | 189 | return Err(ClientError::InvalidPassword.into()); |
| 114 | } | 190 | } |
| 115 | 191 | ||
| 116 | let expired_at = Utc::now() + Duration::days(req.token_lifetime as i64); | 192 | let expired_at = Utc::now() + Duration::days(req.token_lifetime as i64); |
| @@ -123,24 +199,33 @@ async fn login( | |||
| 123 | }, | 199 | }, |
| 124 | &state.secret, | 200 | &state.secret, |
| 125 | ) | 201 | ) |
| 126 | .map_err(|e| ApiError::InternalJwt(e.to_string()))?; | 202 | .map_err(|e| ServerError::Token(e.to_string()))?; |
| 127 | 203 | ||
| 128 | Ok(SuccessResponse(Token { token, expired_at })) | 204 | Ok(SuccessResponse::ok(Token { token, expired_at })) |
| 129 | } | ||
| 130 | |||
| 131 | #[derive(Deserialize)] | ||
| 132 | struct ChangePasswordRequest { | ||
| 133 | old_password: String, | ||
| 134 | new_password: String, | ||
| 135 | } | 205 | } |
| 136 | 206 | ||
| 207 | #[utoipa::path( | ||
| 208 | put, | ||
| 209 | path = "/change/password", | ||
| 210 | tag = ACCOUNT, | ||
| 211 | summary = "Change password", | ||
| 212 | request_body = ChangePasswordRequest, | ||
| 213 | responses( | ||
| 214 | ( | ||
| 215 | status = 200, body = SuccessResponse<Account>, | ||
| 216 | description = "Success response with changed account data" | ||
| 217 | ), | ||
| 218 | GlobalResponses | ||
| 219 | ), | ||
| 220 | security(("auth" = [])) | ||
| 221 | )] | ||
| 137 | async fn change_password( | 222 | async fn change_password( |
| 138 | State(state): State<AppState>, | 223 | State(state): State<AppState>, |
| 139 | Auth(user): Auth, | 224 | Auth(user): Auth, |
| 140 | ApiJson(req): ApiJson<ChangePasswordRequest>, | 225 | ApiJson(req): ApiJson<ChangePasswordRequest>, |
| 141 | ) -> ApiResult<Account> { | 226 | ) -> ApiResult<Account> { |
| 142 | if !validate_password(&req.old_password, &user.password_hash)? { | 227 | if !validate_password(&req.old_password, &user.password_hash)? { |
| 143 | return Err(ApiError::InvalidPassword); | 228 | return Err(ClientError::InvalidPassword.into()); |
| 144 | } | 229 | } |
| 145 | 230 | ||
| 146 | let mut active_user = user.into_active_model(); | 231 | let mut active_user = user.into_active_model(); |
| @@ -148,7 +233,7 @@ async fn change_password( | |||
| 148 | active_user.password_issue_date = Set(Utc::now().naive_utc()); | 233 | active_user.password_issue_date = Set(Utc::now().naive_utc()); |
| 149 | 234 | ||
| 150 | let user = active_user.update(&state.db).await?; | 235 | let user = active_user.update(&state.db).await?; |
| 151 | Ok(SuccessResponse(Account { | 236 | Ok(SuccessResponse::ok(Account { |
| 152 | id: user.id, | 237 | id: user.id, |
| 153 | username: user.username, | 238 | username: user.username, |
| 154 | first_name: user.first_name, | 239 | first_name: user.first_name, |
| @@ -156,23 +241,33 @@ async fn change_password( | |||
| 156 | })) | 241 | })) |
| 157 | } | 242 | } |
| 158 | 243 | ||
| 159 | #[derive(Deserialize)] | 244 | #[utoipa::path( |
| 160 | struct DeleteUserRequest { | 245 | delete, |
| 161 | password: String, | 246 | path = "/delete", |
| 162 | } | 247 | tag = ACCOUNT, |
| 163 | 248 | summary = "Delete", | |
| 164 | async fn delete_account( | 249 | request_body = DeleteUserRequest, |
| 250 | responses( | ||
| 251 | ( | ||
| 252 | status = 200, body = SuccessResponse<Account>, | ||
| 253 | description = "Success response with deleted account data" | ||
| 254 | ), | ||
| 255 | GlobalResponses | ||
| 256 | ), | ||
| 257 | security(("auth" = [])) | ||
| 258 | )] | ||
| 259 | async fn delete( | ||
| 165 | State(state): State<AppState>, | 260 | State(state): State<AppState>, |
| 166 | Auth(user): Auth, | 261 | Auth(user): Auth, |
| 167 | ApiJson(req): ApiJson<DeleteUserRequest>, | 262 | ApiJson(req): ApiJson<DeleteUserRequest>, |
| 168 | ) -> ApiResult<Account> { | 263 | ) -> ApiResult<Account> { |
| 169 | if !validate_password(&req.password, &user.password_hash)? { | 264 | if !validate_password(&req.password, &user.password_hash)? { |
| 170 | return Err(ApiError::InvalidPassword); | 265 | return Err(ClientError::InvalidPassword.into()); |
| 171 | } | 266 | } |
| 172 | 267 | ||
| 173 | user.clone().delete(&state.db).await?; | 268 | user.clone().delete(&state.db).await?; |
| 174 | 269 | ||
| 175 | Ok(SuccessResponse(Account { | 270 | Ok(SuccessResponse::ok(Account { |
| 176 | id: user.id, | 271 | id: user.id, |
| 177 | username: user.username, | 272 | username: user.username, |
| 178 | first_name: user.first_name, | 273 | first_name: user.first_name, |
| @@ -180,11 +275,11 @@ async fn delete_account( | |||
| 180 | })) | 275 | })) |
| 181 | } | 276 | } |
| 182 | 277 | ||
| 183 | pub(crate) fn router() -> Router<AppState> { | 278 | pub(crate) fn router() -> OpenApiRouter<AppState> { |
| 184 | Router::new() | 279 | OpenApiRouter::new() |
| 185 | .route("/me", get(me)) | 280 | .routes(routes!(me)) |
| 186 | .route("/register", post(register)) | 281 | .routes(routes!(register)) |
| 187 | .route("/login", post(login)) | 282 | .routes(routes!(login)) |
| 188 | .route("/change_password", put(change_password)) | 283 | .routes(routes!(change_password)) |
| 189 | .route("/delete", delete(delete_account)) | 284 | .routes(routes!(delete)) |
| 190 | } | 285 | } |
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 @@ | |||
| 1 | mod account; | 1 | mod account; |
| 2 | 2 | ||
| 3 | use axum::Router; | 3 | use utoipa::OpenApi; |
| 4 | use utoipa_axum::router::OpenApiRouter; | ||
| 4 | 5 | ||
| 5 | use crate::state::AppState; | 6 | use crate::{AppOpenApi, AppState}; |
| 6 | 7 | ||
| 7 | pub(crate) fn router() -> Router<AppState> { | 8 | pub(crate) fn router() -> OpenApiRouter<AppState> { |
| 8 | Router::new().nest("/account", account::router()) | 9 | OpenApiRouter::with_openapi(AppOpenApi::openapi()).nest("/account", account::router()) |
| 9 | } | 10 | } |
