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
4 changes: 3 additions & 1 deletion directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 36 additions & 11 deletions directory/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"iter"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
12 changes: 7 additions & 5 deletions directory/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
23 changes: 15 additions & 8 deletions directory/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading