Skip to main content

alat/
client.rs

1//! The HTTP client that drives every ALAT API call.
2//!
3//! [`Client`] owns a pooled, thread-safe `reqwest::Client` plus the [`Config`]
4//! and the **active subscription key**. The endpoint methods live in
5//! [`crate::modules`] as inherent methods on `Client`; the low-level
6//! [`get_json`](Client::get_json) / [`post_json`](Client::post_json) helpers
7//! below do the shared work of URL building, header injection, sending, and
8//! decoding.
9//!
10//! # Multiple products / subscription keys
11//!
12//! Azure APIM issues a **separate subscription key per product**, and one key
13//! only authorizes the APIs inside that product. The SDK's endpoints span
14//! several products (e.g. on Playground, "Wallet Services" covers wallet/account/
15//! bills/airtime, while statements are a *separate* product with their own key).
16//!
17//! Rather than force you to build a whole new client per key, the subscription
18//! key is injected **per request**, and [`with_subscription_key`](Client::with_subscription_key)
19//! cheaply derives a sibling client that **shares the same connection pool**:
20//!
21//! ```no_run
22//! use alat::{Client, Config};
23//! # fn main() -> Result<(), alat::Error> {
24//! // One client for the "Wallet Services" product...
25//! let wallet = Client::new(Config::playground("WALLET_SERVICES_KEY", "api_key"))?;
26//! // ...and a sibling for "Get Statement Service" sharing the HTTP pool.
27//! let statements = wallet.with_subscription_key("GET_STATEMENT_KEY")?;
28//! # let _ = (wallet, statements);
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! The channel API key (`x-api-key`) is a single per-channel credential issued by
34//! Wema and is the same across products, so it lives on [`Config`] and does not
35//! need swapping.
36
37use crate::config::Config;
38use crate::error::{Error, Result};
39use reqwest::header::{HeaderMap, HeaderValue};
40use serde::{de::DeserializeOwned, Serialize};
41use std::sync::Arc;
42
43/// How many bytes of a failed-to-decode body to keep in [`Error::Decode`].
44const BODY_SNIPPET_LIMIT: usize = 2_000;
45
46/// HTTP header carrying the APIM subscription key.
47const SUBSCRIPTION_KEY_HEADER: &str = "Ocp-Apim-Subscription-Key";
48/// HTTP header carrying the channel API key.
49const API_KEY_HEADER: &str = "x-api-key";
50
51/// A configured, reusable ALAT API client.
52///
53/// Cloning is cheap (the inner `reqwest::Client`, [`Config`], and subscription
54/// key are reference counted) and clones share the same connection pool, so a
55/// single `Client` can be shared freely across tasks/threads.
56///
57/// # Example
58///
59/// ```no_run
60/// use alat::{Client, Config};
61///
62/// # fn main() -> Result<(), alat::Error> {
63/// let config = Config::playground("subscription_key", "api_key");
64/// let client = Client::new(config)?;
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Debug, Clone)]
69pub struct Client {
70    config: Arc<Config>,
71    /// The subscription key sent on every request from *this* client. Defaults
72    /// to [`Config::subscription_key`] but can be swapped per product via
73    /// [`with_subscription_key`](Client::with_subscription_key).
74    subscription_key: Arc<str>,
75    http: reqwest::Client,
76}
77
78impl Client {
79    /// Builds a client from a [`Config`].
80    ///
81    /// `Content-Type: application/json` and `Cache-Control: no-cache` (banking
82    /// reads must never be cached) are installed as default headers. The
83    /// subscription key (`Ocp-Apim-Subscription-Key`) and channel API key
84    /// (`x-api-key`) are validated here and then injected on every request.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`Error::Configuration`] if a credential contains bytes that are
89    /// not valid in an HTTP header value, or if the underlying HTTP client fails
90    /// to build. A bad key is reported here, never silently dropped.
91    pub fn new(config: Config) -> Result<Self> {
92        validate_header_value("subscription_key", &config.subscription_key)?;
93        validate_header_value("api_key", &config.api_key)?;
94        if let Some(access) = &config.access_key {
95            validate_header_value("access_key", access)?;
96        }
97
98        let mut headers = HeaderMap::new();
99        headers.insert(
100            reqwest::header::CONTENT_TYPE,
101            HeaderValue::from_static("application/json"),
102        );
103        headers.insert(
104            reqwest::header::CACHE_CONTROL,
105            HeaderValue::from_static("no-cache"),
106        );
107
108        let http = reqwest::Client::builder()
109            .default_headers(headers)
110            .timeout(config.timeout)
111            .build()
112            .map_err(|e| Error::Configuration(format!("failed to build HTTP client: {e}")))?;
113
114        let subscription_key = Arc::from(config.subscription_key.as_str());
115        Ok(Self {
116            config: Arc::new(config),
117            subscription_key,
118            http,
119        })
120    }
121
122    /// Derives a sibling client that uses a **different subscription key** while
123    /// sharing this client's connection pool, channel API key, gateway, and
124    /// timeout.
125    ///
126    /// Use this when your endpoints span multiple APIM products (each product
127    /// has its own subscription key). See the [module docs](self).
128    ///
129    /// # Errors
130    /// [`Error::Configuration`] if the key has invalid header bytes.
131    pub fn with_subscription_key(&self, subscription_key: impl AsRef<str>) -> Result<Self> {
132        let key = subscription_key.as_ref();
133        validate_header_value("subscription_key", key)?;
134        Ok(Self {
135            config: self.config.clone(),
136            subscription_key: Arc::from(key),
137            http: self.http.clone(),
138        })
139    }
140
141    /// Borrows the [`Config`] this client was built with.
142    pub fn config(&self) -> &Config {
143        &self.config
144    }
145
146    /// The subscription key this client currently sends.
147    pub fn subscription_key(&self) -> &str {
148        &self.subscription_key
149    }
150
151    /// Borrows the underlying `reqwest::Client` for advanced/custom requests.
152    pub fn http_client(&self) -> &reqwest::Client {
153        &self.http
154    }
155
156    /// Joins the configured gateway base URL with a relative API `path`.
157    fn url(&self, path: &str) -> String {
158        format!(
159            "{}/{}",
160            self.config.base_url.trim_end_matches('/'),
161            path.trim_start_matches('/')
162        )
163    }
164
165    /// The authentication headers injected on every request: the active
166    /// subscription key and the channel API key.
167    fn auth_headers(&self) -> Vec<(&'static str, String)> {
168        vec![
169            (SUBSCRIPTION_KEY_HEADER, self.subscription_key.to_string()),
170            (API_KEY_HEADER, self.config.api_key.clone()),
171        ]
172    }
173
174    /// Issues a `GET` and decodes the JSON response into `T`.
175    ///
176    /// `query` is a slice of `(name, value)` pairs that are percent-encoded and
177    /// appended to the URL. `extra_headers` adds per-request headers (e.g. the
178    /// bills/airtime `access` header) on top of the authentication headers.
179    pub async fn get_json<T>(
180        &self,
181        path: &str,
182        query: &[(&str, &str)],
183        extra_headers: &[(&'static str, String)],
184    ) -> Result<T>
185    where
186        T: DeserializeOwned,
187    {
188        let mut req = self.http.get(self.url(path));
189        if !query.is_empty() {
190            req = req.query(query);
191        }
192        req = self.apply_all_headers(req, extra_headers);
193        self.send(req).await
194    }
195
196    /// Serializes `body` as JSON, issues a `POST`, and decodes the response into `T`.
197    ///
198    /// `extra_headers` adds per-request headers (e.g. the funds-transfer `hash`
199    /// signature, or the bills/airtime `access` key) alongside auth headers.
200    pub async fn post_json<B, T>(
201        &self,
202        path: &str,
203        body: &B,
204        extra_headers: &[(&'static str, String)],
205    ) -> Result<T>
206    where
207        B: Serialize + ?Sized,
208        T: DeserializeOwned,
209    {
210        let req = self.apply_all_headers(self.http.post(self.url(path)).json(body), extra_headers);
211        self.send(req).await
212    }
213
214    /// Applies authentication headers followed by any per-call headers.
215    fn apply_all_headers(
216        &self,
217        mut req: reqwest::RequestBuilder,
218        extra_headers: &[(&'static str, String)],
219    ) -> reqwest::RequestBuilder {
220        for (name, value) in self.auth_headers() {
221            req = req.header(name, value);
222        }
223        for (name, value) in extra_headers {
224            req = req.header(*name, value);
225        }
226        req
227    }
228
229    /// Sends a prepared request and routes the outcome through a single, uniform
230    /// decode/error path so every endpoint reports failures identically.
231    async fn send<T>(&self, req: reqwest::RequestBuilder) -> Result<T>
232    where
233        T: DeserializeOwned,
234    {
235        let response = req.send().await?;
236        let status = response.status();
237        // Read the body as text first so a decode failure can include it for
238        // diagnostics instead of being swallowed by `reqwest::Response::json`.
239        let body = response.text().await?;
240
241        if !status.is_success() {
242            return Err(Error::Http { status, body });
243        }
244
245        serde_json::from_str::<T>(&body).map_err(|e| Error::Decode {
246            message: e.to_string(),
247            body: truncate(&body),
248        })
249    }
250
251    /// Builds the `access` header list bills/airtime endpoints need.
252    ///
253    /// `required` controls whether a missing [`Config::access_key`] is a hard
254    /// error (for endpoints that mandate it) or simply omitted (for endpoints
255    /// where it is optional).
256    pub(crate) fn access_headers(&self, required: bool) -> Result<Vec<(&'static str, String)>> {
257        match &self.config.access_key {
258            Some(key) => Ok(vec![("access", key.clone())]),
259            None if required => Err(Error::Configuration(
260                "this endpoint requires the bills/airtime `access` key; set it via \
261                 Config::with_access_key(...)"
262                    .into(),
263            )),
264            None => Ok(Vec::new()),
265        }
266    }
267}
268
269/// Fails fast if a credential cannot be used as an HTTP header value.
270fn validate_header_value(name: &str, value: &str) -> Result<()> {
271    HeaderValue::from_str(value)
272        .map(|_| ())
273        .map_err(|_| Error::Configuration(format!("{name} contains invalid header characters")))
274}
275
276/// Truncates a body snippet for inclusion in [`Error::Decode`].
277fn truncate(s: &str) -> String {
278    if s.len() <= BODY_SNIPPET_LIMIT {
279        s.to_string()
280    } else {
281        format!("{}… [truncated, {} bytes total]", &s[..BODY_SNIPPET_LIMIT], s.len())
282    }
283}