Skip to content

src/api-v4.ts

163 lines · 5.4 KB · TypeScript
Raw
import {
	cloudflareAuthHeaders,
	pickAuthOptions,
	type CloudflareAuthOptions,
} from './auth.ts'
import {
	buildRequestBody,
	type FormFields,
	type RawBody,
} from './request-body.ts'

export type { FormFieldValue, FormFields, RawBody } from './request-body.ts'

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

export type CloudflareApiV4Input = CloudflareAuthOptions & {
	method?: HttpMethod | string
	path?: string
	query?: Record<string, string | number | boolean | null | undefined>
	/** JSON body (stringified, `Content-Type: application/json`). */
	body?: unknown
	/**
	 * Multipart form body. Values are strings or file parts
	 * `{ filename, content | base64, contentType? }`. Mutually exclusive with
	 * `body` and `rawBody`.
	 */
	form?: FormFields
	/**
	 * Raw request body: text, bytes, or `{ base64 }`. Sent with `contentType`
	 * (default `application/octet-stream`). Mutually exclusive with `body` and `form`.
	 */
	rawBody?: RawBody
	/** Content-Type for `rawBody` only. */
	contentType?: string
}

export type CloudflareApiV4Result = {
	status: number
	httpStatus: number
	body: unknown
	success: boolean
	result: unknown
	resultInfo: unknown
	errors: unknown[]
	messages: unknown[]
}

const ALLOWED_METHODS = new Set<string>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
const BASE_URL = 'https://api.cloudflare.com'

function assertSafePath(path: unknown): string {
	const trimmed = String(path || '').trim()
	if (!trimmed.startsWith('/')) {
		throw new Error('path must start with `/` and must not include a host.')
	}
	if (!trimmed.startsWith('/client/v4/')) {
		throw new Error('path must start with `/client/v4/`.')
	}
	if (trimmed.includes('..')) {
		throw new Error('path must not contain `..`.')
	}
	if (/\s|#/.test(trimmed)) {
		throw new Error('path contains disallowed characters.')
	}
	if (trimmed.length > 2048) {
		throw new Error('path exceeds maximum length.')
	}
	return trimmed
}

function normalizeMethod(method: unknown): string {
	const normalized = String(method || 'GET').trim().toUpperCase()
	if (!ALLOWED_METHODS.has(normalized)) {
		throw new Error('method must be one of GET, POST, PUT, PATCH, or DELETE.')
	}
	return normalized
}

/**
 * Call a Cloudflare API v4 endpoint under `/client/v4/` with the selected
 * token alias. Sends JSON (`body`), multipart (`form`), or raw bytes/text
 * (`rawBody` + `contentType`) — pass at most one.
 *
 * For lists, prefer `./api-v4-list` (auto-paginates). For static sites, prefer
 * `./publish-static` (handles the Pages upload JWT and multipart deploy). For
 * R2 object PUTs from a URL, `./r2-put-object` streams `sourceUrl` for you.
 *
 * @param params.path - Relative path starting with `/client/v4/`.
 * @param params.method - GET (default), POST, PUT, PATCH, or DELETE.
 * @param params.query - Query string params (null/undefined skipped).
 * @param params.body - JSON body.
 * @param params.form - Multipart fields: string or `{ filename, content | base64, contentType? }`.
 * @param params.rawBody - Raw body: string, bytes, or `{ base64 }`.
 * @param params.contentType - Content-Type for `rawBody`.
 * @param params.account - Token alias (`default`, `kody`, `pages`); defaults to `default`.
 * @param params.apiTokenSecret - Explicit Kody secret name when no alias fits (not a raw token).
 * @returns Parsed JSON body with `success`, `result`, `errors`, and HTTP status.
 * @example
 * import apiV4 from 'kody:@kentcdodds/cloudflare/api-v4'
 * const result = await apiV4({
 *   account: 'kody',
 *   method: 'POST',
 *   path: '/client/v4/accounts/ACCOUNT_ID/d1/database/DB_ID/query',
 *   body: { sql: 'SELECT 1 AS ok' },
 * })
 * // => { success: true, result: [ ... ], errors: [], ... }
 *
 * // Multipart (e.g. a Workers script or Pages deployment):
 * await apiV4({
 *   method: 'POST',
 *   path: '/client/v4/accounts/ACCOUNT_ID/pages/projects/my-site/deployments',
 *   account: 'pages',
 *   form: { manifest: '{}', _headers: { filename: '_headers', content: '/*\n  X-Robots-Tag: noindex' } },
 * })
 */
export default async function cloudflareApiV4(params: CloudflareApiV4Input = {}): Promise<CloudflareApiV4Result> {
	const method = normalizeMethod(params.method)
	const path = assertSafePath(params.path)
	const url = new URL(path, BASE_URL)
	const query = params.query && typeof params.query === 'object' ? params.query : null
	if (query) {
		for (const [key, value] of Object.entries(query)) {
			if (value === undefined || value === null) continue
			url.searchParams.set(String(key), String(value))
		}
	}

	const built = buildRequestBody(params, method)
	const headers = cloudflareAuthHeaders(pickAuthOptions(params))
	if (built.contentType) headers['Content-Type'] = built.contentType
	const init: RequestInit = { method, headers }
	if (built.body !== undefined) init.body = built.body

	const response = await fetch(url.toString(), init)
	const text = await response.text()
	if (response.status === 204 || !text.trim()) {
		return {
			status: response.status,
			httpStatus: response.status,
			body: null,
			success: response.ok,
			result: null,
			resultInfo: null,
			errors: [],
			messages: [],
		}
	}
	try {
		const body = JSON.parse(text)
		return {
			status: response.status,
			httpStatus: response.status,
			body,
			success: body.success === true,
			result: body.result,
			resultInfo: body.result_info,
			errors: body.errors || [],
			messages: body.messages || [],
		}
	} catch {
		throw new Error('Cloudflare API returned non-JSON (' + response.status + ').')
	}
}