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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ for candidate, err := range directoryClient.SearchServices(ctx, directory.Search
```

Use `Search` and `ContinueSearch` for mixed Service/Collection results, or `SearchServices` and
`ContinueSearchServices` for Service-only discovery. Each returns independent `Items` and
`ContinueSearchServices` for native ODP Service-only discovery. Mixed results include
`Service.Source.Type` and the exact `Service.Source.URL`: check the type before using an ODP Agent
client. OpenAPI Collections are Directory groups, not ODP operation targets. `ServiceFilters.Sources`
restricts both mixed search and suggestions to `SourceODP` or `SourceOpenAPI`; omit it for all sources.
Each search returns independent `Items` and
`Responses` iterators. `Suggest` returns matching target names; `SuggestServices` provides
Service-only keyword suggestions. Mixed search is capped at 100 results without continuation;
refine the query rather than assuming every match was returned.
Expand Down
53 changes: 46 additions & 7 deletions directory/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ODP directory package

Package `directory` searches indexed Services and submitted Collections. It does not crawl
catalogs or index Offerings. After discovery, an Agent inspects each result's live ODP document and
queries that Service's Collections and Offerings.
Package `directory` searches indexed Services and Collections from ODP and OpenAPI sources. It does
not crawl catalogs or index Offerings. Each mixed result identifies its exact discovery document.
The Agent package navigates ODP catalogs only; it does not execute OpenAPI operations.

The production origin is fixed at `https://api.inflowpay.ai`. Select `Sandbox` to use
the fixed `https://sandbox.inflowpay.ai` environment. Callers cannot configure another
Expand All @@ -26,7 +26,8 @@ for result, err := range search.Items {
}
switch result.Type {
case "service":
fmt.Printf("Service: %s (%s)\n", result.Service.Name, result.Service.ServiceOrigin)
fmt.Printf("Service: %s (%s), source: %s\n", result.Service.Name,
result.Service.Source.Type, result.Service.Source.URL)
case "collection":
fmt.Printf("Collection: %s, ID %s, through %s\n",
result.Collection.Name, result.Collection.ID, result.Service.ServiceOrigin)
Expand All @@ -39,8 +40,27 @@ for result, err := range search.Items {
Omit `Types` to select both types, or provide `Types: []string{"collection"}` or `[]string{"service"}`.
The list must be nonempty and distinct. Filters apply to the owning Service for either type.

A Collection is identified by its owning Service origin and case-sensitive `Collection.ID`.
Inspect that Service, then call the Agent client's `GetCollection` with the ID.
A result's `Service.ServiceID` identifies the indexed Service. Several source documents can share
an API origin, so the origin alone does not identify an imported Service. `Service.Source.URL`
preserves the exact document path and query; it can be hosted on a different origin from the API.

Check `Service.Source.Type` before choosing the next operation:

- `SourceODP`: inspect the Service's live ODP document. For a Collection, call the Agent client's
`GetCollection` with the case-sensitive `Collection.ID`.
- `SourceOpenAPI`: read `Service.Source.URL` with an OpenAPI-aware client. A Collection ID identifies
a Directory presentation group, not an ODP Collection endpoint. Its source is the parent's document.
- Any other value: display the metadata or report an unsupported source. Do not assume ODP.

`Source.X402Discovery` means that a supporting fixed-path x402 discovery document was detected.
It does not prove that an operation accepts payment or that the caller can execute it. Imported
description, language and localizations may be absent; their Go values are empty strings or nil
slices. Imported results do not populate ODP `Operations`. Native ODP results retain their required
metadata validation. `SearchServices` returns native ODP Services only.

Mixed search requires the Directory's source-aware response format. A missing or empty source URL
is reported as a record issue; the client does not infer a document URL from the API origin.

`Result.IndexedAt` reports Collection freshness; `Result.Service.IndexedAt` reports its parent's
freshness. A Service may have `AvailableThrough` platform attribution. A Collection's attribution
is its owning `Service`.
Expand All @@ -50,6 +70,25 @@ Unknown types retain the wire type in `Type` and complete JSON in `Raw`; their `
retain additive metadata in `Additional`. Nested Service parsing omits unverified execution
metadata such as endpoint paths, just as Service-only search does.

Filter mixed search or suggestions by source using the same `ServiceFilters`:

```go
filters := &directory.ServiceFilters{
Sources: []directory.SourceType{directory.SourceOpenAPI},
}
search := directoryClient.Search(ctx, directory.DirectorySearchRequest{
SearchRequest: directory.SearchRequest{Query: "weather", Filters: filters},
}, directory.IterationOptions{})
names, err := directoryClient.Suggest(ctx, directory.SuggestionRequest{
Prefix: "weather", Filters: filters,
})
```

Omit `Sources` for all sources. A supplied list must contain one or two distinct values from
`SourceODP` and `SourceOpenAPI`. Values within the list are alternatives; other filter categories
are combined with it. Collection filters use the owning Service's source. Source filters do not
make native `SearchServices` return OpenAPI entries.

The mixed endpoint returns at most 100 results (also its default limit), without continuation.
An absent `Next` does not promise that all matches were returned; refine the query or filters.
Collection search eligibility does not depend on permission to show a Directory landing card.
Expand Down Expand Up @@ -176,7 +215,7 @@ names, err := directoryClient.Suggest(ctx, directory.SuggestionRequest{
```

`Suggest` sends POST `/v1/directory/suggestions`. Its optional `Filters` use the same
structure as search, including AEP, keywords, ODP operations, payments and trust.
structure as search, including AEP, keywords, ODP operations, payments, sources and trust.
Collection filters apply to the owning Service; the output remains names only.

`SuggestServices` uses GET and retains keyword-prefix suggestions for Service-only discovery.
Expand Down
9 changes: 2 additions & 7 deletions directory/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,10 @@ func parseResult(data []byte) (Result, error) {
if kind != "service" && kind != "collection" {
return Result{Type: kind, Raw: append(json.RawMessage(nil), data...)}, nil
}
service, err := parseService(object["service"])
service, err := parseIndexedService(object["service"])
if err != nil {
return Result{}, err
}
serviceID, err := requiredText(service.Additional["service_id"], "service_id", 1, 128)
if err != nil {
return Result{}, err
}
delete(service.Additional, "service_id")
stamp, err := requiredText(object["indexed_at"], "indexed_at", 1, 64)
if err != nil {
return Result{}, err
Expand All @@ -125,7 +120,7 @@ func parseResult(data []byte) (Result, error) {
if err != nil {
return Result{}, errors.New("indexed_at must be a date-time")
}
result := Result{Type: kind, Service: &IndexedService{Service: service, ServiceID: serviceID}, IndexedAt: indexedAt}
result := Result{Type: kind, Service: &service, IndexedAt: indexedAt}
if kind == "service" {
result.Additional = cloneAdditional(object, "type", "service", "indexed_at", "available_through")
if raw, present := object["available_through"]; present {
Expand Down
2 changes: 2 additions & 0 deletions directory/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func mixedResult(kind string) map[string]any {
panic(err)
}
service["service_id"] = "ca0304cc-ab28-43e5-af94-7bdf11b40c6e"
service["source"] = map[string]any{"type": "odp", "url": "https://compute.example/.well-known/odp", "x402_discovery": false}
result := map[string]any{"type": kind, "service": service, "indexed_at": "2026-09-18T12:00:00Z"}
if kind == "collection" {
result["collection"] = map[string]any{"id": "Weather", "name": "Weather forecasts", "description": "Forecasts and conditions."}
Expand Down Expand Up @@ -96,6 +97,7 @@ func TestMixedSearchRejectsMalformedKnownEntries(t *testing.T) {
mutations := []func(map[string]any){
func(v map[string]any) { v["type"] = "" },
func(v map[string]any) { v["service"] = nil },
func(v map[string]any) { v["service"] = true },
func(v map[string]any) { delete(v["service"].(map[string]any), "service_id") },
func(v map[string]any) { v["indexed_at"] = "yesterday" },
func(v map[string]any) { delete(v, "indexed_at") },
Expand Down
158 changes: 158 additions & 0 deletions directory/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package directory

import (
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"

odp "github.com/offering-protocol/odp-go"
)

func parseIndexedService(data []byte) (IndexedService, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal(data, &object); err != nil {
return IndexedService{}, err
}
id, err := requiredText(object["service_id"], "service_id", 1, 128)
if err != nil {
return IndexedService{}, err
}
source, err := parseSource(object["source"])
if err != nil {
return IndexedService{}, err
}
var service Service
if source.Type == SourceODP {
service, err = parseService(data)
} else {
service, err = parseImportedService(data, object)
}
if err != nil {
return IndexedService{}, err
}
delete(service.Additional, "service_id")
delete(service.Additional, "source")
return IndexedService{Service: service, ServiceID: id, Source: source}, nil
}

func parseSource(data []byte) (Source, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal(data, &object); err != nil {
return Source{}, err
}
kind, err := requiredText(object["type"], "source.type", 1, 128)
if err != nil {
return Source{}, err
}
address, err := requiredText(object["url"], "source.url", 1, 2048)
if err != nil {
return Source{}, err
}
parsed, err := url.Parse(address)
if err != nil || parsed.Hostname() == "" || parsed.User != nil || strings.Contains(address, "#") || !publicHTTPSOrigin(address) {
return Source{}, errors.New("source.url must be a public HTTPS document URL without credentials or a fragment")
}
var discovery *bool
if err := json.Unmarshal(object["x402_discovery"], &discovery); err != nil || discovery == nil {
return Source{}, errors.New("source.x402_discovery must be a boolean")
}
return Source{
Additional: cloneAdditional(object, "type", "url", "x402_discovery"),
Type: SourceType(kind), URL: address, X402Discovery: *discovery,
}, nil
}

func parseImportedService(data []byte, object map[string]json.RawMessage) (Service, error) {
reference, err := parseServiceReference(data)
if err != nil {
return Service{}, err
}
name, err := requiredText(object["name"], "name", 1, 128)
if err != nil {
return Service{}, err
}
stamp, err := requiredText(object["indexed_at"], "indexed_at", 1, 64)
if err != nil {
return Service{}, err
}
indexedAt, err := time.Parse(time.RFC3339Nano, strings.ToUpper(stamp))
if err != nil {
return Service{}, errors.New("indexed_at must be a date-time")
}
service := Service{Name: name, ServiceOrigin: reference.ServiceOrigin, IndexedAt: indexedAt}
for field, destination := range map[string]any{
"description": &service.Description, "documentation_url": &service.DocumentationURL,
"keywords": &service.Keywords, "language": &service.Language, "localizations": &service.Localizations,
"status_url": &service.StatusURL, "support_url": &service.SupportURL, "website_url": &service.WebsiteURL,
} {
if raw, present := object[field]; present {
if string(raw) == "null" || json.Unmarshal(raw, destination) != nil {
return Service{}, fmt.Errorf("%s is invalid", field)
}
}
}
if raw, present := object["protocols"]; present {
service.Protocols, err = parseImportedProtocols(raw)
if err != nil {
return Service{}, err
}
}
known := append([]string{
"service_id", "source", "service_origin", "name", "description", "documentation_url", "language", "localizations",
"keywords", "operations", "protocols", "indexed_at", "status_url", "support_url", "website_url",
}, unverifiedMembers...)
service.Additional = cloneAdditional(object, known...)
return service, nil
}

func parseImportedProtocols(data []byte) (*odp.ServiceProtocols, error) {
var object map[string]json.RawMessage
if json.Unmarshal(data, &object) != nil || object == nil {
return nil, errors.New("protocols must be an object")
}
enrollment, err := importedDescriptors(object["enrollment"], []odp.Protocol{odp.ProtocolAEP}, parseEnrollment)
if err != nil {
return nil, err
}
payments, err := importedDescriptors(object["payments"], []odp.Protocol{odp.ProtocolMPP, odp.ProtocolX402}, parsePayment)
if err != nil {
return nil, err
}
trust, err := importedDescriptors(object["trust"], []odp.Protocol{odp.ProtocolTAP}, parseTrust)
if err != nil {
return nil, err
}
return &odp.ServiceProtocols{Enrollment: enrollment, Payments: payments, Trust: trust}, nil
}

func importedDescriptors[T any](data []byte, known []odp.Protocol, parse func(json.RawMessage) (T, error)) ([]T, error) {
if data == nil {
return nil, nil
}
var descriptors []json.RawMessage
if json.Unmarshal(data, &descriptors) != nil || descriptors == nil {
return nil, errors.New("protocol descriptors must be an array")
}
var result []T
for _, raw := range descriptors {
var descriptor struct {
Name odp.Protocol `json:"name"`
}
if json.Unmarshal(raw, &descriptor) != nil || descriptor.Name == "" {
return nil, errors.New("protocol descriptor must have a name")
}
if !slices.Contains(known, descriptor.Name) {
continue
}
value, err := parse(raw)
if err != nil {
return nil, err
}
result = append(result, value)
}
return result, nil
}
Loading
Loading