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<string, CanvaScalar>
query?: Record<string, CanvaScalar | Array<CanvaScalar> | null | undefined>
headers?: Record<string, CanvaScalar>
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<typeof fetch> {
if (!authenticatedFetch) {
authenticatedFetch = await createAuthenticatedFetch(CANVA_INTEGRATION)
}
return authenticatedFetch
}
function inputRecord(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${name} must be an object.`)
}
return value as Record<string, unknown>
}
function buildPath(
template: string,
params: CanvaApiInput['params'] = {},
): string {
const used = new Set<string>()
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<unknown> {
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<CanvaApiResponse | CanvaDryRun> {
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<CanvaApiResponse | CanvaDryRun> {
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,
})
}