Skip to main content

alpr/
client.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use serde::de::DeserializeOwned;
6use serde::Deserialize;
7use ureq::RequestExt;
8
9use crate::error::{ApiError, Error};
10use crate::multipart::Form;
11use crate::version::{DEFAULT_BASE_URL, VERSION};
12
13/// Any error a [`Transport`] answers.
14pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
15
16/// One request, as a [`Transport`] sends it.
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct HttpRequest {
20    /// GET, POST…
21    pub method: String,
22    /// The whole address, with the query.
23    pub url: String,
24    /// The headers, in the order they are sent.
25    pub headers: Vec<(String, String)>,
26    /// The body of a call that sends one: a multipart form.
27    pub body: Option<Vec<u8>>,
28    /// How long the call may take.
29    pub timeout: Duration,
30}
31
32impl HttpRequest {
33    /// The first value of a header, its name in any case.
34    pub fn header(&self, name: &str) -> Option<&str> {
35        find(&self.headers, name)
36    }
37}
38
39/// One answer, as a [`Transport`] reads it.
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct HttpResponse {
42    /// The HTTP status.
43    pub status: u16,
44    /// The headers.
45    pub headers: Vec<(String, String)>,
46    /// The body.
47    pub body: Vec<u8>,
48}
49
50impl HttpResponse {
51    /// An answer with this status and body and no headers.
52    pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
53        Self {
54            status,
55            headers: Vec::new(),
56            body: body.into(),
57        }
58    }
59
60    /// The same answer with one more header.
61    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
62        self.headers.push((name.into(), value.into()));
63        self
64    }
65
66    /// The first value of a header, its name in any case.
67    pub fn header(&self, name: &str) -> Option<&str> {
68        find(&self.headers, name)
69    }
70}
71
72fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
73    headers
74        .iter()
75        .find(|(key, _)| key.eq_ignore_ascii_case(name))
76        .map(|(_, value)| value.as_str())
77}
78
79/// Sends a request and answers what came back, whatever its status. The SDK
80/// sends through [`UreqTransport`]; a test, or a program with its own HTTP
81/// client, gives another one to [`ClientBuilder::transport`]. A function
82/// from [`HttpRequest`] to `Result<HttpResponse, BoxError>` is one.
83pub trait Transport: Send + Sync {
84    /// Sends one request.
85    fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError>;
86}
87
88impl<F> Transport for F
89where
90    F: Fn(HttpRequest) -> Result<HttpResponse, BoxError> + Send + Sync,
91{
92    fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError> {
93        self(request)
94    }
95}
96
97/// The HTTP the SDK sends through: [ureq](https://docs.rs/ureq), blocking,
98/// with rustls.
99#[derive(Debug, Clone)]
100pub struct UreqTransport {
101    agent: ureq::Agent,
102}
103
104impl UreqTransport {
105    /// A transport with its own connection pool.
106    pub fn new() -> Self {
107        let agent = ureq::Agent::config_builder()
108            .http_status_as_error(false)
109            // The API never redirects, and a redirect would carry the key along.
110            .max_redirects(0)
111            .max_redirects_will_error(false)
112            .build()
113            .new_agent();
114        Self { agent }
115    }
116}
117
118impl Default for UreqTransport {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Transport for UreqTransport {
125    fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError> {
126        let mut builder = ureq::http::Request::builder()
127            .method(request.method.as_str())
128            .uri(request.url.as_str());
129        for (name, value) in &request.headers {
130            builder = builder.header(name.as_str(), value.as_str());
131        }
132        let timeout = Some(request.timeout);
133        let mut response = match request.body {
134            Some(body) => builder
135                .body(body)?
136                .with_agent(&self.agent)
137                .configure()
138                .timeout_global(timeout)
139                .run()?,
140            None => builder
141                .body(())?
142                .with_agent(&self.agent)
143                .configure()
144                .timeout_global(timeout)
145                .run()?,
146        };
147        let headers = response
148            .headers()
149            .iter()
150            .filter_map(|(name, value)| {
151                Some((name.as_str().to_owned(), value.to_str().ok()?.to_owned()))
152            })
153            .collect();
154        let status = response.status().as_u16();
155        let body = response.body_mut().read_to_vec()?;
156        Ok(HttpResponse {
157            status,
158            headers,
159            body,
160        })
161    }
162}
163
164/// Options of one call.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct RequestOptions {
167    /// Sends the call with another workspace's key.
168    pub api_key: Option<String>,
169}
170
171/// Alpr.sh's API with one workspace's API key (`alpr_live_…`), which says the
172/// workspace and the scopes the calls may use.
173///
174/// A client is cheap to clone, and its clones share one connection pool.
175#[derive(Clone)]
176pub struct Client {
177    api_key: String,
178    base_url: String,
179    timeout: Duration,
180    transport: Arc<dyn Transport>,
181}
182
183impl fmt::Debug for Client {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        f.debug_struct("Client")
186            .field("base_url", &self.base_url)
187            .field("timeout", &self.timeout)
188            .finish_non_exhaustive()
189    }
190}
191
192/// Configures a [`Client`].
193pub struct ClientBuilder {
194    api_key: String,
195    base_url: String,
196    timeout: Duration,
197    transport: Option<Arc<dyn Transport>>,
198}
199
200impl fmt::Debug for ClientBuilder {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        f.debug_struct("ClientBuilder")
203            .field("base_url", &self.base_url)
204            .field("timeout", &self.timeout)
205            .finish_non_exhaustive()
206    }
207}
208
209impl ClientBuilder {
210    /// Points the client at another address, such as a local server. It must
211    /// be https; http is accepted only for localhost, 127.0.0.0/8 and ::1,
212    /// and any other makes every call answer [`Error::InsecureBaseUrl`]
213    /// before anything is sent.
214    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
215        self.base_url = base_url.into().trim_end_matches('/').to_owned();
216        self
217    }
218
219    /// How long a call may take; 30 seconds by default.
220    pub fn timeout(mut self, timeout: Duration) -> Self {
221        self.timeout = timeout;
222        self
223    }
224
225    /// Sends the requests through `transport`.
226    pub fn transport(mut self, transport: impl Transport + 'static) -> Self {
227        self.transport = Some(Arc::new(transport));
228        self
229    }
230
231    /// The client.
232    pub fn build(self) -> Client {
233        Client {
234            api_key: self.api_key,
235            base_url: self.base_url,
236            timeout: self.timeout,
237            transport: self
238                .transport
239                .unwrap_or_else(|| Arc::new(UreqTransport::new())),
240        }
241    }
242}
243
244/// The `{"data": [...]}` an answer wraps a list in.
245#[derive(Deserialize)]
246pub(crate) struct Data<T> {
247    pub(crate) data: T,
248}
249
250impl Client {
251    /// A client for the workspace `api_key` belongs to.
252    pub fn new(api_key: impl Into<String>) -> Self {
253        Self::builder(api_key).build()
254    }
255
256    /// Configures a client for the workspace `api_key` belongs to.
257    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
258        ClientBuilder {
259            api_key: api_key.into(),
260            base_url: DEFAULT_BASE_URL.to_owned(),
261            timeout: Duration::from_secs(30),
262            transport: None,
263        }
264    }
265
266    /// Calls the API and reads the whole answer as a `T`; a refusal is
267    /// [`Error::Api`].
268    pub(crate) fn request<T: DeserializeOwned>(
269        &self,
270        method: &str,
271        path: &str,
272        query: &[(&str, String)],
273        form: Option<Form<'_>>,
274        options: &RequestOptions,
275    ) -> Result<T, Error> {
276        if !secure_base_url(&self.base_url) {
277            return Err(Error::InsecureBaseUrl);
278        }
279        let mut url = format!("{}{}", self.base_url, path);
280        let pairs: Vec<String> = query
281            .iter()
282            .filter(|(_, value)| !value.is_empty())
283            .map(|(name, value)| format!("{}={}", escape(name), escape(value)))
284            .collect();
285        if !pairs.is_empty() {
286            url.push('?');
287            url.push_str(&pairs.join("&"));
288        }
289        let api_key = options.api_key.as_deref().unwrap_or(&self.api_key);
290        let mut headers = vec![
291            ("Authorization".to_owned(), format!("Bearer {api_key}")),
292            ("Accept".to_owned(), "application/json".to_owned()),
293            ("User-Agent".to_owned(), format!("alpr-rust/{VERSION}")),
294        ];
295        let body = form.map(|form| {
296            let (body, content_type) = form.encode();
297            headers.push(("Content-Type".to_owned(), content_type));
298            body
299        });
300        let request = HttpRequest {
301            method: method.to_owned(),
302            url,
303            headers,
304            body,
305            timeout: self.timeout,
306        };
307        let response = self.transport.send(request).map_err(Error::Transport)?;
308        if response.status >= 300 {
309            return Err(Error::Api(ApiError::from_response(&response)));
310        }
311        if response.status == 204 {
312            // No body: only a request for `()` can read it.
313            return serde_json::from_slice(b"null").map_err(Error::Json);
314        }
315        serde_json::from_slice(&response.body).map_err(Error::Json)
316    }
317}
318
319/// Says the base URL is https, or http to localhost, 127.0.0.0/8 or ::1.
320fn secure_base_url(base_url: &str) -> bool {
321    let Ok(uri) = base_url.parse::<ureq::http::Uri>() else {
322        return false;
323    };
324    let host = uri
325        .host()
326        .unwrap_or("")
327        .trim_matches(['[', ']'])
328        .to_ascii_lowercase();
329    let loopback = host == "localhost"
330        || host
331            .parse::<std::net::IpAddr>()
332            .is_ok_and(|address| address.is_loopback());
333    match uri.scheme_str().map(str::to_ascii_lowercase).as_deref() {
334        Some("https") => true,
335        Some("http") => loopback,
336        _ => false,
337    }
338}
339
340/// A path parameter or a query value, escaped: everything but letters,
341/// digits and `-._~`.
342pub(crate) fn escape(value: &str) -> String {
343    const HEX: &[u8; 16] = b"0123456789ABCDEF";
344    let mut out = String::with_capacity(value.len());
345    for byte in value.bytes() {
346        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
347            out.push(byte as char);
348        } else {
349            out.push('%');
350            out.push(HEX[usize::from(byte >> 4)] as char);
351            out.push(HEX[usize::from(byte & 15)] as char);
352        }
353    }
354    out
355}