Skip to main content

idkollen_client/
client.rs

1use reqwest::header::USER_AGENT;
2use serde::{Serialize, de::DeserializeOwned};
3use std::time::Duration;
4use thiserror::Error;
5
6#[cfg(feature = "async")]
7use crate::endpoints::{
8    BankIdNoEndpoint, BankIdSeEndpoint, DocumentEndpoint, FrejaEndpoint, FtnEndpoint,
9    MitIdEndpoint, VippsEndpoint,
10};
11
12#[cfg(feature = "blocking")]
13use crate::endpoints::{
14    BankIdNoBlockingEndpoint, BankIdSeBlockingEndpoint, DocumentBlockingEndpoint,
15    FrejaBlockingEndpoint, FtnBlockingEndpoint, MitIdBlockingEndpoint, VippsBlockingEndpoint,
16};
17
18/// Errors returned by all client operations.
19#[non_exhaustive]
20#[derive(Debug, Error)]
21pub enum IdkollenError {
22    /// Network-level error (connection refused, TLS failure, etc.).
23    #[error("HTTP error: {0}")]
24    Http(
25        #[from]
26        #[source]
27        reqwest::Error,
28    ),
29    /// The server returned a non-2xx response.
30    #[error("API error {status}: {message}")]
31    Api { status: u16, message: String },
32    /// The server returned an unexpected JSON shape.
33    #[error("JSON error: {0}")]
34    Deserialization(
35        #[from]
36        #[source]
37        serde_path_to_error::Error<serde_json::Error>,
38    ),
39}
40
41/// Error returned by `wait_for_*` polling helpers.
42#[non_exhaustive]
43#[derive(Debug, Error)]
44pub enum WaitError {
45    /// Polling exceeded the configured timeout without reaching a terminal state.
46    #[error("Poll timed out without reaching a terminal state")]
47    Timeout,
48    /// An underlying network or API error occurred during polling.
49    #[error(transparent)]
50    Client(#[from] IdkollenError),
51}
52
53/// Pre-configured API base URL selection.
54#[non_exhaustive]
55#[derive(Debug, Clone)]
56pub enum Environment {
57    /// `https://api.idkollen.se`
58    Production,
59    /// `https://stgapi.idkollen.se`
60    Staging,
61}
62
63impl Environment {
64    #[inline]
65    #[must_use]
66    fn base_url(&self) -> &'static str {
67        match self {
68            Self::Production => "https://api.idkollen.se",
69            Self::Staging => "https://stgapi.idkollen.se",
70        }
71    }
72}
73
74/// Options for the high-level `wait_for_*` polling helpers.
75#[derive(Debug, Clone)]
76pub struct PollOptions {
77    /// How long to sleep between status polls. Default: 2 s.
78    pub interval: Duration,
79    /// Maximum total time to wait before returning [`WaitError::Timeout`]. Default: 300 s.
80    pub timeout: Duration,
81}
82
83impl Default for PollOptions {
84    #[inline]
85    fn default() -> Self {
86        Self {
87            interval: Duration::from_secs(2),
88            timeout: Duration::from_secs(300),
89        }
90    }
91}
92
93/// Builder for constructing an [`IdkollenClient`] or [`IdkollenBlockingClient`].
94pub struct IdkollenClientBuilder {
95    environment: Environment,
96    base_url: Option<String>,
97    client_id: String,
98    client_secret: String,
99    user_agent: String,
100    #[cfg(feature = "async")]
101    http_client: Option<reqwest::Client>,
102    #[cfg(feature = "blocking")]
103    blocking_http_client: Option<reqwest::blocking::Client>,
104}
105
106impl IdkollenClientBuilder {
107    #[must_use]
108    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
109        Self {
110            environment: Environment::Production,
111            base_url: None,
112            client_id: client_id.into(),
113            client_secret: client_secret.into(),
114            user_agent: format!("idkollen-client-rs/{}", env!("CARGO_PKG_VERSION")),
115            #[cfg(feature = "async")]
116            http_client: None,
117            #[cfg(feature = "blocking")]
118            blocking_http_client: None,
119        }
120    }
121
122    #[inline]
123    #[must_use]
124    pub fn environment(mut self, env: Environment) -> Self {
125        self.environment = env;
126        self
127    }
128
129    #[inline]
130    #[must_use]
131    pub fn base_url(mut self, url: impl Into<String>) -> Self {
132        self.base_url = Some(url.into());
133        self
134    }
135
136    /// Override the `User-Agent` header sent with every request.
137    #[inline]
138    #[must_use]
139    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
140        self.user_agent = ua.into();
141        self
142    }
143
144    #[cfg(feature = "async")]
145    #[inline]
146    #[must_use]
147    pub fn http_client(mut self, client: reqwest::Client) -> Self {
148        self.http_client = Some(client);
149        self
150    }
151
152    #[cfg(feature = "blocking")]
153    #[inline]
154    #[must_use]
155    pub fn blocking_http_client(mut self, client: reqwest::blocking::Client) -> Self {
156        self.blocking_http_client = Some(client);
157        self
158    }
159
160    #[cfg(feature = "async")]
161    pub fn build(self) -> Result<IdkollenClient, IdkollenError> {
162        let base_url = self
163            .base_url
164            .unwrap_or_else(|| self.environment.base_url().to_owned());
165        let http = self.http_client.map(Ok).unwrap_or_else(|| {
166            reqwest::Client::builder()
167                .timeout(Duration::from_secs(30))
168                .build()
169        })?;
170
171        Ok(IdkollenClient {
172            http,
173            base_url,
174            client_id: self.client_id,
175            client_secret: self.client_secret,
176            user_agent: self.user_agent,
177        })
178    }
179
180    #[cfg(feature = "blocking")]
181    pub fn build_blocking(self) -> Result<IdkollenBlockingClient, IdkollenError> {
182        let base_url = self
183            .base_url
184            .unwrap_or_else(|| self.environment.base_url().to_owned());
185        let http = self.blocking_http_client.map(Ok).unwrap_or_else(|| {
186            reqwest::blocking::Client::builder()
187                .timeout(Duration::from_secs(30))
188                .build()
189        })?;
190
191        Ok(IdkollenBlockingClient {
192            http,
193            base_url,
194            client_id: self.client_id,
195            client_secret: self.client_secret,
196            user_agent: self.user_agent,
197        })
198    }
199}
200
201#[cfg(feature = "async")]
202pub struct IdkollenClient {
203    pub(crate) http: reqwest::Client,
204    pub(crate) base_url: String,
205    pub(crate) client_id: String,
206    pub(crate) client_secret: String,
207    pub(crate) user_agent: String,
208}
209
210#[cfg(feature = "async")]
211impl IdkollenClient {
212    #[inline]
213    #[must_use]
214    pub fn bankid_se(&self) -> BankIdSeEndpoint<'_> {
215        BankIdSeEndpoint(self)
216    }
217
218    #[inline]
219    #[must_use]
220    pub fn bankid_no(&self) -> BankIdNoEndpoint<'_> {
221        BankIdNoEndpoint(self)
222    }
223
224    #[inline]
225    #[must_use]
226    pub fn freja(&self) -> FrejaEndpoint<'_> {
227        FrejaEndpoint(self)
228    }
229
230    #[inline]
231    #[must_use]
232    pub fn mitid(&self) -> MitIdEndpoint<'_> {
233        MitIdEndpoint(self)
234    }
235
236    #[inline]
237    #[must_use]
238    pub fn ftn(&self) -> FtnEndpoint<'_> {
239        FtnEndpoint(self)
240    }
241
242    #[inline]
243    #[must_use]
244    pub fn vipps(&self) -> VippsEndpoint<'_> {
245        VippsEndpoint(self)
246    }
247
248    #[inline]
249    #[must_use]
250    pub fn document(&self) -> DocumentEndpoint<'_> {
251        DocumentEndpoint(self)
252    }
253
254    #[inline]
255    pub(crate) fn url(&self, path: &str) -> String {
256        format!("{}{}", self.base_url, path)
257    }
258
259    pub(crate) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, IdkollenError> {
260        let resp = self
261            .http
262            .get(self.url(path))
263            .basic_auth(&self.client_id, Some(&self.client_secret))
264            .header(USER_AGENT, &self.user_agent)
265            .send()
266            .await?;
267
268        parse_response(resp).await
269    }
270
271    pub(crate) async fn post<B: Serialize, T: DeserializeOwned>(
272        &self,
273        path: &str,
274        body: &B,
275    ) -> Result<T, IdkollenError> {
276        let resp = self
277            .http
278            .post(self.url(path))
279            .basic_auth(&self.client_id, Some(&self.client_secret))
280            .header(USER_AGENT, &self.user_agent)
281            .json(body)
282            .send()
283            .await?;
284
285        parse_response(resp).await
286    }
287
288    pub(crate) async fn delete(&self, path: &str) -> Result<(), IdkollenError> {
289        let resp = self
290            .http
291            .delete(self.url(path))
292            .basic_auth(&self.client_id, Some(&self.client_secret))
293            .header(USER_AGENT, &self.user_agent)
294            .send()
295            .await?;
296
297        if resp.status().is_success() {
298            Ok(())
299        } else {
300            let status = resp.status().as_u16();
301            let message = resp.text().await.unwrap_or_default();
302
303            Err(IdkollenError::Api { status, message })
304        }
305    }
306
307    pub(crate) async fn post_multipart<T: DeserializeOwned>(
308        &self,
309        path: &str,
310        form: reqwest::multipart::Form,
311    ) -> Result<T, IdkollenError> {
312        let resp = self
313            .http
314            .post(self.url(path))
315            .basic_auth(&self.client_id, Some(&self.client_secret))
316            .header(USER_AGENT, &self.user_agent)
317            .multipart(form)
318            .send()
319            .await?;
320
321        parse_response(resp).await
322    }
323
324    pub(crate) async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, IdkollenError> {
325        let resp = self
326            .http
327            .get(self.url(path))
328            .basic_auth(&self.client_id, Some(&self.client_secret))
329            .header(USER_AGENT, &self.user_agent)
330            .send()
331            .await?;
332
333        if resp.status().is_success() {
334            Ok(resp.bytes().await?.to_vec())
335        } else {
336            let status = resp.status().as_u16();
337            let message = resp.text().await.unwrap_or_default();
338
339            Err(IdkollenError::Api { status, message })
340        }
341    }
342}
343
344#[cfg(feature = "async")]
345async fn parse_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, IdkollenError> {
346    if resp.status().is_success() {
347        let text = resp.text().await?;
348        let deserializer = &mut serde_json::Deserializer::from_str(&text);
349
350        Ok(serde_path_to_error::deserialize(deserializer)?)
351    } else {
352        let status = resp.status().as_u16();
353        let message = resp.text().await.unwrap_or_default();
354
355        Err(IdkollenError::Api { status, message })
356    }
357}
358
359#[cfg(feature = "blocking")]
360pub struct IdkollenBlockingClient {
361    pub(crate) http: reqwest::blocking::Client,
362    pub(crate) base_url: String,
363    pub(crate) client_id: String,
364    pub(crate) client_secret: String,
365    pub(crate) user_agent: String,
366}
367
368#[cfg(feature = "blocking")]
369impl IdkollenBlockingClient {
370    #[inline]
371    #[must_use]
372    pub fn bankid_se(&self) -> BankIdSeBlockingEndpoint<'_> {
373        BankIdSeBlockingEndpoint(self)
374    }
375
376    #[inline]
377    #[must_use]
378    pub fn bankid_no(&self) -> BankIdNoBlockingEndpoint<'_> {
379        BankIdNoBlockingEndpoint(self)
380    }
381
382    #[inline]
383    #[must_use]
384    pub fn freja(&self) -> FrejaBlockingEndpoint<'_> {
385        FrejaBlockingEndpoint(self)
386    }
387
388    #[inline]
389    #[must_use]
390    pub fn mitid(&self) -> MitIdBlockingEndpoint<'_> {
391        MitIdBlockingEndpoint(self)
392    }
393
394    #[inline]
395    #[must_use]
396    pub fn ftn(&self) -> FtnBlockingEndpoint<'_> {
397        FtnBlockingEndpoint(self)
398    }
399
400    #[inline]
401    #[must_use]
402    pub fn vipps(&self) -> VippsBlockingEndpoint<'_> {
403        VippsBlockingEndpoint(self)
404    }
405
406    #[inline]
407    #[must_use]
408    pub fn document(&self) -> DocumentBlockingEndpoint<'_> {
409        DocumentBlockingEndpoint(self)
410    }
411
412    #[inline]
413    pub(crate) fn url(&self, path: &str) -> String {
414        format!("{}{}", self.base_url, path)
415    }
416
417    pub(crate) fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, IdkollenError> {
418        let resp = self
419            .http
420            .get(self.url(path))
421            .basic_auth(&self.client_id, Some(&self.client_secret))
422            .header(USER_AGENT, &self.user_agent)
423            .send()?;
424
425        parse_blocking_response(resp)
426    }
427
428    pub(crate) fn post<B: Serialize, T: DeserializeOwned>(
429        &self,
430        path: &str,
431        body: &B,
432    ) -> Result<T, IdkollenError> {
433        let resp = self
434            .http
435            .post(self.url(path))
436            .basic_auth(&self.client_id, Some(&self.client_secret))
437            .header(USER_AGENT, &self.user_agent)
438            .json(body)
439            .send()?;
440
441        parse_blocking_response(resp)
442    }
443
444    pub(crate) fn delete(&self, path: &str) -> Result<(), IdkollenError> {
445        let resp = self
446            .http
447            .delete(self.url(path))
448            .basic_auth(&self.client_id, Some(&self.client_secret))
449            .header(USER_AGENT, &self.user_agent)
450            .send()?;
451
452        if resp.status().is_success() {
453            Ok(())
454        } else {
455            let status = resp.status().as_u16();
456            let message = resp.text().unwrap_or_default();
457
458            Err(IdkollenError::Api { status, message })
459        }
460    }
461
462    pub(crate) fn post_multipart<T: DeserializeOwned>(
463        &self,
464        path: &str,
465        form: reqwest::blocking::multipart::Form,
466    ) -> Result<T, IdkollenError> {
467        let resp = self
468            .http
469            .post(self.url(path))
470            .basic_auth(&self.client_id, Some(&self.client_secret))
471            .header(USER_AGENT, &self.user_agent)
472            .multipart(form)
473            .send()?;
474
475        parse_blocking_response(resp)
476    }
477
478    pub(crate) fn get_bytes(&self, path: &str) -> Result<Vec<u8>, IdkollenError> {
479        let resp = self
480            .http
481            .get(self.url(path))
482            .basic_auth(&self.client_id, Some(&self.client_secret))
483            .header(USER_AGENT, &self.user_agent)
484            .send()?;
485
486        if resp.status().is_success() {
487            Ok(resp.bytes()?.to_vec())
488        } else {
489            let status = resp.status().as_u16();
490            let message = resp.text().unwrap_or_default();
491
492            Err(IdkollenError::Api { status, message })
493        }
494    }
495}
496
497#[cfg(feature = "blocking")]
498fn parse_blocking_response<T: DeserializeOwned>(
499    resp: reqwest::blocking::Response,
500) -> Result<T, IdkollenError> {
501    if resp.status().is_success() {
502        let text = resp.text()?;
503        let deserializer = &mut serde_json::Deserializer::from_str(&text);
504
505        Ok(serde_path_to_error::deserialize(deserializer)?)
506    } else {
507        let status = resp.status().as_u16();
508        let message = resp.text().unwrap_or_default();
509
510        Err(IdkollenError::Api { status, message })
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::models::{BankIdSePhoneAuthRequest, CallInitiator, Pno};
518
519    #[tokio::test]
520    async fn test() {
521        let client = IdkollenClientBuilder::new("494c2afa-fb68-4891-9b3b-8a0056771707", "123456")
522            .environment(Environment::Staging)
523            .build()
524            .unwrap();
525
526        let response = client
527            .bankid_se()
528            .phone_auth(BankIdSePhoneAuthRequest::new(
529                Pno::parse("9012073731").unwrap(),
530                CallInitiator::User,
531            ))
532            .await
533            .unwrap();
534
535        println!("{:#?}", response);
536    }
537}