diff --git a/bindings/wasm/examples/src/resolve_history.js b/bindings/wasm/examples/src/resolve_history.js index f7fd9da569..8e7235b0d3 100644 --- a/bindings/wasm/examples/src/resolve_history.js +++ b/bindings/wasm/examples/src/resolve_history.js @@ -113,7 +113,9 @@ async function resolveHistory(clientConfig) { let serviceJSON2 = { id: diffDoc2.id + "#linked-domain-2", type: "LinkedDomains", - serviceEndpoint: "https://example.com", + serviceEndpoint: { + "origins": ["https://iota.org/", "https://example.com/"] + }, }; diffDoc2.insertService(Service.fromJSON(serviceJSON2)); diffDoc2.updated = Timestamp.nowUTC(); diff --git a/examples/low-level-api/resolve_history.rs b/examples/low-level-api/resolve_history.rs index 274ddcc543..e04962ff9c 100644 --- a/examples/low-level-api/resolve_history.rs +++ b/examples/low-level-api/resolve_history.rs @@ -92,7 +92,7 @@ async fn main() -> Result<()> { let service: Service = Service::from_json_value(json!({ "id": diff_doc_1.id().to_url().join("#linked-domain-1")?, "type": "LinkedDomains", - "serviceEndpoint": "https://iota.org" + "serviceEndpoint": "https://iota.org/" }))?; assert!(diff_doc_1.insert_service(service)); diff_doc_1.set_updated(Timestamp::now_utc()); @@ -120,7 +120,9 @@ async fn main() -> Result<()> { let service: Service = Service::from_json_value(json!({ "id": diff_doc_2.id().to_url().join("#linked-domain-2")?, "type": "LinkedDomains", - "serviceEndpoint": "https://example.com" + "serviceEndpoint": { + "origins": ["https://iota.org/", "https://example.com/"] + } }))?; diff_doc_2.insert_service(service); diff_doc_2.set_updated(Timestamp::now_utc()); diff --git a/identity-account/src/events/command.rs b/identity-account/src/events/command.rs index 2e01aff485..d2634f7f3f 100644 --- a/identity-account/src/events/command.rs +++ b/identity-account/src/events/command.rs @@ -5,8 +5,8 @@ use crypto::signatures::ed25519; use identity_core::common::Fragment; use identity_core::common::Object; -use identity_core::common::Url; use identity_core::crypto::PublicKey; +use identity_did::service::ServiceEndpoint; use identity_did::verification::MethodData; use identity_did::verification::MethodScope; use identity_did::verification::MethodType; @@ -60,7 +60,7 @@ pub(crate) enum Command { CreateService { fragment: String, type_: String, - endpoint: Url, + endpoint: ServiceEndpoint, properties: Option, }, DeleteService { @@ -358,12 +358,12 @@ impl_command_builder!( /// # Parameters /// - `type_`: the type of the service, e.g. `"LinkedDomains"`, required. /// - `fragment`: the identifier of the service in the document, required. -/// - `endpoint`: the url of the service, required. +/// - `endpoint`: the `ServiceEndpoint` of the service, required. /// - `properties`: additional properties of the service, optional. CreateService { @required fragment String, @required type_ String, - @required endpoint Url, + @required endpoint ServiceEndpoint, @optional properties Object, }); diff --git a/identity-account/src/identity/identity_state.rs b/identity-account/src/identity/identity_state.rs index 4c693b41fe..76940d71df 100644 --- a/identity-account/src/identity/identity_state.rs +++ b/identity-account/src/identity/identity_state.rs @@ -18,6 +18,7 @@ use identity_did::did::DID; use identity_did::document::CoreDocument; use identity_did::document::DocumentBuilder; use identity_did::service::Service as CoreService; +use identity_did::service::ServiceEndpoint; use identity_did::verifiable::Properties as VerifiableProperties; use identity_did::verification::MethodData; use identity_did::verification::MethodRef as CoreMethodRef; @@ -569,14 +570,14 @@ pub struct TinyService { #[serde(rename = "2")] type_: String, #[serde(rename = "3")] - endpoint: Url, + endpoint: ServiceEndpoint, #[serde(rename = "4")] properties: Option, } impl TinyService { /// Creates a new `TinyService`. - pub fn new(fragment: String, type_: String, endpoint: Url, properties: Option) -> Self { + pub fn new(fragment: String, type_: String, endpoint: ServiceEndpoint, properties: Option) -> Self { Self { fragment: Fragment::new(fragment), type_, diff --git a/identity-did/Cargo.toml b/identity-did/Cargo.toml index bb138f5921..0914e11431 100644 --- a/identity-did/Cargo.toml +++ b/identity-did/Cargo.toml @@ -15,6 +15,7 @@ async-trait = { version = "0.1", default-features = false } did_url = { version = "0.1", default-features = false, features = ["std", "serde"] } form_urlencoded = { version = "1.0.1", default-features = false } identity-core = { version = "=0.4.0", path = "../identity-core" } +indexmap = { version = "1.7", default-features = false, features = ["std", "serde-1"] } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } strum = { version = "0.21", features = ["derive"] } thiserror = { version = "1.0", default-features = false } diff --git a/identity-did/src/diff/diff_document.rs b/identity-did/src/diff/diff_document.rs index 1e0555493b..81b6d04e97 100644 --- a/identity-did/src/diff/diff_document.rs +++ b/identity-did/src/diff/diff_document.rs @@ -317,6 +317,7 @@ mod test { use identity_core::common::Value; use crate::service::ServiceBuilder; + use crate::service::ServiceEndpoint; use crate::verification::MethodBuilder; use crate::verification::MethodData; use crate::verification::MethodType; @@ -340,7 +341,7 @@ mod test { fn service(did_url: CoreDIDUrl) -> Service { ServiceBuilder::default() .id(did_url) - .service_endpoint(Url::parse("did:service:1234").unwrap()) + .service_endpoint(ServiceEndpoint::One(Url::parse("did:service:1234").unwrap())) .type_("test_service") .build() .unwrap() diff --git a/identity-did/src/diff/diff_service.rs b/identity-did/src/diff/diff_service.rs index e758d33fed..529128a5fe 100644 --- a/identity-did/src/diff/diff_service.rs +++ b/identity-did/src/diff/diff_service.rs @@ -1,17 +1,20 @@ // Copyright 2020-2021 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use serde::Deserialize; +use serde::Serialize; + use identity_core::common::Object; -use identity_core::common::Url; +use identity_core::convert::FromJson; +use identity_core::convert::ToJson; use identity_core::diff::Diff; use identity_core::diff::DiffString; use identity_core::diff::Error; use identity_core::diff::Result; -use serde::Deserialize; -use serde::Serialize; use crate::did::CoreDIDUrl; use crate::service::Service; +use crate::service::ServiceEndpoint; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct DiffService @@ -72,7 +75,7 @@ where .transpose()? .unwrap_or_else(|| self.type_().to_string()); - let service_endpoint: Url = diff + let service_endpoint: ServiceEndpoint = diff .service_endpoint .map(|value| self.service_endpoint().merge(value)) .transpose()? @@ -105,9 +108,9 @@ where .transpose()? .ok_or_else(|| Error::convert("Missing field `service.type_`"))?; - let service_endpoint: Url = diff + let service_endpoint: ServiceEndpoint = diff .service_endpoint - .map(Url::from_diff) + .map(ServiceEndpoint::from_diff) .transpose()? .ok_or_else(|| Error::convert("Missing field `service.service_endpoint`"))?; @@ -131,10 +134,41 @@ where } } +impl Diff for ServiceEndpoint { + type Type = DiffString; + + fn diff(&self, other: &Self) -> identity_core::diff::Result { + self + .to_json() + .map_err(identity_core::diff::Error::diff)? + .diff(&other.to_string()) + } + + fn merge(&self, diff: Self::Type) -> identity_core::diff::Result { + self + .to_json() + .map_err(identity_core::diff::Error::diff)? + .merge(diff) + .and_then(|this| Self::from_json(&this).map_err(identity_core::diff::Error::merge)) + } + + fn from_diff(diff: Self::Type) -> identity_core::diff::Result { + String::from_diff(diff).and_then(|this| Self::from_json(&this).map_err(identity_core::diff::Error::convert)) + } + + fn into_diff(self) -> identity_core::diff::Result { + self.to_json().map_err(identity_core::diff::Error::diff)?.into_diff() + } +} + #[cfg(test)] mod test { - use super::*; + use indexmap::IndexMap; + use identity_core::common::Object; + use identity_core::common::Url; + + use super::*; fn controller() -> CoreDIDUrl { "did:example:1234".parse().unwrap() @@ -146,7 +180,7 @@ mod test { properties.insert("key1".to_string(), "value1".into()); Service::builder(properties) .id(controller) - .service_endpoint(Url::parse("did:service:1234").unwrap()) + .service_endpoint(Url::parse("did:service:1234").unwrap().into()) .type_("test_service") .build() .unwrap() @@ -183,17 +217,76 @@ mod test { } #[test] - fn test_service_endpoint() { + fn test_service_endpoint_one() { let service = service(); let mut new = service.clone(); - let new_url = "did:test:1234".to_string(); - *new.service_endpoint_mut() = Url::parse(new_url.clone()).unwrap(); + let new_url = "did:test:1234#service".to_string(); + *new.service_endpoint_mut() = Url::parse(new_url).unwrap().into(); + + let diff = service.diff(&new).unwrap(); + assert!(diff.id.is_none()); + assert!(diff.properties.is_none()); + assert!(diff.type_.is_none()); + assert_eq!( + diff.service_endpoint, + Some(DiffString(Some("\"did:test:1234#service\"".to_owned()))) + ); + let merge = service.merge(diff).unwrap(); + assert_eq!(merge, new); + } + + #[test] + fn test_service_endpoint_set() { + let service = service(); + + let mut new = service.clone(); + let new_url_set = vec![ + Url::parse("https://example.com/").unwrap(), + Url::parse("did:test:1234#service").unwrap(), + ]; + *new.service_endpoint_mut() = ServiceEndpoint::Set(new_url_set.try_into().unwrap()); + + let diff = service.diff(&new).unwrap(); + assert!(diff.id.is_none()); + assert!(diff.properties.is_none()); + assert!(diff.type_.is_none()); + assert_eq!( + diff.service_endpoint, + Some(DiffString(Some( + r#"["https://example.com/","did:test:1234#service"]"#.to_owned() + ))) + ); + let merge = service.merge(diff).unwrap(); + assert_eq!(merge, new); + } + + #[test] + fn test_service_endpoint_map() { + let service = service(); + + let mut new = service.clone(); + let mut new_url_map = IndexMap::new(); + new_url_map.insert( + "origins".to_owned(), + vec![ + Url::parse("https://example.com/").unwrap(), + Url::parse("did:test:1234#service").unwrap(), + ] + .try_into() + .unwrap(), + ); + *new.service_endpoint_mut() = ServiceEndpoint::Map(new_url_map); let diff = service.diff(&new).unwrap(); assert!(diff.id.is_none()); assert!(diff.properties.is_none()); assert!(diff.type_.is_none()); - assert_eq!(diff.service_endpoint, Some(DiffString(Some(new_url)))); + assert_eq!( + diff.service_endpoint, + Some(DiffString(Some( + r#"{"origins":["https://example.com/","did:test:1234#service"]}"#.to_owned() + ))) + ); let merge = service.merge(diff).unwrap(); assert_eq!(merge, new); } diff --git a/identity-did/src/error.rs b/identity-did/src/error.rs index d7357ad982..b8268603ab 100644 --- a/identity-did/src/error.rs +++ b/identity-did/src/error.rs @@ -76,5 +76,5 @@ pub enum Error { #[error("Invalid DID Resolution Fragment")] InvalidDIDFragment, #[error("Invalid DID Resolution Service")] - InvalidServiceProtocol, + InvalidResolutionService, } diff --git a/identity-did/src/resolution/impls.rs b/identity-did/src/resolution/impls.rs index 864c489561..ba981211b8 100644 --- a/identity-did/src/resolution/impls.rs +++ b/identity-did/src/resolution/impls.rs @@ -20,6 +20,7 @@ use crate::resolution::Resolution; use crate::resolution::ResolverMethod; use crate::resolution::Resource; use crate::resolution::SecondaryResource; +use crate::service::ServiceEndpoint; use crate::utils::OrderedSet; /// Resolves a DID into a DID Document by using the "Read" operation of the DID method. @@ -229,7 +230,12 @@ fn dereference_primary(document: CoreDocument, mut did_url: CoreDIDUrl) -> Resul .find(|service| matches!(service.id().fragment(), Some(fragment) if fragment == target)) .map(|service| service.service_endpoint()) // 1.2. Execute the Service Endpoint Construction algorithm. - .map(|url| service_endpoint_ctor(did_url, url)) + .map(|endpoint| match endpoint { + ServiceEndpoint::One(url) => service_endpoint_ctor(did_url, url), + // TODO: support service endpoint sets and map? Dereferencing spec does not address them. + ServiceEndpoint::Set(_) => Err(Error::InvalidResolutionService), + ServiceEndpoint::Map(_) => Err(Error::InvalidResolutionService), + }) .transpose()? // 1.3. Return the output service endpoint URL. .map(Into::into) @@ -321,7 +327,7 @@ fn service_endpoint_ctor(did: CoreDIDUrl, url: &Url) -> Result { // The input service endpoint URL MUST be an HTTP(S) URL. if url.scheme() != "https" { - return Err(Error::InvalidServiceProtocol); + return Err(Error::InvalidResolutionService); } // 1. Initialize a string output service endpoint URL to the value of @@ -455,7 +461,7 @@ mod test { fn generate_service(did: &CoreDID, fragment: &str, url: &str) -> Service { Service::builder(Default::default()) .id(did.to_url().join(fragment).unwrap()) - .service_endpoint(Url::parse(url).unwrap()) + .service_endpoint(Url::parse(url).unwrap().into()) .type_("LinkedDomains") .build() .unwrap() diff --git a/identity-did/src/service/builder.rs b/identity-did/src/service/builder.rs index 3cbbe6015b..fc5e5d4b91 100644 --- a/identity-did/src/service/builder.rs +++ b/identity-did/src/service/builder.rs @@ -2,18 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 use identity_core::common::Object; -use identity_core::common::Url; use crate::did::CoreDIDUrl; use crate::error::Result; use crate::service::Service; +use crate::service::ServiceEndpoint; /// A `ServiceBuilder` is used to generate a customized `Service`. #[derive(Clone, Debug, Default)] pub struct ServiceBuilder { pub(crate) id: Option, pub(crate) type_: Option, - pub(crate) service_endpoint: Option, + pub(crate) service_endpoint: Option, pub(crate) properties: T, } @@ -44,7 +44,7 @@ impl ServiceBuilder { /// Sets the `serviceEndpoint` value of the generated `Service`. #[must_use] - pub fn service_endpoint(mut self, value: Url) -> Self { + pub fn service_endpoint(mut self, value: ServiceEndpoint) -> Self { self.service_endpoint = Some(value); self } @@ -57,6 +57,8 @@ impl ServiceBuilder { #[cfg(test)] mod tests { + use identity_core::common::Url; + use super::*; #[test] @@ -64,7 +66,7 @@ mod tests { fn test_missing_id() { let _: Service = ServiceBuilder::default() .type_("ServiceType") - .service_endpoint("https://example.com".parse().unwrap()) + .service_endpoint(Url::parse("https://example.com").unwrap().into()) .build() .unwrap(); } @@ -74,7 +76,7 @@ mod tests { fn test_missing_type_() { let _: Service = ServiceBuilder::default() .id("did:example:123".parse().unwrap()) - .service_endpoint("https://example.com".parse().unwrap()) + .service_endpoint(Url::parse("https://example.com").unwrap().into()) .build() .unwrap(); } diff --git a/identity-did/src/service/mod.rs b/identity-did/src/service/mod.rs index 238c2ed098..e7da5717ef 100644 --- a/identity-did/src/service/mod.rs +++ b/identity-did/src/service/mod.rs @@ -5,6 +5,8 @@ mod builder; mod service; +mod service_endpoint; pub use self::builder::ServiceBuilder; pub use self::service::Service; +pub use self::service_endpoint::ServiceEndpoint; diff --git a/identity-did/src/service/service.rs b/identity-did/src/service/service.rs index 14a0818248..df547b9d8d 100644 --- a/identity-did/src/service/service.rs +++ b/identity-did/src/service/service.rs @@ -5,15 +5,17 @@ use core::fmt::Display; use core::fmt::Error as FmtError; use core::fmt::Formatter; use core::fmt::Result as FmtResult; + +use serde::Serialize; + use identity_core::common::Object; -use identity_core::common::Url; use identity_core::convert::ToJson; -use serde::Serialize; use crate::did::CoreDIDUrl; use crate::error::Error; use crate::error::Result; use crate::service::ServiceBuilder; +use crate::service::ServiceEndpoint; /// A DID Document Service used to enable trusted interactions associated with a DID subject. /// @@ -24,7 +26,7 @@ pub struct Service { #[serde(rename = "type")] pub(crate) type_: String, #[serde(rename = "serviceEndpoint")] - pub(crate) service_endpoint: Url, + pub(crate) service_endpoint: ServiceEndpoint, #[serde(flatten)] pub(crate) properties: T, } @@ -68,12 +70,12 @@ impl Service { } /// Returns a reference to the `Service` endpoint. - pub fn service_endpoint(&self) -> &Url { + pub fn service_endpoint(&self) -> &ServiceEndpoint { &self.service_endpoint } /// Returns a mutable reference to the `Service` endpoint. - pub fn service_endpoint_mut(&mut self) -> &mut Url { + pub fn service_endpoint_mut(&mut self) -> &mut ServiceEndpoint { &mut self.service_endpoint } @@ -88,6 +90,12 @@ impl Service { } } +impl AsRef for Service { + fn as_ref(&self) -> &CoreDIDUrl { + self.id() + } +} + impl Display for Service where T: Serialize, @@ -101,8 +109,52 @@ where } } -impl AsRef for Service { - fn as_ref(&self) -> &CoreDIDUrl { - self.id() +#[cfg(test)] +mod tests { + use crate::did::CoreDIDUrl; + use crate::service::Service; + use identity_core::common::Object; + use identity_core::common::Url; + + use crate::service::service::ServiceEndpoint; + use crate::utils::OrderedSet; + use identity_core::convert::FromJson; + use identity_core::convert::ToJson; + + #[test] + fn test_service_serde() { + // Single endpoint + { + let service: Service = Service::builder(Object::new()) + .id(CoreDIDUrl::parse("did:example:123#service").unwrap()) + .type_("LinkedDomains".to_owned()) + .service_endpoint(Url::parse("https://iota.org/").unwrap().into()) + .build() + .unwrap(); + let expected = r#"{"id":"did:example:123#service","type":"LinkedDomains","serviceEndpoint":"https://iota.org/"}"#; + assert_eq!(service.to_json().unwrap(), expected); + assert_eq!(Service::from_json(expected).unwrap(), service); + } + + // Set of endpoints + { + let endpoint: ServiceEndpoint = ServiceEndpoint::Set( + OrderedSet::try_from(vec![ + Url::parse("https://iota.org/").unwrap(), + Url::parse("wss://www.example.com/socketserver/").unwrap(), + Url::parse("did:abc:123#service").unwrap(), + ]) + .unwrap(), + ); + let service: Service = Service::builder(Object::new()) + .id(CoreDIDUrl::parse("did:example:123#service").unwrap()) + .type_("LinkedDomains".to_owned()) + .service_endpoint(endpoint) + .build() + .unwrap(); + let expected = r#"{"id":"did:example:123#service","type":"LinkedDomains","serviceEndpoint":["https://iota.org/","wss://www.example.com/socketserver/","did:abc:123#service"]}"#; + assert_eq!(service.to_json().unwrap(), expected); + assert_eq!(Service::from_json(expected).unwrap(), service) + } } } diff --git a/identity-did/src/service/service_endpoint.rs b/identity-did/src/service/service_endpoint.rs new file mode 100644 index 0000000000..c75b1e42b8 --- /dev/null +++ b/identity-did/src/service/service_endpoint.rs @@ -0,0 +1,231 @@ +// Copyright 2020-2021 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use core::fmt::Display; +use core::fmt::Error as FmtError; +use core::fmt::Formatter; +use core::fmt::Result as FmtResult; + +use indexmap::map::IndexMap; +use serde::Serialize; + +use identity_core::common::Url; +use identity_core::convert::ToJson; + +use crate::utils::OrderedSet; + +/// A single URL, set, or map of endpoints specified in a [`Service`]. +/// +/// [Specification](https://www.w3.org/TR/did-core/#dfn-serviceendpoint) +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ServiceEndpoint { + One(Url), + Set(OrderedSet), + Map(IndexMap>), + // TODO: enforce set/map is non-empty? +} + +impl From for ServiceEndpoint { + fn from(url: Url) -> Self { + ServiceEndpoint::One(url) + } +} + +impl From> for ServiceEndpoint { + fn from(set: OrderedSet) -> Self { + ServiceEndpoint::Set(set) + } +} + +impl From>> for ServiceEndpoint { + fn from(map: IndexMap>) -> Self { + ServiceEndpoint::Map(map) + } +} + +impl Display for ServiceEndpoint { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + if f.alternate() { + f.write_str(&self.to_json_pretty().map_err(|_| FmtError)?) + } else { + f.write_str(&self.to_json().map_err(|_| FmtError)?) + } + } +} + +#[cfg(test)] +mod tests { + use indexmap::map::IndexMap; + + use identity_core::common::Url; + use identity_core::convert::FromJson; + use identity_core::convert::ToJson; + + use crate::service::ServiceEndpoint; + use crate::utils::OrderedSet; + + #[test] + fn test_service_endpoint_one() { + let url1 = Url::parse("https://iota.org/").unwrap(); + let url2 = Url::parse("wss://www.example.com/socketserver/").unwrap(); + let url3 = Url::parse("did:abc:123#service").unwrap(); + + // VALID: One. + let endpoint1: ServiceEndpoint = ServiceEndpoint::One(url1); + let ser_endpoint1: String = endpoint1.to_json().unwrap(); + assert_eq!(ser_endpoint1, "\"https://iota.org/\""); + assert_eq!(endpoint1, ServiceEndpoint::from_json(&ser_endpoint1).unwrap()); + + let endpoint2: ServiceEndpoint = ServiceEndpoint::One(url2); + let ser_endpoint2: String = endpoint2.to_json().unwrap(); + assert_eq!(ser_endpoint2, "\"wss://www.example.com/socketserver/\""); + assert_eq!(endpoint2, ServiceEndpoint::from_json(&ser_endpoint2).unwrap()); + + let endpoint3: ServiceEndpoint = ServiceEndpoint::One(url3); + let ser_endpoint3: String = endpoint3.to_json().unwrap(); + assert_eq!(ser_endpoint3, "\"did:abc:123#service\""); + assert_eq!(endpoint3, ServiceEndpoint::from_json(&ser_endpoint3).unwrap()); + } + + #[test] + fn test_service_endpoint_set() { + let url1 = Url::parse("https://iota.org/").unwrap(); + let url2 = Url::parse("wss://www.example.com/socketserver/").unwrap(); + let url3 = Url::parse("did:abc:123#service").unwrap(); + + // VALID: Set. + let mut set: OrderedSet = OrderedSet::new(); + // One element. + assert!(set.append(url1.clone())); + let endpoint_set: ServiceEndpoint = ServiceEndpoint::Set(set.clone()); + let ser_endpoint_set: String = endpoint_set.to_json().unwrap(); + assert_eq!(ser_endpoint_set, "[\"https://iota.org/\"]"); + assert_eq!(endpoint_set, ServiceEndpoint::from_json(&ser_endpoint_set).unwrap()); + // Two elements. + assert!(set.append(url2.clone())); + let endpoint_set: ServiceEndpoint = ServiceEndpoint::Set(set.clone()); + let ser_endpoint_set: String = endpoint_set.to_json().unwrap(); + assert_eq!( + ser_endpoint_set, + "[\"https://iota.org/\",\"wss://www.example.com/socketserver/\"]" + ); + assert_eq!(endpoint_set, ServiceEndpoint::from_json(&ser_endpoint_set).unwrap()); + // Three elements. + assert!(set.append(url3.clone())); + let endpoint_set: ServiceEndpoint = ServiceEndpoint::Set(set.clone()); + let ser_endpoint_set: String = endpoint_set.to_json().unwrap(); + assert_eq!( + ser_endpoint_set, + "[\"https://iota.org/\",\"wss://www.example.com/socketserver/\",\"did:abc:123#service\"]" + ); + assert_eq!(endpoint_set, ServiceEndpoint::from_json(&ser_endpoint_set).unwrap()); + + // VALID: Set ignores duplicates. + let mut duplicates_set: OrderedSet = OrderedSet::new(); + duplicates_set.append(url1.clone()); + duplicates_set.append(url1.clone()); + assert_eq!( + ServiceEndpoint::Set(duplicates_set.clone()).to_json().unwrap(), + "[\"https://iota.org/\"]" + ); + duplicates_set.append(url2.clone()); + duplicates_set.append(url2.clone()); + duplicates_set.append(url1.clone()); + assert_eq!( + ServiceEndpoint::Set(duplicates_set.clone()).to_json().unwrap(), + "[\"https://iota.org/\",\"wss://www.example.com/socketserver/\"]" + ); + assert!(duplicates_set.append(url3.clone())); + duplicates_set.append(url3); + duplicates_set.append(url1); + duplicates_set.append(url2); + assert_eq!( + ServiceEndpoint::Set(duplicates_set.clone()).to_json().unwrap(), + "[\"https://iota.org/\",\"wss://www.example.com/socketserver/\",\"did:abc:123#service\"]" + ); + } + + #[test] + fn test_service_endpoint_map() { + let url1 = Url::parse("https://iota.org/").unwrap(); + let url2 = Url::parse("wss://www.example.com/socketserver/").unwrap(); + let url3 = Url::parse("did:abc:123#service").unwrap(); + let url4 = Url::parse("did:xyz:789#link").unwrap(); + + // VALID: Map. + let mut map: IndexMap> = IndexMap::new(); + // One entry. + assert!(map + .insert("key".to_owned(), OrderedSet::try_from(vec![url1]).unwrap()) + .is_none()); + let endpoint_map: ServiceEndpoint = ServiceEndpoint::Map(map.clone()); + let ser_endpoint_map: String = endpoint_map.to_json().unwrap(); + assert_eq!(ser_endpoint_map, r#"{"key":["https://iota.org/"]}"#); + assert_eq!(endpoint_map, ServiceEndpoint::from_json(&ser_endpoint_map).unwrap()); + // Two entries. + assert!(map + .insert("apple".to_owned(), OrderedSet::try_from(vec![url2]).unwrap()) + .is_none()); + let endpoint_map: ServiceEndpoint = ServiceEndpoint::Map(map.clone()); + let ser_endpoint_map: String = endpoint_map.to_json().unwrap(); + assert_eq!( + ser_endpoint_map, + r#"{"key":["https://iota.org/"],"apple":["wss://www.example.com/socketserver/"]}"# + ); + assert_eq!(endpoint_map, ServiceEndpoint::from_json(&ser_endpoint_map).unwrap()); + // Three entries. + assert!(map + .insert("example".to_owned(), OrderedSet::try_from(vec![url3]).unwrap()) + .is_none()); + let endpoint_map: ServiceEndpoint = ServiceEndpoint::Map(map.clone()); + let ser_endpoint_map: String = endpoint_map.to_json().unwrap(); + assert_eq!( + ser_endpoint_map, + r#"{"key":["https://iota.org/"],"apple":["wss://www.example.com/socketserver/"],"example":["did:abc:123#service"]}"# + ); + assert_eq!(endpoint_map, ServiceEndpoint::from_json(&ser_endpoint_map).unwrap()); + + // Ensure insertion order is maintained. + // Remove first entry and add a new one. + map.shift_remove("key"); // N.B: only shift_remove retains order for IndexMap + assert!(map + .insert("bee".to_owned(), OrderedSet::try_from(vec![url4]).unwrap()) + .is_none()); + let endpoint_map: ServiceEndpoint = ServiceEndpoint::Map(map.clone()); + let ser_endpoint_map: String = endpoint_map.to_json().unwrap(); + assert_eq!( + ser_endpoint_map, + r#"{"apple":["wss://www.example.com/socketserver/"],"example":["did:abc:123#service"],"bee":["did:xyz:789#link"]}"# + ); + assert_eq!(endpoint_map, ServiceEndpoint::from_json(&ser_endpoint_map).unwrap()); + } + + #[test] + fn test_service_endpoint_serde_fails() { + // INVALID: empty + assert!(ServiceEndpoint::from_json("").is_err()); + assert!(ServiceEndpoint::from_json("\"\"").is_err()); + + // INVALID: spaces + assert!(ServiceEndpoint::from_json("\" \"").is_err()); + assert!(ServiceEndpoint::from_json("\"\t\"").is_err()); + assert!(ServiceEndpoint::from_json(r#""https:// iota.org/""#).is_err()); + assert!(ServiceEndpoint::from_json(r#"["https://iota.org/","wss://www.example.com /socketserver/"]"#).is_err()); + assert!(ServiceEndpoint::from_json(r#"{"key":["https:// iota.org/"],"apple":["wss://www.example.com/socketserver/"],"example":["did:abc:123#service"]}"#).is_err()); + + // INVALID: set with duplicate keys + assert!(ServiceEndpoint::from_json(r#"["https://iota.org/","https://iota.org/"]"#).is_err()); + // INVALID: set with duplicate keys when normalised + assert!(ServiceEndpoint::from_json(r#"["https://iota.org/a/./b/../b/.","https://iota.org/a/b/"]"#).is_err()); + + // INVALID: map with no keys + assert!(ServiceEndpoint::from_json(r#"{["https://iota.org/"],["wss://www.example.com/socketserver/"]}"#).is_err()); + assert!( + ServiceEndpoint::from_json(r#"{"key1":["https://iota.org/"],["wss://www.example.com/socketserver/"]}"#).is_err() + ); + assert!( + ServiceEndpoint::from_json(r#"{["https://iota.org/"],"key2":["wss://www.example.com/socketserver/"]}"#).is_err() + ); + } +} diff --git a/identity-did/src/utils/key_comparable.rs b/identity-did/src/utils/key_comparable.rs index 23dbf8549b..f08fe236f3 100644 --- a/identity-did/src/utils/key_comparable.rs +++ b/identity-did/src/utils/key_comparable.rs @@ -3,6 +3,7 @@ use crate::did::CoreDIDUrl; use core::convert::AsRef; +use identity_core::common::Url; /// A trait for comparing types only by a certain key. pub trait KeyComparable { @@ -18,3 +19,11 @@ impl> KeyComparable for T { self.as_ref() } } + +impl KeyComparable for Url { + type Key = Url; + + fn as_key(&self) -> &Self::Key { + self + } +}