taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 9bedf27ccf36f6b24da16745986faed546d95ab2
parent 6472b10238c25a90ca805c5f94e33747cbd35c67
Author: Antoine A <>
Date:   Thu, 30 Apr 2026 16:14:22 +0200

common: many small improvements

Diffstat:
MCargo.toml | 27+++++++--------------------
Mcommon/taler-api/Cargo.toml | 3++-
Mcommon/taler-api/src/api/revenue.rs | 9++-------
Mcommon/taler-api/src/api/transfer.rs | 2+-
Mcommon/taler-api/src/api/wire.rs | 4++--
Mcommon/taler-api/src/config.rs | 24++++++++++--------------
Mcommon/taler-api/src/db.rs | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcommon/taler-api/src/error.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++--------------
Acommon/taler-api/src/extract.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/taler-api/src/json.rs | 144-------------------------------------------------------------------------------
Mcommon/taler-api/src/lib.rs | 2+-
Mcommon/taler-api/tests/api.rs | 2+-
Mcommon/taler-api/tests/common/db.rs | 2+-
Mcommon/taler-common/Cargo.toml | 1+
Mcommon/taler-common/src/cli.rs | 16++++++++--------
Mcommon/taler-common/src/config.rs | 47+++++++++++++++++++++++++++++++++++++----------
Mcommon/taler-common/src/db.rs | 4+++-
Mcommon/taler-common/src/types.rs | 2+-
Mcommon/taler-common/src/types/iban/registry.rs | 10+++++++++-
Mcommon/taler-common/src/types/payto.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcommon/taler-common/src/types/timestamp.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Dcommon/taler-enum-meta/Cargo.toml | 18------------------
Acommon/taler-macros/Cargo.toml | 17+++++++++++++++++
Rcommon/taler-enum-meta/src/lib.rs -> common/taler-macros/src/lib.rs | 0
Mcommon/taler-test-utils/Cargo.toml | 2++
Mcommon/taler-test-utils/src/db.rs | 2+-
Mcommon/taler-test-utils/src/routine.rs | 48++++++++++++++++++++++++------------------------
Mcommon/taler-test-utils/src/server.rs | 19+++++++++++++++----
Mtaler-apns-relay/Cargo.toml | 2+-
Mtaler-apns-relay/src/api.rs | 13++++++++-----
Mtaler-apns-relay/src/apns.rs | 2+-
Mtaler-cyclos/src/config.rs | 4++--
Mtaler-cyclos/src/db.rs | 2+-
Mtaler-cyclos/src/payto.rs | 19++++++-------------
Mtaler-magnet-bank/src/config.rs | 4++--
Mtaler-magnet-bank/src/db.rs | 2+-
Mtaler-magnet-bank/src/lib.rs | 5+++--
37 files changed, 602 insertions(+), 362 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -1,17 +1,6 @@ [workspace] resolver = "3" -members = [ - "common/taler-api", - "common/taler-common", - "common/taler-build", - "common/taler-test-utils", - "common/failure-injection", - "common/http-client", - "common/taler-enum-meta", - "taler-magnet-bank", - "taler-cyclos", - "taler-apns-relay", -] +members = ["common/*", "taler-magnet-bank", "taler-cyclos", "taler-apns-relay"] [workspace.package] version = "1.5.0" @@ -34,15 +23,13 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } serde_path_to_error = "0.1" serde_urlencoded = "0.7" -serde_with = { version = "3.11.0", default-features = false, features = [ - "macros", -] } +serde_with = { version = "3.11.0", default-features = false, features = ["macros"] } tokio = { version = "1.42", features = ["macros"] } -axum = "0.8" +axum = { version = "0.8", features = ["macros"] } sqlx = { version = "0.8", default-features = false, features = [ - "postgres", - "runtime-tokio", - "tls-rustls-aws-lc-rs", + "postgres", + "runtime-tokio", + "tls-rustls-aws-lc-rs", ] } url = { version = "2.2", features = ["serde"] } criterion = { version = "0.8", default-features = false } @@ -55,7 +42,7 @@ taler-common = { path = "common/taler-common" } taler-api = { path = "common/taler-api" } taler-test-utils = { path = "common/taler-test-utils" } taler-build = { path = "common/taler-build" } -taler-enum-meta = { path = "common/taler-enum-meta" } +taler-macros = { path = "common/taler-macros" } failure-injection = { path = "common/failure-injection" } http-client = { path = "common/http-client" } hyper = { version = "1.8.1", features = ["client", "http1", "http2"] } diff --git a/common/taler-api/Cargo.toml b/common/taler-api/Cargo.toml @@ -18,9 +18,10 @@ http-body-util.workspace = true zlib-rs = "0.6.3" tokio = { workspace = true, features = ["signal"] } serde.workspace = true -tracing.workspace = true serde_json.workspace = true +serde_urlencoded.workspace = true serde_path_to_error.workspace = true +tracing.workspace = true axum.workspace = true url.workspace = true thiserror.workspace = true diff --git a/common/taler-api/src/api/revenue.rs b/common/taler-api/src/api/revenue.rs @@ -16,13 +16,7 @@ use std::sync::Arc; -use axum::{ - Json, Router, - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - routing::get, -}; +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; use taler_common::{ api_params::{History, HistoryParams}, api_revenue::{RevenueConfig, RevenueIncomingHistory}, @@ -34,6 +28,7 @@ use crate::{ auth::AuthMethod, constants::{MAX_PAGE_SIZE, MAX_TIMEOUT_MS, REVENUE_API_VERSION}, error::ApiResult, + extract::Query, }; pub trait Revenue: TalerApi { diff --git a/common/taler-api/src/api/transfer.rs b/common/taler-api/src/api/transfer.rs @@ -37,7 +37,7 @@ use crate::{ constants::PREPARED_TRANSFER_API_VERSION, crypto::check_eddsa_signature, error::{ApiResult, failure, failure_code}, - json::Req, + extract::Req, }; pub trait PreparedTransfer: TalerApi { diff --git a/common/taler-api/src/api/wire.rs b/common/taler-api/src/api/wire.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, LazyLock}; use axum::{ Json, Router, - extract::{Path, Query, State}, + extract::State, http::StatusCode, response::IntoResponse as _, routing::{get, post}, @@ -40,7 +40,7 @@ use crate::{ auth::AuthMethod, constants::{MAX_PAGE_SIZE, MAX_TIMEOUT_MS, WIRE_GATEWAY_API_VERSION}, error::{ApiResult, failure, failure_code, failure_status}, - json::Req, + extract::{Path, Query, Req}, }; pub trait WireGateway: TalerApi { diff --git a/common/taler-api/src/config.rs b/common/taler-api/src/config.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025 Taler Systems SA + Copyright (C) 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -67,21 +67,19 @@ impl ApiCfg { pub fn parse(sect: Section) -> Result<Option<Self>, ValueErr> { Ok(if sect.boolean("ENABLED").require()? { let auth = map_config!(sect, "auth_method", "AUTH_METHOD", - "none" => { - Ok(AuthCfg::None) - }, + "none" => { AuthCfg::None }, "basic" => { - Ok(AuthCfg::Basic { + AuthCfg::Basic { username: sect.str("USERNAME").require()?, - password: sect.str("PASSWORD").require()? - }) + password: sect.str("PASSWORD").require()? + } }, "bearer" => { let token = sect.str("AUTH_TOKEN").opt()?; if let Some(token) = token { - Ok(AuthCfg::Bearer(token)) + AuthCfg::Bearer(token) } else { - Ok(AuthCfg::Bearer(sect.str("TOKEN").require()?)) + AuthCfg::Bearer(sect.str("TOKEN").require()?) } } ) @@ -99,16 +97,14 @@ impl Serve { "tcp" => { let port = sect.number("PORT").require()?; let ip: IpAddr = sect.parse("IP addr", "BIND_TO").require()?; - Ok::<Serve, ValueErr>(Serve::Tcp(SocketAddr::new(ip, port))) + Serve::Tcp(SocketAddr::new(ip, port)) }, "unix" => { let path = sect.path("UNIXPATH").require()?; let permission = sect.unix_mode("UNIXPATH_MODE").require()?; - Ok::<Serve, ValueErr>(Serve::Unix { path, permission }) + Serve::Unix { path, permission } }, - "systemd" => { - Ok::<Serve, ValueErr>(Serve::Systemd) - } + "systemd" => { Serve::Systemd } ) .require() } diff --git a/common/taler-api/src/db.rs b/common/taler-api/src/db.rs @@ -39,33 +39,66 @@ use tokio::sync::watch::Receiver; use url::Url; pub type PgQueryBuilder<'b> = QueryBuilder<'b, Postgres>; + /* ------ Serialization ----- */ +pub trait PgError { + const PG_SERIALIZATION_FAILURE: &str = "40001"; + const PG_DEADLOCK_DETECTED: &str = "40P01"; + const PG_UNIQUE_VIOLATION: &str = "23505"; + const PG_FOREIGN_KEY_VIOLATION: &str = "23503"; + + fn is_retryable_err(&self) -> bool; + fn is_unique_err(&self) -> bool; + fn is_fk_err(&self) -> bool; +} + +impl PgError for sqlx::error::Error { + fn is_retryable_err(&self) -> bool { + if let sqlx::Error::Database(e) = self { + return matches!( + e.downcast_ref::<sqlx::postgres::PgDatabaseError>().code(), + Self::PG_SERIALIZATION_FAILURE | Self::PG_DEADLOCK_DETECTED + ); + } + false + } + + fn is_unique_err(&self) -> bool { + if let sqlx::Error::Database(e) = self { + return e.downcast_ref::<sqlx::postgres::PgDatabaseError>().code() + == Self::PG_UNIQUE_VIOLATION; + } + false + } + + fn is_fk_err(&self) -> bool { + if let sqlx::Error::Database(e) = self { + return e.downcast_ref::<sqlx::postgres::PgDatabaseError>().code() + == Self::PG_FOREIGN_KEY_VIOLATION; + } + false + } +} + #[macro_export] macro_rules! serialized { ($logic:expr) => {{ + use $crate::db::PgError; let mut attempts = 0; const MAX_RETRIES: u32 = 5; - /// PostgreSQL serialization failure error code (40001) - const PG_SERIALIZATION_FAILURE: &str = "40001"; - /// PostgreSQL deadlock detected error code (40P01) - const PG_DEADLOCK_DETECTED: &str = "40P01"; loop { - match $logic.await { - Ok(res) => break Ok(res), - Err(sqlx::Error::Database(e)) - if matches!( - e.downcast_ref::<sqlx::postgres::PgDatabaseError>().code(), - PG_SERIALIZATION_FAILURE | PG_DEADLOCK_DETECTED - ) && attempts < MAX_RETRIES => - { - attempts += 1; - tokio::task::yield_now().await; - continue; - } - Err(e) => break Err(e), + let res = $logic.await; + if let sqlx::Result::Err(e) = &res + && e.is_retryable_err() + && attempts < MAX_RETRIES + { + attempts += 1; + tokio::task::yield_now().await; + continue; } + break res; } }}; } @@ -74,8 +107,8 @@ macro_rules! serialized { pub async fn page<'a, 'b, R: Send + Unpin>( db: &PgPool, - id_col: &str, params: &Page, + id_col: &str, prepare: impl Fn() -> QueryBuilder<'a, Postgres> + Copy, map: impl Fn(PgRow) -> Result<R, Error> + Send + Copy, ) -> Result<Vec<R>, Error> { @@ -112,7 +145,7 @@ pub async fn history<'a, 'b, R: Send + Unpin>( prepare: impl Fn() -> QueryBuilder<'a, Postgres> + Copy, map: impl Fn(PgRow) -> Result<R, Error> + Send + Copy, ) -> Result<Vec<R>, Error> { - let load = async || page(pool, id_col, &params.page, prepare, map).await; + let load = async || page(pool, &params.page, id_col, prepare, map).await; // When going backward there is always at least one transaction or none if params.page.limit >= 0 && params.timeout_ms.is_some_and(|it| it > 0) { @@ -174,32 +207,77 @@ pub trait TypeHelper { index: I, map: M, ) -> sqlx::Result<R>; + fn try_get_opt_map< + 'r, + I: sqlx::ColumnIndex<Self>, + T: Decode<'r, Postgres> + Type<Postgres>, + E: Into<BoxDynError>, + R, + M: FnOnce(T) -> Result<R, E>, + >( + &'r self, + index: I, + map: M, + ) -> sqlx::Result<Option<R>> { + self.try_get_map(index, |it: Option<T>| it.map(map).transpose()) + } fn try_get_parse<I: sqlx::ColumnIndex<Self>, E: Into<BoxDynError>, T: FromStr<Err = E>>( &self, index: I, - ) -> sqlx::Result<T>; + ) -> sqlx::Result<T> { + self.try_get_map(index, |s: &str| s.parse()) + } fn try_get_opt_parse<I: sqlx::ColumnIndex<Self>, E: Into<BoxDynError>, T: FromStr<Err = E>>( &self, index: I, - ) -> sqlx::Result<Option<T>>; + ) -> sqlx::Result<Option<T>> { + self.try_get_map(index, |s: Option<&str>| s.map(|s| s.parse()).transpose()) + } fn try_get_timestamp<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Timestamp> { self.try_get_map(index, |micros| { jiff::Timestamp::from_microsecond(micros) .map_err(|e| format!("expected timestamp micros got overflowing {micros}: {e}")) }) } + fn try_get_opt_timestamp<I: sqlx::ColumnIndex<Self>>( + &self, + index: I, + ) -> sqlx::Result<Option<Timestamp>> { + self.try_get_map(index, |micros: Option<i64>| { + if let Some(micros) = micros { + Some(jiff::Timestamp::from_microsecond(micros).map_err(|e| { + format!("expected timestamp micros got overflowing {micros}: {e}") + })) + .transpose() + } else { + Ok(None) + } + }) + } fn try_get_date<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Date> { let timestamp = self.try_get_timestamp(index)?; let zoned = timestamp.to_zoned(TimeZone::UTC); assert_eq!(zoned.time(), Time::midnight()); Ok(zoned.date()) } + fn try_get_u16<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<u16> { + self.try_get_map(index, |signed: i16| signed.try_into()) + } + fn try_get_opt_u16<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Option<u16>> { + self.try_get_opt_map(index, |signed: i16| signed.try_into()) + } fn try_get_u32<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<u32> { self.try_get_map(index, |signed: i32| signed.try_into()) } + fn try_get_opt_u32<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Option<u32>> { + self.try_get_opt_map(index, |signed: i32| signed.try_into()) + } fn try_get_u64<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<u64> { self.try_get_map(index, |signed: i64| signed.try_into()) } + fn try_get_opt_u64<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<Option<u64>> { + self.try_get_opt_map(index, |signed: i64| signed.try_into()) + } fn try_get_safeu64<I: sqlx::ColumnIndex<Self>>(&self, index: I) -> sqlx::Result<SafeU64> { self.try_get_map(index, |signed: i64| SafeU64::try_from(signed)) } @@ -253,20 +331,6 @@ impl TypeHelper for PgRow { }) } - fn try_get_parse<I: sqlx::ColumnIndex<Self>, E: Into<BoxDynError>, T: FromStr<Err = E>>( - &self, - index: I, - ) -> sqlx::Result<T> { - self.try_get_map(index, |s: &str| s.parse()) - } - - fn try_get_opt_parse<I: sqlx::ColumnIndex<Self>, E: Into<BoxDynError>, T: FromStr<Err = E>>( - &self, - index: I, - ) -> sqlx::Result<Option<T>> { - self.try_get_map(index, |s: Option<&str>| s.map(|s| s.parse()).transpose()) - } - fn try_get_amount<I: sqlx::ColumnIndex<Self>>( &self, index: I, diff --git a/common/taler-api/src/error.rs b/common/taler-api/src/error.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + Copyright (C) 2024, 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -14,8 +14,11 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +use std::fmt::Display; + use axum::{ Json, + extract::{path::ErrorKind, rejection::PathRejection}, http::{HeaderMap, HeaderValue, StatusCode, header::IntoHeaderName}, response::{IntoResponse, Response}, }; @@ -25,6 +28,7 @@ use taler_common::{ pub type ApiResult<T> = Result<T, ApiError>; +#[derive(Debug)] pub struct ApiError { code: ErrorCode, hint: Option<Box<str>>, @@ -46,9 +50,9 @@ impl ApiError { } } - pub fn with_hint(self, hint: impl Into<Box<str>>) -> Self { + pub fn with_hint(self, hint: impl Display) -> Self { Self { - hint: Some(hint.into()), + hint: Some(hint.to_string().into_boxed_str()), ..self } } @@ -67,9 +71,9 @@ impl ApiError { } } - pub fn with_path(self, path: impl Into<Box<str>>) -> Self { + pub fn with_path(self, path: impl Display) -> Self { Self { - path: Some(path.into()), + path: Some(path.to_string().into_boxed_str()), ..self } } @@ -109,27 +113,54 @@ impl From<sqlx::Error> for ApiError { impl From<PaytoErr> for ApiError { fn from(value: PaytoErr) -> Self { - ApiError::new(ErrorCode::GENERIC_PAYTO_URI_MALFORMED).with_hint(value.to_string()) + failure(ErrorCode::GENERIC_PAYTO_URI_MALFORMED, value) } } impl From<ParamsErr> for ApiError { fn from(value: ParamsErr) -> Self { - ApiError::new(ErrorCode::GENERIC_PARAMETER_MALFORMED) - .with_hint(value.to_string()) - .with_path(value.param) + failure(ErrorCode::GENERIC_PARAMETER_MALFORMED, &value).with_path(value.param) + } +} + +impl From<serde_path_to_error::Error<serde_urlencoded::de::Error>> for ApiError { + fn from(value: serde_path_to_error::Error<serde_urlencoded::de::Error>) -> Self { + failure(ErrorCode::GENERIC_PARAMETER_MALFORMED, value.inner()) + .with_path(value.path().to_string()) + .with_log(value.to_string()) } } impl From<serde_path_to_error::Error<serde_json::Error>> for ApiError { fn from(value: serde_path_to_error::Error<serde_json::Error>) -> Self { - ApiError::new(ErrorCode::GENERIC_JSON_INVALID) - .with_hint(value.inner().to_string()) + failure(ErrorCode::GENERIC_JSON_INVALID, value.inner()) .with_path(value.path().to_string()) .with_log(value.to_string()) } } +impl From<PathRejection> for ApiError { + fn from(value: PathRejection) -> Self { + match value { + PathRejection::FailedToDeserializePathParams(err) => { + let kind = err.into_kind(); + let err = failure(ErrorCode::GENERIC_PATH_SEGMENT_MALFORMED, &kind); + match kind { + ErrorKind::ParseErrorAtKey { key, .. } + | ErrorKind::InvalidUtf8InPathParam { key } + | ErrorKind::DeserializeError { key, .. } => err.with_path(key), + ErrorKind::ParseErrorAtIndex { index, .. } => err.with_path(index), + _ => err, + } + } + PathRejection::MissingPathParams(err) => { + failure(ErrorCode::BANK_UNMANAGED_EXCEPTION, err) + } + _ => failure(ErrorCode::BANK_UNMANAGED_EXCEPTION, value), + } + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { let status_code = self.status.unwrap_or_else(|| { @@ -173,16 +204,20 @@ pub fn failure_code(code: ErrorCode) -> ApiError { ApiError::new(code) } -pub fn failure(code: ErrorCode, hint: impl Into<Box<str>>) -> ApiError { +pub fn failure(code: ErrorCode, hint: impl Display) -> ApiError { ApiError::new(code).with_hint(hint) } -pub fn failure_status(code: ErrorCode, hint: impl Into<Box<str>>, status: StatusCode) -> ApiError { +pub fn failure_status(code: ErrorCode, hint: impl Display, status: StatusCode) -> ApiError { ApiError::new(code).with_hint(hint).with_status(status) } -pub fn not_implemented(hint: impl Into<Box<str>>) -> ApiError { +pub fn not_implemented(hint: impl Display) -> ApiError { ApiError::new(ErrorCode::END) .with_hint(hint) .with_status(StatusCode::NOT_IMPLEMENTED) } + +pub fn unauthorized(hint: impl Display) -> ApiError { + ApiError::new(ErrorCode::GENERIC_UNAUTHORIZED).with_hint(hint) +} diff --git a/common/taler-api/src/extract.rs b/common/taler-api/src/extract.rs @@ -0,0 +1,173 @@ +/* + This file is part of TALER + Copyright (C) 2025, 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use axum::{ + body::Bytes, + extract::{FromRequest, FromRequestParts, Request}, + http::{StatusCode, header, request::Parts}, +}; +use http_body_util::BodyExt as _; +use serde::de::DeserializeOwned; +use taler_common::error_code::ErrorCode; +use url::form_urlencoded; +use zlib_rs::{InflateConfig, ReturnCode}; + +use crate::{ + constants::MAX_BODY_LENGTH, + error::{ApiError, failure, failure_status}, +}; + +#[derive(Debug, Clone, Copy, Default)] +#[must_use] +pub struct Req<T>(pub T); + +impl<T, S> FromRequest<S> for Req<T> +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { + // Check content type + match req.headers().get(header::CONTENT_TYPE) { + Some(header) => { + if !header.as_bytes().starts_with(b"application/json") { + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + "Bad Content-Type header", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + )); + } + } + None => { + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + "Missing Content-Type header", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + )); + } + } + + // Check content length if present and wellformed + if let Some(length) = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|it| it.to_str().ok()) + .and_then(|it| it.parse::<usize>().ok()) + && length > MAX_BODY_LENGTH + { + return Err(failure( + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, + format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + )); + } + + // Check compression + let compressed = if let Some(encoding) = req.headers().get(header::CONTENT_ENCODING) { + if encoding == "deflate" { + true + } else { + return Err(failure_status( + ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, + format!( + "Unsupported encoding '{}'", + String::from_utf8_lossy(encoding.as_bytes()) + ), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + )); + } + } else { + false + }; + + // Buffer body + let (_, body) = req.into_parts(); + let body = http_body_util::Limited::new(body, MAX_BODY_LENGTH); + let bytes = match body.collect().await { + Ok(chunks) => chunks.to_bytes(), + Err(it) => match it.downcast::<http_body_util::LengthLimitError>() { + Ok(_) => { + return Err(failure( + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, + format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), + )); + } + Err(err) => { + return Err(failure( + ErrorCode::GENERIC_UNEXPECTED_REQUEST_ERROR, + format!("Failed to read body: {err}"), + )); + } + }, + }; + + let bytes = if compressed { + let mut buf = [0; MAX_BODY_LENGTH]; + let (decompressed, code) = + zlib_rs::decompress_slice(&mut buf, &bytes, InflateConfig::default()); + dbg!(code); + match code { + ReturnCode::Ok => Bytes::copy_from_slice(decompressed), + ReturnCode::BufError => { + return Err(failure( + ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, + format!("Decompressed body is suspiciously big > {MAX_BODY_LENGTH}B"), + )); + } + _ => { + return Err(failure( + ErrorCode::GENERIC_COMPRESSION_INVALID, + "Failed to decompress body: invalid compression", + )); + } + } + } else { + bytes + }; + let mut de = serde_json::de::Deserializer::from_slice(&bytes); + let parsed = serde_path_to_error::deserialize(&mut de)?; + Ok(Req(parsed)) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Path<T>(pub T); + +impl<T: serde::de::DeserializeOwned + Send, S: Sync + Send> FromRequestParts<S> for Path<T> { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { + Ok(Self( + axum::extract::Path::from_request_parts(parts, &()).await?.0, + )) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Query<T>(pub T); + +impl<T: serde::de::DeserializeOwned, S: Sync + Send> FromRequestParts<S> for Query<T> { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { + let query = parts.uri.query().unwrap_or_default(); + let deserializer = + serde_urlencoded::Deserializer::new(form_urlencoded::parse(query.as_bytes())); + let params = serde_path_to_error::deserialize(deserializer)?; + Ok(Query(params)) + } +} diff --git a/common/taler-api/src/json.rs b/common/taler-api/src/json.rs @@ -1,144 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2025, 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use axum::{ - body::Bytes, - extract::{FromRequest, Request}, - http::{StatusCode, header}, -}; -use http_body_util::BodyExt as _; -use serde::de::DeserializeOwned; -use taler_common::error_code::ErrorCode; -use zlib_rs::{InflateConfig, ReturnCode}; - -use crate::{ - constants::MAX_BODY_LENGTH, - error::{ApiError, failure, failure_status}, -}; - -#[derive(Debug, Clone, Copy, Default)] -#[must_use] -pub struct Req<T>(pub T); - -impl<T, S> FromRequest<S> for Req<T> -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = ApiError; - - async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { - // Check content type - match req.headers().get(header::CONTENT_TYPE) { - Some(header) => { - if !header.as_bytes().starts_with(b"application/json") { - return Err(failure_status( - ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, - "Bad Content-Type header", - StatusCode::UNSUPPORTED_MEDIA_TYPE, - )); - } - } - None => { - return Err(failure_status( - ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, - "Missing Content-Type header", - StatusCode::UNSUPPORTED_MEDIA_TYPE, - )); - } - } - - // Check content length if present and wellformed - if let Some(length) = req - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|it| it.to_str().ok()) - .and_then(|it| it.parse::<usize>().ok()) - && length > MAX_BODY_LENGTH - { - return Err(failure( - ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), - )); - } - - // Check compression - let compressed = if let Some(encoding) = req.headers().get(header::CONTENT_ENCODING) { - if encoding == "deflate" { - true - } else { - return Err(failure_status( - ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED, - format!( - "Unsupported encoding '{}'", - String::from_utf8_lossy(encoding.as_bytes()) - ), - StatusCode::UNSUPPORTED_MEDIA_TYPE, - )); - } - } else { - false - }; - - // Buffer body - let (_, body) = req.into_parts(); - let body = http_body_util::Limited::new(body, MAX_BODY_LENGTH); - let bytes = match body.collect().await { - Ok(chunks) => chunks.to_bytes(), - Err(it) => match it.downcast::<http_body_util::LengthLimitError>() { - Ok(_) => { - return Err(failure( - ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, - format!("Body is suspiciously big > {MAX_BODY_LENGTH}B"), - )); - } - Err(err) => { - return Err(failure( - ErrorCode::GENERIC_UNEXPECTED_REQUEST_ERROR, - format!("Failed to read body: {err}"), - )); - } - }, - }; - - let bytes = if compressed { - let mut buf = [0; MAX_BODY_LENGTH]; - let (decompressed, code) = - zlib_rs::decompress_slice(&mut buf, &bytes, InflateConfig::default()); - dbg!(code); - match code { - ReturnCode::Ok => Bytes::copy_from_slice(decompressed), - ReturnCode::BufError => { - return Err(failure( - ErrorCode::GENERIC_UPLOAD_EXCEEDS_LIMIT, - format!("Decompressed body is suspiciously big > {MAX_BODY_LENGTH}B"), - )); - } - _ => { - return Err(failure( - ErrorCode::GENERIC_COMPRESSION_INVALID, - "Failed to decompress body: invalid compression", - )); - } - } - } else { - bytes - }; - let mut de = serde_json::de::Deserializer::from_slice(&bytes); - let parsed = serde_path_to_error::deserialize(&mut de)?; - Ok(Req(parsed)) - } -} diff --git a/common/taler-api/src/lib.rs b/common/taler-api/src/lib.rs @@ -27,7 +27,7 @@ pub mod constants; pub mod crypto; pub mod db; pub mod error; -pub mod json; +pub mod extract; pub mod notification; pub mod subject; diff --git a/common/taler-api/tests/api.rs b/common/taler-api/tests/api.rs @@ -91,7 +91,7 @@ async fn outgoing_history() { async |i| { server .post("/taler-wire-gateway/transfer") - .json(&json!({ + .json(json!({ "request_uid": HashCode::rand(), "amount": amount(format!("EUR:0.0{i}")), "exchange_base_url": url("http://exchange.taler"), diff --git a/common/taler-api/tests/common/db.rs b/common/taler-api/tests/common/db.rs @@ -100,8 +100,8 @@ pub async fn transfer_page( ) -> sqlx::Result<Vec<TransferListStatus>> { page( db, - "transfer_id", params, + "transfer_id", || { let mut builder = QueryBuilder::new( " diff --git a/common/taler-common/Cargo.toml b/common/taler-common/Cargo.toml @@ -32,6 +32,7 @@ sqlx = { workspace = true, features = ["macros"] } compact_str.workspace = true aws-lc-rs.workspace = true regex.workspace = true +taler-macros.workspace = true [dev-dependencies] criterion.workspace = true diff --git a/common/taler-common/src/cli.rs b/common/taler-common/src/cli.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2025 Taler Systems SA + Copyright (C) 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -40,7 +40,7 @@ pub enum ConfigCmd { } impl ConfigCmd { - pub fn run(&self, cfg: &Config) -> anyhow::Result<()> { + pub fn run(self, cfg: &Config) -> anyhow::Result<()> { let mut out = std::io::stdout().lock(); match self { ConfigCmd::Get { @@ -48,20 +48,20 @@ impl ConfigCmd { option, filename, } => { - let sect = cfg.section(section); - let value = if *filename { - sect.path(option).require()? + let sect = cfg.section(&section); + let value = if filename { + sect.path(&option).require()? } else { - sect.str(option).require()? + sect.str(&option).require()? }; writeln!(&mut out, "{value}")?; } ConfigCmd::Pathsub { path_expr } => { - let path = cfg.pathsub(path_expr, 0)?; + let path = cfg.pathsub(&path_expr, 0)?; writeln!(&mut out, "{path}")?; } ConfigCmd::Dump { diagnostics } => { - cfg.print(&mut out, *diagnostics)?; + cfg.print(&mut out, diagnostics)?; } } Ok(()) diff --git a/common/taler-common/src/config.rs b/common/taler-common/src/config.rs @@ -25,7 +25,7 @@ use std::{ use compact_str::CompactString; use indexmap::IndexMap; -use jiff::SignedDuration; +use jiff::{SignedDuration, Span}; use url::Url; use crate::types::{ @@ -256,7 +256,7 @@ pub mod parser { } } "inline-secret" => { - let (section, secret_file) = arg.split_once(" ").ok_or_else(|| + let (section, secret_file) = arg.split_once(" ").ok_or_else(|| line_err( "Invalid configuration, @inline-secret@ directive requires exactly two arguments", src, @@ -468,6 +468,17 @@ pub mod parser { )?; Ok(parser.finish()) } + + pub fn from_mem_with_env(src: ConfigSource, str: &str) -> Result<Config, ParserErr> { + let mut parser = Parser::empty(); + parser.load_env(src)?; + parser.parse( + std::io::Cursor::new(str), + PathBuf::from_str("mem").unwrap(), + 0, + )?; + Ok(parser.finish()) + } } } @@ -523,12 +534,20 @@ pub struct Config { impl Config { pub fn section<'cfg, 'arg>(&'cfg self, section: &'arg str) -> Section<'cfg, 'arg> { Section { - section, + name: section, config: self, values: self.sections.get(&section.to_uppercase()), } } + pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> { + self.sections.iter().map(|(section, values)| Section { + name: section, + config: self, + values: Some(values), + }) + } + /** * Substitute ${...} and $... placeholders in a string * with values from the PATHS section in the @@ -674,7 +693,7 @@ impl Config { /** Accessor/Converter for Taler-like configuration sections */ pub struct Section<'cfg, 'arg> { - section: &'arg str, + pub name: &'arg str, config: &'cfg Config, values: Option<&'cfg IndexMap<String, Line>>, } @@ -687,9 +706,7 @@ macro_rules! map_config { $self.map($ty, $option, |value| { match value { $($key => { - (|| { - $parse - })().map_err(|e| ::taler_common::config::MapErr::Err(e)) + (||Ok($parse))().map_err(|e| ::taler_common::config::MapErr::Err(e)) })*, _ => Err(::taler_common::config::MapErr::Invalid(keys)) } @@ -724,7 +741,7 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { value, option, ty, - section: self.section, + section: self.name, } } @@ -757,7 +774,7 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { buf.push('\''); ValueErr::Invalid { ty: ty.to_owned(), - section: self.section.to_owned(), + section: self.name.to_owned(), option: option.to_owned(), err: buf, } @@ -777,7 +794,7 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { self.inner(ty, option, |v| { transform(v).map_err(|e| ValueErr::Invalid { ty: ty.to_owned(), - section: self.section.to_owned(), + section: self.name.to_owned(), option: option.to_owned(), err: e.to_string(), }) @@ -920,10 +937,20 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { }) } + /** Access [option] as a duration */ + pub fn span(&self, option: &'arg str) -> Value<'arg, Span> { + self.parse("temporal", option) + } + /** Access [option] as a regex */ pub fn regex(&self, option: &'arg str) -> Value<'arg, regex::Regex> { self.parse("Pattern", option) } + + /** Access option as json object */ + pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> { + self.value("json", option, |it| serde_json::from_str(it)) + } } pub struct Value<'arg, T> { diff --git a/common/taler-common/src/db.rs b/common/taler-common/src/db.rs @@ -24,11 +24,13 @@ use sqlx::{ Connection, Executor, PgConnection, PgPool, Row, postgres::{PgConnectOptions, PgPoolOptions, PgRow}, }; +use taler_macros::EnumMeta; use tracing::{debug, info}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, EnumMeta)] #[allow(non_camel_case_types)] #[sqlx(type_name = "incoming_type")] +#[enum_meta(Str)] pub enum IncomingType { reserve, kyc, diff --git a/common/taler-common/src/types.rs b/common/taler-common/src/types.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + Copyright (C) 2024, 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software diff --git a/common/taler-common/src/types/iban/registry.rs b/common/taler-common/src/types/iban/registry.rs @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::fmt::Display; +use std::{fmt::Display, str::FromStr}; use Country::*; use IbanC::*; @@ -1008,6 +1008,14 @@ impl Country { } } +impl FromStr for Country { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Self::from_iso(s).ok_or_else(|| format!("Unknown country '{s}'")) + } +} + impl Display for Country { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.iso()) diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -16,7 +16,7 @@ use std::{ fmt::{Debug, Display}, - ops::Deref, + ops::{Deref, DerefMut}, str::FromStr, }; @@ -117,18 +117,26 @@ pub enum PaytoErr { #[error("malformed payto URI query: {0}")] Query(#[from] serde_path_to_error::Error<serde_urlencoded::de::Error>), #[error("expected a payto URI got {0}")] - NotPayto(String), + NotPayto(CompactString), #[error("unsupported payto kind, expected {0} got {1}")] - UnsupportedKind(&'static str, String), + UnsupportedKind(&'static str, CompactString), #[error("to much path segment for a {0} payto uri")] TooLong(&'static str), - #[error(transparent)] - Custom(Box<dyn std::error::Error + Sync + Send + 'static>), + #[error("missing segment {0} in path")] + MissingSegment(&'static str), + #[error("malformed segment {0}: {1}")] + MalformedSegment( + &'static str, + Box<dyn std::error::Error + Sync + Send + 'static>, + ), } impl PaytoErr { - pub fn custom<E: std::error::Error + Sync + Send + 'static>(e: E) -> Self { - Self::Custom(Box::new(e)) + pub fn malformed_segment<E: std::error::Error + Sync + Send + 'static>( + segment: &'static str, + e: E, + ) -> Self { + Self::MalformedSegment(segment, Box::new(e)) } } @@ -140,7 +148,7 @@ impl FromStr for PaytoURI { let url: Url = s.parse()?; // Check scheme if url.scheme() != "payto" { - return Err(PaytoErr::NotPayto(url.scheme().to_owned())); + return Err(PaytoErr::NotPayto(url.scheme().into())); } Ok(Self(url)) } @@ -158,13 +166,20 @@ pub struct BankID { const IBAN: &str = "iban"; -#[derive(Debug, thiserror::Error)] -#[error("missing IBAN in path")] -pub struct MissingIban; - impl PaytoImpl for BankID { fn as_payto(&self) -> PaytoURI { - PaytoURI::from_parts(IBAN, format_args!("/{}", self.iban)) + PaytoURI::from_parts( + IBAN, + format_args!( + "/{}", + std::fmt::from_fn(|f| { + if let Some(bic) = &self.bic { + write!(f, "{bic}/")?; + } + write!(f, "{}", self.iban) + }) + ), + ) } fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { @@ -172,33 +187,41 @@ impl PaytoImpl for BankID { if url.domain() != Some(IBAN) { return Err(PaytoErr::UnsupportedKind( IBAN, - url.domain().unwrap_or_default().to_owned(), + url.domain().unwrap_or_default().into(), )); } let Some(mut segments) = url.path_segments() else { - return Err(PaytoErr::custom(MissingIban)); + return Err(PaytoErr::MissingSegment("iban")); }; let Some(first) = segments.next() else { - return Err(PaytoErr::custom(MissingIban)); + return Err(PaytoErr::MissingSegment("iban")); }; let (iban, bic) = match segments.next() { - Some(second) => ( - second.parse().map_err(PaytoErr::custom)?, - Some(first.parse().map_err(PaytoErr::custom)?), - ), - None => (first.parse().map_err(PaytoErr::custom)?, None), + Some(second) => (second, Some(first)), + None => (first, None), }; - Ok(Self { iban, bic }) + Ok(Self { + iban: iban + .parse() + .map_err(|e| PaytoErr::malformed_segment("iban", e))?, + bic: bic + .map(|bic| { + bic.parse() + .map_err(|e| PaytoErr::malformed_segment("bic", e)) + }) + .transpose()?, + }) } } impl PaytoImpl for IBAN { fn as_payto(&self) -> PaytoURI { - PaytoURI::from_parts(IBAN, format_args!("/{self}")) + PaytoURI::from_parts("iban", format_args!("/{self}")) } fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { + raw.as_ref().path_segments().unwrap_or("".split('/')); let payto = BankID::parse(raw)?; Ok(payto.iban) } @@ -280,6 +303,12 @@ impl<P: PaytoImpl> Deref for Payto<P> { } } +impl<P: PaytoImpl> DerefMut for Payto<P> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + #[derive(Debug, Clone, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)] pub struct FullPayto<P> { inner: P, diff --git a/common/taler-common/src/types/timestamp.rs b/common/taler-common/src/types/timestamp.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024-2025 Taler Systems SA + Copyright (C) 2024, 2025, 2026 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -14,7 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use std::{fmt::Display, ops::Add}; +use std::{fmt::Display, ops::Add, time::Duration}; use jiff::{Timestamp, civil::Time, tz::TimeZone}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error, ser::SerializeStruct}; // codespell:ignore @@ -105,3 +105,60 @@ impl Add<jiff::Span> for TalerTimestamp { } } } + +/// <https://docs.taler.net/core/api-common.html#tsref-type-RelativeTime> +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum RelativeTime { + Forever, + Duration(Duration), +} + +#[derive(Serialize, Deserialize)] +struct RelativeTimeImpl { + d_us: Value, +} + +impl<'de> Deserialize<'de> for RelativeTime { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let tmp = RelativeTimeImpl::deserialize(deserializer)?; + match tmp.d_us { + Value::Number(s) => { + if let Some(micros) = s.as_u64() { + Ok(Self::Duration(Duration::from_micros(micros))) + } else { + Err(Error::custom("Expected microseconds")) + } + } + Value::String(str) if str == "forever" => Ok(Self::Forever), + _ => Err(Error::custom("Expected epoch time or 'forever'")), + } + } +} + +impl Serialize for RelativeTime { + fn serialize<S>(&self, se: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut se_struct = se.serialize_struct("RelativeTime", 1)?; + match self { + RelativeTime::Forever => se_struct.serialize_field("d_us", "forever")?, + RelativeTime::Duration(duration) => { + se_struct.serialize_field("d_us", &duration.as_micros())? + } + } + se_struct.end() + } +} + +impl Display for RelativeTime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RelativeTime::Forever => f.write_str("forever"), + RelativeTime::Duration(duration) => write!(f, "{duration:?}"), + } + } +} diff --git a/common/taler-enum-meta/Cargo.toml b/common/taler-enum-meta/Cargo.toml @@ -1,18 +0,0 @@ -[package] -name = "taler-enum-meta" -version.workspace = true -edition.workspace = true -authors.workspace = true -homepage.workspace = true -repository.workspace = true -license-file.workspace = true - -[lib] -proc-macro = true -doctest = false -test = false - -[dependencies] -proc-macro2 = "1" -quote = "1" -syn = "2" diff --git a/common/taler-macros/Cargo.toml b/common/taler-macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "taler-macros" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[lib] +proc-macro = true +doctest = false +test = false + +[dependencies] +quote = "1" +syn = "2" diff --git a/common/taler-enum-meta/src/lib.rs b/common/taler-macros/src/lib.rs diff --git a/common/taler-test-utils/Cargo.toml b/common/taler-test-utils/Cargo.toml @@ -28,3 +28,4 @@ http-body-util.workspace = true url.workspace = true aws-lc-rs.workspace = true jiff.workspace = true +base64.workspace = true +\ No newline at end of file diff --git a/common/taler-test-utils/src/db.rs b/common/taler-test-utils/src/db.rs @@ -84,7 +84,7 @@ async fn test_db() -> PgConnectOptions { // We need this connection to stay open to keep the advisory lock in place // Leaking it is OK in tests std::mem::forget(conn); - let db_url = format!("postgresql:/{name}"); + let db_url = format!("postgresql:///{name}"); info!("Running on db {db_url}"); PgConnectOptions::from_str(&db_url).unwrap() } diff --git a/common/taler-test-utils/src/routine.rs b/common/taler-test-utils/src/routine.rs @@ -357,13 +357,13 @@ pub async fn transfer_routine( // Check OK let first = server .post("/taler-wire-gateway/transfer") - .json(&req) + .json(req) .await .assert_ok_json::<TransferResponse>(); // Check idempotent let second = server .post("/taler-wire-gateway/transfer") - .json(&req) + .json(req) .await .assert_ok_json::<TransferResponse>(); assert_eq!(first.row_id, second.row_id); @@ -483,7 +483,7 @@ pub async fn transfer_routine( // TODO check bad payto // TODO Bad base URL - /**for base_url in [ + /*for base_url in [ "not-a-url", "file://not.http.com/", "no.transport.com/", @@ -496,7 +496,7 @@ pub async fn transfer_routine( })) .await .assert_error(ErrorCode::GENERIC_JSON_INVALID); - }**/ + }*/ // Malformed metadata for metadata in ["bad_id", "bad id", "bad@id.com", &"A".repeat(41)] { server @@ -552,7 +552,7 @@ pub async fn transfer_routine( routine_pagination::<TransferList>(server, "/taler-wire-gateway/transfers", async |i| { server .post("/taler-wire-gateway/transfer") - .json(&json!({ + .json(json!({ "request_uid": HashCode::rand(), "amount": amount(format!("{currency}:0.0{i}")), "exchange_base_url": url("http://exchange.taler"), @@ -582,7 +582,7 @@ async fn add_incoming_routine( // Valid server .post("/taler-prepared-transfer/registration") - .json(&json!({ + .json(json!({ "type": "reserve", "credit_amount": format!("{currency}:44"), "alg": "EdDSA", @@ -683,7 +683,7 @@ pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool if i % 2 == 0 || !kyc { server .post("/taler-wire-gateway/admin/add-incoming") - .json(&json!({ + .json(json!({ "amount": format!("{currency}:0.0{i}"), "reserve_pub": EddsaPublicKey::rand(), "debit_account": debit_acount, @@ -693,7 +693,7 @@ pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool } else { server .post("/taler-wire-gateway/admin/add-kycauth") - .json(&json!({ + .json(json!({ "amount": format!("{currency}:0.0{i}"), "account_pub": EddsaPublicKey::rand(), "debit_account": debit_acount, @@ -723,7 +723,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b 0 => { server .post("/taler-wire-gateway/admin/add-incoming") - .json(&json!({ + .json(json!({ "amount": format!("{currency}:0.0{i}"), "reserve_pub": EddsaPublicKey::rand(), "debit_account": debit_acount, @@ -738,7 +738,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b let amount = format!("{currency}:0.0{i}"); server .post("/taler-prepared-transfer/registration") - .json(&json!({ + .json(json!({ "credit_amount": amount, "type": "reserve", "alg": "EdDSA", @@ -751,7 +751,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b .assert_ok_json::<RegistrationResponse>(); server .post("/taler-wire-gateway/admin/add-mapped") - .json(&json!({ + .json(json!({ "amount": amount, "authorization_pub": auth_pub, "debit_account": debit_acount, @@ -760,7 +760,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b .assert_ok_json::<TransferResponse>(); server .post("/taler-wire-gateway/admin/add-mapped") - .json(&json!({ + .json(json!({ "amount": amount, "authorization_pub": auth_pub, "debit_account": debit_acount, @@ -773,7 +773,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b let reserve_pub = EddsaPublicKey::rand(); server .post("/taler-prepared-transfer/registration") - .json(&json!({ + .json(json!({ "credit_amount": format!("{currency}:0.0{i}"), "type": "reserve", "alg": "EdDSA", @@ -788,7 +788,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b 3 => { server .post("/taler-wire-gateway/admin/add-kycauth") - .json(&json!({ + .json(json!({ "amount": format!("{currency}:0.0{i}"), "account_pub": EddsaPublicKey::rand(), "debit_account": debit_acount, @@ -803,7 +803,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b let amount = format!("{currency}:0.0{i}"); server .post("/taler-prepared-transfer/registration") - .json(&json!({ + .json(json!({ "credit_amount": amount, "type": "kyc", "alg": "EdDSA", @@ -816,7 +816,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b .assert_ok_json::<RegistrationResponse>(); server .post("/taler-wire-gateway/admin/add-mapped") - .json(&json!({ + .json(json!({ "amount": amount, "authorization_pub": auth_pub, "debit_account": debit_acount, @@ -825,7 +825,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b .assert_ok_json::<TransferResponse>(); server .post("/taler-wire-gateway/admin/add-mapped") - .json(&json!({ + .json(json!({ "amount": amount, "authorization_pub": auth_pub, "debit_account": debit_acount, @@ -838,7 +838,7 @@ pub async fn in_history_routine(server: &Router, debit_acount: &PaytoURI, kyc: b let account_pub = EddsaPublicKey::rand(); server .post("/taler-prepared-transfer/registration") - .json(&json!({ + .json(json!({ "credit_amount": format!("{currency}:0.0{i}"), "type": "kyc", "alg": "EdDSA", @@ -907,7 +907,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( let register = async |auth_pub: &EddsaPublicKey| { server .post("/taler-wire-gateway/admin/add-mapped") - .json(&json!({ + .json(json!({ "amount": format!("{currency}:42"), "authorization_pub": auth_pub, "debit_account": account, @@ -1024,7 +1024,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( .assert_ok_json::<RegistrationResponse>(); server .post("/taler-wire-gateway/admin/add-incoming") - .json(&json!({ + .json(json!({ "amount": amount, "reserve_pub": acc_pub2, "debit_account": account, @@ -1098,7 +1098,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( // Kyc key reuse keep pending ones server .post("/taler-wire-gateway/admin/add-kycauth") - .json(&json!({ + .json(json!({ "amount": amount, "account_pub": acc_pub4, "debit_account": account, @@ -1190,7 +1190,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( .assert_ok_json::<RegistrationResponse>(); server .post("/taler-wire-gateway/admin/add-incoming") - .json(&json!({ + .json(json!({ "amount": amount, "reserve_pub": acc_pub5, "debit_account": account, @@ -1242,7 +1242,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( .assert_ok_json::<RegistrationResponse>(); server .post("/taler-wire-gateway/admin/add-kycauth") - .json(&json!({ + .json(json!({ "amount": amount, "account_pub": acc_pub5, "debit_account": account, @@ -1331,7 +1331,7 @@ pub async fn registration_routine<F1: Future<Output = Vec<Status>>>( let now = (Timestamp::now() - SignedDuration::from_mins(10)).to_string(); server .post("/taler-prepared-transfer/unregistration") - .json(&json!({ + .json(json!({ "timestamp": now, "authorization_pub": auth_pub2, "authorization_sig": eddsa_sign(&auth_pair, now.as_bytes()), diff --git a/common/taler-test-utils/src/server.rs b/common/taler-test-utils/src/server.rs @@ -22,9 +22,10 @@ use axum::{ extract::Query, http::{ HeaderMap, HeaderValue, Method, StatusCode, Uri, - header::{self, AsHeaderName, IntoHeaderName}, + header::{self, AUTHORIZATION, AsHeaderName, IntoHeaderName}, }, }; +use base64::{Engine as _, prelude::BASE64_STANDARD}; use flate2::{Compression, write::ZlibEncoder}; use http_body_util::BodyExt as _; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -65,7 +66,7 @@ impl TestServer for Router { pub struct TestRequest { router: Router, method: Method, - url: Url, + pub url: Url, body: Option<Vec<u8>>, headers: HeaderMap, } @@ -80,9 +81,9 @@ impl TestRequest { self } - pub fn json<T: Serialize>(mut self, body: &T) -> Self { + pub fn json<T: Serialize>(mut self, body: T) -> Self { assert!(self.body.is_none()); - let bytes = serde_json::to_vec(body).unwrap(); + let bytes = serde_json::to_vec(&body).unwrap(); self.body = Some(bytes); self.headers.insert( header::CONTENT_TYPE, @@ -118,6 +119,16 @@ impl TestRequest { self } + pub fn basic_auth(self, username: &str, password: &str) -> Self { + self.header( + AUTHORIZATION, + format!( + "Basic {}", + BASE64_STANDARD.encode(format!("{username}:{password}")) + ), + ) + } + async fn send(self) -> TestResponse { let Self { router, diff --git a/taler-apns-relay/Cargo.toml b/taler-apns-relay/Cargo.toml @@ -18,7 +18,7 @@ axum.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true -taler-enum-meta.workspace = true +taler-macros.workspace = true anyhow.workspace = true clap.workspace = true hyper.workspace = true diff --git a/taler-apns-relay/src/api.rs b/taler-apns-relay/src/api.rs @@ -18,14 +18,17 @@ use std::sync::Arc; use axum::{ Json, Router, - extract::{Path, State}, + extract::State, response::{IntoResponse as _, NoContent}, routing::{delete, get, post}, }; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use taler_api::{error::ApiResult, json::Req}; +use taler_api::{ + error::ApiResult, + extract::{Path, Req}, +}; use crate::db; @@ -118,21 +121,21 @@ mod test { server .post("/devices") - .json(&json!({ + .json(json!({ "token": "device1" })) .await .assert_no_content(); server .post("/devices") - .json(&json!({ + .json(json!({ "token": "device1" })) .await .assert_no_content(); server .post("/devices") - .json(&json!({ + .json(json!({ "token": "device2" })) .await diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -32,7 +32,7 @@ use jiff::{SignedDuration, Timestamp}; use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; use serde::Deserialize; use taler_common::error::FmtSource; -use taler_enum_meta::EnumMeta; +use taler_macros::EnumMeta; use crate::config::ApnsConfig; diff --git a/taler-cyclos/src/config.rs b/taler-cyclos/src/config.rs @@ -179,8 +179,8 @@ impl WorkerCfg { Ok(Self { frequency: sect.duration("FREQUENCY").require()?, account_type: map_config!(sect, "account type", "ACCOUNT_TYPE", - "exchange" => { Ok(AccountType::Exchange) }, - "normal" => { Ok(AccountType::Normal) } + "exchange" => { AccountType::Exchange }, + "normal" => { AccountType::Normal } ) .require()?, account_type_id: sect diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs @@ -470,8 +470,8 @@ pub async fn transfer_page( ) -> sqlx::Result<Vec<TransferListStatus>> { page( db, - "initiated_id", params, + "initiated_id", || { let mut builder = QueryBuilder::new( " diff --git a/taler-cyclos/src/payto.rs b/taler-cyclos/src/payto.rs @@ -44,24 +44,16 @@ impl Display for CyclosId { } } -#[derive(Debug, thiserror::Error)] -#[error("malformed cyclos id: {0}")] -pub struct CyclosIdError(ParseIntError); - impl FromStr for CyclosId { - type Err = CyclosIdError; + type Err = ParseIntError; fn from_str(s: &str) -> Result<Self, Self::Err> { - Ok(Self(i64::from_str(s).map_err(CyclosIdError)?)) + Ok(Self(i64::from_str(s)?)) } } const CYCLOS: &str = "cyclos"; -#[derive(Debug, thiserror::Error)] -#[error("missing cyclos root and account id in path")] -pub struct MissingParts; - impl PaytoImpl for CyclosAccount { fn as_payto(&self) -> PaytoURI { PaytoURI::from_parts(CYCLOS, format_args!("/{}/{}", self.root, self.id)) @@ -72,15 +64,16 @@ impl PaytoImpl for CyclosAccount { if url.domain() != Some(CYCLOS) { return Err(PaytoErr::UnsupportedKind( CYCLOS, - url.domain().unwrap_or_default().to_owned(), + url.domain().unwrap_or_default().into(), )); } let Some((root, id)) = url.path().trim_start_matches('/').rsplit_once('/') else { - return Err(PaytoErr::custom(MissingParts)); + return Err(PaytoErr::MissingSegment("cyclos account id")); }; Ok(CyclosAccount { - id: CyclosId::from_str(id).map_err(PaytoErr::custom)?, + id: CyclosId::from_str(id) + .map_err(|e| PaytoErr::malformed_segment("cyclos account id", e))?, root: CompactString::new(root), }) } diff --git a/taler-magnet-bank/src/config.rs b/taler-magnet-bank/src/config.rs @@ -96,8 +96,8 @@ impl WorkerCfg { payto, frequency: sect.duration("FREQUENCY").require()?, account_type: map_config!(sect, "account type", "ACCOUNT_TYPE", - "exchange" => { Ok(AccountType::Exchange) }, - "normal" => { Ok(AccountType::Normal) } + "exchange" => { AccountType::Exchange }, + "normal" => { AccountType::Normal } ) .require()?, api_url: sect.base_url("API_URL").require()?, diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -486,8 +486,8 @@ pub async fn transfer_page( ) -> sqlx::Result<Vec<TransferListStatus>> { page( db, - "initiated_id", params, + "initiated_id", || { let mut builder = QueryBuilder::new( " diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs @@ -162,8 +162,9 @@ impl PaytoImpl for HuIban { } fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { - let iban_payto = IbanPayto::try_from(raw).map_err(PaytoErr::custom)?; - Self::try_from(iban_payto.into_inner().iban).map_err(PaytoErr::custom) + let iban_payto = IbanPayto::try_from(raw)?; + Self::try_from(iban_payto.into_inner().iban) + .map_err(|e| PaytoErr::malformed_segment("iban", e)) } }