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}