Skip to content
Closed
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
523 changes: 523 additions & 0 deletions oxide-api/src/Api.ts

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions oxide-api/src/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/

import { camelToSnake, processResponseBody, snakeify, isNotNull } from "./util";
import { camelToSnake, snakeifyKeys, isNotNull, camelifyKeys } from "./util";

/** Success responses from the API */
export type ApiSuccess<Data> = {
Expand Down Expand Up @@ -62,6 +62,8 @@ function encodeQueryParam(key: string, value: unknown) {

export async function handleResponse<Data>(
response: Response,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformResponse?: (o: any) => void,
): Promise<ApiResult<Data>> {
const respText = await response.text();

Expand All @@ -70,8 +72,13 @@ export async function handleResponse<Data>(
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)) : {};
if (respText.length > 0) {
respJson = JSON.parse(respText);
transformResponse?.(respJson); // no assignment because this mutates the object
respJson = camelifyKeys(respJson);
} else {
respJson = {};
}
} catch (e) {
return {
type: "client_error",
Expand Down Expand Up @@ -115,6 +122,8 @@ export interface FullParams extends FetchParams {
body?: unknown;
host?: string;
method?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformResponse?: (o: any) => void;
}

export interface ApiConfig {
Expand Down Expand Up @@ -148,14 +157,15 @@ export class HttpClient {
path,
query,
host,
transformResponse,
...fetchParams
}: FullParams): Promise<ApiResult<Data>> {
const url = (host || this.host) + path + toQueryString(query);
const init = {
...mergeParams(this.baseParams, fetchParams),
body: JSON.stringify(snakeify(body), replacer),
body: JSON.stringify(snakeifyKeys(body), replacer),
};
return handleResponse(await fetch(url, init));
return handleResponse(await fetch(url, init), transformResponse);
}
}

Expand Down
13 changes: 2 additions & 11 deletions oxide-api/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,9 @@ export const mapObj =
return newObj;
};

export const parseIfDate = (k: string | undefined, v: unknown) => {
if (typeof v === "string" && (k?.startsWith("time_") || k === "timestamp")) {
const d = new Date(v);
if (isNaN(d.getTime())) return v;
return d;
}
return v;
};
export const snakeifyKeys = mapObj(camelToSnake);

export const snakeify = mapObj(camelToSnake);

export const processResponseBody = mapObj(snakeToCamel, parseIfDate);
export const camelifyKeys = mapObj(snakeToCamel);

export function isNotNull<T>(value: T): value is NonNullable<T> {
return value != null;
Expand Down
88 changes: 88 additions & 0 deletions oxide-openapi-gen-ts/src/client/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { describe, expect, it } from "vitest";
import { genTransformResponse } from "./api";
import { type OpenAPIV3 } from "openapi-types";

const projectSchema = {
description: "View of a Project",
type: "object",
properties: {
description: {
description: "human-readable free-form text about a resource",
type: "string",
},
id: {
description:
"unique, immutable, system-controlled identifier for each resource",
type: "string",
format: "uuid",
},
name: {
description:
"unique, mutable, user-controlled identifier for each resource",
allOf: [Array],
},
time_created: {
description: "timestamp when this resource was created",
type: "string",
format: "date-time",
},
time_modified: {
description: "timestamp when this resource was last modified",
type: "string",
format: "date-time",
},
},
required: ["description", "id", "name", "time_created", "time_modified"],
};

// TODO: add array and nested object properties
const noTransforms = {
description: "View of a Project",
type: "object",
properties: {
description: {
description: "human-readable free-form text about a resource",
type: "string",
},
id: {
description:
"unique, immutable, system-controlled identifier for each resource",
type: "string",
format: "uuid",
},
name: {
description:
"unique, mutable, user-controlled identifier for each resource",
allOf: [Array],
},
},
required: ["description", "id", "name"],
};

const spec: OpenAPIV3.Document = {
openapi: "",
info: { title: "", version: "" },
paths: {},
};

describe("generateTransformFunction", () => {
it("handles timestamps at top level", () => {
expect(genTransformResponse(spec, projectSchema)).toMatchInlineSnapshot(`
"(o) => {
o.time_created = new Date(o.time_created)
o.time_modified = new Date(o.time_modified)
}"
`);
});

it("returns null when there are no transforms to make", () => {
expect(genTransformResponse(spec, noTransforms)).toEqual(undefined);
});
});
63 changes: 62 additions & 1 deletion oxide-openapi-gen-ts/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
snakeToPascal,
} from "../util";
import { initIO } from "../io";
import type { Schema } from "../schema/base";
import { refToSchemaName, type Schema } from "../schema/base";
import {
contentRef,
docComment,
Expand Down Expand Up @@ -276,6 +276,18 @@ export function generateApi(spec: OpenAPIV3.Document, destDir: string) {
if (queryParams.length > 0) {
w(" query,");
}

if (successType) {
// insert transformResponse if necessary
const schema = spec.components!.schemas![successType];
if (schema && "properties" in schema && schema.properties) {
const transformResponse = genTransformResponse(spec, schema);
if (transformResponse) {
w0("transformResponse: " + genTransformResponse(spec, schema) + ",");
}
}
}

w(` ...params,
})
},`);
Expand Down Expand Up @@ -336,3 +348,52 @@ export function generateApi(spec: OpenAPIV3.Document, destDir: string) {
export default Api;`);
out.end();
}

// TODO: special case for the common transform function that just does the
// created and modified timestamps, could save a lot of lines
export function genTransformResponse(
spec: OpenAPIV3.Document,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: any
): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function recurse(schema: any, path: string): string | undefined {
if (schema.type === "object") {
const properties = Object.entries(schema.properties || {})
.map(([key, propSchema]) => {
const propPath = path ? `${path}.${key}` : key;
const transformCode = recurse(propSchema, propPath);
return transformCode ? `o.${key} = ${transformCode}` : undefined;
})
.filter((x) => x);
if (properties.length === 0) return undefined;

return properties.join("\n");
} else if (schema.type === "array") {
const transformCode = recurse(schema.items, "");
return transformCode
? `o.${path}.map((o: any) => {
${transformCode}
return o
})`
: undefined;
} else if (schema.type === "string") {
if (schema.format === "date-time") {
return `new Date(o.${path})`;
} else if (schema.format === "uint128") {
return `BigInt(o.${path})`;
}
} else if ("$ref" in schema) {
const schemaName = refToSchemaName(schema.$ref);
const _schema = spec.components!.schemas![schemaName];
return recurse(_schema, path);
}
return undefined;
}

const transformCode = recurse(schema, "");
if (!transformCode) return undefined;
return `(o) => {
${transformCode}
}`;
}
12 changes: 6 additions & 6 deletions oxide-openapi-gen-ts/src/client/msw-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function generateMSWHandlers(spec: OpenAPIV3.Document, destDir: string) {
import type { SnakeCasedPropertiesDeep as Snakify, Promisable } from "type-fest";
import { type ZodSchema } from "zod";
import type * as Api from "./Api";
import { snakeify } from "./util";
import { snakeifyKeys } from "./util";
import * as schema from "./validate";

type HandlerResult<T> = Json<T> | StrictResponse<Json<T>>;
Expand Down Expand Up @@ -90,10 +90,10 @@ export function generateMSWHandlers(spec: OpenAPIV3.Document, destDir: string) {
? `body: Json<Api.${bodyType}>,`
: "";
const pathParams = conf.parameters?.filter(
(param) => "name" in param && param.schema && param.in === "path",
(param) => "name" in param && param.schema && param.in === "path"
);
const queryParams = conf.parameters?.filter(
(param) => "name" in param && param.schema && param.in === "query",
(param) => "name" in param && param.schema && param.in === "query"
);
const pathParamsType = pathParams?.length
? `path: Api.${snakeToPascal(opId)}PathParams,`
Expand Down Expand Up @@ -159,7 +159,7 @@ export function generateMSWHandlers(spec: OpenAPIV3.Document, destDir: string) {
let body = undefined
if (bodySchema) {
const rawBody = await req.json()
const result = bodySchema.transform(snakeify).safeParse(rawBody);
const result = bodySchema.transform(snakeifyKeys).safeParse(rawBody);
if (!result.success) {
const message = 'Zod error for body: ' + JSON.stringify(result.error)
return json({ error_code: 'InvalidRequest', message }, { status: 400 })
Expand Down Expand Up @@ -222,8 +222,8 @@ export function generateMSWHandlers(spec: OpenAPIV3.Document, destDir: string) {

w(
`http.${method}('${formatPath(
path,
)}', handler(handlers['${handler}'], ${paramSchema}, ${bodySchema})),`,
path
)}', handler(handlers['${handler}'], ${paramSchema}, ${bodySchema})),`
);
}
w(`]}`);
Expand Down
8 changes: 5 additions & 3 deletions oxide-openapi-gen-ts/src/client/static/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ describe("handleResponse", () => {
expect(response.headers.get("Content-Type")).toBe("application/json");
});

it("parses dates and converts to camel case", async () => {
it("parses dates and applies transformResponse", async () => {
const resp = json({ time_created: "2022-05-01" });
const { response, ...rest } = await handleResponse(resp);
const { response, ...rest } = await handleResponse(resp, (o) => {
o.time_created = new Date(o.time_created);
});
expect(rest).toMatchObject({
type: "success",
data: {
Expand All @@ -64,7 +66,7 @@ describe("handleResponse", () => {
expect(response.headers.get("Content-Type")).toBe("application/json");
});

it("leaves unparseable dates alone", async () => {
it("doesn't try to parse dates if there is no transformResponse", async () => {
const resp = json({ time_created: "abc" });
const { response, ...rest } = await handleResponse(resp);
expect(rest).toMatchObject({
Expand Down
22 changes: 16 additions & 6 deletions oxide-openapi-gen-ts/src/client/static/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/

import { camelToSnake, processResponseBody, snakeify, isNotNull } from "./util";
import { camelToSnake, snakeifyKeys, isNotNull, camelifyKeys } from "./util";

/** Success responses from the API */
export type ApiSuccess<Data> = {
Expand Down Expand Up @@ -61,7 +61,9 @@ function encodeQueryParam(key: string, value: unknown) {
}

export async function handleResponse<Data>(
response: Response
response: Response,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformResponse?: (o: any) => void
): Promise<ApiResult<Data>> {
const respText = await response.text();

Expand All @@ -70,8 +72,13 @@ export async function handleResponse<Data>(
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)) : {};
if (respText.length > 0) {
respJson = JSON.parse(respText);
transformResponse?.(respJson); // no assignment because this mutates the object
respJson = camelifyKeys(respJson);
} else {
respJson = {};
}
} catch (e) {
return {
type: "client_error",
Expand Down Expand Up @@ -115,6 +122,8 @@ export interface FullParams extends FetchParams {
body?: unknown;
host?: string;
method?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformResponse?: (o: any) => void;
}

export interface ApiConfig {
Expand Down Expand Up @@ -148,14 +157,15 @@ export class HttpClient {
path,
query,
host,
transformResponse,
...fetchParams
}: FullParams): Promise<ApiResult<Data>> {
const url = (host || this.host) + path + toQueryString(query);
const init = {
...mergeParams(this.baseParams, fetchParams),
body: JSON.stringify(snakeify(body), replacer),
body: JSON.stringify(snakeifyKeys(body), replacer),
};
return handleResponse(await fetch(url, init));
return handleResponse(await fetch(url, init), transformResponse);
}
}

Expand Down
Loading
Loading