mirror of
https://git.dirksys.ovh/dirk/bankserver.git
synced 2025-12-20 02:59:20 +01:00
Implement various endpoints
This commit is contained in:
parent
01629f00b5
commit
d1bb61e2a8
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -153,6 +153,7 @@ dependencies = [
|
||||
"garde",
|
||||
"jsonwebtoken",
|
||||
"password-auth",
|
||||
"regex",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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<Arc<AppState>> {
|
||||
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<AccountInfo>));
|
||||
|
||||
#[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<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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
</head>
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
@ -21,9 +21,7 @@
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
227
src/api/mod.rs
227
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<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 {
|
||||
trace: Option<SpanTrace>,
|
||||
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<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 {
|
||||
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<Arc<AppState>> {
|
||||
.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<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"))]
|
||||
pub struct Schemas;
|
||||
#[cfg(feature = "schemas")]
|
||||
|
||||
@ -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<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)]
|
||||
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
|
||||
@ -23,35 +32,349 @@ pub struct TransactionHistory {
|
||||
transactions: Vec<Transaction>,
|
||||
}
|
||||
|
||||
#[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<Vec<UserTarget<'static>>> 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::<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))]
|
||||
#[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<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)]
|
||||
#[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<AccountSelector>,
|
||||
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<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))
|
||||
}
|
||||
|
||||
@ -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<Arc<AppState>> {
|
||||
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<UserInfo>,
|
||||
}
|
||||
make_schemas!((); (Pagination<User>, 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<UserTarget>,
|
||||
) -> Result<Json<UserInfo>, Error> {
|
||||
) -> Result<Json<User>, 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<Json<UserBalance>, Error> {
|
||||
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();
|
||||
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 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<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 }))
|
||||
}
|
||||
|
||||
@ -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<Row> 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<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))]
|
||||
pub async fn list_all(
|
||||
client: &impl GenericClient,
|
||||
) -> Result<Vec<AccountInfo>, tokio_postgres::Error> {
|
||||
pagination: RequestPagination,
|
||||
) -> Result<Pagination<AccountInfo>, 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<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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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));
|
||||
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
|
||||
pub struct Transaction {
|
||||
@ -118,4 +146,21 @@ impl Transaction {
|
||||
) -> Result<Vec<Transaction>, tokio_postgres::Error> {
|
||||
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!()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Row> for UserInfo {
|
||||
impl From<Row> 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<Option<(String, UserInfo)>, tokio_postgres::Error> {
|
||||
) -> Result<Option<(String, User)>, 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<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))]
|
||||
pub async fn list(client: &impl GenericClient) -> Result<Vec<UserInfo>, tokio_postgres::Error> {
|
||||
let stmt = client.prepare_cached("select id,name from users").await?;
|
||||
pub async fn list(
|
||||
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
|
||||
.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<Option<UserInfo>, tokio_postgres::Error> {
|
||||
) -> Result<Option<User>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user