← 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,
})
}