Skip to content

Commit dec490c

Browse files
authored
fix(zod): report a misplaced boolean required instead of crashing (#3822)
1 parent c9ca72e commit dec490c

2 files changed

Lines changed: 138 additions & 11 deletions

File tree

packages/zod/src/index.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,29 @@ const isDiscriminatableMember = (
421421
return hasLiteralDiscriminator(resolved, property);
422422
};
423423

424+
/**
425+
* Read a schema object's `required` keyword.
426+
*
427+
* Some generators emit `required: true` on the schema referenced by a request
428+
* body, borrowing the boolean that belongs on the request body object. Spreading
429+
* that boolean fails with `(schema.required ?? []) is not iterable`, which says
430+
* nothing about the document. Report the offending schema and the expected shape
431+
* instead. The document is not rewritten: a boolean carries no property names,
432+
* so there is nothing to recover from it. (#3719)
433+
*/
434+
const getRequiredKeys = (schema: OpenApiSchemaObject, name: string) => {
435+
const required = schema.required as unknown;
436+
437+
if (required === undefined) return [];
438+
if (Array.isArray(required)) return required;
439+
440+
throw new Error(
441+
`Invalid OpenAPI document: schema "${name}" has \`required: ${JSON.stringify(
442+
required,
443+
)}\`, but a schema object's \`required\` must be an array of property names. A boolean \`required\` belongs on the request body object or on a parameter, not on the schema it references.`,
444+
);
445+
};
446+
424447
export const generateZodValidationSchemaDefinition = (
425448
schema: OpenApiSchemaObject | OpenApiReferenceObject | undefined,
426449
context: ContextSpec,
@@ -765,19 +788,24 @@ export const generateZodValidationSchemaDefinition = (
765788
const allOfRequired = schema.allOf
766789
? [
767790
...new Set([
768-
...(schema.required ?? []),
769-
...schemas.flatMap((member) => {
791+
...getRequiredKeys(schema, name),
792+
...schemas.flatMap((member, index) => {
770793
// Only the member's top-level `required` is needed. For `$ref`
771794
// members resolve shallowly (no deep property dereference) and
772795
// tolerate unresolvable refs — they simply contribute no keys.
773-
const resolved =
774-
'$ref' in member && typeof member.$ref === 'string'
775-
? tryResolveRefSchema(member.$ref, context)
776-
: (member as OpenApiSchemaObject);
777-
const memberRequired = resolved?.required;
778-
return Array.isArray(memberRequired)
779-
? (memberRequired as string[])
780-
: [];
796+
const isRef = '$ref' in member && typeof member.$ref === 'string';
797+
const resolved = isRef
798+
? tryResolveRefSchema(member.$ref as string, context)
799+
: (member as OpenApiSchemaObject);
800+
if (!resolved) return [];
801+
// A constraint-only member never reaches the object path below,
802+
// so a misplaced boolean here would otherwise pass unreported.
803+
// Name the member, not the composing schema, or the message
804+
// points at the wrong place in the document.
805+
return getRequiredKeys(
806+
resolved,
807+
isRef ? (member.$ref as string) : `${name}.allOf[${index}]`,
808+
);
781809
}),
782810
]),
783811
]
@@ -1235,7 +1263,7 @@ export const generateZodValidationSchemaDefinition = (
12351263
// A property is required when this schema requires it OR when a
12361264
// sibling `allOf` member requires it (propagated via additionalRequired). (#3171)
12371265
const requiredKeys = new Set<string>([
1238-
...(schema.required ?? []),
1266+
...getRequiredKeys(schema, name),
12391267
...(rules?.additionalRequired ?? []),
12401268
]);
12411269

packages/zod/src/zod.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12636,3 +12636,102 @@ describe('constraint-only oneOf/anyOf branches (#3780)', () => {
1263612636
expect(zod).toContain('"B": zod.number().int().optional()');
1263712637
});
1263812638
});
12639+
12640+
describe('misplaced boolean `required` (#3719)', () => {
12641+
// Some generators put `required: true` on the schema a request body
12642+
// references, borrowing the boolean that belongs on the request body object.
12643+
// That used to surface as `(schema.required ?? []) is not iterable`, which
12644+
// names neither the schema nor the expected shape.
12645+
const schema = {
12646+
type: 'object',
12647+
required: true,
12648+
properties: { name: { type: 'string' } },
12649+
} as unknown as OpenApiSchemaObject;
12650+
12651+
it('names the schema and the expected shape', () => {
12652+
expect(() =>
12653+
generateZodValidationSchemaDefinition(
12654+
schema,
12655+
{ output: { override: {} } } as ContextSpec,
12656+
'Item',
12657+
true,
12658+
false,
12659+
{ required: true },
12660+
),
12661+
).toThrowError(
12662+
/schema "Item" has `required: true`, but a schema object's `required` must be an array of property names/,
12663+
);
12664+
});
12665+
12666+
it('reports it on the allOf path too', () => {
12667+
expect(() =>
12668+
generateZodValidationSchemaDefinition(
12669+
{
12670+
allOf: [{ type: 'object', properties: { a: { type: 'string' } } }],
12671+
required: true,
12672+
} as unknown as OpenApiSchemaObject,
12673+
{ output: { override: {} } } as ContextSpec,
12674+
'Composed',
12675+
true,
12676+
false,
12677+
{ required: true },
12678+
),
12679+
).toThrowError(/schema "Composed" has `required: true`/);
12680+
});
12681+
12682+
it('names the offending allOf member, not the composing schema', () => {
12683+
// A constraint-only member has no properties, so it never reaches the
12684+
// object path that validates `required`. Without a check here the same
12685+
// malformed keyword is reported in one position and ignored in another.
12686+
expect(() =>
12687+
generateZodValidationSchemaDefinition(
12688+
{
12689+
allOf: [
12690+
{ type: 'object', properties: { a: { type: 'string' } } },
12691+
{ required: true },
12692+
],
12693+
} as unknown as OpenApiSchemaObject,
12694+
{ output: { override: {} } } as ContextSpec,
12695+
'Member',
12696+
true,
12697+
false,
12698+
{ required: true },
12699+
),
12700+
).toThrowError(/schema "Member\.allOf\[1\]" has `required: true`/);
12701+
});
12702+
12703+
it('still accepts a valid required array', () => {
12704+
expect(() =>
12705+
generateZodValidationSchemaDefinition(
12706+
{
12707+
type: 'object',
12708+
required: ['name'],
12709+
properties: { name: { type: 'string' } },
12710+
} as OpenApiSchemaObject,
12711+
{ output: { override: {} } } as ContextSpec,
12712+
'Valid',
12713+
true,
12714+
false,
12715+
{ required: true },
12716+
),
12717+
).not.toThrow();
12718+
});
12719+
12720+
it('still accepts a valid required array on an allOf member', () => {
12721+
expect(() =>
12722+
generateZodValidationSchemaDefinition(
12723+
{
12724+
allOf: [
12725+
{ type: 'object', properties: { a: { type: 'string' } } },
12726+
{ required: ['a'] },
12727+
],
12728+
} as unknown as OpenApiSchemaObject,
12729+
{ output: { override: {} } } as ContextSpec,
12730+
'ValidMember',
12731+
true,
12732+
false,
12733+
{ required: true },
12734+
),
12735+
).not.toThrow();
12736+
});
12737+
});

0 commit comments

Comments
 (0)