@jskit-ai/crud-server-generator 0.1.26 → 0.1.28

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.
@@ -1,7 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { registerRoutes } from "../src/server/registerRoutes.js";
2
+ import test, { after } from "node:test";
4
3
  import { resolveApiBasePath } from "@jskit-ai/users-core/shared/support/usersApiPaths";
4
+ import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
5
+
6
+ const fixture = await createTemplateServerFixture();
7
+ const { registerRoutes } = await fixture.importServerModule("registerRoutes.js");
8
+
9
+ after(async () => {
10
+ await fixture.cleanup();
11
+ });
5
12
 
6
13
  function createReplyDouble() {
7
14
  return {
@@ -45,14 +52,7 @@ test("crud routes build create/update action input with explicit payload and pat
45
52
 
46
53
  registerRoutes(app, {
47
54
  routeRelativePath: "/customers",
48
- routeSurfaceRequiresWorkspace: true,
49
- actionIds: {
50
- list: "crud.customers.list",
51
- view: "crud.customers.view",
52
- create: "crud.customers.create",
53
- update: "crud.customers.update",
54
- delete: "crud.customers.delete"
55
- }
55
+ routeSurfaceRequiresWorkspace: true
56
56
  });
57
57
 
58
58
  const workspaceRouteBase = resolveApiBasePath({
@@ -125,14 +125,7 @@ test("crud routes omit workspaceSlug for non-workspace calls and apply configure
125
125
 
126
126
  registerRoutes(app, {
127
127
  routeRelativePath: "/customers",
128
- routeSurface: "console",
129
- actionIds: {
130
- list: "crud.customers.list",
131
- view: "crud.customers.view",
132
- create: "crud.customers.create",
133
- update: "crud.customers.update",
134
- delete: "crud.customers.delete"
135
- }
128
+ routeSurface: "console"
136
129
  });
137
130
 
138
131
  const createRoute = findRoute(registeredRoutes, "POST", "/api/customers");
@@ -161,7 +154,7 @@ test("crud routes omit workspaceSlug for non-workspace calls and apply configure
161
154
  });
162
155
  });
163
156
 
164
- test("crud routes normalize route ownership filter values before registering visibility", () => {
157
+ test("crud routes validate route ownership filter values before registering visibility", () => {
165
158
  const registeredRoutes = [];
166
159
  const router = {
167
160
  register(method, path, route, handler) {
@@ -184,32 +177,142 @@ test("crud routes normalize route ownership filter values before registering vis
184
177
 
185
178
  registerRoutes(app, {
186
179
  routeRelativePath: "/customers",
187
- routeOwnershipFilter: " Workspace_User ",
188
- actionIds: {
189
- list: "crud.customers.list",
190
- view: "crud.customers.view",
191
- create: "crud.customers.create",
192
- update: "crud.customers.update",
193
- delete: "crud.customers.delete"
194
- }
195
- });
196
- registerRoutes(app, {
197
- routeRelativePath: "/customers-public",
198
- routeOwnershipFilter: "not-a-real-filter",
199
- actionIds: {
200
- list: "crud.customers-public.list",
201
- view: "crud.customers-public.view",
202
- create: "crud.customers-public.create",
203
- update: "crud.customers-public.update",
204
- delete: "crud.customers-public.delete"
205
- }
180
+ routeOwnershipFilter: " Workspace_User "
206
181
  });
182
+ assert.throws(
183
+ () =>
184
+ registerRoutes(app, {
185
+ routeRelativePath: "/customers-public",
186
+ routeOwnershipFilter: "not-a-real-filter"
187
+ }),
188
+ /must be one of/
189
+ );
207
190
 
208
191
  const workspaceUserRoute = findRoute(registeredRoutes, "GET", "/api/customers");
209
- const fallbackPublicRoute = findRoute(registeredRoutes, "GET", "/api/customers-public");
210
192
 
211
193
  assert.ok(workspaceUserRoute);
212
- assert.ok(fallbackPublicRoute);
213
194
  assert.equal(workspaceUserRoute.route.visibility, "workspace_user");
214
- assert.equal(fallbackPublicRoute.route.visibility, "public");
195
+ });
196
+
197
+ test("crud list route forwards normalized query input from list query validators", async () => {
198
+ const registeredRoutes = [];
199
+ const router = {
200
+ register(method, path, route, handler) {
201
+ registeredRoutes.push({
202
+ method,
203
+ path,
204
+ route,
205
+ handler
206
+ });
207
+ }
208
+ };
209
+ const app = {
210
+ make(token) {
211
+ if (token !== "jskit.http.router") {
212
+ throw new Error(`Unexpected token: ${String(token)}`);
213
+ }
214
+ return router;
215
+ }
216
+ };
217
+
218
+ registerRoutes(app, {
219
+ routeRelativePath: "/customers",
220
+ routeSurfaceRequiresWorkspace: true
221
+ });
222
+
223
+ const workspaceRouteBase = resolveApiBasePath({
224
+ surfaceRequiresWorkspace: true,
225
+ relativePath: "/customers"
226
+ });
227
+ const listRoute = findRoute(registeredRoutes, "GET", workspaceRouteBase);
228
+ assert.ok(listRoute);
229
+
230
+ const calls = [];
231
+ const executeAction = async (payload) => {
232
+ calls.push(payload);
233
+ return {};
234
+ };
235
+
236
+ await listRoute.handler(
237
+ {
238
+ input: {
239
+ params: { workspaceSlug: "acme" },
240
+ query: {
241
+ cursor: 3,
242
+ limit: 25,
243
+ q: "to",
244
+ contactId: "2971",
245
+ include: "vetId"
246
+ }
247
+ },
248
+ executeAction
249
+ },
250
+ createReplyDouble()
251
+ );
252
+
253
+ assert.deepEqual(calls[0].input, {
254
+ workspaceSlug: "acme",
255
+ cursor: 3,
256
+ limit: 25,
257
+ q: "to",
258
+ contactId: "2971",
259
+ include: "vetId"
260
+ });
261
+ });
262
+
263
+ test("crud view route forwards include query input", async () => {
264
+ const registeredRoutes = [];
265
+ const router = {
266
+ register(method, path, route, handler) {
267
+ registeredRoutes.push({
268
+ method,
269
+ path,
270
+ route,
271
+ handler
272
+ });
273
+ }
274
+ };
275
+ const app = {
276
+ make(token) {
277
+ if (token !== "jskit.http.router") {
278
+ throw new Error(`Unexpected token: ${String(token)}`);
279
+ }
280
+ return router;
281
+ }
282
+ };
283
+
284
+ registerRoutes(app, {
285
+ routeRelativePath: "/customers",
286
+ routeSurfaceRequiresWorkspace: true
287
+ });
288
+
289
+ const workspaceRouteBase = resolveApiBasePath({
290
+ surfaceRequiresWorkspace: true,
291
+ relativePath: "/customers"
292
+ });
293
+ const viewRoute = findRoute(registeredRoutes, "GET", `${workspaceRouteBase}/:recordId`);
294
+ assert.ok(viewRoute);
295
+
296
+ const calls = [];
297
+ const executeAction = async (payload) => {
298
+ calls.push(payload);
299
+ return {};
300
+ };
301
+
302
+ await viewRoute.handler(
303
+ {
304
+ input: {
305
+ params: { workspaceSlug: "acme", recordId: 7 },
306
+ query: { include: "vetId" }
307
+ },
308
+ executeAction
309
+ },
310
+ createReplyDouble()
311
+ );
312
+
313
+ assert.deepEqual(calls[0].input, {
314
+ workspaceSlug: "acme",
315
+ recordId: 7,
316
+ include: "vetId"
317
+ });
215
318
  });
@@ -0,0 +1,169 @@
1
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ const testSupportDirectory = path.dirname(fileURLToPath(import.meta.url));
6
+ const packageRoot = path.resolve(testSupportDirectory, "..");
7
+ const serverTemplateRoot = path.join(packageRoot, "templates", "src", "local-package", "server");
8
+
9
+ const CRUD_NAMESPACE = Object.freeze({
10
+ snake: "customers",
11
+ camel: "customers",
12
+ singularCamel: "customer",
13
+ pascal: "Customers"
14
+ });
15
+
16
+ const TEMPLATE_REPLACEMENTS = Object.freeze([
17
+ ["${option:namespace|snake}", CRUD_NAMESPACE.snake],
18
+ ["${option:namespace|camel}", CRUD_NAMESPACE.camel],
19
+ ["${option:namespace|singular|camel}", CRUD_NAMESPACE.singularCamel],
20
+ ["${option:namespace|pascal}", CRUD_NAMESPACE.pascal],
21
+ ["__JSKIT_CRUD_ID_COLUMN__", JSON.stringify("id")]
22
+ ]);
23
+
24
+ function applyTemplateReplacements(sourceText = "") {
25
+ let rendered = String(sourceText || "");
26
+ for (const [needle, replacement] of TEMPLATE_REPLACEMENTS) {
27
+ rendered = rendered.split(needle).join(replacement);
28
+ }
29
+ return rendered;
30
+ }
31
+
32
+ function buildResourceStubSource() {
33
+ return `const recordOutputValidator = Object.freeze({
34
+ schema: {
35
+ properties: {
36
+ id: {},
37
+ name: {}
38
+ }
39
+ },
40
+ normalize(payload = {}) {
41
+ return payload;
42
+ }
43
+ });
44
+
45
+ const createBodyValidator = Object.freeze({
46
+ schema: {
47
+ properties: {
48
+ name: {},
49
+ contactId: {}
50
+ }
51
+ },
52
+ normalize(payload = {}) {
53
+ return payload;
54
+ }
55
+ });
56
+
57
+ const patchBodyValidator = Object.freeze({
58
+ schema: {
59
+ properties: {
60
+ name: {},
61
+ contactId: {}
62
+ }
63
+ },
64
+ normalize: createBodyValidator.normalize
65
+ });
66
+
67
+ const resource = Object.freeze({
68
+ resource: "customers",
69
+ tableName: "customers",
70
+ idColumn: "id",
71
+ operations: {
72
+ list: {
73
+ outputValidator: Object.freeze({
74
+ schema: {
75
+ properties: {
76
+ items: {},
77
+ nextCursor: {}
78
+ }
79
+ },
80
+ normalize(payload = {}) {
81
+ return payload;
82
+ }
83
+ })
84
+ },
85
+ view: {
86
+ outputValidator: recordOutputValidator
87
+ },
88
+ create: {
89
+ bodyValidator: createBodyValidator,
90
+ outputValidator: recordOutputValidator
91
+ },
92
+ patch: {
93
+ bodyValidator: patchBodyValidator,
94
+ outputValidator: recordOutputValidator
95
+ },
96
+ delete: {
97
+ outputValidator: Object.freeze({
98
+ schema: {
99
+ properties: {
100
+ id: {},
101
+ deleted: {}
102
+ }
103
+ },
104
+ normalize(payload = {}) {
105
+ return payload;
106
+ }
107
+ })
108
+ }
109
+ },
110
+ fieldMeta: [
111
+ {
112
+ key: "contactId",
113
+ relation: {
114
+ kind: "lookup",
115
+ namespace: "contacts",
116
+ valueKey: "id"
117
+ }
118
+ }
119
+ ]
120
+ });
121
+
122
+ export { resource };
123
+ `;
124
+ }
125
+
126
+ async function renderServerTemplateFile(targetServerDirectory, fileName) {
127
+ const templatePath = path.join(serverTemplateRoot, fileName);
128
+ const templateSource = await readFile(templatePath, "utf8");
129
+ const renderedSource = applyTemplateReplacements(templateSource);
130
+ await writeFile(path.join(targetServerDirectory, fileName), renderedSource, "utf8");
131
+ }
132
+
133
+ async function createTemplateServerFixture() {
134
+ const fixtureRoot = await mkdtemp(path.join(packageRoot, ".tmp-crud-server-template-fixture-"));
135
+ const srcRoot = path.join(fixtureRoot, "src");
136
+ const serverRoot = path.join(srcRoot, "server");
137
+ const sharedRoot = path.join(srcRoot, "shared");
138
+
139
+ await mkdir(serverRoot, { recursive: true });
140
+ await mkdir(sharedRoot, { recursive: true });
141
+ await writeFile(
142
+ path.join(fixtureRoot, "package.json"),
143
+ JSON.stringify({ name: "crud-server-template-fixture", private: true, type: "module" }, null, 2),
144
+ "utf8"
145
+ );
146
+ await writeFile(path.join(sharedRoot, "customerResource.js"), buildResourceStubSource(), "utf8");
147
+
148
+ for (const fileName of ["actionIds.js", "actions.js", "registerRoutes.js", "repository.js", "service.js"]) {
149
+ await renderServerTemplateFile(serverRoot, fileName);
150
+ }
151
+
152
+ async function importServerModule(fileName) {
153
+ const absolutePath = path.join(serverRoot, fileName);
154
+ const href = pathToFileURL(absolutePath).href;
155
+ return import(`${href}?t=${Date.now()}_${Math.random()}`);
156
+ }
157
+
158
+ async function cleanup() {
159
+ await rm(fixtureRoot, { recursive: true, force: true });
160
+ }
161
+
162
+ return Object.freeze({
163
+ fixtureRoot,
164
+ importServerModule,
165
+ cleanup
166
+ });
167
+ }
168
+
169
+ export { createTemplateServerFixture };
@@ -1,22 +0,0 @@
1
- function requireActionIdPrefix(actionIdPrefix) {
2
- const prefix = String(actionIdPrefix || "").trim();
3
- if (!prefix) {
4
- throw new TypeError("createActionIds requires actionIdPrefix.");
5
- }
6
-
7
- return prefix;
8
- }
9
-
10
- function createActionIds(actionIdPrefix) {
11
- const prefix = requireActionIdPrefix(actionIdPrefix);
12
-
13
- return Object.freeze({
14
- list: `${prefix}.list`,
15
- view: `${prefix}.view`,
16
- create: `${prefix}.create`,
17
- update: `${prefix}.update`,
18
- delete: `${prefix}.delete`
19
- });
20
- }
21
-
22
- export { requireActionIdPrefix, createActionIds };
@@ -1,152 +0,0 @@
1
- import {
2
- cursorPaginationQueryValidator,
3
- recordIdParamsValidator
4
- } from "@jskit-ai/kernel/shared/validators";
5
- import { workspaceSlugParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
6
- import { crudResource } from "../shared/crud/crudResource.js";
7
- import { createActionIds } from "./actionIds.js";
8
-
9
- function requireActionSurface(surface = "") {
10
- const normalizedSurface = String(surface || "").trim().toLowerCase();
11
- if (!normalizedSurface) {
12
- throw new TypeError("createActions requires a non-empty surface.");
13
- }
14
-
15
- return normalizedSurface;
16
- }
17
-
18
- function createActions({ actionIdPrefix, surface = "" } = {}) {
19
- const actionIds = createActionIds(actionIdPrefix);
20
- const actionSurface = requireActionSurface(surface);
21
-
22
- return Object.freeze([
23
- {
24
- id: actionIds.list,
25
- version: 1,
26
- kind: "query",
27
- channels: ["api", "automation", "internal"],
28
- surfaces: [actionSurface],
29
- permission: {
30
- require: "authenticated"
31
- },
32
- inputValidator: [workspaceSlugParamsValidator, cursorPaginationQueryValidator],
33
- outputValidator: crudResource.operations.list.outputValidator,
34
- idempotency: "none",
35
- audit: {
36
- actionName: actionIds.list
37
- },
38
- observability: {},
39
- async execute(input, context, deps) {
40
- return deps.crudService.listRecords(input, {
41
- context,
42
- visibilityContext: context?.visibilityContext
43
- });
44
- }
45
- },
46
- {
47
- id: actionIds.view,
48
- version: 1,
49
- kind: "query",
50
- channels: ["api", "automation", "internal"],
51
- surfaces: [actionSurface],
52
- permission: {
53
- require: "authenticated"
54
- },
55
- inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator],
56
- outputValidator: crudResource.operations.view.outputValidator,
57
- idempotency: "none",
58
- audit: {
59
- actionName: actionIds.view
60
- },
61
- observability: {},
62
- async execute(input, context, deps) {
63
- return deps.crudService.getRecord(input.recordId, {
64
- context,
65
- visibilityContext: context?.visibilityContext
66
- });
67
- }
68
- },
69
- {
70
- id: actionIds.create,
71
- version: 1,
72
- kind: "command",
73
- channels: ["api", "automation", "internal"],
74
- surfaces: [actionSurface],
75
- permission: {
76
- require: "authenticated"
77
- },
78
- inputValidator: [
79
- workspaceSlugParamsValidator,
80
- {
81
- payload: crudResource.operations.create.bodyValidator
82
- }
83
- ],
84
- outputValidator: crudResource.operations.create.outputValidator,
85
- idempotency: "optional",
86
- audit: {
87
- actionName: actionIds.create
88
- },
89
- observability: {},
90
- async execute(input, context, deps) {
91
- return deps.crudService.createRecord(input.payload, {
92
- context,
93
- visibilityContext: context?.visibilityContext
94
- });
95
- }
96
- },
97
- {
98
- id: actionIds.update,
99
- version: 1,
100
- kind: "command",
101
- channels: ["api", "automation", "internal"],
102
- surfaces: [actionSurface],
103
- permission: {
104
- require: "authenticated"
105
- },
106
- inputValidator: [
107
- workspaceSlugParamsValidator,
108
- recordIdParamsValidator,
109
- {
110
- patch: crudResource.operations.patch.bodyValidator
111
- }
112
- ],
113
- outputValidator: crudResource.operations.patch.outputValidator,
114
- idempotency: "optional",
115
- audit: {
116
- actionName: actionIds.update
117
- },
118
- observability: {},
119
- async execute(input, context, deps) {
120
- return deps.crudService.updateRecord(input.recordId, input.patch, {
121
- context,
122
- visibilityContext: context?.visibilityContext
123
- });
124
- }
125
- },
126
- {
127
- id: actionIds.delete,
128
- version: 1,
129
- kind: "command",
130
- channels: ["api", "automation", "internal"],
131
- surfaces: [actionSurface],
132
- permission: {
133
- require: "authenticated"
134
- },
135
- inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator],
136
- outputValidator: crudResource.operations.delete.outputValidator,
137
- idempotency: "optional",
138
- audit: {
139
- actionName: actionIds.delete
140
- },
141
- observability: {},
142
- async execute(input, context, deps) {
143
- return deps.crudService.deleteRecord(input.recordId, {
144
- context,
145
- visibilityContext: context?.visibilityContext
146
- });
147
- }
148
- }
149
- ]);
150
- }
151
-
152
- export { createActions };