kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
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
rodzic
082231c228
commit
d9df972989
Plik diff jest za duży
Load Diff
|
@ -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')}
|
||||||
},`
|
},`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue