Skip to content
← Public packages

@kody/notion

Search, read, query, and safely write Notion pages and databases through the saved notion OAuth integration.

src/request.ts

198 lines · 6.7 KB · TypeScript
/**
 * @fileoverview Generic Notion API escape hatch through the saved `notion` OAuth integration.
 * Prefer dedicated exports when available; use this for arbitrary paths.
 * Mutating requests require `confirm: true`; use `dryRun: true` to preview.
 *
 * @example
 * import request from 'kody:@kody/notion/request'
 * const me = await request({ path: '/users/me' })
 */

import { createAuthenticatedFetch } from 'kody:runtime'
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

  constructor(path: string, status: number, details: JsonRecord) {
    super('Notion API ' + path + ' failed: ' + (details.code || details.message || String(status)))
    this.name = 'NotionApiError'
    this.path = path
    this.status = status
    this.details = details
  }
}

let authenticatedFetch: typeof fetch | null = null

async function getAuthenticatedFetch(): Promise<typeof fetch> {
  if (!authenticatedFetch) {
    authenticatedFetch = await createAuthenticatedFetch(NOTION_INTEGRATION)
  }
  return authenticatedFetch
}

export type NotionRequestOptions = {
  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
}

/**
 * Call any Notion API endpoint through the saved `notion` OAuth integration.
 * Prefer dedicated exports when available; use `./request` for arbitrary paths.
 * Mutating requests require `confirm: true`; use `dryRun: true` to preview.
 *
 * @param path - Absolute Notion API path such as `/users/me` or `/search`.
 * @returns The validated path string.
 *
 * @example
 * import request from 'kody:@kody/notion/request'
 * const me = await request({ path: '/users/me' })
 */
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 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()
  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)
  }

  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): Promise<string> {
  const database = await notionRequest<DatabaseContainer>(
    '/databases/' + encodeURIComponent(databaseId),
  )
  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 the saved `notion` OAuth integration.
 * Mutating requests require `confirm: true`; use `dryRun: true` to preview.
 *
 * @param params.path - Absolute Notion API path such as `/users/me` or `/search`.
 * @param params.method - HTTP method (default GET).
 * @param params.body - Optional JSON body for POST/PATCH.
 * @param params.dryRun - When true, preview without calling Notion.
 * @param params.confirm - Required for mutating requests.
 * @returns Parsed Notion JSON response (or dry-run preview).
 *
 * @example
 * import request from 'kody:@kody/notion/request'
 * const me = await request({ path: '/users/me' })
 */
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')

  if (!isReadOnlyRequest(method, path)) {
    if (input.dryRun === true) {
      return { dryRun: true, method, path, query: query ?? null, body: body ?? null }
    }
    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,
  })
}