Skip to content
← Public packages

@kody/openapi

Bind an OpenAPI spec and call selected operations with saved integration or secret names.

src/selection.ts

218 lines · 6.1 KB · TypeScript
import { type OpenApiBoundAuth } from './auth.ts'
import {
	type OpenApiHttpMethod,
	type OpenApiOperation,
	type OpenApiParameter,
	type OpenApiRequestBody,
} from './spec-types.ts'

export const openApiBindingNamePattern = /^[a-z0-9][a-z0-9_-]{0,63}$/
export const maxOpenApiBindingOperations = 100
export const maxOpenApiBindingSerializedBytes = 900_000
export const maxOpenApiBindings = 32

export type OpenApiBindingSelection = {
	operationIds?: Array<string>
	tags?: Array<string>
	pathPrefixes?: Array<string>
}

export type OpenApiBindingOperation = {
	slug: string
	operationId: string | null
	method: OpenApiHttpMethod
	path: string
	summary: string | null
	description: string | null
	tags: Array<string>
	deprecated: boolean
	parameters: Array<OpenApiParameter>
	requestBody: OpenApiRequestBody | null
}

export type OpenApiBinding = {
	name: string
	specUrl: string
	apiBaseUrl: string
	description: string | null
	auth: OpenApiBoundAuth
	selection: OpenApiBindingSelection
	includeDestructive: boolean
	specTitle: string | null
	specVersion: string | null
	operations: Array<OpenApiBindingOperation>
	updatedAt: string
}

export type OpenApiBindingSummary = {
	name: string
	specUrl: string
	apiBaseUrl: string
	authKind: string
	operationCount: number
	updatedAt: string
	specTitle: string | null
	description: string | null
}

export function assertBindingName(name: string): string {
	const trimmed = name.trim()
	if (!openApiBindingNamePattern.test(trimmed)) {
		throw new Error(
			'name must match ^[a-z0-9][a-z0-9_-]{0,63}$ (used as the binding key in call({ name }))',
		)
	}
	return trimmed
}

export function normalizeApiBaseUrl(apiBaseUrl: string) {
	const trimmed = apiBaseUrl.trim()
	return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}

export function normalizeSelection(
	selection: OpenApiBindingSelection,
): OpenApiBindingSelection {
	const operationIds = (selection.operationIds ?? [])
		.map((value) => value.trim())
		.filter(Boolean)
	const tags = (selection.tags ?? []).map((value) => value.trim()).filter(Boolean)
	const pathPrefixes = (selection.pathPrefixes ?? [])
		.map((value) => value.trim())
		.filter(Boolean)
	if (
		operationIds.length === 0 &&
		tags.length === 0 &&
		pathPrefixes.length === 0
	) {
		throw new Error(
			'selection requires at least one of operationIds, tags, or pathPrefixes',
		)
	}
	return {
		...(operationIds.length > 0 ? { operationIds } : {}),
		...(tags.length > 0 ? { tags } : {}),
		...(pathPrefixes.length > 0 ? { pathPrefixes } : {}),
	}
}

function operationMatchesSelection(
	operation: OpenApiOperation,
	selection: OpenApiBindingSelection,
) {
	const operationIds = selection.operationIds ?? []
	if (operationIds.length > 0) {
		if (
			operationIds.includes(operation.slug) ||
			(operation.operationId != null &&
				operationIds.includes(operation.operationId))
		) {
			return true
		}
	}
	const tags = selection.tags ?? []
	if (tags.length > 0 && operation.tags.some((tag) => tags.includes(tag))) {
		return true
	}
	const pathPrefixes = selection.pathPrefixes ?? []
	if (
		pathPrefixes.length > 0 &&
		pathPrefixes.some((prefix) => operation.path.startsWith(prefix))
	) {
		return true
	}
	return (
		operationIds.length === 0 && tags.length === 0 && pathPrefixes.length === 0
	)
}

function toBindingOperation(
	operation: OpenApiOperation,
): OpenApiBindingOperation {
	return {
		slug: operation.slug,
		operationId: operation.operationId,
		method: operation.method,
		path: operation.path,
		summary: operation.summary,
		description: operation.description,
		tags: operation.tags,
		deprecated: operation.deprecated,
		parameters: operation.parameters,
		requestBody: operation.requestBody,
	}
}

export function resolveOpenApiSelection(input: {
	operations: ReadonlyArray<OpenApiOperation>
	selection: OpenApiBindingSelection
	includeDestructive: boolean
}): { operations: Array<OpenApiBindingOperation>; warnings: Array<string> } {
	const selection = normalizeSelection(input.selection)
	const warnings: Array<string> = []
	const matched = new Map<string, OpenApiOperation>()

	for (const operation of input.operations) {
		if (!operationMatchesSelection(operation, selection)) continue
		matched.set(operation.slug, operation)
	}

	const explicitIds = new Set(selection.operationIds ?? [])
	const isExplicitlySelected = (operation: OpenApiOperation) =>
		explicitIds.has(operation.slug) ||
		(operation.operationId != null && explicitIds.has(operation.operationId))

	const kept: Array<OpenApiBindingOperation> = []
	for (const operation of matched.values()) {
		if (operation.method === 'delete' && !input.includeDestructive) {
			if (isExplicitlySelected(operation)) {
				warnings.push(
					`Excluded destructive DELETE operation "${operation.slug}" because includeDestructive is false.`,
				)
			}
			continue
		}
		kept.push(toBindingOperation(operation))
	}

	kept.sort((left, right) => left.slug.localeCompare(right.slug, 'en'))

	if (kept.length === 0) {
		throw new Error(
			'OpenAPI binding selection matched 0 operations. Broaden operationIds, tags, or pathPrefixes, or set includeDestructive if you intended DELETE operations.',
		)
	}
	if (kept.length > maxOpenApiBindingOperations) {
		throw new Error(
			`OpenAPI binding selection matched ${kept.length} operations, exceeding the max of ${maxOpenApiBindingOperations}. Narrow the selection — this package never auto-exposes an entire large spec.`,
		)
	}

	return { operations: kept, warnings }
}

export function assertOpenApiBindingWithinSizeLimit(binding: OpenApiBinding) {
	const serialized = JSON.stringify(binding)
	const bytes = new TextEncoder().encode(serialized).byteLength
	if (bytes > maxOpenApiBindingSerializedBytes) {
		throw new Error(
			`OpenAPI binding snapshot is ${bytes} bytes, exceeding the ${maxOpenApiBindingSerializedBytes}-byte cap. Narrow the selection and try again.`,
		)
	}
	return serialized
}

export function toOpenApiBindingSummary(
	binding: OpenApiBinding,
): OpenApiBindingSummary {
	return {
		name: binding.name,
		specUrl: binding.specUrl,
		apiBaseUrl: binding.apiBaseUrl,
		authKind: binding.auth.kind,
		operationCount: binding.operations.length,
		updatedAt: binding.updatedAt,
		specTitle: binding.specTitle,
		description: binding.description,
	}
}