use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, PROXY_AUTHORIZATION, WWW_AUTHENTICATE};
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use secrecy::{ExposeSecret as _, SecretString};
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::generated::common::Error;
const CANONICAL_JSON: &str = "application/json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequestTimeout(pub Duration);
#[async_trait::async_trait]
pub trait Transport: Send + Sync {
async fn send(
&self,
request: http::Request<Vec<u8>>,
) -> Result<http::Response<Vec<u8>>, TransportError>;
}
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
#[error("the request did not complete inside the engine's timeout")]
Timeout {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("the request could not be sent")]
Send {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("the {status} answer is longer than the {limit}-byte limit")]
BodyTooLarge {
status: StatusCode,
limit: usize,
},
}
#[derive(Debug, Clone)]
pub struct ReqwestTransport {
client: reqwest::Client,
timeout: Option<Duration>,
max_body: Option<usize>,
}
impl ReqwestTransport {
pub fn new(builder: reqwest::ClientBuilder) -> Result<Self, reqwest::Error> {
Ok(Self {
client: builder
.redirect(reqwest::redirect::Policy::none())
.build()?,
timeout: None,
max_body: None,
})
}
pub fn with_builder_timeout(
builder: reqwest::ClientBuilder,
timeout: Duration,
) -> Result<Self, reqwest::Error> {
Ok(Self {
client: builder
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.build()?,
timeout: Some(timeout),
max_body: None,
})
}
pub fn with_timeout(timeout: Duration) -> Result<Self, reqwest::Error> {
Self::with_builder_timeout(reqwest::Client::builder(), timeout)
}
#[must_use]
pub fn with_max_body(mut self, limit: usize) -> Self {
self.max_body = Some(limit);
self
}
}
fn engine_error(source: reqwest::Error) -> TransportError {
if source.is_timeout() {
TransportError::Timeout {
source: Box::new(source),
}
} else {
TransportError::Send {
source: Box::new(source),
}
}
}
async fn read_body(
mut response: reqwest::Response,
limit: Option<usize>,
) -> Result<Vec<u8>, TransportError> {
let Some(limit) = limit else {
return Ok(response.bytes().await.map_err(engine_error)?.to_vec());
};
let too_large = TransportError::BodyTooLarge {
status: response.status(),
limit,
};
let declared = match response.content_length().map(usize::try_from) {
None => 0,
Some(Ok(declared)) if declared <= limit => declared,
Some(_) => return Err(too_large),
};
let mut body = Vec::with_capacity(declared);
while let Some(chunk) = response.chunk().await.map_err(engine_error)? {
if chunk.len() > limit.saturating_sub(body.len()) {
return Err(too_large);
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
#[async_trait::async_trait]
impl Transport for ReqwestTransport {
async fn send(
&self,
request: http::Request<Vec<u8>>,
) -> Result<http::Response<Vec<u8>>, TransportError> {
let budget = request.extensions().get::<RequestTimeout>().map(|t| t.0);
let mut request =
reqwest::Request::try_from(request).map_err(|source| TransportError::Send {
source: Box::new(source),
})?;
if let Some(budget) = budget {
let timeout = self.timeout.map_or(budget, |own| own.min(budget));
*request.timeout_mut() = Some(timeout);
}
let response = self.client.execute(request).await.map_err(engine_error)?;
let status = response.status();
let headers = response.headers().clone();
let body = read_body(response, self.max_body).await?;
let mut out = http::Response::new(body);
*out.status_mut() = status;
*out.headers_mut() = headers;
Ok(out)
}
}
#[derive(Clone)]
pub enum Credentials {
Basic {
user: String,
password: SecretString,
},
Bearer(SecretString),
Dpop(SecretString),
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Basic { user, .. } => f
.debug_struct("Basic")
.field("user", user)
.field("password", &"<redacted>")
.finish(),
Self::Bearer(_) => f.debug_tuple("Bearer").field(&"<redacted>").finish(),
Self::Dpop(_) => f.debug_tuple("Dpop").field(&"<redacted>").finish(),
}
}
}
impl Credentials {
#[must_use]
pub fn basic(user: impl Into<String>, password: impl Into<SecretString>) -> Self {
Self::Basic {
user: user.into(),
password: password.into(),
}
}
#[must_use]
pub fn bearer(token: impl Into<SecretString>) -> Self {
Self::Bearer(token.into())
}
#[must_use]
pub fn dpop(token: impl Into<SecretString>) -> Self {
Self::Dpop(token.into())
}
pub fn header_value(&self) -> Result<HeaderValue, InvalidCredentials> {
use base64::Engine as _;
let text = match self {
Self::Basic { user, password } => {
if user.contains(':') {
return Err(InvalidCredentials::ColonInUserId);
}
if user.chars().any(|c| c.is_ascii_control()) {
return Err(InvalidCredentials::ControlCharacter(BasicPart::UserId));
}
if password
.expose_secret()
.chars()
.any(|c| c.is_ascii_control())
{
return Err(InvalidCredentials::ControlCharacter(BasicPart::Password));
}
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{user}:{}", password.expose_secret()))
)
}
Self::Bearer(token) => {
if !is_b64token(token.expose_secret()) {
return Err(InvalidCredentials::NotB64Token);
}
format!("Bearer {}", token.expose_secret())
}
Self::Dpop(token) => {
if !is_b64token(token.expose_secret()) {
return Err(InvalidCredentials::NotToken68);
}
format!("DPoP {}", token.expose_secret())
}
};
let mut value =
HeaderValue::from_str(&text).map_err(InvalidCredentials::NotAHeaderValue)?;
value.set_sensitive(true);
Ok(value)
}
}
fn is_b64token(token: &str) -> bool {
let body = token.trim_end_matches('=');
!body.is_empty()
&& body.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
})
}
#[derive(Debug, thiserror::Error)]
pub enum InvalidCredentials {
#[error("the basic user-id contains a colon (RFC 7617 §2)")]
ColonInUserId,
#[error("the basic {0} contains a control character (RFC 7617 §2)")]
ControlCharacter(BasicPart),
#[error("the bearer token is not a b64token (RFC 6750 §2.1)")]
NotB64Token,
#[error("the DPoP access token is not a token68 (RFC 9449 §7.1)")]
NotToken68,
#[error("the credentials do not form a legal Authorization header value")]
NotAHeaderValue(#[source] http::header::InvalidHeaderValue),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BasicPart {
UserId,
Password,
}
impl fmt::Display for BasicPart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::UserId => "user-id",
Self::Password => "password",
})
}
}
#[async_trait::async_trait]
pub trait CredentialsProvider: Send + Sync + fmt::Debug {
async fn credentials(&self) -> Result<Credentials, CredentialsError>;
fn refused(&self) {}
}
#[async_trait::async_trait]
impl CredentialsProvider for Credentials {
async fn credentials(&self) -> Result<Credentials, CredentialsError> {
Ok(self.clone())
}
}
#[async_trait::async_trait]
impl<P: CredentialsProvider + ?Sized> CredentialsProvider for Arc<P> {
async fn credentials(&self) -> Result<Credentials, CredentialsError> {
P::credentials(self).await
}
fn refused(&self) {
P::refused(self);
}
}
#[derive(Debug, thiserror::Error)]
#[error("the credentials provider could not produce a credential")]
pub struct CredentialsError {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
}
impl CredentialsError {
#[must_use]
pub fn new(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self {
source: source.into(),
}
}
}
const DPOP: HeaderName = HeaderName::from_static("dpop");
const DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce");
#[async_trait::async_trait]
pub trait DpopProver: Send + Sync + fmt::Debug {
async fn proof(&self, request: &DpopProofRequest<'_>) -> Result<String, CredentialsError>;
fn nonce(&self, nonce: &str);
}
#[async_trait::async_trait]
impl<P: DpopProver + ?Sized> DpopProver for Arc<P> {
async fn proof(&self, request: &DpopProofRequest<'_>) -> Result<String, CredentialsError> {
P::proof(self, request).await
}
fn nonce(&self, nonce: &str) {
P::nonce(self, nonce);
}
}
#[derive(Debug, Clone, Copy)]
pub struct DpopProofRequest<'a> {
method: &'a Method,
uri: &'a http::Uri,
access_token: &'a SecretString,
}
impl<'a> DpopProofRequest<'a> {
#[must_use]
pub fn method(&self) -> &'a Method {
self.method
}
#[must_use]
pub fn uri(&self) -> &'a http::Uri {
self.uri
}
#[must_use]
pub fn access_token(&self) -> &'a SecretString {
self.access_token
}
}
fn is_nonce_challenge(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> bool {
if status == StatusCode::UNAUTHORIZED {
return headers.get_all(WWW_AUTHENTICATE).iter().any(|value| {
value.to_str().is_ok_and(|text| {
text.split_once(' ').is_some_and(|(scheme, params)| {
scheme.eq_ignore_ascii_case("DPoP") && params.contains("use_dpop_nonce")
})
})
});
}
status == StatusCode::BAD_REQUEST
&& serde_json::from_slice::<OAuthErrorBody>(body)
.is_ok_and(|parsed| parsed.error == "use_dpop_nonce")
}
#[derive(serde::Deserialize)]
struct OAuthErrorBody {
error: String,
}
#[derive(Debug, Clone, Default)]
pub struct CallOptions {
deadline: Option<Instant>,
headers: HeaderMap,
}
impl CallOptions {
#[must_use]
pub fn with_deadline(mut self, deadline: Instant) -> Self {
self.deadline = Some(deadline);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.deadline = Instant::now().checked_add(timeout);
self
}
pub fn with_header(mut self, name: &str, value: &str) -> Result<Self, ClientError> {
let (name, mut value) = header_field(name, value)?;
if name == AUTHORIZATION || name == PROXY_AUTHORIZATION {
value.set_sensitive(true);
}
self.headers.append(name, value);
Ok(self)
}
#[must_use]
pub fn deadline(&self) -> Option<Instant> {
self.deadline
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
}
fn header_field(name: &str, value: &str) -> Result<(HeaderName, HeaderValue), ClientError> {
let header_name =
HeaderName::from_bytes(name.as_bytes()).map_err(|source| ClientError::HeaderName {
header: name.to_owned(),
source,
})?;
let header_value = HeaderValue::from_str(value).map_err(|source| ClientError::HeaderValue {
header: name.to_owned(),
source,
})?;
Ok((header_name, header_value))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: usize,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(2),
}
}
}
#[derive(Debug, Clone)]
pub struct Client<T> {
transport: T,
base: url::Url,
credentials: Option<Arc<dyn CredentialsProvider>>,
dpop: Option<Arc<dyn DpopProver>>,
retry: RetryPolicy,
}
impl<T: Transport> Client<T> {
pub fn new(transport: T, base: url::Url) -> Result<Self, ClientError> {
if base.cannot_be_a_base() {
return Err(ClientError::BaseUrl { base });
}
Ok(Self {
transport,
base,
credentials: None,
dpop: None,
retry: RetryPolicy::default(),
})
}
#[must_use]
pub fn with_credentials(mut self, credentials: Credentials) -> Self {
self.credentials = Some(Arc::new(credentials));
self
}
#[must_use]
pub fn with_credentials_provider(
mut self,
provider: impl CredentialsProvider + 'static,
) -> Self {
self.credentials = Some(Arc::new(provider));
self
}
#[must_use]
pub fn with_dpop_prover(mut self, prover: impl DpopProver + 'static) -> Self {
self.dpop = Some(Arc::new(prover));
self
}
#[must_use]
pub fn with_retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
#[must_use]
pub fn base(&self) -> &url::Url {
&self.base
}
#[must_use]
pub fn transport(&self) -> &T {
&self.transport
}
#[must_use]
pub fn retry(&self) -> RetryPolicy {
self.retry
}
pub async fn execute(&self, request: Request) -> Result<Answer, ClientError> {
use backon::Retryable as _;
if !is_idempotent(&request.method) || self.retry.max_attempts <= 1 {
return self.attempt(&request, false).await;
}
let retries = self.retry.max_attempts.saturating_sub(1);
let backoff = backon::ExponentialBuilder::new()
.with_min_delay(self.retry.initial_backoff)
.with_max_delay(self.retry.max_backoff)
.with_max_times(retries);
let deadline = request.deadline;
let sent = std::sync::atomic::AtomicBool::new(false);
(|| {
let earlier = sent.swap(true, std::sync::atomic::Ordering::Relaxed);
self.attempt(&request, earlier)
})
.retry(backoff)
.when(ClientError::is_retryable)
.adjust(move |_, delay| {
delay.filter(|delay| {
deadline.is_none_or(|deadline| {
Instant::now()
.checked_add(*delay)
.is_some_and(|wake| wake < deadline)
})
})
})
.await
}
async fn attempt(&self, request: &Request, sent: bool) -> Result<Answer, ClientError> {
let answer = self.forward(request, sent).await?;
if answer.status == StatusCode::UNAUTHORIZED {
return Err(ClientError::Unauthorized {
method: answer.method,
path: answer.path,
challenge: header_text(&answer.headers, WWW_AUTHENTICATE.as_str()),
body: ErrorBody::from_bytes(answer.body),
});
}
if answer.status == StatusCode::FORBIDDEN {
return Err(ClientError::Forbidden {
method: answer.method,
path: answer.path,
body: ErrorBody::from_bytes(answer.body),
});
}
if answer.status.is_server_error() {
return Err(ClientError::ServiceFailure {
method: answer.method,
path: answer.path,
status: answer.status,
body: ErrorBody::from_bytes(answer.body),
});
}
Ok(answer)
}
pub async fn forward(&self, request: &Request, mut sent: bool) -> Result<Answer, ClientError> {
request.remaining(sent)?;
let credentials =
match self.credentials.as_ref() {
Some(provider) => Some(provider.credentials().await.map_err(|source| {
ClientError::Credentials {
method: request.method.clone(),
path: request.path.clone(),
source,
}
})?),
None => None,
};
let prover = match (&credentials, self.dpop.as_ref()) {
(Some(Credentials::Dpop(token)), Some(prover)) => Some((prover, token)),
_ => None,
};
let mut nonce_resend = prover.is_some();
let (parts, body) = loop {
let mut built = self.build(request, credentials.as_ref())?;
if let Some((prover, token)) = prover {
let proof = prover
.proof(&DpopProofRequest {
method: built.method(),
uri: built.uri(),
access_token: token,
})
.await
.map_err(|source| ClientError::DpopProof {
method: request.method.clone(),
path: request.path.clone(),
sent,
source,
})?;
let mut value =
HeaderValue::from_str(&proof).map_err(|source| ClientError::HeaderValue {
header: DPOP.to_string(),
source,
})?;
value.set_sensitive(true);
built.headers_mut().insert(DPOP, value);
}
if let Some(left) = request.remaining(sent)? {
built.extensions_mut().insert(RequestTimeout(left));
}
let response = self
.transport
.send(built)
.await
.map_err(|source| match source {
TransportError::BodyTooLarge { status, limit } => ClientError::AnswerTooLarge {
method: request.method.clone(),
path: request.path.clone(),
status,
limit,
},
source => ClientError::Transport {
method: request.method.clone(),
path: request.path.clone(),
source,
},
})?;
sent = true;
let (parts, body) = response.into_parts();
if let Some((prover, _)) = prover
&& let Some(nonce) = header_text(&parts.headers, DPOP_NONCE.as_str())
{
prover.nonce(&nonce);
if nonce_resend && is_nonce_challenge(parts.status, &parts.headers, &body) {
nonce_resend = false;
continue;
}
}
break (parts, body);
};
if parts.status == StatusCode::UNAUTHORIZED
&& let Some(provider) = self.credentials.as_ref()
{
provider.refused();
}
Ok(Answer {
method: request.method.clone(),
path: request.path.clone(),
status: parts.status,
headers: parts.headers,
body,
})
}
fn build(
&self,
request: &Request,
credentials: Option<&Credentials>,
) -> Result<http::Request<Vec<u8>>, ClientError> {
let mut url = self.base.clone();
let root = self.base.path().trim_end_matches('/').to_owned();
url.set_path(&format!("{root}{}", request.path));
url.set_query((!request.query.is_empty()).then_some(request.query.as_str()));
let mut builder = http::Request::builder()
.method(request.method.clone())
.uri(url.as_str());
if let Some(headers) = builder.headers_mut() {
headers.extend(request.headers.clone());
if !headers.contains_key(ACCEPT) {
headers.insert(ACCEPT, HeaderValue::from_static(CANONICAL_JSON));
}
if let Some(credentials) = credentials {
let value = credentials.header_value().map_err(|source| {
ClientError::InvalidCredentials {
method: request.method.clone(),
path: request.path.clone(),
source,
}
})?;
headers.insert(AUTHORIZATION, value);
}
}
builder
.body(request.body.clone().unwrap_or_default())
.map_err(|source| ClientError::Build {
method: request.method.clone(),
path: request.path.clone(),
source,
})
}
}
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::OPTIONS | Method::PUT | Method::DELETE
)
}
fn header_text(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
#[derive(Clone)]
pub struct ErrorBody {
raw: Vec<u8>,
error: Option<Box<Error>>,
problem: Option<Box<Problem>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
kind: Option<String>,
title: Option<String>,
status: Option<StatusCode>,
detail: Option<String>,
instance: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Member<T> {
Typed(T),
Other(serde::de::IgnoredAny),
}
impl<T> Member<T> {
fn typed(self) -> Option<T> {
match self {
Self::Typed(value) => Some(value),
Self::Other(_) => None,
}
}
}
#[derive(serde::Deserialize)]
struct ProblemWire {
#[serde(rename = "type")]
kind: Option<Member<String>>,
title: Option<Member<String>>,
status: Option<Member<u64>>,
detail: Option<Member<String>>,
instance: Option<Member<String>>,
}
impl Problem {
#[must_use]
pub fn from_slice(body: &[u8]) -> Option<Self> {
let wire = serde_json::from_slice::<ProblemWire>(body).ok()?;
let problem = Self {
kind: wire.kind.and_then(Member::typed),
title: wire.title.and_then(Member::typed),
status: wire
.status
.and_then(Member::typed)
.and_then(|code| u16::try_from(code).ok())
.and_then(|code| StatusCode::from_u16(code).ok()),
detail: wire.detail.and_then(Member::typed),
instance: wire.instance.and_then(Member::typed),
};
let any = problem.kind.is_some()
|| problem.title.is_some()
|| problem.status.is_some()
|| problem.detail.is_some()
|| problem.instance.is_some();
any.then_some(problem)
}
#[must_use]
pub fn problem_type(&self) -> &str {
self.kind.as_deref().unwrap_or("about:blank")
}
#[must_use]
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
#[must_use]
pub fn status(&self) -> Option<StatusCode> {
self.status
}
#[must_use]
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
#[must_use]
pub fn instance(&self) -> Option<&str> {
self.instance.as_deref()
}
}
impl ErrorBody {
#[must_use]
pub fn from_bytes(raw: Vec<u8>) -> Self {
let error = serde_json::from_slice::<Error>(&raw).ok().map(Box::new);
let problem = Problem::from_slice(&raw).map(Box::new);
Self {
raw,
error,
problem,
}
}
#[must_use]
pub fn raw(&self) -> &[u8] {
&self.raw
}
#[must_use]
pub fn text(&self) -> Option<&str> {
std::str::from_utf8(&self.raw).ok()
}
#[must_use]
pub fn error(&self) -> Option<&Error> {
self.error.as_deref()
}
#[must_use]
pub fn problem(&self) -> Option<&Problem> {
self.problem.as_deref()
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.error.as_ref().map(|e| e.message.as_str()).or_else(|| {
self.problem
.as_ref()
.and_then(|problem| problem.detail().or_else(|| problem.title()))
})
}
#[must_use]
pub fn validation_errors(&self) -> &[String] {
self.error
.as_ref()
.map_or(&[], |e| e.validation_errors.as_slice())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.raw.is_empty()
}
}
impl fmt::Debug for ErrorBody {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ErrorBody")
.field("error", &self.error)
.field("problem", &self.problem)
.field("raw", &String::from_utf8_lossy(&self.raw))
.finish()
}
}
#[must_use]
pub fn path_segment(value: &impl fmt::Display) -> String {
urlencoding::encode(&value.to_string())
.replace("%3A", ":")
.replace("%40", "@")
}
#[derive(Debug, Clone)]
pub struct Request {
method: Method,
path: String,
query: String,
headers: HeaderMap,
body: Option<Vec<u8>>,
deadline: Option<Instant>,
}
impl Request {
#[must_use]
pub fn new(method: Method, path: String) -> Self {
Self {
method,
path,
query: String::new(),
headers: HeaderMap::new(),
body: None,
deadline: None,
}
}
#[must_use]
pub fn deadline(&self) -> Option<Instant> {
self.deadline
}
pub fn set_deadline(&mut self, deadline: Instant) {
self.deadline = Some(self.deadline.map_or(deadline, |set| set.min(deadline)));
}
pub fn apply_options(&mut self, options: &CallOptions) {
if let Some(deadline) = options.deadline {
self.set_deadline(deadline);
}
for name in options.headers.keys() {
self.headers.remove(name);
}
for (name, value) in &options.headers {
self.headers.append(name.clone(), value.clone());
}
}
fn remaining(&self, sent: bool) -> Result<Option<Duration>, ClientError> {
let Some(deadline) = self.deadline else {
return Ok(None);
};
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(ClientError::DeadlineElapsed {
method: self.method.clone(),
path: self.path.clone(),
sent,
});
}
Ok(Some(left))
}
#[must_use]
pub fn method(&self) -> &Method {
&self.method
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn query_string(&self) -> &str {
&self.query
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn headers_mut(&mut self) -> &mut HeaderMap {
&mut self.headers
}
pub fn raw_query(&mut self, query: &str) {
query.clone_into(&mut self.query);
}
pub fn raw_body(&mut self, body: Vec<u8>, content_type: Option<HeaderValue>) {
if let Some(content_type) = content_type {
self.headers.insert(CONTENT_TYPE, content_type);
}
self.body = Some(body);
}
#[must_use]
pub fn body(&self) -> Option<&[u8]> {
self.body.as_deref()
}
pub fn query(&mut self, name: &str, value: impl fmt::Display) {
if !self.query.is_empty() {
self.query.push('&');
}
self.query.push_str(&urlencoding::encode(name));
self.query.push('=');
self.query
.push_str(&urlencoding::encode(&value.to_string()));
}
pub fn header(&mut self, name: &str, value: &str) -> Result<(), ClientError> {
let (header_name, header_value) = header_field(name, value)?;
self.headers.append(header_name, header_value);
Ok(())
}
pub fn json_body<B: serde::Serialize + ?Sized>(
&mut self,
body: &B,
content_type: Option<&str>,
) -> Result<(), ClientError> {
let media = content_type.map(|ct| ct.split(';').next().unwrap_or_default().trim());
if let Some(requested) = content_type
&& !media.is_some_and(|m| m.eq_ignore_ascii_case(CANONICAL_JSON))
{
return Err(ClientError::UnsupportedMediaType {
requested: requested.to_owned(),
});
}
let bytes = serde_json::to_vec(body).map_err(|source| ClientError::Serialize { source })?;
self.headers
.insert(CONTENT_TYPE, HeaderValue::from_static(CANONICAL_JSON));
self.body = Some(bytes);
Ok(())
}
pub fn text_body(&mut self, text: &str, content_type: &str) -> Result<(), ClientError> {
let value =
HeaderValue::from_str(content_type).map_err(|source| ClientError::HeaderValue {
header: CONTENT_TYPE.to_string(),
source,
})?;
self.headers.insert(CONTENT_TYPE, value);
self.body = Some(text.as_bytes().to_vec());
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Answer {
method: Method,
path: String,
status: StatusCode,
headers: HeaderMap,
body: Vec<u8>,
}
impl Answer {
#[must_use]
pub fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
#[must_use]
pub fn header(&self, name: &str) -> Option<String> {
header_text(&self.headers, name)
}
#[must_use]
pub fn header_all(&self, name: &str) -> Vec<String> {
self.headers
.get_all(name)
.iter()
.filter_map(|value| value.to_str().ok())
.map(str::to_owned)
.collect()
}
#[must_use]
pub fn body(&self) -> &[u8] {
&self.body
}
#[must_use]
pub fn into_body(self) -> Vec<u8> {
self.body
}
pub fn json<D: serde::de::DeserializeOwned>(&self) -> Result<D, ClientError> {
let mut deserializer = serde_json::Deserializer::from_slice(&self.body);
serde_path_to_error::deserialize(&mut deserializer).map_err(|source| ClientError::Body {
method: self.method.clone(),
path: self.path.clone(),
status: self.status,
source,
})
}
pub fn optional_json<D: serde::de::DeserializeOwned>(&self) -> Result<Option<D>, ClientError> {
if self.body.iter().all(u8::is_ascii_whitespace) {
return Ok(None);
}
self.json().map(Some)
}
#[must_use]
pub fn error_body(&self) -> ErrorBody {
ErrorBody::from_bytes(self.body.clone())
}
#[must_use]
pub fn into_undocumented(self) -> ClientError {
ClientError::UndocumentedStatus {
method: self.method,
path: self.path,
status: self.status,
body: ErrorBody::from_bytes(self.body),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("{base} cannot be the base of an ITS-REST path")]
BaseUrl {
base: url::Url,
},
#[error(
"the deadline of {method} {path} passed before the request was sent (an earlier send went out: {sent})"
)]
DeadlineElapsed {
method: Method,
path: String,
sent: bool,
},
#[error("no DPoP proof could be made for {method} {path} (an earlier send went out: {sent})")]
DpopProof {
method: Method,
path: String,
sent: bool,
#[source]
source: CredentialsError,
},
#[error("no credential could be obtained for {method} {path}")]
Credentials {
method: Method,
path: String,
#[source]
source: CredentialsError,
},
#[error("the {method} {path} request could not be sent")]
Transport {
method: Method,
path: String,
#[source]
source: TransportError,
},
#[error("the {status} answer to {method} {path} is longer than the {limit}-byte limit")]
AnswerTooLarge {
method: Method,
path: String,
status: StatusCode,
limit: usize,
},
#[error("the {method} {path} request could not be built")]
Build {
method: Method,
path: String,
#[source]
source: http::Error,
},
#[error("{header} is not a legal header name")]
HeaderName {
header: String,
#[source]
source: http::header::InvalidHeaderName,
},
#[error("the credentials for {method} {path} cannot be sent")]
InvalidCredentials {
method: Method,
path: String,
#[source]
source: InvalidCredentials,
},
#[error("the value for the {header} header is not a legal header value")]
HeaderValue {
header: String,
#[source]
source: http::header::InvalidHeaderValue,
},
#[error("the typed client sends canonical JSON, not {requested}")]
UnsupportedMediaType {
requested: String,
},
#[error("the request body could not be serialized")]
Serialize {
#[source]
source: serde_json::Error,
},
#[error("the openEHR service refused the credentials for {method} {path}")]
Unauthorized {
method: Method,
path: String,
challenge: Option<String>,
body: ErrorBody,
},
#[error("the openEHR service refuses to authorize {method} {path}")]
Forbidden {
method: Method,
path: String,
body: ErrorBody,
},
#[error("the openEHR service answered {status} for {method} {path}")]
ServiceFailure {
method: Method,
path: String,
status: StatusCode,
body: ErrorBody,
},
#[error(
"the openEHR service answered {status} for {method} {path}, a status outside the operation's documented set"
)]
UndocumentedStatus {
method: Method,
path: String,
status: StatusCode,
body: ErrorBody,
},
#[error("the {status} body of {method} {path} is not the documented shape")]
Body {
method: Method,
path: String,
status: StatusCode,
#[source]
source: serde_path_to_error::Error<serde_json::Error>,
},
}
impl ClientError {
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport { .. } => true,
Self::ServiceFailure { status, .. } => *status != StatusCode::NOT_IMPLEMENTED,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::{Credentials, ErrorBody, Request, path_segment};
use http::Method;
#[test]
fn a_path_segment_is_percent_encoded_with_the_pchar_extras_literal() {
assert_eq!(path_segment(&"Vital Signs"), "Vital%20Signs");
assert_eq!(path_segment(&"8849182c::system::1"), "8849182c::system::1");
assert_eq!(path_segment(&"a/b?c#d%3A"), "a%2Fb%3Fc%23d%253A");
assert_eq!(path_segment(&"user@host"), "user@host");
}
#[test]
fn an_error_body_decodes_the_its_rest_error_and_keeps_the_bytes() {
let full = ErrorBody::from_bytes(
br#"{"message":"unknown template","validationErrors":["/content: no template"]}"#
.to_vec(),
);
assert_eq!(full.message(), Some("unknown template"));
assert_eq!(full.validation_errors(), ["/content: no template"]);
let prose = ErrorBody::from_bytes(br#"{"message":"malformed body"}"#.to_vec());
assert_eq!(prose.message(), Some("malformed body"));
assert_eq!(prose.validation_errors(), Vec::<String>::new());
let other = ErrorBody::from_bytes(b"<html>gateway</html>".to_vec());
assert!(other.error().is_none());
assert_eq!(other.text(), Some("<html>gateway</html>"));
assert_eq!(other.raw(), b"<html>gateway</html>");
assert!(ErrorBody::from_bytes(Vec::new()).is_empty());
}
#[test]
fn an_error_body_decodes_problem_details() {
let full = ErrorBody::from_bytes(
br#"{"type":"https://example.org/problems#conflict","title":"Conflict","status":409,"detail":"the EHR is not modifiable","instance":"urn:uuid:7d44b88c-4199-4bad-97dc-d78268e01398","errors":[]}"#
.to_vec(),
);
let problem = full.problem().expect("a problem");
assert_eq!(
problem.problem_type(),
"https://example.org/problems#conflict"
);
assert_eq!(problem.title(), Some("Conflict"));
assert_eq!(problem.status(), Some(http::StatusCode::CONFLICT));
assert_eq!(problem.detail(), Some("the EHR is not modifiable"));
assert_eq!(
problem.instance(),
Some("urn:uuid:7d44b88c-4199-4bad-97dc-d78268e01398")
);
assert!(full.error().is_none());
assert_eq!(full.message(), Some("the EHR is not modifiable"));
assert_eq!(full.validation_errors(), Vec::<String>::new());
let titled = ErrorBody::from_bytes(br#"{"title":"Not Found","status":"404"}"#.to_vec());
let problem = titled.problem().expect("a problem");
assert_eq!(problem.problem_type(), "about:blank");
assert_eq!(problem.status(), None, "a string status is ignored");
assert_eq!(titled.message(), Some("Not Found"));
let mistyped = ErrorBody::from_bytes(br#"{"title":7,"detail":["x"]}"#.to_vec());
assert!(mistyped.problem().is_none());
assert_eq!(mistyped.message(), None);
let its = ErrorBody::from_bytes(br#"{"message":"unknown template"}"#.to_vec());
assert!(its.problem().is_none());
assert_eq!(its.message(), Some("unknown template"));
}
#[test]
fn query_pairs_are_encoded_and_joined() {
let mut request = Request::new(Method::GET, "/query/aql".to_owned());
request.query("q", "SELECT c FROM COMPOSITION c");
request.query("fetch", 10);
request.query("ehr_id", "7d44/x");
assert_eq!(
request.query_string(),
"q=SELECT%20c%20FROM%20COMPOSITION%20c&fetch=10&ehr_id=7d44%2Fx"
);
}
#[test]
fn a_typed_body_refuses_a_non_json_content_type() {
let mut request = Request::new(Method::POST, "/ehr".to_owned());
let refused = request.json_body("a typed body", Some("application/xml"));
assert!(refused.is_err());
assert!(request.body().is_none());
}
#[test]
fn credentials_debug_redacts_the_secret() {
let basic = Credentials::basic("alice", "hunter2");
let bearer = Credentials::bearer("eyJ.secret".to_owned());
assert!(!format!("{basic:?}").contains("hunter2"));
assert!(!format!("{bearer:?}").contains("secret"));
assert!(format!("{basic:?}").contains("alice"));
}
#[test]
#[expect(
clippy::panic_in_result_fn,
reason = "test assertions in the Book's Result-returning shape (.claude/rules/testing.md §Test shapes)"
)]
fn credentials_render_the_authorization_value() -> Result<(), Box<dyn std::error::Error>> {
let basic = Credentials::basic("alice", "hunter2").header_value()?;
assert_eq!(basic.to_str()?, "Basic YWxpY2U6aHVudGVyMg==");
assert!(basic.is_sensitive());
let bearer = Credentials::bearer("tok").header_value()?;
assert_eq!(bearer.to_str()?, "Bearer tok");
let padded = Credentials::bearer("mF_9.B5f-4.1JqM+/~==").header_value()?;
assert_eq!(padded.to_str()?, "Bearer mF_9.B5f-4.1JqM+/~==");
let colon = Credentials::basic("alice", "a:b").header_value()?;
assert_eq!(colon.to_str()?, "Basic YWxpY2U6YTpi");
let dpop =
Credentials::dpop("Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU").header_value()?;
assert_eq!(
dpop.to_str()?,
"DPoP Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"
);
assert!(dpop.is_sensitive());
assert!(matches!(
Credentials::dpop("not a token").header_value(),
Err(super::InvalidCredentials::NotToken68)
));
assert!(!format!("{:?}", Credentials::dpop("secret-dpop")).contains("secret"));
Ok(())
}
#[test]
fn credentials_breaking_rfc_7617_or_6750_are_refused() {
use super::{BasicPart, InvalidCredentials};
let refused = |c: Credentials| c.header_value().err();
assert!(matches!(
refused(Credentials::basic("al:ice", "pw")),
Some(InvalidCredentials::ColonInUserId)
));
assert!(matches!(
refused(Credentials::basic("al\tice", "pw")),
Some(InvalidCredentials::ControlCharacter(BasicPart::UserId))
));
for password in ["p\u{7f}w", "p\nw", "\0"] {
assert!(
matches!(
refused(Credentials::basic("alice", password)),
Some(InvalidCredentials::ControlCharacter(BasicPart::Password))
),
"{password:?}"
);
}
for token in ["", "=", "a b", "tok\n", "a=b", "t\u{e9}k", "a,b"] {
assert!(
matches!(
refused(Credentials::bearer(token.to_owned())),
Some(InvalidCredentials::NotB64Token)
),
"{token:?}"
);
}
assert!(refused(Credentials::basic("j\u{f6}rg", "p\u{e9}")).is_none());
let shown = InvalidCredentials::ControlCharacter(BasicPart::Password).to_string();
assert!(!shown.contains("p\nw"), "{shown}");
}
}