Skip to content

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

Package listing

@kentcdodds/notion

src/request.ts

195 lines · 6.3 KB · TypeScript
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<string, unknown>

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<string, typeof fetch>()

async function getAuthenticatedFetch(integration: string): Promise<typeof fetch> {
  let authed = authenticatedFetchByIntegration.get(integration)
  if (!authed) {
    authed = await createAuthenticatedFetch(integration)
    authenticatedFetchByIntegration.set(integration, authed)
  }
  return authed
}

export type NotionRequestOptions = NotionAccountParams & {
  method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
  query?: Record<string, string | number | undefined>
  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<T extends JsonRecord = JsonRecord>(
  path: string,
  options: NotionRequestOptions = {},
): Promise<T> {
  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<string, string> = {
    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<string> {
  const database = await notionRequest<DatabaseContainer>(
    '/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.
 */
export default async function request(params: Record<string, unknown> = {}) {
  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,
  })
}