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)
}