/** * Call any Notion API endpoint through a saved Notion OAuth integration. * Pass `account` / `integration` to select a workspace (default: `notion`). * Mutating requests require `confirm: true`; use `dryRun: true` to preview. * Prefer named helpers (`./search`, `./get-page`, …) when one exists. * * @param params.path - Absolute Notion API path such as `/search` or `/pages/{id}`. * @param params.method - `GET` | `POST` | `PATCH` | `DELETE`. Default `GET`. * @param params.body - JSON body for write requests. * @param params.account - Purpose suffix (`work` → `notion-work`) or omit for `notion`. * @param params.confirm - Required `true` for mutating requests after explicit approval. * @param params.dryRun - When true, preview a mutation without calling Notion. * @returns Notion JSON for live calls, or `{ dryRun: true, method, path, ... }` when previewing. * * @example * import request from 'kody:@kentcdodds/notion/request' * const result = await request({ path: '/search', method: 'POST', body: { query: 'Kody', page_size: 5 } }) * // => { object: 'list', results: [...], next_cursor: null, has_more: false } */ import { createAuthenticatedFetch } from 'kody:runtime' import { resolveIntegrationName, type NotionAccountParams } from './accounts.ts' import { inputRecord, optionalRecord, optionalString, requiredString } from './validation.ts' export const NOTION_API_BASE_URL = 'https://api.notion.com/v1' export const NOTION_INTEGRATION = 'notion' export const NOTION_VERSION = '2026-03-11' export type JsonRecord = Record export class NotionApiError extends Error { readonly path: string readonly status: number readonly details: JsonRecord readonly integration: string constructor(path: string, status: number, details: JsonRecord, integration: string) { super( 'Notion API ' + path + ' via integration "' + integration + '" failed: ' + (details.code || details.message || String(status)), ) this.name = 'NotionApiError' this.path = path this.status = status this.details = details this.integration = integration } } /** Per-integration authenticated fetch cache (Notion tokens are workspace-scoped). */ const authenticatedFetchByIntegration = new Map() async function getAuthenticatedFetch(integration: string): Promise { let authed = authenticatedFetchByIntegration.get(integration) if (!authed) { // Literal names only — stamp gate forbids createAuthenticatedFetch(runtimeName). switch (integration) { case 'notion': authed = await createAuthenticatedFetch('notion') break case 'notion-work': authed = await createAuthenticatedFetch('notion-work') break case 'notion-personal': authed = await createAuthenticatedFetch('notion-personal') break default: throw new Error( 'Unknown Notion integration "' + integration + '". Pass account/integration resolving to notion, notion-work, or notion-personal.', ) } authenticatedFetchByIntegration.set(integration, authed) } return authed } export type NotionRequestOptions = NotionAccountParams & { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' query?: Record body?: JsonRecord notionVersion?: string } function assertNotionVersion(version: string): string { if (!/^\d{4}-\d{2}-\d{2}$/.test(version)) { throw new Error("notionVersion must be a Notion API date version such as '2026-03-11'.") } return version } export function assertNotionPath(path: string): string { if (!/^\/[a-zA-Z0-9/_.~-]+$/.test(path)) { throw new Error("path must be an absolute Notion API path such as '/search' or '/pages/{id}'.") } return path } export async function notionRequest( path: string, options: NotionRequestOptions = {}, ): Promise { assertNotionPath(path) const integration = resolveIntegrationName(options) const url = new URL(NOTION_API_BASE_URL + path) for (const [key, value] of Object.entries(options.query ?? {})) { if (value === undefined) continue url.searchParams.set(key, String(value)) } const headers: Record = { accept: 'application/json', 'Notion-Version': options.notionVersion ? assertNotionVersion(options.notionVersion) : NOTION_VERSION, } if (options.body !== undefined) headers['content-type'] = 'application/json' const authedFetch = await getAuthenticatedFetch(integration) const response = await authedFetch(url.toString(), { method: options.method ?? 'GET', headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) const result = (await response.json()) as JsonRecord if (!response.ok) { throw new NotionApiError(path, response.status, result, integration) } return result as T } export function nextCursor(response: JsonRecord): string | null { const cursor = response.next_cursor return typeof cursor === 'string' && cursor.length > 0 ? cursor : null } type DatabaseContainer = { data_sources?: Array<{ id?: string; name?: string }> [key: string]: unknown } /** * Resolve a database id to its single data source id. Databases are containers * since Notion-Version 2025-09-03; rows and schema live on data sources. * Throws when the database has multiple data sources — pass the specific * dataSourceId instead in that case. */ export async function resolveDataSourceId( databaseId: string, accountParams: NotionAccountParams = {}, ): Promise { const database = await notionRequest( '/databases/' + encodeURIComponent(databaseId), accountParams, ) const dataSources = database.data_sources ?? [] if (dataSources.length === 1 && typeof dataSources[0].id === 'string') { return dataSources[0].id } if (dataSources.length === 0) { throw new Error('Database ' + databaseId + ' has no data sources.') } throw new Error( 'Database ' + databaseId + ' has multiple data sources; pass dataSourceId explicitly. Options: ' + dataSources.map((source) => source.id + ' (' + source.name + ')').join(', '), ) } const allowedMethods = new Set(['GET', 'POST', 'PATCH', 'DELETE']) const readOnlyPostPaths = new Set(['/search']) function isReadOnlyRequest(method: string, path: string): boolean { if (method === 'GET') return true if ( method === 'POST' && (readOnlyPostPaths.has(path) || /^\/data_sources\/[^/]+\/query$/.test(path)) ) { return true } return false } /** * Call any Notion API endpoint through a saved Notion OAuth integration. * Pass `account` / `integration` to select a workspace (default: `notion`). * Mutating requests require `confirm: true`; use `dryRun: true` to preview. * Prefer named helpers (`./search`, `./get-page`, …) when one exists. * * @param params.path - Absolute Notion API path such as `/search` or `/pages/{id}`. * @param params.method - `GET` | `POST` | `PATCH` | `DELETE`. Default `GET`. * @param params.body - JSON body for write requests. * @param params.account - Purpose suffix (`work` → `notion-work`) or omit for `notion`. * @param params.confirm - Required `true` for mutating requests after explicit approval. * @param params.dryRun - When true, preview a mutation without calling Notion. * @returns Notion JSON for live calls, or `{ dryRun: true, method, path, ... }` when previewing. * * @example * import request from 'kody:@kentcdodds/notion/request' * const result = await request({ path: '/search', method: 'POST', body: { query: 'Kody', page_size: 5 } }) * // => { object: 'list', results: [...], next_cursor: null, has_more: false } */ export default async function request(params: Record = {}) { const input = inputRecord(params) const path = assertNotionPath(requiredString(input, 'path')) const method = (optionalString(input, 'method') ?? 'GET').toUpperCase() if (!allowedMethods.has(method)) { throw new Error('method must be GET, POST, PATCH, or DELETE.') } const body = optionalRecord(input, 'body') const query = optionalRecord(input, 'query') as NotionRequestOptions['query'] const notionVersion = optionalString(input, 'notionVersion') const account = optionalString(input, 'account') const integration = optionalString(input, 'integration') if (!isReadOnlyRequest(method, path)) { if (input.dryRun === true) { return { dryRun: true, method, path, query: query ?? null, body: body ?? null, integration: resolveIntegrationName({ account, integration }), } } if (input.confirm !== true) { throw new Error(method + ' ' + path + ' mutates Notion data and requires confirm: true after explicit user approval. Use dryRun: true to preview.') } } return notionRequest(path, { method: method as NotionRequestOptions['method'], query, body, notionVersion, account, integration, }) }