-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmsw-handlers.ts
230 lines (198 loc) · 7.92 KB
/
msw-handlers.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
/**
* 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 type { OpenAPIV3 } from "openapi-types";
import { initIO } from "../io";
import { snakeToCamel, snakeToPascal } from "../util";
import { contentRef, iterPathConfig } from "./base";
import path from "node:path";
import fs from "node:fs";
const formatPath = (path: string) =>
path.replace(/{(\w+)}/g, (n) => `:${snakeToCamel(n.slice(1, -1))}`);
export function generateMSWHandlers(spec: OpenAPIV3.Document, destDir: string) {
if (!spec.components) return;
const outFile = path.resolve(destDir, "msw-handlers.ts");
const out = fs.createWriteStream(outFile, { flags: "w" });
const io = initIO(out);
const { w } = io;
w(`
/**
* 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 {
http,
type HttpHandler,
HttpResponse,
type StrictResponse,
type PathParams,
} from "msw";
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 * as schema from "./validate";
type HandlerResult<T> = Json<T> | StrictResponse<Json<T>>;
type StatusCode = number
// these are used for turning our nice JS-ified API types back into the original
// API JSON types (snake cased and dates as strings) for use in our mock API
type StringifyDates<T> = T extends Date
? string
: {
[K in keyof T]: T[K] extends Array<infer U>
? Array<StringifyDates<U>>
: StringifyDates<T[K]>
}
/**
* Snake case fields and convert dates to strings. Not intended to be a general
* purpose JSON type!
*/
export type Json<B> = Snakify<StringifyDates<B>>
export const json = HttpResponse.json
// Shortcut to reduce number of imports required in consumers
export { HttpResponse }
`);
w(`export interface MSWHandlers {`);
for (const { conf, method, opId, path } of iterPathConfig(spec.paths)) {
const opName = snakeToCamel(opId);
const successResponse =
conf.responses["200"] ||
conf.responses["201"] ||
conf.responses["202"] ||
conf.responses["204"];
const successType = contentRef(successResponse, "Api.");
const bodyType = contentRef(conf.requestBody);
const body =
bodyType && (method === "post" || method === "put")
? `body: Json<Api.${bodyType}>,`
: "";
const pathParams = conf.parameters?.filter(
(param) => "name" in param && param.schema && param.in === "path",
);
const queryParams = conf.parameters?.filter(
(param) => "name" in param && param.schema && param.in === "query",
);
const pathParamsType = pathParams?.length
? `path: Api.${snakeToPascal(opId)}PathParams,`
: "";
const queryParamsType = queryParams?.length
? `query: Api.${snakeToPascal(opId)}QueryParams,`
: "";
const params = `params: { ${pathParamsType} ${queryParamsType} ${body} req: Request, cookies: Record<string, string> }`;
const resultType = successType
? `Promisable<HandlerResult<${successType}>>`
: "Promisable<StatusCode>";
w(`/** \`${method.toUpperCase()} ${formatPath(path)}\` */`);
w(` ${opName}: (${params}) => ${resultType},`);
}
w("}");
w(`
function validateParams<S extends ZodSchema>(schema: S, req: Request, pathParams: PathParams) {
const rawParams = new URLSearchParams(new URL(req.url).search)
const params: [string, unknown][] = []
// Ensure numeric params like \`limit\` are parsed as numbers
for (const [name, value] of rawParams) {
params.push([name, isNaN(Number(value)) ? value : Number(value)])
}
const result = schema.safeParse({
path: pathParams,
query: Object.fromEntries(params),
})
if (result.success) {
return { params: result.data }
}
// if any of the errors come from path params, just 404 — the resource cannot
// exist if there's no valid name
const status = result.error.issues.some((e) => e.path[0] === 'path') ? 404 : 400
const error_code = status === 404 ? 'NotFound' : 'InvalidRequest'
const message = 'Zod error for params: ' + JSON.stringify(result.error)
return { paramsErr: json({ error_code, message }, { status }) }
}
const handler = (handler: MSWHandlers[keyof MSWHandlers], paramSchema: ZodSchema | null, bodySchema: ZodSchema | null) =>
async ({
request: req,
params: pathParams,
cookies
}: {
request: Request;
params: PathParams;
cookies: Record<string, string | string[]>;
}) => {
const { params, paramsErr } = paramSchema
? validateParams(paramSchema, req, pathParams)
: { params: {}, paramsErr: undefined };
if (paramsErr) return paramsErr;
const { path, query } = params
let body = undefined
if (bodySchema) {
const rawBody = await req.json()
const result = bodySchema.transform(snakeify).safeParse(rawBody);
if (!result.success) {
const message = 'Zod error for body: ' + JSON.stringify(result.error)
return json({ error_code: 'InvalidRequest', message }, { status: 400 })
}
body = result.data
}
try {
// TypeScript can't narrow the handler down because there's not an explicit relationship between the schema
// being present and the shape of the handler API. The type of this function could be resolved such that the
// relevant schema is required if and only if the handler has a type that matches the inferred schema
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (handler as any).apply(null, [{path, query, body, req, cookies}])
if (typeof result === "number") {
return new HttpResponse(null, { status: result });
}
if (result instanceof Response) {
return result;
}
return json(result);
} catch (thrown) {
if (typeof thrown === 'number') {
return new HttpResponse(null, { status: thrown });
}
if (typeof thrown === "string") {
return json({ message: thrown }, { status: 400 });
}
if (thrown instanceof Response) {
return thrown;
}
// if it's not one of those, then we don't know what to do with it
console.error('Unexpected mock error', thrown)
if (typeof thrown === 'function') {
console.error(
"It looks like you've accidentally thrown an error constructor function from a mock handler without calling it!"
)
}
// rethrow so everything breaks because this isn't supposed to happen
throw thrown
}
}
export function makeHandlers(
handlers: MSWHandlers,
): HttpHandler[] {
return [`);
for (const { path, method, opId, conf } of iterPathConfig(spec.paths)) {
const handler = snakeToCamel(opId);
const bodyType = contentRef(conf.requestBody, "schema.");
const bodySchema =
bodyType !== "void" && (method === "post" || method === "put")
? bodyType
: "null";
const paramSchema = conf.parameters?.length
? `schema.${snakeToPascal(opId)}Params`
: "null";
w(
`http.${method}('${formatPath(
path,
)}', handler(handlers['${handler}'], ${paramSchema}, ${bodySchema})),`,
);
}
w(`]}`);
}