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>,
},
}
#[derive(Debug, Clone)]
pub struct ReqwestTransport {
client: reqwest::Client,
timeout: Option<Duration>,
}
impl ReqwestTransport {
pub fn new(builder: reqwest::ClientBuilder) -> Result<Self, reqwest::Error> {
Ok(Self {
client: builder
.redirect(reqwest::redirect::Policy::none())
.build()?,
timeout: 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),
})
}
pub fn with_timeout(timeout: Duration) -> Result<Self, reqwest::Error> {
Self::with_builder_timeout(reqwest::Client::builder(), timeout)
}
}
#[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(|source| {
if source.is_timeout() {
TransportError::Timeout {
source: Box::new(source),
}
} else {
TransportError::Send {
source: Box::new(source),
}
}
})?;
let status = response.status();
let headers = response.headers().clone();
let body = response
.bytes()
.await
.map_err(|source| TransportError::Send {
source: Box::new(source),
})?
.to_vec();
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),
}
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(),
}
}
}
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())
}
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())
}
};
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 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(),
}
}
}
#[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>>,
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,
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_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).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;
(|| self.attempt(&request))
.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
}
pub async fn forward(&self, request: Request) -> Result<Answer, ClientError> {
self.send_once(&request).await
}
async fn attempt(&self, request: &Request) -> Result<Answer, ClientError> {
let answer = self.send_once(request).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)
}
async fn send_once(&self, request: &Request) -> Result<Answer, ClientError> {
request.remaining()?;
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 mut built = self.build(request, credentials.as_ref())?;
if let Some(left) = request.remaining()? {
built.extensions_mut().insert(RequestTimeout(left));
}
let response =
self.transport
.send(built)
.await
.map_err(|source| ClientError::Transport {
method: request.method.clone(),
path: request.path.clone(),
source,
})?;
let (parts, body) = response.into_parts();
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>>,
}
impl ErrorBody {
#[must_use]
pub fn from_bytes(raw: Vec<u8>) -> Self {
let error = serde_json::from_slice::<Error>(&raw).ok().map(Box::new);
Self { raw, error }
}
#[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 message(&self) -> Option<&str> {
self.error.as_ref().map(|e| e.message.as_str())
}
#[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("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) -> 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(),
});
}
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")]
DeadlineElapsed {
method: Method,
path: String,
},
#[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 {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!(prose.validation_errors().is_empty());
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 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");
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}");
}
}