Skip to content

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

Package listing

@kentcdodds/dropbox

src/request.ts

331 lines · 9.1 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
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<typeof fetch> {
	if (!authenticatedFetch) {
		authenticatedFetch = await createAuthenticatedFetch(DROPBOX_INTEGRATION)
	}
	return authenticatedFetch
}

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<Response> {
	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<DropboxRequestResult> {
	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<T>(endpoint: string, body: unknown = null): Promise<T> {
	const result = await dropboxRequestWithResponse({ endpoint, kind: 'rpc', body })
	return result.body as T
}

export async function dropboxDownload(apiArg: JsonRecord): Promise<DropboxRequestResult> {
	return dropboxRequestWithResponse({
		endpoint: 'files/download',
		kind: 'download',
		apiArg,
	})
}

export async function dropboxUpload(
	apiArg: JsonRecord,
	bytesBase64: string,
): Promise<DropboxRequestResult> {
	return dropboxRequestWithResponse({
		endpoint: 'files/upload',
		kind: 'upload',
		apiArg,
		bytesBase64,
	})
}

export async function request(
	input: DropboxRequestInput,
): Promise<DropboxRequestResult | JsonRecord> {
	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 })
}

function decodeBase64(value: string): Uint8Array {
	let binary: string
	try {
		binary = atob(value)
	} catch {
		throw new Error('bytesBase64 must be valid base64.')
	}
	const bytes = new Uint8Array(binary.length)
	for (let index = 0; index < binary.length; index += 1) {
		bytes[index] = binary.charCodeAt(index)
	}
	return bytes
}

function encodeBase64(buffer: ArrayBuffer): string {
	const bytes = new Uint8Array(buffer)
	const chunks: string[] = []
	const chunkSize = 0x8000
	for (let index = 0; index < bytes.length; index += chunkSize) {
		chunks.push(String.fromCharCode(...bytes.subarray(index, index + chunkSize)))
	}
	return btoa(chunks.join(''))
}

async function parseDownloadResponse(response: Response): Promise<JsonRecord> {
	const metadataHeader = response.headers.get('dropbox-api-result')
	const bytes = await response.arrayBuffer()
	return {
		metadata: metadataHeader ? JSON.parse(metadataHeader) : null,
		bytesBase64: encodeBase64(bytes),
		byteLength: bytes.byteLength,
		contentType: response.headers.get('content-type'),
	}
}

async function parseJsonResponse(response: Response): Promise<unknown> {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text
	}
}

async function parseErrorResponse(response: Response): Promise<unknown> {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return text
	}
}

function selectResponseHeaders(headers: Headers): Record<string, string> {
	const selected = [
		'content-length',
		'content-type',
		'dropbox-api-result',
		'x-dropbox-request-id',
	]
	const output: Record<string, string> = {}
	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<DropboxRequestInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'request')
	requireString(input.endpoint, 'endpoint')
	return request(input as DropboxRequestInput)
}