@openhi/constructs 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/rest-api-lambda.handler.js +21523 -330
- package/lib/rest-api-lambda.handler.js.map +1 -1
- package/lib/rest-api-lambda.handler.mjs +21523 -330
- package/lib/rest-api-lambda.handler.mjs.map +1 -1
- package/package.json +1 -1
- package/scripts/generate-data-routes.mjs +337 -0
- package/scripts/generate-route-tests.mjs +227 -0
package/package.json
CHANGED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate REST route modules for each FHIR type that has operations.
|
|
4
|
+
* Run from repo root: node packages/@openhi/constructs/scripts/generate-data-routes.mjs
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
12
|
+
const ROUTES_DATA = path.join(ROOT, "src/data/rest-api/routes/data");
|
|
13
|
+
const OPERATIONS = path.join(ROOT, "src/data/operations/data");
|
|
14
|
+
const COMMON = path.join(ROOT, "src/data/rest-api/routes/common.ts");
|
|
15
|
+
const REST_API = path.join(ROOT, "src/data/rest-api/rest-api.ts");
|
|
16
|
+
|
|
17
|
+
// Get types that have operations (folder name -> PascalCase type from create-operation import)
|
|
18
|
+
const dirs = fs.readdirSync(OPERATIONS).filter((d) => {
|
|
19
|
+
const p = path.join(OPERATIONS, d);
|
|
20
|
+
return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, `${d}-create-operation.ts`));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const types = [];
|
|
24
|
+
for (const dir of dirs) {
|
|
25
|
+
const createPath = path.join(OPERATIONS, dir, `${dir}-create-operation.ts`);
|
|
26
|
+
const line = fs.readFileSync(createPath, "utf8").split("\n")[0];
|
|
27
|
+
const match = line.match(/import type \{ ([^}]+) \}/);
|
|
28
|
+
if (!match) continue;
|
|
29
|
+
const imports = match[1].split(",").map((s) => s.trim());
|
|
30
|
+
const type = imports.find((t) => t !== "Meta");
|
|
31
|
+
if (type) types.push({ type, folder: dir });
|
|
32
|
+
}
|
|
33
|
+
types.sort((a, b) => a.type.localeCompare(b.type));
|
|
34
|
+
|
|
35
|
+
function basePathKey(type) {
|
|
36
|
+
return type.toUpperCase().replace(/-/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pluralRoute(type) {
|
|
40
|
+
return type + "s";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createRouteName(type, op) {
|
|
44
|
+
const lower = type.charAt(0).toLowerCase() + type.slice(1);
|
|
45
|
+
if (op === "list") return `list${pluralRoute(type)}Route`;
|
|
46
|
+
if (op === "getById") return `get${type}ByIdRoute`;
|
|
47
|
+
if (op === "create") return `create${type}Route`;
|
|
48
|
+
if (op === "update") return `update${type}Route`;
|
|
49
|
+
if (op === "delete") return `delete${type}Route`;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function opName(type, op) {
|
|
54
|
+
if (op === "list") return `list${pluralRoute(type)}Operation`;
|
|
55
|
+
if (op === "getById") return `get${type}ByIdOperation`;
|
|
56
|
+
if (op === "create") return `create${type}Operation`;
|
|
57
|
+
if (op === "update") return `update${type}Operation`;
|
|
58
|
+
if (op === "delete") return `delete${type}Operation`;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const { type, folder } of types) {
|
|
63
|
+
const routeDir = path.join(ROUTES_DATA, folder);
|
|
64
|
+
const routerFile = path.join(routeDir, `${folder}.ts`);
|
|
65
|
+
if (fs.existsSync(routerFile)) continue;
|
|
66
|
+
|
|
67
|
+
fs.mkdirSync(routeDir, { recursive: true });
|
|
68
|
+
const basePath = `/${type}`;
|
|
69
|
+
const key = basePathKey(type);
|
|
70
|
+
|
|
71
|
+
const createContent = `import type { ${type} } from "@openhi/types";
|
|
72
|
+
import type { Request, Response } from "express";
|
|
73
|
+
import { create${type}Operation } from "../../../../operations/data/${folder}/${folder}-create-operation";
|
|
74
|
+
import {
|
|
75
|
+
BASE_PATH,
|
|
76
|
+
requireJsonBodyAs,
|
|
77
|
+
sendOperationOutcome500,
|
|
78
|
+
} from "../../common";
|
|
79
|
+
|
|
80
|
+
/** POST ${basePath} — create: accepts ${type} in body, persists via data layer, returns 201. */
|
|
81
|
+
export async function create${type}Route(
|
|
82
|
+
req: Request,
|
|
83
|
+
res: Response,
|
|
84
|
+
): Promise<Response> {
|
|
85
|
+
const bodyResult = requireJsonBodyAs<${type}>(req, res);
|
|
86
|
+
if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
|
|
87
|
+
|
|
88
|
+
const ctx = req.openhiContext!;
|
|
89
|
+
const body = bodyResult.body;
|
|
90
|
+
const resource: ${type} = {
|
|
91
|
+
...body,
|
|
92
|
+
resourceType: "${type}",
|
|
93
|
+
} as ${type};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const result = await create${type}Operation({
|
|
97
|
+
context: ctx,
|
|
98
|
+
body: resource,
|
|
99
|
+
});
|
|
100
|
+
return res
|
|
101
|
+
.status(201)
|
|
102
|
+
.location(\`\${BASE_PATH.${key}}/\${result.id}\`)
|
|
103
|
+
.json(result.resource);
|
|
104
|
+
} catch (err: unknown) {
|
|
105
|
+
return sendOperationOutcome500(res, err, "POST ${type} error:");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const updateContent = `import type { ${type} } from "@openhi/types";
|
|
111
|
+
import type { Request, Response } from "express";
|
|
112
|
+
import { NotFoundError, domainErrorToHttpStatus } from "../../../../errors";
|
|
113
|
+
import { update${type}Operation } from "../../../../operations/data/${folder}/${folder}-update-operation";
|
|
114
|
+
import {
|
|
115
|
+
requireJsonBodyAs,
|
|
116
|
+
sendOperationOutcome404,
|
|
117
|
+
sendOperationOutcome500,
|
|
118
|
+
} from "../../common";
|
|
119
|
+
|
|
120
|
+
/** PUT ${basePath}/:id — update: accepts ${type} in body, persists via data layer, returns 200. */
|
|
121
|
+
export async function update${type}Route(
|
|
122
|
+
req: Request,
|
|
123
|
+
res: Response,
|
|
124
|
+
): Promise<Response> {
|
|
125
|
+
const bodyResult = requireJsonBodyAs<${type}>(req, res);
|
|
126
|
+
if ("errorResponse" in bodyResult) return bodyResult.errorResponse;
|
|
127
|
+
|
|
128
|
+
const id = String(req.params.id);
|
|
129
|
+
const ctx = req.openhiContext!;
|
|
130
|
+
const body = bodyResult.body;
|
|
131
|
+
const resource: ${type} = {
|
|
132
|
+
...body,
|
|
133
|
+
resourceType: "${type}",
|
|
134
|
+
id,
|
|
135
|
+
} as ${type};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await update${type}Operation({
|
|
139
|
+
context: ctx,
|
|
140
|
+
id,
|
|
141
|
+
body: resource,
|
|
142
|
+
});
|
|
143
|
+
return res.json(result.resource);
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const status = domainErrorToHttpStatus(err);
|
|
146
|
+
if (status === 404) {
|
|
147
|
+
const diagnostics =
|
|
148
|
+
err instanceof NotFoundError ? err.message : \`${type} \${id} not found\`;
|
|
149
|
+
return sendOperationOutcome404(res, diagnostics);
|
|
150
|
+
}
|
|
151
|
+
return sendOperationOutcome500(res, err, "PUT ${type} error:");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
const listContent = `import type { Request, Response } from "express";
|
|
157
|
+
import { list${pluralRoute(type)}Operation } from "../../../../operations/data/${folder}/${folder}-list-operation";
|
|
158
|
+
import {
|
|
159
|
+
BASE_PATH,
|
|
160
|
+
buildSearchsetBundle,
|
|
161
|
+
sendOperationOutcome500,
|
|
162
|
+
} from "../../common";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* GET ${basePath} — search/list: returns a FHIR Bundle (searchset) from the data store.
|
|
166
|
+
*/
|
|
167
|
+
export async function list${pluralRoute(type)}Route(
|
|
168
|
+
req: Request,
|
|
169
|
+
res: Response,
|
|
170
|
+
): Promise<Response> {
|
|
171
|
+
const ctx = req.openhiContext!;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const result = await list${pluralRoute(type)}Operation({ context: ctx });
|
|
175
|
+
const bundle = buildSearchsetBundle(BASE_PATH.${key}, result.entries);
|
|
176
|
+
return res.json(bundle);
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
return sendOperationOutcome500(res, err, "GET ${basePath} list error:");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
const getByIdContent = `import type { Request, Response } from "express";
|
|
184
|
+
import { NotFoundError, domainErrorToHttpStatus } from "../../../../errors";
|
|
185
|
+
import { get${type}ByIdOperation } from "../../../../operations/data/${folder}/${folder}-get-by-id-operation";
|
|
186
|
+
import { sendOperationOutcome404, sendOperationOutcome500 } from "../../common";
|
|
187
|
+
|
|
188
|
+
/** GET ${basePath}/:id — read: returns a single ${type} resource from the data store or 404. */
|
|
189
|
+
export async function get${type}ByIdRoute(
|
|
190
|
+
req: Request,
|
|
191
|
+
res: Response,
|
|
192
|
+
): Promise<Response> {
|
|
193
|
+
const id = String(req.params.id);
|
|
194
|
+
const ctx = req.openhiContext!;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const result = await get${type}ByIdOperation({ context: ctx, id });
|
|
198
|
+
return res.json(result.resource);
|
|
199
|
+
} catch (err: unknown) {
|
|
200
|
+
const status = domainErrorToHttpStatus(err);
|
|
201
|
+
if (status === 404) {
|
|
202
|
+
const diagnostics =
|
|
203
|
+
err instanceof NotFoundError ? err.message : \`${type} \${id} not found\`;
|
|
204
|
+
return sendOperationOutcome404(res, diagnostics);
|
|
205
|
+
}
|
|
206
|
+
return sendOperationOutcome500(res, err, "GET ${type} error:");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
const deleteContent = `import type { Request, Response } from "express";
|
|
212
|
+
import { delete${type}Operation } from "../../../../operations/data/${folder}/${folder}-delete-operation";
|
|
213
|
+
import { sendOperationOutcome500 } from "../../common";
|
|
214
|
+
|
|
215
|
+
/** DELETE ${basePath}/:id — delete: removes from data store, returns 204. */
|
|
216
|
+
export async function delete${type}Route(
|
|
217
|
+
req: Request,
|
|
218
|
+
res: Response,
|
|
219
|
+
): Promise<Response> {
|
|
220
|
+
const id = String(req.params.id);
|
|
221
|
+
const ctx = req.openhiContext!;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await delete${type}Operation({ context: ctx, id });
|
|
225
|
+
return res.status(204).send();
|
|
226
|
+
} catch (err: unknown) {
|
|
227
|
+
return sendOperationOutcome500(res, err, "DELETE ${type} error:");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
const routerContent = `import express from "express";
|
|
233
|
+
import { create${type}Route } from "./${folder}-create-route";
|
|
234
|
+
import { delete${type}Route } from "./${folder}-delete-route";
|
|
235
|
+
import { get${type}ByIdRoute } from "./${folder}-get-by-id-route";
|
|
236
|
+
import { list${pluralRoute(type)}Route } from "./${folder}-list-route";
|
|
237
|
+
import { update${type}Route } from "./${folder}-update-route";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* ${type} REST router: ${basePath}
|
|
241
|
+
* FHIR R4 ${type} resource.
|
|
242
|
+
*/
|
|
243
|
+
const router: express.Router = express.Router();
|
|
244
|
+
|
|
245
|
+
router.get("/", list${pluralRoute(type)}Route);
|
|
246
|
+
router.get("/:id", get${type}ByIdRoute);
|
|
247
|
+
router.post("/", create${type}Route);
|
|
248
|
+
router.put("/:id", update${type}Route);
|
|
249
|
+
router.delete("/:id", delete${type}Route);
|
|
250
|
+
|
|
251
|
+
export { router as ${folder}Router };
|
|
252
|
+
`;
|
|
253
|
+
|
|
254
|
+
fs.writeFileSync(path.join(routeDir, `${folder}-create-route.ts`), createContent);
|
|
255
|
+
fs.writeFileSync(path.join(routeDir, `${folder}-update-route.ts`), updateContent);
|
|
256
|
+
fs.writeFileSync(path.join(routeDir, `${folder}-list-route.ts`), listContent);
|
|
257
|
+
fs.writeFileSync(path.join(routeDir, `${folder}-get-by-id-route.ts`), getByIdContent);
|
|
258
|
+
fs.writeFileSync(path.join(routeDir, `${folder}-delete-route.ts`), deleteContent);
|
|
259
|
+
fs.writeFileSync(routerFile, routerContent);
|
|
260
|
+
console.log(`Generated routes for ${type}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Update common.ts BASE_PATH: collect all keys from generated + existing
|
|
264
|
+
const existingCommon = fs.readFileSync(COMMON, "utf8");
|
|
265
|
+
const basePathMatch = existingCommon.match(/export const BASE_PATH = \{([^}]+)\} as const;/s);
|
|
266
|
+
const existingKeys = new Set();
|
|
267
|
+
if (basePathMatch) {
|
|
268
|
+
basePathMatch[1].split("\n").forEach((line) => {
|
|
269
|
+
const m = line.match(/([A-Z_]+):/);
|
|
270
|
+
if (m) existingKeys.add(m[1]);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
for (const { type } of types) {
|
|
274
|
+
existingKeys.add(basePathKey(type));
|
|
275
|
+
}
|
|
276
|
+
const sortedKeys = [...existingKeys].sort();
|
|
277
|
+
const basePathEntries = sortedKeys.map((k) => ` ${k}: "/${k.charAt(0) + k.slice(1).toLowerCase().replace(/_/g, "")}",`).join("\n");
|
|
278
|
+
// Key to path: ACCOUNT -> /Account, ACTIVITY_DEFINITION -> /ActivityDefinition. So we need type from key.
|
|
279
|
+
// Actually BASE_PATH.PATIENT = "/Patient", so key PATIENT -> /Patient. So path is "/" + key with first letter upper and rest lower... but ACTIVITYDEFINITION -> /Activitydefinition. We need the actual type name. So we need a map from key to type. Keys in the code are the type in uppercase: PATIENT, ACCOUNT, ACTIVITYDEFINITION. So path = "/" + type (PascalCase). So we need to keep type when building. Let me build from types array.
|
|
280
|
+
const keyToPath = {};
|
|
281
|
+
for (const { type } of types) {
|
|
282
|
+
keyToPath[basePathKey(type)] = `/${type}`;
|
|
283
|
+
}
|
|
284
|
+
// Add existing
|
|
285
|
+
keyToPath.CONFIGURATION = "/Configuration";
|
|
286
|
+
keyToPath.ENCOUNTER = "/Encounter";
|
|
287
|
+
keyToPath.PATIENT = "/Patient";
|
|
288
|
+
keyToPath.PRACTITIONER = "/Practitioner";
|
|
289
|
+
const sortedKeys2 = Object.keys(keyToPath).sort();
|
|
290
|
+
const basePathLines = sortedKeys2.map((k) => ` ${k}: "${keyToPath[k]}",`).join("\n");
|
|
291
|
+
const newCommon = existingCommon.replace(
|
|
292
|
+
/export const BASE_PATH = \{[\s\S]*?\} as const;/,
|
|
293
|
+
`export const BASE_PATH = {\n${basePathLines}\n} as const;`
|
|
294
|
+
);
|
|
295
|
+
fs.writeFileSync(COMMON, newCommon);
|
|
296
|
+
console.log("Updated common.ts BASE_PATH");
|
|
297
|
+
|
|
298
|
+
// Update rest-api.ts: add imports and app.use for all data route folders
|
|
299
|
+
const existingRest = fs.readFileSync(REST_API, "utf8");
|
|
300
|
+
const dataDirs = fs.readdirSync(ROUTES_DATA).filter((d) => {
|
|
301
|
+
const p = path.join(ROUTES_DATA, d);
|
|
302
|
+
return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, `${d}.ts`));
|
|
303
|
+
});
|
|
304
|
+
dataDirs.sort();
|
|
305
|
+
const paths = dataDirs.map((d) => `"/${d.charAt(0).toUpperCase() + d.slice(1).replace(/([a-z])([A-Z])/g, "$1$2")}"`);
|
|
306
|
+
// Path: folder "account" -> "/Account", "activitydefinition" -> "/ActivityDefinition". So we need PascalCase from folder. Folder is lowercase type name. So account -> Account, activitydefinition -> ActivityDefinition. We have the types array with type and folder. So path for folder "account" is "/Account" (type from types find by folder).
|
|
307
|
+
const pathForFolder = (folder) => {
|
|
308
|
+
const t = types.find((x) => x.folder === folder);
|
|
309
|
+
return t ? `/${t.type}` : "/" + folder.charAt(0).toUpperCase() + folder.slice(1);
|
|
310
|
+
};
|
|
311
|
+
const routerImports = dataDirs.map((d) => `import { ${d}Router } from "./routes/data/${d}/${d}";`).join("\n");
|
|
312
|
+
const middlewarePaths = ["/Configuration", ...dataDirs.map((d) => pathForFolder(d))].sort();
|
|
313
|
+
const appUseLines = dataDirs.map((d) => `app.use("${pathForFolder(d)}", ${d}Router);`).join("\n");
|
|
314
|
+
const middlewareArr = "[\n " + middlewarePaths.map((p) => `"${p}"`).join(",\n ") + "\n ]";
|
|
315
|
+
|
|
316
|
+
let newRest = existingRest;
|
|
317
|
+
// Replace openHiContextMiddleware array
|
|
318
|
+
newRest = newRest.replace(/app\.use\(\s*\[[\s\S]*?\],\s*openHiContextMiddleware,?\s*\)/, `app.use(\n ${middlewareArr},\n openHiContextMiddleware,\n)`);
|
|
319
|
+
// Replace imports - keep configurationRouter and add all data routers
|
|
320
|
+
newRest = newRest.replace(
|
|
321
|
+
/import \{ configurationRouter \} from "\.\/routes\/control\/configuration\/configuration";\n(import \{ \w+Router \} from "\.\/routes\/data\/[^"]+";\n)*/,
|
|
322
|
+
`import { configurationRouter } from "./routes/control/configuration/configuration";\n${dataDirs.map((d) => `import { ${d}Router } from "./routes/data/${d}/${d}";`).join("\n")}\n`
|
|
323
|
+
);
|
|
324
|
+
// Replace app.use block - data routers then Configuration
|
|
325
|
+
const useComment = (folder) => {
|
|
326
|
+
const t = types.find((x) => x.folder === folder);
|
|
327
|
+
const name = t ? t.type : folder;
|
|
328
|
+
return `// FHIR R4 ${name} resource`;
|
|
329
|
+
};
|
|
330
|
+
const dataRouterBlock = dataDirs.map((d) => `${useComment(d)}\napp.use("${pathForFolder(d)}", ${d}Router);`).join("\n\n");
|
|
331
|
+
newRest = newRest.replace(
|
|
332
|
+
/(\/\/ FHIR R4 .* resource\napp\.use\("\/[^"]+", \w+Router\);?\n)+(\/\/ Control Plane.*\napp\.use\("\/Configuration", configurationRouter\);)/s,
|
|
333
|
+
`${dataRouterBlock}\n\n// Control Plane Configuration resource\napp.use("/Configuration", configurationRouter);`
|
|
334
|
+
);
|
|
335
|
+
fs.writeFileSync(REST_API, newRest);
|
|
336
|
+
console.log("Updated rest-api.ts");
|
|
337
|
+
console.log(`Total: ${dataDirs.length} data route modules`);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate one route test file per FHIR type that has rest-api routes.
|
|
4
|
+
* Run from constructs package: node scripts/generate-route-tests.mjs
|
|
5
|
+
* Ensures routes/data/<folder>/<folder>.test.ts exists for each type.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
13
|
+
const ROUTES_DATA = path.join(ROOT, "src/data/rest-api/routes/data");
|
|
14
|
+
const OPERATIONS = path.join(ROOT, "src/data/operations/data");
|
|
15
|
+
|
|
16
|
+
const dirs = fs.readdirSync(OPERATIONS).filter((d) => {
|
|
17
|
+
const p = path.join(OPERATIONS, d);
|
|
18
|
+
return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, `${d}-create-operation.ts`));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const types = [];
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
const createPath = path.join(OPERATIONS, dir, `${dir}-create-operation.ts`);
|
|
24
|
+
const line = fs.readFileSync(createPath, "utf8").split("\n")[0];
|
|
25
|
+
const match = line.match(/import type \{ ([^}]+) \}/);
|
|
26
|
+
if (!match) continue;
|
|
27
|
+
const imports = match[1].split(",").map((s) => s.trim());
|
|
28
|
+
const type = imports.find((t) => t !== "Meta");
|
|
29
|
+
if (type) types.push({ type, folder: dir });
|
|
30
|
+
}
|
|
31
|
+
types.sort((a, b) => a.type.localeCompare(b.type));
|
|
32
|
+
|
|
33
|
+
function basePathKey(type) {
|
|
34
|
+
return type.toUpperCase().replace(/-/g, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pluralRoute(type) {
|
|
38
|
+
return type + "s";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function testContent(type, folder) {
|
|
42
|
+
const key = basePathKey(type);
|
|
43
|
+
const plural = pluralRoute(type);
|
|
44
|
+
const createOp = `create${type}Operation`;
|
|
45
|
+
const updateOp = `update${type}Operation`;
|
|
46
|
+
const listOp = `list${plural}Operation`;
|
|
47
|
+
const getByIdOp = `get${type}ByIdOperation`;
|
|
48
|
+
const deleteOp = `delete${type}Operation`;
|
|
49
|
+
const createRoute = `create${type}Route`;
|
|
50
|
+
const updateRoute = `update${type}Route`;
|
|
51
|
+
const listRoute = `list${plural}Route`;
|
|
52
|
+
const getByIdRoute = `get${type}ByIdRoute`;
|
|
53
|
+
const deleteRoute = `delete${type}Route`;
|
|
54
|
+
|
|
55
|
+
return `import type { Request, Response } from "express";
|
|
56
|
+
import { NotFoundError } from "../../../../errors";
|
|
57
|
+
import { ${createRoute} } from "./${folder}-create-route";
|
|
58
|
+
import { ${deleteRoute} } from "./${folder}-delete-route";
|
|
59
|
+
import { ${getByIdRoute} } from "./${folder}-get-by-id-route";
|
|
60
|
+
import { ${listRoute} } from "./${folder}-list-route";
|
|
61
|
+
import { ${updateRoute} } from "./${folder}-update-route";
|
|
62
|
+
import { create${type}Operation } from "../../../../operations/data/${folder}/${folder}-create-operation";
|
|
63
|
+
import { delete${type}Operation } from "../../../../operations/data/${folder}/${folder}-delete-operation";
|
|
64
|
+
import { get${type}ByIdOperation } from "../../../../operations/data/${folder}/${folder}-get-by-id-operation";
|
|
65
|
+
import { list${plural}Operation } from "../../../../operations/data/${folder}/${folder}-list-operation";
|
|
66
|
+
import { update${type}Operation } from "../../../../operations/data/${folder}/${folder}-update-operation";
|
|
67
|
+
|
|
68
|
+
jest.mock("../../../../operations/data/${folder}/${folder}-create-operation");
|
|
69
|
+
jest.mock("../../../../operations/data/${folder}/${folder}-update-operation");
|
|
70
|
+
jest.mock("../../../../operations/data/${folder}/${folder}-list-operation");
|
|
71
|
+
jest.mock("../../../../operations/data/${folder}/${folder}-get-by-id-operation");
|
|
72
|
+
jest.mock("../../../../operations/data/${folder}/${folder}-delete-operation");
|
|
73
|
+
|
|
74
|
+
const defaultContext = {
|
|
75
|
+
tenantId: "tenant-1",
|
|
76
|
+
workspaceId: "ws-1",
|
|
77
|
+
date: "2026-02-26T12:00:00.000Z",
|
|
78
|
+
actorId: "actor-1",
|
|
79
|
+
actorName: "Test Actor",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function mockRes(): Response {
|
|
83
|
+
const res = {} as Response;
|
|
84
|
+
res.status = jest.fn().mockReturnThis();
|
|
85
|
+
res.json = jest.fn().mockReturnThis();
|
|
86
|
+
res.location = jest.fn().mockReturnThis();
|
|
87
|
+
res.send = jest.fn().mockReturnThis();
|
|
88
|
+
return res;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mockReq(overrides: Partial<{ params: Record<string, string>; body: unknown }> = {}): Request {
|
|
92
|
+
const req = {
|
|
93
|
+
openhiContext: defaultContext,
|
|
94
|
+
params: {},
|
|
95
|
+
body: {},
|
|
96
|
+
...overrides,
|
|
97
|
+
} as unknown as Request;
|
|
98
|
+
return req;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("${type} routes", () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
jest.clearAllMocks();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("${createRoute}", () => {
|
|
107
|
+
it("returns 201 and location when create succeeds", async () => {
|
|
108
|
+
const id = "01HXYZ";
|
|
109
|
+
(create${type}Operation as jest.Mock).mockResolvedValue({
|
|
110
|
+
id,
|
|
111
|
+
resource: { resourceType: "${type}", id },
|
|
112
|
+
});
|
|
113
|
+
const req = mockReq({
|
|
114
|
+
body: { resourceType: "${type}" },
|
|
115
|
+
});
|
|
116
|
+
const res = mockRes();
|
|
117
|
+
|
|
118
|
+
await ${createRoute}(req, res);
|
|
119
|
+
|
|
120
|
+
expect(create${type}Operation).toHaveBeenCalledWith({
|
|
121
|
+
context: defaultContext,
|
|
122
|
+
body: expect.objectContaining({ resourceType: "${type}" }),
|
|
123
|
+
});
|
|
124
|
+
expect(res.status).toHaveBeenCalledWith(201);
|
|
125
|
+
expect(res.location).toHaveBeenCalled();
|
|
126
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ id }));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("${listRoute}", () => {
|
|
131
|
+
it("returns bundle when list succeeds", async () => {
|
|
132
|
+
(list${plural}Operation as jest.Mock).mockResolvedValue({
|
|
133
|
+
entries: [{ id: "01HXYZ", resource: { resourceType: "${type}", id: "01HXYZ" } }],
|
|
134
|
+
});
|
|
135
|
+
const req = mockReq();
|
|
136
|
+
const res = mockRes();
|
|
137
|
+
|
|
138
|
+
await ${listRoute}(req, res);
|
|
139
|
+
|
|
140
|
+
expect(list${plural}Operation).toHaveBeenCalledWith({ context: defaultContext });
|
|
141
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
resourceType: "Bundle",
|
|
144
|
+
type: "searchset",
|
|
145
|
+
entry: expect.any(Array),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("${getByIdRoute}", () => {
|
|
152
|
+
it("returns 404 when resource not found", async () => {
|
|
153
|
+
(get${type}ByIdOperation as jest.Mock).mockRejectedValue(new NotFoundError("not found"));
|
|
154
|
+
const req = mockReq({ params: { id: "01HXYZ" } });
|
|
155
|
+
const res = mockRes();
|
|
156
|
+
|
|
157
|
+
await ${getByIdRoute}(req, res);
|
|
158
|
+
|
|
159
|
+
expect(get${type}ByIdOperation).toHaveBeenCalledWith({
|
|
160
|
+
context: defaultContext,
|
|
161
|
+
id: "01HXYZ",
|
|
162
|
+
});
|
|
163
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns resource when found", async () => {
|
|
167
|
+
const resource = { resourceType: "${type}", id: "01HXYZ" };
|
|
168
|
+
(get${type}ByIdOperation as jest.Mock).mockResolvedValue({ resource });
|
|
169
|
+
const req = mockReq({ params: { id: "01HXYZ" } });
|
|
170
|
+
const res = mockRes();
|
|
171
|
+
|
|
172
|
+
await ${getByIdRoute}(req, res);
|
|
173
|
+
|
|
174
|
+
expect(res.json).toHaveBeenCalledWith(resource);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("${updateRoute}", () => {
|
|
179
|
+
it("returns 200 and resource when update succeeds", async () => {
|
|
180
|
+
const resource = { resourceType: "${type}", id: "01HXYZ" };
|
|
181
|
+
(update${type}Operation as jest.Mock).mockResolvedValue({ resource });
|
|
182
|
+
const req = mockReq({ params: { id: "01HXYZ" }, body: { resourceType: "${type}" } });
|
|
183
|
+
const res = mockRes();
|
|
184
|
+
|
|
185
|
+
await ${updateRoute}(req, res);
|
|
186
|
+
|
|
187
|
+
expect(update${type}Operation).toHaveBeenCalledWith({
|
|
188
|
+
context: defaultContext,
|
|
189
|
+
id: "01HXYZ",
|
|
190
|
+
body: expect.objectContaining({ resourceType: "${type}", id: "01HXYZ" }),
|
|
191
|
+
});
|
|
192
|
+
expect(res.json).toHaveBeenCalledWith(resource);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("${deleteRoute}", () => {
|
|
197
|
+
it("returns 204 when delete succeeds", async () => {
|
|
198
|
+
(delete${type}Operation as jest.Mock).mockResolvedValue(undefined);
|
|
199
|
+
const req = mockReq({ params: { id: "01HXYZ" } });
|
|
200
|
+
const res = mockRes();
|
|
201
|
+
|
|
202
|
+
await ${deleteRoute}(req, res);
|
|
203
|
+
|
|
204
|
+
expect(delete${type}Operation).toHaveBeenCalledWith({
|
|
205
|
+
context: defaultContext,
|
|
206
|
+
id: "01HXYZ",
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toHaveBeenCalledWith(204);
|
|
209
|
+
expect(res.send).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const { type, folder } of types) {
|
|
217
|
+
const routeDir = path.join(ROUTES_DATA, folder);
|
|
218
|
+
const testFile = path.join(routeDir, `${folder}.test.ts`);
|
|
219
|
+
if (fs.existsSync(testFile)) {
|
|
220
|
+
console.log(`Skip ${type} (test exists)`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
fs.writeFileSync(testFile, testContent(type, folder), "utf8");
|
|
224
|
+
console.log(`Generated ${folder}.test.ts for ${type}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log(`Total: ${types.length} route types; ensured tests for all.`);
|