taler-rust

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

commit d59c00bf5c8ac23ee4eb4b01c2e7fdbed7822c76
parent 4f7360d5c53e3e7fb0dbafcee8af43aa073c20ee
Author: Antoine A <>
Date:   Sat, 11 Apr 2026 11:44:03 +0200

common: make more types copy and add enum metadata macro

Diffstat:
MCargo.toml | 2++
MMakefile | 2+-
Mcommon/taler-api/src/api/transfer.rs | 2+-
Mcommon/taler-api/tests/common/db.rs | 2+-
Mcommon/taler-common/src/types/amount.rs | 25++++++++++++++++++++++---
Mcommon/taler-common/src/types/iban.rs | 10+++++++---
Mcommon/taler-common/src/types/payto.rs | 9+++++++++
Acommon/taler-enum-meta/Cargo.toml | 18++++++++++++++++++
Acommon/taler-enum-meta/src/lib.rs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-apns-relay/Cargo.toml | 2+-
Mtaler-apns-relay/src/apns.rs | 82++++++++++++++++++++++++++++++++++---------------------------------------------
Mtaler-magnet-bank/src/db.rs | 2+-
12 files changed, 279 insertions(+), 58 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "common/taler-test-utils", "common/failure-injection", "common/http-client", + "common/taler-enum-meta", "taler-magnet-bank", "taler-cyclos", "taler-apns-relay", @@ -54,6 +55,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" } failure-injection = { path = "common/failure-injection" } http-client = { path = "common/http-client" } hyper = { version = "1.8.1", features = ["client", "http1", "http2"] } diff --git a/Makefile b/Makefile @@ -43,7 +43,7 @@ install: build install-nobuild-files install -D -t $(bin_dir) target/release/taler-apns-relay .PHONY: check -check: install-nobuild-files +check: install cargo clippy --all-targets cargo test diff --git a/common/taler-api/src/api/transfer.rs b/common/taler-api/src/api/transfer.rs @@ -21,7 +21,7 @@ use axum::{ extract::State, http::StatusCode, response::IntoResponse as _, - routing::{delete, get, post}, + routing::{get, post}, }; use jiff::{SignedDuration, Timestamp}; use taler_common::{ diff --git a/common/taler-api/tests/common/db.rs b/common/taler-api/tests/common/db.rs @@ -68,7 +68,7 @@ pub async fn transfer(db: &PgPool, req: &TransferRequest) -> sqlx::Result<Transf FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8) ", ) - .bind(&req.amount) + .bind(req.amount) .bind(req.exchange_base_url.as_str()) .bind(&req.metadata) .bind(&subject) diff --git a/common/taler-common/src/types/amount.rs b/common/taler-common/src/types/amount.rs @@ -108,7 +108,14 @@ struct PgTalerAmount { } #[derive( - Debug, Clone, Copy, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + serde_with::DeserializeFromStr, + serde_with::SerializeDisplay, )] pub struct Decimal { /** Integer part */ @@ -232,6 +239,12 @@ impl Display for Decimal { } } +impl Debug for Decimal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + impl sqlx::Type<sqlx::Postgres> for Decimal { fn type_info() -> sqlx::postgres::PgTypeInfo { PgTalerAmount::type_info() @@ -268,7 +281,7 @@ pub fn decimal(decimal: impl AsRef<str>) -> Decimal { /// <https://docs.taler.net/core/api-common.html#tsref-type-Amount> #[derive( - Debug, Clone, Copy, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, + Clone, Copy, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, )] pub struct Amount { pub currency: Currency, @@ -378,6 +391,12 @@ impl Display for Amount { } } +impl Debug for Amount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + impl sqlx::Type<sqlx::Postgres> for Amount { fn type_info() -> sqlx::postgres::PgTypeInfo { PgTalerAmount::type_info() @@ -504,6 +523,6 @@ fn test_amount_normalize() { assert_eq!(Amount::new(&eur, MAX_VALUE, FRAC_BASE).normalize(), None); for amount in [Amount::max(&eur), Amount::zero(&eur)] { - assert_eq!(amount.clone().normalize(), Some(amount)) + assert_eq!(amount.normalize(), Some(amount)) } } diff --git a/common/taler-common/src/types/iban.rs b/common/taler-common/src/types/iban.rs @@ -40,9 +40,7 @@ pub fn bic(bic: impl AsRef<str>) -> BIC { bic.as_ref().parse().expect("invalid BIC") } -#[derive( - Debug, Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, -)] +#[derive(Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay)] /// International Bank Account Number (IBAN) pub struct IBAN { country: Country, @@ -200,6 +198,12 @@ impl Display for IBAN { } } +impl Debug for IBAN { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + /// Bank Identifier Code (BIC) #[derive( Debug, Clone, PartialEq, Eq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay, diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs @@ -35,6 +35,15 @@ pub fn payto(url: impl AsRef<str>) -> PaytoURI { url.as_ref().parse().expect("invalid payto") } +/// Generate an iban payto URI, panic if malformed +pub fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { + IbanPayto::new(BankID { + iban: iban.as_ref().parse().expect("invalid IBAN"), + bic: None, + }) + .as_full_payto(name.as_ref()) +} + pub trait PaytoImpl: Sized { fn as_payto(&self) -> PaytoURI; fn as_full_payto(&self, name: &str) -> PaytoURI { diff --git a/common/taler-enum-meta/Cargo.toml b/common/taler-enum-meta/Cargo.toml @@ -0,0 +1,18 @@ +[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-enum-meta/src/lib.rs b/common/taler-enum-meta/src/lib.rs @@ -0,0 +1,181 @@ +/* + This file is part of TALER + Copyright (C) 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 proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Error, Expr, Lit, Meta, parse_macro_input}; + +#[proc_macro_derive(EnumMeta, attributes(enum_meta, code))] +pub fn derive_domain_code(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + + // Parse features + let mut enabled_doc = false; + let mut enabled_code = false; + let mut enabled_str = false; + + for attr in &input.attrs { + if attr.path().is_ident("enum_meta") + && let Err(e) = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("Description") { + enabled_doc = true; + } else if meta.path.is_ident("DomainCode") { + enabled_code = true; + } else if meta.path.is_ident("Str") { + enabled_str = true; + } else { + return Err(meta.error("unknown enum_meta option")); + } + Ok(()) + }) + { + return e.to_compile_error().into(); + } + } + + let variants = if let Data::Enum(data) = &input.data { + &data.variants + } else { + return Error::new(input.ident.span(), "EnumMeta only supports enums") + .to_compile_error() + .into(); + }; + + // Helper: extract the first string literal from a name-value attribute. + let extract_str_attr = |variant: &syn::Variant, ident: &str| -> Option<String> { + variant.attrs.iter().find_map(|a| { + if a.path().is_ident(ident) + && let Meta::NameValue(nv) = &a.meta + && let Expr::Lit(expr) = &nv.value + && let Lit::Str(s) = &expr.lit + { + Some(s.value()) + } else { + None + } + }) + }; + + let mut description_arms = Vec::new(); + let mut code_arms = Vec::new(); + let mut from_str_arms = Vec::new(); + let mut as_ref_arms = Vec::new(); + + for variant in variants { + let v_ident = &variant.ident; + let v_str = v_ident.to_string(); + + // Single pass: collect doc and code in one go, then use what's needed. + let doc = + enabled_doc.then(|| extract_str_attr(variant, "doc").map(|s| s.trim().to_string())); + let code = (enabled_code).then(|| extract_str_attr(variant, "code")); + + if let Some(doc) = doc { + let doc = match doc { + Some(d) => d, + None => { + return Error::new( + v_ident.span(), + format!("variant `{v_str}` is missing `/// documentation`"), + ) + .to_compile_error() + .into(); + } + }; + description_arms.push(quote! { Self::#v_ident => #doc }); + } + + if let Some(code) = code { + let code = match code { + Some(c) => c, + None => { + return Error::new( + v_ident.span(), + format!("variant `{v_str}` is missing `#[code = \"...\"]`"), + ) + .to_compile_error() + .into(); + } + }; + from_str_arms.push(quote! { #code => Ok(Self::#v_ident) }); + code_arms.push(quote! { Self::#v_ident => #code }); + } else if enabled_str { + from_str_arms.push(quote! { #v_str => Ok(Self::#v_ident) }); + } + + if enabled_str { + as_ref_arms.push(quote! { Self::#v_ident => #v_str }); + } + } + + let mut expanded = quote! {}; + + if enabled_doc { + expanded.extend(quote! { + impl #name { + /// Returns the documentation description associated + pub fn description(&self) -> &'static str { + match self { #(#description_arms),* } + } + } + }); + } + + if enabled_code { + expanded.extend(quote! { + impl #name { + /// Returns the domain code associated + pub fn code(&self) -> &'static str { + match self { #(#code_arms),* } + } + } + }); + } + + if enabled_str { + expanded.extend(quote! { + impl AsRef<str> for #name { + fn as_ref(&self) -> &str { + match self { #(#as_ref_arms),* } + } + } + + impl std::fmt::Display for #name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_ref()) + } + } + }); + } + + if enabled_code || enabled_str { + let unknown_label = if enabled_code { "code" } else { "name" }; + expanded.extend(quote! { + impl std::str::FromStr for #name { + type Err = String; + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + #(#from_str_arms,)* + _ => Err(format!("Unknown {0} for {1}: {2}", #unknown_label, stringify!(#name), s)) + } + } + } + }); + } + + TokenStream::from(expanded) +} diff --git a/taler-apns-relay/Cargo.toml b/taler-apns-relay/Cargo.toml @@ -18,6 +18,7 @@ axum.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true +taler-enum-meta.workspace = true anyhow.workspace = true clap.workspace = true hyper.workspace = true @@ -33,7 +34,6 @@ base64.workspace = true compact_str.workspace = true tracing.workspace = true thiserror.workspace = true -strum_macros = "0.28" rustls-pki-types = "1" [dev-dependencies] diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -32,6 +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 crate::config::ApnsConfig; @@ -43,89 +44,76 @@ pub struct ApnsErrorBody { pub timestamp: Option<u64>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::AsRefStr, strum_macros::Display)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] pub enum Reason { + /// The collapse identifier exceeds the maximum allowed size BadCollapseId, + /// The specified device token is invalid BadDeviceToken, + /// The apns-expiration value is invalid BadExpirationDate, + /// The apns-id value is invalid BadMessageId, + /// The apns-priority value is invalid BadPriority, + /// The apns-topic value is invalid BadTopic, + /// The device token doesn't match the specified topic DeviceTokenNotForTopic, + /// One or more headers are repeated DuplicateHeaders, + /// Idle timeout IdleTimeout, + /// The apns-push-type value is invalid InvalidPushType, + /// The device token isn't specified in the request :path MissingDeviceToken, + /// The apns-topic header is missing and required MissingTopic, + /// The message payload is empty PayloadEmpty, + /// Pushing to this topic is not allowed TopicDisallowed, + /// The certificate is invalid BadCertificate, + /// The client certificate doesn't match the environment BadCertificateEnvironment, + /// The provider token is stale ExpiredProviderToken, + /// The specified action is not allowed Forbidden, + /// The provider token is not valid InvalidProviderToken, + /// No provider certificate or token was specified MissingProviderToken, + /// The key ID in the provider token is unrelated to this connection UnrelatedKeyIdInToken, + /// The key ID in the provider token doesn’t match the environment BadEnvironmentKeyIdInToken, + /// The request contained an invalid :path value BadPath, + /// The specified :method value isn't POST MethodNotAllowed, + /// The device token has expired ExpiredToken, + /// The device token is inactive for the specified topic Unregistered, + /// The message payload is too large PayloadTooLarge, + /// The authentication token is being updated too often TooManyProviderTokenUpdates, + /// Too many requests to the same device token TooManyRequests, + /// An internal server error InternalServerError, + /// The service is unavailable ServiceUnavailable, + /// The APNs server is shutting down Shutdown, } impl Reason { - /// Returns the documentation description associated with the error - pub fn description(&self) -> &'static str { - match self { - Self::BadCollapseId => "The collapse identifier exceeds the maximum allowed size", - Self::BadDeviceToken => "The specified device token is invalid", - Self::BadExpirationDate => "The apns-expiration value is invalid", - Self::BadMessageId => "The apns-id value is invalid", - Self::BadPriority => "The apns-priority value is invalid", - Self::BadTopic => "The apns-topic value is invalid", - Self::DeviceTokenNotForTopic => "The device token doesn't match the specified topic", - Self::DuplicateHeaders => "One or more headers are repeated", - Self::IdleTimeout => "Idle timeout", - Self::InvalidPushType => "The apns-push-type value is invalid", - Self::MissingDeviceToken => "The device token isn't specified in the request :path", - Self::MissingTopic => "The apns-topic header is missing and required", - Self::PayloadEmpty => "The message payload is empty", - Self::TopicDisallowed => "Pushing to this topic is not allowed", - Self::BadCertificate => "The certificate is invalid", - Self::BadCertificateEnvironment => { - "The client certificate doesn't match the environment" - } - Self::ExpiredProviderToken => "The provider token is stale", - Self::Forbidden => "The specified action is not allowed", - Self::InvalidProviderToken => "The provider token is not valid", - Self::MissingProviderToken => "No provider certificate or token was specified", - Self::UnrelatedKeyIdInToken => { - "The key ID in the provider token is unrelated to this connection" - } - Self::BadEnvironmentKeyIdInToken => { - "The key ID in the provider token doesn’t match the environment" - } - Self::BadPath => "The request contained an invalid :path value", - Self::MethodNotAllowed => "The specified :method value isn't POST", - Self::ExpiredToken => "The device token has expired", - Self::Unregistered => "The device token is inactive for the specified topic", - Self::PayloadTooLarge => "The message payload is too large", - Self::TooManyProviderTokenUpdates => { - "The authentication token is being updated too often" - } - Self::TooManyRequests => "Too many requests to the same device token", - Self::InternalServerError => "An internal server error occurred", - Self::ServiceUnavailable => "The service is unavailable", - Self::Shutdown => "The APNs server is shutting down", - } - } - /// Returns the HTTP status code associated with the error pub fn status_code(&self) -> u16 { match self { diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -1521,7 +1521,7 @@ mod test { &mut db, &TxIn { code: 12, - amount: amount.clone(), + amount, subject: "subject".into(), debtor: payto.clone(), value_date: date,