diff --git a/directory/README.md b/directory/README.md index 4858054..e5ea281 100644 --- a/directory/README.md +++ b/directory/README.md @@ -62,7 +62,9 @@ Mixed search requires the Directory's source-aware response format. A missing or 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 +freshness. A Service may have `Publisher` attribution with `PublisherID`, `Name`, and `WebsiteURL`. +The website is a display link, not a discovery or execution target. Additional response members +are retained; publisher attribution may be absent or null. A Collection's attribution is its owning `Service`. Unknown types retain the wire type in `Type` and complete JSON in `Raw`; their `Service` and diff --git a/directory/search.go b/directory/search.go index 5a91932..b48959b 100644 --- a/directory/search.go +++ b/directory/search.go @@ -7,6 +7,7 @@ import ( "fmt" "iter" "net/http" + "net/url" "strings" "time" "unicode/utf8" @@ -122,13 +123,13 @@ func parseResult(data []byte) (Result, error) { } 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 { - reference, err := parseServiceReference(raw) + result.Additional = cloneAdditional(object, "type", "service", "indexed_at", "publisher") + if raw, present := object["publisher"]; present && string(raw) != "null" { + reference, err := parsePublisher(raw) if err != nil { return Result{}, err } - result.AvailableThrough = &reference + result.Publisher = &reference } return result, nil } @@ -155,29 +156,53 @@ func parseResult(data []byte) (Result, error) { return result, nil } -func parseServiceReference(data []byte) (ServiceReference, error) { +func parsePublisher(data []byte) (Publisher, error) { var object map[string]json.RawMessage if err := json.Unmarshal(data, &object); err != nil { - return ServiceReference{}, err + return Publisher{}, err + } + id, err := requiredText(object["publisher_id"], "publisher_id", 1, 128) + if err != nil { + return Publisher{}, err + } + name, err := requiredText(object["name"], "name", 1, 128) + if err != nil { + return Publisher{}, err + } + website, err := requiredText(object["website_url"], "website_url", 1, 512) + if err != nil { + return Publisher{}, err + } + parsed, err := url.Parse(website) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return Publisher{}, errors.New("Publisher website must be an HTTPS URL without credentials") + } + return Publisher{PublisherID: id, Name: name, WebsiteURL: website, Additional: cloneAdditional(object, "publisher_id", "name", "website_url")}, nil +} + +func parseServiceReference(data []byte) (serviceReference, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return serviceReference{}, err } id, err := requiredText(object["service_id"], "service_id", 1, 128) if err != nil { - return ServiceReference{}, err + return serviceReference{}, err } origin, err := requiredText(object["service_origin"], "service_origin", 1, 2048) if err != nil { - return ServiceReference{}, err + return serviceReference{}, err } canonical, err := odp.DeriveServiceOrigin(origin) if err != nil || canonical != origin || !publicHTTPSOrigin(origin) { - return ServiceReference{}, errors.New("Attribution origin must be a canonical public HTTPS origin") + return serviceReference{}, errors.New("Service origin must be a canonical public HTTPS origin") } var name string if raw, present := object["name"]; present { name, err = requiredText(raw, "name", 1, 128) if err != nil { - return ServiceReference{}, err + return serviceReference{}, err } } - return ServiceReference{ServiceID: id, ServiceOrigin: origin, Name: name, Additional: cloneAdditional(object, "service_id", "service_origin", "name")}, nil + return serviceReference{ServiceID: id, ServiceOrigin: origin, Name: name, Additional: cloneAdditional(object, "service_id", "service_origin", "name")}, nil } diff --git a/directory/search_test.go b/directory/search_test.go index 3b1d537..2b7adea 100644 --- a/directory/search_test.go +++ b/directory/search_test.go @@ -37,7 +37,7 @@ func mixedBody(t *testing.T, items ...any) string { func TestMixedSearchSequence(t *testing.T) { service := mixedResult("service") - service["available_through"] = map[string]any{"service_id": "platform", "service_origin": "https://platform.example", "name": "Platform", "extra": true} + service["publisher"] = map[string]any{"publisher_id": "platform", "website_url": "https://platform.example/catalog", "name": "Platform", "extra": true} service["extra"] = "retained" collection := mixedResult("collection") collection["collection"].(map[string]any)["extra"] = true @@ -71,7 +71,7 @@ func TestMixedSearchSequence(t *testing.T) { if err != nil || len(result.Items) != 3 || len(result.Issues) != 1 || result.Issues[0].Index != 3 || result.Issues[0].Scope != directory.IssueResult { t.Fatalf("result = %#v, %v", result, err) } - if result.Items[0].AvailableThrough.Name != "Platform" || string(result.Items[0].Additional["extra"]) != `"retained"` || string(result.Items[0].AvailableThrough.Additional["extra"]) != "true" { + if result.Items[0].Publisher.Name != "Platform" || string(result.Items[0].Additional["extra"]) != `"retained"` || string(result.Items[0].Publisher.Additional["extra"]) != "true" { t.Fatalf("Service attribution or additional fields = %#v", result.Items[0]) } if result.Items[1].Collection.ID != "Weather" || result.Items[1].Service.ServiceID == "" || result.Items[1].IndexedAt.Equal(result.Items[1].Service.IndexedAt) || string(result.Items[1].Collection.Additional["extra"]) != "true" { @@ -121,9 +121,9 @@ func TestMixedSearchRejectsMalformedKnownEntries(t *testing.T) { } } } - for _, reference := range []any{nil, false, map[string]any{}, map[string]any{"service_id": "x"}, map[string]any{"service_id": "x", "service_origin": "http://localhost"}, map[string]any{"service_id": "x", "service_origin": "https://platform.example", "name": nil}} { + for _, reference := range []any{false, map[string]any{}, map[string]any{"publisher_id": "x"}, map[string]any{"publisher_id": "x", "name": "Platform"}, map[string]any{"publisher_id": "x", "name": "Platform", "website_url": "http://localhost"}, map[string]any{"publisher_id": "x", "name": "Platform", "website_url": "https://user:secret@platform.example"}, map[string]any{"publisher_id": "x", "name": "Platform", "website_url": "://invalid"}} { item := mixedResult("service") - item["available_through"] = reference + item["publisher"] = reference value := client(t, func(*http.Request) (*http.Response, error) { return response(200, mixedBody(t, item), nil), nil }, directory.Production) @@ -144,7 +144,9 @@ func TestMixedSearchOptionalFields(t *testing.T) { item["collection"].(map[string]any)["description"] = description } service := mixedResult("service") - service["available_through"] = map[string]any{"service_id": "x", "service_origin": "https://platform.example"} + service["publisher"] = nil + service["available_through"] = map[string]any{"service_id": "legacy", "service_origin": "https://legacy.example"} + service["future_metadata"] = map[string]any{"arbitrary": true} value := client(t, func(*http.Request) (*http.Response, error) { return response(200, mixedBody(t, item, service), nil), nil }, directory.Production) diff --git a/directory/types.go b/directory/types.go index 1049707..2b8d190 100644 --- a/directory/types.go +++ b/directory/types.go @@ -82,13 +82,20 @@ type Source struct { X402Discovery bool } -type ServiceReference struct { +type serviceReference struct { Additional odp.AdditionalMembers ServiceID string ServiceOrigin string Name string } +type Publisher struct { + Additional odp.AdditionalMembers + PublisherID string + Name string + WebsiteURL string +} + type CollectionSummary struct { Additional odp.AdditionalMembers ID string @@ -98,13 +105,13 @@ type CollectionSummary struct { // Result retains unknown resource types in Raw without interpreting them as Services. type Result struct { - Additional odp.AdditionalMembers - Type string - Service *IndexedService - Collection *CollectionSummary - AvailableThrough *ServiceReference - IndexedAt time.Time - Raw json.RawMessage + Additional odp.AdditionalMembers + Type string + Service *IndexedService + Collection *CollectionSummary + Publisher *Publisher + IndexedAt time.Time + Raw json.RawMessage } type IterationOptions struct {