use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SmartScope {
Launch,
LaunchContext(LaunchContext),
Identity(String),
Resource(ResourceScope),
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchContext {
Patient,
Episode,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceScope {
pub compartment: Compartment,
pub resource: ResourceSelector,
pub permissions: Permissions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compartment {
Patient,
User,
System,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceSelector {
Template(Pattern),
Composition(Pattern),
Aql(Pattern),
}
impl ResourceSelector {
#[must_use]
pub const fn family(&self) -> ResourceFamily {
match self {
ResourceSelector::Template(_) => ResourceFamily::Template,
ResourceSelector::Composition(_) => ResourceFamily::Composition,
ResourceSelector::Aql(_) => ResourceFamily::Aql,
}
}
#[must_use]
pub const fn pattern(&self) -> &Pattern {
match self {
ResourceSelector::Template(p)
| ResourceSelector::Composition(p)
| ResourceSelector::Aql(p) => p,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceFamily {
Template,
Composition,
Aql,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[expect(
clippy::struct_excessive_bools,
reason = "the flags mirror the five SMART CRUDS letters (c/r/u/d/s) 1:1 (ITS-REST SMART API); collapsing them into bitflags would hide the spec's own field names"
)]
pub struct Permissions {
pub create: bool,
pub read: bool,
pub update: bool,
pub delete: bool,
pub search: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Permission {
Create,
Read,
Update,
Delete,
Search,
}
impl Permissions {
#[must_use]
pub const fn contains(self, perm: Permission) -> bool {
match perm {
Permission::Create => self.create,
Permission::Read => self.read,
Permission::Update => self.update,
Permission::Delete => self.delete,
Permission::Search => self.search,
}
}
fn parse(tail: &str) -> Option<Self> {
if tail.is_empty() {
return None;
}
let mut p = Permissions::default();
for ch in tail.chars() {
match ch {
'c' => p.create = true,
'r' => p.read = true,
'u' => p.update = true,
'd' => p.delete = true,
's' => p.search = true,
_ => return None,
}
}
Some(p)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pattern(String);
impl Pattern {
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
Self(raw.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn matches(&self, candidate: &str) -> bool {
if self.0 == "*" || self.0 == "**" {
return true;
}
glob_match(self.0.as_bytes(), candidate.as_bytes())
}
}
impl fmt::Display for Pattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn glob_match(pat: &[u8], text: &[u8]) -> bool {
match pat.split_first() {
None => text.is_empty(),
Some((&b'*', after_star)) => match after_star.strip_prefix(b"*") {
Some(rest) => {
(0..=text.len()).any(|i| text.get(i..).is_some_and(|tail| glob_match(rest, tail)))
}
None => match_single_star(after_star, text),
},
Some((&c, pat_rest)) => match text.split_first() {
Some((&t, text_rest)) if t == c => glob_match(pat_rest, text_rest),
_ => false,
},
}
}
fn match_single_star(after_star: &[u8], text: &[u8]) -> bool {
let mut i = 0;
loop {
let Some(tail) = text.get(i..) else {
return false;
};
if glob_match(after_star, tail) {
return true;
}
if tail.first().is_none_or(|&b| b == b':') {
return false;
}
i += 1;
}
}
impl SmartScope {
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw {
"launch" => return SmartScope::Launch,
"openid" | "profile" | "fhirUser" | "offline_access" | "online_access" => {
return SmartScope::Identity(raw.to_owned());
}
_ => {}
}
if let Some(ctx) = raw.strip_prefix("launch/") {
let ctx = match ctx {
"patient" => LaunchContext::Patient,
"episode" => LaunchContext::Episode,
other => LaunchContext::Other(other.to_owned()),
};
return SmartScope::LaunchContext(ctx);
}
if let Some(scope) = parse_resource_scope(raw) {
return SmartScope::Resource(scope);
}
SmartScope::Other(raw.to_owned())
}
#[must_use]
pub fn parse_all(scope_claim: &str) -> Vec<SmartScope> {
scope_claim
.split_whitespace()
.map(SmartScope::parse)
.collect()
}
}
fn parse_resource_scope(raw: &str) -> Option<ResourceScope> {
let (compartment_str, rest) = raw.split_once('/')?;
let compartment = match compartment_str {
"patient" => Compartment::Patient,
"user" => Compartment::User,
"system" => Compartment::System,
_ => return None,
};
let (resource_str, perm_str) = rest.rsplit_once('.')?;
let permissions = Permissions::parse(perm_str)?;
let resource = parse_resource_selector(resource_str)?;
Some(ResourceScope {
compartment,
resource,
permissions,
})
}
fn parse_resource_selector(raw: &str) -> Option<ResourceSelector> {
if let Some(id) = raw.strip_prefix("template-") {
return Some(ResourceSelector::Template(Pattern::new(id)));
}
if let Some(id) = raw.strip_prefix("composition-") {
return Some(ResourceSelector::Composition(Pattern::new(id)));
}
if let Some(name) = raw.strip_prefix("aql-") {
return Some(ResourceSelector::Aql(Pattern::new(name)));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn resource(raw: &str) -> ResourceScope {
match SmartScope::parse(raw) {
SmartScope::Resource(r) => r,
other => panic!("expected resource scope, got {other:?}"),
}
}
#[test]
fn launch_and_context_scopes() {
assert_eq!(SmartScope::parse("launch"), SmartScope::Launch);
assert_eq!(
SmartScope::parse("launch/patient"),
SmartScope::LaunchContext(LaunchContext::Patient)
);
assert_eq!(
SmartScope::parse("launch/episode"),
SmartScope::LaunchContext(LaunchContext::Episode)
);
assert_eq!(
SmartScope::parse("launch/encounter"),
SmartScope::LaunchContext(LaunchContext::Other("encounter".to_owned()))
);
}
#[test]
fn identity_scopes() {
for s in ["openid", "profile", "fhirUser", "offline_access"] {
assert_eq!(SmartScope::parse(s), SmartScope::Identity(s.to_owned()));
}
}
#[test]
fn unknown_scope_is_inert_other() {
assert_eq!(
SmartScope::parse("something/weird"),
SmartScope::Other("something/weird".to_owned())
);
assert_eq!(
SmartScope::parse("admin/composition-*.r"),
SmartScope::Other("admin/composition-*.r".to_owned())
);
}
#[test]
fn master04_example_wildcard_resource_is_inert() {
for raw in ["patient/*.rs", "user/*.rs"] {
assert_eq!(SmartScope::parse(raw), SmartScope::Other(raw.to_owned()));
}
let parsed = SmartScope::parse_all(
"openid profile launch launch/patient patient/*.rs user/*.rs offline_access",
);
assert_eq!(parsed.len(), 7);
assert_eq!(
parsed
.iter()
.filter(|s| matches!(s, SmartScope::Other(_)))
.count(),
2
);
assert!(!parsed.iter().any(|s| matches!(s, SmartScope::Resource(_))));
}
#[test]
fn compartments_parse() {
assert_eq!(
resource("patient/composition-*.r").compartment,
Compartment::Patient
);
assert_eq!(
resource("user/composition-*.r").compartment,
Compartment::User
);
assert_eq!(
resource("system/composition-*.r").compartment,
Compartment::System
);
}
#[test]
fn resource_families_parse() {
assert_eq!(
resource("user/template-*.cruds").resource.family(),
ResourceFamily::Template
);
assert_eq!(
resource("patient/composition-*.r").resource.family(),
ResourceFamily::Composition
);
assert_eq!(
resource("patient/aql-*.rs").resource.family(),
ResourceFamily::Aql
);
}
#[test]
fn permission_tails_parse_order_free() {
let p = resource("patient/composition-*.crud").permissions;
assert!(p.create && p.read && p.update && p.delete && !p.search);
let p = resource("patient/aql-*.rs").permissions;
assert!(p.read && p.search && !p.create);
let p = resource("system/aql-*.cruds").permissions;
assert!(p.create && p.read && p.update && p.delete && p.search);
assert!(
resource("patient/composition-*.r")
.permissions
.contains(Permission::Read)
);
assert!(
!resource("patient/composition-*.r")
.permissions
.contains(Permission::Create)
);
}
#[test]
fn bad_permission_char_falls_back_to_other() {
assert!(matches!(
SmartScope::parse("patient/composition-*.rx"),
SmartScope::Other(_)
));
assert!(matches!(
SmartScope::parse("patient/composition-*."),
SmartScope::Other(_)
));
}
#[test]
fn dotted_ids_keep_their_dots() {
let r = resource("patient/composition-MyHospital::Template.v0.r");
assert_eq!(r.resource.pattern().as_str(), "MyHospital::Template.v0");
assert!(r.permissions.read);
let r = resource("patient/aql-org.openehr::bloodpressure.v1.rs");
assert_eq!(
r.resource.pattern().as_str(),
"org.openehr::bloodpressure.v1"
);
assert!(r.permissions.read && r.permissions.search);
}
#[test]
fn pattern_exact_match_only() {
let p = Pattern::new("MyHospital::Template.v0");
assert!(p.matches("MyHospital::Template.v0"));
assert!(!p.matches("MyHospital::Template.v1"));
assert!(!p.matches("OtherHospital::Template.v0"));
assert!(!p.matches("Template.v0"));
}
#[test]
fn pattern_query_exact_match_only() {
let p = Pattern::new("org.openehr::bloodpressure.v1");
assert!(p.matches("org.openehr::bloodpressure.v1"));
assert!(!p.matches("org.openehr::bloodpressure.v2"));
}
#[test]
fn pattern_any_namespace_fixed_name() {
let p = Pattern::new("*::Template.v0");
assert!(p.matches("MyHospital::Template.v0"));
assert!(p.matches("org.openehr::Template.v0"));
assert!(!p.matches("MyHospital::Template.v1"));
assert!(!p.matches("A::B::Template.v0"));
assert!(!p.matches("Template.v0"));
}
#[test]
fn pattern_fixed_namespace_any_name() {
let p = Pattern::new("MyHospital::*");
assert!(p.matches("MyHospital::Template.v0"));
assert!(p.matches("MyHospital::Anything.v9"));
assert!(!p.matches("OtherHospital::Template.v0"));
assert!(!p.matches("MyHospital::Sub::Template.v0"));
}
#[test]
fn pattern_bare_star_matches_all() {
let p = Pattern::new("*");
assert!(p.matches("Template.v0"));
assert!(p.matches("MyHospital::Template.v0"));
assert!(p.matches("org.openehr::bloodpressure.v1"));
assert!(p.matches(""));
}
#[test]
fn pattern_double_star_is_recursive() {
let p = Pattern::new("**");
assert!(p.matches("A::B::C::deep.v1"));
let p = Pattern::new("MyHospital::**");
assert!(p.matches("MyHospital::Sub::Template.v0"));
assert!(p.matches("MyHospital::Template.v0"));
}
#[test]
fn maximal_table_rows_parse() {
for row in [
"patient/composition-MyTemplate.crud",
"user/composition-MyTemplate.crud",
"system/composition-MyTemplate.crud",
"user/template-MyTemplate.crud",
"system/template-MyTemplate.crud",
"patient/aql-MyQuery.rs",
"user/aql-MyQuery.cruds",
"system/aql-MyQuery.cruds",
] {
assert!(
matches!(SmartScope::parse(row), SmartScope::Resource(_)),
"row did not parse as a resource scope: {row}"
);
}
}
#[test]
fn parse_all_splits_the_scope_claim() {
let scopes = SmartScope::parse_all("openid launch/patient patient/composition-*.rs");
assert_eq!(scopes.len(), 3);
assert_eq!(scopes[0], SmartScope::Identity("openid".to_owned()));
assert_eq!(scopes[1], SmartScope::LaunchContext(LaunchContext::Patient));
assert!(matches!(scopes[2], SmartScope::Resource(_)));
}
#[test]
fn system_aql_wildcard_grants_all_queries() {
let r = resource("system/aql-*.rs");
assert_eq!(r.compartment, Compartment::System);
assert!(r.resource.pattern().matches("any::query.v1"));
assert!(r.resource.pattern().matches("adhoc"));
assert!(r.permissions.search && r.permissions.read);
}
}