From de7c0f71101340c2b32ad2bc75f759b7b50548a9 Mon Sep 17 00:00:00 2001 From: Rorik Star Platinum Date: Wed, 5 Nov 2025 17:30:50 +0300 Subject: [PATCH] (feat) added handler --- Cargo.lock | 1 + Cargo.toml | 1 + src/api.rs | 8 +- src/api/banking.rs | 198 +++++++++++++++++++++++++++++++++++++----- src/main.rs | 3 +- src/route.rs | 2 +- src/route/handlers.rs | 12 +++ src/state.rs | 10 +-- 8 files changed, 203 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af3ff73..d8dd2eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,6 +1227,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 06c98e0..26c8b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" axum = "0.8" tokio = { version = "1.48", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } +url = "2.5" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] } dotenvy = "0.15" diff --git a/src/api.rs b/src/api.rs index 411315a..937e7f0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,4 +1,8 @@ // src/api.rs -// Объявляет клиенты для работы с внешними API +// This file is the public-facing module for all external API interactions. -pub mod banking; // Наш HTTP клиент к банковским API +// Make the banking submodule public. +pub mod banking; + +// Re-export the primary client struct so other modules can use it via `crate::api::*`. +pub use banking::BankingClients; diff --git a/src/api/banking.rs b/src/api/banking.rs index 178f1e9..4eae367 100644 --- a/src/api/banking.rs +++ b/src/api/banking.rs @@ -1,46 +1,204 @@ // src/api/banking.rs use reqwest::Client as HttpClient; -use std::sync::Arc; +use serde::Deserialize; +use std::env; +use std::str::FromStr; +use std::sync::Arc; // Import Arc +use tokio::sync::RwLock; +use url::Url; +use chrono::{DateTime, Duration, Utc}; -// Модели и ошибки можно вынести в отдельные файлы в api/ -// Например, api/banking_models.rs +// --- Public Enums and Errors --- -#[derive(Debug, serde::Deserialize)] -pub struct BankToken { - pub access_token: String, - pub expires_in: i64, +#[derive(Debug, thiserror::Error)] +pub enum BankingError { + #[error("Environment variable not set: {0}")] + MissingEnvVar(String), + #[error("Invalid URL: {0}")] + UrlParseError(#[from] url::ParseError), + #[error("HTTP request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + #[error("API returned an error: {status} {body}")] + ApiError { status: u16, body: String }, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] pub enum Bank { VBank, ABank, SBank, } -impl Bank { - // ... +// --- Data Models --- + +#[derive(Debug, Deserialize)] +pub struct BankTokenResponse { + pub access_token: String, + pub expires_in: i64, } +#[derive(Debug, Clone)] +struct StoredToken { + access_token: String, + expires_at: DateTime, +} + +impl StoredToken { + fn is_valid(&self) -> bool { + // Check if the token is valid for at least the next 60 seconds. + self.expires_at > Utc::now() + Duration::seconds(60) + } +} + +// --- Client Implementation --- + #[derive(Clone)] pub struct BankClient { - // ... -} - -impl BankClient { - // ... + http_client: HttpClient, + base_url: Url, + client_id: String, + client_secret: String, + // FIX: Wrap the RwLock in an Arc to make it shareable and clonable. + token: Arc>>, } #[derive(Clone)] pub struct BankingClients { - pub vbank: BankClient, - pub abank: BankClient, - pub sbank: BankClient, + pub vbank: Arc, + pub abank: Arc, + pub sbank: Arc, +} + +// --- impl Blocks --- + +impl FromStr for Bank { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "vbank" => Ok(Bank::VBank), + "abank" => Ok(Bank::ABank), + "sbank" => Ok(Bank::SBank), + _ => Err(anyhow::anyhow!("Unknown bank: {}", s)), + } + } +} + +impl BankClient { + pub fn new( + http_client: HttpClient, + base_url: Url, + client_id: String, + client_secret: String, + ) -> Self { + Self { + http_client, + base_url, + client_id, + client_secret, + // FIX: Initialize the Arc> + token: Arc::new(RwLock::new(None)), + } + } + + /// Fetches a new token from the bank's API. + async fn fetch_new_token(&self) -> Result { + println!("🔄 Requesting new token for bank: {}", self.base_url.host_str().unwrap_or("")); + let token_url = self.base_url.join("/auth/bank-token")?; + + let response = self + .http_client + .post(token_url) + .query(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ]) + .send() + .await?; + + match response.status().is_success() { + true => { + let token_response: BankTokenResponse = response.json().await?; + Ok(StoredToken { + access_token: token_response.access_token, + expires_at: Utc::now() + Duration::seconds(token_response.expires_in), + }) + }, + false => { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Err(BankingError::ApiError { status, body }) + } + } + } + + /// Gets a valid token, refreshing if necessary. (Refactored) + pub async fn get_token(&self) -> Result { + // First, perform a read-only check. + let read_guard = self.token.read().await; + if let Some(token) = read_guard.as_ref() { + if token.is_valid() { + return Ok(token.access_token.clone()); + } + } + // Drop the read lock before acquiring a write lock. + drop(read_guard); + + // If the token is invalid or missing, acquire a write lock. + let mut write_guard = self.token.write().await; + + // Re-check in case another thread refreshed the token while we were waiting. + match write_guard.as_ref() { + Some(token) if token.is_valid() => { + return Ok(token.access_token.clone()) + }, + _ => { + // The token is definitely invalid, so we fetch a new one. + let new_token = self.fetch_new_token().await?; + let access_token = new_token.access_token.clone(); + *write_guard = Some(new_token); // Update the stored token + Ok(access_token) + } + } + } } impl BankingClients { - pub async fn new() -> Self { - // ... + pub async fn new() -> Result { + let http_client = HttpClient::new(); + let get_env = |key: &str| -> Result { + env::var(key).map_err(|_| BankingError::MissingEnvVar(key.to_string())) + }; + + let vbank = { + let base_url = Url::parse(&get_env("VBANK_API_URL")?)?; + let client_id = get_env("VBANK_CLIENT_ID")?; + let client_secret = get_env("VBANK_CLIENT_SECRET")?; + Arc::new(BankClient::new(http_client.clone(), base_url, client_id, client_secret)) + }; + + let abank = { + let base_url = Url::parse(&get_env("ABANK_API_URL")?)?; + let client_id = get_env("ABANK_CLIENT_ID")?; + let client_secret = get_env("ABANK_CLIENT_SECRET")?; + Arc::new(BankClient::new(http_client.clone(), base_url, client_id, client_secret)) + }; + + let sbank = { + let base_url = Url::parse(&get_env("SBANK_API_URL")?)?; + let client_id = get_env("SBANK_CLIENT_ID")?; + let client_secret = get_env("SBANK_CLIENT_SECRET")?; + Arc::new(BankClient::new(http_client, base_url, client_id, client_secret)) + }; + + Ok(Self { vbank, abank, sbank }) + } + + pub fn get_client(&self, bank: Bank) -> &Arc { + match bank { + Bank::VBank => &self.vbank, + Bank::ABank => &self.abank, + Bank::SBank => &self.sbank, + } } } diff --git a/src/main.rs b/src/main.rs index b9fc884..37f4dce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,7 @@ mod state; mod api; use std::net::SocketAddr; -use api::banking::BankingClients; - +use crate::state::AppState; /// Общее состояние приложения, передаётся во все handlers diff --git a/src/route.rs b/src/route.rs index 41492e7..63c6da8 100644 --- a/src/route.rs +++ b/src/route.rs @@ -15,7 +15,7 @@ pub fn router(app_state: AppState) -> Router { Router::new() // GET /api/health — health-check эндпоинт .route("/api/health", get(handlers::health_handler)) - + .route("/api/test-token", get(handlers::test_token_handler)) // Сюда добавим новые роуты по мере разработки: // .route("/api/accounts", get(handlers::accounts::get_accounts)) // .route("/api/payments", post(handlers::payments::create_payment)) diff --git a/src/route/handlers.rs b/src/route/handlers.rs index f06f0e8..00a6648 100644 --- a/src/route/handlers.rs +++ b/src/route/handlers.rs @@ -38,3 +38,15 @@ pub async fn health_handler( ).into_response(), } } + +pub async fn test_token_handler( + State(state): State, +) -> impl IntoResponse { + match state.banking_clients.vbank.get_token().await { + Ok(token) => (StatusCode::OK, Json(json!({ "vbank_token": token }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} diff --git a/src/state.rs b/src/state.rs index d44af75..7943de3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,7 @@ // src/state.rs -// Ответственность: создание и управление общим состоянием приложения (AppState) use crate::db; -use crate::api::banking::BankingClients; +use crate::api::BankingClients; use sqlx::PgPool; /// Общее состояние приложения, доступное во всех handlers @@ -13,15 +12,12 @@ pub struct AppState { } impl AppState { - /// Асинхронный конструктор для AppState - /// - /// Инициализирует все необходимые ресурсы (пул БД, HTTP клиенты) - /// и собирает их в единый стейт. pub async fn new() -> Self { let db_pool = db::init_pool().await; println!("✅ Database connection pool created successfully."); - let banking_clients = BankingClients::new().await; + // FIX: Add .await and handle the potential error + let banking_clients = BankingClients::new().await.expect("Failed to initialize banking clients"); println!("✅ Banking API clients initialized."); Self {