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.
old-agentic
Travis Fischer 2025-03-23 03:53:03 +08:00
rodzic 082231c228
commit d9df972989
3 zmienionych plików z 2074 dodań i 51 usunięć

Wyświetl plik

@ -19,6 +19,7 @@ import {
getOperationParamsName, getOperationParamsName,
getOperationResponseName, getOperationResponseName,
jsonSchemaToZod, jsonSchemaToZod,
naiveMergeJSONSchemas,
prettify prettify
} from './utils' } from './utils'
@ -38,7 +39,9 @@ const httpMethods = [
] as const ] as const
async function main() { 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') assert(pathToOpenApiSpec, 'Missing path to OpenAPI spec')
const parser = new SwaggerParser() const parser = new SwaggerParser()
@ -177,6 +180,7 @@ async function main() {
operationIds.add(operationName) operationIds.add(operationName)
const operationNameSnakeCase = decamelize(operationName) const operationNameSnakeCase = decamelize(operationName)
// if (path !== '/comments' || method !== 'post') continue
// if (path !== '/crawl/status/{jobId}') continue // if (path !== '/crawl/status/{jobId}') continue
// if (path !== '/pets' || method !== 'post') continue // if (path !== '/pets' || method !== 'post') continue
// console.log(method, path, operationName) // console.log(method, path, operationName)
@ -191,6 +195,7 @@ async function main() {
const operationResponseJSONSchemas: Record<string, IJsonSchema> = {} const operationResponseJSONSchemas: Record<string, IJsonSchema> = {}
const operationParamsSources: Record<string, string> = {} const operationParamsSources: Record<string, string> = {}
let operationParamsUnionSource: string | undefined
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
function addJSONSchemaParams(schema: IJsonSchema, source: string) { function addJSONSchemaParams(schema: IJsonSchema, source: string) {
@ -208,6 +213,16 @@ async function main() {
) )
operationParamsSources[key] = source 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 { } else {
assert(schema.type === 'object') assert(schema.type === 'object')
@ -233,6 +248,17 @@ async function main() {
...schema.required ...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 componentSchemas
) )
const operationResponseName = getOperationResponseName(operationName) 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 // 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( let operationsParamsSchema = jsonSchemaToZod(
operationParamsJSONSchema, operationParamsJSONSchema,
{ name: `${operationParamsName}Schema`, type: operationParamsName } { name: `${operationParamsName}Schema`, type: operationParamsName }
@ -430,18 +480,18 @@ async function main() {
${description ? `/**\n * ${description}\n */` : ''} ${description ? `/**\n * ${description}\n */` : ''}
@aiFunction({ @aiFunction({
name: '${operationNameSnakeCase}', name: '${operationNameSnakeCase}',
${description ? `description: '${description}',` : ''} ${description ? `description: '${description}',` : ''}${hasUnionParams ? '\n// TODO: Improve handling of union params' : ''}
inputSchema: ${namespaceName}.${operationParamsName}Schema, 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}${ return this.ky.${method}(${pathTemplate}${
onlyHasPathParams !hasParams || onlyHasPathParams
? '' ? ''
: `, { : `, {
${hasQueryParams ? (onlyHasOneParamsSource ? `searchParams: sanitizeSearchParams(params),` : `searchParams: sanitizeSearchParams(pick(params, '${queryParams.join("', '")}')),`) : ''} ${hasQueryParams ? (onlyHasOneParamsSource || hasUnionParams ? `searchParams: sanitizeSearchParams(params),` : `searchParams: sanitizeSearchParams(pick(params, '${queryParams.join("', '")}')),`) : ''}
${hasBodyParams ? (onlyHasOneParamsSource ? `json: params,` : `json: pick(params, '${bodyParams.join("', '")}'),`) : ''} ${hasBodyParams ? (onlyHasOneParamsSource || hasUnionParams ? `json: params,` : `json: pick(params, '${bodyParams.join("', '")}'),`) : ''}
${hasFormDataParams ? (onlyHasOneParamsSource ? `form: params,` : `form: pick(params, '${formDataParams.join("', '")}'),`) : ''} ${hasFormDataParams ? (onlyHasOneParamsSource || hasUnionParams ? `form: params,` : `form: pick(params, '${formDataParams.join("', '")}'),`) : ''}
${hasHeadersParams ? (onlyHasOneParamsSource ? `headers: params,` : `headers: pick(params, '${headersParams.join("', '")}'),`) : ''} ${hasHeadersParams ? (onlyHasOneParamsSource || hasUnionParams ? `headers: params,` : `headers: pick(params, '${headersParams.join("', '")}'),`) : ''}
}` }`
}).json<${namespaceName}.${operationResponseName}>() }).json<${namespaceName}.${operationResponseName}>()
} }
@ -458,12 +508,11 @@ async function main() {
> = {} > = {}
for (const ref of componentsToProcess) { for (const ref of componentsToProcess) {
const component = parser.$refs.get(ref) const component = { $ref: ref }
assert(component)
const resolved = new Set<string>() const resolved = new Set<string>()
const dereferenced = dereference(component, parser.$refs) const dereferenced = dereference(component, parser.$refs)
dereference(component, parser.$refs, resolved, 0, Number.POSITIVE_INFINITY) dereferenceFull(component, parser.$refs, resolved)
assert(dereferenced) assert(dereferenced)
for (const ref of resolved) { for (const ref of resolved) {
@ -483,33 +532,25 @@ async function main() {
const name = `${type}Schema` const name = `${type}Schema`
const { dereferenced, refs } = componentToRefs[ref]! const { dereferenced } = componentToRefs[ref]!
if (processedComponents.has(ref)) { if (processedComponents.has(ref)) {
continue continue
} }
for (const r of refs) {
if (processedComponents.has(r)) {
continue
}
processedComponents.add(r)
}
processedComponents.add(ref) processedComponents.add(ref)
if (type === 'SearchResponse') {
console.log(type, dereferenced)
}
const schema = jsonSchemaToZod(dereferenced, { name, type }) const schema = jsonSchemaToZod(dereferenced, { name, type })
componentSchemas[type] = schema componentSchemas[type] = schema
// console.log(ref, name, dereferenced)
} }
// console.log( console.log(
// '\ncomponents', '\ncomponents',
// Array.from(componentsToProcess) Array.from(sortedComponents).map((ref) => getComponentName(ref))
// .map((ref) => getComponentName(ref)) )
// .sort()
// )
// console.log( // console.log(
// '\nmodels', // '\nmodels',
@ -525,6 +566,9 @@ async function main() {
const aiClientMethodsString = aiClientMethods.join('\n\n') const aiClientMethodsString = aiClientMethods.join('\n\n')
const header = ` const header = `
/* eslint-disable unicorn/no-unreadable-iife */
/* eslint-disable unicorn/no-array-reduce */
/** /**
* This file was auto-generated from an OpenAPI spec. * This file was auto-generated from an OpenAPI spec.
*/ */
@ -540,13 +584,20 @@ import {
import defaultKy, { type KyInstance } from 'ky' import defaultKy, { type KyInstance } from 'ky'
import { z } from 'zod'`.trim() import { z } from 'zod'`.trim()
const commentLine = `// ${'-'.repeat(77)}`
const outputTypes = ( const outputTypes = (
await prettify( await prettify(
[ [
header, header,
`export namespace ${namespaceName} {`, `export namespace ${namespaceName} {`,
apiBaseUrl ? `export const apiBaseUrl = '${apiBaseUrl}'` : undefined, apiBaseUrl ? `export const apiBaseUrl = '${apiBaseUrl}'` : undefined,
Object.values(componentSchemas).length
? `${commentLine}\n// Component schemas\n${commentLine}`
: undefined,
...Object.values(componentSchemas), ...Object.values(componentSchemas),
Object.values(operationSchemas).length
? `${commentLine}\n// Operation schemas\n${commentLine}`
: undefined,
...Object.values(operationSchemas), ...Object.values(operationSchemas),
'}' '}'
] ]
@ -554,7 +605,7 @@ import { z } from 'zod'`.trim()
.join('\n\n') .join('\n\n')
) )
) )
.replaceAll(/z\.object\({}\)\.merge\(([^)]*)\)/g, '$1') .replaceAll(/z\s*\.object\({}\)\s*\.merge\(([^)]*)\)/gm, '$1')
.replaceAll(/\/\*\*(\S.*)\*\//g, '/** $1 */') .replaceAll(/\/\*\*(\S.*)\*\//g, '/** $1 */')
const output = await prettify( const output = await prettify(
@ -593,7 +644,7 @@ export class ${clientName} extends AIFunctionsProvider {
${ ${
hasGlobalApiKeyInHeader hasGlobalApiKeyInHeader
? `headers: { ? `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 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 { assert } from '@agentic/core'
import { import {
type JsonSchema, type JsonSchema,
jsonSchemaToZod as jsonSchemaToZodImpl jsonSchemaToZod as jsonSchemaToZodImpl,
type ParserOverride
} from 'json-schema-to-zod' } from 'json-schema-to-zod'
import * as prettier from 'prettier' import * as prettier from 'prettier'
@ -75,7 +76,8 @@ export function dereference<T extends object = object>(
refs: SwaggerParser.$Refs, refs: SwaggerParser.$Refs,
resolved?: Set<string>, resolved?: Set<string>,
depth = 0, depth = 0,
maxDepth = 1 maxDepth = 1,
visited = new Set<string>()
): T { ): T {
if (!obj) return obj if (!obj) return obj
@ -85,23 +87,25 @@ export function dereference<T extends object = object>(
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map((item) => return obj.map((item) =>
dereference(item, refs, resolved, depth + 1, maxDepth) dereference(item, refs, resolved, depth + 1, maxDepth, visited)
) as T ) as T
} else if (typeof obj === 'object') { } else if (typeof obj === 'object') {
if ('$ref' in obj) { if ('$ref' in obj) {
const ref = obj.$ref as string const ref = obj.$ref as string
const derefed = refs.get(ref) if (visited?.has(ref)) {
if (!derefed) {
return obj return obj
} }
visited?.add(ref)
const derefed = refs.get(ref)
assert(derefed, `Invalid schema: $ref not found for ${ref}`)
resolved?.add(ref) resolved?.add(ref)
derefed.title = ref.split('/').pop()! derefed.title ??= ref.split('/').pop()!
return dereference(derefed, refs, resolved, depth + 1, maxDepth) return dereference(derefed, refs, resolved, depth + 1, maxDepth, visited)
} else { } else {
return Object.fromEntries( return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [ Object.entries(obj).map(([key, value]) => [
key, key,
dereference(value, refs, resolved, depth + 1, maxDepth) dereference(value, refs, resolved, depth + 1, maxDepth, visited)
]) ])
) as T ) 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( export function jsonSchemaToZod(
schema: JsonSchema, schema: JsonSchema,
{ {
@ -126,17 +171,7 @@ export function jsonSchemaToZod(
withJsdocs: true, withJsdocs: true,
type: type ?? true, type: type ?? true,
noImport: true, noImport: true,
parserOverride: (schema, _refs) => { parserOverride: createParserOverride({ type })
if ('$ref' in schema) {
const ref = schema.$ref as string
if (!ref) return
const name = getComponentName(ref)
if (!name) return
return `${name}Schema`
}
}
}) })
} }
@ -251,3 +286,33 @@ export function getOperationResponseName(
return tempName 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
}