use std::fmt;
use fhir_types::codec::{Json, Object, Path, Value};
use fhir_types::r4::bundle::Bundle;
use fhir_types::r4::resource::Resource;
use fhir_types::r4::subscription::{Subscription, SubscriptionChannel};
use url::Url;
use crate::outcome;
use crate::search::escape;
use ihe_atna::redact::RedactedUrl;
use super::error::{InvalidInput, SubscriptionMalformation};
pub(super) const FHIR_JSON: &str = "application/fhir+json";
const REASON: &str = "Patient Master Identity changes for a federation gateway's resolution state";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Criteria {
AllPatients,
IdentifierSystem(String),
}
impl Criteria {
pub fn identifier_system(system: &str) -> Result<Self, InvalidInput> {
Url::parse(system).map_err(|_unparsable| InvalidInput::System)?;
Ok(Self::IdentifierSystem(system.to_owned()))
}
#[must_use]
pub fn text(&self) -> String {
match self {
Self::AllPatients => String::from("Patient"),
Self::IdentifierSystem(system) => format!("Patient?identifier={}|", escape(system)),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct SubscriptionRequest {
criteria: Criteria,
endpoint: Url,
}
impl SubscriptionRequest {
pub fn new(criteria: Criteria, endpoint: Url) -> Result<Self, InvalidInput> {
if !matches!(endpoint.scheme(), "http" | "https")
|| endpoint.cannot_be_a_base()
|| endpoint.fragment().is_some()
{
return Err(InvalidInput::Endpoint);
}
Ok(Self { criteria, endpoint })
}
#[must_use]
pub fn criteria(&self) -> &Criteria {
&self.criteria
}
#[must_use]
pub fn endpoint(&self) -> &Url {
&self.endpoint
}
#[must_use]
pub fn resource(&self) -> Subscription {
Subscription {
status: "requested".into(),
reason: REASON.into(),
criteria: self.criteria.text().into(),
channel: SubscriptionChannel {
r#type: "message".into(),
endpoint: Some(self.endpoint.as_str().into()),
payload: Some(FHIR_JSON.into()),
..SubscriptionChannel::default()
},
..Subscription::default()
}
}
}
impl fmt::Debug for SubscriptionRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SubscriptionRequest")
.field("criteria", &self.criteria)
.field("endpoint", &RedactedUrl(self.endpoint.as_str()))
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Subscribed {
location: Url,
}
impl Subscribed {
#[must_use]
pub fn location(&self) -> &Url {
&self.location
}
pub(super) fn at(endpoint: &Url, location: &str) -> Result<Self, SubscriptionMalformation> {
let resolved = endpoint
.join(location)
.map_err(|_unparsable| SubscriptionMalformation::Location)?;
let prefix = format!("{}/", endpoint.as_str().trim_end_matches('/'));
let tail = resolved
.as_str()
.strip_prefix(prefix.as_str())
.ok_or(SubscriptionMalformation::Location)?;
let id = match tail.split('/').collect::<Vec<_>>().as_slice() {
[id] | [id, "_history", _] if !id.is_empty() => (*id).to_owned(),
_ => return Err(SubscriptionMalformation::Location),
};
let location = Url::parse(&format!("{prefix}{id}"))
.map_err(|_unparsable| SubscriptionMalformation::Location)?;
Ok(Self { location })
}
}
impl fmt::Debug for Subscribed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Subscribed")
.field("location", &RedactedUrl(self.location.as_str()))
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriptionStatus {
Requested,
Active,
Error,
Off,
}
impl SubscriptionStatus {
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
match code {
"requested" => Some(Self::Requested),
"active" => Some(Self::Active),
"error" => Some(Self::Error),
"off" => Some(Self::Off),
_ => None,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Requested => "requested",
Self::Active => "active",
Self::Error => "error",
Self::Off => "off",
}
}
}
pub(super) fn status(
media: Option<&str>,
body: &[u8],
) -> Result<SubscriptionStatus, SubscriptionMalformation> {
if !outcome::fhir_json(media) {
return Err(SubscriptionMalformation::NotFhirJson);
}
let value: Value =
serde_json::from_slice(body).map_err(|error| SubscriptionMalformation::NotJson {
line: error.line(),
column: error.column(),
})?;
let object: &Object = value
.as_object()
.ok_or(SubscriptionMalformation::NotASubscription)?;
if object.get("resourceType").and_then(Value::as_str) != Some("Subscription") {
return Err(SubscriptionMalformation::NotASubscription);
}
let subscription = Subscription::from_json(object, &mut Path::root("Subscription"))
.map_err(|error| SubscriptionMalformation::Decode { kind: error.kind })?;
subscription
.status
.value
.as_deref()
.and_then(SubscriptionStatus::from_code)
.ok_or(SubscriptionMalformation::Status)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub subscribed: Subscribed,
pub status: SubscriptionStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Search {
Found(Vec<Listed>),
Unsupported,
}
pub(super) fn listed(
media: Option<&str>,
body: &[u8],
request: &SubscriptionRequest,
endpoint: &Url,
) -> Result<Vec<Listed>, SubscriptionMalformation> {
if !outcome::fhir_json(media) {
return Err(SubscriptionMalformation::NotFhirJson);
}
let value: Value =
serde_json::from_slice(body).map_err(|error| SubscriptionMalformation::NotJson {
line: error.line(),
column: error.column(),
})?;
let object: &Object = value
.as_object()
.ok_or(SubscriptionMalformation::NotASearchset)?;
if object.get("resourceType").and_then(Value::as_str) != Some("Bundle") {
return Err(SubscriptionMalformation::NotASearchset);
}
let bundle = Bundle::from_json(object, &mut Path::root("Bundle"))
.map_err(|error| SubscriptionMalformation::Decode { kind: error.kind })?;
if bundle.r#type.value.as_deref() != Some("searchset") {
return Err(SubscriptionMalformation::NotASearchset);
}
let criteria = request.criteria.text();
let mut found = Vec::new();
for entry in bundle.entry {
let subscription = match entry.resource {
Some(Resource::Subscription(subscription)) => subscription,
Some(Resource::OperationOutcome(_)) => continue,
_ => return Err(SubscriptionMalformation::NotASearchset),
};
let channel = &subscription.channel;
let ours = subscription.criteria.value.as_deref() == Some(criteria.as_str())
&& channel.r#type.value.as_deref() == Some("message")
&& channel
.endpoint
.as_ref()
.and_then(|url| url.value.as_deref())
== Some(request.endpoint.as_str());
if !ours {
continue;
}
let id = subscription
.id
.as_deref()
.filter(|id| !id.is_empty())
.ok_or(SubscriptionMalformation::NoId)?;
let status = subscription
.status
.value
.as_deref()
.and_then(SubscriptionStatus::from_code)
.ok_or(SubscriptionMalformation::Status)?;
found.push(Listed {
subscribed: Subscribed::at(endpoint, &format!("Subscription/{id}"))?,
status,
});
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::{Criteria, Subscribed};
use crate::pmir::error::SubscriptionMalformation;
use url::Url;
#[test]
fn a_criteria_limited_by_system_escapes_the_separators() {
let criteria = Criteria::identifier_system("urn:oid:2.999.1").expect("an absolute URI");
assert_eq!("Patient?identifier=urn:oid:2.999.1|", criteria.text());
assert_eq!("Patient", Criteria::AllPatients.text());
assert!(Criteria::identifier_system("not a uri").is_err());
}
#[test]
fn a_location_is_kept_only_under_the_base_and_without_its_version() {
let endpoint = Url::parse("https://pmir.example.org/fhir/Subscription").expect("a URL");
for location in [
"Subscription/s1",
"https://pmir.example.org/fhir/Subscription/s1",
"https://pmir.example.org/fhir/Subscription/s1/_history/2",
] {
let subscribed = Subscribed::at(&endpoint, location).expect("under the base");
assert_eq!(
"https://pmir.example.org/fhir/Subscription/s1",
subscribed.location().as_str(),
"{location}"
);
}
for location in [
"https://elsewhere.example.org/fhir/Subscription/s1",
"Patient/s1",
"Subscription/",
"Subscription/s1/extra",
] {
assert_eq!(
Err(SubscriptionMalformation::Location),
Subscribed::at(&endpoint, location),
"{location}"
);
}
}
}