@jskit-ai/kernel 0.1.55 → 0.1.56

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.
Files changed (55) hide show
  1. package/package.json +3 -2
  2. package/server/actions/ActionRuntimeServiceProvider.test.js +23 -15
  3. package/server/http/lib/kernel.test.js +447 -0
  4. package/server/http/lib/routeRegistration.js +236 -15
  5. package/server/http/lib/routeTransport.js +126 -0
  6. package/server/http/lib/routeValidator.js +133 -198
  7. package/server/http/lib/routeValidator.test.js +385 -278
  8. package/server/http/lib/router.js +17 -2
  9. package/server/platform/providerRuntime.test.js +7 -7
  10. package/server/runtime/bootBootstrapRoutes.js +2 -18
  11. package/server/runtime/bootBootstrapRoutes.test.js +5 -14
  12. package/server/runtime/fastifyBootstrap.js +119 -0
  13. package/server/runtime/fastifyBootstrap.test.js +119 -1
  14. package/server/runtime/moduleConfig.js +32 -62
  15. package/server/runtime/moduleConfig.test.js +48 -24
  16. package/server/support/pageTargets.js +15 -9
  17. package/server/support/pageTargets.test.js +1 -1
  18. package/shared/actions/actionContributorHelpers.js +5 -11
  19. package/shared/actions/actionDefinitions.js +37 -150
  20. package/shared/actions/actionDefinitions.test.js +117 -136
  21. package/shared/actions/policies.js +25 -169
  22. package/shared/actions/policies.test.js +76 -87
  23. package/shared/actions/registry.test.js +24 -50
  24. package/shared/support/crudFieldContract.js +322 -0
  25. package/shared/support/crudFieldContract.test.js +67 -0
  26. package/shared/support/crudListFilters.js +582 -38
  27. package/shared/support/crudListFilters.test.js +178 -8
  28. package/shared/support/crudLookup.js +14 -7
  29. package/shared/support/crudLookup.test.js +91 -66
  30. package/shared/support/shellLayoutTargets.test.js +1 -1
  31. package/shared/validators/composeSchemaDefinitions.js +53 -0
  32. package/shared/validators/composeSchemaDefinitions.test.js +156 -0
  33. package/shared/validators/createCursorListValidator.js +22 -35
  34. package/shared/validators/createCursorListValidator.test.js +22 -23
  35. package/shared/validators/cursorPaginationQueryValidator.js +14 -24
  36. package/shared/validators/cursorPaginationQueryValidator.test.js +18 -8
  37. package/shared/validators/htmlTimeSchemas.js +6 -4
  38. package/shared/validators/index.js +15 -7
  39. package/shared/validators/jsonRestSchemaSupport.js +139 -0
  40. package/shared/validators/mergeObjectSchemas.js +44 -6
  41. package/shared/validators/mergeObjectSchemas.test.js +60 -35
  42. package/shared/validators/recordIdParamsValidator.js +19 -52
  43. package/shared/validators/recordIdParamsValidator.test.js +13 -8
  44. package/shared/validators/resourceRequiredMetadata.js +3 -3
  45. package/shared/validators/resourceRequiredMetadata.test.js +29 -16
  46. package/shared/validators/schemaDefinitions.js +126 -0
  47. package/shared/validators/schemaDefinitions.test.js +51 -0
  48. package/shared/validators/schemaPayloadValidation.js +65 -0
  49. package/test/barrelExposure.test.js +30 -0
  50. package/test/routeInputContractGuard.test.js +10 -6
  51. package/shared/validators/mergeValidators.js +0 -89
  52. package/shared/validators/mergeValidators.test.js +0 -116
  53. package/shared/validators/nestValidator.js +0 -53
  54. package/shared/validators/nestValidator.test.js +0 -60
  55. package/shared/validators/settingsFieldNormalization.js +0 -40
@@ -1,57 +1,105 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
3
4
 
4
5
  import { createRouter } from "./router.js";
5
6
  import { compileRouteValidator, defineRouteValidator, resolveRouteValidatorOptions } from "./routeValidator.js";
6
7
 
8
+ function stripJsonRestTransportExtensions(value) {
9
+ if (Array.isArray(value)) {
10
+ return value.map((entry) => stripJsonRestTransportExtensions(entry));
11
+ }
12
+
13
+ if (!value || typeof value !== "object") {
14
+ return value;
15
+ }
16
+
17
+ const sanitized = {};
18
+
19
+ for (const [key, entry] of Object.entries(value)) {
20
+ if (key === "x-json-rest-schema") {
21
+ continue;
22
+ }
23
+
24
+ sanitized[key] = stripJsonRestTransportExtensions(entry);
25
+ }
26
+
27
+ return sanitized;
28
+ }
29
+
30
+ function toFastifySchema(schema, mode) {
31
+ return stripJsonRestTransportExtensions(schema.toJsonSchema({ mode }));
32
+ }
33
+
34
+ function createMockJsonRestSchema() {
35
+ return createSchema({
36
+ name: {
37
+ type: "string",
38
+ required: true,
39
+ minLength: 1,
40
+ maxLength: 160,
41
+ messages: {
42
+ minLength: "Name is required."
43
+ }
44
+ }
45
+ });
46
+ }
47
+
7
48
  test("defineRouteValidator compiles body/query/params and maps query schema to querystring", () => {
8
- const bodySchema = {
9
- type: "object"
10
- };
11
- const querySchema = {
12
- type: "object"
13
- };
14
- const paramsSchema = {
15
- type: "object"
16
- };
49
+ const bodySchema = createSchema({
50
+ name: {
51
+ type: "string",
52
+ required: true,
53
+ minLength: 1
54
+ }
55
+ });
56
+ const querySchema = createSchema({
57
+ search: {
58
+ type: "string",
59
+ required: false,
60
+ minLength: 1
61
+ }
62
+ });
63
+ const paramsSchema = createSchema({
64
+ contactId: {
65
+ type: "string",
66
+ required: true,
67
+ minLength: 1
68
+ }
69
+ });
70
+ const responseBodySchema = createSchema({
71
+ ok: {
72
+ type: "boolean",
73
+ required: true
74
+ }
75
+ });
17
76
  const responseSchema = {
18
77
  200: {
19
- schema: {
20
- type: "object"
21
- }
78
+ schema: responseBodySchema
22
79
  }
23
80
  };
24
81
  const headersSchema = {
25
82
  type: "object"
26
83
  };
27
84
 
28
- const normalizeBody = (body) => body;
29
- const normalizeQuery = (query) => query;
30
- const normalizeParams = (params) => params;
31
-
32
85
  const validator = defineRouteValidator({
33
86
  meta: {
34
87
  tags: ["contacts", "intake"],
35
88
  summary: "Create contact intake"
36
89
  },
37
- bodyValidator: {
38
- schema: bodySchema,
39
- normalize: normalizeBody
90
+ body: {
91
+ schema: bodySchema
40
92
  },
41
- queryValidator: {
42
- schema: querySchema,
43
- normalize: normalizeQuery
93
+ query: {
94
+ schema: querySchema
44
95
  },
45
- paramsValidator: {
96
+ params: {
46
97
  schema: paramsSchema
47
98
  },
48
- responseValidators: responseSchema,
99
+ responses: responseSchema,
49
100
  advanced: {
50
101
  fastifySchema: {
51
102
  headers: headersSchema
52
- },
53
- jskitInput: {
54
- params: normalizeParams
55
103
  }
56
104
  }
57
105
  });
@@ -61,62 +109,73 @@ test("defineRouteValidator compiles body/query/params and maps query schema to q
61
109
  assert.deepEqual(compiled.schema, {
62
110
  tags: ["contacts", "intake"],
63
111
  summary: "Create contact intake",
64
- body: bodySchema,
65
- querystring: querySchema,
66
- params: paramsSchema,
112
+ body: toFastifySchema(bodySchema, "patch"),
113
+ querystring: toFastifySchema(querySchema, "patch"),
114
+ params: toFastifySchema(paramsSchema, "patch"),
67
115
  response: {
68
- 200: {
69
- type: "object"
70
- }
116
+ 200: toFastifySchema(responseBodySchema, "replace")
71
117
  },
72
118
  headers: headersSchema
73
119
  });
74
- assert.equal(compiled.input.body, normalizeBody);
75
- assert.equal(compiled.input.query, normalizeQuery);
76
- assert.equal(compiled.input.params, normalizeParams);
120
+ assert.equal(typeof compiled.input.body, "function");
121
+ assert.equal(typeof compiled.input.query, "function");
122
+ assert.equal(typeof compiled.input.params, "function");
123
+ assert.deepEqual(compiled.input.body({
124
+ name: " Acme "
125
+ }), {
126
+ name: "Acme"
127
+ });
77
128
  });
78
129
 
79
- test("compileRouteValidator accepts plain validator objects", () => {
80
- const querySchema = {
81
- type: "object"
82
- };
83
- const normalizeQuery = (query) => ({
84
- dryRun: Boolean(query?.dryRun)
130
+ test("compileRouteValidator accepts json-rest-schema definitions", () => {
131
+ const querySchema = createSchema({
132
+ search: {
133
+ type: "string",
134
+ required: false,
135
+ minLength: 1
136
+ }
85
137
  });
86
138
 
87
139
  const compiled = compileRouteValidator({
88
- queryValidator: {
89
- schema: querySchema,
90
- normalize: normalizeQuery
140
+ query: {
141
+ schema: querySchema
91
142
  }
92
143
  });
93
144
 
94
145
  assert.deepEqual(compiled.schema, {
95
- querystring: querySchema
146
+ querystring: toFastifySchema(querySchema, "patch")
96
147
  });
97
- assert.equal(compiled.input.query, normalizeQuery);
148
+ assert.equal(typeof compiled.input.query, "function");
98
149
  });
99
150
 
100
- test("compileRouteValidator creates pass-through request.input transforms for schema-only params and query", () => {
101
- const querySchema = {
102
- type: "object"
103
- };
104
- const paramsSchema = {
105
- type: "object"
106
- };
151
+ test("compileRouteValidator creates request.input transforms for schema-only params and query", () => {
152
+ const querySchema = createSchema({
153
+ workspaceSlug: {
154
+ type: "string",
155
+ required: false,
156
+ minLength: 1
157
+ }
158
+ });
159
+ const paramsSchema = createSchema({
160
+ workspaceSlug: {
161
+ type: "string",
162
+ required: true,
163
+ minLength: 1
164
+ }
165
+ });
107
166
 
108
167
  const compiled = compileRouteValidator({
109
- queryValidator: {
168
+ query: {
110
169
  schema: querySchema
111
170
  },
112
- paramsValidator: {
171
+ params: {
113
172
  schema: paramsSchema
114
173
  }
115
174
  });
116
175
 
117
176
  assert.deepEqual(compiled.schema, {
118
- querystring: querySchema,
119
- params: paramsSchema
177
+ querystring: toFastifySchema(querySchema, "patch"),
178
+ params: toFastifySchema(paramsSchema, "patch")
120
179
  });
121
180
  assert.equal(typeof compiled.input.query, "function");
122
181
  assert.equal(typeof compiled.input.params, "function");
@@ -124,202 +183,111 @@ test("compileRouteValidator creates pass-through request.input transforms for sc
124
183
  assert.deepEqual(compiled.input.params({ workspaceSlug: "acme" }), { workspaceSlug: "acme" });
125
184
  });
126
185
 
127
- test("compileRouteValidator accepts response validator objects and extracts only response schemas", () => {
128
- const responseBodySchema = {
129
- type: "object"
130
- };
131
- const normalizeOutput = (payload) => ({
132
- ...payload,
133
- normalized: true
186
+ test("compileRouteValidator accepts response schema definitions and extracts only response schemas", () => {
187
+ const responseBodySchema = createSchema({
188
+ ok: {
189
+ type: "boolean",
190
+ required: true
191
+ }
134
192
  });
135
193
 
136
194
  const compiled = compileRouteValidator({
137
- responseValidators: {
195
+ responses: {
138
196
  200: {
139
- schema: responseBodySchema,
140
- normalize: normalizeOutput
197
+ schema: responseBodySchema
141
198
  },
142
199
  400: {
143
- schema: {
144
- type: "object"
145
- }
200
+ schema: createSchema({
201
+ ok: {
202
+ type: "boolean",
203
+ required: true
204
+ }
205
+ })
146
206
  }
147
207
  }
148
208
  });
149
209
 
150
210
  assert.deepEqual(compiled.schema, {
151
211
  response: {
152
- 200: responseBodySchema,
153
- 400: {
154
- type: "object"
155
- }
212
+ 200: toFastifySchema(responseBodySchema, "replace"),
213
+ 400: toFastifySchema(createSchema({
214
+ ok: {
215
+ type: "boolean",
216
+ required: true
217
+ }
218
+ }), "replace")
156
219
  }
157
220
  });
158
- assert.equal(Object.prototype.hasOwnProperty.call(compiled, "output"), false);
221
+ assert.equal(Object.hasOwn(compiled, "output"), false);
159
222
  });
160
223
 
161
- test("compileRouteValidator merges query validator arrays automatically", () => {
162
- const paginationQuery = {
163
- schema: {
164
- type: "object",
165
- properties: {
166
- cursor: {
167
- type: "string"
168
- }
169
- },
170
- additionalProperties: false
171
- }
172
- };
173
- const searchQuery = {
174
- schema: {
175
- type: "object",
176
- properties: {
177
- search: {
178
- type: "string"
179
- }
180
- },
181
- additionalProperties: false
182
- }
183
- };
184
-
224
+ test("compileRouteValidator turns json-rest-schema validators into transport schema plus input normalization", () => {
225
+ const bodySchema = createMockJsonRestSchema();
185
226
  const compiled = compileRouteValidator({
186
- queryValidator: [paginationQuery, searchQuery]
227
+ body: {
228
+ schema: bodySchema,
229
+ mode: "patch"
230
+ }
187
231
  });
188
232
 
189
233
  assert.deepEqual(compiled.schema, {
190
- querystring: {
191
- type: "object",
192
- properties: {
193
- cursor: {
194
- type: "string"
195
- },
196
- search: {
197
- type: "string"
198
- }
199
- },
200
- required: ["cursor", "search"],
201
- additionalProperties: false
202
- }
234
+ body: toFastifySchema(bodySchema, "patch")
203
235
  });
204
- });
205
236
 
206
- test("compileRouteValidator merges params validator arrays automatically", () => {
207
- const workspaceSlugParams = {
208
- schema: {
209
- type: "object",
210
- properties: {
211
- workspaceSlug: {
212
- type: "string"
213
- }
214
- },
215
- required: ["workspaceSlug"],
216
- additionalProperties: false
217
- }
218
- };
219
- const inviteIdParams = {
220
- schema: {
221
- type: "object",
222
- properties: {
223
- inviteId: {
224
- type: "string"
225
- }
226
- },
227
- required: ["inviteId"],
228
- additionalProperties: false
229
- }
230
- };
237
+ const normalized = compiled.input.body({
238
+ name: " Acme "
239
+ });
240
+ assert.deepEqual(normalized, {
241
+ name: "Acme"
242
+ });
243
+ });
231
244
 
245
+ test("compileRouteValidator surfaces shared schema validation errors with HTTP 400 metadata", () => {
246
+ const bodySchema = createMockJsonRestSchema();
232
247
  const compiled = compileRouteValidator({
233
- paramsValidator: [workspaceSlugParams, inviteIdParams]
248
+ body: {
249
+ schema: bodySchema,
250
+ mode: "patch"
251
+ }
234
252
  });
235
253
 
236
- assert.deepEqual(compiled.schema, {
237
- params: {
238
- type: "object",
239
- properties: {
240
- workspaceSlug: {
241
- type: "string"
242
- },
243
- inviteId: {
244
- type: "string"
245
- }
246
- },
247
- required: ["workspaceSlug", "inviteId"],
248
- additionalProperties: false
254
+ assert.throws(
255
+ () => compiled.input.body({ name: "" }),
256
+ (error) => {
257
+ assert.equal(error?.statusCode, 400);
258
+ assert.deepEqual(error?.details?.fieldErrors, {
259
+ name: "Name is required."
260
+ });
261
+ return true;
249
262
  }
250
- });
263
+ );
251
264
  });
252
265
 
253
- test("compileRouteValidator composes multiple query normalizers in validator arrays", () => {
254
- const compiled = compileRouteValidator({
255
- queryValidator: [
256
- {
257
- schema: {
258
- type: "object",
259
- properties: {
266
+ test("compileRouteValidator rejects validator arrays", () => {
267
+ assert.throws(
268
+ () => compileRouteValidator({
269
+ query: [
270
+ {
271
+ schema: createSchema({
260
272
  cursor: {
261
- type: "string"
262
- }
263
- },
264
- additionalProperties: false
265
- },
266
- normalize(query = {}) {
267
- return {
268
- cursor: String(query.cursor || "").trim()
269
- };
270
- }
271
- },
272
- {
273
- schema: {
274
- type: "object",
275
- properties: {
276
- search: {
277
- type: "string"
273
+ type: "string",
274
+ required: false
278
275
  }
279
- },
280
- additionalProperties: false
281
- },
282
- normalize(query = {}) {
283
- return {
284
- search: String(query.search || "").trim().toLowerCase()
285
- };
276
+ })
286
277
  }
287
- }
288
- ]
289
- });
290
-
291
- assert.deepEqual(compiled.input.query({ cursor: " 100 ", search: " ACME " }), {
292
- cursor: "100",
293
- search: "acme"
294
- });
295
- });
296
-
297
- test("resolveRouteValidatorOptions ignores legacy schema/input definitions", () => {
298
- const resolved = resolveRouteValidatorOptions({
299
- method: "POST",
300
- path: "/contacts",
301
- options: {
302
- schema: {
303
- bodyValidator: {}
304
- },
305
- input: {
306
- body: () => ({})
307
- },
308
- middleware: ["api"]
309
- }
310
- });
311
-
312
- assert.equal(Object.prototype.hasOwnProperty.call(resolved, "schema"), false);
313
- assert.equal(Object.prototype.hasOwnProperty.call(resolved, "input"), false);
314
- assert.deepEqual(resolved.middleware, ["api"]);
278
+ ]
279
+ }),
280
+ /route validator\.query must be a schema definition object/
281
+ );
315
282
  });
316
283
 
317
284
  test("resolveRouteValidatorOptions supports inline validator shape without wrapper", () => {
318
- const bodySchema = {
319
- type: "object"
320
- };
321
- const normalizeBody = (body) => ({
322
- name: String(body?.name || "").trim()
285
+ const bodySchema = createSchema({
286
+ name: {
287
+ type: "string",
288
+ required: true,
289
+ minLength: 1
290
+ }
323
291
  });
324
292
 
325
293
  const resolved = resolveRouteValidatorOptions({
@@ -330,9 +298,8 @@ test("resolveRouteValidatorOptions supports inline validator shape without wrapp
330
298
  tags: ["contacts"],
331
299
  summary: "Create contact"
332
300
  },
333
- bodyValidator: {
334
- schema: bodySchema,
335
- normalize: normalizeBody
301
+ body: {
302
+ schema: bodySchema
336
303
  },
337
304
  middleware: ["api"]
338
305
  }
@@ -341,37 +308,73 @@ test("resolveRouteValidatorOptions supports inline validator shape without wrapp
341
308
  assert.deepEqual(resolved.schema, {
342
309
  tags: ["contacts"],
343
310
  summary: "Create contact",
344
- body: bodySchema
311
+ body: toFastifySchema(bodySchema, "patch")
312
+ });
313
+ assert.equal(typeof resolved.input.body, "function");
314
+ assert.deepEqual(resolved.input.body({
315
+ name: " Ada "
316
+ }), {
317
+ name: "Ada"
345
318
  });
346
- assert.equal(resolved.input.body, normalizeBody);
347
319
  assert.deepEqual(resolved.middleware, ["api"]);
348
320
  });
349
321
 
350
- test("resolveRouteValidatorOptions ignores validator wrapper", () => {
351
- const resolved = resolveRouteValidatorOptions({
352
- method: "POST",
353
- path: "/contacts",
354
- options: {
355
- validator: defineRouteValidator({}),
356
- middleware: ["api"]
357
- }
358
- });
322
+ test("resolveRouteValidatorOptions rejects schema/input definitions", () => {
323
+ assert.throws(
324
+ () => resolveRouteValidatorOptions({
325
+ method: "POST",
326
+ path: "/contacts",
327
+ options: {
328
+ schema: {
329
+ body: {}
330
+ },
331
+ input: {
332
+ body: () => ({})
333
+ },
334
+ middleware: ["api"]
335
+ }
336
+ }),
337
+ /uses unsupported validator options: schema, input/
338
+ );
339
+ });
359
340
 
360
- assert.equal(Object.prototype.hasOwnProperty.call(resolved, "validator"), false);
361
- assert.deepEqual(resolved.middleware, ["api"]);
341
+ test("resolveRouteValidatorOptions rejects validator wrapper", () => {
342
+ assert.throws(
343
+ () => resolveRouteValidatorOptions({
344
+ method: "POST",
345
+ path: "/contacts",
346
+ options: {
347
+ validator: defineRouteValidator({}),
348
+ middleware: ["api"]
349
+ }
350
+ }),
351
+ /uses unsupported validator options: validator/
352
+ );
362
353
  });
363
354
 
364
- test("defineRouteValidator rejects unsupported advanced.jskitInput keys", () => {
355
+ test("defineRouteValidator rejects unsupported advanced.jskitInput", () => {
365
356
  assert.throws(
366
357
  () =>
367
358
  defineRouteValidator({
368
359
  advanced: {
369
- jskitInput: {
370
- headers: () => ({})
360
+ jskitInput: {}
361
+ }
362
+ }),
363
+ /advanced\.jskitInput is not supported/
364
+ );
365
+ });
366
+
367
+ test("defineRouteValidator rejects unsupported top-level keys generically", () => {
368
+ assert.throws(
369
+ () =>
370
+ defineRouteValidator({
371
+ unsupportedContract: {
372
+ schema: {
373
+ type: "object"
371
374
  }
372
375
  }
373
376
  }),
374
- /advanced\.jskitInput\.headers is not supported/
377
+ /defineRouteValidator\(\)\.unsupportedContract is not supported/
375
378
  );
376
379
  });
377
380
 
@@ -397,56 +400,55 @@ test("defineRouteValidator validates meta fields", () => {
397
400
  );
398
401
  });
399
402
 
400
- test("HttpRouter.register ignores validator wrapper options", () => {
403
+ test("HttpRouter.register rejects validator wrapper options", () => {
401
404
  const router = createRouter();
402
- router.register(
403
- "POST",
404
- "/contacts",
405
- {
406
- validator: defineRouteValidator({})
407
- },
408
- async () => {}
405
+ assert.throws(
406
+ () => router.register(
407
+ "POST",
408
+ "/contacts",
409
+ {
410
+ validator: defineRouteValidator({})
411
+ },
412
+ async () => {}
413
+ ),
414
+ /uses unsupported validator options: validator/
409
415
  );
410
-
411
- const [route] = router.list();
412
- assert.equal(route.schema, undefined);
413
- assert.equal(route.input, null);
414
416
  });
415
417
 
416
- test("HttpRouter.register ignores compiled legacy-style route options", () => {
418
+ test("HttpRouter.register rejects compiled route option payloads", () => {
417
419
  const router = createRouter();
418
- const querySchema = {
419
- type: "object"
420
- };
421
- const normalizeQuery = (query) => ({
422
- dryRun: query?.dryRun === true
420
+ const querySchema = createSchema({
421
+ dryRun: {
422
+ type: "boolean",
423
+ required: false,
424
+ strictBoolean: true
425
+ }
423
426
  });
424
427
 
425
428
  const validator = defineRouteValidator({
426
- queryValidator: {
427
- schema: querySchema,
428
- normalize: normalizeQuery
429
+ query: {
430
+ schema: querySchema
429
431
  }
430
432
  });
431
433
 
432
- router.get(
433
- "/contacts",
434
- validator.toRouteOptions(),
435
- async () => {}
434
+ assert.throws(
435
+ () => router.get(
436
+ "/contacts",
437
+ validator.toRouteOptions(),
438
+ async () => {}
439
+ ),
440
+ /uses unsupported validator options: schema, input/
436
441
  );
437
-
438
- const [route] = router.list();
439
- assert.equal(route.schema, undefined);
440
- assert.equal(route.input, null);
441
442
  });
442
443
 
443
444
  test("HttpRouter.register accepts inline validator shape directly", () => {
444
445
  const router = createRouter();
445
- const querySchema = {
446
- type: "object"
447
- };
448
- const normalizeQuery = (query) => ({
449
- dryRun: query?.dryRun === true
446
+ const querySchema = createSchema({
447
+ dryRun: {
448
+ type: "boolean",
449
+ required: false,
450
+ strictBoolean: true
451
+ }
450
452
  });
451
453
 
452
454
  router.get(
@@ -456,9 +458,8 @@ test("HttpRouter.register accepts inline validator shape directly", () => {
456
458
  tags: ["contacts"],
457
459
  summary: "List contacts"
458
460
  },
459
- queryValidator: {
460
- schema: querySchema,
461
- normalize: normalizeQuery
461
+ query: {
462
+ schema: querySchema
462
463
  }
463
464
  },
464
465
  async () => {}
@@ -468,7 +469,113 @@ test("HttpRouter.register accepts inline validator shape directly", () => {
468
469
  assert.deepEqual(route.schema, {
469
470
  tags: ["contacts"],
470
471
  summary: "List contacts",
471
- querystring: querySchema
472
+ querystring: toFastifySchema(querySchema, "patch")
473
+ });
474
+ assert.equal(typeof route.input.query, "function");
475
+ assert.deepEqual(route.input.query({
476
+ dryRun: true
477
+ }), {
478
+ dryRun: true
479
+ });
480
+ });
481
+
482
+ test("HttpRouter.register accepts explicit transport metadata and output transform", () => {
483
+ const router = createRouter();
484
+ const output = (payload) => ({
485
+ data: payload
486
+ });
487
+ const error = (currentError) => ({
488
+ errors: [{
489
+ title: currentError.message
490
+ }]
472
491
  });
473
- assert.equal(route.input.query, normalizeQuery);
492
+
493
+ router.get(
494
+ "/contacts",
495
+ {
496
+ transport: {
497
+ kind: "jsonapi-resource",
498
+ contentType: "application/vnd.api+json",
499
+ request: {
500
+ body(body) {
501
+ return body?.data?.attributes || {};
502
+ }
503
+ },
504
+ error
505
+ },
506
+ output
507
+ },
508
+ async () => {}
509
+ );
510
+
511
+ const [route] = router.list();
512
+ assert.equal(route.transport.kind, "jsonapi-resource");
513
+ assert.equal(route.transport.contentType, "application/vnd.api+json");
514
+ assert.equal(typeof route.transport.request.body, "function");
515
+ assert.equal(route.transport.error, error);
516
+ assert.equal(route.output, output);
517
+ });
518
+
519
+ test("HttpRouter.register rejects invalid transport definitions and output transforms", () => {
520
+ const router = createRouter();
521
+
522
+ assert.throws(
523
+ () => router.get(
524
+ "/contacts",
525
+ {
526
+ transport: {
527
+ kind: "weird"
528
+ }
529
+ },
530
+ async () => {}
531
+ ),
532
+ /transport\.kind must be one of: command, jsonapi-resource/
533
+ );
534
+
535
+ assert.throws(
536
+ () => router.get(
537
+ "/contacts",
538
+ {
539
+ output: {
540
+ mode: "unsupported"
541
+ }
542
+ },
543
+ async () => {}
544
+ ),
545
+ /output must be a function/
546
+ );
547
+ });
548
+
549
+ test("compileRouteValidator strips json-rest transport metadata before Fastify handoff", () => {
550
+ const nestedSchema = createSchema({
551
+ profile: {
552
+ type: "object",
553
+ required: false,
554
+ schema: createSchema({
555
+ email: {
556
+ type: "string",
557
+ required: false,
558
+ minLength: 3,
559
+ format: "email"
560
+ }
561
+ }),
562
+ additionalProperties: true
563
+ }
564
+ });
565
+
566
+ const compiled = compileRouteValidator({
567
+ body: {
568
+ schema: nestedSchema
569
+ },
570
+ responses: {
571
+ 200: {
572
+ schema: nestedSchema,
573
+ mode: "replace"
574
+ }
575
+ }
576
+ });
577
+
578
+ assert.equal(JSON.stringify(compiled.schema).includes("x-json-rest-schema"), false);
579
+ assert.deepEqual(compiled.schema.body, toFastifySchema(nestedSchema, "patch"));
580
+ assert.deepEqual(compiled.schema.response[200], toFastifySchema(nestedSchema, "replace"));
474
581
  });