Skip to content

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

Package listing

@kentcdodds/google

src/core.ts

24 lines · 5.2 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import { resolveGoogleAccount } from './accounts.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', youtubeAnalytics: 'https://youtubeanalytics.googleapis.com', 'youtube-analytics': 'https://youtubeanalytics.googleapis.com', openid: 'https://openidconnect.googleapis.com' } as const
export type GoogleRequestParams = { account: string; 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 }
export class GoogleApiError extends Error { status?: number; statusText?: string; account?: string; integrationName?: string; url?: string; data?: unknown; causeMessage?: 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 }
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('Could not authenticate Google account "' + account.account + '" (' + account.integrationName + ')' + (causeMessage ? ': ' + causeMessage : '.'), { account: account.account, integrationName: account.integrationName, causeMessage }) } const response = await authFetch(requestUrl, { method, headers, body }); const data = await parseResponse(response); if (!response.ok) throw new GoogleApiError(googleErrorMessage(data) || 'Google API request failed with HTTP ' + response.status + '.', { status: response.status, statusText: response.statusText, account: account.account, integrationName: account.integrationName, url: requestUrl, data }); return data }
export async function getUserInfo(params: { account: string }): Promise<UserInfo> { return await requestGoogle({ ...params, api: 'openid', path: '/v1/userinfo' }) as UserInfo }

/**
 * Send an authenticated request to a Google API for the chosen account alias.
 * @param params.account - Google account alias (`personal`, `business`, `youtube-brand`, `youtube-plus`).
 * @returns Parsed JSON body, plain text, or null for HTTP 204 responses.
 * @example
 * import requestGoogle from 'kody:@kentcdodds/google/core'
 * const labels = await requestGoogle({ account: 'personal', api: 'gmail', path: '/gmail/v1/users/me/labels' })
 * // => { labels: [...] }
 */
export default requestGoogle