Skip to content

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

Package listing

@kentcdodds/cloudflare

src/api-v4.ts

121 lines · 3.7 KB · TypeScript
import {
  cloudflareAuthHeaders,
  pickAuthOptions,
  type CloudflareAuthOptions,
} from './auth.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>
  body?: unknown
}

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

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

function assertSafePath(path) {
  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) {
  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/`.
 * @param params.path - Relative path starting with `/client/v4/`.
 * @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: [], ... }
 */
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 headers = cloudflareAuthHeaders(pickAuthOptions(params))
  const init = { method, headers }
  if (params.body !== undefined && method !== 'GET') {
    headers['Content-Type'] = 'application/json'
    init.body = JSON.stringify(params.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 + ').')
  }
}