Skip to main content

nordnet_api/
client.rs

1//! HTTP client for the Nordnet External API.
2//!
3//! Wraps `reqwest::Client` with:
4//!   - Base URL composition (`{base}/{path}` with leading-slash tolerated).
5//!   - `Authorization: Basic <session_key:session_key>` injection when a
6//!     [`Session`] is attached.
7//!   - Single response-parsing path so every method routes identical
8//!     status-code handling.
9//!
10//! Non-2xx responses (including 429 Too Many Requests and 503 Service
11//! Unavailable) surface to the caller as the matching [`Error`] variant.
12//! Retry policy is deliberately a caller concern — the library does not
13//! sleep, retry, or hide latency. POST/PUT operations on `/orders` are
14//! non-idempotent; a hidden retry could double-place an order if a
15//! response is lost in flight. Callers that want backoff should wrap
16//! these methods explicitly.
17//!
18//! No method here calls a Nordnet host directly — callers supply the base
19//! URL (production, test, or a `wiremock::MockServer`).
20
21use reqwest::{
22    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
23    Method, Response,
24};
25use serde::de::DeserializeOwned;
26use serde::Serialize;
27
28use crate::error::Error;
29use nordnet_model::auth::Session;
30
31/// Typed HTTP client for the Nordnet API. Cheap to clone — wraps a
32/// `reqwest::Client` and a base URL + optional session.
33#[derive(Debug, Clone)]
34pub struct Client {
35    http: reqwest::Client,
36    base_url: String,
37    session: Option<Session>,
38}
39
40impl Client {
41    /// Build a client for the given base URL (e.g.
42    /// `https://public.nordnet.se/api/2`). The base URL is used verbatim;
43    /// trailing slashes are stripped.
44    pub fn new(base_url: impl Into<String>) -> Result<Self, Error> {
45        let http = reqwest::Client::builder()
46            .build()
47            .map_err(Error::Transport)?;
48        Ok(Self {
49            http,
50            base_url: base_url.into().trim_end_matches('/').to_owned(),
51            session: None,
52        })
53    }
54
55    /// Attach (or replace) the authenticated session used for the
56    /// `Authorization` header on subsequent requests.
57    pub fn with_session(mut self, session: Session) -> Self {
58        self.session = Some(session);
59        self
60    }
61
62    /// Active session, if any. Mostly useful for tests.
63    pub fn session(&self) -> Option<&Session> {
64        self.session.as_ref()
65    }
66
67    /// Base URL the client targets.
68    pub fn base_url(&self) -> &str {
69        &self.base_url
70    }
71
72    /// GET `<base_url><path>` and parse the JSON body as `T`.
73    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
74        self.send::<T, ()>(Method::GET, path, None).await
75    }
76
77    /// POST a JSON body to `<base_url><path>` and parse the JSON response.
78    pub async fn post<T: DeserializeOwned, B: Serialize>(
79        &self,
80        path: &str,
81        body: &B,
82    ) -> Result<T, Error> {
83        self.send(Method::POST, path, Some(Body::Json(body))).await
84    }
85
86    /// PUT a JSON body to `<base_url><path>` and parse the JSON response.
87    pub async fn put<T: DeserializeOwned, B: Serialize>(
88        &self,
89        path: &str,
90        body: &B,
91    ) -> Result<T, Error> {
92        self.send(Method::PUT, path, Some(Body::Json(body))).await
93    }
94
95    /// POST a body to `<base_url><path>` encoded as
96    /// `application/x-www-form-urlencoded`, and parse the JSON response.
97    ///
98    /// Required for endpoints whose Swagger 2.0 parameter table marks every
99    /// body parameter as `FormData` (e.g. `POST /accounts/{accid}/orders`).
100    /// JSON bodies are silently rejected by these endpoints.
101    pub async fn post_form<T: DeserializeOwned, B: Serialize>(
102        &self,
103        path: &str,
104        body: &B,
105    ) -> Result<T, Error> {
106        self.send(Method::POST, path, Some(Body::Form(body))).await
107    }
108
109    /// PUT a body to `<base_url><path>` encoded as
110    /// `application/x-www-form-urlencoded`, and parse the JSON response.
111    ///
112    /// Required for endpoints whose Swagger 2.0 parameter table marks every
113    /// body parameter as `FormData` (e.g. `PUT /accounts/{accid}/orders/{order_id}`).
114    pub async fn put_form<T: DeserializeOwned, B: Serialize>(
115        &self,
116        path: &str,
117        body: &B,
118    ) -> Result<T, Error> {
119        self.send(Method::PUT, path, Some(Body::Form(body))).await
120    }
121
122    /// PUT `<base_url><path>` with no request body. The wire request omits
123    /// the `Content-Type` header and sends a zero-length body — this is the
124    /// shape Nordnet's `PUT /login` (refresh session) expects.
125    pub async fn put_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
126        self.send::<T, ()>(Method::PUT, path, None).await
127    }
128
129    /// DELETE `<base_url><path>` and parse the JSON response.
130    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
131        self.send::<T, ()>(Method::DELETE, path, None).await
132    }
133
134    /// Compose the full URL for `path`. Public so tests and resource
135    /// modules can build requests without re-implementing the rule.
136    pub fn url(&self, path: &str) -> String {
137        if path.starts_with('/') {
138            format!("{}{}", self.base_url, path)
139        } else {
140            format!("{}/{}", self.base_url, path)
141        }
142    }
143
144    fn auth_headers(&self) -> Result<HeaderMap, Error> {
145        let mut headers = HeaderMap::new();
146        if let Some(session) = &self.session {
147            let value = session.basic_auth_header();
148            let header =
149                HeaderValue::from_str(&value).map_err(|e| Error::InvalidHeader(e.to_string()))?;
150            headers.insert(AUTHORIZATION, header);
151        }
152        Ok(headers)
153    }
154
155    async fn send<T: DeserializeOwned, B: Serialize>(
156        &self,
157        method: Method,
158        path: &str,
159        body: Option<Body<'_, B>>,
160    ) -> Result<T, Error> {
161        let url = self.url(path);
162        let headers = self.auth_headers()?;
163        let response = self.execute_once(method, &url, headers, body).await?;
164        parse_response::<T>(response).await
165    }
166
167    async fn execute_once<B: Serialize>(
168        &self,
169        method: Method,
170        url: &str,
171        headers: HeaderMap,
172        body: Option<Body<'_, B>>,
173    ) -> Result<Response, Error> {
174        let mut req = self.http.request(method, url).headers(headers);
175        match body {
176            Some(Body::Json(b)) => {
177                req = req.header(CONTENT_TYPE, "application/json").json(b);
178            }
179            Some(Body::Form(b)) => {
180                // Encode via serde_urlencoded directly (rather than
181                // `RequestBuilder::form`, which is gated on a reqwest
182                // feature we don't enable). Wire format is identical.
183                let encoded =
184                    serde_urlencoded::to_string(b).map_err(|e| Error::EncodeForm(e.to_string()))?;
185                req = req
186                    .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
187                    .body(encoded);
188            }
189            None => {}
190        }
191        req.send().await.map_err(Error::Transport)
192    }
193}
194
195/// Internal body wrapper used to thread the encoding choice from the public
196/// helper (`post`, `put`, `post_form`, `put_form`) down to `execute_once`.
197/// Kept private — callers only see the typed helpers.
198enum Body<'a, B: Serialize> {
199    Json(&'a B),
200    Form(&'a B),
201}
202
203/// Single response-parsing path used by every method on [`Client`].
204async fn parse_response<T: DeserializeOwned>(response: Response) -> Result<T, Error> {
205    let status = response.status();
206    let body = response.text().await.map_err(Error::Transport)?;
207
208    if status.is_success() {
209        serde_json::from_str::<T>(&body).map_err(|source| Error::Decode { source, body })
210    } else {
211        Err(Error::from_status(status.as_u16(), body))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn url_handles_leading_slash() {
221        let c = Client::new("http://example.com/api/2").unwrap();
222        assert_eq!(c.url("/accounts"), "http://example.com/api/2/accounts");
223        assert_eq!(c.url("accounts"), "http://example.com/api/2/accounts");
224    }
225
226    #[test]
227    fn url_strips_trailing_slash_on_base() {
228        let c = Client::new("http://example.com/api/2/").unwrap();
229        assert_eq!(c.url("/x"), "http://example.com/api/2/x");
230    }
231
232    #[test]
233    fn no_session_no_auth_header() {
234        let c = Client::new("http://x").unwrap();
235        let h = c.auth_headers().unwrap();
236        assert!(!h.contains_key(AUTHORIZATION));
237    }
238
239    #[test]
240    fn with_session_sets_basic_auth() {
241        let c = Client::new("http://x").unwrap().with_session(Session {
242            session_key: "abc".into(),
243            expires_in: 60,
244        });
245        let h = c.auth_headers().unwrap();
246        assert_eq!(
247            h.get(AUTHORIZATION).unwrap().to_str().unwrap(),
248            "Basic YWJjOmFiYw=="
249        );
250    }
251}