use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use ureq::RequestExt;
use crate::error::{ApiError, Error};
use crate::multipart::Form;
use crate::version::{DEFAULT_BASE_URL, VERSION};
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout: Duration,
}
impl HttpRequest {
pub fn header(&self, name: &str) -> Option<&str> {
find(&self.headers, name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
headers: Vec::new(),
body: body.into(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn header(&self, name: &str) -> Option<&str> {
find(&self.headers, name)
}
}
fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
pub trait Transport: Send + Sync {
fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError>;
}
impl<F> Transport for F
where
F: Fn(HttpRequest) -> Result<HttpResponse, BoxError> + Send + Sync,
{
fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError> {
self(request)
}
}
#[derive(Debug, Clone)]
pub struct UreqTransport {
agent: ureq::Agent,
}
impl UreqTransport {
pub fn new() -> Self {
let agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.max_redirects(0)
.max_redirects_will_error(false)
.build()
.new_agent();
Self { agent }
}
}
impl Default for UreqTransport {
fn default() -> Self {
Self::new()
}
}
impl Transport for UreqTransport {
fn send(&self, request: HttpRequest) -> Result<HttpResponse, BoxError> {
let mut builder = ureq::http::Request::builder()
.method(request.method.as_str())
.uri(request.url.as_str());
for (name, value) in &request.headers {
builder = builder.header(name.as_str(), value.as_str());
}
let timeout = Some(request.timeout);
let mut response = match request.body {
Some(body) => builder
.body(body)?
.with_agent(&self.agent)
.configure()
.timeout_global(timeout)
.run()?,
None => builder
.body(())?
.with_agent(&self.agent)
.configure()
.timeout_global(timeout)
.run()?,
};
let headers = response
.headers()
.iter()
.filter_map(|(name, value)| {
Some((name.as_str().to_owned(), value.to_str().ok()?.to_owned()))
})
.collect();
let status = response.status().as_u16();
let body = response.body_mut().read_to_vec()?;
Ok(HttpResponse {
status,
headers,
body,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestOptions {
pub api_key: Option<String>,
}
#[derive(Clone)]
pub struct Client {
api_key: String,
base_url: String,
timeout: Duration,
transport: Arc<dyn Transport>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.base_url)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
pub struct ClientBuilder {
api_key: String,
base_url: String,
timeout: Duration,
transport: Option<Arc<dyn Transport>>,
}
impl fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
impl ClientBuilder {
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_owned();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn transport(mut self, transport: impl Transport + 'static) -> Self {
self.transport = Some(Arc::new(transport));
self
}
pub fn build(self) -> Client {
Client {
api_key: self.api_key,
base_url: self.base_url,
timeout: self.timeout,
transport: self
.transport
.unwrap_or_else(|| Arc::new(UreqTransport::new())),
}
}
}
#[derive(Deserialize)]
pub(crate) struct Data<T> {
pub(crate) data: T,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
Self::builder(api_key).build()
}
pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
ClientBuilder {
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.to_owned(),
timeout: Duration::from_secs(30),
transport: None,
}
}
pub(crate) fn request<T: DeserializeOwned>(
&self,
method: &str,
path: &str,
query: &[(&str, String)],
form: Option<Form<'_>>,
options: &RequestOptions,
) -> Result<T, Error> {
if !secure_base_url(&self.base_url) {
return Err(Error::InsecureBaseUrl);
}
let mut url = format!("{}{}", self.base_url, path);
let pairs: Vec<String> = query
.iter()
.filter(|(_, value)| !value.is_empty())
.map(|(name, value)| format!("{}={}", escape(name), escape(value)))
.collect();
if !pairs.is_empty() {
url.push('?');
url.push_str(&pairs.join("&"));
}
let api_key = options.api_key.as_deref().unwrap_or(&self.api_key);
let mut headers = vec![
("Authorization".to_owned(), format!("Bearer {api_key}")),
("Accept".to_owned(), "application/json".to_owned()),
("User-Agent".to_owned(), format!("alpr-rust/{VERSION}")),
];
let body = form.map(|form| {
let (body, content_type) = form.encode();
headers.push(("Content-Type".to_owned(), content_type));
body
});
let request = HttpRequest {
method: method.to_owned(),
url,
headers,
body,
timeout: self.timeout,
};
let response = self.transport.send(request).map_err(Error::Transport)?;
if response.status >= 300 {
return Err(Error::Api(ApiError::from_response(&response)));
}
if response.status == 204 {
return serde_json::from_slice(b"null").map_err(Error::Json);
}
serde_json::from_slice(&response.body).map_err(Error::Json)
}
}
fn secure_base_url(base_url: &str) -> bool {
let Ok(uri) = base_url.parse::<ureq::http::Uri>() else {
return false;
};
let host = uri
.host()
.unwrap_or("")
.trim_matches(['[', ']'])
.to_ascii_lowercase();
let loopback = host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback());
match uri.scheme_str().map(str::to_ascii_lowercase).as_deref() {
Some("https") => true,
Some("http") => loopback,
_ => false,
}
}
pub(crate) fn escape(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(byte as char);
} else {
out.push('%');
out.push(HEX[usize::from(byte >> 4)] as char);
out.push(HEX[usize::from(byte & 15)] as char);
}
}
out
}