Implement various endpoints

This commit is contained in:
DSeeLP 2025-03-04 08:28:46 +01:00
parent 01629f00b5
commit d1bb61e2a8
16 changed files with 890 additions and 113 deletions

1
Cargo.lock generated
View File

@ -153,6 +153,7 @@ dependencies = [
"garde", "garde",
"jsonwebtoken", "jsonwebtoken",
"password-auth", "password-auth",
"regex",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -22,6 +22,7 @@ futures-util = "0.3.31"
garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] } garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] }
jsonwebtoken = { version = "9.3", default-features = false } jsonwebtoken = { version = "9.3", default-features = false }
password-auth = "1.0.0" password-auth = "1.0.0"
regex = "1.11.1"
schemars = { version = "1.0.0-alpha.17", optional = true, features = ["chrono04", "uuid1"] } schemars = { version = "1.0.0-alpha.17", optional = true, features = ["chrono04", "uuid1"] }
serde = { version = "1.0.218", features = ["derive"] } serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.139" serde_json = "1.0.139"

View File

@ -7,6 +7,6 @@ dev:
cargo run --bin bankserver cargo run --bin bankserver
openapi: 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 redocly bundle openapi-temp.yaml -o openapi.json
rm openapi-temp.yaml rm openapi-temp.yaml

View File

@ -12,6 +12,7 @@ create table accounts(
); );
create table transactions( create table transactions(
id uuid primary key,
"from" uuid not null references accounts(id), "from" uuid not null references accounts(id),
"to" uuid not null references accounts(id), "to" uuid not null references accounts(id),
amount bigint not null constraint positive_amount check (amount > 0), amount bigint not null constraint positive_amount check (amount > 0),

View File

@ -73,7 +73,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserInfo' $ref: '#/components/schemas/User'
404: 404:
$ref: '#/components/responses/ResourceNotFound' $ref: '#/components/responses/ResourceNotFound'
401: 401:
@ -94,7 +94,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserInfo' $ref: '#/components/schemas/User'
401: 401:
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
default: default:
@ -155,7 +155,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TransactionHistory' $ref: '#/components/schemas/PaginatedTransactions'
401: 401:
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
default: default:
@ -188,6 +188,8 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
422:
$ref: '#/components/responses/InvalidBody'
default: default:
$ref: '#/components/responses/Default' $ref: '#/components/responses/Default'
/api/accounts: /api/accounts:
@ -202,7 +204,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ListAccounts' $ref: '#/components/schemas/PaginatedAccounts'
default: default:
$ref: '#/components/responses/Default' $ref: '#/components/responses/Default'
@ -224,7 +226,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TransactionHistory' $ref: '#/components/schemas/PaginatedTransactions'
401: 401:
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
default: default:
@ -233,6 +235,9 @@ paths:
get: get:
operationId: users-list-all operationId: users-list-all
summary: List all users summary: List all users
parameters:
- $ref: '#/components/parameters/PaginationOffset'
- $ref: '#/components/parameters/PaginationLimit'
tags: tags:
- Users - Users
responses: responses:
@ -241,7 +246,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ListUsers' $ref: '#/components/schemas/PaginatedUserList'
default: default:
$ref: '#/components/responses/Default' $ref: '#/components/responses/Default'
components: components:
@ -264,8 +269,20 @@ components:
in: path in: path
required: true required: true
schema: schema:
type: string type: stripage[offset]ng
format: uuid format: uuid
PaginationLimit:
name: limit
in: query
required: true
schema:
type: uint64
PaginationOffset:
name: offset
in: query
default: 0
schema:
type: uint64
securitySchemes: securitySchemes:
bearer: bearer:
type: http type: http
@ -276,6 +293,12 @@ components:
description: Internal Server Error description: Internal Server Error
Default: Default:
description: Other Errors description: Other Errors
InvalidBody:
description: ""
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
Unauthorized: Unauthorized:
description: Access token is missing or invalid description: Access token is missing or invalid
content: content:
@ -290,7 +313,7 @@ components:
invalid_jwt: invalid_jwt:
value: value:
id: auth.jwt.invalid id: auth.jwt.invalid
message: string message: string
ResourceNotFound: ResourceNotFound:
description: Resource not found description: Resource not found
content: content:

View File

@ -1,20 +1,27 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{Router, extract::Path, routing::get}; use axum::{Router, extract::Path, http::StatusCode, routing::get};
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; 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<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/{id}", get(account_info)) // .route("/{id}", get(account_info))
.route("/", get(list_accounts)) .route("/", get(list_accounts))
.route("/{id}/transactions", get(account_transactions))
} }
make_schemas!((); (ListAccounts)); make_schemas!((); (Pagination<AccountInfo>));
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[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<Uuid>) {} pub async fn account_info(EState(state): State, _: Auth, Path(id): Path<Uuid>) {}
pub async fn list_accounts(EState(state): State, _: Auth) {} pub async fn account_transactions(
EState(state): State,
auth: Auth,
Path(id): Path<Uuid>,
Query(TransactionQuery {
pagination,
direction,
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<Json<Pagination<AccountInfo>>, Error> {
let conn = state.conn().await?;
let result = Account::list_all(&conn, pagination).await?;
Ok(Json(result))
}

View File

@ -19,7 +19,7 @@ use uuid::Uuid;
use crate::{ use crate::{
api::{ApiError, InnerError}, api::{ApiError, InnerError},
model::User, model::{Name, User},
}; };
use super::{AppState, EState, Error, Json, State, make_schemas}; use super::{AppState, EState, Error, Json, State, make_schemas};
@ -35,8 +35,8 @@ make_schemas!((Credentials); (RegisterSuccess, LoginSuccess));
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct Credentials { pub struct Credentials {
#[garde(length(min = 3, max = 32), alphanumeric, pattern("^[a-z0-9_-]+$"))] #[garde(dive)]
pub name: String, pub name: Name,
#[garde(length(min = 8, max = 96))] #[garde(length(min = 8, max = 96))]
pub password: String, pub password: String,
} }

View File

@ -7,7 +7,8 @@
</head> </head>
<body> <body>
<rapi-doc spec-url="openapi.json"> </rapi-doc> <rapi-doc spec-url="openapi.json" allow-spec-url-load="false" allow-spec-file-load="false"
show-method-in-nav-bar="as-colored-text" use-path-in-nav-bar="true"></rapi-doc>
</body> </body>
</html> </html>

View File

@ -21,9 +21,7 @@
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
presets: [ presets: [
SwaggerUIBundle.presets.apis, SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
], ],
layout: "StandaloneLayout",
}); });
}; };
</script> </script>

View File

@ -2,13 +2,14 @@ use std::{borrow::Cow, sync::Arc};
use axum::{ use axum::{
Router, Router,
extract::{FromRequest, Request}, extract::{FromRequest, FromRequestParts, Request},
http::StatusCode, http::{StatusCode, request::Parts},
response::IntoResponse, response::IntoResponse,
}; };
use jsonwebtoken::{DecodingKey, EncodingKey}; use jsonwebtoken::{DecodingKey, EncodingKey};
use serde::{Serialize, de::DeserializeOwned}; use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use tracing_error::SpanTrace; use tracing_error::SpanTrace;
pub use axum::extract::State as EState; pub use axum::extract::State as EState;
@ -51,6 +52,31 @@ impl<T: Serialize> IntoResponse for Json<T> {
} }
} }
#[derive(Debug, Clone, Copy, Default)]
pub struct Query<T>(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<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for Query<T> {
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
match axum::extract::Query::<T>::try_from_uri(&parts.uri) {
Ok(query) => Ok(Self(query.0)),
Err(error) => Err(rejection_error!("malformed_query", error)),
}
}
}
pub struct Error { pub struct Error {
trace: Option<SpanTrace>, trace: Option<SpanTrace>,
inner: InnerError, inner: InnerError,
@ -62,6 +88,7 @@ pub enum InnerError {
Postgres(tokio_postgres::Error), Postgres(tokio_postgres::Error),
PHCParse(password_auth::ParseError), PHCParse(password_auth::ParseError),
Plain(ApiError<'static>), Plain(ApiError<'static>),
Validation(ValidationErrors),
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -74,7 +101,14 @@ pub struct ApiError<'a> {
} }
impl<'a> 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 { Self {
status, status,
id: Cow::Borrowed(id), id: Cow::Borrowed(id),
@ -159,28 +193,111 @@ impl InnerError {
InnerError::Pool(_) => true, InnerError::Pool(_) => true,
InnerError::Postgres(_) => true, InnerError::Postgres(_) => true,
InnerError::PHCParse(_) => true, InnerError::PHCParse(_) => true,
InnerError::Validation(_) => false,
InnerError::Plain(err) => matches!(err.status, StatusCode::INTERNAL_SERVER_ERROR), InnerError::Plain(err) => matches!(err.status, StatusCode::INTERNAL_SERVER_ERROR),
} }
} }
} }
impl From<garde::Report> 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<ValidationError>,
}
#[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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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 { impl IntoResponse for InnerError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
InnerError::Pool(_) | InnerError::Postgres(_) | InnerError::PHCParse(_) => { 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::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 { impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
self.inner.into_response() self.inner.into_response()
@ -211,7 +328,7 @@ pub fn router() -> Router<Arc<AppState>> {
.nest("/transactions", transactions::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 { macro_rules! make_schemas {
(($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => { (($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => {
@ -262,6 +379,92 @@ macro_rules! make_schemas {
pub(crate) use 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<S: Send + Sync> FromRequestParts<S> for RequestPagination {
type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Query::<RequestPagination>::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<T> {
pub pagination: PaginationMeta,
pub result: Vec<T>,
}
impl<T> Pagination<T> {
pub fn new(result: Vec<T>, total: u64, pagination: RequestPagination) -> Self {
Self {
pagination: PaginationMeta {
total,
limit: pagination.limit,
offset: pagination.offset,
},
result,
}
}
}
pub trait PaginationType {
const NAME: &str;
}
impl<T: PaginationType + JsonSchema> JsonSchema for Pagination<T> {
fn schema_name() -> Cow<'static, str> {
format!("Paginated{}", T::NAME).into()
}
fn schema_id() -> Cow<'static, str> {
Cow::Borrowed(std::any::type_name::<Self>())
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let result_schema = generator.subschema_for::<Vec<T>>();
let pagination_schema = generator.subschema_for::<PaginationMeta>();
schemars::Schema::try_from(serde_json::json!({
"type": "object",
"properties": {
"pagination": pagination_schema,
"result": result_schema
},
"required": ["pagination", "result"]
}))
.unwrap()
}
}
#[cfg(not(feature = "schemas"))] #[cfg(not(feature = "schemas"))]
pub struct Schemas; pub struct Schemas;
#[cfg(feature = "schemas")] #[cfg(feature = "schemas")]

View File

@ -1,21 +1,30 @@
use std::sync::Arc; use std::{borrow::Cow, cell::RefCell, sync::Arc};
use axum::{ use axum::{Router, routing::post};
Router, use deadpool_postgres::GenericClient;
routing::{get, post}, use garde::Validate;
}; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; 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<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new().route("/", get(transaction_history).post(make_payment)) Router::new().route("/", post(make_payment))
} }
make_schemas!((Direction, MakePayment); (TransactionHistory)); make_schemas!((MakePayment); (Pagination<FullTransaction>));
impl PaginationType for FullTransaction {
const NAME: &str = "Transactions";
}
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
@ -23,35 +32,349 @@ pub struct TransactionHistory {
transactions: Vec<Transaction>, transactions: Vec<Transaction>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Validate)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[serde(untagged, rename = "PaymentTarget")]
#[serde(rename_all = "lowercase")] #[garde(context(RefCell<Vec<UserTarget<'static>>> as ctx))]
pub enum Direction { pub enum BodyPaymentTarget {
Received, Id(#[garde(skip)] Uuid),
Sent, 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::<Self>())
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut uuid_schema = generator.subschema_for::<Uuid>();
let mut name_schema = generator.subschema_for::<Name>();
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<Vec<UserTarget<'static>>>,
) -> 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<UserTarget, garde::Error> {
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<Vec<UserTarget<'static>>>))]
#[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))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
#[serde(untagged)] #[serde(untagged)]
pub enum NameOrUuid { pub enum NameOrUuid {
Id(Uuid), Id(#[garde(skip)] Uuid),
Name(String), Name(#[garde(dive)] Name),
}
#[derive(Validate)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct AccountSelector {
#[garde(dive)]
user: NameOrUuid,
#[garde(dive)]
account: Option<Name>,
}
impl AccountSelector {
async fn account_id(
&self,
client: &impl GenericClient,
) -> Result<Option<Uuid>, 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<Name>,
},
}
impl<'de> Deserialize<'de> for AccountSelector {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<Self>())
}
fn validate_transform_into(
src: Self::Src,
ctx: &<Self::Src as Validate>::Context,
parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Self>;
}
pub struct UnvalidatedTransform<Dest: ValidateTransform> {
src: Dest::Src,
}
impl<'de, Dest> Deserialize<'de> for UnvalidatedTransform<Dest>
where
Dest: ValidateTransform,
Dest::Src: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(UnvalidatedTransform {
src: <Dest::Src as Deserialize<'de>>::deserialize(deserializer)?,
})
}
}
impl<Dest> schemars::JsonSchema for UnvalidatedTransform<Dest>
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 {
<Dest::Src as schemars::JsonSchema>::json_schema(generator)
}
fn always_inline_schema() -> bool {
<Dest::Src as schemars::JsonSchema>::always_inline_schema()
}
}
impl<Dest: ValidateTransform> UnvalidatedTransform<Dest> {
pub fn validate_transform_into(
self,
ctx: &<Dest::Src as Validate>::Context,
parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Dest> {
Dest::validate_transform_into(self.src, ctx, parent, report)
}
pub fn validate_with(
self,
ctx: &<Dest::Src as Validate>::Context,
) -> Result<Dest, garde::Report> {
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: &<Self::Src as Validate>::Context,
mut parent: &mut dyn FnMut() -> garde::Path,
report: &mut garde::Report,
) -> Option<Self> {
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)] #[derive(Deserialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct MakePayment { pub struct MakePayment {
pub from: NameOrUuid, from: NameOrUuid,
pub to: NameOrUuid, to: UnvalidatedTransform<AccountSelector>,
/// amount in cents amount: u64,
#[garde(min = 1)] }
pub 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) { #[derive(Deserialize)]
// Transaction::get_all_for_user(client, id) pub struct TransactionQuery {
#[serde(flatten)]
pub pagination: RequestPagination,
pub direction: Option<Direction>,
} }
pub async fn make_payment(EState(state): State, auth: Auth, Json(body): Json<MakePayment>) {
// Transaction::get_all_for_user(client, id) pub async fn transaction_history(
EState(state): State,
auth: Auth,
Query(TransactionQuery {
direction,
pagination,
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<MakePayment>,
) -> Result<Json<Transaction>, 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))
} }

View File

@ -6,17 +6,20 @@ use uuid::Uuid;
use crate::{ use crate::{
api::ApiError, api::ApiError,
model::{Account, User, UserAccountInfo, UserInfo}, model::{Account, FullTransaction, Transaction, User, UserAccountInfo},
}; };
use super::{ 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<Arc<AppState>> { pub(super) fn router() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/{target}", get(user_info)) .route("/{target}", get(user_info))
.route("/@me/balance", get(user_balance)) .route("/@me/balance", get(user_balance))
.route("/@me/accounts", get(user_accounts))
.route("/@me/transactions", get(me_transaction_history))
.route("/", get(list_users)) .route("/", get(list_users))
} }
@ -37,13 +40,7 @@ impl UserTarget {
} }
} }
make_schemas!((); (ListUsers, UserAccounts, UserBalance)); make_schemas!((); (Pagination<User>, UserAccounts, UserBalance));
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct ListUsers {
users: Vec<UserInfo>,
}
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
@ -61,7 +58,7 @@ pub async fn user_info(
EState(state): State, EState(state): State,
auth: Auth, auth: Auth,
Path(target): Path<UserTarget>, Path(target): Path<UserTarget>,
) -> Result<Json<UserInfo>, Error> { ) -> Result<Json<User>, Error> {
let user = target.user_id(&auth); let user = target.user_id(&auth);
let conn = state.conn().await?; let conn = state.conn().await?;
let info = User::info(&conn, user).await?; let info = User::info(&conn, user).await?;
@ -69,9 +66,9 @@ pub async fn user_info(
return Ok(Json(info)); return Ok(Json(info));
} }
if matches!(target, UserTarget::Me) { 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<Json<UserBalance>, Error> { pub async fn user_balance(EState(state): State, auth: Auth) -> Result<Json<UserBalance>, Error> {
let conn = state.conn().await?; let conn = state.conn().await?;
@ -79,8 +76,31 @@ pub async fn user_balance(EState(state): State, auth: Auth) -> Result<Json<UserB
let balance = info.iter().map(|info| info.balance).sum(); let balance = info.iter().map(|info| info.balance).sum();
Ok(Json(UserBalance { balance })) Ok(Json(UserBalance { balance }))
} }
pub async fn list_users(EState(state): State, _: Auth) -> Result<Json<ListUsers>, Error> { pub async fn list_users(
EState(state): State,
_: Auth,
pagination: RequestPagination,
) -> Result<Json<Pagination<User>>, Error> {
let conn = state.conn().await?; let conn = state.conn().await?;
let users = User::list(&conn).await?; let users = User::list(&conn, pagination).await?;
Ok(Json(ListUsers { users })) Ok(Json(users))
}
pub async fn me_transaction_history(
EState(state): State,
auth: Auth,
Query(TransactionQuery {
direction,
pagination,
}): Query<TransactionQuery>,
) -> Result<Json<Pagination<FullTransaction>>, 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<Json<UserAccounts>, 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 }))
} }

View File

@ -4,6 +4,10 @@ use tokio_postgres::Row;
use tracing::instrument; use tracing::instrument;
use uuid::{NoContext, Timestamp, Uuid}; use uuid::{NoContext, Timestamp, Uuid};
use crate::api::{Pagination, PaginationType, RequestPagination};
use super::count;
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct Account { pub struct Account {
@ -28,6 +32,20 @@ pub struct UserAccountInfo {
pub balance: u64, 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<Row> for AccountInfo { impl From<Row> for AccountInfo {
fn from(value: Row) -> Self { fn from(value: Row) -> Self {
Self { Self {
@ -64,32 +82,25 @@ impl Account {
Ok(id) Ok(id)
} }
#[instrument(skip(client))]
pub async fn get_password_by_username(
client: &impl GenericClient,
name: &str,
) -> Result<Option<String>, 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))] #[instrument(skip(client))]
pub async fn list_all( pub async fn list_all(
client: &impl GenericClient, client: &impl GenericClient,
) -> Result<Vec<AccountInfo>, tokio_postgres::Error> { pagination: RequestPagination,
) -> Result<Pagination<AccountInfo>, tokio_postgres::Error> {
let stmt = client 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?; .await?;
let users = client let users = client
.query(&stmt, &[]) .query(&stmt, &[&pagination.limit(), &pagination.offset()])
.await? .await?
.into_iter() .into_iter()
.map(AccountInfo::from) .map(AccountInfo::from)
.collect(); .collect();
Ok(users) let total = count(client, &stmt_count, &[]).await?;
Ok(Pagination::new(users, total, pagination))
} }
#[instrument(skip(client))] #[instrument(skip(client))]
@ -108,4 +119,43 @@ impl Account {
.collect(); .collect();
Ok(users) Ok(users)
} }
pub async fn by_id(
client: &impl GenericClient,
id: Uuid,
) -> Result<Option<UserAccountInfo>, 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<Option<bool>, 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<Option<UserAccountInfo>, 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))
}
} }

View File

@ -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 account;
mod transaction; mod transaction;
mod user; mod user;
pub use account::{Account, AccountInfo, UserAccountInfo}; pub use account::{Account, AccountInfo, UserAccountInfo};
pub use transaction::Transaction; pub use transaction::{Direction, FullTransaction, Transaction};
pub use user::{User, UserInfo}; pub use user::User;
use crate::api::make_schemas; use crate::api::make_schemas;
make_schemas!((); (UserInfo, Account, AccountInfo, UserAccountInfo, Transaction)); static NAME_PATTERN_RE: LazyLock<Regex> = 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<T: ?Sized + ToStatement + Sync + Send>(
client: &impl GenericClient,
statement: &T,
params: &[&(dyn ToSql + Sync)],
) -> Result<u64, tokio_postgres::Error> {
Ok(client.query_one(statement, params).await?.get::<_, i64>(0) as u64)
}
make_schemas!((Direction); (User, Account, AccountInfo, UserAccountInfo, Transaction, FullTransaction));

View File

@ -1,9 +1,37 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use deadpool_postgres::GenericClient; use deadpool_postgres::GenericClient;
use serde::Serialize; use serde::{Deserialize, Serialize};
use tokio_postgres::Row; use tokio_postgres::Row;
use uuid::Uuid; 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<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct Transaction { pub struct Transaction {
@ -118,4 +146,21 @@ impl Transaction {
) -> Result<Vec<Transaction>, tokio_postgres::Error> { ) -> Result<Vec<Transaction>, tokio_postgres::Error> {
todo!() todo!()
} }
pub async fn account_history(
client: &impl GenericClient,
id: Uuid,
direction: Option<Direction>,
pagination: RequestPagination,
) -> Result<Pagination<FullTransaction>, tokio_postgres::Error> {
todo!()
}
pub async fn user_history(
client: &impl GenericClient,
id: Uuid,
direction: Option<Direction>,
pagination: RequestPagination,
) -> Result<Pagination<FullTransaction>, tokio_postgres::Error> {
todo!()
}
} }

View File

@ -5,24 +5,18 @@ use tokio_postgres::{Row, error::SqlState};
use tracing::instrument; use tracing::instrument;
use uuid::{NoContext, Timestamp, Uuid}; use uuid::{NoContext, Timestamp, Uuid};
use crate::api::{ApiError, Error}; use crate::api::{ApiError, Error, Pagination, PaginationType, RequestPagination};
use super::Account; use super::{Account, count};
pub struct User {
pub id: Uuid,
pub name: String,
pub password: String,
}
#[derive(Serialize)] #[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct UserInfo { pub struct User {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
} }
impl From<Row> for UserInfo { impl From<Row> for User {
fn from(value: Row) -> Self { fn from(value: Row) -> Self {
Self { Self {
id: value.get("id"), id: value.get("id"),
@ -45,6 +39,10 @@ fn unique_violation(error: tokio_postgres::Error, api_error: fn() -> ApiError<'s
return error.into(); return error.into();
} }
impl PaginationType for User {
const NAME: &str = "UserList";
}
impl User { impl User {
#[instrument(skip(client, password))] #[instrument(skip(client, password))]
pub async fn create( pub async fn create(
@ -71,7 +69,7 @@ impl User {
pub async fn get_password_and_info_by_username( pub async fn get_password_and_info_by_username(
client: &impl GenericClient, client: &impl GenericClient,
name: &str, name: &str,
) -> Result<Option<(String, UserInfo)>, tokio_postgres::Error> { ) -> Result<Option<(String, User)>, tokio_postgres::Error> {
let stmt = client let stmt = client
.prepare_cached("select id,name,password from users where name = $1") .prepare_cached("select id,name,password from users where name = $1")
.await?; .await?;
@ -81,28 +79,53 @@ impl User {
(password, res.into()) (password, res.into())
})) }))
} }
#[instrument(skip(client))]
pub async fn info_by_name(
client: &impl GenericClient,
name: &str,
) -> Result<Option<User>, 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))] #[instrument(skip(client))]
pub async fn list(client: &impl GenericClient) -> Result<Vec<UserInfo>, tokio_postgres::Error> { pub async fn list(
let stmt = client.prepare_cached("select id,name from users").await?; client: &impl GenericClient,
pagination: RequestPagination,
) -> Result<Pagination<User>, 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 let users = client
.query(&stmt, &[]) .query(&stmt, &[&pagination.limit(), &pagination.offset()])
.await? .await?
.into_iter() .into_iter()
.map(UserInfo::from) .map(User::from)
.collect(); .collect();
Ok(users) let count = count(
client,
&stmt_count,
&[&pagination.limit(), &pagination.offset()],
)
.await?;
Ok(Pagination::new(users, count, pagination))
} }
#[instrument(skip(client))] #[instrument(skip(client))]
pub async fn info( pub async fn info(
client: &impl GenericClient, client: &impl GenericClient,
id: Uuid, id: Uuid,
) -> Result<Option<UserInfo>, tokio_postgres::Error> { ) -> Result<Option<User>, tokio_postgres::Error> {
let stmt = client let stmt = client
.prepare_cached("select id,name from users where id = $1") .prepare_cached("select id,name from users where id = $1")
.await?; .await?;
let info = client.query_opt(&stmt, &[]).await?.map(UserInfo::from); let info = client.query_opt(&stmt, &[]).await?.map(User::from);
Ok(info) Ok(info)
} }
} }