Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wide-canyons-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wormajs": patch
---

correct the usage of pyloadModifier, optimize config hook calling
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default defineConfig({
match: key => key === 'userId',
handler: schema => {
return {
'attr1?': 'string', // 生成为可选参数
attr1: { required: false, type: 'string' }, // 生成为可选参数
attr2: 'number', // 生成为必填参数
attr3: {
// 嵌套数据
Expand Down Expand Up @@ -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<string | number | boolean | null>; type?: SchemaPrimitive }`(值必须是给定集合之一,`type` 可选表示枚举元素的基础类型)
- **可选普通类型**:`{ required: boolean; type: Schema }`(独立的普通类型字段本身可选时使用,以 `type` 字段为准)
Expand Down Expand Up @@ -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` 参数:
Expand Down
4 changes: 4 additions & 0 deletions packages/worma/src/functions/prepareConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export function extendsConfig(config: GeneratorConfig, newConfig: Partial<Genera

// handleApi is a special case, we need to merge the functions
if (typeof newValue === 'function' && typeof srcValue === 'function') {
// Avoid chaining the same function reference with itself (idempotency guard).
if (newValue === srcValue) {
return newValue
}
// chain the functions
return (...args: any[]) => {
const result = srcValue(...args)
Expand Down
13 changes: 6 additions & 7 deletions packages/worma/src/generate.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -21,12 +21,11 @@ async function generate(config: Config, options?: GenerateApiOptions): Promise<b
const projectPath = options?.projectPath ?? process.cwd()
const emit = options?.onProgress

// Load phase (shared, no per-gen events during load) — plugins may modify generator configs
logger.debug('Loading config', { projectPath })
await configHelper.load(config, projectPath)
// Use the processed generators from ConfigManager (after plugin hooks have run)
const generators = configHelper.getConfig().generator
logger.debug('Config loaded', { generatorCount: generators.length })
// Each generate() call creates its own ConfigHelper / ConfigManager,
// so multiple concurrent calls never share mutable config state.
const helper = new ConfigHelper()
await helper.load(config, projectPath)
const generators = helper.getConfig().generator

// Run all generators in parallel, each with its own ProgressTracker
const results = await Promise.all(
Expand Down
17 changes: 2 additions & 15 deletions packages/worma/src/helper/config/ConfigHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,20 @@ import type { ProgressTracker } from '@/helper/progress'
import type { Config, GeneratorConfig, UserConfigExport } from '@/type'
import { isArray } from 'lodash'
import { TemplateHelper } from '@/helper'
import { logger } from '@/helper/logger'
import { ConfigManager } from './ConfigManager'
import { GeneratorHelper } from './GeneratorHelper'

export class ConfigHelper {
private static instance: ConfigHelper
private configManager = ConfigManager.getInstance()
private configManager = new ConfigManager()
private projectPath: string
public static getInstance(): ConfigHelper {
if (!ConfigHelper.instance) {
ConfigHelper.instance = new ConfigHelper()
}
return ConfigHelper.instance
}

public async load(config: Partial<Config>, projectPath = process.cwd(), tracker?: ProgressTracker): Promise<void> {
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()
}
Expand Down Expand Up @@ -91,5 +80,3 @@ export class ConfigHelper {
return Promise.all(allAlovaJSon)
}
}

export const configHelper = ConfigHelper.getInstance()
13 changes: 1 addition & 12 deletions packages/worma/src/helper/config/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>

Expand All @@ -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
}

/**
* 加载并验证配置
*/
Expand Down Expand Up @@ -93,6 +85,3 @@ export class ConfigManager {
return result
}
}

// 导出单例实例
export const configManager = ConfigManager.getInstance()
148 changes: 127 additions & 21 deletions packages/worma/src/plugins/presets/payloadModifier/hepler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {}
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<string> = 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
Expand All @@ -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
}
Expand Down Expand Up @@ -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'
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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<T extends MaybeSchemaObject = SchemaObject>(
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 }
Expand All @@ -211,23 +302,38 @@ export function applyModifierSchema<T extends MaybeSchemaObject = SchemaObject>(
= (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<string, any>
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,
}
}
Loading
Loading