From e92c8ccdc17bffb7e8fa1e75dba629642fed9440 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Wed, 5 Aug 2026 10:17:56 -0700 Subject: [PATCH 1/3] Rework date-mapping to check keys suggested by schema, support arrays These are two closely related changes: 1. In an attempt to keep the "try making this string a Date if the property name looks good" approach without missing known fields, I made date-parsing depend on a generated set of known-to-be-date property names. The logic of checking that parsing actually _works_ remains necessary, since we're still guessing, but this eliminates false negatives like `first_seen` without adding them to a series of conditional checks. 2. OxQL results are the one instance in the full nexus schema of an array of dates, which was unsupported here. Types suggested Date[], in reality we got an array of strings! This change adds support for date arrays, by updating the parser and changing our object mapper to also apply the value mapper to objects and arrays. --- oxide-openapi-gen-ts/src/__snapshots__/Api.ts | 33 ++++++- .../src/__snapshots__/http-client.ts | 72 +++++++++------- .../src/__snapshots__/recursive-validate.ts | 10 ++- .../src/__snapshots__/util.ts | 48 ++++++----- .../src/__snapshots__/validate.ts | 10 ++- oxide-openapi-gen-ts/src/client/api.ts | 36 +++++++- .../src/client/static/http-client.test.ts | 30 +++---- .../src/client/static/http-client.ts | 72 +++++++++------- .../src/client/static/util.test.ts | 86 ++++++++++++------- .../src/client/static/util.ts | 48 ++++++----- .../src/client/zodValidators.ts | 10 ++- oxide-openapi-gen-ts/src/gen.test.ts | 37 +++++++- 12 files changed, 324 insertions(+), 168 deletions(-) diff --git a/oxide-openapi-gen-ts/src/__snapshots__/Api.ts b/oxide-openapi-gen-ts/src/__snapshots__/Api.ts index 479379c..3d041f3 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/Api.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/Api.ts @@ -1,8 +1,8 @@ /* eslint-disable */ import type { FetchParams, FullParams, ApiResult } from "./http-client"; - import { dateReplacer, handleResponse, mergeParams, toQueryString } from './http-client' - import { snakeify } from './util' + import { dateReplacer, handleResponseWithMapper, mergeParams, toQueryString } from './http-client' + import { makeParseIfDate, snakeify } from './util' export type { ApiResult, ErrorBody, ErrorResult } from './http-client' @@ -6006,6 +6006,35 @@ export type NameSortMode = "name_ascending" ; +const dateTimeProperties = new Set([ + "auto_restart_cooldown_expiration", + "created", + "first_seen", + "last_seen", + "start_time", + "time_aborted", + "time_committed", + "time_completed", + "time_created", + "time_expires", + "time_last_auto_restarted", + "time_last_step_planned", + "time_last_used", + "time_modified", + "time_requested", + "time_run_state_updated", + "time_sent", + "time_started", + "timestamp" +]); +const dateTimeArrayProperties = new Set([ + "start_times", + "timestamps" +]); + +export const parseIfDate = makeParseIfDate(dateTimeProperties, dateTimeArrayProperties); +const handleResponse = handleResponseWithMapper(parseIfDate); + export interface ProbeListQueryParams { limit?: number | null, pageToken?: string | null, diff --git a/oxide-openapi-gen-ts/src/__snapshots__/http-client.ts b/oxide-openapi-gen-ts/src/__snapshots__/http-client.ts index 666fdcc..7ba8393 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/http-client.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/http-client.ts @@ -6,7 +6,13 @@ * Copyright Oxide Computer Company */ -import { camelToSnake, processResponseBody, isNotNull } from "./util"; +import { + camelToSnake, + mapObj, + snakeToCamel, + isNotNull, + type ValueMapper, +} from "./util"; /** Success responses from the API */ export type ApiSuccess = { @@ -60,42 +66,44 @@ function encodeQueryParam(key: string, value: unknown) { )}`; } -export async function handleResponse( - response: Response, -): Promise> { - const respText = await response.text(); - - // catch JSON parse or processing errors - let respJson; - try { - // don't bother trying to parse empty responses like 204s - // TODO: is empty object what we want here? - respJson = - respText.length > 0 ? processResponseBody(JSON.parse(respText)) : {}; - } catch (e) { - return { - type: "client_error", - response, - error: e as Error, - text: respText, - }; - } +export const handleResponseWithMapper = + (valueMapper: ValueMapper) => + async (response: Response): Promise> => { + const respText = await response.text(); + + // catch JSON parse or processing errors + let respJson; + try { + // don't bother trying to parse empty responses like 204s + // TODO: is empty object what we want here? + respJson = + respText.length > 0 + ? mapObj(snakeToCamel, valueMapper)(JSON.parse(respText)) + : {}; + } catch (e) { + return { + type: "client_error", + response, + error: e as Error, + text: respText, + }; + } - if (!response.ok) { + if (!response.ok) { + return { + type: "error", + response, + data: respJson as ErrorBody, + }; + } + + // don't validate respJson, just assume it matches the type return { - type: "error", + type: "success", response, - data: respJson as ErrorBody, + data: respJson as Data, }; - } - - // don't validate respJson, just assume it matches the type - return { - type: "success", - response, - data: respJson as Data, }; -} // has to be any. the particular query params types don't like unknown // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/oxide-openapi-gen-ts/src/__snapshots__/recursive-validate.ts b/oxide-openapi-gen-ts/src/__snapshots__/recursive-validate.ts index d48c6de..1a7af9a 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/recursive-validate.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/recursive-validate.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import { z, ZodType } from 'zod/v4'; - import { processResponseBody, uniqueItems } from './util'; + import { mapObj, snakeToCamel, uniqueItems } from './util'; /** * Zod only supports string enums at the moment. A previous issue was opened @@ -15,7 +15,13 @@ /** Helper to ensure booleans provided as strings end up with the correct value */ const SafeBoolean = z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()) - + + /** + * Right now, the only value mapping we do is from ISO string to Date object, + * which zod handles fine on its own. + */ + const processResponseBody = mapObj(snakeToCamel, (_, v) => v); + import type * as Api from './Api'; export const TreeNode: ZodType = z.preprocess(processResponseBody,z.object({"value": z.string(), diff --git a/oxide-openapi-gen-ts/src/__snapshots__/util.ts b/oxide-openapi-gen-ts/src/__snapshots__/util.ts index 8ebee32..e91b37c 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/util.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/util.ts @@ -19,8 +19,11 @@ export const isObjectOrArray = (o: unknown) => !(o instanceof Error) && o !== null; +export type ValueMapper = (k: string | undefined, v: unknown) => unknown; + /** - * Recursively map (k, v) pairs using Object.entries + * Recursively map (k, v) pairs using Object.entries. Recursion happens after + * mapping, so objects and arrays may themselves be mapped. * * Note that value transform function takes both k and v so we can use the key * to decide whether to transform the value. @@ -29,10 +32,7 @@ export const isObjectOrArray = (o: unknown) => * @param vf maps key + value to value */ export const mapObj = - ( - kf: (k: string) => string, - vf: (k: string | undefined, v: unknown) => unknown = (_, v) => v, - ) => + (kf: (k: string) => string, vf: ValueMapper = (_, v) => v) => (o: unknown): unknown => { if (!isObjectOrArray(o)) return o; @@ -40,32 +40,36 @@ export const mapObj = const newObj: Record = {}; for (const [k, v] of Object.entries(o as Record)) { - newObj[kf(k)] = isObjectOrArray(v) ? mapObj(kf, vf)(v) : vf(k, v); + const mapped = vf(k, v); + newObj[kf(k)] = isObjectOrArray(mapped) ? mapObj(kf, vf)(mapped) : mapped; } return newObj; }; const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; -export const parseIfDate = (k: string | undefined, v: unknown) => { - if ( - typeof v === "string" && - isoDateRegex.test(v) && - (k?.startsWith("time_") || - k?.endsWith("_time") || - k?.endsWith("_expiration") || - k === "timestamp") - ) { - const d = new Date(v); - if (isNaN(d.getTime())) return v; - return d; - } - return v; +/** + * Parse an ISO date string to a Date, or return unchanged. + */ +const parseDate = (v: unknown) => { + if (typeof v !== "string" || !isoDateRegex.test(v)) return v; + const d = new Date(v); + return isNaN(d.getTime()) ? v : d; }; -export const snakeify = mapObj(camelToSnake); +/** + * Build a date parser sensitive only to the keys provided. + */ +export const makeParseIfDate = + (dateProps: Set, dateArrayProps: Set): ValueMapper => + (k, v) => { + if (k === undefined) return v; + if (dateProps.has(k)) return parseDate(v); + if (dateArrayProps.has(k) && Array.isArray(v)) return v.map(parseDate); + return v; + }; -export const processResponseBody = mapObj(snakeToCamel, parseIfDate); +export const snakeify = mapObj(camelToSnake); export function isNotNull(value: T): value is NonNullable { return value != null; diff --git a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts index 8f605d4..9cf03a2 100644 --- a/oxide-openapi-gen-ts/src/__snapshots__/validate.ts +++ b/oxide-openapi-gen-ts/src/__snapshots__/validate.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import { z, ZodType } from 'zod/v4'; - import { processResponseBody, uniqueItems } from './util'; + import { mapObj, snakeToCamel, uniqueItems } from './util'; /** * Zod only supports string enums at the moment. A previous issue was opened @@ -15,7 +15,13 @@ /** Helper to ensure booleans provided as strings end up with the correct value */ const SafeBoolean = z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()) - + + /** + * Right now, the only value mapping we do is from ISO string to Date object, + * which zod handles fine on its own. + */ + const processResponseBody = mapObj(snakeToCamel, (_, v) => v); + /** * An IPv4 subnet * diff --git a/oxide-openapi-gen-ts/src/client/api.ts b/oxide-openapi-gen-ts/src/client/api.ts index b410e83..129d0de 100644 --- a/oxide-openapi-gen-ts/src/client/api.ts +++ b/oxide-openapi-gen-ts/src/client/api.ts @@ -122,12 +122,14 @@ export async function generateApi(spec: OpenAPIV3.Document, destDir: string) { const out = fs.createWriteStream(outFile, { flags: "w" }); const io = initIO(out); const { w, w0 } = io; + const dateTimeProperties = new Set(); + const dateTimeArrayProperties = new Set(); w(`/* eslint-disable */ import type { FetchParams, FullParams, ApiResult } from "./http-client"; - import { dateReplacer, handleResponse, mergeParams, toQueryString } from './http-client' - import { snakeify } from './util' + import { dateReplacer, handleResponseWithMapper, mergeParams, toQueryString } from './http-client' + import { makeParseIfDate, snakeify } from './util' export type { ApiResult, ErrorBody, ErrorResult } from './http-client' `); @@ -153,8 +155,38 @@ export async function generateApi(spec: OpenAPIV3.Document, destDir: string) { w(`export type ${schemaName} =`); schemaToTypes(schema, io); w(";\n"); + + // Collect the property names of date-like strings and arrays of such + if (!("$ref" in schema) && schema.properties) { + for (const [property, pSchema] of Object.entries(schema.properties)) { + if ("$ref" in pSchema) continue; + if (pSchema.format === "date-time") { + dateTimeProperties.add(property); + } else if ( + pSchema.type === "array" && + !("$ref" in pSchema.items) && + pSchema.items.format === "date-time" + ) { + dateTimeArrayProperties.add(property); + } + } + } } + const setToCode = (name: string, items: Set) => + `const ${name} = new Set([\n${[...items] + .sort() + .map((s) => ` "${s}"`) + .join(",\n")}\n]);`; + + w(setToCode("dateTimeProperties", dateTimeProperties)); + w(setToCode("dateTimeArrayProperties", dateTimeArrayProperties)); + + w(` +export const parseIfDate = makeParseIfDate(dateTimeProperties, dateTimeArrayProperties); +const handleResponse = handleResponseWithMapper(parseIfDate); +`); + const operations = getOperations(spec); // Generate separate path and query param types diff --git a/oxide-openapi-gen-ts/src/client/static/http-client.test.ts b/oxide-openapi-gen-ts/src/client/static/http-client.test.ts index 47cadb8..ac17758 100644 --- a/oxide-openapi-gen-ts/src/client/static/http-client.test.ts +++ b/oxide-openapi-gen-ts/src/client/static/http-client.test.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { handleResponse, mergeParams } from "./http-client"; +import { handleResponseWithMapper, mergeParams } from "./http-client"; import { describe, expect, it } from "vitest"; const headers = { "Content-Type": "application/json" }; @@ -17,7 +17,9 @@ const json = (body: any, status = 200) => describe("handleResponse", () => { it("handles success", async () => { - const { response, ...rest } = await handleResponse(json({ abc: 123 })); + const { response, ...rest } = await handleResponseWithMapper((_, a) => a)( + json({ abc: 123 }), + ); expect(rest).toMatchObject({ data: { abc: 123 }, type: "success", @@ -27,7 +29,7 @@ describe("handleResponse", () => { }); it('API error returns type "error"', async () => { - const { response, ...rest } = await handleResponse( + const { response, ...rest } = await handleResponseWithMapper((_, a) => a)( json({ bad_stuff: "hi" }, 400), ); expect(rest).toMatchObject({ @@ -40,7 +42,9 @@ describe("handleResponse", () => { it("non-json response causes client_error w/ text and error", async () => { const resp = new Response("not json", { headers }); - const { response, ...rest } = await handleResponse(resp); + const { response, ...rest } = await handleResponseWithMapper((_, a) => a)( + resp, + ); expect(rest).toMatchObject({ error: expect.any(SyntaxError), text: "not json", @@ -50,25 +54,13 @@ describe("handleResponse", () => { expect(response.headers.get("Content-Type")).toBe("application/json"); }); - it("parses dates and converts to camel case", async () => { + it("applies the given mapper", async () => { const resp = json({ time_created: "2022-05-01T02:03:04Z" }); - const { response, ...rest } = await handleResponse(resp); - expect(rest).toMatchObject({ - type: "success", - data: { - timeCreated: new Date(Date.UTC(2022, 4, 1, 2, 3, 4)), - }, - }); - expect(response.headers.get("Content-Type")).toBe("application/json"); - }); - - it("leaves unparseable dates alone", async () => { - const resp = json({ time_created: "abc" }); - const { response, ...rest } = await handleResponse(resp); + const { response, ...rest } = await handleResponseWithMapper(() => 1)(resp); expect(rest).toMatchObject({ type: "success", data: { - timeCreated: "abc", + timeCreated: 1, }, }); expect(response.headers.get("Content-Type")).toBe("application/json"); diff --git a/oxide-openapi-gen-ts/src/client/static/http-client.ts b/oxide-openapi-gen-ts/src/client/static/http-client.ts index 666fdcc..7ba8393 100644 --- a/oxide-openapi-gen-ts/src/client/static/http-client.ts +++ b/oxide-openapi-gen-ts/src/client/static/http-client.ts @@ -6,7 +6,13 @@ * Copyright Oxide Computer Company */ -import { camelToSnake, processResponseBody, isNotNull } from "./util"; +import { + camelToSnake, + mapObj, + snakeToCamel, + isNotNull, + type ValueMapper, +} from "./util"; /** Success responses from the API */ export type ApiSuccess = { @@ -60,42 +66,44 @@ function encodeQueryParam(key: string, value: unknown) { )}`; } -export async function handleResponse( - response: Response, -): Promise> { - const respText = await response.text(); - - // catch JSON parse or processing errors - let respJson; - try { - // don't bother trying to parse empty responses like 204s - // TODO: is empty object what we want here? - respJson = - respText.length > 0 ? processResponseBody(JSON.parse(respText)) : {}; - } catch (e) { - return { - type: "client_error", - response, - error: e as Error, - text: respText, - }; - } +export const handleResponseWithMapper = + (valueMapper: ValueMapper) => + async (response: Response): Promise> => { + const respText = await response.text(); + + // catch JSON parse or processing errors + let respJson; + try { + // don't bother trying to parse empty responses like 204s + // TODO: is empty object what we want here? + respJson = + respText.length > 0 + ? mapObj(snakeToCamel, valueMapper)(JSON.parse(respText)) + : {}; + } catch (e) { + return { + type: "client_error", + response, + error: e as Error, + text: respText, + }; + } - if (!response.ok) { + if (!response.ok) { + return { + type: "error", + response, + data: respJson as ErrorBody, + }; + } + + // don't validate respJson, just assume it matches the type return { - type: "error", + type: "success", response, - data: respJson as ErrorBody, + data: respJson as Data, }; - } - - // don't validate respJson, just assume it matches the type - return { - type: "success", - response, - data: respJson as Data, }; -} // has to be any. the particular query params types don't like unknown // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/oxide-openapi-gen-ts/src/client/static/util.test.ts b/oxide-openapi-gen-ts/src/client/static/util.test.ts index 35cf305..f3cef2d 100644 --- a/oxide-openapi-gen-ts/src/client/static/util.test.ts +++ b/oxide-openapi-gen-ts/src/client/static/util.test.ts @@ -9,9 +9,8 @@ import { camelToSnake, isObjectOrArray, + makeParseIfDate, mapObj, - parseIfDate, - processResponseBody, snakeify, snakeToCamel, uniqueItems, @@ -58,40 +57,45 @@ describe("mapObj", () => { expect(fn({ x: 5, y: { z: 3 } })).toEqual({ x_: 10, y_: { z_: 6 } }); expect(fn([{ x: 5 }, "abc"])).toEqual([{ x_: 10 }, "abc"]); }); -}); - -test("processResponseBody", () => { - expect(processResponseBody({})).toEqual({}); - const date = new Date(); - const dateStr = date.toISOString(); - const resp = { - id: "big-uuid", - another_prop: "abc", - time_created: dateStr, - }; - expect(processResponseBody(resp)).toMatchObject({ - id: "big-uuid", - anotherProp: "abc", - timeCreated: expect.any(Date), + it("maps over objects and arrays before recursing", () => { + const doubleArray = mapObj( + (k) => k + "_", + (k, v) => + k === "a" && Array.isArray(v) + ? v.map((n) => (typeof n === "number" ? n * 2 : n)) + : v, + ); + expect( + doubleArray({ + a: [1, { a: 2 }, [3]], + b: [1, 2, 3], + }), + ).toEqual({ + a_: [2, { a_: 2 }, [3]], + b_: [1, 2, 3], + }); }); }); -describe("parseIfDate", () => { - it("passes through non-date values", () => { - expect(parseIfDate("abc", 123)).toEqual(123); - expect(parseIfDate("abc", "def")).toEqual("def"); - }); +describe("makeParseIfDate", () => { + const datePropertyNames = ["time_created", "timestamp"]; + const dateArrayPropertyNames = ["start_times"]; + const dateProps = new Set(datePropertyNames); + const dateArrayProps = new Set(dateArrayPropertyNames); + const parseIfDate = makeParseIfDate(dateProps, dateArrayProps); const timestamp = 1643092429315; const dateStr = new Date(timestamp).toISOString(); - it("doesn't parse dates if key doesn't start with time_", () => { + it("doesn't parse dates if key isn't a known date property", () => { + expect(parseIfDate("abc", 123)).toEqual(123); expect(parseIfDate("abc", dateStr)).toEqual(dateStr); + expect(parseIfDate(undefined, dateStr)).toEqual(dateStr); }); - it.each(["time_whatever", "auto_thing_expiration", "timestamp"])( - "parses dates if key is '%s'", + it.each(datePropertyNames)( + "parses dates if the key is a known date property name ('%s')", (key) => { const value = parseIfDate(key, dateStr); expect(value).toBeInstanceOf(Date); @@ -99,9 +103,32 @@ describe("parseIfDate", () => { }, ); - it("passes through values that fail to parse as dates", () => { - const value = parseIfDate("time_whatever", "blah"); - expect(value).toEqual("blah"); + it("passes through values that fail to parse as dates, even if key is known", () => { + expect(parseIfDate(datePropertyNames[0], "blah")).toEqual("blah"); + }); + + it.each(dateArrayPropertyNames)( + "parses arrays of dates if the key is a known date-array property name ('%s')", + () => { + const value = parseIfDate(dateArrayPropertyNames[0], [ + dateStr, + dateStr, + ]) as unknown[]; + expect(value.map((d) => (d as Date).getTime())).toEqual([ + timestamp, + timestamp, + ]); + }, + ); + + it("parses just the date-like elements of a mixed array", () => { + const value = parseIfDate(dateArrayPropertyNames[0], [ + "blah", + dateStr, + ]) as unknown[]; + expect(value[0]).toBe("blah"); + expect(value[1]).toBeInstanceOf(Date); + expect((value[1] as Date).getTime()).toEqual(timestamp); }); it.each([ @@ -116,8 +143,7 @@ describe("parseIfDate", () => { "2023-01-01T12:00:00.123456789123Z", "2023-01-01T12:00:00.123456789123123Z", ])("parses dates with fractional digits: %s", (dateString) => { - const value = parseIfDate("time_whatever", dateString); - expect(value).toBeInstanceOf(Date); + expect(parseIfDate(datePropertyNames[0], dateString)).toBeInstanceOf(Date); }); }); diff --git a/oxide-openapi-gen-ts/src/client/static/util.ts b/oxide-openapi-gen-ts/src/client/static/util.ts index 8ebee32..e91b37c 100644 --- a/oxide-openapi-gen-ts/src/client/static/util.ts +++ b/oxide-openapi-gen-ts/src/client/static/util.ts @@ -19,8 +19,11 @@ export const isObjectOrArray = (o: unknown) => !(o instanceof Error) && o !== null; +export type ValueMapper = (k: string | undefined, v: unknown) => unknown; + /** - * Recursively map (k, v) pairs using Object.entries + * Recursively map (k, v) pairs using Object.entries. Recursion happens after + * mapping, so objects and arrays may themselves be mapped. * * Note that value transform function takes both k and v so we can use the key * to decide whether to transform the value. @@ -29,10 +32,7 @@ export const isObjectOrArray = (o: unknown) => * @param vf maps key + value to value */ export const mapObj = - ( - kf: (k: string) => string, - vf: (k: string | undefined, v: unknown) => unknown = (_, v) => v, - ) => + (kf: (k: string) => string, vf: ValueMapper = (_, v) => v) => (o: unknown): unknown => { if (!isObjectOrArray(o)) return o; @@ -40,32 +40,36 @@ export const mapObj = const newObj: Record = {}; for (const [k, v] of Object.entries(o as Record)) { - newObj[kf(k)] = isObjectOrArray(v) ? mapObj(kf, vf)(v) : vf(k, v); + const mapped = vf(k, v); + newObj[kf(k)] = isObjectOrArray(mapped) ? mapObj(kf, vf)(mapped) : mapped; } return newObj; }; const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; -export const parseIfDate = (k: string | undefined, v: unknown) => { - if ( - typeof v === "string" && - isoDateRegex.test(v) && - (k?.startsWith("time_") || - k?.endsWith("_time") || - k?.endsWith("_expiration") || - k === "timestamp") - ) { - const d = new Date(v); - if (isNaN(d.getTime())) return v; - return d; - } - return v; +/** + * Parse an ISO date string to a Date, or return unchanged. + */ +const parseDate = (v: unknown) => { + if (typeof v !== "string" || !isoDateRegex.test(v)) return v; + const d = new Date(v); + return isNaN(d.getTime()) ? v : d; }; -export const snakeify = mapObj(camelToSnake); +/** + * Build a date parser sensitive only to the keys provided. + */ +export const makeParseIfDate = + (dateProps: Set, dateArrayProps: Set): ValueMapper => + (k, v) => { + if (k === undefined) return v; + if (dateProps.has(k)) return parseDate(v); + if (dateArrayProps.has(k) && Array.isArray(v)) return v.map(parseDate); + return v; + }; -export const processResponseBody = mapObj(snakeToCamel, parseIfDate); +export const snakeify = mapObj(camelToSnake); export function isNotNull(value: T): value is NonNullable { return value != null; diff --git a/oxide-openapi-gen-ts/src/client/zodValidators.ts b/oxide-openapi-gen-ts/src/client/zodValidators.ts index cdd537f..92ab0da 100644 --- a/oxide-openapi-gen-ts/src/client/zodValidators.ts +++ b/oxide-openapi-gen-ts/src/client/zodValidators.ts @@ -42,7 +42,7 @@ export async function generateZodValidators( w(`/* eslint-disable */ import { z, ZodType } from 'zod/v4'; - import { processResponseBody, uniqueItems } from './util'; + import { mapObj, snakeToCamel, uniqueItems } from './util'; /** * Zod only supports string enums at the moment. A previous issue was opened @@ -56,7 +56,13 @@ export async function generateZodValidators( /** Helper to ensure booleans provided as strings end up with the correct value */ const SafeBoolean = z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()) - `); + + /** + * Right now, the only value mapping we do is from ISO string to Date object, + * which zod handles fine on its own. + */ + const processResponseBody = mapObj(snakeToCamel, (_, v) => v); +`); if (cyclicSchemas.size > 0) { w(`import type * as Api from './Api';\n`); diff --git a/oxide-openapi-gen-ts/src/gen.test.ts b/oxide-openapi-gen-ts/src/gen.test.ts index f6b2ef5..c73d957 100644 --- a/oxide-openapi-gen-ts/src/gen.test.ts +++ b/oxide-openapi-gen-ts/src/gen.test.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { test, expect, beforeAll, afterAll } from "vitest"; +import { test, describe, expect, beforeAll, afterAll } from "vitest"; import { generate } from "./generate"; import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; @@ -34,6 +34,41 @@ test("Api.ts", async () => { await expect(read("Api.ts")).toMatchFileSnapshot("./__snapshots__/Api.ts"); }); +describe("Api.ts handleResponse date and date array mapping", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let handleResponse: (response: Response) => Promise; + beforeAll(async () => { + const { parseIfDate } = await import(join(tempDir, "Api.ts")); + const { handleResponseWithMapper } = await import( + join(tempDir, "http-client.ts") + ); + handleResponse = handleResponseWithMapper(parseIfDate); + }); + + const timestamp = 1643092429315; + const dateStr = new Date(timestamp).toISOString(); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); + + test("maps a date property to a Date", async () => { + const { data } = await handleResponse(json({ time_created: dateStr })); + expect(data.timeCreated).toBeInstanceOf(Date); + expect((data.timeCreated as Date).getTime()).toEqual(timestamp); + }); + + test("maps a date-array property to Date[]", async () => { + const { data } = await handleResponse( + json({ start_times: [dateStr, dateStr, dateStr] }), + ); + expect(Array.isArray(data.startTimes)).toBe(true); + expect(data.startTimes[0]).toBeInstanceOf(Date); + expect(data.startTimes[1]).toBeInstanceOf(Date); + expect((data.startTimes[0] as Date).getTime()).toEqual(timestamp); + }); +}); + test("http-client.ts", async () => { await expect(read("http-client.ts")).toMatchFileSnapshot( "./__snapshots__/http-client.ts", From c6e3fe5a8c3913a098b0327481ea54ff1c475354 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:46:45 +0000 Subject: [PATCH 2/3] Autogenerate config update --- oxide-api/src/Api.ts | 33 ++++++++++++++++- oxide-api/src/http-client.ts | 71 ++++++++++++++++++++---------------- oxide-api/src/util.ts | 48 +++++++++++++----------- 3 files changed, 96 insertions(+), 56 deletions(-) diff --git a/oxide-api/src/Api.ts b/oxide-api/src/Api.ts index 6703dd0..ca577b2 100644 --- a/oxide-api/src/Api.ts +++ b/oxide-api/src/Api.ts @@ -11,11 +11,11 @@ import type { FetchParams, FullParams, ApiResult } from "./http-client"; import { dateReplacer, - handleResponse, + handleResponseWithMapper, mergeParams, toQueryString, } from "./http-client"; -import { snakeify } from "./util"; +import { makeParseIfDate, snakeify } from "./util"; export type { ApiResult, ErrorBody, ErrorResult } from "./http-client"; @@ -5843,6 +5843,35 @@ export type VersionSortMode = */ export type NameSortMode = "name_ascending"; +const dateTimeProperties = new Set([ + "auto_restart_cooldown_expiration", + "created", + "first_seen", + "last_seen", + "start_time", + "time_aborted", + "time_committed", + "time_completed", + "time_created", + "time_expires", + "time_last_auto_restarted", + "time_last_step_planned", + "time_last_used", + "time_modified", + "time_requested", + "time_run_state_updated", + "time_sent", + "time_started", + "timestamp", +]); +const dateTimeArrayProperties = new Set(["start_times", "timestamps"]); + +export const parseIfDate = makeParseIfDate( + dateTimeProperties, + dateTimeArrayProperties, +); +const handleResponse = handleResponseWithMapper(parseIfDate); + export interface ProbeListQueryParams { limit?: number | null; pageToken?: string | null; diff --git a/oxide-api/src/http-client.ts b/oxide-api/src/http-client.ts index 666fdcc..5a92734 100644 --- a/oxide-api/src/http-client.ts +++ b/oxide-api/src/http-client.ts @@ -6,7 +6,13 @@ * Copyright Oxide Computer Company */ -import { camelToSnake, processResponseBody, isNotNull } from "./util"; +import { + camelToSnake, + mapObj, + snakeToCamel, + isNotNull, + type ValueMapper, +} from "./util"; /** Success responses from the API */ export type ApiSuccess = { @@ -60,42 +66,43 @@ function encodeQueryParam(key: string, value: unknown) { )}`; } -export async function handleResponse( - response: Response, -): Promise> { - const respText = await response.text(); - - // catch JSON parse or processing errors - let respJson; - try { - // don't bother trying to parse empty responses like 204s - // TODO: is empty object what we want here? - respJson = - respText.length > 0 ? processResponseBody(JSON.parse(respText)) : {}; - } catch (e) { - return { - type: "client_error", - response, - error: e as Error, - text: respText, - }; - } +export const handleResponseWithMapper = (valueMapper: ValueMapper) => { + const processResponseBody = mapObj(snakeToCamel, valueMapper); + return async (response: Response): Promise> => { + const respText = await response.text(); + + // catch JSON parse or processing errors + let respJson; + try { + // don't bother trying to parse empty responses like 204s + // TODO: is empty object what we want here? + respJson = + respText.length > 0 ? processResponseBody(JSON.parse(respText)) : {}; + } catch (e) { + return { + type: "client_error", + response, + error: e as Error, + text: respText, + }; + } - if (!response.ok) { + if (!response.ok) { + return { + type: "error", + response, + data: respJson as ErrorBody, + }; + } + + // don't validate respJson, just assume it matches the type return { - type: "error", + type: "success", response, - data: respJson as ErrorBody, + data: respJson as Data, }; - } - - // don't validate respJson, just assume it matches the type - return { - type: "success", - response, - data: respJson as Data, }; -} +}; // has to be any. the particular query params types don't like unknown // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/oxide-api/src/util.ts b/oxide-api/src/util.ts index 8ebee32..ca4529c 100644 --- a/oxide-api/src/util.ts +++ b/oxide-api/src/util.ts @@ -19,8 +19,11 @@ export const isObjectOrArray = (o: unknown) => !(o instanceof Error) && o !== null; +export type ValueMapper = (k: string | undefined, v: unknown) => unknown; + /** - * Recursively map (k, v) pairs using Object.entries + * Recursively map (k, v) pairs using Object.entries. Recursion happens after + * mapping, so objects and arrays may themselves be mapped. * * Note that value transform function takes both k and v so we can use the key * to decide whether to transform the value. @@ -29,10 +32,7 @@ export const isObjectOrArray = (o: unknown) => * @param vf maps key + value to value */ export const mapObj = - ( - kf: (k: string) => string, - vf: (k: string | undefined, v: unknown) => unknown = (_, v) => v, - ) => + (kf: (k: string) => string, vf: ValueMapper = (_, v) => v) => (o: unknown): unknown => { if (!isObjectOrArray(o)) return o; @@ -40,32 +40,36 @@ export const mapObj = const newObj: Record = {}; for (const [k, v] of Object.entries(o as Record)) { - newObj[kf(k)] = isObjectOrArray(v) ? mapObj(kf, vf)(v) : vf(k, v); + const mapped = vf(k, v); + newObj[kf(k)] = isObjectOrArray(mapped) ? mapObj(kf, vf)(mapped) : mapped; } return newObj; }; const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; -export const parseIfDate = (k: string | undefined, v: unknown) => { - if ( - typeof v === "string" && - isoDateRegex.test(v) && - (k?.startsWith("time_") || - k?.endsWith("_time") || - k?.endsWith("_expiration") || - k === "timestamp") - ) { - const d = new Date(v); - if (isNaN(d.getTime())) return v; - return d; - } - return v; +/** + * Parse an ISO date string to a Date, or return unchanged. + */ +const parseDate = (v: unknown) => { + if (typeof v !== "string" || !isoDateRegex.test(v)) return v; + const d = new Date(v); + return isNaN(d.getTime()) ? v : d; }; -export const snakeify = mapObj(camelToSnake); +/** + * Build a date parser sensitive only to the keys provided. + */ +export const makeParseIfDate = + (dateProps: Set, dateArrayProps: Set): ValueMapper => + (k, v) => { + if (k === undefined) return v; + if (Array.isArray(v)) return dateArrayProps.has(k) ? v.map(parseDate) : v; + if (dateProps.has(k)) return parseDate(v); + return v; + }; -export const processResponseBody = mapObj(snakeToCamel, parseIfDate); +export const snakeify = mapObj(camelToSnake); export function isNotNull(value: T): value is NonNullable { return value != null; From 79ead7ce32f1ebed0c6801b49f66bbe5dafb10e0 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:23:57 +0000 Subject: [PATCH 3/3] Autogenerate config update --- oxide-api/src/http-client.ts | 11 ++++++----- oxide-api/src/util.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/oxide-api/src/http-client.ts b/oxide-api/src/http-client.ts index 5a92734..7ba8393 100644 --- a/oxide-api/src/http-client.ts +++ b/oxide-api/src/http-client.ts @@ -66,9 +66,9 @@ function encodeQueryParam(key: string, value: unknown) { )}`; } -export const handleResponseWithMapper = (valueMapper: ValueMapper) => { - const processResponseBody = mapObj(snakeToCamel, valueMapper); - return async (response: Response): Promise> => { +export const handleResponseWithMapper = + (valueMapper: ValueMapper) => + async (response: Response): Promise> => { const respText = await response.text(); // catch JSON parse or processing errors @@ -77,7 +77,9 @@ export const handleResponseWithMapper = (valueMapper: ValueMapper) => { // don't bother trying to parse empty responses like 204s // TODO: is empty object what we want here? respJson = - respText.length > 0 ? processResponseBody(JSON.parse(respText)) : {}; + respText.length > 0 + ? mapObj(snakeToCamel, valueMapper)(JSON.parse(respText)) + : {}; } catch (e) { return { type: "client_error", @@ -102,7 +104,6 @@ export const handleResponseWithMapper = (valueMapper: ValueMapper) => { data: respJson as Data, }; }; -}; // has to be any. the particular query params types don't like unknown // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/oxide-api/src/util.ts b/oxide-api/src/util.ts index ca4529c..e91b37c 100644 --- a/oxide-api/src/util.ts +++ b/oxide-api/src/util.ts @@ -64,8 +64,8 @@ export const makeParseIfDate = (dateProps: Set, dateArrayProps: Set): ValueMapper => (k, v) => { if (k === undefined) return v; - if (Array.isArray(v)) return dateArrayProps.has(k) ? v.map(parseDate) : v; if (dateProps.has(k)) return parseDate(v); + if (dateArrayProps.has(k) && Array.isArray(v)) return v.map(parseDate); return v; };