diff --git a/lib/package.json b/lib/package.json index 1f981cfb..cda240b5 100644 --- a/lib/package.json +++ b/lib/package.json @@ -13,7 +13,7 @@ "openapi-zod-client": "./bin.js" }, "scripts": { - "test": "vitest", + "test": "export NODE_OPTIONS=$(echo '$NODE_OPTIONS' | sed 's/--openssl-legacy-provider//') vitest", "test:ci": "vitest run", "lint:ts": "tsc --noEmit", "lint": "eslint -c .eslintrc.build.js './src/**/*.ts' --cache --format=pretty", diff --git a/lib/src/openApiToTypescript.ts b/lib/src/openApiToTypescript.ts index cb7a09b0..176f2e26 100644 --- a/lib/src/openApiToTypescript.ts +++ b/lib/src/openApiToTypescript.ts @@ -47,6 +47,10 @@ const wrapReadOnly = return theType; }; +const handleDefaultValue = (schema: SchemaObject, node: ts.TypeNode | TypeDefinitionObject | string) => { + return schema.default !== undefined ? t.union([node, t.reference("undefined")]) : node; +}; + export const getTypescriptFromOpenApi = ({ schema, meta: inheritedMeta, @@ -79,8 +83,9 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { return t.reference(schemaName); } + let actualSchema: SchemaObject | undefined; if (!result) { - const actualSchema = ctx.resolver.getSchemaByRef(schema.$ref); + actualSchema = ctx.resolver.getSchemaByRef(schema.$ref); if (!actualSchema) { throw new Error(`Schema ${schema.$ref} not found`); } @@ -93,7 +98,9 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { schemaName = ctx.resolver.resolveRef(schema.$ref)?.normalized; } - return t.reference(schemaName); + return actualSchema?.nullable + ? t.union([t.reference(schemaName), t.reference("null")]) + : t.reference(schemaName); } if (Array.isArray(schema.type)) { @@ -116,7 +123,10 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { }) as TypeDefinition ); - return schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types); + return handleDefaultValue( + schema, + schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types) + ); } if (schema.type === "null") { @@ -132,7 +142,10 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { (prop) => getTypescriptFromOpenApi({ schema: prop, ctx, meta, options }) as TypeDefinition ); - return schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types); + return handleDefaultValue( + schema, + schema.nullable ? t.union([...types, t.reference("null")]) : t.union(types) + ); } // anyOf = oneOf but with 1 or more = `T extends oneOf ? T | T[] : never` @@ -147,9 +160,12 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { ) ); - return schema.nullable - ? t.union([oneOf, doWrapReadOnly(t.array(oneOf)), t.reference("null")]) - : t.union([oneOf, doWrapReadOnly(t.array(oneOf))]); + return handleDefaultValue( + schema, + schema.nullable + ? t.union([oneOf, doWrapReadOnly(t.array(oneOf)), t.reference("null")]) + : t.union([oneOf, doWrapReadOnly(t.array(oneOf))]) + ); } if (schema.allOf) { @@ -177,21 +193,28 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { ); } - return schema.nullable ? t.union([t.intersection(types), t.reference("null")]) : t.intersection(types); + return handleDefaultValue( + schema, + schema.nullable ? t.union([t.intersection(types), t.reference("null")]) : t.intersection(types) + ); } const schemaType = schema.type ? (schema.type.toLowerCase() as NonNullable) : undefined; if (schemaType && isPrimitiveType(schemaType)) { if (schema.enum) { if (schemaType !== "string" && schema.enum.some((e) => typeof e === "string")) { - return schema.nullable ? t.union([t.never(), t.reference("null")]) : t.never(); + return handleDefaultValue( + schema, + schema.nullable ? t.union([t.never(), t.reference("null")]) : t.never() + ); } const hasNull = schema.enum.includes(null); const withoutNull = schema.enum.filter((f) => f !== null); - return schema.nullable || hasNull - ? t.union([...withoutNull, t.reference("null")]) - : t.union(withoutNull); + return handleDefaultValue( + schema, + schema.nullable || hasNull ? t.union([...withoutNull, t.reference("null")]) : t.union(withoutNull) + ); } if (schemaType === "string") @@ -215,19 +238,25 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { arrayOfType = t.reference(arrayOfType); } - return schema.nullable - ? t.union([doWrapReadOnly(t.array(arrayOfType)), t.reference("null")]) - : doWrapReadOnly(t.array(arrayOfType)); + return handleDefaultValue( + schema, + schema.nullable + ? t.union([doWrapReadOnly(t.array(arrayOfType)), t.reference("null")]) + : doWrapReadOnly(t.array(arrayOfType)) + ); } - return schema.nullable - ? t.union([doWrapReadOnly(t.array(t.any())), t.reference("null")]) - : doWrapReadOnly(t.array(t.any())); + return handleDefaultValue( + schema, + schema.nullable + ? t.union([doWrapReadOnly(t.array(t.any())), t.reference("null")]) + : doWrapReadOnly(t.array(t.any())) + ); } if (schemaType === "object" || schema.properties || schema.additionalProperties) { if (!schema.properties) { - return {}; + return handleDefaultValue(schema, schema.nullable ? t.union([{}, t.reference("null")]) : {}); } canBeWrapped = false; @@ -283,14 +312,21 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { } const isRequired = Boolean(isPartial ? true : schema.required?.includes(prop)); - return [`${wrapWithQuotesIfNeeded(prop)}`, isRequired ? propType : t.optional(propType)]; + const hasDefault = "default" in propSchema ? propSchema.default !== undefined : false; + return [ + `${wrapWithQuotesIfNeeded(prop)}`, + isRequired && !hasDefault ? propType : t.optional(propType), + ]; }) ); const objectType = additionalProperties ? t.intersection([props, additionalProperties]) : props; if (isInline) { - return isPartial ? t.reference("Partial", [doWrapReadOnly(objectType)]) : doWrapReadOnly(objectType); + const buffer = isPartial + ? t.reference("Partial", [doWrapReadOnly(objectType)]) + : doWrapReadOnly(objectType); + return schema.nullable ? t.union([buffer, t.reference("null")]) : buffer; } if (!inheritedMeta?.name) { @@ -301,7 +337,9 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => { return t.type(inheritedMeta.name, doWrapReadOnly(objectType)); } - return t.type(inheritedMeta.name, t.reference("Partial", [doWrapReadOnly(objectType)])); + return schema.nullable + ? t.union([t.type(inheritedMeta.name, doWrapReadOnly(objectType)), t.reference("null")]) + : t.type(inheritedMeta.name, t.reference("Partial", [doWrapReadOnly(objectType)])); } if (!schemaType) return t.unknown(); diff --git a/lib/src/openApiToZod.ts b/lib/src/openApiToZod.ts index 2bd35d25..9ec366ae 100644 --- a/lib/src/openApiToZod.ts +++ b/lib/src/openApiToZod.ts @@ -89,7 +89,7 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option /* when there are multiple allOf we are unable to use a discriminatedUnion as this library adds an * 'z.and' to the schema that it creates which breaks type inference */ - const hasMultipleAllOf = schema.oneOf?.some((obj) => isSchemaObject(obj) && (obj?.allOf || []).length > 1); + const hasMultipleAllOf = schema.oneOf?.some((obj) => isSchemaObject(obj) && (obj?.allOf ?? []).length > 1); if (schema.discriminator && !hasMultipleAllOf) { const propertyName = schema.discriminator.propertyName; @@ -101,7 +101,15 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option } return code.assign( - `z.union([${schema.oneOf.map((prop) => getZodSchema({ schema: prop, ctx, meta, options })).join(", ")}])` + `z.union([${schema.oneOf + .map( + (prop) => + getZodSchema({ schema: prop, ctx, meta, options }) + + (isReferenceObject(prop) + ? "" + : getZodChain({ schema: prop, meta: { ...meta, isRequired: true }, options })) + ) + .join(", ")}])` ); } @@ -138,6 +146,7 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option const type = getZodSchema({ schema: schema.allOf[0]!, ctx, meta, options }); return code.assign(type.toString()); } + const { patchRequiredSchemaInLoop, noRequiredOnlyAllof, composedRequiredSchema } = inferRequiredSchema(schema); const types = noRequiredOnlyAllof.map((prop) => { @@ -146,7 +155,7 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option return zodSchema; }); - if (composedRequiredSchema.required.length) { + if (composedRequiredSchema.required.length > 0) { types.push( getZodSchema({ schema: composedRequiredSchema, @@ -156,6 +165,7 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option }) ); } + const first = types.at(0)!; const rest = types .slice(1) @@ -175,8 +185,8 @@ export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, option return code.assign(`z.literal(${valueString})`); } - // eslint-disable-next-line sonarjs/no-nested-template-literals return code.assign( + // eslint-disable-next-line sonarjs/no-nested-template-literals `z.enum([${schema.enum.map((value) => (value === null ? "null" : `"${value}"`)).join(", ")}])` ); } diff --git a/lib/tests/enum-null.test.ts b/lib/tests/enum-null.test.ts index ba377504..4fc81fe9 100644 --- a/lib/tests/enum-null.test.ts +++ b/lib/tests/enum-null.test.ts @@ -112,7 +112,7 @@ test("enum-null", async () => { import { z } from "zod"; type Compound = Partial<{ - field: Null1 | Null2 | Null3 | Null4 | string; + field: Null1 | Null2 | (Null3 | null) | (Null4 | null) | string; }>; type Null1 = null; type Null2 = "a" | null; diff --git a/lib/tests/export-all-types.test.ts b/lib/tests/export-all-types.test.ts index 6169d27f..226c1e8b 100644 --- a/lib/tests/export-all-types.test.ts +++ b/lib/tests/export-all-types.test.ts @@ -104,7 +104,7 @@ describe("export-all-types", () => { expect(data).toEqual({ schemas: { Settings: "z.object({ theme_color: z.string(), features: Features.min(1) }).partial().passthrough()", - Author: "z.object({ name: z.union([z.string(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), settings: Settings }).partial().passthrough()", + Author: "z.object({ name: z.union([z.string().nullable(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), settings: Settings }).partial().passthrough()", Features: "z.array(z.string())", Song: "z.object({ name: z.string(), duration: z.number() }).partial().passthrough()", Playlist: @@ -207,7 +207,7 @@ describe("export-all-types", () => { .passthrough(); const Author: z.ZodType = z .object({ - name: z.union([z.string(), z.number()]).nullable(), + name: z.union([z.string().nullable(), z.number()]).nullable(), title: Title.min(1).max(30), id: Id, mail: z.string(), diff --git a/lib/tests/jsdoc.test.ts b/lib/tests/jsdoc.test.ts index ba91812e..5f30eaa3 100644 --- a/lib/tests/jsdoc.test.ts +++ b/lib/tests/jsdoc.test.ts @@ -143,13 +143,13 @@ type ComplexObject = Partial<{ * @minimum 0 * @maximum 10 */ - manyTagsNum: number; + manyTagsNum: number | undefined; /** * A boolean * * @default true */ - bool: boolean; + bool: boolean | undefined; ref: SimpleObject; /** * An array of SimpleObject