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.pull/700/head
rodzic
c2ef2271d8
commit
e717810812
Plik diff jest za duży
Load Diff
|
@ -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')}
|
||||
},`
|
||||
: ''
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue