Skip to content

src/request-body.ts

111 lines · 4.2 KB · TypeScript
Raw
/**
 * Request body builders for Cloudflare API v4 calls: JSON, multipart form, or
 * raw bytes/text. Pure (no network).
 */

import { base64ToBytes } from './static-site.ts'

/**
 * One multipart field: a plain string, or a file part. File parts take UTF-8
 * `content` or `base64` bytes, plus optional `contentType`.
 */
export type FormFieldValue =
	| string
	| {
			filename: string
			content?: string
			base64?: string
			contentType?: string
	  }

export type FormFields = Record<string, FormFieldValue>

/** Raw body: text sent as-is, bytes, or `{ base64 }` for JSON callers. */
export type RawBody = string | Uint8Array | ArrayBuffer | { base64: string }

export type BuiltBody = {
	body: BodyInit | undefined
	/** Content-Type to set, or `null` to let fetch set it (multipart boundary). */
	contentType: string | null
	kind: 'none' | 'json' | 'form' | 'raw'
}

/** Build a FormData from string / file fields. */
export function buildFormData(fields: FormFields): FormData {
	if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
		throw new Error('form must be an object of field names to string or { filename, content | base64 }.')
	}
	const form = new FormData()
	for (const [name, value] of Object.entries(fields)) {
		if (typeof value === 'string') {
			form.append(name, value)
			continue
		}
		if (!value || typeof value !== 'object' || typeof value.filename !== 'string' || !value.filename) {
			throw new Error(`form["${name}"] must be a string or { filename, content | base64, contentType? }.`)
		}
		const unknown = Object.keys(value).filter(
			(key) => !['filename', 'content', 'base64', 'contentType'].includes(key),
		)
		if (unknown.length) {
			throw new Error(`form["${name}"] has unknown key(s): ${unknown.join(', ')}.`)
		}
		const hasContent = typeof value.content === 'string'
		const hasBase64 = typeof value.base64 === 'string'
		if (hasContent === hasBase64) {
			throw new Error(`form["${name}"] needs exactly one of content or base64.`)
		}
		const bytes = hasBase64 ? base64ToBytes(value.base64 as string) : new TextEncoder().encode(value.content)
		const type = value.contentType ?? (hasBase64 ? 'application/octet-stream' : 'text/plain')
		form.append(name, new File([bytes as BlobPart], value.filename, { type }))
	}
	return form
}

/** Normalize a raw body into something fetch accepts. */
export function buildRawBody(raw: RawBody): BodyInit {
	if (typeof raw === 'string') return raw
	if (raw instanceof Uint8Array) return raw as BodyInit
	if (raw instanceof ArrayBuffer) return new Uint8Array(raw) as BodyInit
	if (raw && typeof raw === 'object' && typeof (raw as { base64?: unknown }).base64 === 'string') {
		return base64ToBytes((raw as { base64: string }).base64) as BodyInit
	}
	throw new Error('rawBody must be a string, Uint8Array, ArrayBuffer, or { base64 }.')
}

/**
 * Pick exactly one of `body` (JSON), `form` (multipart), or `rawBody`.
 * GET requests never carry a body (a JSON `body` on GET is ignored, as before;
 * `form` / `rawBody` on GET throw).
 */
export function buildRequestBody(
	params: { body?: unknown; form?: FormFields; rawBody?: RawBody; contentType?: string },
	method: string,
): BuiltBody {
	const provided = (['body', 'form', 'rawBody'] as const).filter(
		(key) => params[key] !== undefined,
	)
	if (provided.length > 1) {
		throw new Error(`Pass only one of body, form, or rawBody (got ${provided.join(', ')}).`)
	}
	if (params.contentType !== undefined && provided[0] !== 'rawBody') {
		throw new Error('contentType only applies with rawBody (JSON and form set their own).')
	}
	if (provided.length === 0) return { body: undefined, contentType: null, kind: 'none' }
	if (method === 'GET') {
		// Historical api-v4 behavior: a JSON body on GET is ignored.
		if (provided[0] === 'body') return { body: undefined, contentType: null, kind: 'none' }
		throw new Error(`GET requests cannot send ${provided[0]}.`)
	}
	if (provided[0] === 'form') {
		return { body: buildFormData(params.form as FormFields), contentType: null, kind: 'form' }
	}
	if (provided[0] === 'rawBody') {
		return {
			body: buildRawBody(params.rawBody as RawBody),
			contentType: params.contentType ?? 'application/octet-stream',
			kind: 'raw',
		}
	}
	return { body: JSON.stringify(params.body), contentType: 'application/json', kind: 'json' }
}