Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/kit

src/client.ts

194 lines · 4.9 KB · TypeScript
import { kitFetch, resolveKitAuth, type ResolvedKitAuth } from './auth.ts'
import type { DryRunResult, JsonRecord, KitAuthInput, KitPageInfo, MutationInput } from './types.ts'
import { isMutatingMethod, nextStepForAuth } from './setup.ts'
import { kitUrl, mutationPreview, normalizeKitPath, rejectBroadcastSend } from './safety.ts'

export class KitApiError extends Error {
	readonly status: number
	readonly body: unknown
	readonly method: string
	readonly path: string

	constructor(
		message: string,
		options: { status: number; body: unknown; method: string; path: string },
	) {
		super(message)
		this.name = 'KitApiError'
		this.status = options.status
		this.body = options.body
		this.method = options.method
		this.path = options.path
	}
}

export type KitRequestInput = KitAuthInput &
	MutationInput & {
		path: string
		method?: string
		query?: JsonRecord
		body?: JsonRecord
	}

export type KitResponse<T> = {
	data: T
	pageInfo: KitPageInfo
	status: number
}

export { kitUrl, mutationPreview, normalizeKitPath, rejectBroadcastSend }

function kitErrorMessage(body: unknown): string | null {
	if (!body || typeof body !== 'object') return null
	const record = body as { errors?: unknown; message?: unknown }
	if (Array.isArray(record.errors) && record.errors.every((item) => typeof item === 'string')) {
		return record.errors.join('; ')
	}
	if (typeof record.message === 'string' && record.message.length > 0) {
		return record.message
	}
	return null
}

export function pageInfoFrom(body: unknown): KitPageInfo {
	const pagination =
		body && typeof body === 'object'
			? ((body as { pagination?: { has_next_page?: unknown; end_cursor?: unknown } }).pagination ??
				null)
			: null
	const after = typeof pagination?.end_cursor === 'string' ? pagination.end_cursor : null
	return {
		hasNextPage: Boolean(pagination?.has_next_page && after),
		after,
	}
}

export async function kitRequest<T = unknown>(input: KitRequestInput): Promise<KitResponse<T>> {
	const method = (input.method ?? 'GET').toUpperCase()
	const path = normalizeKitPath(input.path)
	if (isMutatingMethod(method)) {
		const preview = mutationPreview(input, {
			method,
			path,
			body: input.body,
		})
		if (preview) {
			return {
				data: preview as T,
				pageInfo: { hasNextPage: false, after: null },
				status: 0,
			}
		}
	}
	const auth = await resolveKitAuth(input)
	return kitRequestWithAuth<T>(auth, {
		method,
		path,
		query: input.query,
		body: input.body,
	})
}

export async function kitRequestWithAuth<T = unknown>(
	auth: ResolvedKitAuth,
	input: {
		method: string
		path: string
		query?: JsonRecord
		body?: JsonRecord
	},
): Promise<KitResponse<T>> {
	const url = kitUrl(input.path, input.query)
	const headers = new Headers({
		Accept: 'application/json',
		'User-Agent': 'kody-kit/1.0',
	})
	const init: RequestInit = { method: input.method, headers }
	if (input.body !== undefined) {
		headers.set('Content-Type', 'application/json')
		init.body = JSON.stringify(input.body)
	}

	const response = await kitFetch(auth, url, init)
	const text = await response.text()
	let parsed: unknown = text
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}

	if (!response.ok) {
		const firstMessage =
			kitErrorMessage(parsed) ||
			(typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText)
		throw new KitApiError(
			[
				`Kit ${input.method} ${input.path} failed (${response.status}): ${firstMessage}.`,
				nextStepForAuth(auth),
			].join(' '),
			{
				status: response.status,
				body: parsed,
				method: input.method,
				path: input.path,
			},
		)
	}

	return {
		data: parsed as T,
		pageInfo: pageInfoFrom(parsed),
		status: response.status,
	}
}

export async function kitListAll<T>(
	input: KitAuthInput & {
		path: string
		itemKey: string
		query?: JsonRecord
		maxItems?: number
	},
): Promise<Array<T>> {
	const maxItems = input.maxItems ?? 25
	const items: Array<T> = []
	let after: string | undefined
	for (;;) {
		const remaining = maxItems - items.length
		const perPage = Math.min(100, remaining)
		const page = await kitRequest<JsonRecord>({
			...input,
			method: 'GET',
			path: input.path,
			query: {
				...(input.query ?? {}),
				per_page: perPage,
				...(after ? { after } : {}),
			},
		})
		const chunk = page.data?.[input.itemKey]
		if (Array.isArray(chunk)) items.push(...(chunk as Array<T>))
		if (items.length >= maxItems || !page.pageInfo.hasNextPage || !page.pageInfo.after) {
			return items.slice(0, maxItems)
		}
		after = page.pageInfo.after
	}
}

export function unwrapRecord<T extends JsonRecord>(
	body: unknown,
	key: string,
	action: string,
): T {
	if (!body || typeof body !== 'object') {
		throw new Error(`${action}: Kit returned no ${key}.`)
	}
	const value = (body as JsonRecord)[key]
	if (!value || typeof value !== 'object' || Array.isArray(value)) {
		throw new Error(`${action}: Kit returned no ${key}.`)
	}
	return value as T
}

export { isMutatingMethod }