From 1074ed919e5781eb8c99f71c37889c913f991848 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Fri, 25 Sep 2026 22:03:50 -0400 Subject: [PATCH] feat(directory): replace service attribution with publishers --- crates/odp-directory/README.md | 4 +- crates/odp-directory/src/models.rs | 10 ++--- crates/odp-directory/src/results.rs | 17 +++++--- crates/odp-directory/tests/mixed.rs | 63 +++++++++++++++++++++++++++-- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/crates/odp-directory/README.md b/crates/odp-directory/README.md index 6b64859..7a5f53f 100644 --- a/crates/odp-directory/README.md +++ b/crates/odp-directory/README.md @@ -105,7 +105,9 @@ Collection identity is its owning `service.service_id` plus its case-sensitive C Several OpenAPI documents can share an API origin without being the same Directory Service. The result's `indexed_at` describes the Collection's freshness; `service.indexed_at` describes the parent's freshness. Both are timestamp strings. `service.service_id` identifies the -Directory's Service record. Service results may include `available_through` platform attribution; +Directory's Service record. Service results may include optional `publisher` attribution with `publisher_id`, `name`, +and `website_url`. The website is a display link, not a discovery or execution target. +Omitted or null publisher attribution is accepted, and additional fields are preserved; Collection attribution is the owning `service` itself. For `service.source.source_type == "odp"`, inspect the owning Service's live ODP document, then diff --git a/crates/odp-directory/src/models.rs b/crates/odp-directory/src/models.rs index 8784284..48eac17 100644 --- a/crates/odp-directory/src/models.rs +++ b/crates/odp-directory/src/models.rs @@ -143,7 +143,7 @@ pub enum DirectoryResult { pub struct ServiceResult { pub service: DirectoryIndexedService, pub indexed_at: String, - pub available_through: Option, + pub publisher: Option, #[serde(flatten)] pub additional: AdditionalMembers, } @@ -158,10 +158,10 @@ pub struct CollectionResult { } #[derive(Clone, Debug, Deserialize, PartialEq)] -pub struct ServiceReference { - pub service_id: String, - pub service_origin: String, - pub name: Option, +pub struct Publisher { + pub publisher_id: String, + pub name: String, + pub website_url: String, #[serde(flatten)] pub additional: AdditionalMembers, } diff --git a/crates/odp-directory/src/results.rs b/crates/odp-directory/src/results.rs index 4bed6f8..5ece30a 100644 --- a/crates/odp-directory/src/results.rs +++ b/crates/odp-directory/src/results.rs @@ -114,11 +114,18 @@ fn native_service(object: &mut serde_json::Map) -> Result<(), Dir fn finish_result(mut raw: Value, kind: &str) -> Result { if kind == "service" { - if let Some(reference) = raw.get("available_through") { - text(reference, "service_id", 128)?; - origin(reference)?; - if reference.get("name").is_some() { - text(reference, "name", 128)?; + if let Some(publisher) = raw.get("publisher").filter(|value| !value.is_null()) { + text(publisher, "publisher_id", 128)?; + text(publisher, "name", 128)?; + let website = url::Url::parse(text(publisher, "website_url", 512)?).map_err(invalid)?; + if website.scheme() != "https" + || website.host_str().is_none() + || !website.username().is_empty() + || website.password().is_some() + { + return Err(invalid( + "Publisher website must be an HTTPS URL without credentials", + )); } } raw.as_object_mut() diff --git a/crates/odp-directory/tests/mixed.rs b/crates/odp-directory/tests/mixed.rs index ab22b1b..0500d97 100644 --- a/crates/odp-directory/tests/mixed.rs +++ b/crates/odp-directory/tests/mixed.rs @@ -466,7 +466,7 @@ async fn sends_source_filters_on_all_supported_routes_and_rejects_empty_or_dupli async fn decodes_mixed_results_and_preserves_unknown_types() { let (client, stub) = setup(); let mut first = item("service"); - first["available_through"] = json!({"service_id":"platform", "service_origin":"https://platform.example", "name":"Platform"}); + first["publisher"] = json!({"publisher_id":"platform", "website_url":"https://platform.example/catalog", "name":"Platform", "extra":true}); first["extra"] = json!(true); let future = json!({"type":"future","nested":{"data":42}}); stub.ok(json!({"items":[first,item("collection"),future],"extra":42, @@ -482,11 +482,16 @@ async fn decodes_mixed_results_and_preserves_unknown_types() { panic!("service") }; assert_eq!(service.service.service_id, "parent"); + assert_eq!(service.publisher.as_ref().unwrap().name, "Platform"); + assert_eq!(service.additional["extra"], true); assert_eq!( - service.available_through.as_ref().unwrap().name.as_deref(), - Some("Platform") + service.publisher.as_ref().unwrap().additional["extra"], + true + ); + assert_eq!( + service.publisher.as_ref().unwrap().website_url, + "https://platform.example/catalog" ); - assert_eq!(service.additional["extra"], true); assert_eq!(service.service.protocols.as_ref().unwrap().trust.len(), 1); let DirectoryResult::Collection(collection) = &response.items[1] else { panic!("collection") @@ -500,6 +505,56 @@ async fn decodes_mixed_results_and_preserves_unknown_types() { assert_eq!(raw, &future); } +#[tokio::test] +async fn publisher_metadata_is_optional_and_extensible() { + let (client, stub) = setup(); + let mut first = item("service"); + first["publisher"] = Value::Null; + first["available_through"] = json!({"service_id":"legacy"}); + first["future_metadata"] = json!({"arbitrary":true}); + stub.ok(json!({"items":[first,item("service")]})); + let response = client + .search(&ResourceSearchRequest::default()) + .await + .unwrap(); + assert!(response.issues.is_empty()); + assert_eq!(response.items.len(), 2); + let DirectoryResult::Service(service) = &response.items[0] else { + panic!("service") + }; + assert!(service.publisher.is_none()); + assert_eq!( + service.additional["available_through"], + json!({"service_id":"legacy"}) + ); + assert_eq!( + service.additional["future_metadata"], + json!({"arbitrary":true}) + ); +} + +#[tokio::test] +async fn validates_publisher_websites() { + for address in [ + "http://platform.example", + "https://user@platform.example", + "https://user:secret@platform.example", + "://invalid", + ] { + let (client, stub) = setup(); + let mut first = item("service"); + first["publisher"] = + json!({"publisher_id":"gateway","name":"Gateway","website_url":address}); + stub.ok(json!({"items":[first,item("service")]})); + let response = client + .search(&ResourceSearchRequest::default()) + .await + .unwrap(); + assert_eq!(response.issues.len(), 1); + assert_eq!(response.items.len(), 1); + } +} + #[tokio::test] async fn isolates_malformed_known_items_and_normalizes_future_operations() { let (client, stub) = setup();