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/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", 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..62593d3 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"; @@ -87,6 +87,18 @@ 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()"'); @@ -114,6 +126,29 @@ 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: 0 }, io); + expect(out.value()).toMatchInlineSnapshot( + '"z.number().min(0).max(65535).default(0)"' + ); +}); + +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 +243,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( { @@ -349,3 +485,159 @@ 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", () => { + schemaToZod({ type: "array", items: { type: "string" }, default: [] }, io); + expect(out.value()).toMatchInlineSnapshot('"z.string().array().default([])"'); +}); + +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])"' + ); +}); + +test("object default", () => { + schemaToZod( + { + allOf: [{ $ref: "#/components/schemas/Config" }], + default: { enabled: true }, + }, + io + ); + expect(out.value()).toMatchInlineSnapshot( + '"Config.default({"enabled":true})"' + ); +}); + +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(), + })" + `); +}); + +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}), + })" + `); +}); + +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("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("default null with nullable should emit default", () => { + schemaToZod( + { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string", nullable: true, default: null }, + }, + required: ["name"], + }, + io + ); + expect(out.value()).toMatchInlineSnapshot(` + "z.object({"name": z.string(), + "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 eb6a9a7..e0dedad 100644 --- a/oxide-openapi-gen-ts/src/schema/zod.ts +++ b/oxide-openapi-gen-ts/src/schema/zod.ts @@ -15,8 +15,15 @@ import { camelify, snakeToCamel } from "../util"; * Generate the .default() method call with transformed default value */ 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); + + // Only emit .default(null) if the schema is nullable to avoid runtime errors + if (defaultValue === null && !schema.nullable) { + return ""; + } + + return `.default(${JSON.stringify(defaultValue)})`; } export const schemaToZod = makeSchemaGenerator({ @@ -81,11 +88,13 @@ export const schemaToZod = makeSchemaGenerator({ number(schema, { w0 }) { w0("z.number()"); if (schema.nullable) w0(".nullable()"); + w0(getDefaultString(schema)); }, integer(schema, io) { schemaToZodInt(schema, io); if (schema.nullable) io.w0(".nullable()"); + io.w0(getDefaultString(schema)); }, array(schema, io) { @@ -116,13 +125,19 @@ 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 + // 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()`); } w(","); } w0("})"); if (schema.nullable) io.w0(".nullable()"); + w0(getDefaultString(schema)); }, oneOf(schema, io) { @@ -203,8 +218,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})`);