Skip to content

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

Package listing

@kentcdodds/canva

src/client.ts

248 lines · 6.5 KB · TypeScript
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,
	})
}