use crate::{DidDocument, DidError, DidResult};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DidUrl {
pub did: String,
pub path: Option<String>,
pub query: Option<String>,
pub fragment: Option<String>,
}
impl DidUrl {
pub fn parse(url: &str) -> DidResult<Self> {
if !url.starts_with("did:") {
return Err(DidError::InvalidFormat(
"DID URL must start with 'did:'".to_string(),
));
}
let (before_fragment, fragment) = if let Some(pos) = url.find('#') {
let frag = url[pos + 1..].to_string();
if frag.is_empty() {
return Err(DidError::InvalidFormat(
"Fragment identifier cannot be empty".to_string(),
));
}
(&url[..pos], Some(frag))
} else {
(url, None)
};
let (before_query, query) = if let Some(pos) = before_fragment.find('?') {
let q = before_fragment[pos + 1..].to_string();
if q.is_empty() {
return Err(DidError::InvalidFormat(
"Query string cannot be empty".to_string(),
));
}
(&before_fragment[..pos], Some(q))
} else {
(before_fragment, None)
};
let (did_part, path) = extract_did_and_path(before_query)?;
validate_did_structure(did_part)?;
Ok(Self {
did: did_part.to_string(),
path,
query,
fragment,
})
}
pub fn as_url_string(&self) -> String {
let mut out = self.did.clone();
if let Some(ref p) = self.path {
out.push('/');
out.push_str(p);
}
if let Some(ref q) = self.query {
out.push('?');
out.push_str(q);
}
if let Some(ref f) = self.fragment {
out.push('#');
out.push_str(f);
}
out
}
pub fn base_did(&self) -> &str {
&self.did
}
pub fn fragment_id(&self) -> Option<&str> {
self.fragment.as_deref()
}
pub fn query_string(&self) -> Option<&str> {
self.query.as_deref()
}
pub fn path_segment(&self) -> Option<&str> {
self.path.as_deref()
}
pub fn query_params(&self) -> Vec<(String, String)> {
match &self.query {
None => vec![],
Some(q) => q
.split('&')
.filter_map(|pair| {
let mut it = pair.splitn(2, '=');
let key = it.next()?.to_string();
let val = it.next().unwrap_or("").to_string();
Some((key, val))
})
.collect(),
}
}
pub fn is_bare_did(&self) -> bool {
self.path.is_none() && self.query.is_none() && self.fragment.is_none()
}
}
impl fmt::Display for DidUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_url_string())
}
}
fn extract_did_and_path(s: &str) -> DidResult<(&str, Option<String>)> {
let colon_count = s.chars().filter(|&c| c == ':').count();
if colon_count < 2 {
return Err(DidError::InvalidFormat(
"DID URL must contain at least two colons (did:method:id)".to_string(),
));
}
if let Some(slash_pos) = s.find('/') {
let did = &s[..slash_pos];
let path = s[slash_pos + 1..].to_string();
if path.is_empty() {
return Err(DidError::InvalidFormat(
"Path segment cannot be empty after '/'".to_string(),
));
}
Ok((did, Some(path)))
} else {
Ok((s, None))
}
}
fn validate_did_structure(did: &str) -> DidResult<()> {
let parts: Vec<&str> = did.splitn(3, ':').collect();
if parts.len() < 3 || parts[0] != "did" || parts[1].is_empty() || parts[2].is_empty() {
return Err(DidError::InvalidFormat(format!(
"Invalid DID structure: '{}'",
did
)));
}
Ok(())
}
#[derive(Debug, Clone)]
pub enum DereferencedResource {
Document(Box<DidDocument>),
VerificationMethod(serde_json::Value),
Service(serde_json::Value),
ContentStream(Vec<u8>),
}
pub struct DidDereferencer;
impl DidDereferencer {
pub fn dereference(doc: &DidDocument, url: &DidUrl) -> DidResult<DereferencedResource> {
if url.did != doc.id.as_str() {
return Err(DidError::ResolutionFailed(format!(
"DID URL base '{}' does not match document id '{}'",
url.did,
doc.id.as_str()
)));
}
match (&url.fragment, &url.path) {
(Some(fragment), _) => Self::dereference_fragment(doc, fragment),
(None, Some(path)) => Ok(DereferencedResource::ContentStream(
path.as_bytes().to_vec(),
)),
(None, None) => Ok(DereferencedResource::Document(Box::new(doc.clone()))),
}
}
fn dereference_fragment(doc: &DidDocument, fragment: &str) -> DidResult<DereferencedResource> {
let full_id = format!("{}#{}", doc.id.as_str(), fragment);
if let Some(vm) = doc
.verification_method
.iter()
.find(|vm| vm.id == full_id || vm.id.ends_with(&format!("#{}", fragment)))
{
let value = serde_json::to_value(vm)
.map_err(|e| DidError::SerializationError(e.to_string()))?;
return Ok(DereferencedResource::VerificationMethod(value));
}
if let Some(svc) = doc
.service
.iter()
.find(|s| s.id == full_id || s.id.ends_with(&format!("#{}", fragment)))
{
let value = serde_json::to_value(svc)
.map_err(|e| DidError::SerializationError(e.to_string()))?;
return Ok(DereferencedResource::Service(value));
}
Err(DidError::ResolutionFailed(format!(
"Fragment '{}' not found in DID Document '{}'",
fragment,
doc.id.as_str()
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::did::document::DidDocument;
use crate::{Did, Service, VerificationMethod};
fn sample_doc() -> DidDocument {
let did = Did::new("did:example:123").unwrap();
let mut doc = DidDocument::new(did);
let vm =
VerificationMethod::ed25519("did:example:123#key-1", "did:example:123", &[1u8; 32]);
doc.verification_method.push(vm);
doc.service.push(Service {
id: "did:example:123#linked-domain".to_string(),
service_type: "LinkedDomains".to_string(),
service_endpoint: "https://example.com".to_string(),
});
doc
}
#[test]
fn test_parse_bare_did() {
let url = DidUrl::parse("did:example:123").unwrap();
assert_eq!(url.did, "did:example:123");
assert!(url.path.is_none());
assert!(url.query.is_none());
assert!(url.fragment.is_none());
assert!(url.is_bare_did());
}
#[test]
fn test_parse_with_fragment() {
let url = DidUrl::parse("did:example:123#key-1").unwrap();
assert_eq!(url.did, "did:example:123");
assert_eq!(url.fragment_id(), Some("key-1"));
assert!(url.path.is_none());
}
#[test]
fn test_parse_with_path() {
let url = DidUrl::parse("did:example:123/path/to/resource").unwrap();
assert_eq!(url.did, "did:example:123");
assert_eq!(url.path_segment(), Some("path/to/resource"));
}
#[test]
fn test_parse_with_query() {
let url = DidUrl::parse("did:example:123?versionId=1").unwrap();
assert_eq!(url.did, "did:example:123");
assert_eq!(url.query_string(), Some("versionId=1"));
}
#[test]
fn test_parse_full_url() {
let url = DidUrl::parse("did:example:123/path?query=foo#frag").unwrap();
assert_eq!(url.did, "did:example:123");
assert_eq!(url.path_segment(), Some("path"));
assert_eq!(url.query_string(), Some("query=foo"));
assert_eq!(url.fragment_id(), Some("frag"));
}
#[test]
fn test_parse_fragment_only() {
let url = DidUrl::parse("did:example:123#linked-domain").unwrap();
assert_eq!(url.fragment_id(), Some("linked-domain"));
assert!(url.query.is_none());
}
#[test]
fn test_parse_invalid_no_method() {
assert!(DidUrl::parse("not-a-did").is_err());
}
#[test]
fn test_parse_invalid_missing_id() {
assert!(DidUrl::parse("did:example").is_err());
}
#[test]
fn test_parse_did_key_url() {
let did_key = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let url = DidUrl::parse(&format!("{}#key-0", did_key)).unwrap();
assert_eq!(url.did, did_key);
assert_eq!(url.fragment_id(), Some("key-0"));
}
#[test]
fn test_parse_ethr_with_fragment() {
let url = DidUrl::parse("did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#controller")
.unwrap();
assert_eq!(
url.did,
"did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74"
);
assert_eq!(url.fragment_id(), Some("controller"));
}
#[test]
fn test_to_string_bare() {
let url = DidUrl::parse("did:example:456").unwrap();
assert_eq!(url.to_string(), "did:example:456");
}
#[test]
fn test_to_string_with_all_components() {
let url = DidUrl::parse("did:example:456/p?q=1#frag").unwrap();
assert_eq!(url.to_string(), "did:example:456/p?q=1#frag");
}
#[test]
fn test_to_string_roundtrip() {
let original = "did:example:789#authentication";
let url = DidUrl::parse(original).unwrap();
assert_eq!(url.to_string(), original);
}
#[test]
fn test_query_params_single() {
let url = DidUrl::parse("did:example:123?versionId=42").unwrap();
let params = url.query_params();
assert_eq!(params.len(), 1);
assert_eq!(params[0], ("versionId".to_string(), "42".to_string()));
}
#[test]
fn test_query_params_multiple() {
let url = DidUrl::parse("did:example:123?a=1&b=2&c=3").unwrap();
let params = url.query_params();
assert_eq!(params.len(), 3);
}
#[test]
fn test_query_params_empty_when_no_query() {
let url = DidUrl::parse("did:example:123").unwrap();
assert!(url.query_params().is_empty());
}
#[test]
fn test_dereference_bare_did_returns_document() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123").unwrap();
let resource = DidDereferencer::dereference(&doc, &url).unwrap();
assert!(matches!(resource, DereferencedResource::Document(ref _d)));
}
#[test]
fn test_dereference_verification_method_by_fragment() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123#key-1").unwrap();
let resource = DidDereferencer::dereference(&doc, &url).unwrap();
match resource {
DereferencedResource::VerificationMethod(vm) => {
assert_eq!(vm["id"].as_str().unwrap(), "did:example:123#key-1");
assert_eq!(vm["type"].as_str().unwrap(), "Ed25519VerificationKey2020");
}
other => panic!("Expected VerificationMethod, got {:?}", other),
}
}
#[test]
fn test_dereference_service_by_fragment() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123#linked-domain").unwrap();
let resource = DidDereferencer::dereference(&doc, &url).unwrap();
match resource {
DereferencedResource::Service(svc) => {
assert_eq!(svc["id"].as_str().unwrap(), "did:example:123#linked-domain");
assert_eq!(svc["type"].as_str().unwrap(), "LinkedDomains");
}
other => panic!("Expected Service, got {:?}", other),
}
}
#[test]
fn test_dereference_path_returns_content_stream() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123/credentials/1").unwrap();
let resource = DidDereferencer::dereference(&doc, &url).unwrap();
match resource {
DereferencedResource::ContentStream(bytes) => {
assert_eq!(bytes, b"credentials/1");
}
other => panic!("Expected ContentStream, got {:?}", other),
}
}
#[test]
fn test_dereference_unknown_fragment_error() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123#nonexistent").unwrap();
assert!(DidDereferencer::dereference(&doc, &url).is_err());
}
#[test]
fn test_dereference_wrong_did_error() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:OTHER#key-1").unwrap();
assert!(DidDereferencer::dereference(&doc, &url).is_err());
}
#[test]
fn test_dereference_document_has_correct_id() {
let doc = sample_doc();
let url = DidUrl::parse("did:example:123").unwrap();
match DidDereferencer::dereference(&doc, &url).unwrap() {
DereferencedResource::Document(boxed_doc) => {
assert_eq!(boxed_doc.id.as_str(), "did:example:123");
}
_ => panic!("Expected Document"),
}
}
}