use std::sync::Arc;
use crate::{
agents::ForAgent, errors::AtomicResult, storelike::ResourceResponse, urls, Commit, Db, Resource,
};
pub use crate::plugins::BoxFuture;
pub struct GetExtenderContext<'a> {
pub store: &'a Db,
pub url: &'a url::Url,
pub db_resource: &'a mut Resource,
pub for_agent: &'a ForAgent,
}
pub struct CommitExtenderContext<'a> {
pub store: &'a Db,
pub commit: &'a Commit,
pub resource: &'a Resource,
pub is_new: bool,
pub changed_props: &'a std::collections::HashSet<String>,
}
pub type ResourceGetHandler = Arc<
dyn for<'a> Fn(GetExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<ResourceResponse>>
+ Send
+ Sync,
>;
pub type CommitHandler =
Arc<dyn for<'a> Fn(CommitExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<()>> + Send + Sync>;
fn normalize_class(raw: &str) -> String {
crate::Subject::from_raw(raw.trim(), None).pure_id()
}
#[derive(Clone, Debug)]
pub enum ClassExtenderScope {
Global,
Drive(String),
}
#[derive(Clone)]
pub struct ClassExtender {
pub id: Option<String>,
pub classes: Vec<String>,
pub on_resource_get: Option<ResourceGetHandler>,
pub before_commit: Option<CommitHandler>,
pub after_commit: Option<CommitHandler>,
pub scope: ClassExtenderScope,
pub subject: Option<String>,
}
pub struct ClassExtenderBuilder {
id: Option<String>,
classes: Vec<String>,
on_resource_get: Option<ResourceGetHandler>,
before_commit: Option<CommitHandler>,
after_commit: Option<CommitHandler>,
scope: ClassExtenderScope,
subject: Option<String>,
}
impl ClassExtenderBuilder {
pub fn new() -> Self {
Self {
id: None,
classes: Vec::new(),
on_resource_get: None,
before_commit: None,
after_commit: None,
scope: ClassExtenderScope::Global,
subject: None,
}
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn classes(mut self, classes: Vec<String>) -> Self {
self.classes = classes.iter().map(|c| normalize_class(c)).collect();
self
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.classes.push(normalize_class(&class.into()));
self
}
pub fn on_resource_get(mut self, handler: ResourceGetHandler) -> Self {
self.on_resource_get = Some(handler);
self
}
pub fn on_resource_get_fn<F>(mut self, handler: F) -> Self
where
F: for<'a> Fn(GetExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<ResourceResponse>>
+ Send
+ Sync
+ 'static,
{
self.on_resource_get = Some(ClassExtender::wrap_get_handler(handler));
self
}
pub fn before_commit(mut self, handler: CommitHandler) -> Self {
self.before_commit = Some(handler);
self
}
pub fn before_commit_fn<F>(mut self, handler: F) -> Self
where
F: for<'a> Fn(CommitExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<()>>
+ Send
+ Sync
+ 'static,
{
self.before_commit = Some(ClassExtender::wrap_commit_handler(handler));
self
}
pub fn after_commit(mut self, handler: CommitHandler) -> Self {
self.after_commit = Some(handler);
self
}
pub fn after_commit_fn<F>(mut self, handler: F) -> Self
where
F: for<'a> Fn(CommitExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<()>>
+ Send
+ Sync
+ 'static,
{
self.after_commit = Some(ClassExtender::wrap_commit_handler(handler));
self
}
pub fn scope(mut self, scope: ClassExtenderScope) -> Self {
self.scope = scope;
self
}
pub fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into());
self
}
pub fn build(self) -> ClassExtender {
ClassExtender {
id: self.id,
classes: self.classes,
on_resource_get: self.on_resource_get,
before_commit: self.before_commit,
after_commit: self.after_commit,
scope: self.scope,
subject: self.subject,
}
}
}
impl Default for ClassExtenderBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClassExtender {
pub fn builder() -> ClassExtenderBuilder {
ClassExtenderBuilder::new()
}
pub fn resource_has_extender(&self, resource: &Resource) -> AtomicResult<bool> {
let Ok(is_a) = resource.get(urls::IS_A) else {
return Ok(false);
};
let resource_classes = is_a.to_subjects(None)?;
let matched = resource_classes
.iter()
.any(|c| self.classes.contains(&normalize_class(c)));
if !matched && !self.classes.is_empty() {
tracing::debug!(
extender = self.id.as_deref().unwrap_or("<unnamed>"),
declares = ?self.classes,
resource_is_a = ?resource_classes,
"class extender skipped: no class in common"
);
}
Ok(matched)
}
pub fn warn_about_unmatchable_classes(&self) {
for class in &self.classes {
if let Some((_origin, tail)) = class.split_once("/did:") {
tracing::warn!(
extender = self.id.as_deref().unwrap_or("<unnamed>"),
declared = %class,
"class extender declares a class by URL, not by subject — it will never \
match. Use the bare DID instead: did:{}",
tail
);
}
}
}
pub fn wrap_get_handler<F>(handler: F) -> ResourceGetHandler
where
F: for<'a> Fn(GetExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<ResourceResponse>>
+ Send
+ Sync
+ 'static,
{
Arc::new(handler)
}
pub fn wrap_commit_handler<F>(handler: F) -> CommitHandler
where
F: for<'a> Fn(CommitExtenderContext<'a>) -> BoxFuture<'a, AtomicResult<()>>
+ Send
+ Sync
+ 'static,
{
Arc::new(handler)
}
pub async fn check_scope(
&self,
resource: &Resource,
store: &Db,
cached_root: Option<String>,
) -> AtomicResult<(bool, Option<String>)> {
match &self.scope {
ClassExtenderScope::Drive(scope) => {
let subject = resource.get_subject().clone();
if normalize_class(&subject.to_string()) == normalize_class(scope) {
return Ok((true, Some(subject.to_string())));
}
let rs = if let Some(rs) = &cached_root {
rs.clone()
} else {
let parents = resource.get_parent_tree(store).await?;
let Some(root) = parents.last() else {
return Ok((false, None));
};
root.get_subject().to_string()
};
if normalize_class(&rs) != normalize_class(scope) {
tracing::debug!(
extender = self.id.as_deref().unwrap_or("<unnamed>"),
scoped_to = %scope,
resource_root = %rs,
"class extender skipped: resource is in a different drive"
);
return Ok((false, Some(rs)));
}
Ok((true, Some(rs)))
}
ClassExtenderScope::Global => Ok((true, cached_root)),
}
}
pub fn can_extend(&self, resource: &Resource) -> bool {
if self.subject.is_none() {
return true;
};
let Ok(is_a) = resource.get(urls::IS_A) else {
return true;
};
let Ok(is_a_subjects) = is_a.to_subjects(None) else {
return true;
};
!is_a_subjects.contains(&urls::PLUGIN.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Value;
const CLASS: &str = "did:ad:guDVPzQKpsfcS5Vpbgdh0cj19CFapvKuQ5NVyYfjcspo0ZMpua9UzgC8WkDZa1_Z";
fn extender_declaring(class: &str) -> ClassExtender {
ClassExtender::builder()
.id("test".to_string())
.classes(vec![class.to_string()])
.build()
}
fn resource_of_class(raw_is_a: &str) -> Resource {
let mut resource = Resource::new("did:ad:someresource".to_string());
resource
.set_unsafe(
urls::IS_A.into(),
Value::ResourceArray(vec![crate::Subject::from_raw(raw_is_a, None).into()]),
)
.unwrap();
resource
}
#[test]
fn a_bare_did_matches_itself() {
assert!(extender_declaring(CLASS)
.resource_has_extender(&resource_of_class(CLASS))
.unwrap());
}
#[test]
fn a_drive_hint_does_not_change_which_class_this_is() {
let hinted = format!("{CLASS}?drive=did:ad:somedrive");
assert!(extender_declaring(CLASS)
.resource_has_extender(&resource_of_class(&hinted))
.unwrap());
assert!(extender_declaring(&hinted)
.resource_has_extender(&resource_of_class(CLASS))
.unwrap());
}
#[test]
fn surrounding_whitespace_is_not_a_different_class() {
assert!(extender_declaring(&format!(" {CLASS} "))
.resource_has_extender(&resource_of_class(CLASS))
.unwrap());
}
#[test]
fn an_address_bar_url_is_still_not_the_class() {
assert!(!extender_declaring(CLASS)
.resource_has_extender(&resource_of_class(&format!(
"http://localhost:24797/{CLASS}"
)))
.unwrap());
}
#[test]
fn a_different_class_still_does_not_match() {
assert!(!extender_declaring(CLASS)
.resource_has_extender(&resource_of_class("did:ad:someotherclassentirely"))
.unwrap());
}
#[test]
fn a_resource_without_is_a_matches_nothing() {
let bare = Resource::new("did:ad:someresource".to_string());
assert!(!extender_declaring(CLASS)
.resource_has_extender(&bare)
.unwrap());
}
}