mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import type { DefinePlugin, UserConfig } from '@hey-api/openapi-ts'
|
|
import type { ApiSpec, SwaggerDocument, SwaggerOperation, SwaggerSchema } from './api-documents.ts'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { $, defineConfig, definePluginConfig } from '@hey-api/openapi-ts'
|
|
import ts from 'typescript'
|
|
import {
|
|
apiSpecs,
|
|
cloneDocumentWithPaths,
|
|
contractPathSegments,
|
|
fastOpenApiConsoleSpecFilename,
|
|
getDocumentSchemas,
|
|
groupConsolePaths,
|
|
isObject,
|
|
mergeFastOpenApiConsoleSwagger,
|
|
normalizeApiSwagger,
|
|
operationMethods,
|
|
schemaNameFromRef,
|
|
segmentWords,
|
|
toCamelCase,
|
|
toKebabCase,
|
|
} from './api-documents.ts'
|
|
import { typescriptPlugin } from './typescript-generation.ts'
|
|
|
|
type ApiJob = {
|
|
document: SwaggerDocument
|
|
outputPath: string
|
|
plugins?: UserConfig['plugins']
|
|
source?: {
|
|
callback: () => void
|
|
enabled: true
|
|
path: null
|
|
serialize: () => string
|
|
}
|
|
}
|
|
|
|
const strictZodSchemaNames = new Set([
|
|
'AccountProfilePatchPayload',
|
|
'Parameters',
|
|
'AppTextInputPayload',
|
|
'AppSelectInputPayload',
|
|
'AppParagraphInputPayload',
|
|
'AppNumberInputPayload',
|
|
'AppCheckboxInputPayload',
|
|
'AppExternalDataInputPayload',
|
|
])
|
|
const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$'
|
|
const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$'
|
|
|
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
|
|
|
|
const readApiSwagger = (filename: string): SwaggerDocument => {
|
|
const specPath = path.join(apiOpenApiDir, filename)
|
|
|
|
if (!fs.existsSync(specPath)) {
|
|
throw new Error(
|
|
`Missing API OpenAPI spec: ${specPath}. Run "pnpm gen-api-openapi" from packages/contracts/ first.`,
|
|
)
|
|
}
|
|
|
|
const rawSpec = JSON.parse(fs.readFileSync(specPath, 'utf8'))
|
|
if (!isObject(rawSpec) || !isObject(rawSpec.paths))
|
|
throw new Error(`Invalid API OpenAPI spec: ${specPath}`)
|
|
|
|
return rawSpec as SwaggerDocument
|
|
}
|
|
|
|
const consoleContractEntryContent = (segments: string[]) => {
|
|
const contracts = segments.map((segment) => {
|
|
return {
|
|
importPath: toKebabCase(segment),
|
|
name: toCamelCase(segmentWords(segment)),
|
|
}
|
|
})
|
|
|
|
const contractEntries = contracts
|
|
.map(
|
|
(contract) =>
|
|
` ${contract.name}: () => import('./${contract.importPath}/orpc.gen.ts').then(({ ${contract.name} }) => ({ ${contract.name} })),`,
|
|
)
|
|
.join('\n')
|
|
|
|
return `// This file is auto-generated by @hey-api/openapi-ts
|
|
|
|
export const contractLoaders = {
|
|
${contractEntries}
|
|
}
|
|
`
|
|
}
|
|
|
|
const writeConsoleContractEntry = (segments: string[]) => {
|
|
const entryPath = path.resolve(currentDir, 'generated/api/console/orpc.gen.ts')
|
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true })
|
|
fs.writeFileSync(entryPath, consoleContractEntryContent(segments))
|
|
}
|
|
|
|
const consoleRouterContractContent = (segments: string[]) => {
|
|
const contracts = segments.map((segment) => {
|
|
return {
|
|
importPath: toKebabCase(segment),
|
|
name: toCamelCase(segmentWords(segment)),
|
|
}
|
|
})
|
|
|
|
const imports = contracts
|
|
.map((contract) => `import { ${contract.name} } from './${contract.importPath}/orpc.gen.ts'`)
|
|
.join('\n')
|
|
|
|
const communityContractEntries = contracts.map((contract) => ` ${contract.name},`).join('\n')
|
|
|
|
return `// This file is auto-generated by packages/contracts/openapi-ts.api.config.ts
|
|
|
|
${imports}
|
|
import { contract as enterpriseContract } from '../../enterprise/orpc.gen.ts'
|
|
|
|
const communityContract = {
|
|
${communityContractEntries}
|
|
}
|
|
|
|
export const consoleRouterContract = {
|
|
enterprise: enterpriseContract,
|
|
...communityContract,
|
|
}
|
|
`
|
|
}
|
|
|
|
const writeConsoleRouterContract = (segments: string[]) => {
|
|
const routerPath = path.resolve(currentDir, 'generated/api/console/router.gen.ts')
|
|
fs.mkdirSync(path.dirname(routerPath), { recursive: true })
|
|
fs.writeFileSync(routerPath, consoleRouterContractContent(segments))
|
|
}
|
|
|
|
type NoOutputPlugin = DefinePlugin<{ name: 'dify-no-output' }>
|
|
|
|
// openapi-ts falls back to its default plugins when `plugins` is empty, so the
|
|
// entry job needs an explicit plugin that emits nothing beside its source callback.
|
|
const noOutputPlugin = definePluginConfig<NoOutputPlugin['Types']>({
|
|
config: {},
|
|
handler: () => {},
|
|
name: 'dify-no-output',
|
|
})
|
|
|
|
const createConsoleContractEntryJob = (document: SwaggerDocument, segments: string[]): ApiJob => {
|
|
return {
|
|
document,
|
|
outputPath: 'generated/api/console',
|
|
plugins: [noOutputPlugin()],
|
|
source: {
|
|
callback: () => {
|
|
writeConsoleContractEntry(segments)
|
|
writeConsoleRouterContract(segments)
|
|
},
|
|
enabled: true,
|
|
path: null,
|
|
serialize: () => '',
|
|
},
|
|
}
|
|
}
|
|
|
|
const splitConsoleDocument = (document: SwaggerDocument) => {
|
|
const pathsBySegment = groupConsolePaths(document)
|
|
const segments = [...pathsBySegment.keys()].sort((left, right) => left.localeCompare(right))
|
|
const jobs = segments.map((segment): ApiJob => ({
|
|
document: cloneDocumentWithPaths(document, pathsBySegment.get(segment) ?? {}),
|
|
outputPath: `generated/api/console/${toKebabCase(segment)}`,
|
|
}))
|
|
|
|
return [...jobs, createConsoleContractEntryJob(document, segments)]
|
|
}
|
|
|
|
const createApiJobs = (spec: ApiSpec): ApiJob[] => {
|
|
const document = normalizeApiSwagger(
|
|
spec.name === 'console'
|
|
? mergeFastOpenApiConsoleSwagger(
|
|
readApiSwagger(spec.filename),
|
|
readApiSwagger(fastOpenApiConsoleSpecFilename),
|
|
)
|
|
: readApiSwagger(spec.filename),
|
|
)
|
|
|
|
if (spec.name === 'console') return splitConsoleDocument(document)
|
|
|
|
return [
|
|
{
|
|
document,
|
|
outputPath: `generated/api/${spec.name}`,
|
|
},
|
|
]
|
|
}
|
|
|
|
export const apiJobs = apiSpecs.flatMap(createApiJobs)
|
|
|
|
export type OrpcErrorEntry = {
|
|
code: string
|
|
schemaName: string
|
|
status: number
|
|
}
|
|
|
|
/**
|
|
* The fixed `code` and `status` a per-code error component (`{Code}Error`, an `allOf` over the
|
|
* error body) declares, or `undefined` for the open error body and other schemas.
|
|
*/
|
|
const errorComponentIdentity = (
|
|
schema: SwaggerSchema | undefined,
|
|
): { code: string; status: number } | undefined => {
|
|
const parts = Array.isArray(schema?.allOf) ? schema.allOf : []
|
|
for (const part of parts) {
|
|
if (!isObject(part) || !isObject(part.properties)) continue
|
|
const code = part.properties.code
|
|
const status = part.properties.status
|
|
if (
|
|
isObject(code) &&
|
|
typeof code.const === 'string' &&
|
|
isObject(status) &&
|
|
typeof status.const === 'number'
|
|
)
|
|
return { code: code.const, status: status.const }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* The oRPC `errorMap` each operation declares, keyed by generated operationId: one
|
|
* `{ status, data }` entry per error component its 4xx/5xx responses reference. The
|
|
* backend documents each code as one component with one status per surface, so a code
|
|
* that still reaches an operation under two statuses fails generation.
|
|
*/
|
|
export const orpcErrorMaps = (document: SwaggerDocument): Map<string, OrpcErrorEntry[]> => {
|
|
const schemas = getDocumentSchemas(document)
|
|
const errorMaps = new Map<string, OrpcErrorEntry[]>()
|
|
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
|
for (const [method, operation] of Object.entries(pathItem)) {
|
|
if (!operationMethods.has(method) || !isObject(operation)) continue
|
|
const { operationId, responses } = operation as SwaggerOperation
|
|
if (!operationId) continue
|
|
|
|
const entries = new Map<string, OrpcErrorEntry>()
|
|
for (const [status, response] of Object.entries(responses ?? {})) {
|
|
if (!/^[45]\d\d$/.test(status) || !isObject(response) || !isObject(response.content))
|
|
continue
|
|
const media = response.content['application/json']
|
|
const schema = isObject(media) ? media.schema : undefined
|
|
const options = Array.isArray(schema?.anyOf) ? schema.anyOf : [schema]
|
|
for (const option of options) {
|
|
const schemaName =
|
|
isObject(option) && typeof option.$ref === 'string'
|
|
? schemaNameFromRef(option.$ref)
|
|
: undefined
|
|
if (!schemaName) continue
|
|
const identity = errorComponentIdentity(schemas[schemaName])
|
|
if (!identity) continue
|
|
const known = entries.get(identity.code)
|
|
if (known && known.status !== identity.status)
|
|
throw new Error(
|
|
`${method.toUpperCase()} ${pathKey} documents ${identity.code} under ${known.status} and ${identity.status}; oRPC keys errors by code`,
|
|
)
|
|
entries.set(identity.code, { code: identity.code, schemaName, status: identity.status })
|
|
}
|
|
}
|
|
if (entries.size > 0)
|
|
errorMaps.set(
|
|
operationId,
|
|
[...entries.values()].sort((left, right) => left.code.localeCompare(right.code)),
|
|
)
|
|
}
|
|
}
|
|
return errorMaps
|
|
}
|
|
|
|
const createApiConfig = (job: ApiJob): UserConfig => ({
|
|
input: job.document,
|
|
logs: {
|
|
file: false,
|
|
},
|
|
output: {
|
|
module: { extension: '.ts' },
|
|
clean: false,
|
|
entryFile: false,
|
|
fileName: {
|
|
suffix: '.gen',
|
|
},
|
|
path: job.outputPath,
|
|
...(job.source ? { source: job.source } : {}),
|
|
},
|
|
plugins: job.plugins ?? [
|
|
typescriptPlugin(),
|
|
{
|
|
name: 'zod',
|
|
'~resolvers': {
|
|
union: (ctx) => {
|
|
// The generator's recursive lazy schemas infer `any`. Keep this JSON
|
|
// leaf typed through Zod so oRPC clients retain the request contract.
|
|
if (
|
|
ctx.path['~ref'].some(
|
|
(segment) =>
|
|
segment === 'AppConfigJsonValue' || segment === 'AppConfigJsonValueWritable',
|
|
)
|
|
)
|
|
return $(ctx.symbols.z).attr('json').call()
|
|
|
|
return ctx.nodes.base(ctx)
|
|
},
|
|
object: (ctx) => {
|
|
const objectSchema = ctx.nodes.base(ctx)
|
|
const additionalProperties = ctx.schema.additionalProperties
|
|
// Shaped objects otherwise discard the backend's typed JSON extensions.
|
|
if (
|
|
additionalProperties &&
|
|
typeof additionalProperties !== 'boolean' &&
|
|
additionalProperties.$ref === '#/components/schemas/AppConfigJsonValue' &&
|
|
ctx.schema.properties
|
|
)
|
|
return objectSchema.attr('catchall').call($(ctx.symbols.z).attr('json').call())
|
|
|
|
// openapi-ts normalizes `additionalProperties: false` to `never`, but
|
|
// does not make shaped Zod objects strict.
|
|
const isStrictSchema = ctx.path['~ref'].some(
|
|
(segment) => typeof segment === 'string' && strictZodSchemaNames.has(segment),
|
|
)
|
|
if (
|
|
isStrictSchema &&
|
|
(additionalProperties === false || additionalProperties?.type === 'never')
|
|
)
|
|
return objectSchema.attr('strict').call()
|
|
|
|
return objectSchema
|
|
},
|
|
string: (ctx) => {
|
|
if (ctx.schema.format === 'binary')
|
|
return $(ctx.symbols.z)
|
|
.attr('custom')
|
|
.call(
|
|
$.func((predicate) => {
|
|
const value = $.id('value')
|
|
const isBlob = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('Blob'))
|
|
const isFile = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('File'))
|
|
predicate.param('value')
|
|
predicate.do($.return($.binary(isBlob, '||', isFile)))
|
|
}),
|
|
)
|
|
.generic($.type.or($.type('Blob'), $.type('File')))
|
|
|
|
if (ctx.schema.pattern === pydanticDecimalStringPattern) {
|
|
// the pydantic generated regex will emit error like
|
|
// regexp/no-useless-assertions, so patch the regex here
|
|
return $(ctx.symbols.z)
|
|
.attr('string')
|
|
.call()
|
|
.attr('regex')
|
|
.call($.regexp(codegenSafeDecimalStringPattern))
|
|
}
|
|
|
|
return undefined
|
|
},
|
|
},
|
|
},
|
|
{
|
|
contracts: {
|
|
contractName: {
|
|
casing: 'camelCase',
|
|
name: '{{name}}',
|
|
},
|
|
nesting: contractPathSegments,
|
|
segmentName: {
|
|
casing: 'camelCase',
|
|
name: '{{name}}',
|
|
},
|
|
strategy: 'single',
|
|
},
|
|
name: 'orpc',
|
|
validator: 'zod',
|
|
},
|
|
],
|
|
})
|
|
|
|
export default defineConfig(apiJobs.map(createApiConfig))
|