← 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 · TypeScriptimport { 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