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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions crates/odp-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 65 additions & 6 deletions crates/odp-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
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.
Expand All @@ -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.
Expand All @@ -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)`.
Expand Down
14 changes: 13 additions & 1 deletion crates/odp-directory/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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())
})?;
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/odp-directory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod client;
mod models;
mod results;
mod sources;
mod transport;

pub use client::*;
Expand Down
48 changes: 46 additions & 2 deletions crates/odp-directory/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub struct ServiceFilters {
pub operations: Vec<OperationFilter>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payments: Vec<PaymentFilter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sources: Option<Vec<SourceType>>,
/// 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")]
Expand Down Expand Up @@ -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<String>,
pub documentation_url: Option<String>,
pub indexed_at: String,
#[serde(default)]
pub keywords: Vec<String>,
pub language: Option<String>,
#[serde(default)]
pub localizations: Vec<String>,
pub name: String,
#[serde(default)]
pub operations: Vec<OperationDescriptor>,
pub protocols: Option<ServiceProtocols>,
pub service_id: String,
pub service_origin: String,
pub source: DirectorySource,
pub status_url: Option<String>,
pub support_url: Option<String>,
pub website_url: Option<String>,
#[serde(flatten)]
pub additional: AdditionalMembers,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ResourceSearchRequest {
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -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<ServiceReference>,
#[serde(flatten)]
Expand All @@ -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)]
Expand Down
31 changes: 25 additions & 6 deletions crates/odp-directory/src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,17 @@ fn result(mut raw: Value) -> Result<DirectoryResult, DirectoryError> {
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",
Expand All @@ -79,8 +79,19 @@ fn result(mut raw: Value) -> Result<DirectoryResult, DirectoryError> {
"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<String, Value>) -> 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":"/"});
Expand All @@ -98,6 +109,10 @@ fn result(mut raw: Value) -> Result<DirectoryResult, DirectoryError> {
} else {
object.remove("protocols");
}
Ok(())
}

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)?;
Expand Down Expand Up @@ -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)
Expand All @@ -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())
}
Loading
Loading