use crate::base::object_id::{ObjectId, UidBasedId};
use crate::error::ParseError;
use core::fmt;
use serde::{Deserialize, Serialize};
pub const NAMESPACE_LOCAL: &str = "local";
pub const NAMESPACE_UNKNOWN: &str = "unknown";
pub fn validate_namespace(namespace: &str) -> Result<(), ParseError> {
let mut bytes = namespace.bytes();
let Some(first) = bytes.next() else {
return Err(ParseError::new("OBJECT_REF", "empty namespace", namespace));
};
if !first.is_ascii_alphabetic() {
return Err(ParseError::new(
"OBJECT_REF",
"namespace does not start with a letter",
namespace,
));
}
if !bytes.all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'_' | b'.' | b':' | b'/' | b'&' | b'?' | b'=' | b'+' | b'-'
)
}) {
return Err(ParseError::new(
"OBJECT_REF",
"namespace has a character outside [a-zA-Z0-9_.:/&?=+-]",
namespace,
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObjectRef {
#[serde(rename = "_type", skip_serializing_if = "Option::is_none", default)]
declared_type: Option<String>,
namespace: String,
#[serde(rename = "type")]
type_name: String,
id: ObjectId,
}
impl ObjectRef {
pub fn new(
namespace: impl Into<String>,
type_name: impl Into<String>,
id: ObjectId,
) -> Result<Self, ParseError> {
let namespace = namespace.into();
let type_name = type_name.into();
validate_namespace(&namespace)?;
if type_name.is_empty() {
return Err(ParseError::new("OBJECT_REF", "empty type", &type_name));
}
Ok(Self {
declared_type: None,
namespace,
type_name,
id,
})
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
#[must_use]
pub fn type_name(&self) -> &str {
&self.type_name
}
#[must_use]
pub fn id(&self) -> &ObjectId {
&self.id
}
#[must_use]
pub fn is_local(&self) -> bool {
self.namespace == NAMESPACE_LOCAL
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartyRef {
#[serde(rename = "_type", skip_serializing_if = "Option::is_none", default)]
declared_type: Option<String>,
namespace: String,
#[serde(rename = "type")]
type_name: String,
id: ObjectId,
}
impl PartyRef {
pub const PERMITTED_TYPES: [&'static str; 8] = [
"PERSON",
"ORGANISATION",
"GROUP",
"AGENT",
"ROLE",
"PARTY",
"ACTOR",
"ANY",
];
pub fn new(
namespace: impl Into<String>,
type_name: impl Into<String>,
id: ObjectId,
) -> Result<Self, ParseError> {
let namespace = namespace.into();
let type_name = type_name.into();
validate_namespace(&namespace)?;
if !Self::PERMITTED_TYPES.contains(&type_name.as_str()) {
return Err(ParseError::new(
"PARTY_REF",
"type is not a demographic class",
&type_name,
));
}
Ok(Self {
declared_type: None,
namespace,
type_name,
id,
})
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
#[must_use]
pub fn type_name(&self) -> &str {
&self.type_name
}
#[must_use]
pub fn id(&self) -> &ObjectId {
&self.id
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocatableRef {
#[serde(rename = "_type", skip_serializing_if = "Option::is_none", default)]
declared_type: Option<String>,
namespace: String,
#[serde(rename = "type")]
type_name: String,
id: UidBasedId,
#[serde(skip_serializing_if = "Option::is_none", default)]
path: Option<String>,
}
impl LocatableRef {
pub fn new(
namespace: impl Into<String>,
type_name: impl Into<String>,
id: UidBasedId,
path: Option<String>,
) -> Result<Self, ParseError> {
let namespace = namespace.into();
let type_name = type_name.into();
validate_namespace(&namespace)?;
if type_name.is_empty() {
return Err(ParseError::new("LOCATABLE_REF", "empty type", &type_name));
}
if path.as_ref().is_some_and(String::is_empty) {
return Err(ParseError::new(
"LOCATABLE_REF",
"empty path (omit it to mean the root object)",
"",
));
}
Ok(Self {
declared_type: None,
namespace,
type_name,
id,
path,
})
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
#[must_use]
pub fn type_name(&self) -> &str {
&self.type_name
}
#[must_use]
pub fn id(&self) -> &UidBasedId {
&self.id
}
#[must_use]
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
#[must_use]
pub fn uri(&self) -> String {
match &self.path {
Some(p) if p.starts_with('/') => format!("{}:{}{p}", self.namespace, self.id),
Some(p) => format!("{}:{}/{p}", self.namespace, self.id),
None => format!("{}:{}", self.namespace, self.id),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessGroupRef {
#[serde(rename = "_type", skip_serializing_if = "Option::is_none", default)]
declared_type: Option<String>,
namespace: String,
#[serde(rename = "type")]
type_name: String,
id: ObjectId,
}
impl AccessGroupRef {
pub fn new(namespace: impl Into<String>, id: ObjectId) -> Result<Self, ParseError> {
let namespace = namespace.into();
validate_namespace(&namespace)?;
Ok(Self {
declared_type: None,
namespace,
type_name: "ACCESS_GROUP".to_owned(),
id,
})
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
#[must_use]
pub fn type_name(&self) -> &str {
&self.type_name
}
#[must_use]
pub fn id(&self) -> &ObjectId {
&self.id
}
}
impl fmt::Display for ObjectRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.namespace, self.id)
}
}
impl fmt::Display for PartyRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.namespace, self.id)
}
}
impl fmt::Display for LocatableRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.uri())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::HierObjectId;
fn an_id() -> ObjectId {
ObjectId::HierObjectId(
HierObjectId::from_uid_str("87284370-2D4B-4E3D-A3F3-F303D2F4F34B").unwrap(),
)
}
#[test]
fn namespace_grammar_is_enforced() {
assert!(validate_namespace("local").is_ok());
assert!(validate_namespace("uk.nhs.spine").is_ok());
assert!(validate_namespace("https://ehr.example/ns").is_ok());
assert!(validate_namespace("1ns").is_err()); assert!(validate_namespace("").is_err());
assert!(validate_namespace("a b").is_err());
}
#[test]
fn party_ref_refuses_a_non_demographic_class() {
assert!(PartyRef::new("local", "OBSERVATION", an_id()).is_err());
assert!(PartyRef::new("local", "PERSON", an_id()).is_ok());
}
#[test]
fn locatable_ref_uri_does_not_double_the_slash() {
let uid: UidBasedId = "87284370-2D4B-4E3D-A3F3-F303D2F4F34B::s.example::1"
.parse()
.unwrap();
let with_slash = LocatableRef::new(
"local",
"COMPOSITION",
uid.clone(),
Some("/content[0]".into()),
)
.unwrap();
let without =
LocatableRef::new("local", "COMPOSITION", uid, Some("content[0]".into())).unwrap();
assert_eq!(with_slash.uri(), without.uri());
}
}