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..7ba8393 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,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-api/src/util.ts b/oxide-api/src/util.ts index 8ebee32..e91b37c 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 (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__/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",