Skip to content

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

Package listing

@kody/zendesk

src/client.ts

196 lines · 5.4 KB · TypeScript
import { resolveZendeskAuth, zendeskFetch, type ResolvedZendeskAuth } from './auth.ts'
import { extractPageInfo } from './models.ts'
import type {
	DryRunResult,
	JsonRecord,
	MutationInput,
	ZendeskAuthInput,
	ZendeskPageInfo,
} from './types.ts'
import { requireString } from './types.ts'
import {
	inferOperationFromPath,
	isMutatingMethod,
	isSearchPath,
	mutationPreview,
	nextStepForOperation,
	normalizeZendeskPath,
	permissionForOperation,
	zendeskUrl,
	type ZendeskOperation,
} from './setup.ts'

export class ZendeskApiError extends Error {
	readonly status: number
	readonly operation: ZendeskOperation
	readonly details: unknown
	readonly permission: string

	constructor(
		message: string,
		options: {
			status: number
			operation: ZendeskOperation
			details: unknown
			permission: string
		},
	) {
		super(message)
		this.name = 'ZendeskApiError'
		this.status = options.status
		this.operation = options.operation
		this.details = options.details
		this.permission = options.permission
	}
}

export type ZendeskRequestInput = ZendeskAuthInput &
	MutationInput & {
		path: string
		method?: string
		query?: JsonRecord
		body?: JsonRecord
		operation?: ZendeskOperation
	}

export type ZendeskResponse<T> = {
	data: T
	pageInfo: ZendeskPageInfo
	status: number
}

function zendeskErrorMessage(body: unknown): string | null {
	if (typeof body === 'string' && body.trim().length > 0) {
		return body.trim().slice(0, 400)
	}
	if (!body || typeof body !== 'object') return null
	const record = body as {
		error?: unknown
		description?: unknown
		details?: unknown
		message?: unknown
	}
	if (typeof record.description === 'string' && record.description.length > 0) {
		return record.description
	}
	if (typeof record.error === 'string' && record.error.length > 0) return record.error
	if (typeof record.message === 'string' && record.message.length > 0) return record.message
	if (record.details && typeof record.details === 'object') {
		const first = Object.values(record.details as JsonRecord)[0]
		if (Array.isArray(first) && first[0] && typeof first[0] === 'object') {
			const description = (first[0] as { description?: unknown }).description
			if (typeof description === 'string' && description.length > 0) return description
		}
	}
	return null
}

function looksLikeInsufficientPermission(status: number, body: unknown): boolean {
	if (status === 401 || status === 403) return true
	const message = (zendeskErrorMessage(body) ?? '').toLowerCase()
	return (
		message.includes('unauthorized') ||
		message.includes('forbidden') ||
		message.includes('not authorized') ||
		message.includes('permission') ||
		message.includes('invalid access token') ||
		message.includes('scope')
	)
}

export async function zendeskRequest<T = unknown>(
	input: ZendeskRequestInput,
): Promise<ZendeskResponse<T> | DryRunResult> {
	const method = (input.method ?? 'GET').toUpperCase()
	const path = normalizeZendeskPath(requireString(input.path, 'path'), input.subdomain)
	const operation = inferOperationFromPath(method, path, input.operation)
	const mutating = isMutatingMethod(method) && !isSearchPath(path)
	if (mutating) {
		const authForUrl = await resolveZendeskAuth(input).catch(() => null)
		const subdomain = authForUrl?.subdomain ?? input.subdomain
		const url = subdomain
			? zendeskUrl(path, input.query, subdomain)
			: `https://your-subdomain.zendesk.com/api/v2${path}`
		const preview = mutationPreview(input, {
			method,
			path,
			url,
			body: input.body,
		})
		if (preview) return preview
	}
	const auth = await resolveZendeskAuth(input)
	return zendeskRequestWithAuth<T>(auth, {
		method,
		path,
		query: input.query,
		body: input.body,
		operation,
	})
}

export async function zendeskRequestWithAuth<T = unknown>(
	auth: ResolvedZendeskAuth,
	input: {
		method: string
		path: string
		query?: JsonRecord
		body?: JsonRecord
		operation: ZendeskOperation
	},
): Promise<ZendeskResponse<T>> {
	const url = zendeskUrl(input.path, input.query, auth.subdomain)
	const headers = new Headers({
		Accept: 'application/json',
		'User-Agent': 'kody-zendesk/1.0',
	})
	const init: RequestInit = { method: input.method, headers }
	if (input.body !== undefined) {
		headers.set('Content-Type', 'application/json')
		init.body = JSON.stringify(input.body)
	}

	const response = await zendeskFetch(auth, url, init)
	const text = await response.text()
	let parsed: unknown = text
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}

	if (!response.ok || looksLikeInsufficientPermission(response.status, parsed)) {
		const permission = permissionForOperation(input.operation)
		const firstMessage =
			zendeskErrorMessage(parsed) ||
			(typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText)
		throw new ZendeskApiError(
			[
				`Zendesk ${input.operation} failed (${response.status}): ${firstMessage}.`,
				`This call needs the "${permission}" scope (or read/write).`,
				nextStepForOperation(input.operation, {
					authMode: auth.mode,
					integrationName: auth.integrationName,
					secretName: auth.secretName,
					usernameSecretName: auth.usernameSecretName,
					subdomain: auth.subdomain,
					email: auth.email,
				}),
			].join(' '),
			{
				status: response.status,
				operation: input.operation,
				details: parsed,
				permission,
			},
		)
	}

	return {
		data: parsed as T,
		pageInfo: extractPageInfo(parsed),
		status: response.status,
	}
}

export { inferOperationFromPath, isMutatingMethod, isSearchPath }