Skip to main content

comdirect_rest_api/
session.rs

1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4
5use crate::application::session_runtime::{
6    RefreshTokenCallback, SessionRuntime, SessionRuntimeConfig, StateChangeCallback,
7};
8use crate::auth::{LoginChallenge, TanAction};
9use crate::error::Result;
10use crate::types::SessionStateType;
11
12const DEFAULT_BASE_URL: &str = "https://api.comdirect.de";
13
14/// Configuration used to create a [`Session`].
15///
16/// The configuration separates static credentials from runtime concerns such as
17/// timeouts and token refresh behavior. This keeps all I/O settings in one
18/// location and avoids hidden defaults spread across modules.
19///
20/// # Example
21///
22/// ```no_run
23/// use comdirect_rest_api::{Session, SessionConfig};
24///
25/// # async fn demo() -> Result<(), comdirect_rest_api::ComdirectError> {
26/// let config = SessionConfig::new("client-id", "client-secret", "username", "pin")
27///     .with_base_url("https://api.comdirect.de")
28///     .with_request_timeout(std::time::Duration::from_secs(20))
29///     .with_refresh_buffer(std::time::Duration::from_secs(90));
30///
31/// let session = Session::from_config(config)?;
32/// session.try_restore("persisted-refresh-token").await?;
33/// # Ok(())
34/// # }
35/// ```
36#[derive(Debug, Clone)]
37pub struct SessionConfig {
38    client_id: String,
39    client_secret: String,
40    username: String,
41    password: String,
42    base_url: String,
43    request_timeout: Duration,
44    refresh_buffer: Duration,
45}
46
47impl SessionConfig {
48    /// Creates configuration with production defaults.
49    pub fn new(
50        client_id: impl Into<String>,
51        client_secret: impl Into<String>,
52        username: impl Into<String>,
53        password: impl Into<String>,
54    ) -> Self {
55        Self {
56            client_id: client_id.into(),
57            client_secret: client_secret.into(),
58            username: username.into(),
59            password: password.into(),
60            base_url: DEFAULT_BASE_URL.to_string(),
61            request_timeout: Duration::from_secs(30),
62            refresh_buffer: Duration::from_secs(60),
63        }
64    }
65
66    /// Overrides the API base URL.
67    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
68        self.base_url = base_url.into();
69        self
70    }
71
72    /// Overrides the request timeout used for all HTTP calls.
73    pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
74        self.request_timeout = request_timeout;
75        self
76    }
77
78    /// Configures how early token refresh should happen before expiry.
79    pub fn with_refresh_buffer(mut self, refresh_buffer: Duration) -> Self {
80        self.refresh_buffer = refresh_buffer;
81        self
82    }
83}
84
85/// Stateful comdirect API session with automatic token refresh.
86///
87/// The session encapsulates auth orchestration, transient-error retries,
88/// background refresh supervision, and endpoint access.
89///
90/// # Example
91///
92/// ```no_run
93/// use comdirect_rest_api::{Session, TanAction};
94///
95/// # async fn demo() -> Result<(), comdirect_rest_api::ComdirectError> {
96/// let session = Session::new("client-id", "client-secret", "username", "pin")?;
97///
98/// session
99///     .login(|challenge| async move {
100///         println!("challenge {}", challenge.challenge_id);
101///         TanAction::ConfirmPushTan
102///     })
103///     .await?;
104///
105/// let balances = session.accounts().get_balances().await?;
106/// println!("{} accounts", balances.values.len());
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Clone)]
111pub struct Session {
112    username: String,
113    runtime: Arc<SessionRuntime>,
114}
115
116impl Session {
117    /// Creates a session using default runtime settings.
118    pub fn new(
119        client_id: impl Into<String>,
120        client_secret: impl Into<String>,
121        username: impl Into<String>,
122        password: impl Into<String>,
123    ) -> Result<Self> {
124        let config = SessionConfig::new(client_id, client_secret, username, password);
125        Self::from_config(config)
126    }
127
128    /// Creates a session from explicit [`SessionConfig`].
129    pub fn from_config(config: SessionConfig) -> Result<Self> {
130        let username = config.username.clone();
131        let runtime = Arc::new(SessionRuntime::new(SessionRuntimeConfig {
132            base_url: config.base_url,
133            client_id: config.client_id,
134            client_secret: config.client_secret,
135            username: config.username,
136            password: config.password,
137            request_timeout: config.request_timeout,
138            refresh_buffer_secs: config.refresh_buffer.as_secs(),
139        })?);
140
141        Ok(Self { username, runtime })
142    }
143
144    /// Returns a copyable endpoint client for account operations.
145    pub fn accounts(&self) -> crate::accounts::AccountsApi {
146        crate::accounts::AccountsApi::new(self.clone())
147    }
148
149    /// Returns a copyable endpoint client for brokerage operations.
150    pub fn brokerage(&self) -> crate::brokerage::BrokerageApi {
151        crate::brokerage::BrokerageApi::new(self.clone())
152    }
153
154    /// Registers a callback invoked on session state transitions.
155    pub async fn set_state_change_callback<F>(&self, callback: F)
156    where
157        F: Fn(SessionStateType, SessionStateType) + Send + Sync + 'static,
158    {
159        self.runtime
160            .set_state_change_callback(Some(Arc::new(callback) as Arc<StateChangeCallback>))
161            .await;
162    }
163
164    /// Registers a callback invoked whenever a new refresh token is issued.
165    pub async fn set_refresh_token_callback<F>(&self, callback: F)
166    where
167        F: Fn(String) + Send + Sync + 'static,
168    {
169        self.runtime
170            .set_refresh_token_callback(Some(Arc::new(callback) as Arc<RefreshTokenCallback>))
171            .await;
172    }
173
174    /// Reads the current authorization state.
175    pub async fn state(&self) -> SessionStateType {
176        self.runtime.state().await
177    }
178
179    /// Performs non-interactive login using a persisted refresh token.
180    ///
181    /// This is the preferred startup path for long-running services because it
182    /// avoids interactive TAN prompts during cold start.
183    pub async fn try_restore(&self, refresh_token: &str) -> Result<()> {
184        self.runtime.login_try(refresh_token).await
185    }
186
187    /// Performs interactive login and asks the callback how to answer TAN challenges.
188    ///
189    /// Use this for initial bootstrap of refresh tokens. Persist the refresh
190    /// token from `set_refresh_token_callback` and switch to [`Self::try_restore`]
191    /// for subsequent restarts.
192    pub async fn login<F, Fut>(&self, callback: F) -> Result<()>
193    where
194        F: Fn(LoginChallenge) -> Fut + Send + Sync,
195        Fut: Future<Output = TanAction> + Send,
196    {
197        self.runtime.login(callback).await
198    }
199
200    /// Stops background refresh tasks and marks the session unauthorized.
201    ///
202    /// Call this before process shutdown to ensure refresh workers are cancelled
203    /// deterministically.
204    pub async fn shutdown(&self) {
205        self.runtime.shutdown().await;
206    }
207
208    #[cfg(feature = "web")]
209    /// Mounts HTTP login routes for browser-driven TAN flows.
210    pub async fn login_web(self: Arc<Self>, router: axum::Router) -> axum::Router {
211        crate::web::mount_session_login_route(router, "/comdirect", self.username.clone(), self)
212            .await
213    }
214
215    pub(crate) async fn get_authorized_resource(
216        &self,
217        operation: &'static str,
218        api_path: &str,
219    ) -> Result<String> {
220        self.runtime
221            .get_authorized_resource(operation, api_path)
222            .await
223    }
224}