import { createAuthenticatedFetch } from 'kody:runtime' import { canvaOperations, isCanvaOperation, isMutation, type CanvaOperation, type CanvaOperationDefinition, } from './operations.ts' export const CANVA_API_BASE_URL = 'https://api.canva.com/rest' export const CANVA_INTEGRATION = 'canva' type CanvaScalar = string | number | boolean export type CanvaApiInput = { params?: Record query?: Record | null | undefined> headers?: Record body?: unknown confirm?: boolean dryRun?: boolean } export type CanvaApiResponse = { ok: true status: number contentType: string | null body: unknown } export type CanvaDryRun = { dryRun: true operation: CanvaOperation method: string path: string request: { params: CanvaApiInput['params'] query: CanvaApiInput['query'] headers: CanvaApiInput['headers'] body: unknown } } type CanvaErrorDetails = { status: number body: unknown retryAfter: string | null } export class CanvaApiError extends Error { readonly operation: CanvaOperation readonly status: number readonly body: unknown readonly retryAfter: string | null constructor(operation: CanvaOperation, details: CanvaErrorDetails) { const retryMessage = details.status === 429 && details.retryAfter ? ` Retry after ${details.retryAfter}.` : '' super(`Canva ${operation} failed with HTTP ${details.status}.${retryMessage}`) this.name = 'CanvaApiError' this.operation = operation this.status = details.status this.body = details.body this.retryAfter = details.retryAfter } } let authenticatedFetch: typeof fetch | null = null async function getAuthenticatedFetch(): Promise { if (!authenticatedFetch) { authenticatedFetch = await createAuthenticatedFetch(CANVA_INTEGRATION) } return authenticatedFetch } function inputRecord(value: unknown, name: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${name} must be an object.`) } return value as Record } function buildPath( template: string, params: CanvaApiInput['params'] = {}, ): string { const used = new Set() const path = template.replace(/\{([^}]+)\}/g, (_, key: string) => { const value = params[key] if (value === undefined || value === null || String(value).length === 0) { throw new Error(`Missing required Canva path parameter: ${key}.`) } used.add(key) return encodeURIComponent(String(value)) }) const unexpected = Object.keys(params).filter((key) => !used.has(key)) if (unexpected.length > 0) { throw new Error(`Unexpected Canva path parameter(s): ${unexpected.join(', ')}.`) } return path } function addQuery( url: URL, query: CanvaApiInput['query'] = {}, ): void { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item)) continue } url.searchParams.set(key, String(value)) } } function requestHeaders( input: CanvaApiInput, contentType: string | undefined, ): Headers { const headers = new Headers({ accept: 'application/json' }) for (const [key, value] of Object.entries(input.headers ?? {})) { if (key.toLowerCase() === 'authorization') continue headers.set(key, String(value)) } if (contentType && input.body !== undefined && !headers.has('content-type')) { headers.set('content-type', contentType) } return headers } function requestBody( input: CanvaApiInput, contentType: string | undefined, ): BodyInit | undefined { if (input.body === undefined) return undefined if (contentType === 'application/json') return JSON.stringify(input.body) return input.body as BodyInit } async function responseBody(response: Response): Promise { if (response.status === 204) return null const text = await response.text() if (!text) return null const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('json')) { try { return JSON.parse(text) } catch { return text } } return text } export function previewCanvaOperation( operation: CanvaOperation, input: CanvaApiInput, ): CanvaDryRun { const definition: CanvaOperationDefinition = canvaOperations[operation] return { dryRun: true, operation, method: definition.method, path: definition.path, request: { params: input.params, query: input.query, headers: input.headers, body: input.body, }, } } export async function runCanvaOperation( operation: CanvaOperation, input: CanvaApiInput = {}, ): Promise { inputRecord(input, 'input') const definition: CanvaOperationDefinition = canvaOperations[operation] if (isMutation(operation)) { if (input.dryRun === true) return previewCanvaOperation(operation, input) if (input.confirm !== true) { throw new Error( `${operation} mutates Canva data and requires confirm: true after explicit user approval. Use dryRun: true to preview.`, ) } } const url = new URL(CANVA_API_BASE_URL + buildPath(definition.path, input.params)) addQuery(url, input.query) const fetchCanva = await getAuthenticatedFetch() const response = await fetchCanva(url.toString(), { method: definition.method, headers: requestHeaders(input, definition.contentType), body: requestBody(input, definition.contentType), }) const body = await responseBody(response) if (!response.ok) { throw new CanvaApiError(operation, { status: response.status, body, retryAfter: response.headers.get('retry-after'), }) } return { ok: true, status: response.status, contentType: response.headers.get('content-type'), body, } } export function createCanvaOperation(operation: CanvaOperation) { return (input: CanvaApiInput = {}) => runCanvaOperation(operation, input) } export type CanvaDispatchInput = { operation: CanvaOperation input?: CanvaApiInput confirm?: boolean dryRun?: boolean } export async function dispatchCanvaOperation( params: CanvaDispatchInput, ): Promise { const input = inputRecord(params, 'params') if (!isCanvaOperation(input.operation)) { throw new Error('operation must be one of the exported Canva operation names.') } const operationInput = input.input === undefined ? {} : (inputRecord(input.input, 'input') as CanvaApiInput) return runCanvaOperation(input.operation, { ...operationInput, confirm: input.confirm === true || operationInput.confirm === true, dryRun: input.dryRun === true || operationInput.dryRun === true, }) }