use std::fmt;
use std::io::Read as _;
use std::time::Duration;
use base64::Engine as _;
use crate::bench::BenchError;
pub const PASSWORD_ENV: &str = "VEREDICTUM_BENCH_PASSWORD";
pub const TOKEN_ENV: &str = "VEREDICTUM_BENCH_TOKEN";
pub const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthKind {
None,
Basic,
Bearer,
}
impl AuthKind {
pub const ALL: &[AuthKind] = &[AuthKind::None, AuthKind::Basic, AuthKind::Bearer];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
AuthKind::None => "none",
AuthKind::Basic => "basic",
AuthKind::Bearer => "bearer",
}
}
pub fn parse(token: &str) -> Result<Self, BenchError> {
Self::ALL
.iter()
.copied()
.find(|mode| mode.as_str() == token)
.ok_or_else(|| BenchError::UnknownToken {
vocabulary: "auth mode",
token: token.to_owned(),
accepted: Self::ALL
.iter()
.map(|mode| mode.as_str())
.collect::<Vec<_>>()
.join(", "),
})
}
}
impl fmt::Display for AuthKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferReturn {
Unstated,
Minimal,
Identifier,
}
impl PreferReturn {
pub const ALL: &[PreferReturn] = &[
PreferReturn::Unstated,
PreferReturn::Minimal,
PreferReturn::Identifier,
];
#[must_use]
pub const fn header_value(self) -> Option<&'static str> {
match self {
PreferReturn::Unstated => None,
PreferReturn::Minimal => Some("return=minimal"),
PreferReturn::Identifier => Some("return=identifier"),
}
}
}
impl fmt::Display for PreferReturn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.header_value().unwrap_or("(unstated)"))
}
}
#[derive(Debug)]
pub struct BenchReply {
pub status: reqwest::StatusCode,
pub etag: Option<String>,
pub location: Option<String>,
pub content_encoding: Option<String>,
pub body: Vec<u8>,
}
#[derive(Clone)]
pub struct BenchClient {
client: reqwest::blocking::Client,
base_url: String,
authorization: Option<String>,
}
impl fmt::Debug for BenchClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BenchClient")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl BenchClient {
pub fn new(base_url: &str, auth: AuthKind, user: Option<&str>) -> Result<Self, BenchError> {
Self::with_credential(base_url, auth, user, None)
}
pub fn with_credential(
base_url: &str,
auth: AuthKind,
user: Option<&str>,
secret: Option<&str>,
) -> Result<Self, BenchError> {
let from_env = |name: &'static str| match secret {
Some(secret) => Ok(secret.to_owned()),
None => std::env::var(name).map_err(|source| BenchError::Credential { name, source }),
};
let authorization = match auth {
AuthKind::None => None,
AuthKind::Basic => {
let user = user.ok_or(BenchError::MissingUser)?;
let password = from_env(PASSWORD_ENV)?;
let encoded = base64::engine::general_purpose::STANDARD
.encode(format!("{user}:{password}").as_bytes());
Some(format!("Basic {encoded}"))
}
AuthKind::Bearer => Some(format!("Bearer {}", from_env(TOKEN_ENV)?)),
};
let client = reqwest::blocking::Client::builder()
.timeout(CLIENT_TIMEOUT)
.pool_max_idle_per_host(256)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|source| BenchError::Client { source })?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_owned(),
authorization,
})
}
pub fn without_decompression(&self) -> Result<Self, BenchError> {
let client = reqwest::blocking::Client::builder()
.timeout(CLIENT_TIMEOUT)
.pool_max_idle_per_host(4)
.redirect(reqwest::redirect::Policy::none())
.gzip(false)
.brotli(false)
.build()
.map_err(|source| BenchError::Client { source })?;
Ok(Self {
client,
base_url: self.base_url.clone(),
authorization: self.authorization.clone(),
})
}
pub fn without_credential(&self) -> Result<Self, BenchError> {
let mut anonymous = self.without_decompression()?;
anonymous.authorization = None;
Ok(anonymous)
}
#[must_use]
pub fn recorded_base_url(&self) -> String {
strip_userinfo(&self.base_url)
}
pub fn send(
&self,
exchange: &str,
method: reqwest::Method,
path: &str,
body: Option<(&'static str, Vec<u8>)>,
prefer: PreferReturn,
) -> Result<BenchReply, BenchError> {
self.send_with_headers(exchange, method, path, body, prefer, &[])
}
pub fn send_with_headers(
&self,
exchange: &str,
method: reqwest::Method,
path: &str,
body: Option<(&'static str, Vec<u8>)>,
prefer: PreferReturn,
extra: &[(&'static str, &'static str)],
) -> Result<BenchReply, BenchError> {
let accept = match &body {
Some((media_type, _)) if media_type.contains("xml") => "application/xml",
_ => "application/json",
};
let mut request = self
.client
.request(method, format!("{}{path}", self.base_url))
.header("Accept", accept);
if let Some(authorization) = &self.authorization {
request = request.header("Authorization", authorization);
}
if let Some(preference) = prefer.header_value() {
request = request.header("Prefer", preference);
}
if let Some((media_type, bytes)) = body {
request = request.header("Content-Type", media_type).body(bytes);
}
for (name, value) in extra {
request = request.header(*name, *value);
}
let response = request.send().map_err(|source| BenchError::Transport {
exchange: exchange.to_owned(),
source,
})?;
let header = |name: &str| {
response
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
};
let status = response.status();
let etag = header("etag");
let location = header("location");
let content_encoding = header("content-encoding");
let mut sink = Vec::new();
let mut reader = response;
let _drained = reader.read_to_end(&mut sink);
Ok(BenchReply {
status,
etag,
location,
content_encoding,
body: sink,
})
}
}
#[must_use]
pub fn strip_userinfo(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let (authority, path) = match rest.find('/') {
Some(cut) => rest.split_at(cut),
None => (rest, ""),
};
let Some((_userinfo, host)) = authority.rsplit_once('@') else {
return url.to_owned();
};
format!("{scheme}://{host}{path}")
}
#[must_use]
pub fn strip_weak_quotes(etag: &str) -> String {
etag.trim_start_matches("W/").trim_matches('"').to_owned()
}
#[must_use]
pub fn location_last_segment(location: &str) -> Option<String> {
location
.rsplit('/')
.next()
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
}
#[must_use]
#[expect(
clippy::disallowed_types,
reason = "the approved wire-body seam: an identifier reply is a JSON document read for one attribute"
)]
pub fn created_identifier(reply: &BenchReply) -> Option<String> {
let from_body = serde_json::from_slice::<serde_json::Value>(&reply.body)
.ok()
.and_then(|document| {
document.pointer("/uid").and_then(|uid| {
uid.as_str().map(str::to_owned).or_else(|| {
uid.pointer("/value")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
})
})
.filter(|uid| !uid.is_empty());
from_body
.or_else(|| reply.etag.as_deref().map(strip_weak_quotes))
.or_else(|| reply.location.as_deref().and_then(location_last_segment))
.filter(|identifier| !identifier.is_empty())
}
#[must_use]
pub fn query_value(value: &str) -> String {
urlencoding::encode(value).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn userinfo_is_stripped_from_a_recorded_target() {
assert_eq!(
strip_userinfo("https://alice:s3cret@cdr.example/openehr/v1"),
"https://cdr.example/openehr/v1"
);
assert_eq!(
strip_userinfo("https://alice@cdr.example"),
"https://cdr.example"
);
assert_eq!(
strip_userinfo("http://127.0.0.1:8080/rest/openehr/v1"),
"http://127.0.0.1:8080/rest/openehr/v1"
);
assert_eq!(strip_userinfo("not-a-url"), "not-a-url");
}
#[test]
fn identifier_captures_match_the_wire_forms() {
assert_eq!(strip_weak_quotes("W/\"abc::sys::1\""), "abc::sys::1");
assert_eq!(strip_weak_quotes("\"abc\""), "abc");
assert_eq!(
location_last_segment("http://sut/ehr/42").as_deref(),
Some("42")
);
assert_eq!(location_last_segment("http://sut/ehr/"), None);
}
#[test]
fn an_unknown_auth_token_is_refused() {
assert_eq!(AuthKind::parse("bearer").ok(), Some(AuthKind::Bearer));
let error = AuthKind::parse("Bearer").unwrap_err();
assert!(error.to_string().contains("none, basic, bearer"), "{error}");
}
#[test]
fn every_preference_sends_its_own_header_value() {
assert_eq!(PreferReturn::Unstated.header_value(), None);
assert_eq!(PreferReturn::Minimal.header_value(), Some("return=minimal"));
assert_eq!(
PreferReturn::Identifier.header_value(),
Some("return=identifier")
);
assert_eq!(PreferReturn::ALL.len(), 3);
}
#[test]
fn a_created_identifier_reads_the_body_before_the_headers() {
let reply = |body: &str, etag: Option<&str>, location: Option<&str>| BenchReply {
status: reqwest::StatusCode::CREATED,
etag: etag.map(str::to_owned),
location: location.map(str::to_owned),
content_encoding: None,
body: body.as_bytes().to_vec(),
};
assert_eq!(
created_identifier(&reply(
r#"{"uid":"body::sys::1"}"#,
Some("\"etag::sys::1\""),
Some("http://sut/ehr/loc")
))
.as_deref(),
Some("body::sys::1")
);
assert_eq!(
created_identifier(&reply(r#"{"uid":{"value":"nested::sys::1"}}"#, None, None))
.as_deref(),
Some("nested::sys::1")
);
assert_eq!(
created_identifier(&reply("", Some("W/\"etag::sys::1\""), None)).as_deref(),
Some("etag::sys::1")
);
assert_eq!(
created_identifier(&reply("", None, Some("http://sut/ehr/EHR-9"))).as_deref(),
Some("EHR-9")
);
assert_eq!(created_identifier(&reply("", None, None)), None);
}
#[test]
fn a_query_value_is_percent_encoded() {
assert_eq!(
query_value("2026-08-29T10:11:12.5Z"),
"2026-08-29T10%3A11%3A12.5Z"
);
assert!(!query_value("2026-08-29T10:11:12+02:00").contains('+'));
}
#[test]
fn basic_without_a_user_is_refused() {
let error = BenchClient::new("http://stub", AuthKind::Basic, None).unwrap_err();
assert!(matches!(error, BenchError::MissingUser), "{error}");
}
}