Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions oxide-api/src/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -5843,6 +5843,35 @@ export type VersionSortMode =
*/
export type NameSortMode = "name_ascending";

const dateTimeProperties = new Set<string>([
"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<string>(["start_times", "timestamps"]);

export const parseIfDate = makeParseIfDate(
dateTimeProperties,
dateTimeArrayProperties,
);
const handleResponse = handleResponseWithMapper(parseIfDate);

export interface ProbeListQueryParams {
limit?: number | null;
pageToken?: string | null;
Expand Down
72 changes: 40 additions & 32 deletions oxide-api/src/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data> = {
Expand Down Expand Up @@ -60,42 +66,44 @@ function encodeQueryParam(key: string, value: unknown) {
)}`;
}

export async function handleResponse<Data>(
response: Response,
): Promise<ApiResult<Data>> {
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 <Data>(response: Response): Promise<ApiResult<Data>> => {
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
Expand Down
48 changes: 26 additions & 22 deletions oxide-api/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,43 +32,44 @@ 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;

if (Array.isArray(o)) return o.map(mapObj(kf, vf));

const newObj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
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<string>, dateArrayProps: Set<string>): 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<T>(value: T): value is NonNullable<T> {
return value != null;
Expand Down
33 changes: 31 additions & 2 deletions oxide-openapi-gen-ts/src/__snapshots__/Api.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -6006,6 +6006,35 @@ export type NameSortMode =
"name_ascending"
;

const dateTimeProperties = new Set<string>([
"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<string>([
"start_times",
"timestamps"
]);

export const parseIfDate = makeParseIfDate(dateTimeProperties, dateTimeArrayProperties);
const handleResponse = handleResponseWithMapper(parseIfDate);

export interface ProbeListQueryParams {
limit?: number | null,
pageToken?: string | null,
Expand Down
72 changes: 40 additions & 32 deletions oxide-openapi-gen-ts/src/__snapshots__/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data> = {
Expand Down Expand Up @@ -60,42 +66,44 @@ function encodeQueryParam(key: string, value: unknown) {
)}`;
}

export async function handleResponse<Data>(
response: Response,
): Promise<ApiResult<Data>> {
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 <Data>(response: Response): Promise<ApiResult<Data>> => {
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
Expand Down
10 changes: 8 additions & 2 deletions oxide-openapi-gen-ts/src/__snapshots__/recursive-validate.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Api.TreeNode> = z.preprocess(processResponseBody,z.object({"value": z.string(),
Expand Down
Loading
Loading