#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![warn(clippy::pedantic)]
use core::fmt;
use core::str::FromStr;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum EntityType {
Person,
Worker,
Organization,
Case,
Place,
Thing,
Event,
Course,
CourseInstance,
CarePathway,
}
impl EntityType {
pub const ALL: [EntityType; 10] = [
EntityType::Person,
EntityType::Worker,
EntityType::Organization,
EntityType::Case,
EntityType::Place,
EntityType::Thing,
EntityType::Event,
EntityType::Course,
EntityType::CourseInstance,
EntityType::CarePathway,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
EntityType::Person => "person",
EntityType::Worker => "worker",
EntityType::Organization => "organization",
EntityType::Case => "case",
EntityType::Place => "place",
EntityType::Thing => "thing",
EntityType::Event => "event",
EntityType::Course => "course",
EntityType::CourseInstance => "courseinstance",
EntityType::CarePathway => "care_pathway",
}
}
#[must_use]
pub const fn service(self) -> &'static str {
match self {
EntityType::Person => "person-service",
EntityType::Worker => "worker-service",
EntityType::Organization => "organization-service",
EntityType::Case => "case-service",
EntityType::Place => "place-service",
EntityType::Thing => "thing-service",
EntityType::Event => "event-service",
EntityType::Course | EntityType::CourseInstance => "course-service",
EntityType::CarePathway => "care-pathway-service",
}
}
#[must_use]
pub fn from_token(token: &str) -> Option<Self> {
Self::ALL.into_iter().find(|t| t.as_str() == token)
}
}
impl fmt::Display for EntityType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseEntityRefError {
#[error("malformed entity ref (expected `entity_type:uuid`): {0:?}")]
Malformed(String),
#[error("unknown entity type: {0:?}")]
UnknownType(String),
#[error("invalid uuid in entity ref: {0:?}")]
BadId(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct EntityRef {
pub entity_type: EntityType,
pub id: Uuid,
}
impl EntityRef {
#[must_use]
pub const fn new(entity_type: EntityType, id: Uuid) -> Self {
Self { entity_type, id }
}
#[must_use]
pub const fn service(&self) -> &'static str {
self.entity_type.service()
}
}
impl fmt::Display for EntityRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.entity_type.as_str(), self.id)
}
}
impl FromStr for EntityRef {
type Err = ParseEntityRefError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (type_token, id_token) = s
.split_once(':')
.filter(|(t, id)| !t.is_empty() && !id.is_empty() && !id.contains(':'))
.ok_or_else(|| ParseEntityRefError::Malformed(s.to_string()))?;
let entity_type = EntityType::from_token(type_token)
.ok_or_else(|| ParseEntityRefError::UnknownType(type_token.to_string()))?;
let id = Uuid::parse_str(id_token)
.map_err(|_| ParseEntityRefError::BadId(id_token.to_string()))?;
Ok(Self { entity_type, id })
}
}
impl From<EntityRef> for String {
fn from(r: EntityRef) -> Self {
r.to_string()
}
}
impl TryFrom<String> for EntityRef {
type Error = ParseEntityRefError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sensitivity {
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum EdgeKind {
SameIdentity,
WorksAt,
MemberOf,
EmployedBy,
SubjectOf,
}
impl EdgeKind {
pub const ALL: [EdgeKind; 5] = [
EdgeKind::SameIdentity,
EdgeKind::WorksAt,
EdgeKind::MemberOf,
EdgeKind::EmployedBy,
EdgeKind::SubjectOf,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
EdgeKind::SameIdentity => "same_identity",
EdgeKind::WorksAt => "works_at",
EdgeKind::MemberOf => "member_of",
EdgeKind::EmployedBy => "employed_by",
EdgeKind::SubjectOf => "subject_of",
}
}
#[must_use]
pub fn from_token(token: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == token)
}
#[must_use]
pub const fn is_symmetric(self) -> bool {
matches!(self, EdgeKind::SameIdentity)
}
#[must_use]
pub const fn is_temporal(self) -> bool {
matches!(
self,
EdgeKind::WorksAt | EdgeKind::MemberOf | EdgeKind::EmployedBy | EdgeKind::SubjectOf
)
}
#[must_use]
pub const fn inverse(self) -> Option<&'static str> {
match self {
EdgeKind::SameIdentity => None,
EdgeKind::WorksAt | EdgeKind::MemberOf => Some("has_member"),
EdgeKind::EmployedBy => Some("employs"),
EdgeKind::SubjectOf => Some("is_subject_of"),
}
}
#[must_use]
pub const fn sensitivity(self) -> Sensitivity {
match self {
EdgeKind::SubjectOf => Sensitivity::High,
_ => Sensitivity::Medium,
}
}
#[must_use]
pub fn permits(self, from: EntityType, to: EntityType) -> bool {
use EntityType::{Case, Organization, Person, Worker};
match self {
EdgeKind::SameIdentity => {
matches!((from, to), (Person, Worker) | (Worker, Person))
}
EdgeKind::WorksAt | EdgeKind::MemberOf => (from, to) == (Person, Organization),
EdgeKind::EmployedBy => (from, to) == (Worker, Organization),
EdgeKind::SubjectOf => (from, to) == (Case, Person),
}
}
}
impl fmt::Display for EdgeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn a_uuid() -> Uuid {
Uuid::parse_str("0c4f1e2a-0000-4000-8000-000000000000").unwrap()
}
#[test]
fn entity_ref_round_trips_through_its_urn() {
let r = EntityRef::new(EntityType::Person, a_uuid());
let urn = r.to_string();
assert_eq!(urn, "person:0c4f1e2a-0000-4000-8000-000000000000");
assert_eq!(urn.parse::<EntityRef>().unwrap(), r);
}
#[test]
fn parses_every_entity_type_token() {
for t in EntityType::ALL {
let urn = format!("{}:{}", t.as_str(), a_uuid());
assert_eq!(urn.parse::<EntityRef>().unwrap().entity_type, t);
}
}
#[test]
fn course_and_courseinstance_share_one_service() {
assert_eq!(EntityType::Course.service(), "course-service");
assert_eq!(EntityType::CourseInstance.service(), "course-service");
}
#[test]
fn rejects_unknown_type_bad_uuid_and_malformed() {
assert!(matches!(
"widget:0c4f1e2a-0000-4000-8000-000000000000".parse::<EntityRef>(),
Err(ParseEntityRefError::UnknownType(_))
));
assert!(matches!(
"person:not-a-uuid".parse::<EntityRef>(),
Err(ParseEntityRefError::BadId(_))
));
for bad in ["", "person", "person:", ":uuid", "a:b:c"] {
assert!(
matches!(
bad.parse::<EntityRef>(),
Err(ParseEntityRefError::Malformed(_) | ParseEntityRefError::BadId(_))
),
"should reject {bad:?}"
);
}
}
#[test]
fn serde_uses_the_urn_string_form() {
let r = EntityRef::new(EntityType::Case, a_uuid());
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, "\"case:0c4f1e2a-0000-4000-8000-000000000000\"");
assert_eq!(serde_json::from_str::<EntityRef>(&json).unwrap(), r);
}
#[test]
fn edge_kind_registry_endpoint_rules() {
use EntityType::{Case, Organization, Person, Worker};
assert!(EdgeKind::SameIdentity.permits(Person, Worker));
assert!(EdgeKind::SameIdentity.permits(Worker, Person));
assert!(!EdgeKind::SameIdentity.permits(Person, Person));
assert!(EdgeKind::WorksAt.permits(Person, Organization));
assert!(!EdgeKind::WorksAt.permits(Organization, Person));
assert!(EdgeKind::EmployedBy.permits(Worker, Organization));
assert!(EdgeKind::SubjectOf.permits(Case, Person));
assert!(!EdgeKind::SubjectOf.permits(Person, Case));
}
#[test]
fn edge_kind_metadata_matches_the_registry() {
assert!(EdgeKind::SameIdentity.is_symmetric());
assert!(EdgeKind::SameIdentity.inverse().is_none());
assert!(!EdgeKind::SameIdentity.is_temporal());
assert_eq!(EdgeKind::EmployedBy.inverse(), Some("employs"));
assert!(EdgeKind::EmployedBy.is_temporal());
assert_eq!(EdgeKind::SubjectOf.sensitivity(), Sensitivity::High);
for k in EdgeKind::ALL {
if k != EdgeKind::SubjectOf {
assert_eq!(k.sensitivity(), Sensitivity::Medium);
}
}
}
#[test]
fn edge_kind_tokens_round_trip() {
for k in EdgeKind::ALL {
assert_eq!(EdgeKind::from_token(k.as_str()), Some(k));
}
assert_eq!(EdgeKind::from_token("nope"), None);
}
}