diff --git a/.changeset/wide-canyons-give.md b/.changeset/wide-canyons-give.md new file mode 100644 index 00000000..525e70d0 --- /dev/null +++ b/.changeset/wide-canyons-give.md @@ -0,0 +1,5 @@ +--- +"wormajs": patch +--- + +correct the usage of pyloadModifier, optimize config hook calling diff --git a/docs/content/docs/plugin-system/builtin-plugins/payload-modifier.mdx b/docs/content/docs/plugin-system/builtin-plugins/payload-modifier.mdx index 041d91c8..4dcad3d9 100644 --- a/docs/content/docs/plugin-system/builtin-plugins/payload-modifier.mdx +++ b/docs/content/docs/plugin-system/builtin-plugins/payload-modifier.mdx @@ -33,7 +33,7 @@ export default defineConfig({ match: key => key === 'userId', handler: schema => { return { - 'attr1?': 'string', // 生成为可选参数 + attr1: { required: false, type: 'string' }, // 生成为可选参数 attr2: 'number', // 生成为必填参数 attr3: { // 嵌套数据 @@ -63,7 +63,7 @@ type ModifierScope = 'params' | 'pathParams' | 'data' | 'response'; - **基本类型**:`'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown' | 'any' | 'never'` - **数组类型**:`Schema[]`(由元素类型组成的原生数组,例如 `['string']` 表示 `string[]`,`['string', 'number']` 表示元组 `[string, number]`) -- **引用类型(对象)**:`{ [attr: string]: Schema }`(key 末尾加 `?` 表示可选参数) +- **引用类型(对象)**:`{ [attr: string]: Schema }`(可选属性使用 `SchemaOptional` 包装形式 `{ required: false, type: Schema }`) - **联合类型**:`{ oneOf: Schema[] } | { anyOf: Schema[] } | { allOf: Schema[] }` - **枚举类型**:`{ enum: Array; type?: SchemaPrimitive }`(值必须是给定集合之一,`type` 可选表示枚举元素的基础类型) - **可选普通类型**:`{ required: boolean; type: Schema }`(独立的普通类型字段本身可选时使用,以 `type` 字段为准) @@ -188,15 +188,17 @@ payloadModifier([ ### 可选参数与必填参数 -在 Schema 对象的 key 末尾添加 `?` 表示可选,否则为必填: +在 Schema 对象中,必填属性直接写;可选属性使用 `SchemaOptional` 包装形式 `{ required: false, type: Schema }` 表示: ```javascript handler: () => ({ - username: 'string', // 必填 - 'age?': 'number', // 可选 + username: 'string', // 必填 + age: { required: false, type: 'number' }, // 可选 }); ``` +`SchemaOptional` 与逐字段调用时「可选普通类型」的入参表示完全一致,因此整段调用和逐字段调用的 handler 处理逻辑可以统一。 + ### 使用正则匹配 匹配所有以 `_date` 结尾的 `params` 参数: diff --git a/packages/worma/src/functions/prepareConfig.ts b/packages/worma/src/functions/prepareConfig.ts index 8f1963a4..02c84482 100644 --- a/packages/worma/src/functions/prepareConfig.ts +++ b/packages/worma/src/functions/prepareConfig.ts @@ -13,6 +13,10 @@ export function extendsConfig(config: GeneratorConfig, newConfig: Partial { const result = srcValue(...args) diff --git a/packages/worma/src/generate.ts b/packages/worma/src/generate.ts index 8303dc27..b3c5b0fb 100644 --- a/packages/worma/src/generate.ts +++ b/packages/worma/src/generate.ts @@ -1,6 +1,6 @@ import type { Config, GenerateApiOptions, GeneratorProgressEvent } from '@/type/lib' import { PoolManager } from '@/core/workerPool/poolManager' -import { configHelper, logger, TemplateHelper } from '@/helper' +import { ConfigHelper, logger, TemplateHelper } from '@/helper' import { GeneratorHelper } from '@/helper/config/GeneratorHelper' import { ProgressTracker } from '@/helper/progress' @@ -21,12 +21,11 @@ async function generate(config: Config, options?: GenerateApiOptions): Promise, projectPath = process.cwd(), tracker?: ProgressTracker): Promise { this.projectPath = projectPath - logger.debug('ConfigHelper.load — loading config manager', { projectPath, generatorCount: config.generator?.length ?? 0 }) await this.configManager.load(config, projectPath, tracker) - logger.debug('ConfigHelper.load — reading cache data') await this.readAlovaJson() - logger.debug('ConfigHelper.load — complete') } - public async readUserConfig(userConfig: UserConfigExport) { + public static async readUserConfig(userConfig: UserConfigExport) { if (typeof userConfig === 'function') { return await userConfig() } @@ -91,5 +80,3 @@ export class ConfigHelper { return Promise.all(allAlovaJSon) } } - -export const configHelper = ConfigHelper.getInstance() diff --git a/packages/worma/src/helper/config/ConfigManager.ts b/packages/worma/src/helper/config/ConfigManager.ts index e49bd8ac..7052a95d 100644 --- a/packages/worma/src/helper/config/ConfigManager.ts +++ b/packages/worma/src/helper/config/ConfigManager.ts @@ -7,7 +7,6 @@ import { logger } from '@/helper/logger' import { zConfig } from './zType' export class ConfigManager { - private static instance: ConfigManager private config: Config private readConfig: Readonly @@ -16,17 +15,10 @@ export class ConfigManager { }) private readonly defaultGeneratorConfig = generatorHelper.getDefaultConfig() - private constructor() { + constructor() { this.config = this.defaultConfig } - public static getInstance(): ConfigManager { - if (!ConfigManager.instance) { - ConfigManager.instance = new ConfigManager() - } - return ConfigManager.instance - } - /** * 加载并验证配置 */ @@ -93,6 +85,3 @@ export class ConfigManager { return result } } - -// 导出单例实例 -export const configManager = ConfigManager.getInstance() diff --git a/packages/worma/src/plugins/presets/payloadModifier/hepler.ts b/packages/worma/src/plugins/presets/payloadModifier/hepler.ts index c5379c18..6216a665 100644 --- a/packages/worma/src/plugins/presets/payloadModifier/hepler.ts +++ b/packages/worma/src/plugins/presets/payloadModifier/hepler.ts @@ -16,8 +16,80 @@ import type { SchemaType, } from '@/type' +// Detect a `SchemaOptional` wrapper: { required: boolean, type: Schema }. +// The `required` must be a literal boolean so a plain SchemaReference whose +// property happens to be named "required" (e.g. { required: 'boolean' }) is not misread. +function isSchemaOptional(val: unknown): val is SchemaOptional { + return !!val + && typeof val === 'object' + && !Array.isArray(val) + && typeof (val as SchemaOptional).required === 'boolean' + && 'type' in (val as object) +} + +// Collapse (possibly nested) `SchemaOptional` wrappers. +// - The OUTERMOST `required` wins; inner `required` fields are ignored. +// - A non-wrapped value defaults to required (required: true). +function unwrapOptional(s: Schema): { required: boolean, type: Schema } { + if (!isSchemaOptional(s)) { + return { required: true, type: s } + } + const required = s.required + let type: Schema = s.type + while (isSchemaOptional(type)) { + type = type.type + } + return { required, type } +} + +// Remove the internal `_$ref` marker that `removeAll$ref` stamps onto dereferenced +// component schemas. When a handler replaces a schema, the result must NOT inherit the +// original component's `_$ref` — otherwise `mergeObject`/`removeBaseReference` downstream +// treats the replacement as a reference to the original component and discards the change. +function stripInternalRef(schema: any): any { + if (!schema || typeof schema !== 'object') { + return schema + } + if (Array.isArray(schema)) { + return schema.map(stripInternalRef) + } + const out: Record = {} + for (const key of Object.keys(schema)) { + if (key === '_$ref') { + continue + } + out[key] = stripInternalRef(schema[key]) + } + return out +} + +// Set of valid SchemaPrimitive values for O(1) validation lookup +const VALID_PRIMITIVES: ReadonlySet = new Set([ + 'number', + 'string', + 'boolean', + 'undefined', + 'null', + 'unknown', + 'any', + 'never', +]) + +function validatePrimitive(val: string): void { + if (!VALID_PRIMITIVES.has(val)) { + throw new Error( + `[payloadModifier] Invalid schema type "${val}". Must be one of: ${[...VALID_PRIMITIVES].join(', ')}`, + ) + } +} + // Convert Schema (custom spec) -> OpenAPI SchemaObject function toSchemaObject(base: SchemaObject, s: Schema): SchemaObject { + // A `SchemaOptional` wrapper only affects requiredness (handled by the caller); + // here we care about the type shape, so fully unwrap nested wrappers first. + if (isSchemaOptional(s)) { + s = unwrapOptional(s).type + } const result: SchemaObject = { ...base } const cleanType = (schema: SchemaObject) => { delete schema.type @@ -42,8 +114,9 @@ function toSchemaObject(base: SchemaObject, s: Schema): SchemaObject { return result } - // Primitive types and no-op primitives + // Primitive types — validate against SchemaPrimitive set during conversion if (typeof s === 'string') { + validatePrimitive(s) result.type = s as SchemaType return result } @@ -76,13 +149,17 @@ function toSchemaObject(base: SchemaObject, s: Schema): SchemaObject { const spec = s as SchemaEnum result.enum = spec.enum if (spec.type) { + if (typeof spec.type === 'string') { + validatePrimitive(spec.type) + } result.type = spec.type as any } return result } // Object (reference-like map): replace properties and required with handler's spec - // (the SchemaReference returned by the handler fully replaces this field, only keeping scalar fields like description from base) + // (the SchemaReference returned by the handler fully replaces this field, only keeping + // scalar fields like description from base) const ref = s as SchemaReference if (ref && typeof ref === 'object') { result.type = 'object' @@ -94,15 +171,27 @@ function toSchemaObject(base: SchemaObject, s: Schema): SchemaObject { if (!val) { continue } - const optional = key.endsWith('?') - const cleanKey = optional ? key.slice(0, -1) : key - const baseProp = properties[cleanKey] - properties[cleanKey] = toSchemaObject(baseProp || {}, val) - if (optional) { - requiredSet.delete(cleanKey) + // SchemaOptional wrapper: optionality expressed via { required, type }; + // bare value defaults to required. Nested wrappers are collapsed — outermost + // `required` wins, inner ones are ignored. + let isOptional: boolean + let effectiveVal: Schema + if (isSchemaOptional(val)) { + const { required, type } = unwrapOptional(val) + isOptional = !required + effectiveVal = type } else { - requiredSet.add(cleanKey) + isOptional = false + effectiveVal = val + } + const baseProp = properties[key] + properties[key] = toSchemaObject(baseProp || {}, effectiveVal) + if (isOptional) { + requiredSet.delete(key) + } + else { + requiredSet.add(key) } } @@ -173,8 +262,8 @@ function toSchemaSpec(obj: SchemaObject): Schema { const result: SchemaReference = {} for (const key of Object.keys(properties)) { const spec = toSchemaSpec(properties[key]) - const finalKey = requiredSet.has(key) ? key : `${key}?` - result[finalKey] = spec + // Required fields are written bare; optional fields wrapped with SchemaOptional + result[key] = requiredSet.has(key) ? spec : { required: false, type: spec } } return result } @@ -190,18 +279,20 @@ function toSchemaSpec(obj: SchemaObject): Schema { interface ApplyModifierSchemaOptions { required: boolean + // The matched field key. `undefined` when the whole scope object is passed (no `match`). + key?: string } // Replace whole schema based on handler result (used for params/pathParams) export function applyModifierSchema( schema: T, config: PayloadModifierConfig, - { required }: ApplyModifierSchemaOptions, + { required, key }: ApplyModifierSchemaOptions, ): { required: boolean schema: T | null } { if (!schema || typeof schema !== 'object') { - return schema + return { required, schema: schema as T } } const cloned: SchemaObject = { ...schema } @@ -211,23 +302,38 @@ export function applyModifierSchema( = (required === false && typeof currentSpec === 'string') ? { required: false, type: currentSpec } : currentSpec - const ret = config.handler(handlerInput) + const ret = config.handler(handlerInput, key) if (!ret) { return { required, schema: null, } } - // A returned { required, type } means changing requiredness (driven by the `type` field) - if (typeof ret === 'object' && !Array.isArray(ret) && 'required' in ret && 'type' in ret) { - const opt = ret as SchemaOptional + // A returned SchemaOptional means changing requiredness (driven by the `type` field). + // Nested wrappers are collapsed: the outermost `required` wins, inner ones are ignored; + // `type` may be any Schema expression (primitive, object, array, union, ...). + if (isSchemaOptional(ret)) { + const { required: nextRequired, type } = unwrapOptional(ret) + let r = stripInternalRef(toSchemaObject(cloned, type)) as T + // When handler explicitly sets required=false, propagate to object-level required array + // so all properties become nullable as semantically expected + if (!nextRequired && r && typeof r === 'object' && !Array.isArray(r)) { + const robj = r as Record + if (robj.type === 'object' && Array.isArray(robj.required) && robj.required.length > 0) { + r = { ...r, required: [] } as T + } + } return { - required: !!(opt.required ?? required), - schema: toSchemaObject(cloned, opt.type) as T, + required: nextRequired, + schema: r, } } + // Non-SchemaOptional return: handler explicitly provides a type value, + // so default to required=true (the handler had the chance to wrap with + // { required: false, type: ... } if it wanted to keep it optional). + const r = stripInternalRef(toSchemaObject(cloned, ret)) as T return { - required, - schema: toSchemaObject(cloned, ret) as T, + required: true, + schema: r, } } diff --git a/packages/worma/src/plugins/presets/payloadModifier/index.ts b/packages/worma/src/plugins/presets/payloadModifier/index.ts index 94947d89..868e0294 100644 --- a/packages/worma/src/plugins/presets/payloadModifier/index.ts +++ b/packages/worma/src/plugins/presets/payloadModifier/index.ts @@ -2,7 +2,6 @@ import type { PayloadModifierConfig } from './type' import type { ApiDescriptor, ApiPlugin, - MaybeSchemaObject, Parameter, SchemaObject, } from '@/type' @@ -11,14 +10,64 @@ import { extend, isMatch } from '../utils' import { applyModifierSchema } from './hepler' export { Schema, SchemaAllOf, SchemaAnyOf, SchemaArray, SchemaEnum, SchemaOneOf, SchemaPrimitive, SchemaReference } from './type' -// Apply modifications to object properties (for data/response scopes) -function modifySchemaProperties(schema: T, config: PayloadModifierConfig): T { + +// Convert parameters of a specific type (query/path) into an object schema +function parametersToSchema(parameters: Parameter[] | undefined, type: ParameterIn): SchemaObject { + if (!parameters || !Array.isArray(parameters)) { + return { type: 'object', properties: {}, required: [] } + } + const schema: SchemaObject = { type: 'object', properties: {}, required: [] } + for (const param of parameters) { + if (param.in === type) { + ;(schema.properties as Record)[param.name] = param.schema as SchemaObject + if (param.required) { + ;(schema.required as string[]).push(param.name) + } + } + } + return schema +} + +// Convert an object schema back to parameters, keeping other types untouched +function schemaToParameters( + parameters: Parameter[] | undefined, + schema: SchemaObject | null, + type: ParameterIn, +): Parameter[] | undefined { + if (!parameters || !Array.isArray(parameters)) { + return parameters + } + if (!schema || typeof schema !== 'object' || !schema.properties) { + return parameters.filter(param => param.in !== type) + } + const requiredSet = new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []) + const newParameters: Parameter[] = [] + for (const param of parameters) { + if (param.in !== type) { + newParameters.push(param) + continue + } + const propSchema = (schema.properties as Record>)[param.name] + if (!propSchema) { + continue + } + newParameters.push({ + ...param, + schema: propSchema as Parameter['schema'], + required: requiredSet.has(param.name), + }) + } + return newParameters +} + +// Apply modifications to properties of an object schema (used when `match` is set) +function modifySchemaProperties(schema: T, config: PayloadModifierConfig): T { if (!schema || typeof schema !== 'object') { return schema } const targetSchema: SchemaObject = { ...schema } - // union recursively + // recurse into union keywords if (Array.isArray(targetSchema.oneOf)) { targetSchema.oneOf = targetSchema.oneOf.map(item => modifySchemaProperties(item, config)) } @@ -28,7 +77,7 @@ function modifySchemaProperties(sche if (Array.isArray(targetSchema.allOf)) { targetSchema.allOf = targetSchema.allOf.map(item => modifySchemaProperties(item, config)) } - // modify properties + // modify matched properties if (targetSchema.properties) { const props = { ...targetSchema.properties } as Record let required: string[] = Array.isArray(targetSchema.required) ? [...targetSchema.required] : [] @@ -37,7 +86,11 @@ function modifySchemaProperties(sche if (!isMatch(key, config.match)) { continue } - const { required: requiredOverride, schema: schemaValue } = applyModifierSchema(props[key], config, { required: required.includes(key) }) + const { required: requiredOverride, schema: schemaValue } = applyModifierSchema( + props[key], + config, + { required: required.includes(key), key }, + ) required = required.filter(r => r !== key) if (!schemaValue) { delete props[key] @@ -53,64 +106,83 @@ function modifySchemaProperties(sche } return targetSchema as T } + +// Apply modifications to matched parameters (used when `match` is set for params/pathParams) function modifyParameters( - parameters: Array, + parameters: Parameter[], type: ParameterIn, config: PayloadModifierConfig, -): Array { +): Parameter[] { if (!parameters || !Array.isArray(parameters)) { return parameters } return parameters.map((param) => { - if (param.in === type) { - if (!isMatch(param.name, config.match)) { - return param - } - const { schema, required } = applyModifierSchema(param.schema!, config, { required: !!param.required }) - if (!schema) { - return null - } - return { - ...param, - schema, - required, - } + if (param.in !== type || !isMatch(param.name, config.match)) { + return param } - return param + const { schema, required } = applyModifierSchema( + param.schema!, + config, + { required: !!param.required, key: param.name }, + ) + if (!schema) { + return null + } + return { ...param, schema, required } }).filter(item => item !== null) } + +// Apply config to a parameter scope (params or pathParams) +function applyToParameters( + parameters: Parameter[] | undefined, + type: ParameterIn, + config: PayloadModifierConfig, +): Parameter[] | undefined { + if (!parameters) + return undefined + if (config.match) { + return modifyParameters(parameters, type, config) + } + const schema = parametersToSchema(parameters, type) + const result = applyModifierSchema(schema, config, { required: false }) + return schemaToParameters(parameters, result.schema, type) +} + +// Apply config to a schema scope (data or response) +function applyToSchemaField( + schema: SchemaObject | undefined, + config: PayloadModifierConfig, +): SchemaObject | undefined { + if (!schema) + return undefined + if (config.match) { + return modifySchemaProperties(schema, config) + } + return applyModifierSchema(schema, config, { required: false }).schema ?? undefined +} + function payloadModifierApiDescriptor(apiDescriptor: ApiDescriptor, config: PayloadModifierConfig) { - if (!apiDescriptor) { + if (!apiDescriptor) return null - } const newDescriptor = { ...apiDescriptor } const { scope } = config switch (scope) { case 'params': - if (newDescriptor.parameters) { - newDescriptor.parameters = modifyParameters(newDescriptor.parameters, ParameterIn.QUERY, config) - } + newDescriptor.parameters = applyToParameters(newDescriptor.parameters, ParameterIn.QUERY, config) break case 'pathParams': - if (newDescriptor.parameters) { - newDescriptor.parameters = modifyParameters(newDescriptor.parameters, ParameterIn.PATH, config) - } + newDescriptor.parameters = applyToParameters(newDescriptor.parameters, ParameterIn.PATH, config) break case 'data': - if (newDescriptor.requestBody) { - newDescriptor.requestBody = modifySchemaProperties(newDescriptor.requestBody, config) - } + newDescriptor.requestBody = applyToSchemaField(newDescriptor.requestBody, config) break case 'response': - if (newDescriptor.responses) { - newDescriptor.responses = modifySchemaProperties(newDescriptor.responses, config) - } - break - default: + newDescriptor.responses = applyToSchemaField(newDescriptor.responses, config) break } return newDescriptor } + export function payloadModifier(configs: PayloadModifierConfig[]): ApiPlugin { return { name: PluginName.PAYLOAD_MODIFIER, diff --git a/packages/worma/src/plugins/presets/payloadModifier/type.ts b/packages/worma/src/plugins/presets/payloadModifier/type.ts index 10f473e7..71ecf859 100644 --- a/packages/worma/src/plugins/presets/payloadModifier/type.ts +++ b/packages/worma/src/plugins/presets/payloadModifier/type.ts @@ -10,7 +10,9 @@ export type SchemaArray = Schema[] /** * Object/reference type. - * Append '?' to the end of a key to mark it optional. + * Required properties are written directly; optional properties are wrapped + * with the `SchemaOptional` form `{ required: false, type: Schema }` + * (consistent with how a standalone optional primitive is represented). */ export interface SchemaReference { [attr: string]: Schema @@ -44,8 +46,8 @@ export interface SchemaOptional { * The data Schema. * - SchemaArray is a native array (elements are Schemas) * - composite types use { oneOf | anyOf | allOf: Schema[] } - * - optional object properties use a trailing '?' on the key; - * a standalone optional primitive uses the SchemaOptional wrapper + * - optional object properties are wrapped with `SchemaOptional` ({ required: false, type: Schema }); + * a standalone optional primitive uses the same SchemaOptional wrapper */ export type Schema = SchemaPrimitive @@ -74,10 +76,12 @@ interface ModifierConfig { * @param schema the original field type, already converted to the user-facing Schema representation. * When the field itself is optional and is a primitive, it is passed as { required: false, type: 'string' }. * Narrow the type inside handler if needed (e.g. with a cast). + * @param key the matched field key. When `match` is omitted, the whole scope object is passed to the handler + * once and `key` is `undefined`; when `match` is set, `key` is the matched field name for each call. * @returns Schema to change the type; { required: boolean, type: Schema } to change requiredness (driven by `type`); * void | null | undefined to remove the field. */ - handler: (schema: Schema) => Schema | { required: boolean, type: Schema } | void | null | undefined + handler: (schema: Schema, key?: string) => Schema | { required: boolean, type: Schema } | void | null | undefined } export type PayloadModifierConfig = ModifierConfig diff --git a/packages/worma/src/readConfig.ts b/packages/worma/src/readConfig.ts index a018ff5b..c68c7535 100644 --- a/packages/worma/src/readConfig.ts +++ b/packages/worma/src/readConfig.ts @@ -3,7 +3,7 @@ import { unlink } from 'node:fs/promises' import path from 'node:path' import esbuild from 'esbuild' import { readAllCacheApis, readCacheApis } from '@/functions/wormaJson' -import { configHelper, logger } from '@/helper' +import { ConfigHelper, logger } from '@/helper' import { getUserInstalledDependencies, resolveConfigFile } from '@/utils' import { readWormaRc } from './functions/readWormaRc' /** @@ -29,7 +29,6 @@ export async function readConfig(projectPath = process.cwd()) { name: 'readConfig', }) } - await configHelper.load(config, projectPath) return config } @@ -63,10 +62,8 @@ export async function readConfig(projectPath = process.cwd()) { finally { await unlink(outfile) } - const config = await configHelper.readUserConfig(module.default || module) - // Read the cache file and save it - await configHelper.load(config, projectPath) - return configHelper.getConfig() + const config = await ConfigHelper.readUserConfig(module.default || module) + return config } /** diff --git a/packages/worma/test/config.spec.ts b/packages/worma/test/config.spec.ts index 41fc081f..cccc3284 100644 --- a/packages/worma/test/config.spec.ts +++ b/packages/worma/test/config.spec.ts @@ -143,9 +143,6 @@ export default { input: 'http://localhost:3000/alova-devtools', output: 'src/api', type: 'ts', - bodyMediaType: 'application/json', - responseMediaType: 'application/json', - defaultRequire: false, plugins: [{ getTemplate() { return { path: '' } } }], }, ], @@ -170,9 +167,6 @@ export default { input: 'http://localhost:3000/', output: 'src/api', type: 'ts', - bodyMediaType: 'application/json', - responseMediaType: 'application/json', - defaultRequire: false, plugins: [{ getTemplate() { return { path: '' } } }], }, ], @@ -197,9 +191,6 @@ export default { input: 'http://localhost:3000/alova-devtools', output: 'src/api', type: 'module', - bodyMediaType: 'application/json', - responseMediaType: 'application/json', - defaultRequire: false, plugins: [{ getTemplate() { return { path: '' } } }], }, ], @@ -224,9 +215,6 @@ module.exports = { input: 'http://localhost:3000/alova-devtools', output: 'src/api', type: 'commonjs', - bodyMediaType: 'application/json', - responseMediaType: 'application/json', - defaultRequire: false, plugins: [{ getTemplate() { return { path: '' } } }], }, ], diff --git a/packages/worma/test/defineConfig.spec.ts b/packages/worma/test/defineConfig.spec.ts index bb2e520f..e47064ce 100644 --- a/packages/worma/test/defineConfig.spec.ts +++ b/packages/worma/test/defineConfig.spec.ts @@ -42,18 +42,6 @@ describe('defineConfig', () => { }) describe('configHelper.readUserConfig', () => { - let configHelper: ConfigHelper - - beforeEach(() => { - configHelper = ConfigHelper.getInstance() - // 重置单例实例 - vi.spyOn(ConfigHelper, 'getInstance').mockReturnValue(configHelper) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - it('should return the config object when passed directly', async () => { const config: UserConfig = { generator: [{ @@ -62,7 +50,7 @@ describe('configHelper.readUserConfig', () => { plugins: [{ getTemplate: () => ({ path: 'template' }) }], }], } - const result = await configHelper.readUserConfig(config) + const result = await ConfigHelper.readUserConfig(config) expect(result).toEqual(config) }) @@ -74,7 +62,7 @@ describe('configHelper.readUserConfig', () => { plugins: [{ getTemplate: () => ({ path: 'template' }) }], }], }) - const result = await configHelper.readUserConfig(configFn) + const result = await ConfigHelper.readUserConfig(configFn) expect(result.generator[0].input).toBe('./openapi.json') expect(result.generator[0].output).toBe('./src/api') expect(typeof result.generator[0].plugins[0].getTemplate).toBe('function') @@ -88,7 +76,7 @@ describe('configHelper.readUserConfig', () => { plugins: [{ getTemplate: () => ({ path: 'template' }) }], }], }) - const result = await configHelper.readUserConfig(configPromise) + const result = await ConfigHelper.readUserConfig(configPromise) expect(result.generator[0].input).toBe('./openapi.json') expect(result.generator[0].output).toBe('./src/api') expect(typeof result.generator[0].plugins[0].getTemplate).toBe('function') @@ -102,7 +90,7 @@ describe('configHelper.readUserConfig', () => { plugins: [{ getTemplate: () => ({ path: 'template' }) }], }], }) - const result = await configHelper.readUserConfig(asyncConfigFn) + const result = await ConfigHelper.readUserConfig(asyncConfigFn) expect(result.generator[0].input).toBe('./openapi.json') expect(result.generator[0].output).toBe('./src/api') expect(typeof result.generator[0].plugins[0].getTemplate).toBe('function') diff --git a/packages/worma/test/plugins/payloadModifier.spec.ts b/packages/worma/test/plugins/payloadModifier.spec.ts index a65532c4..9a395884 100644 --- a/packages/worma/test/plugins/payloadModifier.spec.ts +++ b/packages/worma/test/plugins/payloadModifier.spec.ts @@ -103,8 +103,8 @@ describe('payloadModifier plugin tests', () => { expect(rb.properties?.count).toBeUndefined() // 返回原生数组应生成 array 类型 expect(rb.properties?.tags).toEqual({ type: 'array', items: { type: 'string' } }) - // required 现在只包含 name - expect(rb.required).toEqual(['name']) + // required 现在包含 name 和 tags(tags handler 返回非SchemaOptional的['string'],默认 required: true) + expect(rb.required).toEqual(['name', 'tags']) }) it('returns nested object with optional keys', () => { @@ -113,8 +113,8 @@ describe('payloadModifier plugin tests', () => { scope: 'data', match: 'user', handler: () => ({ - 'username': 'string', - 'age?': 'number', + username: 'string', + age: { required: false, type: 'number' }, }), }, ]) @@ -201,15 +201,22 @@ describe('payloadModifier plugin tests', () => { const spec = schema as Record const next: Record = {} for (const key of Object.keys(spec)) { - const cleanKey = key.endsWith('?') ? key.slice(0, -1) : key - if (cleanKey === 'name') { + const val = spec[key] + // 解包 SchemaOptional(可选字段的入参形式) + const isOpt = val && typeof val === 'object' && !Array.isArray(val) + && typeof val.required === 'boolean' && 'type' in val + const unwrapped = isOpt ? val.type : val + if (key === 'name') { continue } - if (cleanKey === 'id') { + if (key === 'id') { next.id = 'string' } + else if (isOpt) { + next[key] = { required: false, type: unwrapped } + } else { - next[key] = spec[key] + next[key] = unwrapped } } next.createdAt = 'string' @@ -238,8 +245,8 @@ describe('payloadModifier plugin tests', () => { } const result = handleApi(api)! - // 入参为 data 子对象的 SchemaReference(可选属性带 ? 后缀) - expect(input).toEqual({ 'id': 'number', 'name?': 'string' }) + // 入参为 data 子对象的 SchemaReference(可选属性用 SchemaOptional 包装) + expect(input).toEqual({ id: 'number', name: { required: false, type: 'string' } }) // data 字段被转换:id -> string,name 移除,新增 createdAt 且均为必填 const res = result.responses as SchemaObject expect(res.properties?.data).toEqual({ @@ -431,7 +438,7 @@ describe('payloadModifier plugin tests', () => { match: 'data', handler: (schema) => { input = schema - // 入参形如 { 'list?': [ { id:'number', 'name?':'string' } ] } + // 入参形如 { list: { required: false, type: [ { id: {required:false,type:'number'}, name: {required:false,type:'string'} } ] } } return { list: [{ id: 'string', name: 'string' }], } @@ -463,8 +470,13 @@ describe('payloadModifier plugin tests', () => { } const result = handleApi(api)! - // 入参形如 { 'list?': [ { 'id?':'number', 'name?':'string' } ] }(item 未声明 required,字段均为可选) - expect(input).toEqual({ 'list?': [{ 'id?': 'number', 'name?': 'string' }] }) + // 入参形如 { list: { required: false, type: [ { id: {required:false,type:'number'}, name: {required:false,type:'string'} } ] } }(list 与 item 字段均为可选) + expect(input).toEqual({ + list: { + required: false, + type: [{ id: { required: false, type: 'number' }, name: { required: false, type: 'string' } }], + }, + }) const res = result.responses as SchemaObject expect(res.properties?.data).toEqual({ type: 'object', @@ -482,6 +494,68 @@ describe('payloadModifier plugin tests', () => { }) }) + it('collapses nested SchemaOptional (outer required wins, inner ignored) with object type', () => { + const handleApi = getHandleApi([ + { + scope: 'data', + handler: () => ({ + required: true, + type: { + // 内层 required=false 被忽略,type 为完整对象表达 + required: false, + type: { + id: 'number', // 未包装 -> 默认必填 + name: { required: false, type: 'string' }, // 可选 + }, + }, + }), + }, + ]) + + const api: ApiDescriptor = { + url: '/create', + method: 'post', + parameters: [], + requestBody: { type: 'object', properties: {}, required: [] }, + responses: { type: 'object', properties: {}, required: [] }, + } + + const result = handleApi(api)! + expect(result.requestBody).toEqual({ + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id'], + }) + }) + + it('collapses deeply nested SchemaOptional on a param (outermost required wins)', () => { + const handleApi = getHandleApi([ + { + scope: 'params', + match: 'token', + handler: () => ({ + required: true, + type: { required: false, type: { required: false, type: 'string' } }, + }), + }, + ]) + + const api: ApiDescriptor = { + url: '/x', + method: 'get', + parameters: [ + { name: 'token', in: 'query', required: false, schema: { type: 'integer' } }, + ], + requestBody: { type: 'object', properties: {}, required: [] }, + responses: { type: 'object', properties: {}, required: [] }, + } + + const result = handleApi(api)! + const tokenParam = result.parameters!.find(p => p.name === 'token')! + expect((tokenParam.schema as SchemaObject).type).toBe('string') + expect(tokenParam.required).toBe(true) + }) + it('handler can return any/unknown/undefined/null/never primitive types', () => { const handleApi = getHandleApi([ { scope: 'params', match: 'a', handler: () => 'any' }, @@ -570,9 +644,29 @@ describe('payloadModifier plugin tests', () => { expect((rb.properties?.other as SchemaObject)?.type).toBe('boolean') // unmatched, untouched }) - it('match omitted transforms every field in scope', () => { + it('match omitted (data): handler is called once on the whole scope object with key undefined', () => { + let calls = 0 + let receivedKey: any + let received: any const handleApi = getHandleApi([ - { scope: 'data', handler: () => 'boolean' }, + { + scope: 'data', + handler: (schema, key) => { + calls++ + receivedKey = key + received = schema + // 在整体对象上把每个字段都改成 boolean,保留可选标记 + const s = schema as Record + const next: Record = {} + for (const k of Object.keys(s)) { + const val = s[k] + const isOpt = val && typeof val === 'object' && !Array.isArray(val) + && typeof val.required === 'boolean' && 'type' in val + next[k] = isOpt ? { required: false, type: 'boolean' } : 'boolean' + } + return next + }, + }, ]) const api: ApiDescriptor = { @@ -592,14 +686,108 @@ describe('payloadModifier plugin tests', () => { } const result = handleApi(api)! + // 整个 scope 只调用一次 + expect(calls).toBe(1) + // match 省略时 key 为 undefined + expect(receivedKey).toBeUndefined() + // 入参为整个 requestBody 对象,可选属性用 SchemaOptional 包装(integer 规范为 number) + expect(received).toEqual({ a: 'string', b: { required: false, type: 'number' }, c: { required: false, type: 'boolean' } }) + // 每个字段都被改成 boolean,必填关系保持不变 const rb = result.requestBody as SchemaObject expect((rb.properties?.a as SchemaObject)?.type).toBe('boolean') expect((rb.properties?.b as SchemaObject)?.type).toBe('boolean') expect((rb.properties?.c as SchemaObject)?.type).toBe('boolean') - // required is unaffected (originally only 'a') expect(rb.required).toEqual(['a']) }) + it('match omitted (params): handler is called once on the whole query object with key undefined', () => { + let calls = 0 + let receivedKey: any + let received: any + const handleApi = getHandleApi([ + { + scope: 'params', + handler: (schema, key) => { + calls++ + receivedKey = key + received = schema + return schema + }, + }, + ]) + + const api: ApiDescriptor = { + url: '/x', + method: 'get', + parameters: [ + { name: 'a', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'b', in: 'query', required: false, schema: { type: 'integer' } }, + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { type: 'object', properties: {}, required: [] }, + responses: { type: 'object', properties: {}, required: [] }, + } + + const result = handleApi(api)! + // 整个 query scope 只调用一次 + expect(calls).toBe(1) + // match 省略时 key 为 undefined + expect(receivedKey).toBeUndefined() + // 入参为整个 query 对象(仅 query 参数,path 参数不在此列),可选属性用 SchemaOptional 包装(integer 规范为 number) + expect(received).toEqual({ a: 'string', b: { required: false, type: 'number' } }) + // 原结构保持不变,path 参数不受影响(integer 经 Schema 表示往返后规范为 number) + const getType = (n: string) => (result.parameters!.find(p => p.name === n)!.schema as SchemaObject).type + expect(getType('a')).toBe('string') + expect(getType('b')).toBe('number') + expect(getType('id')).toBe('string') + }) + + it('match set: handler receives the matched key as the 2nd argument', () => { + const keys: string[] = [] + const handleApi = getHandleApi([ + // 字符串精确匹配 + { scope: 'params', match: 'age', handler: (_s, key) => { + keys.push(key as string) + return 'string' + } }, + // 正则匹配 + { scope: 'params', match: /At$/, handler: (_s, key) => { + keys.push(key as string) + return 'string' + } }, + // 函数匹配 + { + scope: 'data', + match: (k: string) => k.startsWith('user'), + handler: (_s, key) => { + keys.push(key as string) + return 'string' + }, + }, + ]) + + const api: ApiDescriptor = { + url: '/x', + method: 'post', + parameters: [ + { name: 'age', in: 'query', required: false, schema: { type: 'integer' } }, + { name: 'createdAt', in: 'query', required: false, schema: { type: 'string' } }, + { name: 'updatedAt', in: 'query', required: false, schema: { type: 'string' } }, + { name: 'name', in: 'query', required: false, schema: { type: 'integer' } }, + ], + requestBody: { + type: 'object', + properties: { user_name: { type: 'string' }, user_age: { type: 'integer' }, other: { type: 'boolean' } }, + required: [], + }, + responses: { type: 'object', properties: {}, required: [] }, + } + + handleApi(api) + // 命中的字段按顺序记录,未命中的 name/other 不在其中 + expect(keys).toEqual(['age', 'createdAt', 'updatedAt', 'user_name', 'user_age']) + }) + it('handler can return SchemaEnum to produce an enum field', () => { const handleApi = getHandleApi([ { @@ -628,4 +816,68 @@ describe('payloadModifier plugin tests', () => { const handleApi = getHandleApi([{ scope: 'params', match: 'x', handler: () => 'string' }]) expect(handleApi(null as any)).toBeNull() }) + + describe('validation: handler return value', () => { + function api(): ApiDescriptor { + return { + url: '/x', + method: 'get', + parameters: [{ name: 'age', in: 'query', required: false, schema: { type: 'string' } }], + requestBody: { type: 'object', properties: { name: { type: 'string' } }, required: [] }, + responses: { type: 'object', properties: {}, required: [] }, + } + } + + it('throws on invalid primitive type', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => 'int64' }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in SchemaOptional.type', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ required: false, type: 'int64' }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in oneOf', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ oneOf: ['int64', 'string'] }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in anyOf', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ anyOf: ['int64', 'string'] }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in allOf', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ allOf: ['int64', 'string'] }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid type in SchemaEnum', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ enum: ['a', 'b'], type: 'int64' }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in array element', () => { + const handleApi = getHandleApi([{ scope: 'data', match: 'name', handler: () => ['int64'] }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in nested SchemaReference', () => { + const handleApi = getHandleApi([{ scope: 'data', match: 'name', handler: () => ({ key: 'int64' }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('throws on invalid primitive in deeply nested SchemaOptional', () => { + const handleApi = getHandleApi([{ scope: 'params', match: 'age', handler: () => ({ required: true, type: { required: false, type: 'int64' } }) }]) + expect(() => handleApi(api())).toThrow(/Invalid schema type "int64"/) + }) + + it('does not throw for all valid SchemaPrimitive values', () => { + const handleApi = getHandleApi([ + { scope: 'params', match: 'age', handler: () => 'number' }, + ]) + expect(() => handleApi(api())).not.toThrow() + }) + }) }) diff --git a/packages/worma/typings/index.d.ts b/packages/worma/typings/index.d.ts index bae07ac4..9946d58f 100644 --- a/packages/worma/typings/index.d.ts +++ b/packages/worma/typings/index.d.ts @@ -497,7 +497,7 @@ export declare const logger: Logger$1; * @param projectPath The project path where the configuration file is located. The default value is `process.cwd()`. * @returns a promise instance that contains configuration object. */ -export declare function readConfig(projectPath?: string): Promise>; +export declare function readConfig(projectPath?: string): Promise; /** * Get cached API docs. Cache is self-describing — no config needed. * In monorepo, pass ANY sub-package path; cache is always read from the unified cacheRoot. diff --git a/packages/worma/typings/plugins.d.ts b/packages/worma/typings/plugins.d.ts index 256b30b5..d230bb3d 100644 --- a/packages/worma/typings/plugins.d.ts +++ b/packages/worma/typings/plugins.d.ts @@ -542,7 +542,9 @@ export type SchemaPrimitive = "number" | "string" | "boolean" | "undefined" | "n export type SchemaArray = Schema[]; /** * Object/reference type. - * Append '?' to the end of a key to mark it optional. + * Required properties are written directly; optional properties are wrapped + * with the `SchemaOptional` form `{ required: false, type: Schema }` + * (consistent with how a standalone optional primitive is represented). */ export interface SchemaReference { [attr: string]: Schema; @@ -578,8 +580,8 @@ export interface SchemaOptional { * The data Schema. * - SchemaArray is a native array (elements are Schemas) * - composite types use { oneOf | anyOf | allOf: Schema[] } - * - optional object properties use a trailing '?' on the key; - * a standalone optional primitive uses the SchemaOptional wrapper + * - optional object properties are wrapped with `SchemaOptional` ({ required: false, type: Schema }); + * a standalone optional primitive uses the same SchemaOptional wrapper */ export type Schema = SchemaPrimitive | SchemaReference | SchemaArray | SchemaEnum | SchemaOneOf | SchemaAnyOf | SchemaAllOf | SchemaOptional; export interface ModifierConfig { @@ -599,10 +601,12 @@ export interface ModifierConfig { * @param schema the original field type, already converted to the user-facing Schema representation. * When the field itself is optional and is a primitive, it is passed as { required: false, type: 'string' }. * Narrow the type inside handler if needed (e.g. with a cast). + * @param key the matched field key. When `match` is omitted, the whole scope object is passed to the handler + * once and `key` is `undefined`; when `match` is set, `key` is the matched field name for each call. * @returns Schema to change the type; { required: boolean, type: Schema } to change requiredness (driven by `type`); * void | null | undefined to remove the field. */ - handler: (schema: Schema) => Schema | { + handler: (schema: Schema, key?: string) => Schema | { required: boolean; type: Schema; } | void | null | undefined;