use crate::config::TokenFailure;
use crate::config::WireVersion;
use http::StatusCode;
use url::Url;
pub const TX_ISSUE_TYPE: &str = "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TxIssue {
pub code: String,
pub display: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub severity: String,
pub code: String,
pub details: Option<String>,
pub diagnostics: Option<String>,
pub tx_issue_type: Option<TxIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub issues: Vec<Issue>,
}
impl Outcome {
pub fn tx_issues(&self) -> impl Iterator<Item = &TxIssue> {
self.issues
.iter()
.filter_map(|issue| issue.tx_issue_type.as_ref())
}
#[must_use]
pub fn has_tx_issue(&self, code: &str) -> bool {
self.tx_issues().any(|issue| issue.code == code)
}
}
#[derive(Debug, Clone)]
pub struct UpstreamError {
status: StatusCode,
body: String,
outcome: Option<Outcome>,
}
impl UpstreamError {
#[must_use]
pub fn new(version: WireVersion, status: StatusCode, body: String) -> Self {
let outcome = crate::wire::outcome(version, &body).ok();
Self {
status,
body,
outcome,
}
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn outcome(&self) -> Option<&Outcome> {
self.outcome.as_ref()
}
pub fn tx_issues(&self) -> impl Iterator<Item = &TxIssue> {
self.outcome.iter().flat_map(Outcome::tx_issues)
}
#[must_use]
pub fn has_tx_issue(&self, code: &str) -> bool {
self.tx_issues().any(|issue| issue.code == code)
}
}
impl std::fmt::Display for UpstreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self
.outcome
.as_ref()
.and_then(|outcome| outcome.issues.first())
{
Some(issue) => write!(f, "{} ({})", self.status, issue.code),
None => write!(f, "{}", self.status),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BodyError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Parameters(#[from] fhir_types::operation::ParametersError),
#[error(transparent)]
Encode(#[from] fhir_types::codec::EncodeError),
#[error("the answer states no value for the mandatory parameter `{parameter}`")]
MissingValue {
parameter: &'static str,
},
#[error("a batch answered a `{found}` Bundle, not a `batch-response`")]
BatchType {
found: String,
},
#[error("a batch response entry carries no usable response.status")]
EntryStatus,
#[error("a batch response entry carries no resource")]
EntryResource,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{base} cannot be a base URL for a FHIR service")]
BaseUrl {
base: Url,
},
#[error("the HTTP client could not be built")]
ClientBuild {
#[source]
source: reqwest::Error,
},
#[error("the {operation} request could not be written as FHIR JSON")]
RequestBody {
operation: &'static str,
#[source]
source: serde_json::Error,
},
#[error(
"the {operation} request carries `{parameter}`, which FHIR {} declares no parameter for; nothing was sent",
.wire_version.as_str()
)]
UnsupportedParameter {
operation: &'static str,
parameter: &'static str,
wire_version: WireVersion,
},
#[error("the request to {url} could not be sent")]
Transport {
url: Url,
#[source]
source: reqwest::Error,
},
#[error("the request to {url} timed out")]
Timeout {
url: Url,
#[source]
source: reqwest::Error,
},
#[error("the terminology server answered {upstream} for {url}")]
ServerFailure {
url: Url,
upstream: Box<UpstreamError>,
},
#[error("the terminology server refused the credentials for {url}")]
Unauthorized {
url: Url,
challenge: Option<String>,
upstream: Box<UpstreamError>,
},
#[error("no token could be obtained for the request to {url}")]
Token {
url: Url,
#[source]
source: TokenFailure,
},
#[error("the terminology server refused the token for {url} and no new one could be obtained")]
Renewal {
url: Url,
upstream: Box<UpstreamError>,
#[source]
source: TokenFailure,
},
#[error("the terminology server refused {operation} with {upstream}")]
Refused {
operation: &'static str,
url: Url,
upstream: Box<UpstreamError>,
},
#[error("the body of the {operation} answer from {url} could not be decoded")]
Body {
operation: &'static str,
url: Url,
#[source]
source: Box<BodyError>,
},
#[error("the batch answered {answered} entries for {sent} requests")]
BatchArity {
sent: usize,
answered: usize,
},
}
impl Error {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::BaseUrl { .. } => "base-url",
Self::ClientBuild { .. } => "client-build",
Self::RequestBody { .. } => "request-body",
Self::UnsupportedParameter { .. } => "unsupported-parameter",
Self::Transport { .. } => "transport",
Self::Timeout { .. } => "timeout",
Self::ServerFailure { .. } => "server-failure",
Self::Unauthorized { .. } => "unauthorized",
Self::Token { .. } => "token",
Self::Renewal { .. } => "token-renewal",
Self::Refused { .. } => "refused",
Self::Body { .. } => "body",
Self::BatchArity { .. } => "batch-arity",
}
}
#[must_use]
pub fn upstream_status(&self) -> Option<StatusCode> {
match self {
Self::ServerFailure { upstream, .. }
| Self::Unauthorized { upstream, .. }
| Self::Renewal { upstream, .. }
| Self::Refused { upstream, .. } => Some(upstream.status()),
_ => None,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::Transport { .. } | Self::Timeout { .. } | Self::ServerFailure { .. }
)
}
}
#[cfg(test)]
mod tests {
use super::{Error, TX_ISSUE_TYPE, TxIssue, UpstreamError};
use crate::config::WireVersion;
use http::StatusCode;
fn outcome_body(tx_code: &str) -> String {
serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "code-invalid",
"details": {
"coding": [{"system": TX_ISSUE_TYPE, "code": tx_code, "display": "Invalid code"}],
"text": "the code is not in the code system",
},
"diagnostics": "Unknown_Code_in_Version",
}],
})
.to_string()
}
#[test]
fn a_tx_issue_type_coding_is_surfaced() {
let upstream = UpstreamError::new(
WireVersion::R4,
StatusCode::BAD_REQUEST,
outcome_body("invalid-code"),
);
let outcome = upstream.outcome().expect("the body is an OperationOutcome");
assert_eq!(1, outcome.issues.len());
assert!(outcome.has_tx_issue("invalid-code"));
assert_eq!(
vec![&TxIssue {
code: "invalid-code".to_owned(),
display: Some("Invalid code".to_owned()),
}],
upstream.tx_issues().collect::<Vec<_>>()
);
}
#[test]
fn an_r4b_outcome_decodes_the_same_way() {
let upstream = UpstreamError::new(
WireVersion::R4B,
StatusCode::NOT_FOUND,
outcome_body("not-found"),
);
assert!(
upstream
.outcome()
.is_some_and(|outcome| outcome.has_tx_issue("not-found"))
);
}
#[test]
fn a_body_that_is_not_an_outcome_stays_raw() {
let upstream =
UpstreamError::new(WireVersion::R4, StatusCode::BAD_GATEWAY, "no".to_owned());
assert!(upstream.outcome().is_none());
assert_eq!("no", upstream.body());
assert_eq!("502 Bad Gateway", upstream.to_string());
}
#[test]
fn only_transport_timeout_and_server_errors_are_retried() {
let url: url::Url = "http://tx.invalid/r4".parse().expect("a valid URL");
let server = Error::ServerFailure {
url: url.clone(),
upstream: Box::new(UpstreamError::new(
WireVersion::R4,
StatusCode::SERVICE_UNAVAILABLE,
String::new(),
)),
};
assert!(server.is_retryable());
let refused = Error::Refused {
operation: "CodeSystem/$lookup",
url,
upstream: Box::new(UpstreamError::new(
WireVersion::R4,
StatusCode::BAD_REQUEST,
String::new(),
)),
};
assert!(!refused.is_retryable());
assert_eq!("refused", refused.kind());
assert!(
!Error::BatchArity {
sent: 2,
answered: 1
}
.is_retryable()
);
}
}