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/// use std::io::{self, Write};
95///
96/// # async fn demo() -> Result<(), comdirect_rest_api::ComdirectError> {
97/// let session = Session::new("client-id", "client-secret", "username", "pin")?;
98///
99/// session
100/// .login(|challenge| async move {
101/// println!("challenge {}", challenge.challenge_id);
102/// println!("Approve the Push-TAN in your Comdirect app first.");
103/// print!("Press Enter after approval... ");
104/// let _ = io::stdout().flush();
105/// let mut line = String::new();
106/// let _ = io::stdin().read_line(&mut line);
107/// TanAction::ConfirmPushTan
108/// })
109/// .await?;
110///
111/// let balances = session.accounts().get_balances().await?;
112/// println!("{} accounts", balances.values.len());
113/// # Ok(())
114/// # }
115/// ```
116#[derive(Clone)]
117pub struct Session {
118 #[cfg(feature = "web")]
119 username: String,
120 runtime: Arc<SessionRuntime>,
121}
122
123impl Session {
124 /// Creates a session using default runtime settings.
125 pub fn new(
126 client_id: impl Into<String>,
127 client_secret: impl Into<String>,
128 username: impl Into<String>,
129 password: impl Into<String>,
130 ) -> Result<Self> {
131 let config = SessionConfig::new(client_id, client_secret, username, password);
132 Self::from_config(config)
133 }
134
135 /// Creates a session from explicit [`SessionConfig`].
136 pub fn from_config(config: SessionConfig) -> Result<Self> {
137 #[cfg(feature = "web")]
138 let username = config.username.clone();
139 #[cfg(feature = "log")]
140 let log_username = config.username.clone();
141
142 let runtime = Arc::new(SessionRuntime::new(SessionRuntimeConfig {
143 base_url: config.base_url,
144 client_id: config.client_id,
145 client_secret: config.client_secret,
146 username: config.username,
147 password: config.password,
148 request_timeout: config.request_timeout,
149 refresh_buffer_secs: config.refresh_buffer.as_secs(),
150 })?);
151
152 #[cfg(feature = "log")]
153 crate::log_info!(username = %log_username, "session created");
154
155 Ok(Self {
156 #[cfg(feature = "web")]
157 username,
158 runtime,
159 })
160 }
161
162 /// Returns a copyable endpoint client for account operations.
163 pub fn accounts(&self) -> crate::accounts::AccountsApi {
164 crate::accounts::AccountsApi::new(self.clone())
165 }
166
167 /// Returns a copyable endpoint client for brokerage operations.
168 pub fn brokerage(&self) -> crate::brokerage::BrokerageApi {
169 crate::brokerage::BrokerageApi::new(self.clone())
170 }
171
172 /// Registers a callback invoked on session state transitions.
173 pub async fn set_state_change_callback<F>(&self, callback: F)
174 where
175 F: Fn(SessionStateType, SessionStateType) + Send + Sync + 'static,
176 {
177 self.runtime
178 .set_state_change_callback(Some(Arc::new(callback) as Arc<StateChangeCallback>))
179 .await;
180 }
181
182 /// Registers a callback invoked whenever a new refresh token is issued.
183 pub async fn set_refresh_token_callback<F>(&self, callback: F)
184 where
185 F: Fn(String) + Send + Sync + 'static,
186 {
187 self.runtime
188 .set_refresh_token_callback(Some(Arc::new(callback) as Arc<RefreshTokenCallback>))
189 .await;
190 }
191
192 /// Reads the current authorization state.
193 pub async fn state(&self) -> SessionStateType {
194 self.runtime.state().await
195 }
196
197 /// Performs non-interactive login using a persisted refresh token.
198 ///
199 /// This is the preferred startup path for long-running services because it
200 /// avoids interactive TAN prompts during cold start.
201 pub async fn try_restore(&self, refresh_token: &str) -> Result<()> {
202 self.runtime.login_try(refresh_token).await
203 }
204
205 /// Performs interactive login and asks the callback how to answer TAN challenges.
206 ///
207 /// The callback is awaited before TAN completion is submitted. For `ConfirmPushTan`,
208 /// return only after the user has actually confirmed the challenge externally
209 /// (for example via a frontend "I've allowed it" action).
210 ///
211 /// Use this for initial bootstrap of refresh tokens. Persist the refresh
212 /// token from `set_refresh_token_callback` and switch to [`Self::try_restore`]
213 /// for subsequent restarts.
214 pub async fn login<F, Fut>(&self, callback: F) -> Result<()>
215 where
216 F: Fn(LoginChallenge) -> Fut + Send + Sync,
217 Fut: Future<Output = TanAction> + Send,
218 {
219 self.runtime.login(callback).await
220 }
221
222 /// Stops background refresh tasks and marks the session unauthorized.
223 ///
224 /// Call this before process shutdown to ensure refresh workers are cancelled
225 /// deterministically.
226 pub async fn shutdown(&self) {
227 self.runtime.shutdown().await;
228 }
229
230 #[cfg(feature = "web")]
231 /// Mounts HTTP login routes for browser-driven TAN flows.
232 pub async fn login_web(self: Arc<Self>, router: axum::Router) -> axum::Router {
233 crate::web::mount_session_login_route(router, "/comdirect", self.username.clone(), self)
234 .await
235 }
236
237 pub(crate) async fn get_authorized_resource(
238 &self,
239 operation: &'static str,
240 api_path: &str,
241 ) -> Result<String> {
242 self.runtime
243 .get_authorized_resource(operation, api_path)
244 .await
245 }
246}