← 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 · TypeScriptimport { 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,
}
}