import { asanaFetch, resolveAsanaAuth, type ResolvedAsanaAuth } from './auth.ts' import type { AsanaAuthInput, AsanaPageInfo, JsonRecord } from './types.ts' import { requireString } from './types.ts' import { ASANA_API_BASE_URL, inferOperationFromPath, isMutatingMethod, nextStepForScope, scopeForOperation, type AsanaOperation, } from './setup.ts' export class AsanaApiError extends Error { readonly status: number readonly operation: AsanaOperation readonly details: unknown readonly missingScope: string | null constructor( message: string, options: { status: number operation: AsanaOperation details: unknown missingScope: string | null }, ) { super(message) this.name = 'AsanaApiError' this.status = options.status this.operation = options.operation this.details = options.details this.missingScope = options.missingScope } } export type AsanaRequestInput = AsanaAuthInput & { path: string method?: string query?: JsonRecord body?: JsonRecord operation?: AsanaOperation } export type AsanaResponse = { data: T pageInfo: AsanaPageInfo status: number } function asanaErrors(body: unknown): Array<{ message?: string; help?: string }> { if (!body || typeof body !== 'object') return [] const errors = (body as { errors?: unknown }).errors if (!Array.isArray(errors)) return [] return errors as Array<{ message?: string; help?: string }> } function looksLikeInsufficientScope( status: number, errors: Array<{ message?: string }>, ): boolean { if (status === 401 || status === 403) return true return errors.some((error) => { const message = (error.message ?? '').toLowerCase() return ( message.includes('insufficient') || message.includes('missing scope') || message.includes('not authorized') || message.includes('forbidden') || message.includes('do not have permission') || message.includes("don't have permission") ) }) } export function normalizeAsanaPath(path: string): string { const trimmed = requireString(path, 'path') if (trimmed.startsWith('https://')) { const url = new URL(trimmed) if (url.host !== 'app.asana.com') { throw new Error(`Asana requests must use host ${'app.asana.com'}. Got ${url.host}.`) } return `${url.pathname}${url.search}` } if (trimmed.startsWith('/api/1.0/')) return trimmed if (trimmed.startsWith('/api/1.0')) return trimmed return trimmed.startsWith('/') ? `/api/1.0${trimmed}` : `/api/1.0/${trimmed}` } export function asanaUrl(path: string, query?: JsonRecord): string { const normalized = normalizeAsanaPath(path) const url = new URL(normalized, 'https://app.asana.com') if (query) { for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue if (typeof value === 'boolean' || typeof value === 'number') { url.searchParams.set(key, String(value)) continue } if (typeof value === 'string') { url.searchParams.set(key, value) continue } throw new Error(`query.${key} must be a string, number, or boolean.`) } } return url.toString() } export async function asanaRequest( input: AsanaRequestInput, ): Promise> { const method = (input.method ?? 'GET').toUpperCase() const path = requireString(input.path, 'path') const operation = inferOperationFromPath(method, normalizeAsanaPath(path), input.operation) const auth = await resolveAsanaAuth(input) return asanaRequestWithAuth(auth, { method, path, query: input.query, body: input.body, operation, }) } export async function asanaRequestWithAuth( auth: ResolvedAsanaAuth, input: { method: string path: string query?: JsonRecord body?: JsonRecord operation: AsanaOperation }, ): Promise> { const url = asanaUrl(input.path, input.query) const headers = new Headers({ Accept: 'application/json', 'User-Agent': 'kody-asana/1.0', }) const init: RequestInit = { method: input.method, headers } if (input.body !== undefined) { headers.set('Content-Type', 'application/json') init.body = JSON.stringify({ data: input.body }) } const response = await asanaFetch(auth, url, init) const text = await response.text() let parsed: unknown = text try { parsed = text ? JSON.parse(text) : null } catch { parsed = text } const errors = asanaErrors(parsed) if (!response.ok || looksLikeInsufficientScope(response.status, errors)) { const missingScope = scopeForOperation(input.operation) const firstMessage = errors[0]?.message || (typeof parsed === 'string' ? parsed.slice(0, 400) : response.statusText) throw new AsanaApiError( [ `Asana ${input.operation} failed (${response.status}): ${firstMessage}.`, `This call needs the "${missingScope}" permission.`, nextStepForScope(missingScope, { authMode: auth.mode, integrationName: auth.integrationName, secretName: auth.secretName, }), ].join(' '), { status: response.status, operation: input.operation, details: parsed, missingScope, }, ) } if (errors.length > 0) { throw new AsanaApiError(`Asana API error: ${errors[0].message || 'unknown error'}`, { status: response.status, operation: input.operation, details: parsed, missingScope: null, }) } const record = parsed && typeof parsed === 'object' ? (parsed as JsonRecord) : {} const nextPage = record.next_page && typeof record.next_page === 'object' ? (record.next_page as { offset?: unknown }) : null return { data: record.data as T, pageInfo: { hasNextPage: Boolean(nextPage?.offset), nextOffset: typeof nextPage?.offset === 'string' ? nextPage.offset : null, }, status: response.status, } } export { isMutatingMethod, inferOperationFromPath }