Skip to content

src/api-v4-list.ts

163 lines · 5.8 KB · TypeScript
Raw
import cloudflareApiV4, { type CloudflareApiV4Result } from './api-v4.ts'
import type { CloudflareAuthOptions } from './auth.ts'

export type CloudflareApiV4ListInput = CloudflareAuthOptions & {
	/** List endpoint path starting with `/client/v4/`. */
	path: string
	/** Extra query params. `page`, `per_page`, and `cursor` are managed here. */
	query?: Record<string, string | number | boolean | null | undefined>
	/**
	 * Page size. Omit to use the endpoint's default (safest: Pages project
	 * lists reject `per_page` above 10 with error 8000024).
	 */
	perPage?: number
	/** Stop after this many requests (default 50). */
	maxPages?: number
	/** Stop after collecting this many items (default unlimited). */
	maxItems?: number
	/**
	 * Property holding the array when `result` is an object, e.g. `buckets`
	 * for R2. Auto-detected when `result` has exactly one array property.
	 */
	itemsKey?: string
}

export type CloudflareApiV4ListResult<T = unknown> = {
	ok: boolean
	items: T[]
	/** Requests made. */
	pages: number
	/** `result_info.total_count` when the endpoint reports it. */
	totalCount: number | null
	/** True when maxPages / maxItems stopped the walk before the end. */
	truncated: boolean
	httpStatus: number | null
	errors: unknown[]
}

type ResultInfo = {
	page?: number
	per_page?: number
	total_pages?: number
	total_count?: number
	count?: number
	cursor?: string
	cursors?: { after?: string }
}

const MANAGED_QUERY_KEYS = ['page', 'per_page', 'cursor']

/** Pull the item array out of a list response (`result` array or one array property). */
export function extractListItems(result: unknown, itemsKey?: string): unknown[] {
	if (Array.isArray(result)) return result
	if (!result || typeof result !== 'object') return []
	const record = result as Record<string, unknown>
	if (itemsKey) {
		const value = record[itemsKey]
		if (!Array.isArray(value)) {
			throw new Error(`result.${itemsKey} is not an array.`)
		}
		return value
	}
	const arrays = Object.values(record).filter(Array.isArray)
	return arrays.length === 1 ? (arrays[0] as unknown[]) : []
}

/**
 * Decide the next request from `result_info`: a cursor, the next page number,
 * or `null` when the list is done.
 */
export function nextPageQuery(
	info: ResultInfo | null | undefined,
	state: { page: number; received: number; perPage?: number },
): { cursor: string } | { page: number } | null {
	if (!info || typeof info !== 'object') return null
	const cursor = info.cursor || info.cursors?.after
	if (typeof cursor === 'string' && cursor) {
		return state.received === 0 ? null : { cursor }
	}
	if (typeof info.total_pages === 'number') {
		const current = typeof info.page === 'number' ? info.page : state.page
		return current < info.total_pages ? { page: current + 1 } : null
	}
	const size = state.perPage ?? info.per_page
	if (typeof info.page === 'number' && typeof size === 'number' && state.received >= size) {
		return { page: info.page + 1 }
	}
	return null
}

/**
 * Fetch every page of a Cloudflare API v4 list endpoint into one array.
 * Handles page-number (`result_info.total_pages`) and cursor
 * (`result_info.cursor`) pagination. Read-only (`GET`).
 *
 * @param params.path - List path under `/client/v4/`.
 * @param params.perPage - Page size; omit for the endpoint default (Pages max is 10).
 * @param params.maxPages - Request cap (default 50).
 * @param params.account - Token alias (`default`, `kody`, `pages`).
 * @returns `{ ok, items, pages, totalCount, truncated, errors }`.
 * @example
 * import listAll from 'kody:@kentcdodds/cloudflare/api-v4-list'
 * const { items } = await listAll({
 *   account: 'pages',
 *   path: '/client/v4/accounts/ACCOUNT_ID/pages/projects',
 * })
 * // => items: [{ name: 'my-site', subdomain: 'my-site.pages.dev', ... }, ...]
 */
export default async function cloudflareApiV4List<T = unknown>(
	params: CloudflareApiV4ListInput,
): Promise<CloudflareApiV4ListResult<T>> {
	if (!params || typeof params.path !== 'string') {
		throw new Error('path is required (starting with /client/v4/).')
	}
	const query = { ...(params.query ?? {}) }
	for (const key of MANAGED_QUERY_KEYS) {
		if (key in query) throw new Error(`Do not pass "${key}" in query; the list helper manages it.`)
	}
	if (params.perPage !== undefined && (!Number.isInteger(params.perPage) || params.perPage < 1)) {
		throw new Error('perPage must be a positive integer.')
	}
	const maxPages = params.maxPages ?? 50
	const maxItems = params.maxItems ?? Number.POSITIVE_INFINITY
	const items: T[] = []
	let pages = 0
	let totalCount: number | null = null
	let httpStatus: number | null = null
	let next: { cursor: string } | { page: number } | null = { page: 1 }
	let truncated = false

	while (next) {
		if (pages >= maxPages || items.length >= maxItems) {
			truncated = true
			break
		}
		const pageQuery: Record<string, string | number | boolean | null | undefined> = { ...query }
		if ('cursor' in next) pageQuery.cursor = next.cursor
		else if (next.page > 1) pageQuery.page = next.page
		if (params.perPage !== undefined) pageQuery.per_page = params.perPage
		const response: CloudflareApiV4Result = await cloudflareApiV4({
			account: params.account,
			apiTokenSecret: params.apiTokenSecret,
			method: 'GET',
			path: params.path,
			query: pageQuery,
		})
		pages += 1
		httpStatus = response.httpStatus
		if (!response.success) {
			return { ok: false, items, pages, totalCount, truncated: false, httpStatus, errors: response.errors }
		}
		const batch = extractListItems(response.result, params.itemsKey) as T[]
		items.push(...batch)
		const info = (response.resultInfo ?? null) as ResultInfo | null
		if (info && typeof info.total_count === 'number') totalCount = info.total_count
		next = nextPageQuery(info, {
			page: 'page' in next ? next.page : pages,
			received: batch.length,
			perPage: params.perPage,
		})
	}
	if (items.length > maxItems) items.length = maxItems
	return { ok: true, items, pages, totalCount, truncated, httpStatus, errors: [] }
}