Skip to content
← Public packages

@kody/google

Call Gmail, Calendar, Tasks, Drive, Docs, Sheets, People, YouTube, and Analytics through saved Google OAuth.

src/core.ts

230 lines · 7.4 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import { resolveGoogleAccount, type GoogleAuthParams } from './accounts.ts'
import {
	formatInsufficientScopeError,
	isInsufficientScopeError,
} from './scopes.ts'
import type { JsonValue, UserInfo } from './types.ts'

export const GOOGLE_API_BASE_URLS = {
	google: 'https://www.googleapis.com',
	gmail: 'https://gmail.googleapis.com',
	people: 'https://people.googleapis.com',
	tasks: 'https://tasks.googleapis.com',
	docs: 'https://docs.googleapis.com',
	sheets: 'https://sheets.googleapis.com',
	youtubeAnalytics: 'https://youtubeanalytics.googleapis.com',
	'youtube-analytics': 'https://youtubeanalytics.googleapis.com',
	analytics: 'https://analyticsdata.googleapis.com',
	openid: 'https://openidconnect.googleapis.com',
} as const

export type GoogleRequestParams = GoogleAuthParams & {
	api?: keyof typeof GOOGLE_API_BASE_URLS
	url?: string
	path?: string
	method?: string
	query?: Record<string, unknown>
	headers?: HeadersInit
	body?: unknown
}

export type GoogleApiErrorDetails = {
	status?: number
	statusText?: string
	account?: string
	integrationName?: string
	url?: string
	data?: unknown
	causeMessage?: string
	missingScope?: string
}

export class GoogleApiError extends Error {
	status?: number
	statusText?: string
	account?: string
	integrationName?: string
	url?: string
	data?: unknown
	causeMessage?: string
	missingScope?: string
	constructor(message: string, details: GoogleApiErrorDetails = {}) {
		super(message)
		this.name = 'GoogleApiError'
		Object.assign(this, details)
	}
}

function appendQueryParam(url: URL, key: string, value: unknown): void {
	if (value === undefined || value === null) return
	if (Array.isArray(value)) {
		for (const item of value) appendQueryParam(url, key, item)
		return
	}
	url.searchParams.append(key, String(value))
}

function buildUrl(params: GoogleRequestParams): string {
	if (params.url) {
		const url = new URL(params.url)
		for (const [key, value] of Object.entries(params.query || {})) {
			appendQueryParam(url, key, value)
		}
		return url.toString()
	}
	const api = params.api || 'google'
	const base = GOOGLE_API_BASE_URLS[api]
	if (!base) throw new Error('Unknown Google API "' + api + '".')
	const path = params.path || '/'
	const baseUrl = base.endsWith('/') ? base.slice(0, -1) : base
	const cleanPath = path.startsWith('/') ? path.slice(1) : path
	const url = new URL(path.startsWith('http') ? path : baseUrl + '/' + cleanPath)
	for (const [key, value] of Object.entries(params.query || {})) {
		appendQueryParam(url, key, value)
	}
	return url.toString()
}

async function parseResponse(response: Response): Promise<JsonValue | string | null> {
	if (response.status === 204) return null
	const text = await response.text()
	if (!text) return null
	const contentType = response.headers.get('content-type') || ''
	if (contentType.includes('json')) {
		try {
			return JSON.parse(text) as JsonValue
		} catch {
			return text
		}
	}
	try {
		return JSON.parse(text) as JsonValue
	} catch {
		return text
	}
}

function googleErrorMessage(data: unknown): string | null {
	if (!data || typeof data !== 'object') return null
	const record = data as Record<string, unknown>
	const error = record.error
	if (error && typeof error === 'object') {
		const nested = error as Record<string, unknown>
		if (typeof nested.message === 'string') return nested.message
		if (typeof nested.error_description === 'string') return nested.error_description
	}
	if (typeof error === 'string') return error
	if (typeof record.error_description === 'string') return record.error_description
	return null
}

function authFailureMessage(accountLabel: string, integrationName: string, causeMessage: string): string {
	return [
		'Could not authenticate Google integration "' +
			integrationName +
			'" (account "' +
			accountLabel +
			'")' +
			(causeMessage ? ': ' + causeMessage : '.'),
		'Connect or reconnect at https://kody.codes/connect/oauth?provider=' +
			encodeURIComponent(integrationName),
		'Create or reconnect a Google OAuth client you register. Inbox reading and Drive-wide access need gmail.readonly / drive.readonly. See the package README Required setup.',
	].join('\n')
}

/**
 * Send an authenticated request to a Google API.
 * @param params.integration - Saved OAuth integration name. Defaults to `google`.
 * @param params.account - Purpose suffix or full integration name (`work` → `google-work`).
 * @returns Parsed JSON body, plain text, or null for HTTP 204 responses.
 * @example
 * import requestGoogle from 'kody:@kody/google/core'
 * const labels = await requestGoogle({ api: 'gmail', path: '/gmail/v1/users/me/labels' })
 */
export async function requestGoogle(params: GoogleRequestParams): Promise<unknown> {
	const account = resolveGoogleAccount(params)
	const method = params.method || (params.body === undefined ? 'GET' : 'POST')
	const requestUrl = buildUrl(params)
	const headers = new Headers(params.headers || {})
	let body = params.body as BodyInit | undefined
	if (
		body !== undefined &&
		body !== null &&
		typeof body !== 'string' &&
		!(body instanceof FormData) &&
		!(body instanceof Blob)
	) {
		if (!headers.has('content-type')) headers.set('content-type', 'application/json')
		body = JSON.stringify(body)
	}

	let authFetch: typeof fetch
	try {
		authFetch = await createAuthenticatedFetch(account.integrationName)
	} catch (error) {
		const causeMessage = error instanceof Error ? error.message : String(error)
		throw new GoogleApiError(authFailureMessage(account.account, account.integrationName, causeMessage), {
			account: account.account,
			integrationName: account.integrationName,
			causeMessage,
		})
	}

	const response = await authFetch(requestUrl, { method, headers, body }).catch((error) => {
		const causeMessage = error instanceof Error ? error.message : String(error)
		if (/does not allow requests to host/i.test(causeMessage)) {
			const hostMatch = /host "([^"]+)"/.exec(causeMessage)
			const host = hostMatch?.[1] || 'the API host'
			throw new GoogleApiError(
				[
					causeMessage,
					`Add ${host} to this Google connection's allowed hosts, then retry.`,
					'Reconnect: https://kody.codes/connect/oauth?provider=' +
						encodeURIComponent(account.integrationName),
					'New setup: include that host in allowedHosts on the prefilled /connect/oauth URL (see the package README).',
					'Docs need docs.googleapis.com; Sheets need sheets.googleapis.com; Tasks can use www.googleapis.com.',
				].join('\n'),
				{
					account: account.account,
					integrationName: account.integrationName,
					url: requestUrl,
					causeMessage,
				},
			)
		}
		throw error
	})
	const data = await parseResponse(response)
	if (!response.ok) {
		const googleMessage = googleErrorMessage(data)
		const message = isInsufficientScopeError(response.status, data)
			? formatInsufficientScopeError({
					integrationName: account.integrationName,
					url: requestUrl,
					method,
					status: response.status,
					googleMessage,
				})
			: googleMessage || 'Google API request failed with HTTP ' + response.status + '.'
		throw new GoogleApiError(message, {
			status: response.status,
			statusText: response.statusText,
			account: account.account,
			integrationName: account.integrationName,
			url: requestUrl,
			data,
		})
	}
	return data
}

export async function getUserInfo(params: GoogleAuthParams = {}): Promise<UserInfo> {
	return (await requestGoogle({
		...params,
		api: 'openid',
		path: '/v1/userinfo',
	})) as UserInfo
}

export default requestGoogle