mirror of
https://git.dirksys.ovh/dirk/bankserver.git
synced 2025-12-20 11:09:21 +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",
|
"garde",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
|
"regex",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|||||||
227
src/api/mod.rs
227
src/api/mod.rs
@ -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")]
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user