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",
"jsonwebtoken",
"password-auth",
"regex",
"schemars",
"serde",
"serde_json",

View File

@ -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"

View File

@ -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

View File

@ -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),

View File

@ -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:

View File

@ -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))
}

View File

@ -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,
}

View File

@ -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>

View File

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

View File

@ -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")]

View File

@ -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))
}

View File

@ -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 }))
}

View File

@ -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))
}
}

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 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));

View File

@ -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!()
}
}

View File

@ -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)
}
}