From 857e213175ab9324dc33ed86319344ab6fd41932 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Fri, 18 Sep 2026 18:05:21 -0700 Subject: [PATCH] feat(directory): add mixed service and collection discovery --- .changeset/filtered-directory-suggestions.md | 5 + .changeset/mixed-directory-discovery.md | 5 + README.md | 16 +- examples/odp-agent-discovery/README.md | 17 +- examples/odp-agent-discovery/src/index.ts | 18 +- .../odp-agent-discovery/src/mock-directory.ts | 61 +++- packages/agent/README.md | 7 + packages/directory/README.md | 85 +++++- packages/directory/src/index.ts | 256 +++++++++++++--- .../directory/test/unit/mixed-search.test.ts | 289 ++++++++++++++++++ scripts/smoke-examples.mjs | 3 +- 11 files changed, 699 insertions(+), 63 deletions(-) create mode 100644 .changeset/filtered-directory-suggestions.md create mode 100644 .changeset/mixed-directory-discovery.md create mode 100644 packages/directory/test/unit/mixed-search.test.ts diff --git a/.changeset/filtered-directory-suggestions.md b/.changeset/filtered-directory-suggestions.md new file mode 100644 index 0000000..6292745 --- /dev/null +++ b/.changeset/filtered-directory-suggestions.md @@ -0,0 +1,5 @@ +--- +"@offering-protocol/directory": patch +--- + +Support filtered Directory suggestions through POST while retaining Service-only keyword suggestions. diff --git a/.changeset/mixed-directory-discovery.md b/.changeset/mixed-directory-discovery.md new file mode 100644 index 0000000..7e7134f --- /dev/null +++ b/.changeset/mixed-directory-discovery.md @@ -0,0 +1,5 @@ +--- +"@offering-protocol/directory": patch +--- + +Add mixed Service and Collection Directory search, continuation support, and name suggestions. Preserve Service-only discovery methods and expose unknown future result types without discarding their data. diff --git a/README.md b/README.md index 7f1ab75..90bf9e8 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,15 @@ Official TypeScript software development kits for the Services and navigating their Offerings. ODP separates Service discovery from catalog discovery. An Agent searches the canonical directory -for candidate Services, inspects each Service's live ODP document, and then navigates or searches +for candidate Services or indexed Collections, inspects the owning Service's live ODP document, and then navigates or searches that Service's Collections and Offerings. Full Offering details can describe structured attributes, price previews, and executable Actions without forcing every industry into one product schema. ```text Agent Canonical Directory Service │ │ │ - ├── Search Services ─────────────────▶│ │ - │◀── Cached Service metadata ─────────┤ │ + ├── Search Directory ────────────────▶│ │ + │◀── Service / Collection metadata ───┤ │ │ │ │ ├── Inspect /.well-known/odp ──────────────────────────────────────────────▶│ │◀── Operations and protocol capabilities ──────────────────────────────────┤ @@ -40,7 +40,7 @@ Choose the role you are implementing: | ------------------------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | An Agent, command-line tool, or automation | [`@offering-protocol/agent`](./packages/agent/README.md) | Directory-to-Service search, catalog navigation, enrichment, and Action discovery | | A Service with an ODP catalog | [`@offering-protocol/service`](./packages/service/README.md) | Service document, fixed routes, static catalogs, and storage-backed handlers | -| A canonical-directory integration | [`@offering-protocol/directory`](./packages/directory/README.md) | Production or sandbox Service search with bounded lazy pagination | +| A canonical-directory integration | [`@offering-protocol/directory`](./packages/directory/README.md) | Mixed Service/Collection search, suggestions, and Service-only discovery | | An ODP implementation or validation tool | [`@offering-protocol/core`](./packages/core/README.md) | Protocol models, bundled schemas, validation, identity, references, and pagination | All packages are ESM-first, support Node.js 22 or newer, and publish under the @@ -63,8 +63,14 @@ Use `npm install` or `yarn add` if those are the package managers in your applic ## Agent Workflow +For general Directory discovery, use [`directory.search()`](./packages/directory/README.md#search-the-directory) +to receive typed Service and Collection results, and `directory.suggest()` to obtain matching names. +The Directory indexes submitted Collections, not every Offering in a Service's catalog. Its mixed +search returns at most 100 results and currently has no continuation; refine queries to narrow results. + `createOdpAgent` searches the canonical directory and then searches the live catalogs of matching -Services. Directory results never pretend to contain complete Service catalogs. +Services. This orchestration uses `searchServices()`, the Service-only API, and does not interpret +mixed results as Services. Directory results never pretend to contain complete Service catalogs. ```ts import { createOdpAgent } from "@offering-protocol/agent"; diff --git a/examples/odp-agent-discovery/README.md b/examples/odp-agent-discovery/README.md index af4bf6b..9e329a1 100644 --- a/examples/odp-agent-discovery/README.md +++ b/examples/odp-agent-discovery/README.md @@ -4,8 +4,12 @@ This example performs the two-stage discovery flow against any reachable ODP Ser `.env`. The directory is explicitly a mock. `src/mock-directory.ts` probes the configured Service URLs, -builds cached directory entries only for reachable Services, and implements the sandbox Service-search -request in memory. It does not contact a deployed directory or pretend to be its implementation. +builds cached directory entries for reachable Services, and samples at most two Collections from +the first page when both Collection listing and detail retrieval are advertised without required +authentication. It implements unfiltered mixed and +Service-only search requests in memory. This bounded sampling seeds example data only: the deployed +Directory indexes explicitly submitted Collections, rather than crawling each Service. The mock +does not contact a deployed directory or implement its filtering, ranking, or suggestion query. Enter the example directory, copy the configuration template, and run the agent after starting any of the example Services: @@ -17,6 +21,9 @@ pnpm build pnpm start ``` -Unreachable URLs are skipped. For each reachable Service, the output narrates and prints the mock -directory entry, validated ODP Service document, first terse Offering page, and full details for the -first Offering. +Unreachable URLs are skipped. The example calls `directory.search()` and branches on each result's +`type`. For a Service result, it prints the mock entry, validated ODP Service document, first terse +Offering page, and full details for the first Offering. For a Collection result, it prints the entry, +inspects the owning Service, and fetches the live Collection using that Service and Collection ID. +Unknown result types are reported without contacting their contents. The marketplace example +provides Collections, so run it to exercise both known result types. diff --git a/examples/odp-agent-discovery/src/index.ts b/examples/odp-agent-discovery/src/index.ts index f5615fe..d63fea4 100644 --- a/examples/odp-agent-discovery/src/index.ts +++ b/examples/odp-agent-discovery/src/index.ts @@ -26,7 +26,12 @@ for (const unavailable of mock.unavailable) const directory = createDirectoryClient({ environment: "sandbox", transport: mock.transport }); let discovered = 0; -for await (const service of directory.searchServices().items) { +for await (const result of directory.search().items) { + if (result.type === "unknown") { + print("Unrecognized directory result", result); + continue; + } + const service = result.service; discovered += 1; const serviceUrl = mock.serviceUrlFor(service.service_origin); const client = createOdpServiceClient({ @@ -36,12 +41,19 @@ for await (const service of directory.searchServices().items) { initialPageSize: 2 }); - heading(`SERVICE ${discovered}: ${service.name}`); - print("Mock directory entry", service); + heading( + `${result.type.toUpperCase()} ${discovered}: ${result.type === "collection" ? result.collection.name : service.name}` + ); + print("Mock directory entry", result); const inspection = await client.inspect(); print("ODP Service document", inspection.document); + if (result.type === "collection") { + print("Full Collection response", await client.getCollection(result.collection.id)); + continue; + } + const page = await client.listOfferings().pages[Symbol.asyncIterator]().next(); if (page.done) { process.stdout.write("Offering list is empty.\n"); diff --git a/examples/odp-agent-discovery/src/mock-directory.ts b/examples/odp-agent-discovery/src/mock-directory.ts index 9e4ec05..eba2a68 100644 --- a/examples/odp-agent-discovery/src/mock-directory.ts +++ b/examples/odp-agent-discovery/src/mock-directory.ts @@ -1,5 +1,11 @@ +import { randomUUID } from "node:crypto"; + import { createOdpServiceClient } from "@offering-protocol/agent"; -import type { DirectoryService, DirectoryTransport } from "@offering-protocol/directory"; +import type { + DirectoryIndexedService, + DirectoryResult, + DirectoryTransport +} from "@offering-protocol/directory"; export interface MockDirectory { transport: DirectoryTransport; @@ -8,21 +14,24 @@ export interface MockDirectory { } export async function createMockDirectory(serviceUrls: string[]): Promise { - const services: DirectoryService[] = []; + const services: DirectoryIndexedService[] = []; + const items: DirectoryResult[] = []; const localUrls = new Map(); const unavailable: Array<{ serviceUrl: string; message: string }> = []; for (const [index, serviceUrl] of serviceUrls.entries()) { try { - const inspection = await createOdpServiceClient({ + const client = createOdpServiceClient({ serviceUrl, allowLocalNetwork: true, cachePartition: "mock-directory", signal: AbortSignal.timeout(2_000) - }).inspect(); + }); + const inspection = await client.inspect(); const document = inspection.document; const serviceOrigin = `https://service-${index + 1}.mock-directory.example`; - services.push({ + const service: DirectoryIndexedService = { + service_id: randomUUID(), service_origin: serviceOrigin, name: document.name, description: document.description, @@ -32,8 +41,32 @@ export async function createMockDirectory(serviceUrls: string[]): Promise + document.operations.some( + (operation) => operation.name === name && operation.authentication !== "required" + ) + ) + ) { + for await (const collection of client.listCollections({ maxPages: 1, maxItems: 2 }).items) { + items.push({ + type: "collection", + service, + indexed_at: service.indexed_at, + collection: { + id: collection.id, + name: collection.name, + ...(collection.description === undefined + ? {} + : { description: collection.description }) + } + }); + } + } } catch (error) { unavailable.push({ serviceUrl, @@ -50,14 +83,20 @@ export async function createMockDirectory(serviceUrls: string[]): Promise; +} + +export interface DirectoryServiceReference extends Record { + service_id: string; + service_origin: string; + name?: string; +} + +export interface DirectoryIndexedService extends DirectoryService { + service_id: string; +} + +export interface DirectoryServiceResult extends Record { + type: "service"; + service: DirectoryIndexedService; + indexed_at: string; + available_through?: DirectoryServiceReference; +} + +export interface DirectoryCollectionResult extends Record { + type: "collection"; + service: DirectoryIndexedService; + indexed_at: string; + collection: { + [key: string]: unknown; + id: string; + name: string; + description?: string; + }; +} + +export interface DirectoryUnknownResult { + type: "unknown"; + resource_type: string; + raw: Record; +} + +export type DirectoryResult = + DirectoryServiceResult | DirectoryCollectionResult | DirectoryUnknownResult; + export interface DirectoryIterationOptions { maxItems?: number; maxPages?: number; @@ -92,8 +135,8 @@ export interface DirectoryIssue { message: string; } -export interface DirectorySearchPage extends Record { - items: DirectoryService[]; +export interface DirectorySearchPage extends Record { + items: Item[]; next?: string; facets?: DirectoryFacets; /** @@ -104,12 +147,13 @@ export interface DirectorySearchPage extends Record { issues?: DirectoryIssue[]; } -export interface DirectorySearchSequence { - items: AsyncIterable; - pages: AsyncIterable; +export interface DirectorySearchSequence { + items: AsyncIterable; + pages: AsyncIterable>; } export interface DirectorySuggestionRequest { + filters?: DirectoryServiceFilters; prefix: string; limit?: number; signal?: AbortSignal; @@ -117,6 +161,15 @@ export interface DirectorySuggestionRequest { export interface DirectoryClient { readonly environment: DirectoryEnvironment; + search( + request?: DirectoryResourceSearchRequest, + options?: DirectoryIterationOptions + ): DirectorySearchSequence; + continueSearch( + next: string, + options?: DirectoryIterationOptions + ): DirectorySearchSequence; + suggest(request: DirectorySuggestionRequest): Promise; searchServices( request?: DirectorySearchRequest, options?: DirectoryIterationOptions @@ -188,44 +241,101 @@ export function createDirectoryClient(options: DirectoryClientOptions = {}): Dir return { environment, + search(request = {}, iteration = {}) { + const body = validateResourceSearchRequest(request); + const maxPages = optionalInteger(iteration.maxPages, "maxPages", 1, Number.MAX_SAFE_INTEGER); + const maxItems = optionalInteger(iteration.maxItems, "maxItems", 1, 10_000); + const pages = () => + searchPages("/v1/directory/search", parseResult, body, maxPages, iteration.signal); + return { pages: { [Symbol.asyncIterator]: pages }, items: itemIterable(pages, maxItems) }; + }, + continueSearch(next, iteration = {}) { + const reference = requireText(next, "next", 1, 2048); + const maxPages = optionalInteger(iteration.maxPages, "maxPages", 1, Number.MAX_SAFE_INTEGER); + const maxItems = optionalInteger(iteration.maxItems, "maxItems", 1, 10_000); + const pages = () => + searchPages( + "/v1/directory/search", + parseResult, + undefined, + maxPages, + iteration.signal, + reference + ); + return { pages: { [Symbol.asyncIterator]: pages }, items: itemIterable(pages, maxItems) }; + }, + suggest(request) { + return suggestions("/v1/directory/suggestions", request, true); + }, searchServices(request = {}, iteration = {}) { const body = validateSearchRequest(request); const maxPages = optionalInteger(iteration.maxPages, "maxPages", 1, Number.MAX_SAFE_INTEGER); const maxItems = optionalInteger(iteration.maxItems, "maxItems", 1, 10_000); - const pages = () => searchPages(body, maxPages, iteration.signal); + const pages = () => + searchPages("/v1/services/search", parseService, body, maxPages, iteration.signal); return { pages: { [Symbol.asyncIterator]: pages }, items: itemIterable(pages, maxItems) }; }, continueSearchServices(next, iteration = {}) { const reference = requireText(next, "next", 1, 2048); const maxPages = optionalInteger(iteration.maxPages, "maxPages", 1, Number.MAX_SAFE_INTEGER); const maxItems = optionalInteger(iteration.maxItems, "maxItems", 1, 10_000); - const pages = () => searchPages(undefined, maxPages, iteration.signal, reference); + const pages = () => + searchPages( + "/v1/services/search", + parseService, + undefined, + maxPages, + iteration.signal, + reference + ); return { pages: { [Symbol.asyncIterator]: pages }, items: itemIterable(pages, maxItems) }; }, - async suggestServices(request) { - const prefix = requireText(request.prefix, "prefix", 1, 128); - const limit = optionalInteger(request.limit, "limit", 1, 25); - const url = new URL("/v1/services/suggestions", origin); + suggestServices(request) { + return suggestions("/v1/services/suggestions", request); + } + }; + + async function suggestions( + path: string, + request: DirectorySuggestionRequest, + mixed = false + ): Promise { + const prefix = requireText(request.prefix, "prefix", 1, 128); + const limit = optionalInteger(request.limit, "limit", 1, 25); + const url = new URL(path, origin); + if (!mixed && request.filters !== undefined) + throw new TypeError("Service-only suggestions do not support filters."); + const filters = request.filters === undefined ? undefined : validateFilters(request.filters); + if (!mixed) { url.searchParams.set("prefix", prefix); if (limit !== undefined) url.searchParams.set("limit", String(limit)); - const value = await requestJson(url, { - method: "GET", - ...(request.signal === undefined ? {} : { signal: request.signal }) - }); - return parseSuggestions(value); } - }; + const value = await requestJson(url, { + method: mixed ? "POST" : "GET", + ...(mixed + ? { + body: JSON.stringify({ + prefix, + ...(limit === undefined ? {} : { limit }), + ...(filters === undefined ? {} : { filters }) + }) + } + : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }) + }); + return parseSuggestions(value); + } - async function* searchPages( + async function* searchPages( + path: string, + parseItem: (value: unknown) => Item, body: DirectorySearchRequest | undefined, maxPages: number | undefined, signal?: AbortSignal, continuation?: string - ): AsyncGenerator { + ): AsyncGenerator> { let url = - continuation === undefined - ? new URL("/v1/services/search", origin) - : continuationUrl(continuation, origin); + continuation === undefined ? new URL(path, origin) : continuationUrl(continuation, origin); let init: RequestInit = body === undefined ? { method: "GET", ...(signal === undefined ? {} : { signal }) } @@ -238,7 +348,7 @@ export function createDirectoryClient(options: DirectoryClientOptions = {}): Dir // traversal is no longer capped at 16. const visited = new Set([String(url)]); for (let pageNumber = 0; ; pageNumber += 1) { - const page = parseSearchPage(await requestJson(url, init)); + const page = parseSearchPage(await requestJson(url, init), parseItem); yield page; if (page.next === undefined) return; // The caller's own bound ends the sequence cleanly; the last yielded page still carries @@ -364,10 +474,10 @@ async function discard(response: Response): Promise { } /** Yields each item, stopping the instant the caller's budget is met. */ -function itemIterable( - pages: () => AsyncGenerator, +function itemIterable( + pages: () => AsyncGenerator>, maximum: number | undefined -): AsyncIterable { +): AsyncIterable { return { async *[Symbol.asyncIterator]() { let count = 0; @@ -396,6 +506,85 @@ function validateSearchRequest(request: DirectorySearchRequest): DirectorySearch }; } +function validateResourceSearchRequest( + request: DirectoryResourceSearchRequest +): DirectoryResourceSearchRequest { + const base = validateSearchRequest(request); + if (request.types === undefined) return base; + if ( + !Array.isArray(request.types) || + request.types.length === 0 || + request.types.length > 2 || + new Set(request.types).size !== request.types.length || + request.types.some((type) => type !== "service" && type !== "collection") + ) + throw new TypeError("types must contain distinct service or collection values"); + return { ...base, types: [...request.types] }; +} + +function parseResult(value: unknown): DirectoryResult { + const object = requireObject(value, "Directory result"); + const type = requireText(object["type"], "type", 1, 128); + if (type !== "service" && type !== "collection") + return { type: "unknown", resource_type: type, raw: { ...object } }; + const serviceObject = requireObject(object["service"], "service"); + const service: DirectoryIndexedService = { + ...parseService(serviceObject), + service_id: requireText(serviceObject["service_id"], "service_id", 1, 128) + }; + const indexedAt = parseIndexedAt(object["indexed_at"]); + if (type === "service") { + const availableThrough = + object["available_through"] === undefined + ? undefined + : parseServiceReference(object["available_through"]); + return { + ...object, + type, + service, + indexed_at: indexedAt, + ...(availableThrough === undefined ? {} : { available_through: availableThrough }) + }; + } + const collection = requireObject(object["collection"], "collection"); + const id = requireText(collection["id"], "collection.id", 1, 128); + if (!isLocalResourceIdentifier(id)) + throw new TypeError("collection.id must be a local resource identifier"); + const name = requireText(collection["name"], "collection.name", 1, 128); + const description = collection["description"]; + if (description !== undefined && (typeof description !== "string" || description.length > 1024)) + throw new TypeError("collection.description is invalid"); + return { + ...object, + type, + service, + indexed_at: indexedAt, + collection: { ...collection, id, name, ...(description === undefined ? {} : { description }) } + }; +} + +function parseServiceReference(value: unknown): DirectoryServiceReference { + const object = requireObject(value, "available_through"); + const serviceOrigin = requireText(object["service_origin"], "service_origin", 1, 2048); + const url = parseOrigin(serviceOrigin); + if (url.protocol !== "https:" || url.origin !== serviceOrigin || isPrivateHost(url.hostname)) + throw new TypeError("Attribution origin must be a public HTTPS origin"); + const name = optionalText(object["name"], "name", 128); + return { + ...object, + service_id: requireText(object["service_id"], "service_id", 1, 128), + service_origin: serviceOrigin, + ...(name === undefined ? {} : { name }) + }; +} + +function parseIndexedAt(value: unknown): string { + const indexedAt = requireText(value, "indexed_at", 1, 64); + if (!RFC_3339.test(indexedAt) || Number.isNaN(Date.parse(indexedAt))) + throw new TypeError("indexed_at must be an RFC 3339 date-time"); + return indexedAt; +} + function validateFilters(filters: DirectoryServiceFilters): DirectoryServiceFilters { return { ...(filters.keywords === undefined @@ -414,18 +603,21 @@ function validateFilters(filters: DirectoryServiceFilters): DirectoryServiceFilt }; } -function parseSearchPage(value: unknown): DirectorySearchPage { +function parseSearchPage( + value: unknown, + parseItem: (value: unknown) => Item +): DirectorySearchPage { const object = requireObject(value, "Directory search page"); if (!Array.isArray(object["items"]) || object["items"].length > MAXIMUM_ITEMS_PER_PAGE) throw new TypeError("Directory search page items are invalid"); // One stale or nonconformant entry used to reject the whole page, which killed the generator and // made every other Service in the result set undiscoverable. Drop the entry, keep the page, and // tell the caller what was skipped. - const items: DirectoryService[] = []; + const items: Item[] = []; const issues: DirectoryIssue[] = []; object["items"].forEach((entry, index) => { try { - items.push(parseService(entry)); + items.push(parseItem(entry)); } catch (error) { issues.push({ index, @@ -472,11 +664,7 @@ function parseService(value: unknown): DirectoryService { ...(object["support_url"] === undefined ? {} : { support_url: object["support_url"] }), ...(object["website_url"] === undefined ? {} : { website_url: object["website_url"] }) }); - const indexedAt = requireText(object["indexed_at"], "indexed_at", 1, 64); - // `Date.parse` accepts implementation-defined formats such as "December 17, 1995", which breaks - // any consumer that compares or slices the value. - if (!RFC_3339.test(indexedAt) || Number.isNaN(Date.parse(indexedAt))) - throw new TypeError("indexed_at must be an RFC 3339 date-time"); + const indexedAt = parseIndexedAt(object["indexed_at"]); const normalized = { ...object }; // `protocols` is validated and reinstated below, but only when something survives filtering, so // the raw copy has to go first or an all-unknown block would pass straight through. diff --git a/packages/directory/test/unit/mixed-search.test.ts b/packages/directory/test/unit/mixed-search.test.ts new file mode 100644 index 0000000..229ed36 --- /dev/null +++ b/packages/directory/test/unit/mixed-search.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createDirectoryClient, type DirectoryResourceSearchRequest } from "../../src/index.js"; + +const indexedAt = "2026-09-18T12:00:00Z"; +const service = { + service_id: "ca0304cc-ab28-43e5-af94-7bdf11b40c6e", + service_origin: "https://api.example.com", + name: "Example Service", + description: "Data services.", + language: "en", + localizations: ["en"], + indexed_at: "2026-09-18T11:00:00Z", + operations: [ + { name: "get-collection", authentication: "not-required" }, + { name: "list-offerings", authentication: "not-required" }, + { name: "get-offering", authentication: "not-required" } + ] +}; +const collection = { + type: "collection", + indexed_at: indexedAt, + service, + collection: { + id: "Weather", + name: "Weather forecasts", + description: "Forecasts and current conditions." + } +}; +const serviceResult = { type: "service", indexed_at: service.indexed_at, service }; +const attribution = { + service_id: "platform", + service_origin: "https://platform.example", + name: "Platform" +}; + +function json(value: unknown): Response { + return Response.json(value); +} + +function inputUrl(input: string | Request | URL | undefined): string { + return input instanceof Request ? input.url : String(input); +} + +function bodyOf(init: RequestInit | undefined): unknown { + return JSON.parse(typeof init?.body === "string" ? init.body : "null"); +} + +async function collect(values: AsyncIterable): Promise { + const result: T[] = []; + for await (const value of values) result.push(value); + return result; +} + +describe("mixed Directory discovery", () => { + it("reads the server envelope and preserves result identity, freshness and attribution", async () => { + const unknown = { type: "offering", arbitrary: { id: "future" } }; + const transport = vi.fn().mockResolvedValueOnce( + json({ + items: [{ ...serviceResult, available_through: attribution }, collection, unknown], + facets: { keywords: [{ value: "weather", count: 12 }] }, + extra: true + }) + ); + const client = createDirectoryClient({ transport }); + const pages = await collect( + client.search({ query: "weather", types: ["service", "collection"], limit: 25 }).pages + ); + expect(pages).toEqual([ + { + items: [ + { ...serviceResult, available_through: attribution }, + collection, + { type: "unknown", resource_type: "offering", raw: unknown } + ], + facets: { keywords: [{ value: "weather", count: 12 }] }, + extra: true + } + ]); + const [url, init] = transport.mock.calls[0] ?? []; + expect(inputUrl(url)).toBe("https://api.inflowpay.ai/v1/directory/search"); + expect(init?.method).toBe("POST"); + expect(bodyOf(init)).toEqual({ + query: "weather", + types: ["service", "collection"], + limit: 25 + }); + expect(transport).toHaveBeenCalledTimes(1); + }); + + it("copies request inputs before lazy execution and keeps Service-only routes", async () => { + const transport = vi + .fn() + .mockImplementation(() => Promise.resolve(json({ items: [] }))); + const client = createDirectoryClient({ environment: "sandbox", transport }); + const request: DirectoryResourceSearchRequest = { + types: ["collection"], + filters: { keywords: ["weather"] } + }; + const sequence = client.search(request); + request.types?.splice(0); + request.filters?.keywords?.splice(0); + await collect(sequence.items); + expect(bodyOf(transport.mock.calls[0]?.[1])).toEqual({ + types: ["collection"], + filters: { keywords: ["weather"] } + }); + await collect(client.searchServices().items); + expect(inputUrl(transport.mock.calls[1]?.[0])).toBe( + "https://sandbox.inflowpay.ai/v1/services/search" + ); + await collect(client.search().items); + expect(bodyOf(transport.mock.calls[2]?.[1])).toEqual({}); + }); + + it("uses independent lazy traversals and respects both iteration limits", async () => { + const transport = vi.fn().mockImplementation((input) => + Promise.resolve( + json({ + items: [collection, serviceResult], + ...(inputUrl(input).includes("cursor") ? {} : { next: "/v1/directory/search?cursor=2" }) + }) + ) + ); + const client = createDirectoryClient({ transport }); + const results = client.search({}, { maxPages: 1, maxItems: 1 }); + expect(transport).not.toHaveBeenCalled(); + expect(await collect(results.items)).toEqual([collection]); + expect(await collect(results.pages)).toHaveLength(1); + expect(transport).toHaveBeenCalledTimes(2); + expect( + await collect( + client.continueSearch("/v1/directory/search?cursor=2", { maxItems: 1, maxPages: 1 }).items + ) + ).toEqual([collection]); + expect(transport.mock.calls[2]?.[1]?.method).toBe("GET"); + expect(transport.mock.calls[2]?.[1]?.body).toBeUndefined(); + expect(await collect(client.search().items)).toHaveLength(4); + }); + + it("preserves abort signals through mixed continuations and rejects unsafe continuation origins", async () => { + const controller = new AbortController(); + const transport = vi.fn().mockImplementation((_input, init) => { + expect(init?.signal).toBe(controller.signal); + return Promise.resolve(json({ items: [], next: "/v1/directory/search?cursor=2" })); + }); + const client = createDirectoryClient({ transport }); + await expect(collect(client.search({}, { signal: controller.signal }).pages)).rejects.toThrow( + "pagination loop" + ); + transport.mockClear(); + await expect( + collect(client.continueSearch("https://elsewhere.example/search").pages) + ).rejects.toThrow("canonical origin"); + expect(transport).not.toHaveBeenCalled(); + await expect( + collect(client.continueSearch("/v1/directory/search", { signal: controller.signal }).items) + ).rejects.toThrow("pagination loop"); + }); + + it("reports invalid known items without dropping valid or unknown items", async () => { + const malformed = [ + null, + {}, + { type: "" }, + { ...collection, service: null }, + { ...collection, service: { ...service, service_id: "" } }, + { ...collection, indexed_at: "not-a-date" }, + { ...collection, indexed_at: "2026-99-99T00:00:00Z" }, + { ...collection, collection: null }, + { ...collection, collection: { ...collection.collection, id: "../escape" } }, + { ...collection, collection: { ...collection.collection, name: "" } }, + { ...collection, collection: { ...collection.collection, description: null } }, + { ...collection, collection: { ...collection.collection, description: "x".repeat(1025) } }, + { + ...serviceResult, + available_through: { ...attribution, service_origin: "http://platform.example" } + }, + { + ...serviceResult, + available_through: { ...attribution, service_origin: "https://platform.example/path" } + }, + { + ...serviceResult, + available_through: { ...attribution, service_origin: "https://127.0.0.1" } + }, + { ...serviceResult, available_through: { ...attribution, service_id: "" } } + ]; + const client = createDirectoryClient({ + transport: () => Promise.resolve(json({ items: [...malformed, collection] })) + }); + const [page] = await collect(client.search().pages); + expect(page?.items).toEqual([collection]); + expect(page?.issues?.map(({ index }) => index)).toEqual(malformed.map((_, index) => index)); + }); + + it("accepts omitted optional metadata, empty descriptions and additive fields", async () => { + const items = [ + serviceResult, + { + ...serviceResult, + available_through: { service_id: "platform", service_origin: "https://platform.example" } + }, + { ...collection, collection: { id: "Weather", name: "Forecasts", extra: 1 } }, + { ...collection, collection: { ...collection.collection, description: "" } } + ]; + const client = createDirectoryClient({ transport: () => Promise.resolve(json({ items })) }); + expect(await collect(client.search().items)).toEqual(items); + }); + + it("rejects invalid type filters and iteration options before transport", () => { + const transport = vi.fn(); + const client = createDirectoryClient({ transport }); + for (const types of [ + [], + ["offering"], + ["service", "service"], + ["service", "collection", "service"], + null, + "service" + ]) { + // Exercise untyped JavaScript callers at the public input boundary. + expect(() => client.search({ types } as DirectoryResourceSearchRequest)).toThrow("types"); + } + expect(() => client.search({ query: " " })).toThrow("query"); + expect(() => client.search({}, { maxPages: 0 })).toThrow("maxPages"); + expect(() => client.search({}, { maxItems: 0 })).toThrow("maxItems"); + expect(() => client.continueSearch(" ")).toThrow("next"); + expect(() => client.continueSearch("/next", { maxPages: 0 })).toThrow("maxPages"); + expect(() => client.continueSearch("/next", { maxItems: 0 })).toThrow("maxItems"); + expect(transport).not.toHaveBeenCalled(); + }); + + it("returns matching names from mixed suggestions and keeps Service keyword suggestions separate", async () => { + const signal = new AbortController().signal; + const transport = vi + .fn() + .mockImplementation(() => + Promise.resolve(json({ items: ["AccuWeather", "Atlas", "Atlas"] })) + ); + const client = createDirectoryClient({ transport }); + expect(await client.suggest({ prefix: "we", limit: 10, signal })).toEqual([ + "AccuWeather", + "Atlas" + ]); + expect(inputUrl(transport.mock.calls[0]?.[0])).toBe( + "https://api.inflowpay.ai/v1/directory/suggestions" + ); + expect(transport.mock.calls[0]?.[1]?.method).toBe("POST"); + const body = transport.mock.calls[0]?.[1]?.body; + expect(typeof body === "string" ? JSON.parse(body) : undefined).toEqual({ + prefix: "we", + limit: 10 + }); + expect(transport.mock.calls[0]?.[1]?.signal).toBe(signal); + await client.suggestServices({ prefix: "we" }); + expect(inputUrl(transport.mock.calls[1]?.[0])).toBe( + "https://api.inflowpay.ai/v1/services/suggestions?prefix=we" + ); + await expect(client.suggest({ prefix: " " })).rejects.toThrow("prefix"); + await expect(client.suggest({ prefix: "we", limit: 26 })).rejects.toThrow("limit"); + }); + + it("validates and sends suggestion filters without changing the names response", async () => { + const transport = vi.fn().mockResolvedValue(json({ items: ["Weather"] })); + const client = createDirectoryClient({ transport }); + const filters = { + enrollment: [{ name: "aep" as const }], + keywords: ["weather"], + operations: [{ name: "get-offering" as const }], + payments: [{ name: "mpp" as const, options: ["inflow" as const] }], + trust: [{ name: "tap" as const }] + }; + expect(await client.suggest({ prefix: "we", filters })).toEqual(["Weather"]); + const body = transport.mock.calls[0]?.[1]?.body; + expect(typeof body === "string" ? JSON.parse(body) : undefined).toEqual({ + prefix: "we", + filters + }); + expect(new Headers(transport.mock.calls[0]?.[1]?.headers).get("content-type")).toBe( + "application/json" + ); + await expect(client.suggest({ prefix: "we", filters: { keywords: [] } })).rejects.toThrow(); + await expect(client.suggestServices({ prefix: "we", filters })).rejects.toThrow( + "do not support filters" + ); + expect(transport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs index af7bb9e..791f8b4 100644 --- a/scripts/smoke-examples.mjs +++ b/scripts/smoke-examples.mjs @@ -38,7 +38,8 @@ try { "Marketplace Example", "ODP Service document:", "Terse Offering list response:", - "Full Offering response:" + "Full Offering response:", + "Full Collection response:" ]) if (!result.includes(expected)) throw new Error(`Agent walkthrough omitted ${JSON.stringify(expected)}:\n${result}`);