diff --git a/README.md b/README.md index 28d6d44..e30cc80 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/directory/README.md b/directory/README.md index 383fe2d..4858054 100644 --- a/directory/README.md +++ b/directory/README.md @@ -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 @@ -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) @@ -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`. @@ -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. @@ -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. diff --git a/directory/search.go b/directory/search.go index 968a272..5a91932 100644 --- a/directory/search.go +++ b/directory/search.go @@ -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 @@ -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 { diff --git a/directory/search_test.go b/directory/search_test.go index 05a05c1..3b1d537 100644 --- a/directory/search_test.go +++ b/directory/search_test.go @@ -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."} @@ -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") }, diff --git a/directory/source.go b/directory/source.go new file mode 100644 index 0000000..73b3f0a --- /dev/null +++ b/directory/source.go @@ -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 +} diff --git a/directory/source_test.go b/directory/source_test.go new file mode 100644 index 0000000..24aeabe --- /dev/null +++ b/directory/source_test.go @@ -0,0 +1,218 @@ +package directory_test + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + odp "github.com/offering-protocol/odp-go" + "github.com/offering-protocol/odp-go/directory" +) + +func importedResult(kind, sourceType string) map[string]any { + result := mixedResult(kind) + result["service"] = map[string]any{ + "service_id": "openapi-service", "service_origin": "https://api.example.com", + "name": "Imported API", "indexed_at": "2026-09-22T12:00:00Z", + "source": map[string]any{"type": sourceType, "url": "https://docs.example.com/v1/openapi.json?revision=2", "x402_discovery": true, "extra": 1}, + } + return result +} + +func TestSourceAwareSearchAndContinuation(t *testing.T) { + for _, sourceType := range []string{"openapi", "future"} { + for _, continuation := range []bool{false, true} { + t.Run(sourceType+map[bool]string{false: "/search", true: "/continue"}[continuation], func(t *testing.T) { + item := importedResult("collection", sourceType) + service := item["service"].(map[string]any) + service["operations"] = []any{map[string]any{"name": "get-collection", "authentication": "not-required"}} + service["http"] = map[string]any{"endpoint_base": "/fabricated"} + service["extra"] = "retained" + value := client(t, func(request *http.Request) (*http.Response, error) { + if continuation && request.Method != http.MethodGet { + t.Fatalf("method = %s", request.Method) + } + return response(200, mixedBody(t, item, mixedResult("service")), nil), nil + }, directory.Production) + search := value.Search(t.Context(), directory.DirectorySearchRequest{}, directory.IterationOptions{}) + if continuation { + search = value.ContinueSearch(t.Context(), "/v1/directory/search?cursor=x", directory.IterationOptions{}) + } + for page, err := range search.Responses { + if err != nil || len(page.Items) != 2 || len(page.Issues) != 0 { + t.Fatalf("page=%#v err=%v", page, err) + } + got := page.Items[0].Service + if got.Source.Type != directory.SourceType(sourceType) || got.Source.URL != "https://docs.example.com/v1/openapi.json?revision=2" || !got.Source.X402Discovery || string(got.Source.Additional["extra"]) != "1" { + t.Fatalf("source=%#v", got.Source) + } + if got.Description != "" || got.Language != "" || got.Localizations != nil || got.Operations != nil || got.Protocols != nil || got.Additional["http"] != nil || got.Additional["source"] != nil || got.Additional["service_id"] != nil || string(got.Additional["extra"]) != `"retained"` { + t.Fatalf("metadata=%#v", got) + } + if page.Items[0].Collection.ID != "Weather" || page.Items[1].Service.Source.Type != directory.SourceODP || page.Items[1].Service.Source.X402Discovery { + t.Fatalf("results=%#v", page.Items) + } + } + }) + } + } +} + +func TestImportedMetadataAndProtocols(t *testing.T) { + item := importedResult("service", "openapi") + service := item["service"].(map[string]any) + for key, value := range map[string]any{ + "description": "API description", "documentation_url": "https://docs.example.com/", "keywords": []string{"weather"}, + "language": "en", "localizations": []string{"en"}, "status_url": "https://status.example.com/", + "support_url": "https://example.com/support/", "website_url": "https://example.com/", + "protocols": map[string]any{ + "enrollment": []any{map[string]any{"name": "future"}, map[string]any{"name": "aep"}}, + "payments": []any{map[string]any{"name": "x402", "authentication": "not-required", "options": []string{"solana"}}}, + "trust": []any{map[string]any{"name": "tap"}}, + }, + } { + service[key] = value + } + value := client(t, func(*http.Request) (*http.Response, error) { return response(200, mixedBody(t, item), nil), nil }, directory.Production) + for page, err := range value.Search(t.Context(), directory.DirectorySearchRequest{}, directory.IterationOptions{}).Responses { + if err != nil || len(page.Items) != 1 || len(page.Issues) != 0 { + t.Fatalf("page=%#v err=%v", page, err) + } + got := page.Items[0].Service + if got.Description != "API description" || got.DocumentationURL != "https://docs.example.com/" || got.Language != "en" || len(got.Localizations) != 1 || len(got.Keywords) != 1 || got.StatusURL == "" || got.SupportURL == "" || got.WebsiteURL == "" || len(got.Protocols.Enrollment) != 1 || got.Protocols.Payments[0].Name != odp.ProtocolX402 || got.Protocols.Trust[0].Name != odp.ProtocolTAP { + t.Fatalf("metadata=%#v", got) + } + } +} + +func TestSourceRecordFailuresAreIsolated(t *testing.T) { + mutations := map[string]func(map[string]any){ + "missing source": func(v map[string]any) { delete(v, "source") }, + "null source": func(v map[string]any) { v["source"] = nil }, + "invalid source": func(v map[string]any) { v["source"] = true }, + "missing id": func(v map[string]any) { delete(v, "service_id") }, + "missing name": func(v map[string]any) { delete(v, "name") }, + "null name": func(v map[string]any) { v["name"] = nil }, + "missing timestamp": func(v map[string]any) { delete(v, "indexed_at") }, + "bad timestamp": func(v map[string]any) { v["indexed_at"] = "yesterday" }, + "invalid origin": func(v map[string]any) { v["service_origin"] = "http://localhost" }, + "native missing metadata": func(v map[string]any) { v["source"].(map[string]any)["type"] = "odp" }, + } + for _, field := range []string{"description", "documentation_url", "keywords", "language", "localizations", "status_url", "support_url", "website_url"} { + mutations[field+" wrong type"] = func(v map[string]any) { v[field] = 1 } + mutations[field+" null"] = func(v map[string]any) { v[field] = nil } + } + for field, invalid := range map[string][]any{ + "type": {nil, "", 1}, "url": {nil, "", "relative.json", "http://example.com/openapi.json", "https://localhost/openapi.json", "https://127.0.0.1/openapi.json", "https://10.0.0.1/openapi.json", "https://user:password@example.com/openapi.json", "https://example.com/openapi.json#tag", "https://example.com/openapi.json#", "https://example.com/%bad%"}, + "x402_discovery": {nil, "true", 1}, + } { + for index, bad := range invalid { + key, _ := json.Marshal([]any{field, index}) + mutations[string(key)] = func(v map[string]any) { v["source"].(map[string]any)[field] = bad } + } + mutations["missing "+field] = func(v map[string]any) { delete(v["source"].(map[string]any), field) } + } + for _, category := range []string{"enrollment", "payments", "trust"} { + for index, bad := range []any{nil, true, []any{nil}, []any{map[string]any{}}, []any{map[string]any{"name": 1}}, []any{map[string]any{"name": map[string]string{"enrollment": "aep", "payments": "x402", "trust": "tap"}[category], "extra": true}}} { + key, _ := json.Marshal([]any{category, index}) + mutations[string(key)] = func(v map[string]any) { v["protocols"] = map[string]any{category: bad} } + } + } + mutations["protocols null"] = func(v map[string]any) { v["protocols"] = nil } + mutations["protocols invalid"] = func(v map[string]any) { v["protocols"] = true } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + item := importedResult("service", "openapi") + mutate(item["service"].(map[string]any)) + value := client(t, func(*http.Request) (*http.Response, error) { + return response(200, mixedBody(t, item, mixedResult("service")), nil), nil + }, directory.Production) + for page, err := range value.Search(t.Context(), directory.DirectorySearchRequest{}, directory.IterationOptions{}).Responses { + if err != nil || len(page.Items) != 1 || len(page.Issues) != 1 || page.Issues[0].Index != 0 { + t.Fatalf("page=%#v err=%v", page, err) + } + } + }) + } +} + +func TestSourceFiltersSearchAndSuggest(t *testing.T) { + for _, sources := range [][]directory.SourceType{nil, {directory.SourceODP}, {directory.SourceOpenAPI}, {directory.SourceODP, directory.SourceOpenAPI}} { + value := client(t, func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + var payload struct { + Filters directory.ServiceFilters `json:"filters"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(sources) + got, _ := json.Marshal(payload.Filters.Sources) + if string(encoded) != string(got) || len(payload.Filters.Payments) != 1 { + t.Fatalf("body=%s", body) + } + if strings.HasSuffix(request.URL.Path, "/suggestions") { + return response(200, `{"items":["Weather"]}`, nil), nil + } + return response(200, `{"items":[]}`, nil), nil + }, directory.Production) + filters := &directory.ServiceFilters{Sources: sources, Payments: []directory.PaymentFilter{{Name: odp.ProtocolX402}}} + for _, err := range value.Search(t.Context(), directory.DirectorySearchRequest{SearchRequest: directory.SearchRequest{Filters: filters}}, directory.IterationOptions{}).Responses { + if err != nil { + t.Fatal(err) + } + } + for _, err := range value.SearchServices(t.Context(), directory.SearchRequest{Filters: filters}, directory.IterationOptions{}).Responses { + if err != nil { + t.Fatal(err) + } + } + names, err := value.Suggest(t.Context(), directory.SuggestionRequest{Prefix: "we", Filters: filters}) + if err != nil || len(names) != 1 || names[0] != "Weather" { + t.Fatalf("names=%v err=%v", names, err) + } + } +} + +func TestInvalidSourceFiltersDoNotSendRequests(t *testing.T) { + value := client(t, func(*http.Request) (*http.Response, error) { t.Fatal("invalid filters sent"); return nil, nil }, directory.Production) + for _, sources := range [][]directory.SourceType{{}, {"future"}, {"ODP"}, {""}, {directory.SourceODP, directory.SourceODP}, {directory.SourceODP, directory.SourceOpenAPI, "future"}} { + filters := &directory.ServiceFilters{Sources: sources} + for _, err := range value.Search(t.Context(), directory.DirectorySearchRequest{SearchRequest: directory.SearchRequest{Filters: filters}}, directory.IterationOptions{}).Responses { + if err == nil { + t.Fatal("accepted invalid filters") + } + } + for _, err := range value.SearchServices(t.Context(), directory.SearchRequest{Filters: filters}, directory.IterationOptions{}).Responses { + if err == nil { + t.Fatal("accepted invalid filters") + } + } + if _, err := value.Suggest(t.Context(), directory.SuggestionRequest{Prefix: "we", Filters: filters}); err == nil { + t.Fatal("accepted invalid filters") + } + } +} + +func TestSourceFiltersCapturedBeforeIteration(t *testing.T) { + value := client(t, func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil || string(body) != `{"filters":{"sources":["openapi"]}}` { + t.Fatalf("body=%s err=%v", body, err) + } + return response(200, `{"items":[]}`, nil), nil + }, directory.Production) + sources := []directory.SourceType{directory.SourceOpenAPI} + search := value.Search(t.Context(), directory.DirectorySearchRequest{SearchRequest: directory.SearchRequest{Filters: &directory.ServiceFilters{Sources: sources}}}, directory.IterationOptions{}) + sources[0] = directory.SourceODP + for _, err := range search.Responses { + if err != nil { + t.Fatal(err) + } + } +} diff --git a/directory/types.go b/directory/types.go index 606f983..1049707 100644 --- a/directory/types.go +++ b/directory/types.go @@ -33,6 +33,7 @@ type ServiceFilters struct { Keywords []string `json:"keywords,omitempty"` Operations []OperationFilter `json:"operations,omitempty"` Payments []PaymentFilter `json:"payments,omitempty"` + Sources []SourceType `json:"sources,omitempty"` Trust []odp.TrustProtocol `json:"trust,omitempty"` } @@ -58,9 +59,27 @@ type DirectorySearchRequest struct { Types []string `json:"types,omitempty"` } +// IndexedService carries Directory metadata. Check Source.Type before using an ODP Agent client; +// imported Services may omit description, language, localizations and operations. type IndexedService struct { Service ServiceID string + Source Source +} + +type SourceType string + +const ( + SourceODP SourceType = "odp" + SourceOpenAPI SourceType = "openapi" +) + +// Source identifies the discovery document, not an endpoint to invoke. +type Source struct { + Additional odp.AdditionalMembers + Type SourceType + URL string + X402Discovery bool } type ServiceReference struct { diff --git a/directory/validation.go b/directory/validation.go index a065e68..27fa3ef 100644 --- a/directory/validation.go +++ b/directory/validation.go @@ -68,6 +68,16 @@ func normalizedSearchRequest(request SearchRequest) (SearchRequest, error) { } func validateFilters(filters ServiceFilters) (ServiceFilters, error) { + if filters.Sources != nil { + if len(filters.Sources) == 0 || len(filters.Sources) > 2 || !unique(filters.Sources) { + return ServiceFilters{}, errors.New("sources must contain distinct odp or openapi values") + } + for _, source := range filters.Sources { + if source != SourceODP && source != SourceOpenAPI { + return ServiceFilters{}, errors.New("sources must contain distinct odp or openapi values") + } + } + } keywords, err := uniqueText(filters.Keywords, "keywords", 32, 64) if err != nil { return ServiceFilters{}, err @@ -88,7 +98,7 @@ func validateFilters(filters ServiceFilters) (ServiceFilters, error) { if err != nil { return ServiceFilters{}, err } - return ServiceFilters{Enrollment: enrollment, Keywords: keywords, Operations: operationFilters, Payments: paymentFilters, Trust: trust}, nil + return ServiceFilters{Enrollment: enrollment, Keywords: keywords, Operations: operationFilters, Payments: paymentFilters, Sources: filters.Sources, Trust: trust}, nil } func parseSearchPage(data []byte) (SearchResponse[Service], error) { diff --git a/examples/odp-agent-discovery/main.go b/examples/odp-agent-discovery/main.go index 03a8e1f..2cf5e0c 100644 --- a/examples/odp-agent-discovery/main.go +++ b/examples/odp-agent-discovery/main.go @@ -54,6 +54,10 @@ func main() { case "service": fmt.Printf("Directory Service: %s\n", result.Service.Name) case "collection": + if result.Service.Source.Type != directory.SourceODP { + fmt.Printf("Directory group: %s, source: %s\n", result.Collection.Name, result.Service.Source.URL) + continue + } full, err := mock.serviceClients[result.Service.ServiceOrigin].GetCollection(ctx, result.Collection.ID, "full") if err != nil { log.Fatal(err) diff --git a/examples/odp-agent-discovery/mock_directory.go b/examples/odp-agent-discovery/mock_directory.go index ef8637b..37d1d9e 100644 --- a/examples/odp-agent-discovery/mock_directory.go +++ b/examples/odp-agent-discovery/mock_directory.go @@ -22,6 +22,7 @@ type mockDirectory struct { } type directoryServiceJSON struct { + Source *directorySourceJSON `json:"source,omitempty"` ServiceID string `json:"service_id"` Description string `json:"description"` IndexedAt string `json:"indexed_at"` @@ -34,6 +35,12 @@ type directoryServiceJSON struct { ServiceOrigin string `json:"service_origin"` } +type directorySourceJSON struct { + Type string `json:"type"` + URL string `json:"url"` + X402Discovery bool `json:"x402_discovery"` +} + func createMockDirectory(ctx context.Context, candidates []string) (*mockDirectory, error) { serviceClients := make(map[string]*agent.ServiceClient) services := make([]directory.Service, 0, len(candidates)) @@ -64,6 +71,7 @@ func createMockDirectory(ctx context.Context, candidates []string) (*mockDirecto Operations: document.Operations, Protocols: document.Protocols, ServiceOrigin: origin, } wireServices = append(wireServices, wireService) + wireService.Source = &directorySourceJSON{Type: "odp", URL: origin + "/.well-known/odp"} wireResults = append(wireResults, map[string]any{"type": "service", "service": wireService, "indexed_at": wireService.IndexedAt}) anonymous := map[odp.Operation]bool{} for _, operation := range document.Operations { diff --git a/testdata/consumer/main.go b/testdata/consumer/main.go index a411f68..011af46 100644 --- a/testdata/consumer/main.go +++ b/testdata/consumer/main.go @@ -13,7 +13,9 @@ import ( func main() { _, _ = agent.NewServiceClient(agent.ServiceClientOptions{ServiceURL: "https://service.example"}) directoryClient, _ := directory.New(directory.Options{}) - var mixed directory.SearchSequence[directory.Result] = directoryClient.Search(context.Background(), directory.DirectorySearchRequest{}, directory.IterationOptions{}) + var mixed directory.SearchSequence[directory.Result] = directoryClient.Search(context.Background(), directory.DirectorySearchRequest{ + SearchRequest: directory.SearchRequest{Filters: &directory.ServiceFilters{Sources: []directory.SourceType{directory.SourceOpenAPI}}}, + }, directory.IterationOptions{}) var services directory.SearchSequence[directory.Service] = directoryClient.SearchServices(context.Background(), directory.SearchRequest{}, directory.IterationOptions{}) if mixed.Items == nil || mixed.Responses == nil || services.Items == nil || services.Responses == nil { panic("missing search iterator")