Files
dify/packages/contracts/openapi-ts.api.config.ts
Stephen Zhouandautofix-ci[bot] 04ba877c1a feat(web): export OpenAPI mapper types and compatible contract subpaths (#44915)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-28 14:39:37 +00:00

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))