Skip to content
← Public packages

@kody/api-research

Research third-party APIs: registry search, provider discovery, OpenAPI summarize, and client scaffold.

src/search-registry.ts

122 lines · 3.1 KB · TypeScript
import {
	BoundedBodyTooLargeError,
	readBoundedBody,
} from './bounded-body.ts'
import { getErrorMessage } from './error-message.ts'

const INTEGRATIONS_SH_API_BASE = 'https://integrations.sh/api'
const MAX_SEARCH_BODY_BYTES = 500_000
const SEARCH_FETCH_TIMEOUT_MS = 10_000

export type RegistrySearchResult = {
	domain: string
	name: string
	description: string
	kinds: Array<string>
	url: string
}

export type SearchRegistryInput = {
	/** Provider name or domain to search for. */
	query: string
	/** Maximum number of results to return (1–20, default 5). */
	limit?: number
}

export type SearchRegistryOutput = {
	results: Array<RegistrySearchResult>
}

export function buildRegistrySearchUrl(query: string): string {
	return `${INTEGRATIONS_SH_API_BASE}/search?q=${encodeURIComponent(query)}`
}

/**
 * Search the public integrations.sh registry for a provider domain.
 * Use when you need a canonical hostname before calling discover.
 *
 * @param input - Query string and optional result limit
 * @returns Untrusted registry matches (domain, kinds, listing URL)
 *
 * @example
 * import searchRegistry from 'kody:@kody/api-research/search-registry'
 * const { results } = await searchRegistry({ query: 'github' })
 */
export default async function searchRegistry(
	input: SearchRegistryInput,
): Promise<SearchRegistryOutput> {
	const query = input.query.trim()
	if (query.length === 0) {
		throw new Error('query is required')
	}
	const limit = input.limit ?? 5
	if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
		throw new Error('limit must be an integer between 1 and 20')
	}

	const url = buildRegistrySearchUrl(query)
	let response: Response
	try {
		response = await fetch(url, {
			headers: { Accept: 'application/json' },
			redirect: 'follow',
			signal: AbortSignal.timeout(SEARCH_FETCH_TIMEOUT_MS),
		})
	} catch (cause) {
		throw new Error(
			`integrations.sh registry search failed: ${getErrorMessage(cause)}`,
		)
	}

	if (!response.ok) {
		throw new Error(
			`integrations.sh registry search failed: HTTP ${response.status} for ${url}`,
		)
	}

	let body: string
	try {
		body = await readBoundedBody(response, MAX_SEARCH_BODY_BYTES)
	} catch (cause) {
		if (cause instanceof BoundedBodyTooLargeError) {
			throw new Error(
				`integrations.sh registry search failed: ${cause.message}`,
			)
		}
		throw cause
	}

	let parsed: unknown
	try {
		parsed = JSON.parse(body)
	} catch (cause) {
		throw new Error(
			`integrations.sh registry search failed: invalid JSON (${getErrorMessage(cause)})`,
		)
	}

	if (
		typeof parsed !== 'object' ||
		parsed === null ||
		!Array.isArray((parsed as { results?: unknown }).results)
	) {
		throw new Error(
			'integrations.sh registry search failed: unexpected response shape',
		)
	}

	const results = (parsed as { results: Array<Record<string, unknown>> })
		.results
		.slice(0, limit)
		.map((result) => ({
			domain: String(result.domain ?? ''),
			name: String(result.name ?? ''),
			description: String(result.description ?? ''),
			kinds: Array.isArray(result.kinds)
				? result.kinds.filter((kind): kind is string => typeof kind === 'string')
				: [],
			url: String(result.url ?? ''),
		}))

	return { results }
}