From f38cbe2d977fb916414d7d754843444bc691d193 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 10:18:18 -0800 Subject: [PATCH 01/10] Move default to end; no optional when default --- OMICRON_VERSION | 2 +- .../src/__snapshots__/validate.ts | 74 +++++----- oxide-openapi-gen-ts/src/schema/zod.test.ts | 127 ++++++++++++++++++ oxide-openapi-gen-ts/src/schema/zod.ts | 23 +++- 4 files changed, 182 insertions(+), 44 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 2a5ae49..32003e3 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -135be59591f1ba4bc5941f63bf3a08a0b187b1a9 +e81e2a717bce26bd6c89814b51d0b953c5f440f0 diff --git a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts index fc09c20..492bd0b 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts @@ -145,7 +145,7 @@ export const PoolSelector = z.preprocess(processResponseBody,z.union([ z.object({"pool": NameOrId, "type": z.enum(["explicit"]), }), -z.object({"ipVersion": IpVersion.nullable().default(null).optional(), +z.object({"ipVersion": IpVersion.nullable().default(null), "type": z.enum(["auto"]), }), ]) @@ -159,7 +159,7 @@ z.object({"ip": z.ipv4(), "pool": NameOrId.nullable().optional(), "type": z.enum(["explicit"]), }), -z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}).optional(), +z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "type": z.enum(["auto"]), }), ]) @@ -1556,7 +1556,7 @@ export const Distributionint64 = z.preprocess(processResponseBody,z.object({"bin /** * Parameters for creating an ephemeral IP address for an instance. */ -export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}).optional(), +export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), })) /** @@ -1596,7 +1596,7 @@ z.object({"description": z.string(), * Parameters for creating an external IP address for instances. */ export const ExternalIpCreate = z.preprocess(processResponseBody,z.union([ -z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}).optional(), +z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "type": z.enum(["ephemeral"]), }), z.object({"floatingIp": NameOrId, @@ -1734,7 +1734,7 @@ export const FloatingIpAttach = z.preprocess(processResponseBody,z.object({"kind /** * Parameters for creating a new floating IP address for instances. */ -export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"}).optional(), +export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"}), "description": z.string(), "name": Name, })) @@ -1942,7 +1942,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv4 addressing. */ export const PrivateIpv4StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv4Assignment, -"transitIps": Ipv4Net.array().default([]).optional(), +"transitIps": Ipv4Net.array().default([]), })) /** @@ -1961,7 +1961,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv6 addressing. */ export const PrivateIpv6StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv6Assignment, -"transitIps": Ipv6Net.array().default([]).optional(), +"transitIps": Ipv6Net.array().default([]), })) /** @@ -1986,7 +1986,7 @@ z.object({"type": z.enum(["dual_stack"]), * Create-time parameters for an `InstanceNetworkInterface` */ export const InstanceNetworkInterfaceCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), -"ipConfig": PrivateIpStackCreate.default({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}}).optional(), +"ipConfig": PrivateIpStackCreate.default({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}}), "name": Name, "subnetName": Name, "vpcName": Name, @@ -2013,22 +2013,22 @@ z.object({"type": z.enum(["none"]), /** * Create-time parameters for an `Instance` */ -export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default([]).optional(), -"autoRestartPolicy": InstanceAutoRestartPolicy.nullable().default(null).optional(), -"bootDisk": InstanceDiskAttachment.nullable().default(null).optional(), -"cpuPlatform": InstanceCpuPlatform.nullable().default(null).optional(), +export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default([]), +"autoRestartPolicy": InstanceAutoRestartPolicy.nullable().default(null), +"bootDisk": InstanceDiskAttachment.nullable().default(null), +"cpuPlatform": InstanceCpuPlatform.nullable().default(null), "description": z.string(), -"disks": InstanceDiskAttachment.array().default([]).optional(), -"externalIps": ExternalIpCreate.array().default([]).optional(), +"disks": InstanceDiskAttachment.array().default([]), +"externalIps": ExternalIpCreate.array().default([]), "hostname": Hostname, "memory": ByteCount, -"multicastGroups": NameOrId.array().default([]).optional(), +"multicastGroups": NameOrId.array().default([]), "name": Name, "ncpus": InstanceCpuCount, -"networkInterfaces": InstanceNetworkInterfaceAttachment.default({"type":"default_dual_stack"}).optional(), +"networkInterfaces": InstanceNetworkInterfaceAttachment.default({"type":"default_dual_stack"}), "sshPublicKeys": NameOrId.array().nullable().optional(), -"start": SafeBoolean.default(true).optional(), -"userData": z.string().default("").optional(), +"start": SafeBoolean.default(true), +"userData": z.string().default(""), })) /** @@ -2100,8 +2100,8 @@ export const InstanceNetworkInterfaceResultsPage = z.preprocess(processResponseB */ export const InstanceNetworkInterfaceUpdate = z.preprocess(processResponseBody,z.object({"description": z.string().nullable().optional(), "name": Name.nullable().optional(), -"primary": SafeBoolean.default(false).optional(), -"transitIps": IpNet.array().default([]).optional(), +"primary": SafeBoolean.default(false), +"transitIps": IpNet.array().default([]), })) /** @@ -2125,7 +2125,7 @@ export const InstanceUpdate = z.preprocess(processResponseBody,z.object({"autoRe "bootDisk": NameOrId.nullable(), "cpuPlatform": InstanceCpuPlatform.nullable(), "memory": ByteCount, -"multicastGroups": NameOrId.array().nullable().default(null).optional(), +"multicastGroups": NameOrId.array().nullable().default(null), "ncpus": InstanceCpuCount, })) @@ -2244,9 +2244,9 @@ export const IpPool = z.preprocess(processResponseBody,z.object({"description": * ASM: IPv4 addresses outside 232.0.0.0/8, IPv6 addresses with flag field != 3 SSM: IPv4 addresses in 232.0.0.0/8, IPv6 addresses with flag field = 3 */ export const IpPoolCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), -"ipVersion": IpVersion.default("v4").optional(), +"ipVersion": IpVersion.default("v4"), "name": Name, -"poolType": IpPoolType.default("unicast").optional(), +"poolType": IpPoolType.default("unicast"), })) export const IpPoolLinkSilo = z.preprocess(processResponseBody,z.object({"isDefault": SafeBoolean, @@ -2498,11 +2498,11 @@ export const MulticastGroup = z.preprocess(processResponseBody,z.object({"descri * Create-time parameters for a multicast group. */ export const MulticastGroupCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), -"multicastIp": z.ipv4().nullable().default(null).optional(), -"mvlan": z.number().default(null).min(0).max(65535).nullable().optional(), +"multicastIp": z.ipv4().nullable().default(null), +"mvlan": z.number().min(0).max(65535).nullable().default(null), "name": Name, -"pool": NameOrId.nullable().default(null).optional(), -"sourceIps": z.ipv4().array().nullable().default(null).optional(), +"pool": NameOrId.nullable().default(null), +"sourceIps": z.ipv4().array().nullable().default(null), })) /** @@ -2552,7 +2552,7 @@ export const MulticastGroupUpdate = z.preprocess(processResponseBody,z.object({" */ export const PrivateIpv4Config = z.preprocess(processResponseBody,z.object({"ip": z.ipv4(), "subnet": Ipv4Net, -"transitIps": Ipv4Net.array().default([]).optional(), +"transitIps": Ipv4Net.array().default([]), })) /** @@ -2755,7 +2755,7 @@ export const Probe = z.preprocess(processResponseBody,z.object({"description": z */ export const ProbeCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), "name": Name, -"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}).optional(), +"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "sled": z.uuid(), })) @@ -2981,7 +2981,7 @@ export const SamlIdentityProviderCreate = z.preprocess(processResponseBody,z.obj "idpEntityId": z.string(), "idpMetadataSource": IdpMetadataSource, "name": Name, -"signingKeypair": DerEncodedKeyPair.nullable().default(null).optional(), +"signingKeypair": DerEncodedKeyPair.nullable().default(null), "sloUrl": z.string(), "spClientId": z.string(), "technicalContactEmail": z.string(), @@ -3063,7 +3063,7 @@ export const SiloCreate = z.preprocess(processResponseBody,z.object({"adminGroup "description": z.string(), "discoverable": SafeBoolean, "identityMode": SiloIdentityMode, -"mappedFleetRoles": z.record(z.string(),FleetRole.array().refine(...uniqueItems)).optional(), +"mappedFleetRoles": z.record(z.string(),FleetRole.array().refine(...uniqueItems)), "name": Name, "quotas": SiloQuotasCreate, "tlsCertificates": CertificateCreate.array(), @@ -3515,14 +3515,14 @@ export const SwitchPortSettings = z.preprocess(processResponseBody,z.object({"ad * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export const SwitchPortSettingsCreate = z.preprocess(processResponseBody,z.object({"addresses": AddressConfig.array(), -"bgpPeers": BgpPeerConfig.array().default([]).optional(), +"bgpPeers": BgpPeerConfig.array().default([]), "description": z.string(), -"groups": NameOrId.array().default([]).optional(), -"interfaces": SwitchInterfaceConfigCreate.array().default([]).optional(), +"groups": NameOrId.array().default([]), +"interfaces": SwitchInterfaceConfigCreate.array().default([]), "links": LinkConfigCreate.array(), "name": Name, "portConfig": SwitchPortConfigCreate, -"routes": RouteConfig.array().default([]).optional(), +"routes": RouteConfig.array().default([]), })) /** @@ -3883,7 +3883,7 @@ export const VpcFirewallRuleUpdate = z.preprocess(processResponseBody,z.object({ /** * Updated list of firewall rules. Will replace all existing rules. */ -export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default([]).optional(), +export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default([]), })) /** @@ -3988,7 +3988,7 @@ export const WebhookCreate = z.preprocess(processResponseBody,z.object({"descrip "endpoint": z.string(), "name": Name, "secrets": z.string().array(), -"subscriptions": AlertSubscription.array().default([]).optional(), +"subscriptions": AlertSubscription.array().default([]), })) /** diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index ada5c02..33b4520 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -114,6 +114,32 @@ test("integer with default", () => { expect(out.value()).toMatchInlineSnapshot('"z.number().default(42)"'); }); +test("integer with constraints and default", () => { + schemaToZod( + { type: "integer", minimum: 0, maximum: 65535, default: null }, + io + ); + expect(out.value()).toMatchInlineSnapshot( + '"z.number().min(0).max(65535).default(null)"' + ); +}); + +test("integer nullable with constraints and default", () => { + schemaToZod( + { + type: "integer", + minimum: 0, + maximum: 65535, + nullable: true, + default: null, + }, + io + ); + expect(out.value()).toMatchInlineSnapshot( + '"z.number().min(0).max(65535).nullable().default(null)"' + ); +}); + test("integer nullable", () => { schemaToZod({ type: "integer", nullable: true }, io); expect(out.value()).toMatchInlineSnapshot('"z.number().nullable()"'); @@ -208,6 +234,107 @@ test("object with properties", () => { `); }); +test("object with optional property that has default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer", default: 0 }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "count": z.number().default(0), + })" + `); +}); + +test("object with optional array that has default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + tags: { type: "array", items: { type: "string" }, default: [] }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "tags": z.string().array().default([]), + })" + `); +}); + +test("object with optional nullable property that has default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + description: { type: "string", nullable: true, default: null }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "description": z.string().nullable().default(null), + })" + `); +}); + +test("object with optional property WITHOUT default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + phone: { type: "string" }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "email": z.string().optional(), + "phone": z.string().optional(), + })" + `); +}); + +test("object mixing required, optional without default, and optional with default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + count: { type: "integer", default: 0 }, + tags: { type: "array", items: { type: "string" }, default: [] }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "age": z.number().optional(), + "count": z.number().default(0), + "tags": z.string().array().default([]), + })" + `); +}); + test("object nullable", () => { schemaToZod( { diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index eb6a9a7..d8a3499 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -84,8 +84,11 @@ export const schemaToZod = makeSchemaGenerator({ }, integer(schema, io) { - schemaToZodInt(schema, io); - if (schema.nullable) io.w0(".nullable()"); + schemaToZodInt(schema, io, { skipDefault: schema.nullable }); + if (schema.nullable) { + io.w0(".nullable()"); + io.w0(getDefaultString(schema)); + } }, array(schema, io) { @@ -116,7 +119,9 @@ export const schemaToZod = makeSchemaGenerator({ for (const [name, subSchema] of Object.entries(schema.properties || {})) { w0(`${JSON.stringify(snakeToCamel(name))}: `); schemaToZod(subSchema, io); - if (!schema.required?.includes(name)) { + // Only add .optional() if the property is not required AND doesn't have a default value + // .default() already makes the input optional, and adding .optional() would prevent the default from being applied + if (!schema.required?.includes(name) && !("default" in subSchema)) { w0(`.optional()`); } w(","); @@ -195,7 +200,11 @@ export const schemaToZod = makeSchemaGenerator({ }, }); -function schemaToZodInt(schema: OpenAPIV3.SchemaObject, { w0 }: IO) { +function schemaToZodInt( + schema: OpenAPIV3.SchemaObject, + { w0 }: IO, + options?: { skipDefault?: boolean } +) { if ("enum" in schema) { /** See comment in {@link setupZod} */ w0(`IntEnum(${JSON.stringify(schema.enum)} as const)`); @@ -203,8 +212,6 @@ function schemaToZodInt(schema: OpenAPIV3.SchemaObject, { w0 }: IO) { w0(`z.number()`); } - w0(getDefaultString(schema)); - const [, unsigned, size] = schema.format?.match(/(u?)int(\d+)/) || []; if ("minimum" in schema) { w0(`.min(${schema.minimum})`); @@ -222,4 +229,8 @@ function schemaToZodInt(schema: OpenAPIV3.SchemaObject, { w0 }: IO) { // It's signed so remove the most significant bit w0(`.max(${Math.pow(2, parseInt(size) - 1) - 1})`); } + + if (!options?.skipDefault) { + w0(getDefaultString(schema)); + } } From fb52bbd82d39fe94e77b554abc6e1ec5c3d560c6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 10:23:10 -0800 Subject: [PATCH 02/10] bump version to 0.13.1 --- oxide-openapi-gen-ts/package-lock.json | 4 ++-- oxide-openapi-gen-ts/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oxide-openapi-gen-ts/package-lock.json b/oxide-openapi-gen-ts/package-lock.json index e57ce19..526ae70 100644 --- a/oxide-openapi-gen-ts/package-lock.json +++ b/oxide-openapi-gen-ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@oxide/openapi-gen-ts", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@oxide/openapi-gen-ts", - "version": "0.13.0", + "version": "0.13.1", "license": "MPL-2.0", "dependencies": { "@commander-js/extra-typings": "^14.0.0", diff --git a/oxide-openapi-gen-ts/package.json b/oxide-openapi-gen-ts/package.json index 2342973..aab6ac6 100644 --- a/oxide-openapi-gen-ts/package.json +++ b/oxide-openapi-gen-ts/package.json @@ -1,6 +1,6 @@ { "name": "@oxide/openapi-gen-ts", - "version": "0.13.0", + "version": "0.13.1", "description": "OpenAPI client generator used to generate Oxide TypeScript SDK", "keywords": [ "oxide", From 51c61d2fc322335134cd4cee12ca7315a8daa4bf Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 10:53:59 -0800 Subject: [PATCH 03/10] Handle defaults in number schemas --- oxide-openapi-gen-ts/src/schema/zod.test.ts | 10 ++++++++++ oxide-openapi-gen-ts/src/schema/zod.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index 33b4520..4ada42b 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -87,6 +87,16 @@ test("number nullable", () => { expect(out.value()).toMatchInlineSnapshot('"z.number().nullable()"'); }); +test("number with default", () => { + schemaToZod({ type: "number", default: 3.14 }, io); + expect(out.value()).toMatchInlineSnapshot('"z.number().default(3.14)"'); +}); + +test("number nullable with default", () => { + schemaToZod({ type: "number", nullable: true, default: null }, io); + expect(out.value()).toMatchInlineSnapshot('"z.number().nullable().default(null)"'); +}); + test("integer", () => { schemaToZod({ type: "integer" }, io); expect(out.value()).toMatchInlineSnapshot('"z.number()"'); diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index d8a3499..f240fc8 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -81,6 +81,7 @@ export const schemaToZod = makeSchemaGenerator({ number(schema, { w0 }) { w0("z.number()"); if (schema.nullable) w0(".nullable()"); + w0(getDefaultString(schema)); }, integer(schema, io) { From e5e8bbdc8c7bc95199e8262f20f7c7ec9026f657 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 10:55:15 -0800 Subject: [PATCH 04/10] npm run fmt --- oxide-openapi-gen-ts/src/schema/zod.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index 4ada42b..360fee9 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -94,7 +94,9 @@ test("number with default", () => { test("number nullable with default", () => { schemaToZod({ type: "number", nullable: true, default: null }, io); - expect(out.value()).toMatchInlineSnapshot('"z.number().nullable().default(null)"'); + expect(out.value()).toMatchInlineSnapshot( + '"z.number().nullable().default(null)"' + ); }); test("integer", () => { From 5a016d40c06d3419d2afc407da245421a000558b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 11:33:22 -0800 Subject: [PATCH 05/10] code review updates; using factory functions to address shared references --- .../src/__snapshots__/validate.ts | 42 ++++---- oxide-openapi-gen-ts/src/schema/zod.test.ts | 100 +++++++++++++++++- oxide-openapi-gen-ts/src/schema/zod.ts | 19 +++- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts index 492bd0b..f8185f7 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts @@ -159,7 +159,7 @@ z.object({"ip": z.ipv4(), "pool": NameOrId.nullable().optional(), "type": z.enum(["explicit"]), }), -z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), +z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), "type": z.enum(["auto"]), }), ]) @@ -1556,7 +1556,7 @@ export const Distributionint64 = z.preprocess(processResponseBody,z.object({"bin /** * Parameters for creating an ephemeral IP address for an instance. */ -export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), +export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), })) /** @@ -1596,7 +1596,7 @@ z.object({"description": z.string(), * Parameters for creating an external IP address for instances. */ export const ExternalIpCreate = z.preprocess(processResponseBody,z.union([ -z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), +z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), "type": z.enum(["ephemeral"]), }), z.object({"floatingIp": NameOrId, @@ -1734,7 +1734,7 @@ export const FloatingIpAttach = z.preprocess(processResponseBody,z.object({"kind /** * Parameters for creating a new floating IP address for instances. */ -export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"}), +export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default(() => ({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"})), "description": z.string(), "name": Name, })) @@ -1942,7 +1942,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv4 addressing. */ export const PrivateIpv4StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv4Assignment, -"transitIps": Ipv4Net.array().default([]), +"transitIps": Ipv4Net.array().default(() => []), })) /** @@ -1961,7 +1961,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv6 addressing. */ export const PrivateIpv6StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv6Assignment, -"transitIps": Ipv6Net.array().default([]), +"transitIps": Ipv6Net.array().default(() => []), })) /** @@ -1986,7 +1986,7 @@ z.object({"type": z.enum(["dual_stack"]), * Create-time parameters for an `InstanceNetworkInterface` */ export const InstanceNetworkInterfaceCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), -"ipConfig": PrivateIpStackCreate.default({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}}), +"ipConfig": PrivateIpStackCreate.default(() => ({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}})), "name": Name, "subnetName": Name, "vpcName": Name, @@ -2013,19 +2013,19 @@ z.object({"type": z.enum(["none"]), /** * Create-time parameters for an `Instance` */ -export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default([]), +export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default(() => []), "autoRestartPolicy": InstanceAutoRestartPolicy.nullable().default(null), "bootDisk": InstanceDiskAttachment.nullable().default(null), "cpuPlatform": InstanceCpuPlatform.nullable().default(null), "description": z.string(), -"disks": InstanceDiskAttachment.array().default([]), -"externalIps": ExternalIpCreate.array().default([]), +"disks": InstanceDiskAttachment.array().default(() => []), +"externalIps": ExternalIpCreate.array().default(() => []), "hostname": Hostname, "memory": ByteCount, -"multicastGroups": NameOrId.array().default([]), +"multicastGroups": NameOrId.array().default(() => []), "name": Name, "ncpus": InstanceCpuCount, -"networkInterfaces": InstanceNetworkInterfaceAttachment.default({"type":"default_dual_stack"}), +"networkInterfaces": InstanceNetworkInterfaceAttachment.default(() => ({"type":"default_dual_stack"})), "sshPublicKeys": NameOrId.array().nullable().optional(), "start": SafeBoolean.default(true), "userData": z.string().default(""), @@ -2101,7 +2101,7 @@ export const InstanceNetworkInterfaceResultsPage = z.preprocess(processResponseB export const InstanceNetworkInterfaceUpdate = z.preprocess(processResponseBody,z.object({"description": z.string().nullable().optional(), "name": Name.nullable().optional(), "primary": SafeBoolean.default(false), -"transitIps": IpNet.array().default([]), +"transitIps": IpNet.array().default(() => []), })) /** @@ -2552,7 +2552,7 @@ export const MulticastGroupUpdate = z.preprocess(processResponseBody,z.object({" */ export const PrivateIpv4Config = z.preprocess(processResponseBody,z.object({"ip": z.ipv4(), "subnet": Ipv4Net, -"transitIps": Ipv4Net.array().default([]), +"transitIps": Ipv4Net.array().default(() => []), })) /** @@ -2755,7 +2755,7 @@ export const Probe = z.preprocess(processResponseBody,z.object({"description": z */ export const ProbeCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), "name": Name, -"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), +"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), "sled": z.uuid(), })) @@ -3515,14 +3515,14 @@ export const SwitchPortSettings = z.preprocess(processResponseBody,z.object({"ad * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export const SwitchPortSettingsCreate = z.preprocess(processResponseBody,z.object({"addresses": AddressConfig.array(), -"bgpPeers": BgpPeerConfig.array().default([]), +"bgpPeers": BgpPeerConfig.array().default(() => []), "description": z.string(), -"groups": NameOrId.array().default([]), -"interfaces": SwitchInterfaceConfigCreate.array().default([]), +"groups": NameOrId.array().default(() => []), +"interfaces": SwitchInterfaceConfigCreate.array().default(() => []), "links": LinkConfigCreate.array(), "name": Name, "portConfig": SwitchPortConfigCreate, -"routes": RouteConfig.array().default([]), +"routes": RouteConfig.array().default(() => []), })) /** @@ -3883,7 +3883,7 @@ export const VpcFirewallRuleUpdate = z.preprocess(processResponseBody,z.object({ /** * Updated list of firewall rules. Will replace all existing rules. */ -export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default([]), +export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default(() => []), })) /** @@ -3988,7 +3988,7 @@ export const WebhookCreate = z.preprocess(processResponseBody,z.object({"descrip "endpoint": z.string(), "name": Name, "secrets": z.string().array(), -"subscriptions": AlertSubscription.array().default([]), +"subscriptions": AlertSubscription.array().default(() => []), })) /** diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index 360fee9..c903e14 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -194,7 +194,9 @@ test("array nullable", () => { test("array with default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); - expect(out.value()).toMatchInlineSnapshot('"z.string().array().default([])"'); + expect(out.value()).toMatchInlineSnapshot( + `"z.string().array().default(() => [])"` + ); }); test("array nullable with default", () => { @@ -208,7 +210,7 @@ test("array nullable with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - '"z.number().array().nullable().default([1,2])"' + `"z.number().array().nullable().default(() => [1,2])"` ); }); @@ -279,7 +281,7 @@ test("object with optional array that has default", () => { ); expect(out.value()).toMatchInlineSnapshot(` "z.object({"name": z.string(), - "tags": z.string().array().default([]), + "tags": z.string().array().default(() => []), })" `); }); @@ -342,7 +344,7 @@ test("object mixing required, optional without default, and optional with defaul "z.object({"name": z.string(), "age": z.number().optional(), "count": z.number().default(0), - "tags": z.string().array().default([]), + "tags": z.string().array().default(() => []), })" `); }); @@ -478,7 +480,7 @@ test("allOf with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - '"Config.default({"enabled":true})"' + `"Config.default(() => ({"enabled":true}))"` ); }); @@ -488,3 +490,91 @@ test("empty schema", () => { '"z.record(z.string(), z.unknown())"' ); }); + +test("object property with default: undefined should still be optional", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + description: { type: "string", default: undefined }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "description": z.string().optional(), + })" + `); +}); + +test("array default uses factory function", () => { + schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); + expect(out.value()).toMatchInlineSnapshot( + '"z.string().array().default(() => [])"' + ); +}); + +test("array default with values uses factory function", () => { + schemaToZod( + { type: "array", items: { type: "number" }, default: [1, 2, 3] }, + io + ); + expect(out.value()).toMatchInlineSnapshot( + '"z.number().array().default(() => [1,2,3])"' + ); +}); + +test("object default uses factory function", () => { + schemaToZod( + { + allOf: [{ $ref: "#/components/schemas/Config" }], + default: { enabled: true }, + }, + io + ); + expect(out.value()).toMatchInlineSnapshot( + '"Config.default(() => ({"enabled":true}))"' + ); +}); + +test("primitive defaults do not use factory function", () => { + schemaToZod({ type: "string", default: "test" }, io); + expect(out.value()).toMatchInlineSnapshot(`"z.string().default("test")"`); + + out.clear(); + schemaToZod({ type: "number", default: 42 }, io); + expect(out.value()).toMatchInlineSnapshot('"z.number().default(42)"'); + + out.clear(); + schemaToZod({ type: "boolean", default: false }, io); + expect(out.value()).toMatchInlineSnapshot('"SafeBoolean.default(false)"'); +}); + +test("null default does not use factory function", () => { + schemaToZod({ type: "string", nullable: true, default: null }, io); + expect(out.value()).toMatchInlineSnapshot( + '"z.string().nullable().default(null)"' + ); +}); + +test("$ref property should be optional when not required", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + config: { $ref: "#/components/schemas/Config" }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "config": Config.optional(), + })" + `); +}); diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index f240fc8..f43c735 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -13,10 +13,21 @@ import { camelify, snakeToCamel } from "../util"; /** * Generate the .default() method call with transformed default value + * Uses factory functions for arrays and objects to avoid shared reference issues */ function getDefaultString(schema: OpenAPIV3.SchemaObject): string { - if (!("default" in schema)) return ""; - return `.default(${JSON.stringify(camelify(schema.default))})`; + if (!("default" in schema) || schema.default === undefined) return ""; + const defaultValue = camelify(schema.default); + + // Use factory functions for non-primitive defaults to avoid shared references + if (Array.isArray(defaultValue)) { + return `.default(() => ${JSON.stringify(defaultValue)})`; + } + if (defaultValue !== null && typeof defaultValue === "object") { + return `.default(() => (${JSON.stringify(defaultValue)}))`; + } + + return `.default(${JSON.stringify(defaultValue)})`; } export const schemaToZod = makeSchemaGenerator({ @@ -122,7 +133,9 @@ export const schemaToZod = makeSchemaGenerator({ schemaToZod(subSchema, io); // Only add .optional() if the property is not required AND doesn't have a default value // .default() already makes the input optional, and adding .optional() would prevent the default from being applied - if (!schema.required?.includes(name) && !("default" in subSchema)) { + const hasDefault = + "$ref" in subSchema ? false : subSchema.default !== undefined; + if (!schema.required?.includes(name) && !hasDefault) { w0(`.optional()`); } w(","); From 7acd8dc37b139e60dff58c935a83166d6be84074 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 11:44:35 -0800 Subject: [PATCH 06/10] refactor out skipDefault --- oxide-openapi-gen-ts/src/schema/zod.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index f43c735..452f52b 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -96,11 +96,9 @@ export const schemaToZod = makeSchemaGenerator({ }, integer(schema, io) { - schemaToZodInt(schema, io, { skipDefault: schema.nullable }); - if (schema.nullable) { - io.w0(".nullable()"); - io.w0(getDefaultString(schema)); - } + schemaToZodInt(schema, io); + if (schema.nullable) io.w0(".nullable()"); + io.w0(getDefaultString(schema)); }, array(schema, io) { @@ -214,11 +212,7 @@ export const schemaToZod = makeSchemaGenerator({ }, }); -function schemaToZodInt( - schema: OpenAPIV3.SchemaObject, - { w0 }: IO, - options?: { skipDefault?: boolean } -) { +function schemaToZodInt(schema: OpenAPIV3.SchemaObject, { w0 }: IO) { if ("enum" in schema) { /** See comment in {@link setupZod} */ w0(`IntEnum(${JSON.stringify(schema.enum)} as const)`); @@ -243,8 +237,4 @@ function schemaToZodInt( // It's signed so remove the most significant bit w0(`.max(${Math.pow(2, parseInt(size) - 1) - 1})`); } - - if (!options?.skipDefault) { - w0(getDefaultString(schema)); - } } From 1f58ca0fd7008f8cbc569cdbf5afffeabd1223c9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 11:47:22 -0800 Subject: [PATCH 07/10] use default 0 to simplify ordering test --- oxide-openapi-gen-ts/src/schema/zod.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index c903e14..fc0484c 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { expect, test, beforeEach } from "vitest"; +import { beforeEach, expect, test } from "vitest"; import { initIO, TestWritable } from "../io"; import { schemaToZod } from "./zod"; @@ -127,12 +127,9 @@ test("integer with default", () => { }); test("integer with constraints and default", () => { - schemaToZod( - { type: "integer", minimum: 0, maximum: 65535, default: null }, - io - ); + schemaToZod({ type: "integer", minimum: 0, maximum: 65535, default: 0 }, io); expect(out.value()).toMatchInlineSnapshot( - '"z.number().min(0).max(65535).default(null)"' + '"z.number().min(0).max(65535).default(0)"' ); }); From e6c71037a4fd8655529cf90189c135cba03a83bc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 12:20:50 -0800 Subject: [PATCH 08/10] back out factory functions --- .../src/__snapshots__/validate.ts | 42 +++--- oxide-openapi-gen-ts/src/schema/zod.test.ts | 124 ++++++++++++++---- oxide-openapi-gen-ts/src/schema/zod.ts | 15 +-- 3 files changed, 124 insertions(+), 57 deletions(-) diff --git a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts index f8185f7..492bd0b 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts @@ -159,7 +159,7 @@ z.object({"ip": z.ipv4(), "pool": NameOrId.nullable().optional(), "type": z.enum(["explicit"]), }), -z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), +z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "type": z.enum(["auto"]), }), ]) @@ -1556,7 +1556,7 @@ export const Distributionint64 = z.preprocess(processResponseBody,z.object({"bin /** * Parameters for creating an ephemeral IP address for an instance. */ -export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), +export const EphemeralIpCreate = z.preprocess(processResponseBody,z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), })) /** @@ -1596,7 +1596,7 @@ z.object({"description": z.string(), * Parameters for creating an external IP address for instances. */ export const ExternalIpCreate = z.preprocess(processResponseBody,z.union([ -z.object({"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), +z.object({"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "type": z.enum(["ephemeral"]), }), z.object({"floatingIp": NameOrId, @@ -1734,7 +1734,7 @@ export const FloatingIpAttach = z.preprocess(processResponseBody,z.object({"kind /** * Parameters for creating a new floating IP address for instances. */ -export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default(() => ({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"})), +export const FloatingIpCreate = z.preprocess(processResponseBody,z.object({"addressSelector": AddressSelector.default({"poolSelector":{"ipVersion":null,"type":"auto"},"type":"auto"}), "description": z.string(), "name": Name, })) @@ -1942,7 +1942,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv4 addressing. */ export const PrivateIpv4StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv4Assignment, -"transitIps": Ipv4Net.array().default(() => []), +"transitIps": Ipv4Net.array().default([]), })) /** @@ -1961,7 +1961,7 @@ z.object({"type": z.enum(["explicit"]), * Configuration for a network interface's IPv6 addressing. */ export const PrivateIpv6StackCreate = z.preprocess(processResponseBody,z.object({"ip": Ipv6Assignment, -"transitIps": Ipv6Net.array().default(() => []), +"transitIps": Ipv6Net.array().default([]), })) /** @@ -1986,7 +1986,7 @@ z.object({"type": z.enum(["dual_stack"]), * Create-time parameters for an `InstanceNetworkInterface` */ export const InstanceNetworkInterfaceCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), -"ipConfig": PrivateIpStackCreate.default(() => ({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}})), +"ipConfig": PrivateIpStackCreate.default({"type":"dual_stack","value":{"v4":{"ip":{"type":"auto"},"transitIps":[]},"v6":{"ip":{"type":"auto"},"transitIps":[]}}}), "name": Name, "subnetName": Name, "vpcName": Name, @@ -2013,19 +2013,19 @@ z.object({"type": z.enum(["none"]), /** * Create-time parameters for an `Instance` */ -export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default(() => []), +export const InstanceCreate = z.preprocess(processResponseBody,z.object({"antiAffinityGroups": NameOrId.array().default([]), "autoRestartPolicy": InstanceAutoRestartPolicy.nullable().default(null), "bootDisk": InstanceDiskAttachment.nullable().default(null), "cpuPlatform": InstanceCpuPlatform.nullable().default(null), "description": z.string(), -"disks": InstanceDiskAttachment.array().default(() => []), -"externalIps": ExternalIpCreate.array().default(() => []), +"disks": InstanceDiskAttachment.array().default([]), +"externalIps": ExternalIpCreate.array().default([]), "hostname": Hostname, "memory": ByteCount, -"multicastGroups": NameOrId.array().default(() => []), +"multicastGroups": NameOrId.array().default([]), "name": Name, "ncpus": InstanceCpuCount, -"networkInterfaces": InstanceNetworkInterfaceAttachment.default(() => ({"type":"default_dual_stack"})), +"networkInterfaces": InstanceNetworkInterfaceAttachment.default({"type":"default_dual_stack"}), "sshPublicKeys": NameOrId.array().nullable().optional(), "start": SafeBoolean.default(true), "userData": z.string().default(""), @@ -2101,7 +2101,7 @@ export const InstanceNetworkInterfaceResultsPage = z.preprocess(processResponseB export const InstanceNetworkInterfaceUpdate = z.preprocess(processResponseBody,z.object({"description": z.string().nullable().optional(), "name": Name.nullable().optional(), "primary": SafeBoolean.default(false), -"transitIps": IpNet.array().default(() => []), +"transitIps": IpNet.array().default([]), })) /** @@ -2552,7 +2552,7 @@ export const MulticastGroupUpdate = z.preprocess(processResponseBody,z.object({" */ export const PrivateIpv4Config = z.preprocess(processResponseBody,z.object({"ip": z.ipv4(), "subnet": Ipv4Net, -"transitIps": Ipv4Net.array().default(() => []), +"transitIps": Ipv4Net.array().default([]), })) /** @@ -2755,7 +2755,7 @@ export const Probe = z.preprocess(processResponseBody,z.object({"description": z */ export const ProbeCreate = z.preprocess(processResponseBody,z.object({"description": z.string(), "name": Name, -"poolSelector": PoolSelector.default(() => ({"ipVersion":null,"type":"auto"})), +"poolSelector": PoolSelector.default({"ipVersion":null,"type":"auto"}), "sled": z.uuid(), })) @@ -3515,14 +3515,14 @@ export const SwitchPortSettings = z.preprocess(processResponseBody,z.object({"ad * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export const SwitchPortSettingsCreate = z.preprocess(processResponseBody,z.object({"addresses": AddressConfig.array(), -"bgpPeers": BgpPeerConfig.array().default(() => []), +"bgpPeers": BgpPeerConfig.array().default([]), "description": z.string(), -"groups": NameOrId.array().default(() => []), -"interfaces": SwitchInterfaceConfigCreate.array().default(() => []), +"groups": NameOrId.array().default([]), +"interfaces": SwitchInterfaceConfigCreate.array().default([]), "links": LinkConfigCreate.array(), "name": Name, "portConfig": SwitchPortConfigCreate, -"routes": RouteConfig.array().default(() => []), +"routes": RouteConfig.array().default([]), })) /** @@ -3883,7 +3883,7 @@ export const VpcFirewallRuleUpdate = z.preprocess(processResponseBody,z.object({ /** * Updated list of firewall rules. Will replace all existing rules. */ -export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default(() => []), +export const VpcFirewallRuleUpdateParams = z.preprocess(processResponseBody,z.object({"rules": VpcFirewallRuleUpdate.array().default([]), })) /** @@ -3988,7 +3988,7 @@ export const WebhookCreate = z.preprocess(processResponseBody,z.object({"descrip "endpoint": z.string(), "name": Name, "secrets": z.string().array(), -"subscriptions": AlertSubscription.array().default(() => []), +"subscriptions": AlertSubscription.array().default([]), })) /** diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index fc0484c..2747fa4 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -192,7 +192,7 @@ test("array nullable", () => { test("array with default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); expect(out.value()).toMatchInlineSnapshot( - `"z.string().array().default(() => [])"` + `"z.string().array().default([])"` ); }); @@ -207,7 +207,7 @@ test("array nullable with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - `"z.number().array().nullable().default(() => [1,2])"` + `"z.number().array().nullable().default([1,2])"` ); }); @@ -278,7 +278,7 @@ test("object with optional array that has default", () => { ); expect(out.value()).toMatchInlineSnapshot(` "z.object({"name": z.string(), - "tags": z.string().array().default(() => []), + "tags": z.string().array().default([]), })" `); }); @@ -341,7 +341,7 @@ test("object mixing required, optional without default, and optional with defaul "z.object({"name": z.string(), "age": z.number().optional(), "count": z.number().default(0), - "tags": z.string().array().default(() => []), + "tags": z.string().array().default([]), })" `); }); @@ -477,7 +477,7 @@ test("allOf with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - `"Config.default(() => ({"enabled":true}))"` + `"Config.default({"enabled":true})"` ); }); @@ -507,24 +507,24 @@ test("object property with default: undefined should still be optional", () => { `); }); -test("array default uses factory function", () => { +test("array default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); expect(out.value()).toMatchInlineSnapshot( - '"z.string().array().default(() => [])"' + '"z.string().array().default([])"' ); }); -test("array default with values uses factory function", () => { +test("array default with values", () => { schemaToZod( { type: "array", items: { type: "number" }, default: [1, 2, 3] }, io ); expect(out.value()).toMatchInlineSnapshot( - '"z.number().array().default(() => [1,2,3])"' + '"z.number().array().default([1,2,3])"' ); }); -test("object default uses factory function", () => { +test("object default", () => { schemaToZod( { allOf: [{ $ref: "#/components/schemas/Config" }], @@ -533,37 +533,107 @@ test("object default uses factory function", () => { io ); expect(out.value()).toMatchInlineSnapshot( - '"Config.default(() => ({"enabled":true}))"' + '"Config.default({"enabled":true})"' ); }); -test("primitive defaults do not use factory function", () => { - schemaToZod({ type: "string", default: "test" }, io); - expect(out.value()).toMatchInlineSnapshot(`"z.string().default("test")"`); +test("$ref property should be optional when not required", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + config: { $ref: "#/components/schemas/Config" }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "config": Config.optional(), + })" + `); +}); - out.clear(); - schemaToZod({ type: "number", default: 42 }, io); - expect(out.value()).toMatchInlineSnapshot('"z.number().default(42)"'); +test("object-typed property with default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + config: { + type: "object", + properties: { + enable_feature: { type: "boolean" }, + max_retries: { type: "integer" }, + }, + required: ["enable_feature", "max_retries"], + default: { enable_feature: true, max_retries: 3 }, + }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "config": z.object({"enableFeature": SafeBoolean, + "maxRetries": z.number(), + }).default({"enableFeature":true,"maxRetries":3}), + })" + `); +}); - out.clear(); - schemaToZod({ type: "boolean", default: false }, io); - expect(out.value()).toMatchInlineSnapshot('"SafeBoolean.default(false)"'); +test("object-typed property with default should not get .optional()", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + settings: { + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + default: { foo: "bar" }, + }, + }, + required: ["name"], + }, + io + ); + const result = out.value(); + // Should have .default() but NOT .optional() + expect(result).toContain('.default({"foo":"bar"})'); + expect(result).not.toContain('default({"foo":"bar"}).optional()'); }); -test("null default does not use factory function", () => { - schemaToZod({ type: "string", nullable: true, default: null }, io); - expect(out.value()).toMatchInlineSnapshot( - '"z.string().nullable().default(null)"' +test("default null without nullable should be skipped and property marked optional", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string", default: null }, // null default but not nullable + }, + required: ["name"], + }, + io ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "value": z.string().optional(), + })" + `); }); -test("$ref property should be optional when not required", () => { +test("default null with nullable should emit default", () => { schemaToZod( { type: "object", properties: { name: { type: "string" }, - config: { $ref: "#/components/schemas/Config" }, + value: { type: "string", nullable: true, default: null }, }, required: ["name"], }, @@ -571,7 +641,7 @@ test("$ref property should be optional when not required", () => { ); expect(out.value()).toMatchInlineSnapshot(` "z.object({"name": z.string(), - "config": Config.optional(), + "value": z.string().nullable().default(null), })" `); }); diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index 452f52b..143bb0b 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -13,18 +13,14 @@ import { camelify, snakeToCamel } from "../util"; /** * Generate the .default() method call with transformed default value - * Uses factory functions for arrays and objects to avoid shared reference issues */ function getDefaultString(schema: OpenAPIV3.SchemaObject): string { if (!("default" in schema) || schema.default === undefined) return ""; const defaultValue = camelify(schema.default); - // Use factory functions for non-primitive defaults to avoid shared references - if (Array.isArray(defaultValue)) { - return `.default(() => ${JSON.stringify(defaultValue)})`; - } - if (defaultValue !== null && typeof defaultValue === "object") { - return `.default(() => (${JSON.stringify(defaultValue)}))`; + // Only emit .default(null) if the schema is nullable to avoid runtime errors + if (defaultValue === null && !schema.nullable) { + return ""; } return `.default(${JSON.stringify(defaultValue)})`; @@ -131,8 +127,8 @@ export const schemaToZod = makeSchemaGenerator({ schemaToZod(subSchema, io); // Only add .optional() if the property is not required AND doesn't have a default value // .default() already makes the input optional, and adding .optional() would prevent the default from being applied - const hasDefault = - "$ref" in subSchema ? false : subSchema.default !== undefined; + // Use getDefaultString to ensure we match actual default emission + const hasDefault = "$ref" in subSchema ? false : getDefaultString(subSchema) !== ""; if (!schema.required?.includes(name) && !hasDefault) { w0(`.optional()`); } @@ -140,6 +136,7 @@ export const schemaToZod = makeSchemaGenerator({ } w0("})"); if (schema.nullable) io.w0(".nullable()"); + w0(getDefaultString(schema)); }, oneOf(schema, io) { From 56ec198a05172b962fea5d0097a0d1c2fc335e19 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 12:21:06 -0800 Subject: [PATCH 09/10] npm run fmt --- oxide-openapi-gen-ts/src/schema/zod.test.ts | 8 ++------ oxide-openapi-gen-ts/src/schema/zod.ts | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index 2747fa4..ab02b7a 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -191,9 +191,7 @@ test("array nullable", () => { test("array with default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); - expect(out.value()).toMatchInlineSnapshot( - `"z.string().array().default([])"` - ); + expect(out.value()).toMatchInlineSnapshot(`"z.string().array().default([])"`); }); test("array nullable with default", () => { @@ -509,9 +507,7 @@ test("object property with default: undefined should still be optional", () => { test("array default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); - expect(out.value()).toMatchInlineSnapshot( - '"z.string().array().default([])"' - ); + expect(out.value()).toMatchInlineSnapshot('"z.string().array().default([])"'); }); test("array default with values", () => { diff --git a/oxide-openapi-gen-ts/src/schema/zod.ts b/oxide-openapi-gen-ts/src/schema/zod.ts index 143bb0b..e0dedad 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -128,7 +128,8 @@ export const schemaToZod = makeSchemaGenerator({ // Only add .optional() if the property is not required AND doesn't have a default value // .default() already makes the input optional, and adding .optional() would prevent the default from being applied // Use getDefaultString to ensure we match actual default emission - const hasDefault = "$ref" in subSchema ? false : getDefaultString(subSchema) !== ""; + const hasDefault = + "$ref" in subSchema ? false : getDefaultString(subSchema) !== ""; if (!schema.required?.includes(name) && !hasDefault) { w0(`.optional()`); } From 7c9e26dd3b6e04c0de9b4339e4199af1b4bd7a1e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 12:26:16 -0800 Subject: [PATCH 10/10] single quotes --- oxide-openapi-gen-ts/src/schema/zod.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oxide-openapi-gen-ts/src/schema/zod.test.ts b/oxide-openapi-gen-ts/src/schema/zod.test.ts index ab02b7a..62593d3 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.test.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.test.ts @@ -191,7 +191,7 @@ test("array nullable", () => { test("array with default", () => { schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); - expect(out.value()).toMatchInlineSnapshot(`"z.string().array().default([])"`); + expect(out.value()).toMatchInlineSnapshot('"z.string().array().default([])"'); }); test("array nullable with default", () => { @@ -205,7 +205,7 @@ test("array nullable with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - `"z.number().array().nullable().default([1,2])"` + '"z.number().array().nullable().default([1,2])"' ); }); @@ -475,7 +475,7 @@ test("allOf with default", () => { io ); expect(out.value()).toMatchInlineSnapshot( - `"Config.default({"enabled":true})"` + '"Config.default({"enabled":true})"' ); });