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