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/routers/account.rs | |
| parent | 39bf8397949ea2738ac3dfc934fcc3f07a6b0b66 (diff) | |
| download | queue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.tar.gz queue_server-955598dce9aeb5626654c72b0ef94850123fa8ac.zip | |
Add openapi specs and docs
Diffstat (limited to 'src/routers/account.rs')
| -rw-r--r-- | src/routers/account.rs | 221 |
1 files changed, 158 insertions, 63 deletions
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 | } |
