Add Notion client generation from OpenAPI spec

This updates the code to support generating a Notion client
from an OpenAPI specification. It includes schemas,
operation schemas, and AI function decorators. Improved handling
of union types in operation parameters and added a placeholder
for recursive types. Options to supply a Notion API key and base
URL are available. Code aesthetics such as prettier formatting and
comment clues have been addressed for clarity.
pull/700/head
Travis Fischer 2025-03-23 03:53:03 +08:00
rodzic c2ef2271d8
commit e717810812
3 zmienionych plików z 2074 dodań i 51 usunięć

Wyświetl plik

@ -19,6 +19,7 @@ import {
getOperationParamsName,
getOperationResponseName,
jsonSchemaToZod,
naiveMergeJSONSchemas,
prettify
} from './utils'
@ -38,7 +39,9 @@ const httpMethods = [
] as const
async function main() {
const pathToOpenApiSpec = process.argv[2]
const pathToOpenApiSpec =
process.argv[2] ??
path.join(dirname, '..', 'fixtures', 'openapi', 'notion.json')
assert(pathToOpenApiSpec, 'Missing path to OpenAPI spec')
const parser = new SwaggerParser()
@ -177,6 +180,7 @@ async function main() {
operationIds.add(operationName)
const operationNameSnakeCase = decamelize(operationName)
// if (path !== '/comments' || method !== 'post') continue
// if (path !== '/crawl/status/{jobId}') continue
// if (path !== '/pets' || method !== 'post') continue
// console.log(method, path, operationName)
@ -191,6 +195,7 @@ async function main() {
const operationResponseJSONSchemas: Record<string, IJsonSchema> = {}
const operationParamsSources: Record<string, string> = {}
let operationParamsUnionSource: string | undefined
// eslint-disable-next-line unicorn/consistent-function-scoping
function addJSONSchemaParams(schema: IJsonSchema, source: string) {
@ -208,6 +213,16 @@ async function main() {
)
operationParamsSources[key] = source
}
} else if (derefed?.anyOf || derefed?.oneOf) {
const componentName = getComponentName(schema.$ref)
operationParamsSources[componentName] = source
// TODO: handle this case
assert(
!operationParamsUnionSource,
`Duplicate union source ${source} for operation ${operationName}`
)
operationParamsUnionSource = source
}
} else {
assert(schema.type === 'object')
@ -233,6 +248,17 @@ async function main() {
...schema.required
]
}
if (schema?.anyOf || schema?.oneOf) {
operationParamsSources[schema.title || '__union__'] = source
// TODO: handle this case
assert(
!operationParamsUnionSource,
`Duplicate union source ${source} for operation ${operationName}`
)
operationParamsUnionSource = source
}
}
}
@ -308,9 +334,33 @@ async function main() {
componentSchemas
)
const operationResponseName = getOperationResponseName(operationName)
let derefedParams: any = dereference(
operationParamsJSONSchema,
parser.$refs
)
for (const ref of derefedParams.$refs) {
const temp: any = dereference({ $ref: ref }, parser.$refs)
if (temp) {
derefedParams = naiveMergeJSONSchemas(derefedParams, temp)
}
}
// console.log(JSON.stringify(derefedParams, null, 2))
const hasUnionParams = !!(derefedParams.anyOf || derefedParams.oneOf)
const hasParams =
Object.keys(derefedParams.properties ?? {}).length > 0 || hasUnionParams
assert(
hasUnionParams === !!operationParamsUnionSource,
'Unexpected union params'
)
// TODO: handle empty params case
{
// Merge all operations params into one schema declaration
// TODO: Don't generate this if it's only refs. We're currently handling
// this in a hacky way by removing the `z.object({}).merge(...)` down
// below.
let operationsParamsSchema = jsonSchemaToZod(
operationParamsJSONSchema,
{ name: `${operationParamsName}Schema`, type: operationParamsName }
@ -430,18 +480,18 @@ async function main() {
${description ? `/**\n * ${description}\n */` : ''}
@aiFunction({
name: '${operationNameSnakeCase}',
${description ? `description: '${description}',` : ''}
inputSchema: ${namespaceName}.${operationParamsName}Schema,
${description ? `description: '${description}',` : ''}${hasUnionParams ? '\n// TODO: Improve handling of union params' : ''}
inputSchema: ${namespaceName}.${operationParamsName}Schema${hasUnionParams ? ' as any' : ''},
})
async ${operationName}(params: ${namespaceName}.${operationParamsName}): Promise<${namespaceName}.${operationResponseName}> {
async ${operationName}(${!hasParams ? '_' : ''}params: ${namespaceName}.${operationParamsName}): Promise<${namespaceName}.${operationResponseName}> {
return this.ky.${method}(${pathTemplate}${
onlyHasPathParams
!hasParams || onlyHasPathParams
? ''
: `, {
${hasQueryParams ? (onlyHasOneParamsSource ? `searchParams: sanitizeSearchParams(params),` : `searchParams: sanitizeSearchParams(pick(params, '${queryParams.join("', '")}')),`) : ''}
${hasBodyParams ? (onlyHasOneParamsSource ? `json: params,` : `json: pick(params, '${bodyParams.join("', '")}'),`) : ''}
${hasFormDataParams ? (onlyHasOneParamsSource ? `form: params,` : `form: pick(params, '${formDataParams.join("', '")}'),`) : ''}
${hasHeadersParams ? (onlyHasOneParamsSource ? `headers: params,` : `headers: pick(params, '${headersParams.join("', '")}'),`) : ''}
${hasQueryParams ? (onlyHasOneParamsSource || hasUnionParams ? `searchParams: sanitizeSearchParams(params),` : `searchParams: sanitizeSearchParams(pick(params, '${queryParams.join("', '")}')),`) : ''}
${hasBodyParams ? (onlyHasOneParamsSource || hasUnionParams ? `json: params,` : `json: pick(params, '${bodyParams.join("', '")}'),`) : ''}
${hasFormDataParams ? (onlyHasOneParamsSource || hasUnionParams ? `form: params,` : `form: pick(params, '${formDataParams.join("', '")}'),`) : ''}
${hasHeadersParams ? (onlyHasOneParamsSource || hasUnionParams ? `headers: params,` : `headers: pick(params, '${headersParams.join("', '")}'),`) : ''}
}`
}).json<${namespaceName}.${operationResponseName}>()
}
@ -458,12 +508,11 @@ async function main() {
> = {}
for (const ref of componentsToProcess) {
const component = parser.$refs.get(ref)
assert(component)
const component = { $ref: ref }
const resolved = new Set<string>()
const dereferenced = dereference(component, parser.$refs)
dereference(component, parser.$refs, resolved, 0, Number.POSITIVE_INFINITY)
dereferenceFull(component, parser.$refs, resolved)
assert(dereferenced)
for (const ref of resolved) {
@ -483,33 +532,25 @@ async function main() {
const name = `${type}Schema`
const { dereferenced, refs } = componentToRefs[ref]!
const { dereferenced } = componentToRefs[ref]!
if (processedComponents.has(ref)) {
continue
}
for (const r of refs) {
if (processedComponents.has(r)) {
continue
}
processedComponents.add(r)
}
processedComponents.add(ref)
if (type === 'SearchResponse') {
console.log(type, dereferenced)
}
const schema = jsonSchemaToZod(dereferenced, { name, type })
componentSchemas[type] = schema
// console.log(ref, name, dereferenced)
}
// console.log(
// '\ncomponents',
// Array.from(componentsToProcess)
// .map((ref) => getComponentName(ref))
// .sort()
// )
console.log(
'\ncomponents',
Array.from(sortedComponents).map((ref) => getComponentName(ref))
)
// console.log(
// '\nmodels',
@ -525,6 +566,9 @@ async function main() {
const aiClientMethodsString = aiClientMethods.join('\n\n')
const header = `
/* eslint-disable unicorn/no-unreadable-iife */
/* eslint-disable unicorn/no-array-reduce */
/**
* This file was auto-generated from an OpenAPI spec.
*/
@ -540,13 +584,20 @@ import {
import defaultKy, { type KyInstance } from 'ky'
import { z } from 'zod'`.trim()
const commentLine = `// ${'-'.repeat(77)}`
const outputTypes = (
await prettify(
[
header,
`export namespace ${namespaceName} {`,
apiBaseUrl ? `export const apiBaseUrl = '${apiBaseUrl}'` : undefined,
Object.values(componentSchemas).length
? `${commentLine}\n// Component schemas\n${commentLine}`
: undefined,
...Object.values(componentSchemas),
Object.values(operationSchemas).length
? `${commentLine}\n// Operation schemas\n${commentLine}`
: undefined,
...Object.values(operationSchemas),
'}'
]
@ -554,7 +605,7 @@ import { z } from 'zod'`.trim()
.join('\n\n')
)
)
.replaceAll(/z\.object\({}\)\.merge\(([^)]*)\)/g, '$1')
.replaceAll(/z\s*\.object\({}\)\s*\.merge\(([^)]*)\)/gm, '$1')
.replaceAll(/\/\*\*(\S.*)\*\//g, '/** $1 */')
const output = await prettify(
@ -593,7 +644,7 @@ export class ${clientName} extends AIFunctionsProvider {
${
hasGlobalApiKeyInHeader
? `headers: {
${apiKeyHeaderNames.map((name) => `'${(resolvedSecuritySchemes[name] as any).name || 'Authorization'}': ${(resolvedSecuritySchemes[name] as any).schema?.toLowerCase() === 'bearer' ? '`Bearer ${apiKey}`' : 'apiKey'}`).join(',\n')}
${apiKeyHeaderNames.map((name) => `'${(resolvedSecuritySchemes[name] as any).name || 'Authorization'}': ${(resolvedSecuritySchemes[name] as any).schema?.toLowerCase() === 'bearer' || resolvedSecuritySchemes[name]?.type?.toLowerCase() === 'oauth2' ? '`Bearer ${apiKey}`' : 'apiKey'}`).join(',\n')}
},`
: ''
}

Wyświetl plik

@ -1,9 +1,10 @@
import type SwaggerParser from '@apidevtools/swagger-parser'
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
import type { IJsonSchema, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
import { assert } from '@agentic/core'
import {
type JsonSchema,
jsonSchemaToZod as jsonSchemaToZodImpl
jsonSchemaToZod as jsonSchemaToZodImpl,
type ParserOverride
} from 'json-schema-to-zod'
import * as prettier from 'prettier'
@ -75,7 +76,8 @@ export function dereference<T extends object = object>(
refs: SwaggerParser.$Refs,
resolved?: Set<string>,
depth = 0,
maxDepth = 1
maxDepth = 1,
visited = new Set<string>()
): T {
if (!obj) return obj
@ -85,23 +87,25 @@ export function dereference<T extends object = object>(
if (Array.isArray(obj)) {
return obj.map((item) =>
dereference(item, refs, resolved, depth + 1, maxDepth)
dereference(item, refs, resolved, depth + 1, maxDepth, visited)
) as T
} else if (typeof obj === 'object') {
if ('$ref' in obj) {
const ref = obj.$ref as string
const derefed = refs.get(ref)
if (!derefed) {
if (visited?.has(ref)) {
return obj
}
visited?.add(ref)
const derefed = refs.get(ref)
assert(derefed, `Invalid schema: $ref not found for ${ref}`)
resolved?.add(ref)
derefed.title = ref.split('/').pop()!
return dereference(derefed, refs, resolved, depth + 1, maxDepth)
derefed.title ??= ref.split('/').pop()!
return dereference(derefed, refs, resolved, depth + 1, maxDepth, visited)
} else {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key,
dereference(value, refs, resolved, depth + 1, maxDepth)
dereference(value, refs, resolved, depth + 1, maxDepth, visited)
])
) as T
}
@ -110,6 +114,47 @@ export function dereference<T extends object = object>(
}
}
function createParserOverride({
type
}: {
type?: string
} = {}): ParserOverride {
const jsonSchemaToZodParserOverride: ParserOverride = (schema, _refs) => {
if ('$ref' in schema) {
const ref = schema.$ref as string
if (!ref) return
const name = getComponentName(ref)
if (!name) return
if (type === name) {
// TODO: Support recursive types.
return `\n// TODO: Support recursive types for \`${name}Schema\`.\nz.any()`
}
return `${name}Schema`
} else if (schema.oneOf) {
const { oneOf, ...partialSchema } = schema
// Replace oneOf with anyOf because `json-schema-to-zod` treats oneOf
// with a complicated `z.any().superRefine(...)` which we'd like messes
// up the resulting types.
const newSchema = {
...partialSchema,
anyOf: oneOf
}
const res = jsonSchemaToZodImpl(newSchema, {
parserOverride: jsonSchemaToZodParserOverride
})
return res
}
}
return jsonSchemaToZodParserOverride
}
export function jsonSchemaToZod(
schema: JsonSchema,
{
@ -126,17 +171,7 @@ export function jsonSchemaToZod(
withJsdocs: true,
type: type ?? true,
noImport: true,
parserOverride: (schema, _refs) => {
if ('$ref' in schema) {
const ref = schema.$ref as string
if (!ref) return
const name = getComponentName(ref)
if (!name) return
return `${name}Schema`
}
}
parserOverride: createParserOverride({ type })
})
}
@ -251,3 +286,33 @@ export function getOperationResponseName(
return tempName
}
export function naiveMergeJSONSchemas(...schemas: IJsonSchema[]): IJsonSchema {
const result: any = {}
for (const ischema of schemas) {
const schema = ischema as any
const arrayKeys: string[] = []
const objectKeys: string[] = []
for (const [key, value] of Object.entries(schema)) {
if (Array.isArray(value)) {
arrayKeys.push(key)
} else if (typeof value === 'object') {
objectKeys.push(key)
} else {
result[key] = value
}
}
for (const key of arrayKeys) {
result[key] = [...(result[key] ?? []), ...(schema[key] ?? [])]
}
for (const key of objectKeys) {
result[key] = { ...result[key], ...schema[key] }
}
}
return result as IJsonSchema
}