Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/odp-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions crates/odp-directory/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ pub enum DirectoryResult {
pub struct ServiceResult {
pub service: DirectoryIndexedService,
pub indexed_at: String,
pub available_through: Option<ServiceReference>,
pub publisher: Option<Publisher>,
#[serde(flatten)]
pub additional: AdditionalMembers,
}
Expand All @@ -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<String>,
pub struct Publisher {
pub publisher_id: String,
pub name: String,
pub website_url: String,
#[serde(flatten)]
pub additional: AdditionalMembers,
}
Expand Down
17 changes: 12 additions & 5 deletions crates/odp-directory/src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,18 @@ fn native_service(object: &mut serde_json::Map<String, Value>) -> Result<(), Dir

fn finish_result(mut raw: Value, kind: &str) -> Result<DirectoryResult, DirectoryError> {
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()
Expand Down
63 changes: 59 additions & 4 deletions crates/odp-directory/tests/mixed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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();
Expand Down
Loading