Skip to content

Commit 00ed1ee

Browse files
C-ra-ZYastahmer
authored andcommitted
fix: correctly parse required only schema in allOf like https://editor.swagger.io/
1 parent e06350a commit 00ed1ee

8 files changed

+262
-66
lines changed

.changeset/polite-bottles-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-zod-client": minor
3+
---
4+
5+
Fix #260 by infer types of items in a required only allOf item.

lib/src/inferRequiredOnly.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { type SchemaObject, type ReferenceObject, isReferenceObject } from "openapi3-ts";
2+
import type { DocumentResolver } from "./makeSchemaResolver";
3+
4+
const isBrokenAllOfItem = (item: SchemaObject | ReferenceObject): item is SchemaObject => {
5+
if (
6+
!isReferenceObject(item) &&
7+
!!item.required &&
8+
!item.type &&
9+
!item.properties &&
10+
!item?.allOf &&
11+
!item?.anyOf &&
12+
!item.oneOf
13+
) {
14+
return true;
15+
}
16+
return false;
17+
};
18+
19+
export function inferRequiredSchema(schema: SchemaObject) {
20+
if (!schema.allOf) {
21+
throw new Error(
22+
"function inferRequiredSchema is specialized to handle item with required only in an allOf array."
23+
);
24+
}
25+
const [standaloneRequisites, noRequiredOnlyAllof] = schema.allOf.reduce(
26+
(acc, cur) => {
27+
if (isBrokenAllOfItem(cur)) {
28+
const required = (cur as SchemaObject).required;
29+
acc[0].push(...(required ?? []));
30+
} else {
31+
acc[1].push(cur);
32+
}
33+
return acc;
34+
},
35+
[[], []] as [string[], (SchemaObject | ReferenceObject)[]]
36+
);
37+
38+
const composedRequiredSchema = {
39+
properties: standaloneRequisites.reduce(
40+
(acc, cur) => {
41+
acc[cur] = {
42+
// type: "unknown" as SchemaObject["type"],
43+
} as SchemaObject;
44+
return acc;
45+
},
46+
{} as {
47+
[propertyName: string]: SchemaObject | ReferenceObject;
48+
}
49+
),
50+
type: "object" as const,
51+
required: standaloneRequisites,
52+
};
53+
54+
return {
55+
noRequiredOnlyAllof,
56+
composedRequiredSchema,
57+
patchRequiredSchemaInLoop: (prop: SchemaObject | ReferenceObject, resolver: DocumentResolver) => {
58+
if (isReferenceObject(prop)) {
59+
const refType = resolver.getSchemaByRef(prop.$ref);
60+
if (refType) {
61+
composedRequiredSchema.required.forEach((required) => {
62+
composedRequiredSchema.properties[required] = refType?.properties?.[required] ?? {};
63+
});
64+
}
65+
} else {
66+
const properties = prop["properties"] ?? {};
67+
composedRequiredSchema.required.forEach((required) => {
68+
if (properties[required]) {
69+
composedRequiredSchema.properties[required] = properties[required] ?? {};
70+
}
71+
});
72+
}
73+
},
74+
};
75+
}

lib/src/openApiToTypescript.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isReferenceObject } from "./isReferenceObject";
66
import type { DocumentResolver } from "./makeSchemaResolver";
77
import type { TemplateContext } from "./template-context";
88
import { wrapWithQuotesIfNeeded } from "./utils";
9+
import { inferRequiredSchema } from "./inferRequiredOnly";
910

1011
type TsConversionArgs = {
1112
schema: SchemaObject | ReferenceObject;
@@ -96,7 +97,12 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
9697

9798
if (Array.isArray(schema.type)) {
9899
if (schema.type.length === 1) {
99-
return getTypescriptFromOpenApi({ schema: { ...schema, type: schema.type[0]! }, ctx, meta, options });
100+
return getTypescriptFromOpenApi({
101+
schema: { ...schema, type: schema.type[0]! },
102+
ctx,
103+
meta,
104+
options,
105+
});
100106
}
101107

102108
const types = schema.type.map(
@@ -149,10 +155,25 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
149155
if (schema.allOf.length === 1) {
150156
return getTypescriptFromOpenApi({ schema: schema.allOf[0]!, ctx, meta, options });
151157
}
158+
const { patchRequiredSchemaInLoop, noRequiredOnlyAllof, composedRequiredSchema } =
159+
inferRequiredSchema(schema);
152160

153-
const types = schema.allOf.map(
154-
(prop) => getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition
155-
);
161+
const types = noRequiredOnlyAllof.map((prop) => {
162+
const type = getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition;
163+
ctx?.resolver && patchRequiredSchemaInLoop(prop, ctx.resolver);
164+
return type;
165+
});
166+
167+
if (Object.keys(composedRequiredSchema.properties).length) {
168+
types.push(
169+
getTypescriptFromOpenApi({
170+
schema: composedRequiredSchema,
171+
ctx,
172+
meta,
173+
options,
174+
}) as TypeDefinition
175+
);
176+
}
156177
return schema.nullable ? t.union([t.intersection(types), t.reference("null")]) : t.intersection(types);
157178
}
158179

@@ -164,8 +185,10 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
164185
}
165186

166187
const hasNull = schema.enum.includes(null);
167-
const withoutNull = schema.enum.filter(f => f !== null);
168-
return schema.nullable || hasNull ? t.union([...withoutNull, t.reference("null")]) : t.union(withoutNull);
188+
const withoutNull = schema.enum.filter((f) => f !== null);
189+
return schema.nullable || hasNull
190+
? t.union([...withoutNull, t.reference("null")])
191+
: t.union(withoutNull);
169192
}
170193

171194
if (schemaType === "string")
@@ -278,7 +301,6 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
278301
}
279302

280303
if (!schemaType) return t.unknown();
281-
282304
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
283305
throw new Error(`Unsupported schema type: ${schemaType}`);
284306
};
@@ -293,7 +315,7 @@ type SingleType = Exclude<SchemaObject["type"], any[] | undefined>;
293315
const isPrimitiveType = (type: SingleType): type is PrimitiveType => primitiveTypeList.includes(type as any);
294316

295317
const primitiveTypeList = ["string", "number", "integer", "boolean", "null"] as const;
296-
type PrimitiveType = typeof primitiveTypeList[number];
318+
type PrimitiveType = (typeof primitiveTypeList)[number];
297319

298320
const wrapTypeIfInline = ({
299321
isInline,

lib/src/openApiToZod.ts

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CodeMeta } from "./CodeMeta";
66
import { isReferenceObject } from "./isReferenceObject";
77
import type { TemplateContext } from "./template-context";
88
import { escapeControlCharacters, isPrimitiveType, wrapWithQuotesIfNeeded } from "./utils";
9+
import { inferRequiredSchema } from "./inferRequiredOnly";
910

1011
type ConversionArgs = {
1112
schema: SchemaObject | ReferenceObject;
@@ -23,7 +24,6 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
2324
if (!schema) {
2425
throw new Error("Schema is required");
2526
}
26-
2727
const code = new CodeMeta(schema, ctx, inheritedMeta);
2828
const meta = {
2929
parent: code.inherit(inheritedMeta?.parent),
@@ -87,14 +87,14 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
8787

8888
/* when there are multiple allOf we are unable to use a discriminatedUnion as this library adds an
8989
* 'z.and' to the schema that it creates which breaks type inference */
90-
const hasMultipleAllOf = schema.oneOf?.some((obj) => isSchemaObject(obj) && (obj?.allOf || []).length > 1)
90+
const hasMultipleAllOf = schema.oneOf?.some((obj) => isSchemaObject(obj) && (obj?.allOf || []).length > 1);
9191
if (schema.discriminator && !hasMultipleAllOf) {
9292
const propertyName = schema.discriminator.propertyName;
9393

9494
return code.assign(`
9595
z.discriminatedUnion("${propertyName}", [${schema.oneOf
96-
.map((prop) => getZodSchema({ schema: prop, ctx, meta, options }))
97-
.join(", ")}])
96+
.map((prop) => getZodSchema({ schema: prop, ctx, meta, options }))
97+
.join(", ")}])
9898
`);
9999
}
100100

@@ -136,8 +136,24 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
136136
const type = getZodSchema({ schema: schema.allOf[0]!, ctx, meta, options });
137137
return code.assign(type.toString());
138138
}
139-
140-
const types = schema.allOf.map((prop) => getZodSchema({ schema: prop, ctx, meta, options }));
139+
const { patchRequiredSchemaInLoop, noRequiredOnlyAllof, composedRequiredSchema } = inferRequiredSchema(schema);
140+
141+
const types = noRequiredOnlyAllof.map((prop) => {
142+
const zodSchema = getZodSchema({ schema: prop, ctx, meta, options });
143+
ctx?.resolver && patchRequiredSchemaInLoop(prop, ctx.resolver);
144+
return zodSchema;
145+
});
146+
147+
if (composedRequiredSchema.required.length) {
148+
types.push(
149+
getZodSchema({
150+
schema: composedRequiredSchema,
151+
ctx,
152+
meta,
153+
options,
154+
})
155+
);
156+
}
141157
const first = types.at(0)!;
142158
const rest = types
143159
.slice(1)
@@ -158,7 +174,9 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
158174
}
159175

160176
// eslint-disable-next-line sonarjs/no-nested-template-literals
161-
return code.assign(`z.enum([${schema.enum.map((value) => value === null ? "null" : `"${value}"`).join(", ")}])`);
177+
return code.assign(
178+
`z.enum([${schema.enum.map((value) => (value === null ? "null" : `"${value}"`)).join(", ")}])`
179+
);
162180
}
163181

164182
if (schema.enum.some((e) => typeof e === "string")) {
@@ -200,15 +218,14 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
200218
return code.assign(`z.array(z.any())${readonly}`);
201219
}
202220

203-
if (
204-
schemaType === "object" ||
205-
schema.properties ||
206-
schema.additionalProperties ||
207-
(schema.required && Array.isArray(schema.required))
208-
) {
221+
if (schemaType === "object" || schema.properties || schema.additionalProperties) {
209222
// additional properties default to true if additionalPropertiesDefaultValue not provided
210-
const additionalPropsDefaultValue = options?.additionalPropertiesDefaultValue !== undefined ? options?.additionalPropertiesDefaultValue : true;
211-
const additionalProps = schema.additionalProperties === null || schema.additionalProperties === undefined ? additionalPropsDefaultValue : schema.additionalProperties;
223+
const additionalPropsDefaultValue =
224+
options?.additionalPropertiesDefaultValue !== undefined ? options?.additionalPropertiesDefaultValue : true;
225+
const additionalProps =
226+
schema.additionalProperties === null || schema.additionalProperties === undefined
227+
? additionalPropsDefaultValue
228+
: schema.additionalProperties;
212229
const additionalPropsSchema = additionalProps === false ? "" : ".passthrough()";
213230

214231
if (typeof schema.additionalProperties === "object" && Object.keys(schema.additionalProperties).length > 0) {
@@ -235,8 +252,8 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
235252
isRequired: isPartial
236253
? true
237254
: hasRequiredArray
238-
? schema.required?.includes(prop)
239-
: options?.withImplicitRequiredProps,
255+
? schema.required?.includes(prop)
256+
: options?.withImplicitRequiredProps,
240257
name: prop,
241258
} as CodeMetaData;
242259

@@ -263,26 +280,7 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
263280
}
264281

265282
const partial = isPartial ? ".partial()" : "";
266-
267-
const schemaRequired = schema.required;
268-
const schemaProperties = schema.properties;
269-
// properties in required array but not in properties should be validated seprately, when additional props are allowed
270-
const extraPropertiesFromRequiredArray =
271-
additionalProps !== false && schemaRequired
272-
? schemaProperties
273-
? schemaRequired.filter((p) => !(p in schemaProperties))
274-
: schemaRequired
275-
: [];
276-
const extraProperties =
277-
extraPropertiesFromRequiredArray.length > 0
278-
? "z.object({ " + extraPropertiesFromRequiredArray.map((p) => `${p}: z.unknown()`).join(", ") + " })"
279-
: "";
280-
281-
return code.assign(
282-
`z.object(${properties})${partial}${
283-
extraProperties && ".and(" + extraProperties + ")"
284-
}${additionalPropsSchema}${readonly}`
285-
);
283+
return code.assign(`z.object(${properties})${partial}${additionalPropsSchema}${readonly}`);
286284
}
287285

288286
if (!schemaType) return code.assign("z.unknown()");

0 commit comments

Comments
 (0)