/** * Call a Dropbox RPC, upload, or download endpoint with the saved OAuth * integration. Pick this as the generic escape hatch when a dedicated export * does not exist. Unknown and mutating endpoints require `confirm: true`. * * @param params.endpoint - Dropbox API path such as `files/get_metadata`. * @param params.kind - `rpc` (default), `upload`, or `download`. * @param params.body - JSON body for RPC calls. * @param params.apiArg - Dropbox-API-Arg object for content endpoints. * @param params.bytesBase64 - File bytes for `kind: 'upload'`. * @param params.confirm - Required `true` for unknown or mutating endpoints. * @param params.dryRun - When true, return the planned request without calling Dropbox. * @returns `{ ok, status, body, headers, requestId }` (or the parsed JSON body for some helpers). * * @example * import request from 'kody:@kentcdodds/dropbox/request' * const result = await request({ * endpoint: 'files/get_metadata', * body: { path: '/notes/todo.txt' }, * }) */ import { createAuthenticatedFetch } from 'kody:runtime' import { decodeBase64, encodeBase64 } from './base64.ts' import type { DropboxRequestInput, DropboxRequestKind, DropboxRequestResult, JsonRecord, } from './types.ts' import { requireRecord, requireString } from './types.ts' export const DROPBOX_INTEGRATION = 'dropbox' export const DROPBOX_API_ORIGIN = 'https://api.dropboxapi.com' export const DROPBOX_CONTENT_ORIGIN = 'https://content.dropboxapi.com' const READ_ONLY_RPC_ENDPOINTS = new Set([ 'check/app', 'check/user', 'file_properties/properties/search', 'file_requests/count', 'file_requests/get', 'file_requests/list_v2', 'files/get_file_lock_batch', 'files/get_metadata', 'files/list_folder', 'files/list_folder/continue', 'files/list_folder/get_latest_cursor', 'files/search/continue_v2', 'files/search_v2', 'paper/docs/download', 'sharing/check_job_status', 'sharing/get_file_metadata', 'sharing/get_folder_metadata', 'sharing/get_shared_link_metadata', 'sharing/list_file_members', 'sharing/list_file_members/batch', 'sharing/list_folder_members', 'sharing/list_folder_members/continue', 'sharing/list_folders', 'sharing/list_folders/continue', 'sharing/list_mountable_folders', 'sharing/list_received_files', 'sharing/list_received_files/continue', 'sharing/list_shared_links', 'team/features/get_values', 'users/get_account', 'users/get_account_batch', 'users/get_current_account', 'users/get_space_usage', ]) export class DropboxApiError extends Error { readonly status: number readonly statusText: string readonly requestId: string | null readonly details: unknown constructor( message: string, options: { status: number statusText: string requestId: string | null details: unknown }, ) { super(message) this.name = 'DropboxApiError' this.status = options.status this.statusText = options.statusText this.requestId = options.requestId this.details = options.details } } let authenticatedFetch: typeof fetch | null = null async function getAuthenticatedFetch(): Promise { if (!authenticatedFetch) { authenticatedFetch = await createAuthenticatedFetch(DROPBOX_INTEGRATION) } return authenticatedFetch } /** * Call a Dropbox RPC, upload, or download endpoint with the saved OAuth * integration. Pick this as the generic escape hatch when a dedicated export * does not exist. Unknown and mutating endpoints require `confirm: true`. * * @param params.endpoint - Dropbox API path such as `files/get_metadata`. * @param params.kind - `rpc` (default), `upload`, or `download`. * @param params.body - JSON body for RPC calls. * @param params.apiArg - Dropbox-API-Arg object for content endpoints. * @param params.bytesBase64 - File bytes for `kind: 'upload'`. * @param params.confirm - Required `true` for unknown or mutating endpoints. * @param params.dryRun - When true, return the planned request without calling Dropbox. * @returns `{ ok, status, body, headers, requestId }` (or the parsed JSON body for some helpers). * * @example * import request from 'kody:@kentcdodds/dropbox/request' * const result = await request({ * endpoint: 'files/get_metadata', * body: { path: '/notes/todo.txt' }, * }) */ export function normalizeEndpoint(endpoint: string): string { const normalized = requireString(endpoint, 'endpoint').replace(/^\/+/, '').replace(/^2\//, '') if (!/^[a-z0-9_./-]+$/i.test(normalized) || normalized.includes('..')) { throw new Error('endpoint must be a Dropbox API path such as "files/get_metadata".') } return normalized } export function buildDropboxUrl(endpoint: string, kind: DropboxRequestKind = 'rpc'): string { const normalized = normalizeEndpoint(endpoint) switch (kind) { case 'rpc': return `${DROPBOX_API_ORIGIN}/2/${normalized}` case 'upload': case 'download': return `${DROPBOX_CONTENT_ORIGIN}/2/${normalized}` default: { const exhaustiveKind: never = kind throw new Error(`Unsupported Dropbox request kind: ${String(exhaustiveKind)}`) } } } export function isKnownReadOnlyRequest( endpoint: string, kind: DropboxRequestKind = 'rpc', ): boolean { if (kind === 'download') return true if (kind === 'upload') return false return READ_ONLY_RPC_ENDPOINTS.has(normalizeEndpoint(endpoint)) } export async function rawDropboxRequest(input: DropboxRequestInput): Promise { const kind = input.kind ?? 'rpc' const endpoint = normalizeEndpoint(input.endpoint) const headers = new Headers(input.headers) headers.set('accept', 'application/json') let body: BodyInit | null switch (kind) { case 'rpc': headers.set('content-type', 'application/json') body = JSON.stringify(input.body ?? null) break case 'upload': if (!input.apiArg) throw new Error('apiArg is required for Dropbox upload requests.') if (typeof input.bytesBase64 !== 'string') { throw new Error('bytesBase64 is required for Dropbox upload requests.') } headers.set('content-type', 'application/octet-stream') headers.set('dropbox-api-arg', JSON.stringify(input.apiArg)) body = decodeBase64(input.bytesBase64) break case 'download': if (!input.apiArg) throw new Error('apiArg is required for Dropbox download requests.') headers.set('dropbox-api-arg', JSON.stringify(input.apiArg)) body = null break default: { const exhaustiveKind: never = kind throw new Error(`Unsupported Dropbox request kind: ${String(exhaustiveKind)}`) } } const authedFetch = await getAuthenticatedFetch() return authedFetch(buildDropboxUrl(endpoint, kind), { method: 'POST', headers, body, }) } export async function dropboxRequestWithResponse( input: DropboxRequestInput, ): Promise { const kind = input.kind ?? 'rpc' const response = await rawDropboxRequest(input) const requestId = response.headers.get('x-dropbox-request-id') const headers = selectResponseHeaders(response.headers) if (!response.ok) { const details = await parseErrorResponse(response) throw new DropboxApiError( `Dropbox API request failed: ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText, requestId, details, }, ) } const body = kind === 'download' ? await parseDownloadResponse(response) : await parseJsonResponse(response) return { ok: true, status: response.status, statusText: response.statusText, requestId, headers, body, } } export async function dropboxRpc(endpoint: string, body: unknown = null): Promise { const result = await dropboxRequestWithResponse({ endpoint, kind: 'rpc', body }) return result.body as T } export async function dropboxDownload(apiArg: JsonRecord): Promise { return dropboxRequestWithResponse({ endpoint: 'files/download', kind: 'download', apiArg, }) } export async function dropboxUpload( apiArg: JsonRecord, bytesBase64: string, ): Promise { return dropboxRequestWithResponse({ endpoint: 'files/upload', kind: 'upload', apiArg, bytesBase64, }) } export async function request( input: DropboxRequestInput, ): Promise { const endpoint = normalizeEndpoint(input.endpoint) const kind = input.kind ?? 'rpc' const url = buildDropboxUrl(endpoint, kind) if (input.dryRun) { return { dryRun: true, endpoint, kind, url, body: input.body ?? null, apiArg: input.apiArg ?? null, byteLength: typeof input.bytesBase64 === 'string' ? decodeBase64(input.bytesBase64).byteLength : null, authorization: '[managed dropbox OAuth integration]', } } if (!isKnownReadOnlyRequest(endpoint, kind) && input.confirm !== true) { throw new Error( 'This Dropbox request may mutate data. Pass confirm: true only after explicit user approval, or use dryRun: true.', ) } return dropboxRequestWithResponse({ ...input, endpoint, kind }) } async function parseDownloadResponse(response: Response): Promise { const metadataHeader = response.headers.get('dropbox-api-result') const bytes = await response.arrayBuffer() return { metadata: metadataHeader ? JSON.parse(metadataHeader) : null, bytesBase64: encodeBase64(new Uint8Array(bytes)), byteLength: bytes.byteLength, contentType: response.headers.get('content-type'), } } async function parseJsonResponse(response: Response): Promise { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } async function parseErrorResponse(response: Response): Promise { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } function selectResponseHeaders(headers: Headers): Record { const selected = [ 'content-length', 'content-type', 'dropbox-api-result', 'x-dropbox-request-id', ] const output: Record = {} for (const key of selected) { const value = headers.get(key) if (value) output[key] = value } return output } /** * Call a Dropbox RPC, upload, or download endpoint with the saved OAuth integration. * Unknown and mutating endpoints require `confirm: true`; use `dryRun: true` to preview. * @example * import request from 'kody:@kentcdodds/dropbox/request' * const result = await request({ * endpoint: 'files/get_metadata', * body: { path: '/notes/todo.txt' }, * }) */ export default async function requestEntrypoint( params: Partial & Record = {}, ) { const input = requireRecord(params, 'request') requireString(input.endpoint, 'endpoint') return request(input as DropboxRequestInput) }