aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorTolmachev Igor <me@igorek.dev>2025-09-14 23:27:25 +0300
committerTolmachev Igor <me@igorek.dev>2025-09-14 23:27:25 +0300
commit955598dce9aeb5626654c72b0ef94850123fa8ac (patch)
tree4fb161c2e67fdc161ebbca5ced271b6e7724dc30 /src
parent39bf8397949ea2738ac3dfc934fcc3f07a6b0b66 (diff)
downloadqueue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.tar.gz
queue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.zip
Add openapi specs and docs
Diffstat (limited to 'src')
-rw-r--r--src/api.rs56
-rw-r--r--src/error.rs91
-rw-r--r--src/error/client.rs33
-rw-r--r--src/error/mod.rs50
-rw-r--r--src/error/server.rs24
-rw-r--r--src/extract/auth.rs10
-rw-r--r--src/extract/json.rs4
-rw-r--r--src/main.rs11
-rw-r--r--src/response.rs54
-rw-r--r--src/response/error.rs69
-rw-r--r--src/response/mod.rs70
-rw-r--r--src/response/success.rs33
-rw-r--r--src/routers/account.rs221
-rw-r--r--src/routers/mod.rs9
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 @@
1use utoipa::{
2 Modify, OpenApi,
3 openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
4};
5
6use crate::ErrorResponse;
7
8pub mod tags {
9 pub const ACCOUNT: &str = "Account";
10}
11
12struct AuthModifier;
13
14impl 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)]
56pub 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 @@
1use axum::{
2 extract::rejection::JsonRejection,
3 response::{IntoResponse, Response},
4};
5use axum_extra::typed_header::TypedHeaderRejection;
6
7use crate::response::{ErrorResponse, FailResponse, SuccessResponse};
8
9pub type ApiResult<T> = Result<SuccessResponse<T>, ApiError>;
10
11pub 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
24impl From<JsonRejection> for ApiError {
25 fn from(value: JsonRejection) -> Self {
26 Self::BadJsonBody(value.body_text())
27 }
28}
29
30impl From<TypedHeaderRejection> for ApiError {
31 fn from(value: TypedHeaderRejection) -> Self {
32 Self::BadAuthTokenHeader(value.to_string())
33 }
34}
35
36impl From<sea_orm::DbErr> for ApiError {
37 fn from(value: sea_orm::DbErr) -> Self {
38 Self::Database(value.to_string())
39 }
40}
41
42impl 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
48impl 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
66impl 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 @@
1pub enum ClientError {
2 BadJsonBody(String),
3 BadAuthTokenHeader(String),
4 UserAlreadyExists { username: String },
5 InvalidPassword,
6 NotAuthorized,
7}
8
9impl 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 @@
1mod client;
2mod server;
3
4pub use client::ClientError;
5pub use server::ServerError;
6
7use argon2::password_hash::Error as PasswordHashError;
8use axum::extract::rejection::JsonRejection;
9use axum_extra::typed_header::TypedHeaderRejection;
10use sea_orm::DbErr;
11
12pub enum ApiError {
13 Client(ClientError),
14 Server(ServerError),
15}
16
17impl From<ClientError> for ApiError {
18 fn from(value: ClientError) -> Self {
19 Self::Client(value)
20 }
21}
22impl From<ServerError> for ApiError {
23 fn from(value: ServerError) -> Self {
24 Self::Server(value)
25 }
26}
27
28impl From<JsonRejection> for ApiError {
29 fn from(value: JsonRejection) -> Self {
30 Self::Client(ClientError::BadJsonBody(value.body_text()))
31 }
32}
33
34impl From<TypedHeaderRejection> for ApiError {
35 fn from(value: TypedHeaderRejection) -> Self {
36 Self::Client(ClientError::BadAuthTokenHeader(value.to_string()))
37 }
38}
39
40impl From<DbErr> for ApiError {
41 fn from(value: DbErr) -> Self {
42 Self::Server(ServerError::Database(value.to_string()))
43 }
44}
45
46impl 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 @@
1pub enum ServerError {
2 Database(String),
3 PasswordHash(String),
4 Token(String),
5}
6
7impl 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;
4use headers::authorization::{Authorization, Bearer}; 4use headers::authorization::{Authorization, Bearer};
5use sea_orm::EntityTrait; 5use sea_orm::EntityTrait;
6 6
7use crate::{ApiError, AppState, validate_jwt}; 7use crate::{AppState, ClientError, ErrorResponse, validate_jwt};
8 8
9pub struct Auth(pub users::Model); 9pub struct Auth(pub users::Model);
10 10
11impl FromRequestParts<AppState> for Auth { 11impl 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 @@
1use axum::extract::{FromRequest, Request, rejection::JsonRejection}; 1use axum::extract::{FromRequest, Request, rejection::JsonRejection};
2 2
3use crate::error::ApiError; 3use crate::ErrorResponse;
4 4
5pub struct ApiJson<T>(pub T); 5pub 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 @@
1mod api;
1mod auth; 2mod auth;
2mod error; 3mod error;
3mod extract; 4mod extract;
@@ -5,9 +6,10 @@ mod response;
5mod routers; 6mod routers;
6mod state; 7mod state;
7 8
9pub use api::{AppOpenApi, tags};
8pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password}; 10pub use auth::{JwtClaims, create_jwt, create_password, validate_jwt, validate_password};
9pub use error::{ApiError, ApiResult}; 11pub use error::{ApiError, ClientError, ServerError};
10pub use response::{ErrorResponse, FailResponse, SuccessResponse}; 12pub use response::{ApiResult, ErrorResponse, GlobalResponses, SuccessResponse};
11pub use state::AppState; 13pub use state::AppState;
12 14
13use axum::Router; 15use axum::Router;
@@ -16,6 +18,7 @@ use tokio::net::TcpListener;
16use tower_http::trace::TraceLayer; 18use tower_http::trace::TraceLayer;
17use tracing::info; 19use tracing::info;
18use tracing_subscriber::EnvFilter; 20use tracing_subscriber::EnvFilter;
21use utoipa_scalar::{Scalar, Servable};
19 22
20fn env(name: &str) -> String { 23fn 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 {
28async fn router() -> Router { 31async 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 @@
1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use serde::Serialize;
6use serde_json::json;
7
8pub struct SuccessResponse<T>(pub T);
9pub struct FailResponse(pub String, pub String);
10pub struct ErrorResponse(pub String, pub String);
11
12impl<T> IntoResponse for SuccessResponse<T>
13where
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
28impl 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
42impl 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 @@
1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use serde::Serialize;
6use utoipa::ToSchema;
7
8use crate::ApiError;
9
10#[derive(Serialize, ToSchema)]
11#[schema(examples("fail or error"))]
12enum ErrorStatus {
13 #[serde(rename = "fail")]
14 Fail,
15 #[serde(rename = "error")]
16 Error,
17}
18
19#[derive(Serialize, ToSchema)]
20pub 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
28impl 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
46impl 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
59impl<T> From<T> for ErrorResponse
60where
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 @@
1mod error;
2mod success;
3
4pub use error::ErrorResponse;
5use serde_json::json;
6pub use success::SuccessResponse;
7
8use std::collections::BTreeMap;
9
10use utoipa::{
11 IntoResponses, ToSchema,
12 openapi::{
13 ContentBuilder, RefOr, ResponseBuilder, ResponsesBuilder, example::ExampleBuilder,
14 response::Response, schema::RefBuilder,
15 },
16};
17
18pub type ApiResult<T> = Result<SuccessResponse<T>, ErrorResponse>;
19
20pub struct GlobalResponses;
21
22impl 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 @@
1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use serde::Serialize;
6use utoipa::ToSchema;
7
8#[derive(Serialize, ToSchema)]
9enum SuccessStatus {
10 #[serde(rename = "success")]
11 Success,
12}
13
14#[derive(Serialize, ToSchema)]
15pub struct SuccessResponse<T> {
16 status: SuccessStatus,
17 data: T,
18}
19
20impl<T> SuccessResponse<T> {
21 pub fn ok(data: T) -> Self {
22 Self {
23 status: SuccessStatus::Success,
24 data,
25 }
26 }
27}
28
29impl<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 @@
1use axum::{ 1use axum::extract::State;
2 Router,
3 extract::State,
4 routing::{delete, get, post, put},
5};
6use chrono::{DateTime, Duration, Utc}; 2use chrono::{DateTime, Duration, Utc};
7use entity::users::{self}; 3use entity::users::{self};
8use sea_orm::{ 4use sea_orm::{
@@ -10,29 +6,104 @@ use sea_orm::{
10 QueryFilter, 6 QueryFilter,
11}; 7};
12use serde::{Deserialize, Serialize}; 8use serde::{Deserialize, Serialize};
9use utoipa::ToSchema;
10use utoipa_axum::{router::OpenApiRouter, routes};
13 11
14use crate::{ 12use 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")]
21struct Account { 22struct 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")]
29struct Token { 35struct 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")]
42struct 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")]
56enum TokenLifetime {
57 Day = 1,
58 #[default]
59 Week = 7,
60 Month = 31,
61}
62
63#[derive(Deserialize, ToSchema)]
64#[schema(description = "Account login data")]
65struct 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")]
77struct 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")]
86struct 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)]
34async fn me(Auth(user): Auth) -> ApiResult<Account> { 105async 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(
44struct 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)]
51async fn register( 128async 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(
87enum 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"
95struct 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
102async fn login( 178async 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)]
132struct 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)]
137async fn change_password( 222async 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(
160struct DeleteUserRequest { 245 delete,
161 password: String, 246 path = "/delete",
162} 247 tag = ACCOUNT,
163 248 summary = "Delete",
164async 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)]
259async 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
183pub(crate) fn router() -> Router<AppState> { 278pub(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 @@
1mod account; 1mod account;
2 2
3use axum::Router; 3use utoipa::OpenApi;
4use utoipa_axum::router::OpenApiRouter;
4 5
5use crate::state::AppState; 6use crate::{AppOpenApi, AppState};
6 7
7pub(crate) fn router() -> Router<AppState> { 8pub(crate) fn router() -> OpenApiRouter<AppState> {
8 Router::new().nest("/account", account::router()) 9 OpenApiRouter::with_openapi(AppOpenApi::openapi()).nest("/account", account::router())
9} 10}