Compare commits
12 Commits
51246ab378
...
8013f2ad61
| Author | SHA1 | Date | |
|---|---|---|---|
| 8013f2ad61 | |||
| a0d5bae160 | |||
| f526d9ab2b | |||
| 5b3a29f615 | |||
| 49291002ac | |||
| 6f3c5ef106 | |||
| d57eeef78f | |||
| a6625fc55c | |||
| 9dc8166225 | |||
| 716223e45c | |||
| 8f18b8692f | |||
| e99feace0e |
238
Cargo.lock
generated
238
Cargo.lock
generated
@@ -752,6 +752,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -775,7 +785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -788,7 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1175,6 +1185,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1328,6 +1347,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"log",
|
||||
"migration",
|
||||
"reqwest 0.12.28",
|
||||
"rust_decimal",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -1853,6 +1873,25 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2000,6 +2039,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -2011,6 +2051,38 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2029,9 +2101,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3796,6 +3870,46 @@ dependencies = [
|
||||
"bytecheck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@@ -3830,6 +3944,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
@@ -3917,6 +4045,39 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -4179,7 +4340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4918,6 +5079,27 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4939,7 +5121,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -5024,7 +5206,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5427,6 +5609,26 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@@ -5770,6 +5972,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -6285,6 +6493,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -6339,6 +6558,15 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
||||
5
justfile
5
justfile
@@ -37,6 +37,11 @@ dev DISPLAY='1':
|
||||
# Check the start-vnc output for the correct DISPLAY value if you have multiple VNC sessions running
|
||||
DISPLAY=:{{DISPLAY}} pnpm tauri dev
|
||||
|
||||
test-cargo-integration:
|
||||
cargo test \
|
||||
--package finwise integration_tests \
|
||||
-- --ignored
|
||||
|
||||
# docker images for ci
|
||||
DOCKER_IMAGE_NAME := 'gitea.gwmc.dev/finwise/finwise-ci:latest'
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
@@ -29,13 +30,13 @@ thiserror = "2"
|
||||
rust_decimal = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
sha2 = "0.10"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
log = "0.4.29"
|
||||
struct_iterable = "0.1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
sea-orm = { workspace = true, features = ["mock"] }
|
||||
|
||||
[profile.dev]
|
||||
|
||||
51
src-tauri/src/commands/exchange_rate.rs
Normal file
51
src-tauri/src/commands/exchange_rate.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::exchange_rate::ExchangeRateAdapterInfo;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_exchange_rate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
from_currency: String,
|
||||
to_currency: String,
|
||||
) -> CommandResult<String> {
|
||||
let rate = state
|
||||
.exchange_rate_service()
|
||||
.get_exchange_rate(&from_currency, &to_currency)
|
||||
.await?;
|
||||
Ok(rate.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_supported_currencies(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<String>> {
|
||||
Ok(state
|
||||
.exchange_rate_service()
|
||||
.get_supported_currencies()
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_exchange_rate_adapters(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<ExchangeRateAdapterInfo>> {
|
||||
Ok(state.exchange_rate_service().get_available_adapters().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_exchange_rate_adapter(
|
||||
state: tauri::State<'_, AppState>,
|
||||
adapter_name: String,
|
||||
) -> CommandResult<()> {
|
||||
state
|
||||
.exchange_rate_service()
|
||||
.set_adapter(&adapter_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_exchange_rate_adapter(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<String> {
|
||||
state.exchange_rate_service().get_current_adapter().await
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
|
||||
@@ -14,6 +14,9 @@ pub enum AppError {
|
||||
#[error("Invalid amount: {0}")]
|
||||
InvalidAmount(String),
|
||||
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
|
||||
#[error("Currency mismatch: expected {expected}, got {actual}")]
|
||||
CurrencyMismatch { expected: String, actual: String },
|
||||
|
||||
@@ -38,6 +41,7 @@ enum ErrorKind {
|
||||
Validation(String),
|
||||
NotFound(String),
|
||||
InvalidAmount(String),
|
||||
InvalidData(String),
|
||||
CurrencyMismatch { expected: String, actual: String },
|
||||
Io(String),
|
||||
Serialization(String),
|
||||
@@ -57,6 +61,7 @@ impl serde::Serialize for AppError {
|
||||
Self::Validation(_) => ErrorKind::Validation(error_message),
|
||||
Self::NotFound(_) => ErrorKind::NotFound(error_message),
|
||||
Self::InvalidAmount(_) => ErrorKind::InvalidAmount(error_message),
|
||||
Self::InvalidData(_) => ErrorKind::InvalidData(error_message),
|
||||
Self::CurrencyMismatch { expected, actual } => ErrorKind::CurrencyMismatch {
|
||||
expected: expected.clone(),
|
||||
actual: actual.clone(),
|
||||
|
||||
@@ -20,7 +20,8 @@ async fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error
|
||||
|
||||
// Establish database connection
|
||||
let db = db::service::DbService::new(app_handle).await?;
|
||||
let services = services::ServiceFactory::create_services(db.get_connection().clone());
|
||||
let services: services::ServiceFactoryResult =
|
||||
services::ServiceFactory::create_services(db.get_connection().clone()).await;
|
||||
|
||||
// Create app state with all services
|
||||
let app_state = AppState::new(db, services).await;
|
||||
@@ -64,6 +65,12 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Exchange rate commands
|
||||
commands::exchange_rate::get_exchange_rate,
|
||||
commands::exchange_rate::get_supported_currencies,
|
||||
commands::exchange_rate::get_available_exchange_rate_adapters,
|
||||
commands::exchange_rate::set_exchange_rate_adapter,
|
||||
commands::exchange_rate::get_current_exchange_rate_adapter,
|
||||
// Settings commands
|
||||
commands::settings::get_settings,
|
||||
commands::settings::update_setting,
|
||||
|
||||
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal file
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
// Based on https://github.com/fawazahmed0/exchange-api
|
||||
// API: https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{currency}.json
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::ExchangeRateAdapter;
|
||||
use crate::{
|
||||
errors::{AppError, CommandResult},
|
||||
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
};
|
||||
|
||||
const API_BASE_URL: &str =
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies";
|
||||
const ADAPTER_NAME: &str = "exchange_api";
|
||||
const ADAPTER_DISPLAY_NAME: &str = "Exchange API";
|
||||
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates.";
|
||||
|
||||
pub(super) struct ExchangeApiAdapter;
|
||||
|
||||
impl ExchangeApiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[cfg_attr(test, derive(serde::Serialize))]
|
||||
/// {date: "2024-06-01", 'currency_code': {"target_currency_code": rate, ...}}
|
||||
pub(super) struct ExchangeRateResponse {
|
||||
pub date: String,
|
||||
// currency code to exchange rate mapping
|
||||
#[serde(flatten)]
|
||||
pub rates: HashMap<String, HashMap<String, f64>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for ExchangeApiAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: ADAPTER_NAME.to_string(),
|
||||
display_name: ADAPTER_DISPLAY_NAME.to_string(),
|
||||
description: ADAPTER_DESCRIPTION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
|
||||
// The API supports a wide range of currencies. We'll fetch from a common base
|
||||
// and extract the available currency codes.
|
||||
// As a fallback, return a comprehensive list of common currencies.
|
||||
let base = base_currency.unwrap_or("usd").to_lowercase();
|
||||
match fetch_currency_data(&base).await {
|
||||
Ok(data) => {
|
||||
let mut currencies: Vec<String> = data
|
||||
.rates
|
||||
.get(&base)
|
||||
.map(|rates| rates.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
currencies.sort();
|
||||
currencies
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to fetch supported currencies: {}", err);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
let from_lower = from_currency.to_lowercase();
|
||||
let to_lower = to_currency.to_lowercase();
|
||||
|
||||
let data = fetch_currency_data(&from_lower).await?;
|
||||
|
||||
// Look up the rate for the target currency
|
||||
if let Some(rates) = data.rates.get(&from_lower) {
|
||||
if let Some(rate) = rates.get(&to_lower) {
|
||||
// Convert f64 to Decimal for precision
|
||||
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
|
||||
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
|
||||
})?;
|
||||
// Convert date string to unix timestamp (using the date of the API response)
|
||||
let timestamp = chrono::NaiveDate::parse_from_str(&data.date, "%Y-%m-%d")
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to parse date from API response: {}", e))
|
||||
})?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to create datetime from date: {}",
|
||||
data.date
|
||||
))
|
||||
})?
|
||||
.and_utc()
|
||||
.timestamp() as u64;
|
||||
return Ok((decimal_rate, timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::NotFound(format!(
|
||||
"Exchange rate not found for {}/{}",
|
||||
from_currency, to_currency
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_currency_data(currency: &str) -> CommandResult<ExchangeRateResponse> {
|
||||
let url = format!("{}/{}.min.json", API_BASE_URL, currency);
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rate: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Exchange rate API returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let data: ExchangeRateResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_adapter_new() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
// Just verify it can be created - the struct has no fields
|
||||
let _ = adapter;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_info() {
|
||||
let info = ExchangeApiAdapter::get_info();
|
||||
assert_eq!(info.name, ADAPTER_NAME);
|
||||
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
|
||||
assert_eq!(info.description, ADAPTER_DESCRIPTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_deserialization() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"usd": {
|
||||
"eur": 0.92,
|
||||
"gbp": 0.79,
|
||||
"jpy": 148.50
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
assert!(response.rates.contains_key("usd"));
|
||||
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert_eq!(usd_rates.get("eur"), Some(&0.92));
|
||||
assert_eq!(usd_rates.get("gbp"), Some(&0.79));
|
||||
assert_eq!(usd_rates.get("jpy"), Some(&148.50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_many_currencies() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"eur": {
|
||||
"usd": 1.09,
|
||||
"gbp": 0.86,
|
||||
"jpy": 161.20,
|
||||
"cad": 1.47,
|
||||
"aud": 1.65,
|
||||
"chf": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
|
||||
let eur_rates = response.rates.get("eur").expect("EUR rates not found");
|
||||
assert_eq!(eur_rates.len(), 6);
|
||||
assert!(eur_rates.contains_key("usd"));
|
||||
assert!(eur_rates.contains_key("jpy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_empty_rates() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"usd": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert!(usd_rates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_rate_parsing() {
|
||||
// Test that we can parse rates with high precision
|
||||
let rate_str = "0.921234567890";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test with larger rate
|
||||
let large_rate_str = "148.5012345678";
|
||||
let large_rate =
|
||||
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large decimal rate");
|
||||
assert_eq!(large_rate.to_string(), large_rate_str);
|
||||
|
||||
// Test with very small rate
|
||||
let small_rate_str = "0.00000001";
|
||||
let small_rate =
|
||||
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small decimal rate");
|
||||
assert_eq!(small_rate.to_string(), small_rate_str);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_date_parsing() {
|
||||
let date_str = "2024-01-15";
|
||||
let parsed_date =
|
||||
chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").expect("Failed to parse date");
|
||||
|
||||
assert_eq!(parsed_date.year(), 2024);
|
||||
assert_eq!(parsed_date.month(), 1);
|
||||
assert_eq!(parsed_date.day(), 15);
|
||||
|
||||
// Test conversion to datetime
|
||||
let datetime = parsed_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("Failed to create datetime")
|
||||
.and_utc();
|
||||
|
||||
assert_eq!(datetime.timestamp() as u64, 1705276800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_date_parsing_different_formats() {
|
||||
// Test year boundary
|
||||
let date_str = "2024-12-31";
|
||||
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.expect("Failed to parse year boundary date");
|
||||
assert_eq!(parsed.year(), 2024);
|
||||
assert_eq!(parsed.month(), 12);
|
||||
assert_eq!(parsed.day(), 31);
|
||||
|
||||
// Test leap year
|
||||
let date_str = "2024-02-29";
|
||||
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.expect("Failed to parse leap year date");
|
||||
assert_eq!(parsed.month(), 2);
|
||||
assert_eq!(parsed.day(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_serde_roundtrip() {
|
||||
let mut rates = HashMap::new();
|
||||
let mut inner_rates = HashMap::new();
|
||||
inner_rates.insert("eur".to_string(), 0.92_f64);
|
||||
inner_rates.insert("gbp".to_string(), 0.79_f64);
|
||||
rates.insert("usd".to_string(), inner_rates);
|
||||
|
||||
let response = ExchangeRateResponse {
|
||||
date: "2024-01-15".to_string(),
|
||||
rates,
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).expect("Failed to serialize ExchangeRateResponse");
|
||||
let deserialized: ExchangeRateResponse =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize ExchangeRateResponse");
|
||||
|
||||
assert_eq!(deserialized.date, response.date);
|
||||
assert!(deserialized.rates.contains_key("usd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_implements_exchange_rate_adapter() {
|
||||
// Compile-time check that ExchangeApiAdapter implements ExchangeRateAdapter
|
||||
fn check_adapter<T: ExchangeRateAdapter>() {}
|
||||
check_adapter::<ExchangeApiAdapter>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_debug() {
|
||||
let json_data = r#"{"date": "2024-01-15", "usd": {"eur": 0.92}}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
|
||||
let debug_str = format!("{:?}", response);
|
||||
assert!(debug_str.contains("2024-01-15"));
|
||||
assert!(debug_str.contains("usd"));
|
||||
}
|
||||
|
||||
/// Integration tests that call the actual API
|
||||
/// These tests are marked with #[ignore] to avoid running them in CI
|
||||
/// Run with: cargo test --package tauri-app -- --ignored
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_currency_data_real_api() {
|
||||
let result = fetch_currency_data("usd").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch currency data: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
assert!(!data.date.is_empty(), "Date should not be empty");
|
||||
assert!(!data.rates.is_empty(), "Rates should not be empty");
|
||||
|
||||
// Verify USD rates exist
|
||||
assert!(data.rates.contains_key("usd"), "USD rates should exist");
|
||||
let usd_rates = data.rates.get("usd").expect("USD rates not found");
|
||||
|
||||
// Verify common currencies exist in USD rates
|
||||
assert!(usd_rates.contains_key("eur"), "EUR rate should exist");
|
||||
assert!(usd_rates.contains_key("gbp"), "GBP rate should exist");
|
||||
assert!(usd_rates.contains_key("jpy"), "JPY rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_currency_data_eur_base() {
|
||||
let result = fetch_currency_data("eur").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch EUR currency data: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
assert!(data.rates.contains_key("eur"), "EUR rates should exist");
|
||||
|
||||
let eur_rates = data.rates.get("eur").expect("EUR rates not found");
|
||||
assert!(eur_rates.contains_key("usd"), "USD rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_exchange_rate_real_api() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
let result = adapter.get_exchange_rate("usd", "eur").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to get exchange rate: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let (rate, timestamp) = result.expect("Failed to get exchange rate");
|
||||
assert!(rate > Decimal::ZERO, "Rate should be positive");
|
||||
assert!(timestamp > 0, "Timestamp should be valid");
|
||||
|
||||
// USD to EUR should be less than 1
|
||||
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_supported_currencies_real_api() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("usd")).await;
|
||||
assert!(!currencies.is_empty(), "Should have supported currencies");
|
||||
|
||||
// Check for common currencies
|
||||
assert!(
|
||||
currencies.contains(&"eur".to_string()),
|
||||
"Should contain eur"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"gbp".to_string()),
|
||||
"Should contain gbp"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"jpy".to_string()),
|
||||
"Should contain jpy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_currency_conversion_roundtrip() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
// Get USD to EUR rate
|
||||
let (usd_to_eur, _) = adapter
|
||||
.get_exchange_rate("usd", "eur")
|
||||
.await
|
||||
.expect("Failed to get USD to EUR rate");
|
||||
|
||||
// Get EUR to USD rate
|
||||
let (eur_to_usd, _) = adapter
|
||||
.get_exchange_rate("eur", "usd")
|
||||
.await
|
||||
.expect("Failed to get EUR to USD rate");
|
||||
|
||||
// The product of the two rates should be approximately 1
|
||||
let product = usd_to_eur * eur_to_usd;
|
||||
let one = Decimal::ONE;
|
||||
let diff = (product - one).abs();
|
||||
|
||||
// Allow for small differences (0.5% tolerance) due to API rate variations
|
||||
assert!(
|
||||
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
|
||||
"Roundtrip conversion should be approximately 1, got: {}",
|
||||
product
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_real_rate_decimal_parsing() {
|
||||
let result = fetch_currency_data("usd").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
let usd_rates = data.rates.get("usd").expect("USD rates not found");
|
||||
|
||||
// Verify all rates can be converted to Decimal
|
||||
for (currency, rate_f64) in usd_rates {
|
||||
let rate = Decimal::from_f64_retain(*rate_f64);
|
||||
assert!(
|
||||
rate.is_some(),
|
||||
"Failed to convert rate for {}: {}",
|
||||
currency,
|
||||
rate_f64
|
||||
);
|
||||
|
||||
let rate = rate.expect("Decimal conversion failed");
|
||||
assert!(
|
||||
rate > Decimal::ZERO,
|
||||
"Rate for {} should be positive",
|
||||
currency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
// Based on https://www.exchangerate-api.com/docs/free
|
||||
// API: https://api.exchangerate-api.com/v4/latest/{base_currency}
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::ExchangeRateAdapter;
|
||||
use crate::{
|
||||
errors::{AppError, CommandResult},
|
||||
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
};
|
||||
|
||||
const API_BASE_URL: &str = "https://api.exchangerate-api.com/v4/latest";
|
||||
const ADAPTER_NAME: &str = "exchange_rate_api";
|
||||
const ADAPTER_DISPLAY_NAME: &str = "ExchangeRate-API";
|
||||
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates. {\"tag\": \"<a href='https://www.exchangerate-api.com'>Rates By Exchange Rate API</a>\"}";
|
||||
|
||||
pub(super) struct ExchangeRateApiAdapter;
|
||||
|
||||
impl ExchangeRateApiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[cfg_attr(test, derive(serde::Serialize))]
|
||||
pub(super) struct ExchangeRateResponse {
|
||||
#[serde(rename = "base")]
|
||||
pub base_code: String,
|
||||
#[serde(rename = "time_last_updated")]
|
||||
pub time_last_update_unix: u64,
|
||||
// currency code to exchange rate mapping
|
||||
pub rates: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for ExchangeRateApiAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: ADAPTER_NAME.to_string(),
|
||||
display_name: ADAPTER_DISPLAY_NAME.to_string(),
|
||||
description: ADAPTER_DESCRIPTION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
|
||||
// Fetch supported currencies by getting rates for USD
|
||||
let base = base_currency.unwrap_or("USD").to_uppercase();
|
||||
match fetch_exchange_rates(&base).await {
|
||||
Ok(data) => {
|
||||
let mut currencies: Vec<String> = data.rates.keys().cloned().collect();
|
||||
currencies.sort();
|
||||
currencies
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to fetch supported currencies: {}", err);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
let from_upper = from_currency.to_uppercase();
|
||||
let to_upper = to_currency.to_uppercase();
|
||||
|
||||
let data = fetch_exchange_rates(&from_upper).await?;
|
||||
|
||||
// Look up the rate for the target currency
|
||||
if let Some(rate) = data.rates.get(&to_upper) {
|
||||
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
|
||||
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
|
||||
})?;
|
||||
Ok((decimal_rate, data.time_last_update_unix))
|
||||
} else {
|
||||
Err(AppError::NotFound(format!(
|
||||
"Exchange rate not found for {}/{}",
|
||||
from_currency, to_currency
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_exchange_rates(base_currency: &str) -> CommandResult<ExchangeRateResponse> {
|
||||
let url = format!("{}/{}", API_BASE_URL, base_currency.to_uppercase());
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rates: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Exchange rate API returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let data: ExchangeRateResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_adapter_new() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
// Just verify it can be created - the struct has no fields
|
||||
let _ = adapter;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_info() {
|
||||
let info = ExchangeRateApiAdapter::get_info();
|
||||
assert_eq!(info.name, ADAPTER_NAME);
|
||||
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
|
||||
assert_eq!(info.description, ADAPTER_DESCRIPTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_deserialization() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"EUR": 0.92,
|
||||
"GBP": 0.79,
|
||||
"JPY": 148.50
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert_eq!(response.time_last_update_unix, 1704067200);
|
||||
assert_eq!(response.rates.len(), 3);
|
||||
|
||||
assert_eq!(response.rates.get("EUR"), Some(&0.92_f64));
|
||||
assert_eq!(response.rates.get("GBP"), Some(&0.79_f64));
|
||||
assert_eq!(response.rates.get("JPY"), Some(&148.50_f64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_many_currencies() {
|
||||
let json_data = r#"{
|
||||
"base": "EUR",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"USD": 1.09,
|
||||
"GBP": 0.86,
|
||||
"JPY": 161.20,
|
||||
"CAD": 1.47,
|
||||
"AUD": 1.65,
|
||||
"CHF": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "EUR");
|
||||
assert_eq!(response.rates.len(), 6);
|
||||
assert!(response.rates.contains_key("USD"));
|
||||
assert!(response.rates.contains_key("JPY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_empty_rates() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert!(response.rates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_precision_from_f64() {
|
||||
// Test that Decimal can represent rates from f64 precisely
|
||||
let f64_rate: f64 = 0.9215;
|
||||
let decimal_rate =
|
||||
Decimal::from_f64_retain(f64_rate).expect("Failed to retain f64 as Decimal");
|
||||
assert!(decimal_rate > Decimal::ZERO);
|
||||
|
||||
// Test with larger rate
|
||||
let large_f64: f64 = 148.50;
|
||||
let large_decimal =
|
||||
Decimal::from_f64_retain(large_f64).expect("Failed to retain f64 as Decimal");
|
||||
assert!(large_decimal > Decimal::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_case_normalization() {
|
||||
// The adapter should normalize currencies to uppercase
|
||||
let eur_lower = "eur";
|
||||
let eur_upper = "EUR";
|
||||
let eur_mixed = "Eur";
|
||||
|
||||
assert_eq!(eur_lower.to_uppercase(), "EUR");
|
||||
assert_eq!(eur_upper.to_uppercase(), "EUR");
|
||||
assert_eq!(eur_mixed.to_uppercase(), "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_unix_conversion() {
|
||||
let timestamp: u64 = 1704067200; // 2024-01-01 00:00:00 UTC
|
||||
|
||||
// Verify the timestamp can be converted to DateTime
|
||||
let dt = chrono::DateTime::from_timestamp(timestamp as i64, 0);
|
||||
assert!(dt.is_some());
|
||||
|
||||
let dt = dt.expect("DateTime conversion failed");
|
||||
assert_eq!(dt.year(), 2024);
|
||||
assert_eq!(dt.month(), 1);
|
||||
assert_eq!(dt.day(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_serde_roundtrip() {
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("EUR".to_string(), 0.92_f64);
|
||||
rates.insert("GBP".to_string(), 0.79_f64);
|
||||
|
||||
let response = ExchangeRateResponse {
|
||||
base_code: "USD".to_string(),
|
||||
time_last_update_unix: 1704067200,
|
||||
rates,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).expect("Failed to serialize JSON");
|
||||
let deserialized: ExchangeRateResponse =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize JSON");
|
||||
|
||||
assert_eq!(deserialized.base_code, response.base_code);
|
||||
assert_eq!(
|
||||
deserialized.time_last_update_unix,
|
||||
response.time_last_update_unix
|
||||
);
|
||||
assert_eq!(deserialized.rates.len(), response.rates.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_base_url_constant() {
|
||||
assert!(API_BASE_URL.contains("exchangerate-api.com"));
|
||||
assert!(API_BASE_URL.contains("v4/latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_debug() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {"EUR": 0.92}
|
||||
}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
|
||||
let debug_str = format!("{:?}", response);
|
||||
assert!(debug_str.contains("USD"));
|
||||
assert!(debug_str.contains("1704067200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_roundtrip_conversion() {
|
||||
// Test that Decimal -> String -> Decimal preserves value
|
||||
let original =
|
||||
Decimal::from_f64_retain(1.23456789).expect("Failed to retain f64 as Decimal");
|
||||
let as_string = original.to_string();
|
||||
let parsed = Decimal::from_str_exact(&as_string).expect("Failed to parse rate string");
|
||||
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_lookups() {
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("USD".to_string(), Decimal::ONE);
|
||||
rates.insert(
|
||||
"EUR".to_string(),
|
||||
Decimal::from_f64_retain(0.92).expect("Failed to retain f64 as Decimal"),
|
||||
);
|
||||
rates.insert(
|
||||
"GBP".to_string(),
|
||||
Decimal::from_f64_retain(0.79).expect("Failed to retain f64 as Decimal"),
|
||||
);
|
||||
|
||||
// Test that we can look up rates
|
||||
assert!(rates.contains_key("USD"));
|
||||
assert!(rates.contains_key("EUR"));
|
||||
assert!(!rates.contains_key("JPY"));
|
||||
|
||||
// Test getting specific rate
|
||||
let usd_rate = rates.get("USD").expect("USD rate not found");
|
||||
assert_eq!(*usd_rate, Decimal::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_special_currencies() {
|
||||
// Some currencies might have special handling
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"XBT": 0.000023,
|
||||
"XAU": 0.00042
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.rates.len(), 2);
|
||||
assert!(response.rates.contains_key("XBT"));
|
||||
assert!(response.rates.contains_key("XAU"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_from_str_exact_various_rates() {
|
||||
let test_cases = vec![
|
||||
"1.0",
|
||||
"0.5",
|
||||
"0.1234567890",
|
||||
"100.00",
|
||||
"0.000001",
|
||||
"999999.999999",
|
||||
];
|
||||
|
||||
for rate_str in test_cases {
|
||||
let result = Decimal::from_str_exact(rate_str);
|
||||
assert!(result.is_ok(), "Failed to parse: {}", rate_str);
|
||||
|
||||
let decimal = result.expect("Decimal parsing failed");
|
||||
assert_eq!(decimal.to_string(), rate_str);
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration tests that call the actual API
|
||||
/// These tests are marked with #[ignore] to avoid running them in CI
|
||||
/// Run with: cargo test --package tauri-app -- --ignored
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_exchange_rates_real_api() {
|
||||
let result = fetch_exchange_rates("USD").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch exchange rates: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get exchange rates");
|
||||
assert_eq!(data.base_code, "USD");
|
||||
assert!(!data.rates.is_empty(), "Rates should not be empty");
|
||||
assert!(data.time_last_update_unix > 0, "Timestamp should be valid");
|
||||
|
||||
// Verify common currencies exist
|
||||
assert!(data.rates.contains_key("EUR"), "EUR rate should exist");
|
||||
assert!(data.rates.contains_key("GBP"), "GBP rate should exist");
|
||||
assert!(data.rates.contains_key("JPY"), "JPY rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_exchange_rates_eur_base() {
|
||||
let result = fetch_exchange_rates("EUR").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch EUR-based rates: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get exchange rates");
|
||||
assert_eq!(data.base_code, "EUR");
|
||||
assert!(data.rates.contains_key("USD"), "USD rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_exchange_rate_real_api() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "EUR").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to get exchange rate: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let (rate, timestamp) = result.expect("Failed to get exchange rate");
|
||||
assert!(rate > Decimal::ZERO, "Rate should be positive");
|
||||
assert!(timestamp > 0, "Timestamp should be valid");
|
||||
|
||||
// USD to EUR should be less than 1
|
||||
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_supported_currencies_real_api() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
assert!(!currencies.is_empty(), "Should have supported currencies");
|
||||
|
||||
// Check for common currencies
|
||||
assert!(
|
||||
currencies.contains(&"USD".to_string()),
|
||||
"Should contain USD"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"EUR".to_string()),
|
||||
"Should contain EUR"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"GBP".to_string()),
|
||||
"Should contain GBP"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"JPY".to_string()),
|
||||
"Should contain JPY"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_currency_conversion_roundtrip() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
// Get USD to EUR rate
|
||||
let (usd_to_eur, _) = adapter
|
||||
.get_exchange_rate("USD", "EUR")
|
||||
.await
|
||||
.expect("Failed to get USD to EUR rate");
|
||||
|
||||
// Get EUR to USD rate
|
||||
let (eur_to_usd, _) = adapter
|
||||
.get_exchange_rate("EUR", "USD")
|
||||
.await
|
||||
.expect("Failed to get EUR to USD rate");
|
||||
|
||||
// The product of the two rates should be approximately 1
|
||||
let product = usd_to_eur * eur_to_usd;
|
||||
let one = Decimal::ONE;
|
||||
let diff = (product - one).abs();
|
||||
|
||||
// Allow for small floating point differences (0.5% tolerance for real-world rates)
|
||||
assert!(
|
||||
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
|
||||
"Roundtrip conversion should be approximately 1, got: {}",
|
||||
product
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal file
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::CommandResult;
|
||||
|
||||
pub(super) mod exchange_api;
|
||||
pub(super) mod exchange_rate_api;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ExchangeRateAdapterInfo {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExchangeRateAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Get the exchange rate from `from_currency` to `to_currency`.
|
||||
/// Returns the exchange rate and the timestamp of the last update.
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(rust_decimal::Decimal, u64)>;
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String>;
|
||||
}
|
||||
|
||||
pub fn get_default_adapter() -> Arc<dyn ExchangeRateAdapter + Send + Sync> {
|
||||
Arc::new(exchange_api::ExchangeApiAdapter::new())
|
||||
}
|
||||
|
||||
pub fn get_adapter_info() -> Vec<ExchangeRateAdapterInfo> {
|
||||
vec![
|
||||
exchange_api::ExchangeApiAdapter::get_info(),
|
||||
exchange_rate_api::ExchangeRateApiAdapter::get_info(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_adapter_by_name(name: &str) -> Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> {
|
||||
match name {
|
||||
"exchange_api" => Some(Arc::new(exchange_api::ExchangeApiAdapter::new())),
|
||||
"exchange_rate_api" => Some(Arc::new(exchange_rate_api::ExchangeRateApiAdapter::new())),
|
||||
_ => {
|
||||
log::warn!("Unknown adapter name: {}", name);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_info_returns_both_adapters() {
|
||||
let names = get_adapter_info()
|
||||
.iter()
|
||||
.map(|info| info.name.clone())
|
||||
.collect::<Vec<String>>();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"exchange_api".to_string()));
|
||||
assert!(names.contains(&"exchange_rate_api".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_api() {
|
||||
let adapter = get_adapter_by_name("exchange_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_rate_api() {
|
||||
let adapter = get_adapter_by_name("exchange_rate_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_unknown() {
|
||||
let adapter = get_adapter_by_name("unknown_adapter");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_empty() {
|
||||
let adapter = get_adapter_by_name("");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_adapter_new() {
|
||||
let _adapter = exchange_api::ExchangeApiAdapter::new();
|
||||
// Just verify it can be created
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_adapter_new() {
|
||||
let _adapter = exchange_rate_api::ExchangeRateApiAdapter::new();
|
||||
// Just verify it can be created
|
||||
}
|
||||
|
||||
// Test the response struct parsing for exchange_api
|
||||
#[test]
|
||||
fn test_exchange_api_response_deserialization() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-01",
|
||||
"usd": {
|
||||
"eur": 0.85,
|
||||
"gbp": 0.73
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-01");
|
||||
assert!(response.rates.contains_key("usd"));
|
||||
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert_eq!(usd_rates.get("eur"), Some(&0.85_f64));
|
||||
assert_eq!(usd_rates.get("gbp"), Some(&0.73_f64));
|
||||
}
|
||||
|
||||
// Test the response struct parsing for exchange_rate_api
|
||||
#[test]
|
||||
fn test_exchange_rate_api_response_deserialization() {
|
||||
use exchange_rate_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"EUR": 0.85,
|
||||
"GBP": 0.73,
|
||||
"JPY": 150.5
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert_eq!(response.time_last_update_unix, 1704067200);
|
||||
assert!(response.rates.contains_key("EUR"));
|
||||
|
||||
assert_eq!(response.rates.get("EUR"), Some(&0.85_f64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_response_with_empty_rates() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-01",
|
||||
"usd": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-01");
|
||||
assert!(
|
||||
response
|
||||
.rates
|
||||
.get("usd")
|
||||
.expect("USD rates not found")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_response_with_many_currencies() {
|
||||
use exchange_rate_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"base": "EUR",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"USD": 1.18,
|
||||
"GBP": 0.86,
|
||||
"JPY": 160.2,
|
||||
"CAD": 1.58,
|
||||
"AUD": 1.62,
|
||||
"CHF": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.rates.len(), 6);
|
||||
assert!(response.rates.contains_key("USD"));
|
||||
assert!(response.rates.contains_key("JPY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_parsing_from_api_responses() {
|
||||
// Test parsing string rates from exchange_api format
|
||||
let rate_str = "0.851234567890";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test precision is maintained
|
||||
let precise_rate =
|
||||
Decimal::from_f64_retain(0.12345678901234).expect("Failed to parse precise rate");
|
||||
assert!(precise_rate > Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_response_date_format() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{"date": "2024-12-31", "usd": {}}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-12-31");
|
||||
|
||||
// Verify date can be parsed
|
||||
let parsed_date = chrono::NaiveDate::parse_from_str(&response.date, "%Y-%m-%d")
|
||||
.expect("Failed to parse date");
|
||||
assert!(parsed_date.year() == 2024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_trait_object_safety() {
|
||||
// Verify that ExchangeRateAdapter can be used as a trait object
|
||||
let _: Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> = None;
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod adapters;
|
||||
pub mod service;
|
||||
|
||||
// reexporting for easier access
|
||||
pub use adapters::ExchangeRateAdapterInfo;
|
||||
565
src-tauri/src/services/exchange_rate/service.rs
Normal file
565
src-tauri/src/services/exchange_rate/service.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{DatabaseConnection, Set, entity::*};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
db::entities::{exchange_rates, prelude::*},
|
||||
errors::{AppError, CommandResult},
|
||||
services::{
|
||||
ServiceTrait, exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
settings::service::SettingsService,
|
||||
},
|
||||
};
|
||||
|
||||
const PREFIX: &str = "exchange_adapter";
|
||||
const EXCHANGE_ADAPTER_SETTING_KEY: &str = "selected_adapter";
|
||||
const DEFAULT_ADAPTER_NAME: &str = "exchange_api";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExchangeRateService: ServiceTrait {
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Decimal>;
|
||||
|
||||
async fn get_supported_currencies(&self) -> Vec<String>;
|
||||
|
||||
async fn get_available_adapters(&self) -> Vec<ExchangeRateAdapterInfo> {
|
||||
super::adapters::get_adapter_info()
|
||||
}
|
||||
|
||||
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()>;
|
||||
async fn get_current_adapter(&self) -> CommandResult<String>;
|
||||
}
|
||||
|
||||
pub struct ExchangeRateServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
|
||||
adapter: RwLock<Arc<dyn super::adapters::ExchangeRateAdapter + Send + Sync>>,
|
||||
supported_currencies_cache: RwLock<Option<(Vec<String>, chrono::DateTime<chrono::Utc>)>>,
|
||||
}
|
||||
|
||||
impl ExchangeRateServiceImpl {
|
||||
pub async fn new(
|
||||
db: DatabaseConnection,
|
||||
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
|
||||
) -> Self {
|
||||
let adapter_name = settings_service
|
||||
.get_setting(
|
||||
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME,
|
||||
)
|
||||
.await;
|
||||
|
||||
let adapter = super::adapters::get_adapter_by_name(&adapter_name).unwrap_or_else(|| {
|
||||
log::warn!(
|
||||
"Adapter '{}' not found, falling back to default adapter '{}'",
|
||||
adapter_name,
|
||||
DEFAULT_ADAPTER_NAME
|
||||
);
|
||||
super::adapters::get_default_adapter()
|
||||
});
|
||||
|
||||
Self {
|
||||
db,
|
||||
settings_service,
|
||||
adapter: RwLock::new(adapter),
|
||||
supported_currencies_cache: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_settings_key(key: &str) -> String {
|
||||
format!("{}:{}", PREFIX, key)
|
||||
}
|
||||
|
||||
async fn store_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
rate: Decimal,
|
||||
timestamp: u64,
|
||||
) -> CommandResult<()> {
|
||||
let today = Utc::now().date_naive().to_string();
|
||||
let fetched_at = chrono::DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.ok_or_else(|| AppError::InvalidData("Invalid timestamp".into()))?
|
||||
.naive_utc();
|
||||
|
||||
let active_model = exchange_rates::ActiveModel {
|
||||
from_currency: Set(from_currency.to_string()),
|
||||
to_currency: Set(to_currency.to_string()),
|
||||
date: Set(today),
|
||||
rate: Set(rate.to_string()),
|
||||
source: Set(None),
|
||||
fetched_at: Set(Some(fetched_at)),
|
||||
};
|
||||
|
||||
// Use upsert to handle conflicts on the composite primary key
|
||||
let _ = ExchangeRates::insert(active_model)
|
||||
.on_conflict(
|
||||
sea_orm::sea_query::OnConflict::new()
|
||||
.update_column(exchange_rates::Column::Rate)
|
||||
.update_column(exchange_rates::Column::FetchedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_cached_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Option<(Decimal, chrono::DateTime<chrono::Utc>)>> {
|
||||
let today = Utc::now().date_naive().to_string();
|
||||
|
||||
let result =
|
||||
ExchangeRates::find_by_id((from_currency.to_string(), to_currency.to_string(), today))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
if let Some(model) = result {
|
||||
let rate = Decimal::from_str_exact(&model.rate)
|
||||
.ok()
|
||||
.ok_or_else(|| AppError::InvalidData("Failed to parse exchange rate".into()))?;
|
||||
let timestamp = model
|
||||
.fetched_at
|
||||
.ok_or_else(|| AppError::InvalidData("Missing fetched_at timestamp".into()))?;
|
||||
// Convert NaiveDateTime to DateTime<Utc>
|
||||
let timestamp_utc = chrono::DateTime::from_naive_utc_and_offset(timestamp, chrono::Utc);
|
||||
Ok(Some((rate, timestamp_utc)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ServiceTrait for ExchangeRateServiceImpl {
|
||||
async fn on_app_start(&self) -> CommandResult<()> {
|
||||
// Ensure the default adapter is set in settings if not already set
|
||||
let current_adapter = self
|
||||
.settings_service
|
||||
.get_setting(
|
||||
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME,
|
||||
)
|
||||
.await;
|
||||
|
||||
if current_adapter.is_empty() {
|
||||
self.settings_service
|
||||
.update_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME.to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateService for ExchangeRateServiceImpl {
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Decimal> {
|
||||
match self
|
||||
.get_cached_exchange_rate(from_currency, to_currency)
|
||||
.await
|
||||
{
|
||||
Ok(Some((cached_rate, timestamp))) => {
|
||||
// If cached rate is less than 12 hours old and is same date, return it
|
||||
if chrono::Utc::now() - timestamp < chrono::Duration::hours(12)
|
||||
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
|
||||
{
|
||||
return Ok(cached_rate);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No cached rate, will fetch new rate
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error but continue to fetch new rate
|
||||
log::error!("Error fetching cached exchange rate: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = self.adapter.read().await;
|
||||
let (rate, timestamp) = adapter
|
||||
.get_exchange_rate(from_currency, to_currency)
|
||||
.await?;
|
||||
|
||||
// ignore errors when storing exchange rate, since we can still return the fetched rate
|
||||
let _ = self
|
||||
.store_exchange_rate(from_currency, to_currency, rate, timestamp)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Error storing exchange rate: {}", e));
|
||||
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self) -> Vec<String> {
|
||||
let cache = self.supported_currencies_cache.read().await;
|
||||
if let Some((cached_currencies, timestamp)) = &*cache {
|
||||
// If cached currencies are less than 12 hours old and is same date, return them
|
||||
if *timestamp > chrono::Utc::now() - chrono::Duration::hours(12)
|
||||
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
|
||||
{
|
||||
return cached_currencies.clone();
|
||||
}
|
||||
}
|
||||
drop(cache);
|
||||
|
||||
let settings = self.settings_service.get_settings(None).await;
|
||||
let base_currency = match settings {
|
||||
Ok(s) => s.base_currency,
|
||||
Err(_) => return vec![], // If we can't get settings, return empty list
|
||||
};
|
||||
|
||||
let adapter = self.adapter.read().await;
|
||||
let currencies = adapter.get_supported_currencies(Some(&base_currency)).await;
|
||||
*self.supported_currencies_cache.write().await =
|
||||
Some((currencies.clone(), chrono::Utc::now()));
|
||||
|
||||
currencies
|
||||
}
|
||||
|
||||
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()> {
|
||||
if let Some(adapter) = super::adapters::get_adapter_by_name(adapter_name) {
|
||||
*self.adapter.write().await = adapter;
|
||||
// Clear the supported currencies cache when adapter changes
|
||||
*self.supported_currencies_cache.write().await = None;
|
||||
self.settings_service
|
||||
.update_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
adapter_name.to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::errors::AppError::NotFound(format!(
|
||||
"Adapter '{}' not found",
|
||||
adapter_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_current_adapter(&self) -> CommandResult<String> {
|
||||
let settings = self
|
||||
.settings_service
|
||||
.get_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY).as_str(),
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
|
||||
if settings.is_empty() {
|
||||
return Err(crate::errors::AppError::NotFound(
|
||||
"No adapter selected".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::errors::AppError;
|
||||
use crate::services::exchange_rate::adapters::{
|
||||
ExchangeRateAdapter, get_adapter_by_name, get_adapter_info,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
// Mock adapter for testing
|
||||
struct MockAdapter {
|
||||
_name: String,
|
||||
rate_to_return: Option<(Decimal, u64)>,
|
||||
currencies_to_return: Vec<String>,
|
||||
}
|
||||
|
||||
impl MockAdapter {
|
||||
fn new(name: &str) -> Self {
|
||||
Self {
|
||||
_name: name.to_string(),
|
||||
rate_to_return: Some((
|
||||
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal"),
|
||||
1704067200,
|
||||
)), // 2024-01-01 00:00:00 UTC
|
||||
currencies_to_return: vec!["USD".to_string(), "EUR".to_string(), "GBP".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn with_rate(mut self, rate: Decimal, timestamp: u64) -> Self {
|
||||
self.rate_to_return = Some((rate, timestamp));
|
||||
self
|
||||
}
|
||||
|
||||
fn with_currencies(mut self, currencies: Vec<String>) -> Self {
|
||||
self.currencies_to_return = currencies;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_error(mut self) -> Self {
|
||||
self.rate_to_return = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for MockAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: "mock_adapter".to_string(),
|
||||
display_name: "Mock Adapter".to_string(),
|
||||
description: "A mock adapter for testing.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
_from_currency: &str,
|
||||
_to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
match self.rate_to_return {
|
||||
Some((rate, timestamp)) => Ok((rate, timestamp)),
|
||||
None => Err(AppError::Internal("Mock error".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, _base_currency: Option<&str>) -> Vec<String> {
|
||||
self.currencies_to_return.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Creating DbService with mock connections requires special handling
|
||||
// due to type constraints. The tests below use a simpler approach.
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_info() {
|
||||
let infos = get_adapter_info();
|
||||
assert_eq!(infos.len(), 2);
|
||||
assert_eq!(infos[0].name, "exchange_api");
|
||||
assert_eq!(infos[1].name, "exchange_rate_api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_api() {
|
||||
let adapter = get_adapter_by_name("exchange_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_rate_api() {
|
||||
let adapter = get_adapter_by_name("exchange_rate_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_invalid() {
|
||||
let adapter = get_adapter_by_name("invalid_adapter");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_get_exchange_rate() {
|
||||
let adapter = MockAdapter::new("test");
|
||||
let result = adapter.get_exchange_rate("USD", "EUR").await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let (rate, timestamp) = result.expect("Result is None");
|
||||
assert_eq!(
|
||||
rate,
|
||||
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal")
|
||||
);
|
||||
assert_eq!(timestamp, 1704067200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_get_supported_currencies() {
|
||||
let adapter = MockAdapter::new("test");
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
|
||||
assert_eq!(currencies.len(), 3);
|
||||
assert!(currencies.contains(&"USD".to_string()));
|
||||
assert!(currencies.contains(&"EUR".to_string()));
|
||||
assert!(currencies.contains(&"GBP".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_with_custom_rate() {
|
||||
let adapter = MockAdapter::new("test").with_rate(
|
||||
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal"),
|
||||
1704153600,
|
||||
);
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "CAD").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (rate, timestamp) = result.expect("Result is None");
|
||||
assert_eq!(
|
||||
rate,
|
||||
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal")
|
||||
);
|
||||
assert_eq!(timestamp, 1704153600);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_with_custom_currencies() {
|
||||
let custom_currencies = vec!["JPY".to_string(), "CNY".to_string()];
|
||||
let adapter = MockAdapter::new("test").with_currencies(custom_currencies.clone());
|
||||
|
||||
let currencies = adapter.get_supported_currencies(None).await;
|
||||
assert_eq!(currencies, custom_currencies);
|
||||
}
|
||||
|
||||
// Note: The following tests require proper DbService mocking which is complex
|
||||
// due to the Arc<DbService> type. These are integration test patterns.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_exchange_rate() {
|
||||
// Create the exchange rate service with mock adapter
|
||||
let mock_adapter = Arc::new(MockAdapter::new("test"));
|
||||
|
||||
// We need to use a real DbService, so we'll test the store_exchange_rate logic indirectly
|
||||
// through get_exchange_rate when no cache exists
|
||||
|
||||
// For now, just verify the mock adapter works
|
||||
let result = mock_adapter.get_exchange_rate("USD", "EUR").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exchange_rate_service_get_available_adapters() {
|
||||
let mock_adapter: Arc<dyn ExchangeRateAdapter + Send + Sync> =
|
||||
Arc::new(MockAdapter::new("test"));
|
||||
|
||||
// Create service - we need to bypass the DbService type issue for this test
|
||||
// by testing the trait method directly
|
||||
let adapters = mock_adapter.get_supported_currencies(None).await;
|
||||
assert!(!adapters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_rate_parsing() {
|
||||
// Test that we can parse decimal rates correctly
|
||||
let rate_str = "0.8512345678";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse rate string");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test with larger rate
|
||||
let large_rate_str = "150.123456789012";
|
||||
let large_rate =
|
||||
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large rate string");
|
||||
assert_eq!(large_rate.to_string(), large_rate_str);
|
||||
|
||||
// Test with small rate
|
||||
let small_rate_str = "0.00000001";
|
||||
let _small_rate =
|
||||
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small rate string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_conversion() {
|
||||
// Test timestamp to DateTime conversion
|
||||
let timestamp: i64 = 1704067200; // 2024-01-01 00:00:00 UTC
|
||||
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||
assert!(dt.is_some());
|
||||
|
||||
let dt = dt.expect("DateTime conversion failed");
|
||||
assert_eq!(dt.timestamp(), timestamp);
|
||||
|
||||
// Test conversion to naive UTC
|
||||
let naive = dt.naive_utc();
|
||||
assert_eq!(naive.and_utc().timestamp(), timestamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_date_time_to_utc() {
|
||||
let naive = NaiveDateTime::parse_from_str("2024-01-01 12:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime");
|
||||
|
||||
let utc: chrono::DateTime<Utc> =
|
||||
chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc);
|
||||
assert_eq!(utc.naive_utc(), naive);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_service_trait_implementation() {
|
||||
// Verify that ExchangeRateServiceImpl implements ServiceTrait
|
||||
fn _check_service_trait<T: ServiceTrait>() {}
|
||||
|
||||
// This is a compile-time check
|
||||
// If ExchangeRateServiceImpl doesn't implement ServiceTrait, this won't compile
|
||||
// We can't call it directly due to DbService, but the type check works
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_error_handling() {
|
||||
let adapter = MockAdapter::new("test").with_error();
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "XXX").await;
|
||||
assert!(result.is_err());
|
||||
|
||||
match result {
|
||||
Err(AppError::Internal(msg)) => assert_eq!(msg, "Mock error"),
|
||||
_ => unreachable!("Expected AppError::Internal"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_name_cases() {
|
||||
// Test that adapter names are consistent
|
||||
let names = get_adapter_info()
|
||||
.iter()
|
||||
.map(|info| info.name.clone())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
for name in &names {
|
||||
let adapter = get_adapter_by_name(name);
|
||||
assert!(adapter.is_some(), "Adapter '{}' should exist", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_supported_currencies_cache() {
|
||||
// Test that currencies can be retrieved
|
||||
let adapter =
|
||||
MockAdapter::new("test").with_currencies(vec!["USD".to_string(), "EUR".to_string()]);
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
assert_eq!(currencies.len(), 2);
|
||||
|
||||
// Test with None base currency
|
||||
let currencies_none = adapter.get_supported_currencies(None).await;
|
||||
assert_eq!(currencies_none.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_precision() {
|
||||
// Verify decimal precision for financial calculations
|
||||
let rate1 =
|
||||
Decimal::from_f64_retain(0.1234567890).expect("Failed to retain f64 as Decimal");
|
||||
let rate2 =
|
||||
Decimal::from_f64_retain(0.9876543210).expect("Failed to retain f64 as Decimal");
|
||||
|
||||
let product = rate1 * rate2;
|
||||
// Decimal should maintain precision
|
||||
assert!(product > Decimal::ZERO);
|
||||
|
||||
// Test string conversion round-trip
|
||||
let rate_str = rate1.to_string();
|
||||
let _rate_parsed = Decimal::from_str_exact(&rate_str).expect("Failed to parse rate string");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::errors::CommandResult;
|
||||
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -18,12 +19,19 @@ pub struct ServiceFactory;
|
||||
|
||||
pub struct ServiceFactoryResult {
|
||||
pub settings_service: Arc<dyn settings::service::SettingsService>,
|
||||
pub exchange_rate_service: Arc<dyn exchange_rate::service::ExchangeRateService>,
|
||||
}
|
||||
|
||||
impl ServiceFactory {
|
||||
pub fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
|
||||
pub async fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
|
||||
let settings_service = Arc::new(settings::service::SettingsServiceImpl::new(db.clone()));
|
||||
let exchange_rate_service = Arc::new(
|
||||
exchange_rate::service::ExchangeRateServiceImpl::new(db, settings_service.clone())
|
||||
.await,
|
||||
);
|
||||
ServiceFactoryResult {
|
||||
settings_service: Arc::new(settings::service::SettingsServiceImpl::new(db)),
|
||||
settings_service,
|
||||
exchange_rate_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
};
|
||||
use struct_iterable::Iterable;
|
||||
|
||||
use crate::{
|
||||
@@ -20,6 +22,7 @@ pub type SettingModel = settings::Model;
|
||||
#[async_trait]
|
||||
pub trait SettingsService: Send + Sync {
|
||||
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings>;
|
||||
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String>;
|
||||
async fn get_setting(&self, key: &str, default: &str) -> String;
|
||||
async fn update_setting(
|
||||
&self,
|
||||
@@ -70,6 +73,20 @@ impl SettingsService for SettingsServiceImpl {
|
||||
Ok(Settings::from(map))
|
||||
}
|
||||
|
||||
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String> {
|
||||
let settings_list = crate::db::entities::settings::Entity::find()
|
||||
.filter(settings::Column::Key.starts_with(prefix.to_string()))
|
||||
.all(&self.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for setting in settings_list {
|
||||
map.insert(setting.key, setting.value.unwrap_or_default());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
async fn get_setting(&self, key: &str, default: &str) -> String {
|
||||
crate::db::entities::settings::Entity::find_by_id(key.to_string())
|
||||
.one(&self.db)
|
||||
|
||||
@@ -2,11 +2,15 @@ use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::service::DbService,
|
||||
services::{ServiceFactoryResult, ServiceTrait, settings::service::SettingsService},
|
||||
services::{
|
||||
ServiceFactoryResult, ServiceTrait, exchange_rate::service::ExchangeRateService,
|
||||
settings::service::SettingsService,
|
||||
},
|
||||
};
|
||||
pub struct AppState {
|
||||
db: DbService,
|
||||
settings_service: Arc<dyn SettingsService>,
|
||||
exchange_rate_service: Arc<dyn ExchangeRateService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -15,6 +19,7 @@ impl AppState {
|
||||
Self {
|
||||
db,
|
||||
settings_service: services.settings_service,
|
||||
exchange_rate_service: services.exchange_rate_service,
|
||||
}
|
||||
}
|
||||
/// Get the database service
|
||||
@@ -26,8 +31,15 @@ impl AppState {
|
||||
&self.settings_service
|
||||
}
|
||||
|
||||
/// Get the exchange rate service
|
||||
pub fn exchange_rate_service(&self) -> &Arc<dyn ExchangeRateService> {
|
||||
&self.exchange_rate_service
|
||||
}
|
||||
|
||||
pub async fn on_app_start(&self) -> crate::errors::CommandResult<()> {
|
||||
// Call on_app_start for all services
|
||||
self.settings_service.on_app_start().await
|
||||
self.settings_service.on_app_start().await?;
|
||||
self.exchange_rate_service.on_app_start().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user