diff --git a/Cargo.lock b/Cargo.lock index 8c6dbd6..fd6f289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1911,6 +1911,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2201,6 +2210,7 @@ dependencies = [ "axum-extra", "chrono", "dotenv", + "inventory", "jsonwebtoken 10.2.0", "merak-core", "merak-macros", diff --git a/crates/merak/Cargo.toml b/crates/merak/Cargo.toml index 21b2ee8..131a0a4 100644 --- a/crates/merak/Cargo.toml +++ b/crates/merak/Cargo.toml @@ -11,6 +11,7 @@ axum = "0.8.8" axum-extra = { version = "0.12.5", features = ["typed-header"] } chrono = { version = "0.4", features = ["serde"] } dotenv = "0.15.0" +inventory = "0.3.21" jsonwebtoken = { version = "10.2.0", features = ["aws_lc_rs"] } merak-core = { path = "../core", version = "0.1.0-alpha.0" } merak-macros = { path = "../macros", version = "0.1.0-alpha.0" } diff --git a/crates/merak/src/common/code.rs b/crates/merak/src/common/code.rs index 2b96440..80739d3 100644 --- a/crates/merak/src/common/code.rs +++ b/crates/merak/src/common/code.rs @@ -1,68 +1,194 @@ -pub const CODE_OK: i32 = 0; - -pub mod category { - pub const SUCCESS: i32 = 0; - pub const BUSINESS_ERROR: i32 = 1; - pub const PROCESSING: i32 = 2; - pub const PARTIAL_SUCCESS: i32 = 3; - pub const UNKNOWN_ERROR: i32 = 9; +use utoipa::openapi::{KnownFormat, ObjectBuilder, RefOr, Schema, SchemaFormat, schema::Type}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, utoipa::ToSchema)] +#[serde(transparent)] +pub struct BusinessCode(pub i32); + +pub const CODE_OK: BusinessCode = BusinessCode(0); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i32)] +pub enum Category { + Success = 0, + BusinessError = 1, + Processing = 2, + PartialSuccess = 3, + UnknownError = 9, } -pub mod module { - pub const AUTH: i32 = 1; - pub const USER: i32 = 2; - pub const ORG: i32 = 3; - pub const PROJECT: i32 = 4; - pub const SPACE: i32 = 5; - pub const WORKFLOW: i32 = 6; - pub const NODE: i32 = 7; - pub const SUBTASK: i32 = 8; - pub const LINK: i32 = 9; - pub const DOC: i32 = 10; - pub const COMMENT: i32 = 11; - pub const NOTIFICATION: i32 = 12; - pub const COMMON: i32 = 99; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i32)] +pub enum Module { + Auth = 1, + User = 2, + Org = 3, + Project = 4, + Space = 5, + Workflow = 6, + Node = 7, + Subtask = 8, + Link = 9, + Doc = 10, + Comment = 11, + Notification = 12, + Common = 99, } /// Build a business code using the CMMRR scheme. /// C: category, MM: module, RR: reason. -pub const fn make_code(category: i32, module: i32, reason: i32) -> i32 { - (category * 10000) + (module * 100) + reason +pub const fn make_code(category: Category, module: Module, reason: i32) -> BusinessCode { + BusinessCode((category as i32 * 10000) + (module as i32 * 100) + reason) } -/// Common module error codes -pub mod common { - use super::*; +macro_rules! define_codes { + ($enum_name:ident, $cat:expr, $mod_:expr, { + $($(#[doc = $desc:literal])* $variant:ident = $reason:expr),* $(,)? + }) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[repr(i32)] + pub enum $enum_name { + $($variant = $crate::common::code::make_code($cat, $mod_, $reason).0),* + } + + impl From<$enum_name> for $crate::common::code::BusinessCode { + fn from(v: $enum_name) -> Self { + $crate::common::code::BusinessCode(v as i32) + } + } + + impl $enum_name { + pub fn schema_items() -> Vec> { + vec![$( + utoipa::openapi::ObjectBuilder::new() + .title(Some(concat!($($desc),*).trim())) + .schema_type(utoipa::openapi::schema::Type::Integer) + .format(Some(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Int32, + ))) + .enum_values(Some([$crate::common::code::make_code($cat, $mod_, $reason).0])) + .description(Some(concat!($($desc),*).trim())) + .into(), + )*] + } + + pub fn code_entries() -> Vec<(i32, &'static str)> { + vec![$( + ($crate::common::code::make_code($cat, $mod_, $reason).0, concat!($($desc),*).trim()), + )*] + } + } + + impl utoipa::PartialSchema for $enum_name { + fn schema() -> utoipa::openapi::RefOr { + let values = vec![$($crate::common::code::make_code($cat, $mod_, $reason).0),*]; + let descs: Vec<&str> = vec![$(concat!($($desc),*).trim()),*]; + let description = values.iter().zip(descs.iter()) + .map(|(v, d)| format!("- `{}` — {}", v, d)) + .collect::>() + .join("\n"); + + utoipa::openapi::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::Integer) + .format(Some(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Int32, + ))) + .enum_values(Some(values)) + .description(Some(description)) + .into() + } + } + + impl utoipa::ToSchema for $enum_name { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(stringify!($enum_name)) + } + } + inventory::submit!($crate::common::code::CodeSchemaEntry { + name: <$enum_name as utoipa::ToSchema>::name, + schema: <$enum_name as utoipa::PartialSchema>::schema, + }); + }; +} + +define_codes!(CommonCode, Category::BusinessError, Module::Common, { /// Resource not found - pub const NOT_FOUND: i32 = make_code(category::BUSINESS_ERROR, module::COMMON, 1); + NotFound = 1, +}); + +pub struct SuccessCode; + +impl utoipa::PartialSchema for SuccessCode { + fn schema() -> RefOr { + ObjectBuilder::new() + .schema_type(Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) + .enum_values(Some([0])) + .description(Some("Success")) + .into() + } } -/// Authentication module error codes -pub mod auth { - use super::*; +impl utoipa::ToSchema for SuccessCode { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("SuccessCode") + } +} - /// Invalid credentials (wrong username/email or password) - pub const INVALID_CREDENTIALS: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 1); +macro_rules! combine_codes { + ($name:ident, [$($code_type:ty),+ $(,)?]) => { + pub struct $name; - /// User already exists (username or email conflict) - pub const USER_EXISTS: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 2); + impl utoipa::PartialSchema for $name { + fn schema() -> utoipa::openapi::RefOr { + let mut entries = Vec::new(); + $(entries.extend(<$code_type>::code_entries());)+ - /// Password does not meet strength requirements - pub const WEAK_PASSWORD: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 3); + let values: Vec = entries.iter().map(|(v, _)| *v).collect(); + let description = entries.iter() + .map(|(v, d)| format!("- `{}` — {}", v, d)) + .collect::>() + .join("\n"); - /// Token has expired - pub const TOKEN_EXPIRED: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 4); + utoipa::openapi::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::Integer) + .format(Some(utoipa::openapi::SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::Int32, + ))) + .enum_values(Some(values)) + .description(Some(description)) + .into() + } + } - /// Token is invalid or malformed - pub const TOKEN_INVALID: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 5); + impl utoipa::ToSchema for $name { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(stringify!($name)) + } + } - /// Session is invalid or has been revoked - pub const SESSION_INVALID: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 6); + inventory::submit!($crate::common::code::CodeSchemaEntry { + name: <$name as utoipa::ToSchema>::name, + schema: <$name as utoipa::PartialSchema>::schema, + }); + }; +} + +pub struct CodeSchemaEntry { + pub name: fn() -> std::borrow::Cow<'static, str>, + pub schema: fn() -> RefOr, +} - /// User not found - pub const USER_NOT_FOUND: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 7); +inventory::collect!(CodeSchemaEntry); - /// Unauthorized (missing or invalid authorization header) - pub const UNAUTHORIZED: i32 = make_code(category::BUSINESS_ERROR, module::AUTH, 8); +pub fn register_all_codes(api: &mut utoipa::openapi::OpenApi) { + let components = api.components.get_or_insert_with(Default::default); + for entry in inventory::iter:: { + components + .schemas + .insert((entry.name)().to_string(), (entry.schema)()); + } } + +pub(crate) use combine_codes; +pub(crate) use define_codes; diff --git a/crates/merak/src/common/response.rs b/crates/merak/src/common/response.rs index 8388de0..f51e305 100644 --- a/crates/merak/src/common/response.rs +++ b/crates/merak/src/common/response.rs @@ -1,54 +1,60 @@ +use std::marker::PhantomData; + use chrono::Utc; use serde::Serialize; -use utoipa::{ToResponse, ToSchema}; +use utoipa::ToSchema; +use utoipa::openapi::Ref; +use utoipa::openapi::{RefOr, Schema}; +use crate::common::code::BusinessCode; pub use crate::common::code::CODE_OK; #[derive(Debug, Serialize, ToSchema, Default)] pub struct EmptyData {} -#[derive(Debug, Serialize, ToSchema, ToResponse)] -pub struct ErrorResponse { - /// Business error code (CMMRR format). - pub code: i32, - /// Error message. +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + #[schema(schema_with = compose_schame::)] + pub code: BusinessCode, pub message: String, - /// Server timestamp in milliseconds. pub timestamp: i64, + #[serde(skip)] + _codes: PhantomData, } impl ErrorResponse { - pub fn new(code: i32, message: impl Into) -> Self { + pub fn new(code: impl Into, message: impl Into) -> Self { Self { - code, + code: code.into(), message: message.into(), timestamp: Utc::now().timestamp_millis(), + _codes: PhantomData, } } } +fn compose_schame() -> RefOr { + RefOr::Ref(Ref::from_schema_name(T::name().as_ref())) +} + #[derive(Debug, Serialize, ToSchema)] -#[schema(bound = "T: ToSchema")] -pub struct ApiResponse { - /// Business result code for 2xx responses (CMMRR or 0). - pub code: i32, - /// Message describing the result. +pub struct ApiResponse { + pub code: BusinessCode, pub message: String, - /// Server timestamp in milliseconds. pub timestamp: i64, - /// Response payload. + #[schema(schema_with = compose_schame::)] pub data: T, } -impl ApiResponse { +impl ApiResponse { pub fn ok(data: T) -> Self { Self::new(CODE_OK, "OK", data) } - pub fn new(code: i32, message: impl Into, data: T) -> Self { + pub fn new(code: impl Into, message: impl Into, data: T) -> Self { Self { timestamp: Utc::now().timestamp_millis(), - code, + code: code.into(), message: message.into(), data, } @@ -56,7 +62,7 @@ impl ApiResponse { } impl ApiResponse { - pub fn error(code: i32, message: impl Into) -> Self { + pub fn error(code: impl Into, message: impl Into) -> Self { Self::new(code, message, EmptyData::default()) } } diff --git a/crates/merak/src/lib.rs b/crates/merak/src/lib.rs index e766e4a..8f08ed9 100644 --- a/crates/merak/src/lib.rs +++ b/crates/merak/src/lib.rs @@ -2,3 +2,10 @@ pub mod common; pub mod models; pub mod routes; pub mod services; + +use common::code::combine_codes; + +combine_codes!( + BusinessCodes, + [common::code::CommonCode, services::code::AuthCode,] +); diff --git a/crates/merak/src/main.rs b/crates/merak/src/main.rs index 82c5222..0ae1b33 100644 --- a/crates/merak/src/main.rs +++ b/crates/merak/src/main.rs @@ -9,7 +9,8 @@ use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_redoc::{Redoc, Servable}; -use merak::common::code; +use merak::BusinessCodes; +use merak::common::code::{CommonCode, register_all_codes}; use merak::common::response::{ApiResponse, ErrorResponse}; use merak::routes::auth; use merak::services::auth::AuthService; @@ -30,14 +31,15 @@ async fn hello() -> axum::Json> { async fn not_found() -> (StatusCode, axum::Json) { ( - StatusCode::OK, - axum::Json(ErrorResponse::new(code::common::NOT_FOUND, "Not Found")), + StatusCode::NOT_FOUND, + axum::Json(ErrorResponse::new(CommonCode::NotFound, "Not Found")), ) } #[derive(OpenApi)] #[openapi( paths(hello), + components(schemas(BusinessCodes)), tags( (name = "Authentication", description = "Authentication endpoints"), ), @@ -79,13 +81,15 @@ async fn main() -> anyhow::Result<()> { }; // Build openapi + base router - let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + let (router, mut api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) .routes(routes!(hello)) .with_state(state) .nest("/auth", auth::routes().with_state(auth_state)) .fallback(not_found) .split_for_parts(); + register_all_codes(&mut api); + // Redoc UI let router = router .merge(Redoc::with_url("/redoc", api.clone())) diff --git a/crates/merak/src/routes/auth.rs b/crates/merak/src/routes/auth.rs index 8e2927a..eb595d2 100644 --- a/crates/merak/src/routes/auth.rs +++ b/crates/merak/src/routes/auth.rs @@ -14,8 +14,11 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use merak_core::SurrealClient; -use crate::common::code; -use crate::common::response::{ApiResponse, CODE_OK, EmptyData, ErrorResponse}; +use crate::common::response::{ApiResponse, EmptyData, ErrorResponse}; +use crate::services::code::{ + AuthBearerCode, AuthCode, AuthCredentialCode, AuthTokenCode, AuthUserExistsCode, + AuthUserNotFoundCode, AuthWeakPasswordCode, +}; use crate::services::{auth::AuthService, jwt::TokenPair}; /// Authentication route state @@ -41,8 +44,8 @@ impl FromRequestParts for BearerToken { .await .map_err(|e| { ( - StatusCode::OK, - Json(ErrorResponse::new(code::auth::UNAUTHORIZED, e.to_string())), + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new(AuthCode::Unauthorized, e.to_string())), ) .into_response() })?; @@ -109,7 +112,7 @@ impl From for UserResponse { } /// Registration response -#[derive(Debug, Serialize, ToSchema, ToResponse)] +#[derive(Debug, Serialize, ToSchema)] pub struct RegisterResponse { /// User information pub user: UserResponse, @@ -142,9 +145,9 @@ pub struct RefreshTokenResponse { request_body = RegisterRequest, responses( (status = 201, description = "Registration successful", body = ApiResponse), - (status = 400, description = "Invalid request parameters", body = ErrorResponse), - (status = 409, description = "Username or email already exists", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) + (status = 400, description = "Weak password", body = inline(ErrorResponse)), + (status = 409, description = "Username or email already exists", body = inline(ErrorResponse)), + (status = 500, description = "Internal server error", body = ErrorResponse), ), tag = "Authentication" )] @@ -166,11 +169,11 @@ pub async fn register( })), ) .into_response(), - Err(e) => { - let message = e.to_string(); - let code = e.code(); - (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response() - } + Err(e) => ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), + ) + .into_response(), } } @@ -183,8 +186,8 @@ pub async fn register( request_body = LoginRequest, responses( (status = 200, description = "Login successful", body = ApiResponse), - (status = 401, description = "Invalid username or password", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) + (status = 401, description = "Invalid credentials", body = inline(ErrorResponse)), + (status = 500, description = "Internal server error", body = ErrorResponse), ), tag = "Authentication" )] @@ -203,11 +206,11 @@ pub async fn login(State(state): State, Json(req): Json })), ) .into_response(), - Err(e) => { - let message = e.to_string(); - let code = e.code(); - (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response() - } + Err(e) => ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), + ) + .into_response(), } } @@ -220,8 +223,8 @@ pub async fn login(State(state): State, Json(req): Json request_body = RefreshTokenRequest, responses( (status = 200, description = "Token refresh successful", body = ApiResponse), - (status = 401, description = "Refresh token invalid or expired", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) + (status = 401, description = "Token invalid or expired", body = inline(ErrorResponse)), + (status = 500, description = "Internal server error", body = ErrorResponse), ), tag = "Authentication" )] @@ -240,11 +243,11 @@ pub async fn refresh_token( Json(ApiResponse::ok(RefreshTokenResponse { tokens })), ) .into_response(), - Err(e) => { - let message = e.to_string(); - let code = e.code(); - (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response() - } + Err(e) => ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), + ) + .into_response(), } } @@ -256,7 +259,7 @@ pub async fn refresh_token( path = "/logout", responses( (status = 200, description = "Logout successful", body = ApiResponse), - (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = inline(ErrorResponse)), (status = 500, description = "Internal server error", body = ErrorResponse) ), security( @@ -269,20 +272,12 @@ pub async fn logout(State(state): State, BearerToken(bearer): BearerT let token = bearer.token(); match auth_service.logout(&state.db, token).await { - Ok(()) => ( - StatusCode::OK, - Json(ApiResponse::new( - CODE_OK, - "Logged out successfully", - EmptyData::default(), - )), + Ok(()) => (StatusCode::OK, Json(ApiResponse::ok(EmptyData::default()))).into_response(), + Err(e) => ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), ) .into_response(), - Err(e) => { - let message = e.to_string(); - let code = e.code(); - (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response() - } } } @@ -294,8 +289,8 @@ pub async fn logout(State(state): State, BearerToken(bearer): BearerT path = "/me", responses( (status = 200, description = "Successfully retrieved user information", body = ApiResponse), - (status = 401, description = "Unauthorized", body = ErrorResponse), - (status = 404, description = "User not found", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = inline(ErrorResponse)), + (status = 404, description = "User not found", body = inline(ErrorResponse)), (status = 500, description = "Internal server error", body = ErrorResponse) ), security( @@ -313,9 +308,11 @@ pub async fn get_me(State(state): State, BearerToken(bearer): BearerT let claims = match auth_service.verify_access_token(&state.db, token).await { Ok(claims) => claims, Err(e) => { - let message = e.to_string(); - let code = e.code(); - return (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response(); + return ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), + ) + .into_response(); } }; @@ -326,11 +323,11 @@ pub async fn get_me(State(state): State, BearerToken(bearer): BearerT Json(ApiResponse::ok(UserResponse::from(user))), ) .into_response(), - Err(e) => { - let message = e.to_string(); - let code = e.code(); - (StatusCode::OK, Json(ErrorResponse::new(code, message))).into_response() - } + Err(e) => ( + e.status_code(), + Json(ErrorResponse::new(e.code(), e.to_string())), + ) + .into_response(), } } diff --git a/crates/merak/src/services/code.rs b/crates/merak/src/services/code.rs new file mode 100644 index 0000000..2cd2a52 --- /dev/null +++ b/crates/merak/src/services/code.rs @@ -0,0 +1,59 @@ +use crate::common::code::{Category, Module, combine_codes, define_codes}; + +// Full enum for runtime use +define_codes!(AuthCode, Category::BusinessError, Module::Auth, { + /// Invalid credentials + InvalidCredentials = 1, + /// User already exists + UserExists = 2, + /// Weak password + WeakPassword = 3, + /// Token expired + TokenExpired = 4, + /// Token invalid + TokenInvalid = 5, + /// Session invalid + SessionInvalid = 6, + /// User not found + UserNotFound = 7, + /// Unauthorized + Unauthorized = 8, +}); + +// --- Schema-only sub-enums for precise per-endpoint OpenAPI annotations --- + +define_codes!(AuthWeakPasswordCode, Category::BusinessError, Module::Auth, { + /// Weak password + WeakPassword = 3, +}); + +define_codes!(AuthUserExistsCode, Category::BusinessError, Module::Auth, { + /// User already exists + UserExists = 2, +}); + +define_codes!(AuthCredentialCode, Category::BusinessError, Module::Auth, { + /// Invalid credentials + InvalidCredentials = 1, +}); + +define_codes!(AuthTokenCode, Category::BusinessError, Module::Auth, { + /// Token expired + TokenExpired = 4, + /// Token invalid + TokenInvalid = 5, + /// Session invalid + SessionInvalid = 6, +}); + +define_codes!(AuthUnauthorizedCode, Category::BusinessError, Module::Auth, { + /// Unauthorized + Unauthorized = 8, +}); + +define_codes!(AuthUserNotFoundCode, Category::BusinessError, Module::Auth, { + /// User not found + UserNotFound = 7, +}); + +combine_codes!(AuthBearerCode, [AuthTokenCode, AuthUnauthorizedCode]); diff --git a/crates/merak/src/services/error.rs b/crates/merak/src/services/error.rs index d85731c..eb0ce9c 100644 --- a/crates/merak/src/services/error.rs +++ b/crates/merak/src/services/error.rs @@ -1,8 +1,10 @@ use std::{error::Error as StdError, fmt}; use anyhow::Error as AnyError; +use axum::http::StatusCode; -use crate::common::code; +use crate::common::code::{BusinessCode, Category, Module, make_code}; +use crate::services::code::AuthCode; #[derive(Debug)] pub enum AuthError { @@ -23,20 +25,33 @@ pub enum AuthError { pub type AuthResult = std::result::Result; impl AuthError { - pub fn code(&self) -> i32 { + pub fn code(&self) -> BusinessCode { match self { - AuthError::WeakPassword => code::auth::WEAK_PASSWORD, - AuthError::UsernameExists | AuthError::EmailExists => code::auth::USER_EXISTS, + AuthError::WeakPassword => AuthCode::WeakPassword.into(), + AuthError::UsernameExists | AuthError::EmailExists => AuthCode::UserExists.into(), AuthError::InvalidCredentials | AuthError::InvalidOldPassword => { - code::auth::INVALID_CREDENTIALS + AuthCode::InvalidCredentials.into() } - AuthError::TokenExpired | AuthError::SessionExpired => code::auth::TOKEN_EXPIRED, - AuthError::TokenInvalid(_) | AuthError::TokenRevoked => code::auth::TOKEN_INVALID, - AuthError::SessionInvalid(_) => code::auth::SESSION_INVALID, - AuthError::UserNotFound => code::auth::USER_NOT_FOUND, - AuthError::Internal(_) => { - code::make_code(code::category::UNKNOWN_ERROR, code::module::AUTH, 99) + AuthError::TokenExpired | AuthError::SessionExpired => AuthCode::TokenExpired.into(), + AuthError::TokenInvalid(_) | AuthError::TokenRevoked => AuthCode::TokenInvalid.into(), + AuthError::SessionInvalid(_) => AuthCode::SessionInvalid.into(), + AuthError::UserNotFound => AuthCode::UserNotFound.into(), + AuthError::Internal(_) => make_code(Category::UnknownError, Module::Auth, 99), + } + } + + pub fn status_code(&self) -> StatusCode { + match self { + AuthError::WeakPassword => StatusCode::BAD_REQUEST, + AuthError::UsernameExists | AuthError::EmailExists => StatusCode::CONFLICT, + AuthError::InvalidCredentials | AuthError::InvalidOldPassword => { + StatusCode::UNAUTHORIZED } + AuthError::TokenExpired | AuthError::SessionExpired => StatusCode::UNAUTHORIZED, + AuthError::TokenInvalid(_) | AuthError::TokenRevoked => StatusCode::UNAUTHORIZED, + AuthError::SessionInvalid(_) => StatusCode::UNAUTHORIZED, + AuthError::UserNotFound => StatusCode::NOT_FOUND, + AuthError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } diff --git a/crates/merak/src/services/mod.rs b/crates/merak/src/services/mod.rs index d9de808..e39c753 100644 --- a/crates/merak/src/services/mod.rs +++ b/crates/merak/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod code; pub mod error; pub mod jwt; pub mod password; diff --git a/package.json b/package.json index f820f7c..1181cf7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "biome check .", "preview": "vite preview", - "generate-client": "openapi-ts --input http://localhost:8080/apidoc/openapi.json --output ./src/client --client @hey-api/client-axios", + "generate-client": "openapi-ts --input http://localhost:8080/apidoc/openapi.json --output ./src/client --client @hey-api/client-axios && biome format --write ./src/client", "prepare": "prek install" }, "keywords": [ diff --git a/src/client/client/utils.gen.ts b/src/client/client/utils.gen.ts index 676b0b7..436bf65 100644 --- a/src/client/client/utils.gen.ts +++ b/src/client/client/utils.gen.ts @@ -80,10 +80,10 @@ const checkForExistence = ( } if ( 'Cookie' in options.headers && - options.headers.Cookie && - typeof options.headers.Cookie === 'string' + options.headers['Cookie'] && + typeof options.headers['Cookie'] === 'string' ) { - return options.headers.Cookie.includes(`${name}=`); + return options.headers['Cookie'].includes(`${name}=`); } return false; }; @@ -116,13 +116,14 @@ export const setAuthParams = async ({ break; case 'cookie': { const value = `${name}=${token}`; - if ('Cookie' in options.headers && options.headers.Cookie) { - options.headers.Cookie = `${options.headers.Cookie}; ${value}`; + if ('Cookie' in options.headers && options.headers['Cookie']) { + options.headers['Cookie'] = `${options.headers['Cookie']}; ${value}`; } else { - options.headers.Cookie = value; + options.headers['Cookie'] = value; } break; } + case 'header': default: options.headers[name] = token; break; diff --git a/src/client/index.ts b/src/client/index.ts index a285e91..c41092a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,7 +3,6 @@ export { getMe, hello, - hello2, login, logout, type Options, @@ -11,16 +10,29 @@ export { register, } from './sdk.gen'; export type { + ApiResponseEmptyData, + ApiResponseHelloResponse, + ApiResponseLoginResponse, + ApiResponseRefreshTokenResponse, + ApiResponseRegisterResponse, + ApiResponseUserResponse, + AuthBearerCode, + AuthCredentialCode, + AuthTokenCode, + AuthUnauthorizedCode, + AuthUserExistsCode, + AuthUserNotFoundCode, + AuthWeakPasswordCode, + BusinessCode, + BusinessCodes, ClientOptions, + EmptyData, ErrorResponse, GetMeData, GetMeError, GetMeErrors, GetMeResponse, GetMeResponses, - Hello2Data, - Hello2Response, - Hello2Responses, HelloData, HelloResponse, HelloResponse2, diff --git a/src/client/sdk.gen.ts b/src/client/sdk.gen.ts index 03d2779..cda5f28 100644 --- a/src/client/sdk.gen.ts +++ b/src/client/sdk.gen.ts @@ -6,8 +6,6 @@ import type { GetMeData, GetMeErrors, GetMeResponses, - Hello2Data, - Hello2Responses, HelloData, HelloResponses, LoginData, @@ -62,7 +60,7 @@ export const login = ( /** * User logout * - * Client should delete stored tokens (server uses stateless JWT, no additional processing needed) + * Invalidate the current session token on the server */ export const logout = ( options?: Options, @@ -141,12 +139,3 @@ export const hello = ( url: '/hello', ...options, }); - -export const hello2 = ( - options?: Options, -) => - (options?.client ?? client).head({ - responseType: 'json', - url: '/hello', - ...options, - }); diff --git a/src/client/types.gen.ts b/src/client/types.gen.ts index ef7d2fb..a4dafac 100644 --- a/src/client/types.gen.ts +++ b/src/client/types.gen.ts @@ -4,14 +4,69 @@ export type ClientOptions = { baseURL: 'http://localhost:8080' | (string & {}); }; -/** - * Error response - */ +export type ApiResponseEmptyData = { + code: BusinessCode; + data: EmptyData; + message: string; + timestamp: number; +}; + +export type ApiResponseHelloResponse = { + code: BusinessCode; + data: HelloResponse; + message: string; + timestamp: number; +}; + +export type ApiResponseLoginResponse = { + code: BusinessCode; + data: LoginResponse; + message: string; + timestamp: number; +}; + +export type ApiResponseRefreshTokenResponse = { + code: BusinessCode; + data: RefreshTokenResponse; + message: string; + timestamp: number; +}; + +export type ApiResponseRegisterResponse = { + code: BusinessCode; + data: RegisterResponse; + message: string; + timestamp: number; +}; + +export type ApiResponseUserResponse = { + code: BusinessCode; + data: UserResponse; + message: string; + timestamp: number; +}; + +export type BusinessCode = number; + +export type BusinessCodes = + | 19901 + | 10101 + | 10102 + | 10103 + | 10104 + | 10105 + | 10106 + | 10107 + | 10108; + +export type EmptyData = { + [key: string]: unknown; +}; + export type ErrorResponse = { - /** - * Error message - */ + code: BusinessCode; message: string; + timestamp: number; }; export type HelloResponse = { @@ -143,9 +198,13 @@ export type LoginData = { export type LoginErrors = { /** - * Invalid username or password + * Invalid credentials */ - 401: ErrorResponse; + 401: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Internal server error */ @@ -158,7 +217,7 @@ export type LoginResponses = { /** * Login successful */ - 200: LoginResponse; + 200: ApiResponseLoginResponse; }; export type LoginResponse2 = LoginResponses[keyof LoginResponses]; @@ -171,6 +230,14 @@ export type LogoutData = { }; export type LogoutErrors = { + /** + * Unauthorized + */ + 401: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Internal server error */ @@ -183,7 +250,7 @@ export type LogoutResponses = { /** * Logout successful */ - 200: ErrorResponse; + 200: ApiResponseEmptyData; }; export type LogoutResponse = LogoutResponses[keyof LogoutResponses]; @@ -199,11 +266,19 @@ export type GetMeErrors = { /** * Unauthorized */ - 401: ErrorResponse; + 401: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * User not found */ - 404: ErrorResponse; + 404: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Internal server error */ @@ -216,7 +291,7 @@ export type GetMeResponses = { /** * Successfully retrieved user information */ - 200: UserResponse; + 200: ApiResponseUserResponse; }; export type GetMeResponse = GetMeResponses[keyof GetMeResponses]; @@ -230,9 +305,13 @@ export type RefreshTokenData = { export type RefreshTokenErrors = { /** - * Refresh token invalid or expired + * Token invalid or expired */ - 401: ErrorResponse; + 401: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Internal server error */ @@ -245,7 +324,7 @@ export type RefreshTokenResponses = { /** * Token refresh successful */ - 200: RefreshTokenResponse; + 200: ApiResponseRefreshTokenResponse; }; export type RefreshTokenResponse2 = @@ -260,13 +339,21 @@ export type RegisterData = { export type RegisterErrors = { /** - * Invalid request parameters + * Weak password */ - 400: ErrorResponse; + 400: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Username or email already exists */ - 409: ErrorResponse; + 409: { + code: BusinessCode; + message: string; + timestamp: number; + }; /** * Internal server error */ @@ -279,7 +366,7 @@ export type RegisterResponses = { /** * Registration successful */ - 201: RegisterResponse; + 201: ApiResponseRegisterResponse; }; export type RegisterResponse2 = RegisterResponses[keyof RegisterResponses]; @@ -295,23 +382,7 @@ export type HelloResponses = { /** * Successful response */ - 200: HelloResponse; + 200: ApiResponseHelloResponse; }; export type HelloResponse2 = HelloResponses[keyof HelloResponses]; - -export type Hello2Data = { - body?: never; - path?: never; - query?: never; - url: '/hello'; -}; - -export type Hello2Responses = { - /** - * Successful response - */ - 200: HelloResponse; -}; - -export type Hello2Response = Hello2Responses[keyof Hello2Responses];