diff --git a/Cargo.lock b/Cargo.lock index 9f37fd3..139c967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ dependencies = [ "garde", "jsonwebtoken", "password-auth", + "regex", "schemars", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f178e01..b1de3e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ futures-util = "0.3.31" garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] } jsonwebtoken = { version = "9.3", default-features = false } password-auth = "1.0.0" +regex = "1.11.1" schemars = { version = "1.0.0-alpha.17", optional = true, features = ["chrono04", "uuid1"] } serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.139" diff --git a/justfile b/justfile index 93ba81b..0be7a8e 100644 --- a/justfile +++ b/justfile @@ -7,6 +7,6 @@ dev: cargo run --bin bankserver openapi: - yq eval-all -n 'load("openapi-def.yaml") *n load("schemas/schemas.json")' > openapi-temp.yaml + yq eval-all -n 'load("openapi-def.yaml") *n load("schemas/schemas.json")' > openapi-temp.yaml redocly bundle openapi-temp.yaml -o openapi.json rm openapi-temp.yaml diff --git a/migrations/000000_baseline.sql b/migrations/000000_baseline.sql index 5cba077..3ec6b97 100644 --- a/migrations/000000_baseline.sql +++ b/migrations/000000_baseline.sql @@ -12,6 +12,7 @@ create table accounts( ); create table transactions( + id uuid primary key, "from" uuid not null references accounts(id), "to" uuid not null references accounts(id), amount bigint not null constraint positive_amount check (amount > 0), diff --git a/openapi-def.yaml b/openapi-def.yaml index 1e11177..73fc337 100644 --- a/openapi-def.yaml +++ b/openapi-def.yaml @@ -73,7 +73,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserInfo' + $ref: '#/components/schemas/User' 404: $ref: '#/components/responses/ResourceNotFound' 401: @@ -94,7 +94,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserInfo' + $ref: '#/components/schemas/User' 401: $ref: '#/components/responses/Unauthorized' default: @@ -155,7 +155,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TransactionHistory' + $ref: '#/components/schemas/PaginatedTransactions' 401: $ref: '#/components/responses/Unauthorized' default: @@ -188,6 +188,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiError' + 422: + $ref: '#/components/responses/InvalidBody' default: $ref: '#/components/responses/Default' /api/accounts: @@ -202,7 +204,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ListAccounts' + $ref: '#/components/schemas/PaginatedAccounts' default: $ref: '#/components/responses/Default' @@ -224,7 +226,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TransactionHistory' + $ref: '#/components/schemas/PaginatedTransactions' 401: $ref: '#/components/responses/Unauthorized' default: @@ -233,6 +235,9 @@ paths: get: operationId: users-list-all summary: List all users + parameters: + - $ref: '#/components/parameters/PaginationOffset' + - $ref: '#/components/parameters/PaginationLimit' tags: - Users responses: @@ -241,7 +246,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ListUsers' + $ref: '#/components/schemas/PaginatedUserList' default: $ref: '#/components/responses/Default' components: @@ -264,8 +269,20 @@ components: in: path required: true schema: - type: string + type: stripage[offset]ng format: uuid + PaginationLimit: + name: limit + in: query + required: true + schema: + type: uint64 + PaginationOffset: + name: offset + in: query + default: 0 + schema: + type: uint64 securitySchemes: bearer: type: http @@ -276,6 +293,12 @@ components: description: Internal Server Error Default: description: Other Errors + InvalidBody: + description: "" + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' Unauthorized: description: Access token is missing or invalid content: @@ -290,7 +313,7 @@ components: invalid_jwt: value: id: auth.jwt.invalid - message: string + message: string ResourceNotFound: description: Resource not found content: diff --git a/src/api/account.rs b/src/api/account.rs index 941753b..5a5ed4f 100644 --- a/src/api/account.rs +++ b/src/api/account.rs @@ -1,20 +1,27 @@ use std::sync::Arc; -use axum::{Router, extract::Path, routing::get}; +use axum::{Router, extract::Path, http::StatusCode, routing::get}; use serde::Serialize; use uuid::Uuid; -use crate::model::AccountInfo; +use crate::{ + api::{ApiError, Pagination}, + model::{Account, AccountInfo, FullTransaction, Transaction}, +}; -use super::{AppState, EState, State, auth::Auth, make_schemas}; +use super::{ + AppState, EState, Error, Json, Query, RequestPagination, State, auth::Auth, make_schemas, + transactions::TransactionQuery, +}; pub(super) fn router() -> Router> { Router::new() - .route("/{id}", get(account_info)) + // .route("/{id}", get(account_info)) .route("/", get(list_accounts)) + .route("/{id}/transactions", get(account_transactions)) } -make_schemas!((); (ListAccounts)); +make_schemas!((); (Pagination)); #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] @@ -23,4 +30,35 @@ pub struct ListAccounts { } pub async fn account_info(EState(state): State, _: Auth, Path(id): Path) {} -pub async fn list_accounts(EState(state): State, _: Auth) {} +pub async fn account_transactions( + EState(state): State, + auth: Auth, + Path(id): Path, + Query(TransactionQuery { + pagination, + direction, + }): Query, +) -> Result>, Error> { + let conn = state.conn().await?; + match Account::owned_by(&conn, id, auth.user_id()).await? { + Some(false) => { + return Err( + ApiError::const_new(StatusCode::FORBIDDEN, "forbidden", "Forbidden").into(), + ); + } + None => return Err(ApiError::NOT_FOUND.into()), + _ => {} + } + let result = Transaction::account_history(&conn, id, direction, pagination).await?; + + Ok(Json(result)) +} +pub async fn list_accounts( + EState(state): State, + _: Auth, + pagination: RequestPagination, +) -> Result>, Error> { + let conn = state.conn().await?; + let result = Account::list_all(&conn, pagination).await?; + Ok(Json(result)) +} diff --git a/src/api/auth.rs b/src/api/auth.rs index cb61d5e..132b687 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use crate::{ api::{ApiError, InnerError}, - model::User, + model::{Name, User}, }; use super::{AppState, EState, Error, Json, State, make_schemas}; @@ -35,8 +35,8 @@ make_schemas!((Credentials); (RegisterSuccess, LoginSuccess)); #[derive(Deserialize, Validate)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct Credentials { - #[garde(length(min = 3, max = 32), alphanumeric, pattern("^[a-z0-9_-]+$"))] - pub name: String, + #[garde(dive)] + pub name: Name, #[garde(length(min = 8, max = 96))] pub password: String, } diff --git a/src/api/docs/rapidoc.html b/src/api/docs/rapidoc.html index 6eda584..ebc0a22 100644 --- a/src/api/docs/rapidoc.html +++ b/src/api/docs/rapidoc.html @@ -7,7 +7,8 @@ - + \ No newline at end of file diff --git a/src/api/docs/swagger.html b/src/api/docs/swagger.html index 2817736..d7a8fba 100644 --- a/src/api/docs/swagger.html +++ b/src/api/docs/swagger.html @@ -21,9 +21,7 @@ dom_id: '#swagger-ui', presets: [ SwaggerUIBundle.presets.apis, - SwaggerUIStandalonePreset ], - layout: "StandaloneLayout", }); }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 3bf886c..062f897 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,13 +2,14 @@ use std::{borrow::Cow, sync::Arc}; use axum::{ Router, - extract::{FromRequest, Request}, - http::StatusCode, + extract::{FromRequest, FromRequestParts, Request}, + http::{StatusCode, request::Parts}, response::IntoResponse, }; use jsonwebtoken::{DecodingKey, EncodingKey}; -use serde::{Serialize, de::DeserializeOwned}; +use schemars::{JsonSchema, Schema}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use tracing_error::SpanTrace; pub use axum::extract::State as EState; @@ -51,6 +52,31 @@ impl IntoResponse for Json { } } +#[derive(Debug, Clone, Copy, Default)] +pub struct Query(T); + +macro_rules! rejection_error { + ($id:expr, $rejection:expr) => {{ + let rejection = $rejection; + $crate::api::ApiError { + status: rejection.status(), + id: Cow::Borrowed($id), + message: Cow::Owned(rejection.body_text()), + } + }}; +} + +impl FromRequestParts for Query { + type Rejection = ApiError<'static>; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + match axum::extract::Query::::try_from_uri(&parts.uri) { + Ok(query) => Ok(Self(query.0)), + Err(error) => Err(rejection_error!("malformed_query", error)), + } + } +} + pub struct Error { trace: Option, inner: InnerError, @@ -62,6 +88,7 @@ pub enum InnerError { Postgres(tokio_postgres::Error), PHCParse(password_auth::ParseError), Plain(ApiError<'static>), + Validation(ValidationErrors), } #[derive(Debug, Clone, Serialize)] @@ -74,7 +101,14 @@ pub struct ApiError<'a> { } impl<'a> ApiError<'a> { - pub fn const_new(status: StatusCode, id: &'static str, message: &'static str) -> Self { + pub const NOT_FOUND: ApiError<'static> = + ApiError::const_new(StatusCode::NOT_FOUND, "not-found", "Not found"); + pub const INTERNAL_SERVER_ERROR: ApiError<'static> = ApiError::const_new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal-server-error", + "Internal Server Error", + ); + pub const fn const_new(status: StatusCode, id: &'static str, message: &'static str) -> Self { Self { status, id: Cow::Borrowed(id), @@ -159,28 +193,111 @@ impl InnerError { InnerError::Pool(_) => true, InnerError::Postgres(_) => true, InnerError::PHCParse(_) => true, + InnerError::Validation(_) => false, InnerError::Plain(err) => matches!(err.status, StatusCode::INTERNAL_SERVER_ERROR), } } } +impl From for InnerError { + fn from(value: garde::Report) -> Self { + Self::Validation(ValidationErrors { + errors: value + .into_inner() + .into_iter() + .map(ValidationError::from) + .collect(), + }) + } +} + +#[derive(Debug)] +pub struct ValidationErrors { + errors: Vec, +} + +#[derive(Debug, Serialize)] +struct ValidationError { + path: String, + message: String, +} + +impl schemars::JsonSchema for ValidationError { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("SingleValidationError") + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> Schema { + schemars::Schema::try_from(serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["path", "message"] + })) + .unwrap() + } +} + +impl From<(garde::Path, garde::Error)> for ValidationError { + fn from((path, error): (garde::Path, garde::Error)) -> Self { + Self { + path: path.to_string(), + message: error.message().to_owned(), + } + } +} + +#[doc(hidden)] +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename = "ValidationError")] +struct _ValidationErrors<'a> { + #[serde(flatten)] + api: ApiError<'static>, + errors: &'a [ValidationError], +} + +impl Serialize for ValidationErrors { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + _ValidationErrors { + api: ApiError::const_new( + StatusCode::UNPROCESSABLE_ENTITY, + "validation-error", + "Validation failed", + ), + errors: &self.errors, + } + .serialize(serializer) + } +} + +impl IntoResponse for ValidationErrors { + fn into_response(self) -> axum::response::Response { + (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(self)).into_response() + } +} + impl IntoResponse for InnerError { fn into_response(self) -> axum::response::Response { match self { InnerError::Pool(_) | InnerError::Postgres(_) | InnerError::PHCParse(_) => { - INTERNAL_SERVER_ERROR.clone().into_response() + ApiError::INTERNAL_SERVER_ERROR.into_response() } InnerError::Plain(api_error) => api_error.into_response(), + InnerError::Validation(report) => report.into_response(), } } } -pub static INTERNAL_SERVER_ERROR: ApiError<'static> = ApiError { - status: StatusCode::INTERNAL_SERVER_ERROR, - id: Cow::Borrowed("internal_server_error"), - message: Cow::Borrowed("Internal Server Error"), -}; - impl IntoResponse for Error { fn into_response(self) -> axum::response::Response { self.inner.into_response() @@ -211,7 +328,7 @@ pub fn router() -> Router> { .nest("/transactions", transactions::router()) } -make_schemas!((ApiError); (), [crate::model::schemas, auth::schemas, user::schemas, transactions::schemas, account::schemas]); +make_schemas!((); (ApiError, _ValidationErrors), [crate::model::schemas, auth::schemas, user::schemas, transactions::schemas, account::schemas]); macro_rules! make_schemas { (($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => { @@ -262,6 +379,92 @@ macro_rules! make_schemas { pub(crate) use make_schemas; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct RequestPagination { + pub limit: u64, + #[serde(default)] + pub offset: u64, +} + +impl FromRequestParts for RequestPagination { + type Rejection = ApiError<'static>; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + Query::::from_request_parts(parts, state) + .await + .map(|query| query.0) + } +} + +impl RequestPagination { + #[inline] + pub const fn limit(&self) -> i64 { + self.limit as i64 + } + #[inline] + pub const fn offset(&self) -> i64 { + self.limit as i64 + } +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename = "Pagination")] +pub struct PaginationMeta { + pub total: u64, + pub limit: u64, + pub offset: u64, +} + +#[derive(Debug, Clone, Serialize)] +// #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Pagination { + pub pagination: PaginationMeta, + pub result: Vec, +} + +impl Pagination { + pub fn new(result: Vec, total: u64, pagination: RequestPagination) -> Self { + Self { + pagination: PaginationMeta { + total, + limit: pagination.limit, + offset: pagination.offset, + }, + result, + } + } +} + +pub trait PaginationType { + const NAME: &str; +} + +impl JsonSchema for Pagination { + fn schema_name() -> Cow<'static, str> { + format!("Paginated{}", T::NAME).into() + } + + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(std::any::type_name::()) + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let result_schema = generator.subschema_for::>(); + let pagination_schema = generator.subschema_for::(); + schemars::Schema::try_from(serde_json::json!({ + "type": "object", + "properties": { + "pagination": pagination_schema, + "result": result_schema + }, + "required": ["pagination", "result"] + })) + .unwrap() + } +} + #[cfg(not(feature = "schemas"))] pub struct Schemas; #[cfg(feature = "schemas")] diff --git a/src/api/transactions.rs b/src/api/transactions.rs index 05602d0..5f0c4ea 100644 --- a/src/api/transactions.rs +++ b/src/api/transactions.rs @@ -1,21 +1,30 @@ -use std::sync::Arc; +use std::{borrow::Cow, cell::RefCell, sync::Arc}; -use axum::{ - Router, - routing::{get, post}, -}; +use axum::{Router, routing::post}; +use deadpool_postgres::GenericClient; +use garde::Validate; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::{Transaction, UserInfo}; +use crate::model::{ + Account, Direction, FullTransaction, Name, Transaction, USER_ACCOUNT_PATTERN, User, +}; -use super::{AppState, EState, Json, State, auth::Auth, make_schemas}; +use super::{ + AppState, EState, Error, Json, Pagination, PaginationType, Query, RequestPagination, State, + auth::Auth, make_schemas, +}; pub(super) fn router() -> Router> { - Router::new().route("/", get(transaction_history).post(make_payment)) + Router::new().route("/", post(make_payment)) } -make_schemas!((Direction, MakePayment); (TransactionHistory)); +make_schemas!((MakePayment); (Pagination)); + +impl PaginationType for FullTransaction { + const NAME: &str = "Transactions"; +} #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] @@ -23,35 +32,349 @@ pub struct TransactionHistory { transactions: Vec, } -#[derive(Deserialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -#[serde(rename_all = "lowercase")] -pub enum Direction { - Received, - Sent, +#[derive(Deserialize, Validate)] +#[serde(untagged, rename = "PaymentTarget")] +#[garde(context(RefCell>> as ctx))] +pub enum BodyPaymentTarget { + Id(#[garde(skip)] Uuid), + Text(#[garde(custom(validate_user_target))] String), } -#[derive(Deserialize)] +impl schemars::JsonSchema for BodyPaymentTarget { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PaymentTarget") + } + + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(std::any::type_name::()) + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut uuid_schema = generator.subschema_for::(); + let mut name_schema = generator.subschema_for::(); + + uuid_schema.insert("description".to_owned(), "Account Id".into()); + name_schema.insert("description".to_owned(), "Username".into()); + schemars::Schema::try_from(serde_json::json!({ + "oneOf": [ + uuid_schema, + name_schema, + { + "type": "string", + "minLength": 7, + "maxLength": 65, + "pattern": USER_ACCOUNT_PATTERN, + "description": "User and Account seperated with ':'" + } + ] + })) + .unwrap() + } +} + +fn validate_user_target( + value: &str, + ctx: &RefCell>>, +) -> Result<(), garde::Error> { + let parsed = parse_target(value)?; + ctx.borrow_mut().push(match parsed { + UserTarget::User(name) => UserTarget::User(name.into_owned().into()), + UserTarget::UserAccount(name, account) => { + UserTarget::UserAccount(name.into_owned().into(), account.into_owned().into()) + } + }); + Ok(()) +} + +fn parse_target(target: &str) -> Result { + let mut target = target.splitn(2, ':'); + let user = target.next().unwrap(); + let Some(account) = target.next() else { + Name::validate_name(user)?; + return Ok(UserTarget::User(Cow::Borrowed(user))); + }; + Name::validate_name(account)?; + Ok(UserTarget::UserAccount( + Cow::Borrowed(user), + Cow::Borrowed(account), + )) +} + +pub enum UserTarget<'a> { + User(Cow<'a, str>), + UserAccount(Cow<'a, str>, Cow<'a, str>), +} + +#[derive(Deserialize, Validate)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[garde(context(RefCell>>))] +#[serde(rename = "MakePayment")] +pub struct BodyMakePayment { + #[garde(dive)] + pub from: BodyPaymentTarget, + #[garde(dive)] + pub to: BodyPaymentTarget, + /// amount in cents + #[garde(range(min = 1))] + pub amount: u64, +} + +#[derive(Deserialize, Validate)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum NameOrUuid { - Id(Uuid), - Name(String), + Id(#[garde(skip)] Uuid), + Name(#[garde(dive)] Name), +} + +#[derive(Validate)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct AccountSelector { + #[garde(dive)] + user: NameOrUuid, + #[garde(dive)] + account: Option, +} + +impl AccountSelector { + async fn account_id( + &self, + client: &impl GenericClient, + ) -> Result, tokio_postgres::Error> { + let user_id = match &self.user { + NameOrUuid::Id(uuid) => *uuid, + NameOrUuid::Name(name) => match User::info_by_name(client, &*name).await? { + Some(info) => info.id, + None => return Ok(None), + }, + }; + let account_id = match self.account.as_ref() { + Some(name) => match Account::get_for_user(client, user_id, &*name).await? { + Some(info) => info.id, + None => return Ok(None), + }, + None => user_id, + }; + Ok(Some(account_id)) + } +} + +#[doc(hidden)] +#[derive(Deserialize, Validate)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum UnvalidatedAccountSelector { + Username(#[garde(dive)] Name), + Object { + #[garde(dive)] + user: NameOrUuid, + #[garde(dive)] + account: Option, + }, +} + +impl<'de> Deserialize<'de> for AccountSelector { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok( + match UnvalidatedAccountSelector::deserialize(deserializer)? { + UnvalidatedAccountSelector::Username(name) => Self { + user: NameOrUuid::Name(name), + account: None, + }, + UnvalidatedAccountSelector::Object { user, account } => Self { user, account }, + }, + ) + } +} + +pub trait ValidateTransform: Sized { + type Src: Validate; + fn schema_name() -> Cow<'static, str>; + fn schema_id() -> Cow<'static, str> { + Cow::Borrowed(std::any::type_name::()) + } + fn validate_transform_into( + src: Self::Src, + ctx: &::Context, + parent: &mut dyn FnMut() -> garde::Path, + report: &mut garde::Report, + ) -> Option; +} + +pub struct UnvalidatedTransform { + src: Dest::Src, +} + +impl<'de, Dest> Deserialize<'de> for UnvalidatedTransform +where + Dest: ValidateTransform, + Dest::Src: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(UnvalidatedTransform { + src: >::deserialize(deserializer)?, + }) + } +} + +impl schemars::JsonSchema for UnvalidatedTransform +where + Dest: ValidateTransform, + Dest::Src: JsonSchema, +{ + fn schema_name() -> Cow<'static, str> { + Dest::schema_name() + } + + fn schema_id() -> Cow<'static, str> { + Dest::schema_id() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + ::json_schema(generator) + } + fn always_inline_schema() -> bool { + ::always_inline_schema() + } +} + +impl UnvalidatedTransform { + pub fn validate_transform_into( + self, + ctx: &::Context, + parent: &mut dyn FnMut() -> garde::Path, + report: &mut garde::Report, + ) -> Option { + Dest::validate_transform_into(self.src, ctx, parent, report) + } + pub fn validate_with( + self, + ctx: &::Context, + ) -> Result { + let mut report = garde::Report::new(); + let result = + Dest::validate_transform_into(self.src, ctx, &mut garde::Path::empty, &mut report); + match report.is_empty() { + true => Ok(result.unwrap()), + false => Err(report), + } + } +} + +impl ValidateTransform for AccountSelector { + type Src = UnvalidatedAccountSelector; + + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("AccountSelector") + } + + fn validate_transform_into( + src: Self::Src, + ctx: &::Context, + mut parent: &mut dyn FnMut() -> garde::Path, + report: &mut garde::Report, + ) -> Option { + let count = report.iter().count(); + match src { + UnvalidatedAccountSelector::Username(name) => { + name.validate_into(ctx, parent, report); + if count != report.iter().count() { + return None; + } + Some(AccountSelector { + user: NameOrUuid::Name(name), + account: None, + }) + } + UnvalidatedAccountSelector::Object { user, account } => { + { + let mut path = garde::util::nested_path!(parent, "user"); + user.validate_into(ctx, &mut path, report); + } + let mut path = garde::util::nested_path!(parent, "account"); + account.validate_into(ctx, &mut path, report); + if count != report.iter().count() { + return None; + } + Some(AccountSelector { user, account }) + } + } + } } #[derive(Deserialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct MakePayment { - pub from: NameOrUuid, - pub to: NameOrUuid, - /// amount in cents - #[garde(min = 1)] - pub amount: u64, + from: NameOrUuid, + to: UnvalidatedTransform, + amount: u64, +} +impl MakePayment { + pub fn validate(self) -> Result<(NameOrUuid, AccountSelector, u64), garde::Report> { + let mut report = garde::Report::new(); + self.from + .validate_into(&(), &mut || garde::Path::new("from"), &mut report); + let to = self + .to + .validate_transform_into(&(), &mut || garde::Path::new("to"), &mut report); + if let Err(error) = garde::rules::range::apply(&self.amount, (Some(1), None)) { + report.append(garde::Path::new("amount"), error); + } + if !report.is_empty() { + return Err(report); + } + Ok((self.from, to.unwrap(), self.amount)) + } } -pub async fn transaction_history(EState(state): State, auth: Auth) { - // Transaction::get_all_for_user(client, id) +#[derive(Deserialize)] +pub struct TransactionQuery { + #[serde(flatten)] + pub pagination: RequestPagination, + pub direction: Option, } -pub async fn make_payment(EState(state): State, auth: Auth, Json(body): Json) { - // Transaction::get_all_for_user(client, id) + +pub async fn transaction_history( + EState(state): State, + auth: Auth, + Query(TransactionQuery { + direction, + pagination, + }): Query, +) -> Result>, Error> { + let client = state.conn().await?; + let result = Transaction::user_history(&client, auth.user_id(), direction, pagination).await?; + Ok(Json(result)) +} +pub async fn make_payment( + EState(state): State, + auth: Auth, + Json(body): Json, +) -> Result, Error> { + let (from, to, amount) = body.validate()?; + let user_id = auth.user_id(); + + let mut client = state.conn().await?; + let mut client = client.transaction().await?; + + let Some(from) = (match from { + NameOrUuid::Id(uuid) => Account::by_id(&client, uuid).await?, + NameOrUuid::Name(name) => Account::get_for_user(&client, user_id, &*name).await?, + }) else { + todo!("from account doesn't exist") + }; + let Some(to) = to.account_id(&client).await? else { + todo!("to account doesn't exist") + }; + if from.balance < amount { + todo!("not enough money") + } + let transaction = Transaction::create(&mut client, from.id, to, amount, None).await?; + Ok(Json(transaction)) } diff --git a/src/api/user.rs b/src/api/user.rs index f3fbf2b..c68b8b3 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -6,17 +6,20 @@ use uuid::Uuid; use crate::{ api::ApiError, - model::{Account, User, UserAccountInfo, UserInfo}, + model::{Account, FullTransaction, Transaction, User, UserAccountInfo}, }; use super::{ - AppState, EState, Error, INTERNAL_SERVER_ERROR, Json, State, auth::Auth, make_schemas, + AppState, EState, Error, Json, Pagination, Query, RequestPagination, State, auth::Auth, + make_schemas, transactions::TransactionQuery, }; pub(super) fn router() -> Router> { Router::new() .route("/{target}", get(user_info)) .route("/@me/balance", get(user_balance)) + .route("/@me/accounts", get(user_accounts)) + .route("/@me/transactions", get(me_transaction_history)) .route("/", get(list_users)) } @@ -37,13 +40,7 @@ impl UserTarget { } } -make_schemas!((); (ListUsers, UserAccounts, UserBalance)); - -#[derive(Serialize)] -#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct ListUsers { - users: Vec, -} +make_schemas!((); (Pagination, UserAccounts, UserBalance)); #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] @@ -61,7 +58,7 @@ pub async fn user_info( EState(state): State, auth: Auth, Path(target): Path, -) -> Result, Error> { +) -> Result, Error> { let user = target.user_id(&auth); let conn = state.conn().await?; let info = User::info(&conn, user).await?; @@ -69,9 +66,9 @@ pub async fn user_info( return Ok(Json(info)); } if matches!(target, UserTarget::Me) { - return Err(INTERNAL_SERVER_ERROR.clone().into()); + return Err(ApiError::INTERNAL_SERVER_ERROR.into()); } - Err(ApiError::const_new(StatusCode::NOT_FOUND, "not.found", "Not found").into()) + Err(ApiError::NOT_FOUND.into()) } pub async fn user_balance(EState(state): State, auth: Auth) -> Result, Error> { let conn = state.conn().await?; @@ -79,8 +76,31 @@ pub async fn user_balance(EState(state): State, auth: Auth) -> Result Result, Error> { +pub async fn list_users( + EState(state): State, + _: Auth, + pagination: RequestPagination, +) -> Result>, Error> { let conn = state.conn().await?; - let users = User::list(&conn).await?; - Ok(Json(ListUsers { users })) + let users = User::list(&conn, pagination).await?; + Ok(Json(users)) +} +pub async fn me_transaction_history( + EState(state): State, + auth: Auth, + Query(TransactionQuery { + direction, + pagination, + }): Query, +) -> Result>, Error> { + let conn = state.conn().await?; + let result = Transaction::user_history(&conn, auth.user_id(), direction, pagination).await?; + Ok(Json(result)) +} + +pub async fn user_accounts(EState(state): State, auth: Auth) -> Result, Error> { + let user = auth.user_id(); + let conn = state.conn().await?; + let result = Account::list_for_user(&conn, user).await?; + Ok(Json(UserAccounts { accounts: result })) } diff --git a/src/model/account.rs b/src/model/account.rs index 88e7ea5..7e425a2 100644 --- a/src/model/account.rs +++ b/src/model/account.rs @@ -4,6 +4,10 @@ use tokio_postgres::Row; use tracing::instrument; use uuid::{NoContext, Timestamp, Uuid}; +use crate::api::{Pagination, PaginationType, RequestPagination}; + +use super::count; + #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct Account { @@ -28,6 +32,20 @@ pub struct UserAccountInfo { pub balance: u64, } +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ReducedAccountInfo { + pub id: Uuid, + pub name: String, +} + +impl PaginationType for UserAccountInfo { + const NAME: &str = "UserAccounts"; +} +impl PaginationType for AccountInfo { + const NAME: &str = "Accounts"; +} + impl From for AccountInfo { fn from(value: Row) -> Self { Self { @@ -64,32 +82,25 @@ impl Account { Ok(id) } - #[instrument(skip(client))] - pub async fn get_password_by_username( - client: &impl GenericClient, - name: &str, - ) -> Result, tokio_postgres::Error> { - let stmt = client - .prepare_cached("select password from users where name = $1") - .await?; - let res = client.query_opt(&stmt, &[&name]).await?; - Ok(res.map(|res| res.get(0))) - } - #[instrument(skip(client))] pub async fn list_all( client: &impl GenericClient, - ) -> Result, tokio_postgres::Error> { + pagination: RequestPagination, + ) -> Result, tokio_postgres::Error> { let stmt = client - .prepare_cached("select id,\"user\",name from accounts") + .prepare_cached("select id,\"user\",name from accounts limit $1 offset $2") + .await?; + let stmt_count = client + .prepare_cached("select count(*) from accounts limit $1 offset $2") .await?; let users = client - .query(&stmt, &[]) + .query(&stmt, &[&pagination.limit(), &pagination.offset()]) .await? .into_iter() .map(AccountInfo::from) .collect(); - Ok(users) + let total = count(client, &stmt_count, &[]).await?; + Ok(Pagination::new(users, total, pagination)) } #[instrument(skip(client))] @@ -108,4 +119,43 @@ impl Account { .collect(); Ok(users) } + + pub async fn by_id( + client: &impl GenericClient, + id: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,name,balance from accounts where id =$1") + .await?; + let res = client.query_opt(&stmt, &[&id]).await?; + Ok(res.map(UserAccountInfo::from)) + } + + pub async fn owned_by( + client: &impl GenericClient, + account: Uuid, + user: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select user from accounts where id = $1") + .await?; + let Some(res) = client.query_opt(&stmt, &[&account]).await? else { + return Ok(None); + }; + Ok(Some(res.get::<_, Uuid>(0) == user)) + } + + pub async fn get_for_user( + client: &impl GenericClient, + user: Uuid, + name: &str, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached( + "select id,name,balance from accounts where \"user\" = $1 and name = $2", + ) + .await?; + let res = client.query_opt(&stmt, &[&user, &name]).await?; + Ok(res.map(UserAccountInfo::from)) + } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 7fcea5e..a46f62d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,11 +1,61 @@ +use std::{ops::Deref, sync::LazyLock}; + +use deadpool_postgres::GenericClient; +use garde::Validate; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use tokio_postgres::{ToStatement, types::ToSql}; +use uuid::Uuid; + mod account; mod transaction; mod user; pub use account::{Account, AccountInfo, UserAccountInfo}; -pub use transaction::Transaction; -pub use user::{User, UserInfo}; +pub use transaction::{Direction, FullTransaction, Transaction}; +pub use user::User; use crate::api::make_schemas; -make_schemas!((); (UserInfo, Account, AccountInfo, UserAccountInfo, Transaction)); +static NAME_PATTERN_RE: LazyLock = LazyLock::new(|| Regex::new(NAME_PATTERN).unwrap()); + +pub const NAME_PATTERN: &str = "^[a-z0-9_-]+$"; +pub const USER_ACCOUNT_PATTERN: &str = "([a-z0-9_-]+):([a-z0-9_-]+)"; + +#[derive(Debug, Clone, PartialEq, Eq, Validate, Serialize, Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[garde(transparent)] +pub struct Name(#[garde(length(min = 3, max = 32), pattern(NAME_PATTERN_RE))] pub String); + +impl Deref for Name { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} +impl Name { + pub fn into_inner(self) -> String { + self.0 + } + + pub fn validate_name(name: &str) -> Result<(), garde::Error> { + garde::rules::pattern::apply(&name, (&NAME_PATTERN_RE,)) + } +} + +#[derive(Debug)] +pub struct IdWithName { + pub id: Uuid, + pub name: Name, +} + +async fn count( + client: &impl GenericClient, + statement: &T, + params: &[&(dyn ToSql + Sync)], +) -> Result { + Ok(client.query_one(statement, params).await?.get::<_, i64>(0) as u64) +} + +make_schemas!((Direction); (User, Account, AccountInfo, UserAccountInfo, Transaction, FullTransaction)); diff --git a/src/model/transaction.rs b/src/model/transaction.rs index ba424d1..fa21e01 100644 --- a/src/model/transaction.rs +++ b/src/model/transaction.rs @@ -1,9 +1,37 @@ use chrono::{DateTime, Utc}; use deadpool_postgres::GenericClient; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tokio_postgres::Row; use uuid::Uuid; +use crate::api::{Pagination, RequestPagination}; + +use super::{User, account::ReducedAccountInfo}; + +#[derive(Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Received, + Sent, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Participant { + user: User, + account: ReducedAccountInfo, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct FullTransaction { + from: Participant, + to: Participant, + amount: u64, + message: Option, +} + #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct Transaction { @@ -118,4 +146,21 @@ impl Transaction { ) -> Result, tokio_postgres::Error> { todo!() } + + pub async fn account_history( + client: &impl GenericClient, + id: Uuid, + direction: Option, + pagination: RequestPagination, + ) -> Result, tokio_postgres::Error> { + todo!() + } + pub async fn user_history( + client: &impl GenericClient, + id: Uuid, + direction: Option, + pagination: RequestPagination, + ) -> Result, tokio_postgres::Error> { + todo!() + } } diff --git a/src/model/user.rs b/src/model/user.rs index 678a4e0..d4bacf3 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -5,24 +5,18 @@ use tokio_postgres::{Row, error::SqlState}; use tracing::instrument; use uuid::{NoContext, Timestamp, Uuid}; -use crate::api::{ApiError, Error}; +use crate::api::{ApiError, Error, Pagination, PaginationType, RequestPagination}; -use super::Account; - -pub struct User { - pub id: Uuid, - pub name: String, - pub password: String, -} +use super::{Account, count}; #[derive(Serialize)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] -pub struct UserInfo { +pub struct User { pub id: Uuid, pub name: String, } -impl From for UserInfo { +impl From for User { fn from(value: Row) -> Self { Self { id: value.get("id"), @@ -45,6 +39,10 @@ fn unique_violation(error: tokio_postgres::Error, api_error: fn() -> ApiError<'s return error.into(); } +impl PaginationType for User { + const NAME: &str = "UserList"; +} + impl User { #[instrument(skip(client, password))] pub async fn create( @@ -71,7 +69,7 @@ impl User { pub async fn get_password_and_info_by_username( client: &impl GenericClient, name: &str, - ) -> Result, tokio_postgres::Error> { + ) -> Result, tokio_postgres::Error> { let stmt = client .prepare_cached("select id,name,password from users where name = $1") .await?; @@ -81,28 +79,53 @@ impl User { (password, res.into()) })) } + #[instrument(skip(client))] + pub async fn info_by_name( + client: &impl GenericClient, + name: &str, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,name from users where name = $1") + .await?; + let res = client.query_opt(&stmt, &[&name]).await?; + Ok(res.map(User::from)) + } #[instrument(skip(client))] - pub async fn list(client: &impl GenericClient) -> Result, tokio_postgres::Error> { - let stmt = client.prepare_cached("select id,name from users").await?; + pub async fn list( + client: &impl GenericClient, + pagination: RequestPagination, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,name from users limit $1 offset $2") + .await?; + let stmt_count = client + .prepare_cached("select count(*) from users limit $1 offset $2") + .await?; let users = client - .query(&stmt, &[]) + .query(&stmt, &[&pagination.limit(), &pagination.offset()]) .await? .into_iter() - .map(UserInfo::from) + .map(User::from) .collect(); - Ok(users) + let count = count( + client, + &stmt_count, + &[&pagination.limit(), &pagination.offset()], + ) + .await?; + Ok(Pagination::new(users, count, pagination)) } #[instrument(skip(client))] pub async fn info( client: &impl GenericClient, id: Uuid, - ) -> Result, tokio_postgres::Error> { + ) -> Result, tokio_postgres::Error> { let stmt = client .prepare_cached("select id,name from users where id = $1") .await?; - let info = client.query_opt(&stmt, &[]).await?.map(UserInfo::from); + let info = client.query_opt(&stmt, &[]).await?.map(User::from); Ok(info) } }