wormaworma

Payload Modifier

Flexibly modify an API's request and response parameters — add, remove, or change field types, with tag/path filtering and response unwrapping

Payload Modifier

payloadModifier modifies an API's request parameters and response structure: add fields, remove fields, change types, change requiredness, change comments, and unwrap the data wrapper layer.

Quick start

import { defineConfig } from 'wormajs';
import { payloadModifier } from 'wormajs/plugin';

export default defineConfig({
  generator: [
    {
      // ...
      plugins: [
        payloadModifier([
          // All APIs: unwrap response data
          { scope: 'response', unwrap: 'data' },
          // A tag: unwrap then drop a debug field
          { scope: 'response', tag: 'Coverage Analysis', unwrap: 'data', patch: { debugInfo: null } },
          // All APIs: fields ending in id → string (keep original comment)
          { scope: 'response', match: /[Ii]d$/, patch: 'string' },
          { scope: 'params', match: /[Ii]d$/, patch: 'string' },
          // Change only requiredness
          { scope: 'data', match: 'email', patch: { required: false } },
        ]),
      ],
    },
  ],
});

Execution order

Each config item runs in this order:

Filter APIs (path / tag) → Replace root (unwrap) → Select fields (match) → Modify fields (patch) → Custom handler
StepConfig keyEffect
Filter APIspath / tagDecide whether this config applies to the current API
Replace rootunwrapReplace the root node with one of its children; later steps run on the new node
Select fieldsmatchOmitted = modify the root itself; provided = modify all top-level fields under the root that match
Modify fieldspatchDeclarative add / remove / change
Custom handlerhandlerHandle the raw schema yourself

Multiple configs run in array order; a later config sees the result of the earlier ones.

Config

interface ModifierConfig {
  /** Scope (required) */
  scope: 'params' | 'pathParams' | 'data' | 'response';

  /** Filter by API path: string = substring match; omitted = all APIs; array = OR */
  path?: Matcher | Matcher[];
  /** Filter by tag: applies when any of the API's tags matches */
  tag?: Matcher | Matcher[];

  /** Replace root with a child node, dot-separated, only descends through properties */
  unwrap?: string;

  /** Field name to change: omitted = change the root itself; provided = change matching top-level fields */
  match?: Matcher;

  /** What to change: add / remove / change type / change required / change comment */
  patch?: FieldValue;

  /** Custom handler: receives and returns a raw SchemaObject; null / undefined means delete */
  handler?: (schema: SchemaObject, key?: string) => SchemaObject | null | undefined;
}

type Matcher = string | RegExp | ((value: string) => boolean);

Filter APIs

Omitting both path and tag applies to all APIs; providing both is an AND relationship; an array on a single key is an OR relationship.

// Path starts with /api/admin AND has tag 'Coverage Analysis'
{ path: /^\/api\/admin/, tag: 'Coverage Analysis', scope: 'response', patch: { debugInfo: null } }
// Path contains /pets or /orders
{ path: ['/pets', '/orders'], scope: 'data', match: 'userId', patch: 'string' }

When an API has no tags, the tag filter won't match.

Replace root (unwrap)

Replace the root node with one of its children before subsequent operations. Commonly used to unwrap data out of a response.

{ scope: 'response', unwrap: 'data' }       // unwrap one level
{ scope: 'response', unwrap: 'data.list' }  // unwrap multiple levels
  • Only descends through properties; a wrong path prints a warning and skips this config, without affecting other APIs or configs.
  • Doesn't enter array elements (paths like list.items aren't supported); use handler to get array elements.
  • This step only swaps the node — no type conversion; field comments like description are kept as-is.

Select fields

FormWhich fields
No matchThe root node itself (add/remove/change as a whole)
match providedAll top-level fields under the root that match (can be more than one)
match provided but none matchNothing happens

Only the current node's top-level field names match; it won't go deeper, nor into oneOf / anyOf / allOf branches.

{ scope: 'response', match: /[Ii]d$/, patch: 'string' }               // regex
{ scope: 'data', match: 'email', patch: { required: false } }         // substring
{ scope: 'data', match: key => key.startsWith('user'), patch: 'string' } // function

Modify fields

The top-level patch form follows the same rules as each field inside it, and can nest deeper:

Value formMeaning
nullDelete (on a field = delete that field; at the top level = delete the field selected by match)
string / arrayEquivalent to { type: value } — replace the type, keeping description etc.
object without keywordsEquivalent to properties — merge changes into the field list
object with keywordspartial change to this field itself — only the written keys are changed; unwritten keys stay

"Keywords" here are: type, required, description, properties, items, enum, oneOf, anyOf, allOf, format, example, default, deprecated, nullable, title.

How to write a type

Appears in the type-value positions type / items / oneOf to describe a type:

type SchemaDSL
  = 'number' | 'string' | 'boolean' | 'undefined' | 'null' | 'unknown' | 'any' | 'never'
  | SchemaDSL[]                                 // ['string'] is string[]; ['string','number'] is a tuple
  | { oneOf: SchemaDSL[] } | { anyOf: SchemaDSL[] } | { allOf: SchemaDSL[] }
  | { enum: Array<string | number | boolean | null>, type?: SchemaPrimitive }
  | { [field: string]: SchemaDSL };             // object shorthand; listed fields are required

unknown / any / never are written into type as-is, producing the corresponding TS type.

What gets cleared when changing a type

When writing type (or oneOf / anyOf / allOf), these keys describing the old type are cleared to avoid leftovers:

type, properties, items, enum, oneOf, anyOf, allOf, format, required

All other keys (description, title, example, deprecated, nullable, …) are kept.

// Original field: { type: 'integer', format: 'int64', description: 'pet id' }
{ scope: 'data', match: 'id', patch: 'string' }
// Result: { type: 'string', description: 'pet id' }

Behavior of other keywords

KeyBehavior
requiredNot written into the schema; instead adds/removes the parent's required array; for params / pathParams it modifies ParameterObject.required
description / title / format / example / default / deprecated / nullableOverride directly
enumOverride the enum values; when type is omitted and the field had no type either, infer from the values (all ints → integer, has decimals → number, all strings → string, all booleans → boolean)
itemsOverride the array element type; adds type: 'array' if the field wasn't an array
propertiesExplicitly declare which fields to change (also the form when a field name collides with a keyword)

When required is written at the top level (changing the root node), there's no parent, so it has no effect.

requiredness

  • New fields are required by default; to make one optional you must write required: false explicitly.
  • Existing fields without required keep their original requiredness.
  • Writing required: true / false explicitly adds/removes the parent's required array.
{ scope: 'data', match: 'email', patch: { required: false } }            // make an existing field optional
{ scope: 'data', patch: { note: { type: 'string', required: false } } }  // declare a new optional field

When a field name collides with a keyword

When a field name is exactly a keyword like description or type, declare it explicitly with properties:

// Meaning: change the field named description in the target (not the target's description)
{ scope: 'data', patch: { properties: { description: { type: 'string' } } } }

Merge vs. whole replace

{ scope: 'data', match: 'user', patch: { name: 'string' } }           // merge: other user fields kept
{ scope: 'data', match: 'user', patch: { type: { name: 'string' } } } // whole replace: rebuild user's type

Both forms keep the target's own description etc.

Quick reference

// Add: new field with a comment, required by default
{ scope: 'data', patch: { operatorId: { type: 'string', description: 'Operator code, 0 if none' } } }
// Remove a field
{ scope: 'response', unwrap: 'data', patch: { debugInfo: null } }
// Remove the whole scope (data becomes empty; params clears only query params)
{ scope: 'data', patch: null }
// Change type
{ scope: 'data', match: 'tags', patch: ['string'] }                // string[]
{ scope: 'data', match: 'id', patch: { oneOf: ['string', 'number'] } }
// Change comment
{ scope: 'data', match: 'totalCount', patch: { description: 'Total count' } }
// Change requiredness
{ scope: 'params', match: 'page', patch: { required: true } }

Custom handler

When patch can't express it, use handler to get the raw schema and handle it yourself:

{
  scope: 'response',
  path: '/special',
  handler: schema => schema.properties.data,   // unwrap manually
}
  • With match: called once per matching field, second arg is the field name.
  • Without match: called once on the root node, second arg is undefined.
  • Return null / undefined to delete; return schema to keep it as-is.
  • When patch and handler are both written, patch runs first, and handler receives the patched result.
  • Keep comments by spreading the original: schema => ({ ...schema, type: 'string' }).
  • Rename a field here too: copy a key, then delete the old one.

Where the four scopes apply

scopeLocation
dataapiDescriptor.requestBody
responseapiDescriptor.responses
paramsquery params: synthesized into an object schema first, then written back to the param array
pathParamspath params, same as above

New fields added in params / pathParams generate new params placed after same-type params; required is written back to the param's required.

Edge cases

CaseBehavior
unwrap path not foundPrint a warning and skip this config, without affecting other configs or APIs
match matches nothingNothing happens
Field-list change used on a non-object fieldPrint a warning and skip this field
Delete with matchRemove the field and also drop it from required
Delete without matchClear this scope: data / response emptied; params / pathParams remove only the params at that position
Re-runThe same plugin instance processes the same API only once

On this page