Skip to content
← Public packages

@kody/openapi

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

src/request.ts

176 lines · 4.4 KB · TypeScript
import {
	createAuthenticatedFetch,
	secretHeaders,
} from 'kody:runtime'
import { type OpenApiBoundAuth } from './auth.ts'
import {
	BoundedBodyTooLargeError,
	readBoundedBody,
} from './bounded-body.ts'
import { type OpenApiBinding, type OpenApiBindingOperation } from './selection.ts'
import { assertPinnedToApiBaseUrl, buildOperationUrl } from './url.ts'

export const openApiOperationResponseMaxBytes = 300_000

export type OpenApiOperationRequestArgs = {
	params?: Record<string, unknown>
	query?: Record<string, unknown>
	headers?: Record<string, unknown>
	body?: unknown
}

export type OpenApiOperationRequestResult = {
	status: number
	ok: boolean
	contentType: string | null
	body: unknown
	truncated: boolean
}

function asStringRecord(
	value: Record<string, unknown> | undefined,
	label: string,
): Record<string, string> {
	if (value == null) return {}
	const result: Record<string, string> = {}
	for (const [key, entry] of Object.entries(value)) {
		if (entry == null) continue
		if (typeof entry === 'object') {
			throw new Error(`${label}.${key} must be a string or number`)
		}
		result[key] = String(entry)
	}
	return result
}

function shouldSendBody(operation: OpenApiBindingOperation) {
	return !['get', 'head'].includes(operation.method)
}

function serializeBody(
	body: unknown,
	operation: OpenApiBindingOperation,
): string {
	const contentType = operation.requestBody?.contentType ?? 'application/json'
	if (contentType.includes('json') || typeof body === 'object') {
		return JSON.stringify(body)
	}
	return String(body)
}

function authHeaders(auth: OpenApiBoundAuth): Record<string, string> {
	switch (auth.kind) {
		case 'none':
		case 'integration':
			return {}
		case 'bearerSecret':
			return {
				Authorization: `Bearer {{secret:${auth.secretName}}}`,
			}
		case 'headerSecret':
			return {
				[auth.headerName]: `{{secret:${auth.secretName}}}`,
			}
		case 'basicSecrets':
			return {
				Authorization: secretHeaders.basic({
					usernameSecret: auth.usernameSecret,
					passwordSecret: auth.passwordSecret,
				}),
			}
		default: {
			const _exhaustive: never = auth
			return _exhaustive
		}
	}
}

async function resolveFetch(
	auth: OpenApiBoundAuth,
	fetchImpl?: typeof fetch,
): Promise<typeof fetch> {
	if (fetchImpl) return fetchImpl
	if (auth.kind === 'integration') {
		return createAuthenticatedFetch(auth.provider)
	}
	return fetch
}

async function readOperationResponse(
	response: Response,
): Promise<OpenApiOperationRequestResult> {
	const contentType = response.headers.get('content-type')
	let raw: string
	let truncated = false
	try {
		raw = await readBoundedBody(response, openApiOperationResponseMaxBytes)
	} catch (error) {
		if (error instanceof BoundedBodyTooLargeError) {
			truncated = true
			raw = await response.text().catch(() => '')
			raw = raw.slice(0, openApiOperationResponseMaxBytes)
		} else {
			throw error
		}
	}

	let body: unknown = raw
	if (contentType?.includes('json') && raw.length > 0) {
		try {
			body = JSON.parse(raw)
		} catch {
			body = raw
		}
	}

	return {
		status: response.status,
		ok: response.ok,
		contentType,
		body,
		truncated,
	}
}

export async function executeOpenApiOperationRequest(input: {
	binding: OpenApiBinding
	operation: OpenApiBindingOperation
	args?: OpenApiOperationRequestArgs
	fetchImpl?: typeof fetch
}): Promise<OpenApiOperationRequestResult> {
	const args = input.args ?? {}
	const params = asStringRecord(args.params, 'params')
	const userHeaders = asStringRecord(args.headers, 'headers')
	const url = buildOperationUrl({
		apiBaseUrl: input.binding.apiBaseUrl,
		path: input.operation.path,
		params,
		query: args.query ?? {},
	})
	assertPinnedToApiBaseUrl(url, input.binding.apiBaseUrl)

	const headers: Record<string, string> = {
		...authHeaders(input.binding.auth),
		...userHeaders,
	}
	if (
		args.body !== undefined &&
		shouldSendBody(input.operation) &&
		!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')
	) {
		headers['Content-Type'] =
			input.operation.requestBody?.contentType ?? 'application/json'
	}

	const requestInit: RequestInit = {
		method: input.operation.method.toUpperCase(),
		headers,
	}
	if (args.body !== undefined && shouldSendBody(input.operation)) {
		requestInit.body = serializeBody(args.body, input.operation)
	}

	const fetchImpl = await resolveFetch(input.binding.auth, input.fetchImpl)
	const response = await fetchImpl(url.toString(), requestInit)
	return readOperationResponse(response)
}