diff --git a/README.md b/README.md index df91d9b..b698428 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ for candidate Services, inspects each Service's live ODP document, and then navi that Service's Collections and Offerings. `DirectoryClient::search` discovers indexed Services and submitted Collections. Use -`search_services` for a Service-only response or `collect_services` for bounded Service-only +`service.source` to distinguish native ODP from imported OpenAPI documents and retain the exact +discovery URL. Search and suggestions accept source filters. Imported Collections are Directory +groups and must not be passed to ODP operations. Use +`search_services` for a native ODP Service-only response or `collect_services` for bounded Service-only aggregation. `suggest` returns mixed target names; `suggest_services` returns Service-only keyword suggestions. See the [Directory guide](./crates/odp-directory/README.md) for result types, the 100-result cap, and migration from the earlier method names. diff --git a/crates/odp-agent/README.md b/crates/odp-agent/README.md index 3c8562c..837c3b4 100644 --- a/crates/odp-agent/README.md +++ b/crates/odp-agent/README.md @@ -73,9 +73,11 @@ caller can use them without additional network access. ## Search across Services -Federated discovery uses `DirectoryClient::collect_services` and remains Service-only. For mixed -discovery, use `DirectoryClient::search`, inspect each Collection result's owning Service, then -call `ServiceClient::get_collection` with its Collection ID. Collection results are not separate +Federated discovery uses `DirectoryClient::collect_services` and remains native ODP Service-only. +For mixed discovery, use `DirectoryClient::search`. When a Collection's +`service.source.source_type` is `"odp"`, inspect its owning Service, then call +`ServiceClient::get_collection` with its Collection ID. OpenAPI and unknown sources must not be +passed to ODP operations; their Collection IDs identify Directory groups. Collection results are not separate Services. See the [Directory guide](../odp-directory/README.md#search-services-and-collections). ```rust,no_run diff --git a/crates/odp-directory/README.md b/crates/odp-directory/README.md index 4c3561a..6b64859 100644 --- a/crates/odp-directory/README.md +++ b/crates/odp-directory/README.md @@ -55,7 +55,7 @@ let suggestions = directory # } ``` -`search_services` returns one Service-only response. `continue_search_services` follows one opaque +`search_services` returns one native ODP Service-only response. `continue_search_services` follows one opaque `next` reference. `collect_services` performs bounded Service-only traversal, stopping at the response or item limit without fetching another response. Search filters cover keywords, ODP operations, enrollment protocols, payment protocols, payment @@ -101,18 +101,71 @@ for issue in response.issues { `ResultType::Collection`. `None` selects both; explicit lists must be nonempty and distinct. Filters apply to the owning Service. An empty query is omitted, allowing browsing. -Collection identity is its owning Service origin plus its case-sensitive Collection ID. +Collection identity is its owning `service.service_id` plus its case-sensitive Collection ID. +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 +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; Collection attribution is the owning `service` itself. -Inspect the owning Service's live ODP document, then use the Agent client's `get_collection` to -retrieve current details. Directory metadata is not authority to execute an Action or send +For `service.source.source_type == "odp"`, inspect the owning Service's live ODP document, then +use the Agent client's `get_collection` to retrieve current details. An OpenAPI Collection is a +Directory presentation group, not an ODP operation target. Directory metadata is not authority to execute an Action or send credentials. Unknown future result types retain their full raw JSON and are not interpreted as Services. Malformed known results become indexed `issues` without discarding valid results. Additional fields are retained in `additional` maps. +Mixed results use `DirectoryIndexedService`, separate from the native `DirectoryService` returned +by `search_services`. Each mixed Service requires `service_id`, `service_origin`, `name`, +`indexed_at` and `source`. Imported descriptions and languages are optional; missing lists become +empty vectors. Imported results do not expose native ODP operations. Native results retain ODP +validation. Unverified execution fields such as `http` and `payment_origins` are not returned. + +`DirectorySource` describes the document used for discovery: + +- `source_type` is `"odp"`, `"openapi"`, or an unknown future string. Unknown formats remain + readable but must not be passed to ODP operations. +- `url` is the exact document URL, including path and query. It may differ from the API origin; + do not reconstruct it from `service_origin`. +- `x402_discovery` records supporting fixed-path x402 discovery, not proof that an endpoint + accepts payments. Advertised evidence remains in `protocols`. + +The client does not fetch or execute OpenAPI documents. + +## Filter by source + +```rust,no_run +use odp_directory::{DirectoryClient, Environment, ResourceSearchRequest, ServiceFilters, SourceType, SuggestionRequest}; + +# #[tokio::main(flavor = "current_thread")] +# async fn main() -> Result<(), Box> { +let directory = DirectoryClient::new(Environment::Production)?; +let filters = ServiceFilters { + sources: Some(vec![SourceType::Openapi]), + ..Default::default() +}; +let results = directory.search(&ResourceSearchRequest { + filters: Some(filters.clone()), + query: "weather".to_owned(), + ..Default::default() +}).await?; +let names = directory.suggest(&SuggestionRequest { + filters: Some(filters), + prefix: "we".to_owned(), + ..Default::default() +}).await?; +# Ok(()) +# } +``` + +`sources: None` omits the filter. An explicit list must contain one or both distinct +`SourceType::Odp` and `SourceType::Openapi` values. Sources are alternatives, combined with other +filter categories using AND; Collections inherit the owning Service's source. Native +`search_services` accepts the filter but remains ODP-only, so an OpenAPI-only filter yields no +native matches. Unsupported source filter values are rejected during deserialization. + +## Mixed search bounds and suggestions + Mixed search returns at most 100 results. `limit: 0` omits the limit, using the server's default of 100. The server does not currently offer continuation: absent `next` does not mean every match was returned. `continue_search` accepts an opaque same-origin continuation if one is supplied. @@ -121,7 +174,7 @@ a Service and two Collections count as three. Collection search does not depend to display its card on the Directory landing page. `suggest` sends POST `/v1/directory/suggestions`. `SuggestionRequest.filters` accepts the same -`ServiceFilters` as search, including AEP, keywords, ODP operations, payments and trust. +`ServiceFilters` as search, including AEP, keywords, ODP operations, payments, sources and trust. Collection filters apply to their owning Service. Matching spans names, descriptions and keywords, but output contains deduplicated **names of matching Services and Collections**. Despite the argument name `prefix`, matching uses substrings and whitespace-separated alternative terms. @@ -133,6 +186,12 @@ See the [canonical Directory example](../../examples/README.md#canonical-directo ## Migration +- Mixed result `service` fields use `DirectoryIndexedService`; its required `service_id` is a + field rather than an optional accessor. Native Service-only models are unchanged. +- Explicit `ServiceFilters` literals include `sources: None` or `..Default::default()`. +- Known mixed results require `source` metadata from the Directory. Missing sources are reported + as item issues, not assumed to be ODP. + - Service-only `search` calls become `search_services`; `continue_search` calls become `continue_search_services`. - Aggregating `search_services(request, options)` calls become `collect_services(request, options)`. diff --git a/crates/odp-directory/src/client.rs b/crates/odp-directory/src/client.rs index dcaa26c..80f1757 100644 --- a/crates/odp-directory/src/client.rs +++ b/crates/odp-directory/src/client.rs @@ -389,6 +389,10 @@ fn require_service_origin(value: Option<&Value>) -> Result<(), DirectoryError> { } let url = Url::parse(origin).map_err(|error| DirectoryError::InvalidResponse(error.to_string()))?; + require_public_host(&url) +} + +pub(crate) fn require_public_host(url: &Url) -> Result<(), DirectoryError> { // An address literal is judged outright. A name is not resolved here: nothing is being // reached, and an Agent that later connects resolves and judges it again for itself. let reachable = match url.host() { @@ -411,7 +415,7 @@ fn is_local_name(host: &str) -> bool { } /// An indexing time is an RFC 3339 timestamp, not whatever a date parser happens to accept. -fn require_indexed_at(value: Option<&Value>) -> Result<(), DirectoryError> { +pub(crate) fn require_indexed_at(value: Option<&Value>) -> Result<(), DirectoryError> { let indexed_at = value.and_then(Value::as_str).ok_or_else(|| { DirectoryError::InvalidResponse("Directory indexing time is missing".to_owned()) })?; @@ -628,6 +632,14 @@ fn validate_search( let Some(filters) = filters else { return Ok(()); }; + if let Some(sources) = &filters.sources { + if sources.is_empty() || sources.len() > 2 { + return Err(DirectoryError::InvalidRequest( + "sources must contain one or two distinct odp or openapi values".to_owned(), + )); + } + require_unique(sources, "sources")?; + } if filters.keywords.len() > 32 || filters .keywords diff --git a/crates/odp-directory/src/lib.rs b/crates/odp-directory/src/lib.rs index 9aba635..0624c86 100644 --- a/crates/odp-directory/src/lib.rs +++ b/crates/odp-directory/src/lib.rs @@ -3,6 +3,7 @@ mod client; mod models; mod results; +mod sources; mod transport; pub use client::*; diff --git a/crates/odp-directory/src/models.rs b/crates/odp-directory/src/models.rs index 729818d..8784284 100644 --- a/crates/odp-directory/src/models.rs +++ b/crates/odp-directory/src/models.rs @@ -33,6 +33,8 @@ pub struct ServiceFilters { pub operations: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub payments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub sources: Option>, /// A trust filter is either empty or the single-item array `[{"name":"tap"}]`: `tap` is the /// only trust protocol this ODP version names. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -76,6 +78,48 @@ pub enum ResultType { Collection, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceType { + Odp, + Openapi, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct DirectorySource { + /// Unknown future formats remain readable but are not ODP capabilities. + #[serde(rename = "type")] + pub source_type: String, + pub url: String, + pub x402_discovery: bool, + #[serde(flatten)] + pub additional: AdditionalMembers, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct DirectoryIndexedService { + pub description: Option, + pub documentation_url: Option, + pub indexed_at: String, + #[serde(default)] + pub keywords: Vec, + pub language: Option, + #[serde(default)] + pub localizations: Vec, + pub name: String, + #[serde(default)] + pub operations: Vec, + pub protocols: Option, + pub service_id: String, + pub service_origin: String, + pub source: DirectorySource, + pub status_url: Option, + pub support_url: Option, + pub website_url: Option, + #[serde(flatten)] + pub additional: AdditionalMembers, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct ResourceSearchRequest { #[serde(skip_serializing_if = "Option::is_none")] @@ -97,7 +141,7 @@ pub enum DirectoryResult { #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct ServiceResult { - pub service: DirectoryService, + pub service: DirectoryIndexedService, pub indexed_at: String, pub available_through: Option, #[serde(flatten)] @@ -106,7 +150,7 @@ pub struct ServiceResult { #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct CollectionResult { - pub service: DirectoryService, + pub service: DirectoryIndexedService, pub indexed_at: String, pub collection: CollectionSummary, #[serde(flatten)] diff --git a/crates/odp-directory/src/results.rs b/crates/odp-directory/src/results.rs index 1ced3c0..4bed6f8 100644 --- a/crates/odp-directory/src/results.rs +++ b/crates/odp-directory/src/results.rs @@ -60,17 +60,17 @@ fn result(mut raw: Value) -> Result { if kind != "service" && kind != "collection" { return Ok(DirectoryResult::Unknown { kind, raw }); } - text(&raw, "indexed_at", 64)?; + crate::client::require_indexed_at(raw.get("indexed_at"))?; let service = raw .get_mut("service") .ok_or_else(|| invalid("Missing service"))?; text(service, "service_id", 128)?; origin(service)?; - text(service, "indexed_at", 64)?; + crate::client::require_indexed_at(service.get("indexed_at"))?; + let source = crate::sources::read(service.get("source"))?; let object = service .as_object_mut() .ok_or_else(|| invalid("service must be an object"))?; - let mut projection = object.clone(); for name in [ "branding", "http", @@ -79,8 +79,19 @@ fn result(mut raw: Value) -> Result { "payment_origins", "search_capabilities", ] { - projection.remove(name); + object.remove(name); + } + if source.source_type == "odp" { + native_service(object)?; + } else { + crate::sources::imported_service(object)?; } + finish_result(raw, &kind) +} + +fn native_service(object: &mut serde_json::Map) -> Result<(), DirectoryError> { + let mut projection = object.clone(); + projection.remove("source"); let mut document = Value::Object(projection); document["odp_version"] = json!("1.0"); document["http"] = json!({"endpoint_base":"/"}); @@ -98,6 +109,10 @@ fn result(mut raw: Value) -> Result { } else { object.remove("protocols"); } + Ok(()) +} + +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)?; @@ -148,7 +163,11 @@ fn origin(value: &Value) -> Result<(), DirectoryError> { Ok(()) } -fn text<'a>(value: &'a Value, field: &str, maximum: usize) -> Result<&'a str, DirectoryError> { +pub(crate) fn text<'a>( + value: &'a Value, + field: &str, + maximum: usize, +) -> Result<&'a str, DirectoryError> { value .get(field) .and_then(Value::as_str) @@ -160,6 +179,6 @@ fn text<'a>(value: &'a Value, field: &str, maximum: usize) -> Result<&'a str, Di }) } -fn invalid(error: impl std::fmt::Display) -> DirectoryError { +pub(crate) fn invalid(error: impl std::fmt::Display) -> DirectoryError { DirectoryError::InvalidResponse(error.to_string()) } diff --git a/crates/odp-directory/src/sources.rs b/crates/odp-directory/src/sources.rs new file mode 100644 index 0000000..2a446af --- /dev/null +++ b/crates/odp-directory/src/sources.rs @@ -0,0 +1,96 @@ +use std::collections::BTreeSet; + +use odp_core::validate_value; +use serde_json::{Map, Value}; +use url::Url; + +use crate::{ + DirectoryError, DirectorySource, + results::{invalid, text}, +}; + +pub(crate) fn read(value: Option<&Value>) -> Result { + let value = value.ok_or_else(|| invalid("Missing source"))?; + text(value, "type", 128)?; + let exact = text(value, "url", 2048)?; + let url = Url::parse(exact).map_err(invalid)?; + if !exact + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) + || exact.chars().any(char::is_whitespace) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(invalid( + "source.url must be an HTTPS document URL without credentials or fragment", + )); + } + crate::client::require_public_host(&url)?; + serde_json::from_value(value.clone()).map_err(invalid) +} + +pub(crate) fn imported_service(object: &mut Map) -> Result<(), DirectoryError> { + let name = object.get("name").and_then(Value::as_str); + if name.is_none_or(|name| name.trim().is_empty() || name.chars().count() > 128) { + return Err(invalid("Imported Service name is invalid")); + } + for field in [ + "description", + "language", + "documentation_url", + "status_url", + "support_url", + "website_url", + ] { + if object.get(field).is_some_and(|value| !value.is_string()) { + return Err(invalid(format!("{field} must be a string"))); + } + } + object.remove("operations"); + if let Some(protocols) = object.get("protocols") { + let protocols = protocols + .as_object() + .ok_or_else(|| invalid("protocols must be an object"))?; + let mut retained = Map::new(); + for (category, known, schema) in [ + ( + "enrollment", + &["aep"][..], + "enrollment-protocol.schema.json", + ), + ( + "payments", + &["mpp", "x402"][..], + "payment-protocol.schema.json", + ), + ("trust", &["tap"][..], "trust-protocol.schema.json"), + ] { + let Some(values) = protocols.get(category) else { + continue; + }; + let values = values + .as_array() + .filter(|values| !values.is_empty()) + .ok_or_else(|| invalid(format!("protocols.{category} must be a nonempty array")))?; + let mut selected = Vec::new(); + let mut names = BTreeSet::new(); + for descriptor in values { + let name = text(descriptor, "name", 128)?; + if !known.contains(&name) { + continue; + } + if !names.insert(name) { + return Err(invalid(format!("Duplicate {category} descriptor"))); + } + validate_value(descriptor, schema, category).map_err(invalid)?; + selected.push(descriptor.clone()); + } + if !selected.is_empty() { + retained.insert(category.to_owned(), Value::Array(selected)); + } + } + object.insert("protocols".to_owned(), Value::Object(retained)); + } + Ok(()) +} diff --git a/crates/odp-directory/tests/mixed.rs b/crates/odp-directory/tests/mixed.rs index 8de7b30..ab22b1b 100644 --- a/crates/odp-directory/tests/mixed.rs +++ b/crates/odp-directory/tests/mixed.rs @@ -52,6 +52,7 @@ fn setup() -> (DirectoryClient, Arc) { fn service() -> Value { json!({"service_id":"parent", "service_origin":"https://api.example.com", + "source":{"type":"odp","url":"https://api.example.com/.well-known/odp","x402_discovery":false}, "indexed_at":"2026-09-18T11:00:00Z", "name":"Data", "description":"Data services.", "language":"en", "localizations":["en"], "operations":[ {"name":"get-offering","authentication":"not-required"}, @@ -94,6 +95,373 @@ fn item(kind: &str) -> Value { item } +fn imported(kind: &str) -> Value { + let mut value = item(kind); + let service = value["service"].as_object_mut().unwrap(); + for key in [ + "description", + "language", + "localizations", + "keywords", + "operations", + "protocols", + ] { + service.remove(key); + } + service.insert("source".to_owned(), json!({ + "type":"openapi", "url":"https://docs.example/specs/api.json?version=3&key=a%2Fb", "x402_discovery":true + })); + value +} + +#[tokio::test] +async fn preserves_exact_sources_and_optional_metadata_without_odp_capabilities() { + let (client, stub) = setup(); + let mut first = imported("service"); + first["service"]["source"]["extra"] = json!({"retained":true}); + for field in [ + "operations", + "http", + "branding", + "mcp", + "odp_version", + "payment_origins", + "search_capabilities", + ] { + first["service"][field] = json!("not authoritative"); + } + let mut future = imported("collection"); + future["service"]["source"]["type"] = json!("future-format"); + future["service"]["source"]["url"] = json!("HTTPS://Docs.Example:443/other.json?x=1"); + future["service"]["service_id"] = json!("other-document"); + future["service"]["description"] = json!(""); + future["service"]["language"] = json!("en"); + future["service"]["localizations"] = json!(["en"]); + future["service"]["keywords"] = json!(["weather"]); + for field in [ + "documentation_url", + "status_url", + "support_url", + "website_url", + ] { + future["service"][field] = json!(format!("https://example.com/{field}")); + } + stub.ok(json!({"items":[first,imported("collection"),future]})); + let response = client + .continue_search("/v1/directory/search?cursor=opaque") + .await + .unwrap(); + assert!(response.issues.is_empty(), "{:?}", response.issues); + let DirectoryResult::Service(first) = &response.items[0] else { + panic!("service") + }; + assert_eq!(first.service.source.source_type, "openapi"); + assert_eq!( + first.service.source.url, + "https://docs.example/specs/api.json?version=3&key=a%2Fb" + ); + assert!(first.service.source.x402_discovery); + assert_eq!( + first.service.source.additional["extra"], + json!({"retained":true}) + ); + assert_eq!( + serde_json::to_value(&first.service.source).unwrap()["type"], + "openapi" + ); + assert!(first.service.description.is_none()); + assert!(first.service.language.is_none()); + assert!(first.service.operations.is_empty()); + assert!(first.service.localizations.is_empty()); + assert!(first.service.keywords.is_empty()); + assert!(first.service.protocols.is_none()); + assert!(first.service.additional.is_empty()); + let DirectoryResult::Collection(second) = &response.items[1] else { + panic!("collection") + }; + let DirectoryResult::Collection(third) = &response.items[2] else { + panic!("collection") + }; + assert_eq!(second.service.service_origin, third.service.service_origin); + assert_eq!(second.collection.id, third.collection.id); + assert_ne!(second.service.service_id, third.service.service_id); + assert_eq!(third.service.source.source_type, "future-format"); + assert_eq!( + third.service.source.url, + "HTTPS://Docs.Example:443/other.json?x=1" + ); + assert_eq!(third.service.description.as_deref(), Some("")); + assert_eq!(third.service.localizations, ["en"]); + assert_eq!(third.service.keywords, ["weather"]); + assert_eq!( + third.service.website_url.as_deref(), + Some("https://example.com/website_url") + ); + assert_eq!(stub.requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn isolates_invalid_sources_imported_metadata_and_native_records() { + let (client, stub) = setup(); + let mut candidates = Vec::new(); + for field in [ + "source", + "name", + "service_id", + "service_origin", + "indexed_at", + ] { + let mut value = imported("service"); + value["service"].as_object_mut().unwrap().remove(field); + candidates.push(value); + } + for field in ["type", "url", "x402_discovery"] { + for missing in [true, false] { + let mut value = imported("service"); + if missing { + value["service"]["source"] + .as_object_mut() + .unwrap() + .remove(field); + } else { + value["service"]["source"][field] = Value::Null; + } + candidates.push(value); + } + } + for (field, invalid) in [ + ("source", json!(null)), + ("source", json!([])), + ("name", json!(" ")), + ("indexed_at", json!("yesterday")), + ("localizations", json!([null])), + ("keywords", json!([12])), + ] { + let mut value = imported("service"); + value["service"][field] = invalid; + candidates.push(value); + } + for field in [ + "description", + "language", + "localizations", + "keywords", + "documentation_url", + "status_url", + "support_url", + "website_url", + ] { + for invalid in [Value::Null, json!(42)] { + let mut value = imported("service"); + value["service"][field] = invalid; + candidates.push(value); + } + } + for url in [ + "http://example.com/spec", + "/openapi.json", + "https:example.com/spec", + "https://user:secret@example.com/spec", + "https://example.com/spec#part", + "https://localhost/spec", + "https://127.0.0.1/spec", + "https://[::1]/spec", + "https://example.com:70000/spec", + "https://example.com/a\nb", + " ", + "https://[", + ] { + let mut value = imported("service"); + value["service"]["source"]["url"] = json!(url); + candidates.push(value); + } + let mut invalid = imported("service"); + invalid["service"]["source"]["x402_discovery"] = json!("false"); + candidates.push(invalid); + for field in ["operations", "language", "source"] { + let mut native = item("service"); + native["service"].as_object_mut().unwrap().remove(field); + candidates.push(native); + } + let count = candidates.len(); + candidates.push(imported("service")); + candidates.push(item("service")); + stub.ok(json!({"items":candidates})); + let response = client + .search(&ResourceSearchRequest::default()) + .await + .unwrap(); + assert_eq!(response.items.len(), 2, "{:?}", response.issues); + assert_eq!( + response + .issues + .iter() + .map(|issue| issue.index) + .collect::>(), + (0..count).collect::>() + ); + stub.ok(json!({"items":[imported("service")["service"],service()]})); + let native = client + .search_services(&SearchRequest::default()) + .await + .unwrap(); + assert_eq!(native.items.len(), 1); + assert_eq!(native.issues.len(), 1); +} + +#[tokio::test] +async fn validates_recognized_protocol_evidence_without_synthesizing_enrollment() { + let (client, stub) = setup(); + let valid = json!({"payments":[{"name":"x402","authentication":"required","options":["base"]},{"name":"future"}], "trust":[{"name":"tap"},{"name":"future"}]}); + let mut candidates = Vec::new(); + for protocols in [ + json!({}), + valid, + json!({"enrollment":[{"name":"aep"}]}), + json!({"payments":[{"name":"future"}]}), + ] { + let mut item = imported("service"); + item["service"]["protocols"] = protocols; + candidates.push(item); + } + let valid_count = candidates.len(); + for protocols in [ + Value::Null, + json!([]), + json!({"trust":[]}), + json!({"trust":null}), + json!({"trust":[null]}), + json!({"trust":[{}]}), + json!({"trust":[{"name":"tap"},{"name":"tap"}]}), + json!({"enrollment":[{"name":"aep","extra":true}]}), + json!({"payments":[{"name":"x402"}]}), + json!({"payments":[{"name":"x402","authentication":"optional"}]}), + json!({"payments":[{"name":"x402","authentication":"required","options":["unknown"]}]}), + ] { + let mut item = imported("service"); + item["service"]["protocols"] = protocols; + candidates.push(item); + } + let invalid_count = candidates.len() - valid_count; + stub.ok(json!({"items":candidates})); + let response = client + .search(&ResourceSearchRequest::default()) + .await + .unwrap(); + assert_eq!(response.items.len(), valid_count, "{:?}", response.issues); + assert_eq!(response.issues.len(), invalid_count); + let DirectoryResult::Service(item) = &response.items[1] else { + panic!("service") + }; + let protocols = item.service.protocols.as_ref().unwrap(); + assert!(protocols.enrollment.is_empty()); + assert_eq!(protocols.payments.len(), 1); + assert_eq!( + protocols.payments[0].options, + [odp_core::PaymentOption::Base] + ); + assert_eq!(protocols.trust.len(), 1); +} + +#[tokio::test] +async fn sends_source_filters_on_all_supported_routes_and_rejects_empty_or_duplicate_filters() { + let (client, stub) = setup(); + let filters = ServiceFilters { + sources: Some(vec![SourceType::Odp, SourceType::Openapi]), + keywords: vec!["weather".to_owned()], + ..Default::default() + }; + let original = filters.clone(); + stub.ok(json!({"items":[]})); + client + .search(&ResourceSearchRequest { + filters: Some(filters.clone()), + ..Default::default() + }) + .await + .unwrap(); + stub.ok(json!({"items":[]})); + client + .search_services(&SearchRequest { + filters: Some(filters.clone()), + ..Default::default() + }) + .await + .unwrap(); + stub.ok(json!({"items":["Weather"]})); + assert_eq!( + client + .suggest(&SuggestionRequest { + filters: Some(filters.clone()), + prefix: "we".to_owned(), + ..Default::default() + }) + .await + .unwrap(), + ["Weather"] + ); + assert_eq!(original, filters); + for sources in [ + vec![], + vec![SourceType::Odp, SourceType::Odp], + vec![SourceType::Odp, SourceType::Openapi, SourceType::Odp], + ] { + let filters = ServiceFilters { + sources: Some(sources), + ..Default::default() + }; + assert!( + client + .search(&ResourceSearchRequest { + filters: Some(filters.clone()), + ..Default::default() + }) + .await + .is_err() + ); + assert!( + client + .search_services(&SearchRequest { + filters: Some(filters.clone()), + ..Default::default() + }) + .await + .is_err() + ); + assert!( + client + .suggest(&SuggestionRequest { + filters: Some(filters), + prefix: "we".to_owned(), + ..Default::default() + }) + .await + .is_err() + ); + } + for invalid in [json!(["future"]), json!(["ODP"]), json!([null])] { + assert!(serde_json::from_value::(json!({"sources":invalid})).is_err()); + } + let requests = stub.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + for (request, path) in requests.iter().zip([ + "/v1/directory/search", + "/v1/services/search", + "/v1/directory/suggestions", + ]) { + assert_eq!(request.url, format!("https://sandbox.inflowpay.ai{path}")); + assert_eq!(request.method, "POST"); + assert_eq!( + serde_json::from_slice::(&request.body).unwrap()["filters"], + json!({"sources":["odp","openapi"],"keywords":["weather"]}) + ); + } + assert_eq!( + serde_json::to_value(ServiceFilters::default()).unwrap(), + json!({}) + ); +} + #[tokio::test] async fn decodes_mixed_results_and_preserves_unknown_types() { let (client, stub) = setup(); @@ -113,7 +481,7 @@ async fn decodes_mixed_results_and_preserves_unknown_types() { let DirectoryResult::Service(service) = &response.items[0] else { panic!("service") }; - assert_eq!(service.service.service_id(), Some("parent")); + assert_eq!(service.service.service_id, "parent"); assert_eq!( service.available_through.as_ref().unwrap().name.as_deref(), Some("Platform") @@ -185,10 +553,7 @@ async fn isolates_malformed_known_items_and_normalizes_future_operations() { panic!("collection") }; assert_eq!(item.service.operations.len(), 2); - assert_eq!( - item.service.additional.get("http"), - Some(&json!({"endpoint_base":"https://untrusted.example"})) - ); + assert!(!item.service.additional.contains_key("http")); } #[tokio::test] diff --git a/crates/odp-directory/tests/search_conformance.rs b/crates/odp-directory/tests/search_conformance.rs index afc2ac0..edc454f 100644 --- a/crates/odp-directory/tests/search_conformance.rs +++ b/crates/odp-directory/tests/search_conformance.rs @@ -149,6 +149,7 @@ async fn sends_a_request_it_accepts() { keywords: vec!["plants".to_owned(), "seeds".to_owned()], operations: vec![operation(Operation::GetOffering)], payments: vec![payment(vec![PaymentOption::Card])], + sources: None, trust: vec![TrustProtocol { name: Protocol::Tap, }], diff --git a/examples/README.md b/examples/README.md index 5683f54..a8bc168 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,7 @@ cargo run -p odp-examples --bin odp-directory-discovery -- sandbox weather Use `production` instead of `sandbox` for production. Omit `weather` to browse rather than search. This example requires a deployment with `/v1/directory/search`. It requests at most five results, prints Service and Collection names, reports malformed or unknown results, and retrieves full -Collection details only after inspecting the owning Service's advertised anonymous operation. +Collection details only for ODP sources after inspecting the owning Service's advertised +anonymous operation. Imported Collections print their exact document URL without ODP calls. It does not enroll, pay, or invoke Actions. Unlike `odp-agent-discovery`, it uses the real Directory, not a mock. The server's bounded result list is not an exhaustive catalog listing. diff --git a/examples/odp-directory-discovery/main.rs b/examples/odp-directory-discovery/main.rs index 97931c3..97d34ac 100644 --- a/examples/odp-directory-discovery/main.rs +++ b/examples/odp-directory-discovery/main.rs @@ -24,14 +24,18 @@ async fn main() -> Result<(), Box> { for result in response.items { match result { DirectoryResult::Service(item) => println!( - "Service: {} ({})", - item.service.name, item.service.service_origin + "Service: {} ({})\nDiscovery document: {}", + item.service.name, item.service.service_origin, item.service.source.url ), DirectoryResult::Collection(item) => { println!( "Collection: {} ({}, through {})", item.collection.name, item.collection.id, item.service.service_origin ); + if item.service.source.source_type != "odp" { + println!("Discovery document: {}", item.service.source.url); + continue; + } let client = ServiceClient::new(&item.service.service_origin)?; let inspection = client.inspect().await?; if inspection.document.operations.iter().any(|operation| { diff --git a/scripts/verify-consumer.sh b/scripts/verify-consumer.sh index 409da83..4181ab5 100755 --- a/scripts/verify-consumer.sh +++ b/scripts/verify-consumer.sh @@ -42,7 +42,7 @@ EOF cat > "$consumer/src/main.rs" <<'EOF' use odp_agent::ServiceClient; use odp_core::{Representation, ResourceIdentity, ResourceType}; -use odp_directory::{DirectoryClient, Environment, ResourceSearchRequest, ResultType}; +use odp_directory::{DirectoryClient, DirectoryIndexedService, DirectorySource, Environment, ResourceSearchRequest, ResultType, ServiceFilters, SourceType}; use odp_service::ServiceBuilder; fn main() { @@ -54,11 +54,16 @@ fn main() { ); let _ = DirectoryClient::new(Environment::Production); let _ = ResourceSearchRequest { + filters: Some(ServiceFilters { + sources: Some(vec![SourceType::Openapi]), + ..Default::default() + }), types: Some(vec![ResultType::Collection]), ..Default::default() }; let _ = ServiceBuilder::new("Example", "Example Service", "en", "/odp"); let _ = Representation::Terse; + let _: fn(&DirectoryIndexedService) -> &DirectorySource = |service| &service.source; } EOF