@jskit-ai/kernel 0.1.54 → 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
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "typebox": "^1.0.81"
6
+ "json-rest-schema": "1.x.x"
7
7
  },
8
8
  "scripts": {
9
9
  "test": "node --test"
@@ -40,6 +40,7 @@
40
40
  "./shared/support/normalize": "./shared/support/normalize.js",
41
41
  "./shared/support/permissions": "./shared/support/permissions.js",
42
42
  "./shared/support/crudListFilters": "./shared/support/crudListFilters.js",
43
+ "./shared/support/crudFieldContract": "./shared/support/crudFieldContract.js",
43
44
  "./shared/support/crudLookup": "./shared/support/crudLookup.js",
44
45
  "./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
45
46
  "./shared/support/listenerSet": "./shared/support/listenerSet.js",
@@ -1,9 +1,9 @@
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 {
5
- EMPTY_INPUT_VALIDATOR,
6
- OBJECT_INPUT_VALIDATOR
6
+ emptyInputValidator
7
7
  } from "../../shared/actions/actionContributorHelpers.js";
8
8
  import {
9
9
  ActionRuntimeServiceProvider,
@@ -109,7 +109,15 @@ test("ActionRuntimeServiceProvider materializes dependencies and surfaces for ap
109
109
  dependencies: {
110
110
  echoService: "test.echo.service"
111
111
  },
112
- inputValidator: { schema: OBJECT_INPUT_VALIDATOR },
112
+ input: {
113
+ schema: createSchema({
114
+ value: {
115
+ type: "string",
116
+ required: false
117
+ }
118
+ }),
119
+ mode: "patch"
120
+ },
113
121
  idempotency: "none",
114
122
  audit: { actionName: "test.echo" },
115
123
  observability: {},
@@ -154,7 +162,7 @@ test("ActionRuntimeServiceProvider registers SurfaceRuntime from appConfig when
154
162
  kind: "query",
155
163
  channels: ["internal"],
156
164
  surfacesFrom: "enabled",
157
- inputValidator: EMPTY_INPUT_VALIDATOR,
165
+ input: emptyInputValidator,
158
166
  idempotency: "none",
159
167
  audit: { actionName: "test.surfaces.from.appconfig" },
160
168
  observability: {},
@@ -190,7 +198,7 @@ test("ActionRuntimeServiceProvider materializes custom surfacesFrom aliases regi
190
198
  kind: "query",
191
199
  channels: ["internal"],
192
200
  surfacesFrom: "workspace",
193
- inputValidator: EMPTY_INPUT_VALIDATOR,
201
+ input: emptyInputValidator,
194
202
  idempotency: "none",
195
203
  audit: { actionName: "test.workspace.alias" },
196
204
  observability: {},
@@ -227,7 +235,7 @@ test("ActionRuntimeServiceProvider does not infer service method bindings from a
227
235
  dependencies: {
228
236
  customerService: "test.customer.service"
229
237
  },
230
- inputValidator: EMPTY_INPUT_VALIDATOR,
238
+ input: emptyInputValidator,
231
239
  idempotency: "optional",
232
240
  audit: { actionName: "test.customer.create" },
233
241
  observability: {},
@@ -260,7 +268,7 @@ test("app.actions + resolveActionContributors provide canonical contributor wiri
260
268
  kind: "query",
261
269
  channels: ["internal"],
262
270
  surfaces: ["app"],
263
- inputValidator: EMPTY_INPUT_VALIDATOR,
271
+ input: emptyInputValidator,
264
272
  idempotency: "none",
265
273
  audit: { actionName: "alpha.one" },
266
274
  observability: {},
@@ -277,7 +285,7 @@ test("app.actions + resolveActionContributors provide canonical contributor wiri
277
285
  kind: "query",
278
286
  channels: ["internal"],
279
287
  surfaces: ["app"],
280
- inputValidator: EMPTY_INPUT_VALIDATOR,
288
+ input: emptyInputValidator,
281
289
  idempotency: "none",
282
290
  audit: { actionName: "beta.one" },
283
291
  observability: {},
@@ -320,7 +328,7 @@ test("action runtime execute merges static and per-execution dependencies", asyn
320
328
  dependencies: {
321
329
  staticService: "test.static.service"
322
330
  },
323
- inputValidator: EMPTY_INPUT_VALIDATOR,
331
+ input: emptyInputValidator,
324
332
  idempotency: "none",
325
333
  audit: { actionName: "test.deps.merge" },
326
334
  observability: {},
@@ -364,7 +372,7 @@ test("app.actions accepts custom action domains", () => {
364
372
  kind: "query",
365
373
  channels: ["internal"],
366
374
  surfaces: ["app"],
367
- inputValidator: EMPTY_INPUT_VALIDATOR,
375
+ input: emptyInputValidator,
368
376
  idempotency: "none",
369
377
  audit: { actionName: "custom.domain.check" },
370
378
  observability: {},
@@ -391,7 +399,7 @@ test("app.action registers a single action with default contributor id", () => {
391
399
  kind: "query",
392
400
  channels: ["internal"],
393
401
  surfaces: ["app"],
394
- inputValidator: EMPTY_INPUT_VALIDATOR,
402
+ input: emptyInputValidator,
395
403
  idempotency: "none",
396
404
  audit: { actionName: "test.single" },
397
405
  observability: {},
@@ -415,7 +423,7 @@ test("app.actions requires an array", () => {
415
423
  assert.throws(() => app.actions({}), /requires an array/);
416
424
  });
417
425
 
418
- test("EMPTY_INPUT_VALIDATOR allows empty input and rejects unexpected fields", async () => {
426
+ test("emptyInputValidator allows empty input and rejects unexpected fields", async () => {
419
427
  const app = createSingletonApp();
420
428
  const provider = new ActionRuntimeServiceProvider();
421
429
  provider.register(app);
@@ -434,7 +442,7 @@ test("EMPTY_INPUT_VALIDATOR allows empty input and rejects unexpected fields", a
434
442
  kind: "query",
435
443
  channels: ["internal"],
436
444
  surfaces: ["app"],
437
- inputValidator: EMPTY_INPUT_VALIDATOR,
445
+ input: emptyInputValidator,
438
446
  idempotency: "none",
439
447
  audit: { actionName: "test.empty-input" },
440
448
  observability: {},
@@ -501,7 +509,7 @@ test("app.actions rejects invalid domain identifiers", () => {
501
509
  kind: "query",
502
510
  channels: ["internal"],
503
511
  surfaces: ["app"],
504
- inputValidator: EMPTY_INPUT_VALIDATOR,
512
+ input: emptyInputValidator,
505
513
  idempotency: "none",
506
514
  audit: { actionName: "invalid.domain" },
507
515
  observability: {},
@@ -533,7 +541,7 @@ test("app.actions rejects unsupported surfacesFrom aliases", () => {
533
541
  kind: "query",
534
542
  channels: ["internal"],
535
543
  surfacesFrom: "workspace",
536
- inputValidator: EMPTY_INPUT_VALIDATOR,
544
+ input: emptyInputValidator,
537
545
  idempotency: "none",
538
546
  audit: { actionName: "workspace.alias.invalid" },
539
547
  observability: {},
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
3
4
  import { registerActionContextContributor } from "../../actions/ActionRuntimeServiceProvider.js";
4
5
  import { createApplication } from "../../kernel/index.js";
5
6
  import { createRouter } from "./router.js";
@@ -12,12 +13,22 @@ function createFastifyStub() {
12
13
  routes,
13
14
  setErrorHandlerCalls: 0,
14
15
  errorHandler: null,
16
+ contentTypeParsers: new Map(),
15
17
  route(definition) {
16
18
  routes.push(definition);
17
19
  },
18
20
  setErrorHandler(handler) {
19
21
  this.errorHandler = handler;
20
22
  this.setErrorHandlerCalls += 1;
23
+ },
24
+ hasContentTypeParser(contentType) {
25
+ return this.contentTypeParsers.has(String(contentType || "").trim().toLowerCase());
26
+ },
27
+ addContentTypeParser(contentType, options, parser) {
28
+ this.contentTypeParsers.set(String(contentType || "").trim().toLowerCase(), {
29
+ options,
30
+ parser
31
+ });
21
32
  }
22
33
  };
23
34
  }
@@ -508,6 +519,38 @@ test("registerHttpRuntime installs API error handling once by default", () => {
508
519
 
509
520
  assert.equal(fastify.setErrorHandlerCalls, 1);
510
521
  assert.equal(typeof fastify.errorHandler, "function");
522
+ assert.equal(fastify.contentTypeParsers.size, 0);
523
+ });
524
+
525
+ test("registerHttpRuntime installs the JSON:API parser only when a registered route accepts JSON:API request bodies", () => {
526
+ const app = createApplication();
527
+ const fastify = createFastifyStub();
528
+ const router = createRouter();
529
+
530
+ router.post(
531
+ "/jsonapi-body",
532
+ {
533
+ body: {
534
+ schema: createSchema({})
535
+ },
536
+ transport: {
537
+ kind: "jsonapi-resource",
538
+ contentType: "application/vnd.api+json"
539
+ }
540
+ },
541
+ async (_request, reply) => {
542
+ reply.code(204).send();
543
+ }
544
+ );
545
+
546
+ app.instance("jskit.fastify", fastify);
547
+ app.instance("jskit.http.router", router);
548
+
549
+ registerHttpRuntime(app);
550
+ registerHttpRuntime(app);
551
+
552
+ assert.equal(fastify.contentTypeParsers.has("application/vnd.api+json"), true);
553
+ assert.equal(fastify.contentTypeParsers.size, 1);
511
554
  });
512
555
 
513
556
  test("registerHttpRuntime can disable automatic API error handling", () => {
@@ -717,6 +760,109 @@ test("registerRoutes attaches request.input when route input transforms are conf
717
760
  assert.equal(reply.statusCode, 200);
718
761
  });
719
762
 
763
+ test("registerRoutes applies transport request transforms before route input normalization", async () => {
764
+ const fastify = createFastifyStub();
765
+
766
+ registerRoutes(fastify, {
767
+ routes: [
768
+ {
769
+ method: "POST",
770
+ path: "/transport-input",
771
+ transport: {
772
+ kind: "jsonapi-resource",
773
+ request: {
774
+ body(body) {
775
+ return body?.data?.attributes || {};
776
+ }
777
+ }
778
+ },
779
+ input: {
780
+ body: (body) => ({
781
+ name: String(body?.name || "").trim()
782
+ })
783
+ },
784
+ handler: async (request, reply) => {
785
+ assert.equal(request.routeOptions.config.transport.kind, "jsonapi-resource");
786
+ assert.deepEqual(request.input, {
787
+ body: {
788
+ name: "Alice"
789
+ },
790
+ query: undefined,
791
+ params: undefined
792
+ });
793
+ reply.code(200).send({ ok: true });
794
+ }
795
+ }
796
+ ]
797
+ });
798
+
799
+ const request = {
800
+ body: {
801
+ data: {
802
+ type: "contacts",
803
+ attributes: {
804
+ name: " Alice "
805
+ }
806
+ }
807
+ }
808
+ };
809
+ const reply = createReplyStub();
810
+
811
+ await fastify.routes[0].handler(request, reply);
812
+
813
+ assert.equal(reply.statusCode, 200);
814
+ });
815
+
816
+ test("registerRoutes does not invoke transport body transforms for bodyless requests", async () => {
817
+ const fastify = createFastifyStub();
818
+ let bodyTransformCalls = 0;
819
+
820
+ registerRoutes(fastify, {
821
+ routes: [
822
+ {
823
+ method: "GET",
824
+ path: "/transport-input-no-body",
825
+ transport: {
826
+ kind: "jsonapi-resource",
827
+ request: {
828
+ body() {
829
+ bodyTransformCalls += 1;
830
+ throw new Error("transport body transform should not run");
831
+ }
832
+ }
833
+ },
834
+ input: {
835
+ query: (query) => ({
836
+ page: Number(query?.page || 1)
837
+ })
838
+ },
839
+ handler: async (request, reply) => {
840
+ assert.deepEqual(request.input, {
841
+ body: undefined,
842
+ query: {
843
+ page: 2
844
+ },
845
+ params: undefined
846
+ });
847
+ reply.code(200).send({ ok: true });
848
+ }
849
+ }
850
+ ]
851
+ });
852
+
853
+ const request = {
854
+ query: {
855
+ page: "2"
856
+ }
857
+ };
858
+ const reply = createReplyStub();
859
+
860
+ await fastify.routes[0].handler(request, reply);
861
+
862
+ assert.equal(reply.statusCode, 200);
863
+ assert.equal(bodyTransformCalls, 0);
864
+ });
865
+
720
866
  test("registerRoutes leaves request.input undefined when route input is not configured", async () => {
721
867
  const fastify = createFastifyStub();
722
868
 
@@ -740,6 +886,214 @@ test("registerRoutes leaves request.input undefined when route input is not conf
740
886
  assert.equal(reply.statusCode, 200);
741
887
  });
742
888
 
889
+ test("registerRoutes applies output transform on reply.send without changing handler shape", async () => {
890
+ const fastify = createFastifyStub();
891
+
892
+ registerRoutes(fastify, {
893
+ routes: [
894
+ {
895
+ method: "GET",
896
+ path: "/transport-output",
897
+ transport: {
898
+ kind: "jsonapi-resource"
899
+ },
900
+ output(payload) {
901
+ return {
902
+ data: payload
903
+ };
904
+ },
905
+ handler: async (request, reply) => {
906
+ assert.equal(request.routeOptions.config.transport.kind, "jsonapi-resource");
907
+ reply.code(200).send({
908
+ id: "7",
909
+ name: "Alice"
910
+ });
911
+ }
912
+ }
913
+ ]
914
+ });
915
+
916
+ const request = {};
917
+ const reply = createReplyStub();
918
+
919
+ await fastify.routes[0].handler(request, reply);
920
+
921
+ assert.equal(reply.statusCode, 200);
922
+ assert.equal(reply.headers["Content-Type"], undefined);
923
+ assert.deepEqual(reply.payload, {
924
+ data: {
925
+ id: "7",
926
+ name: "Alice"
927
+ }
928
+ });
929
+ });
930
+
931
+ test("registerRoutes applies transport response transforms before reply serialization", async () => {
932
+ const fastify = createFastifyStub();
933
+
934
+ registerRoutes(fastify, {
935
+ routes: [
936
+ {
937
+ method: "GET",
938
+ path: "/transport-response",
939
+ transport: {
940
+ kind: "jsonapi-resource",
941
+ contentType: "application/vnd.api+json",
942
+ response(payload) {
943
+ return {
944
+ data: {
945
+ type: "contacts",
946
+ id: String(payload?.id || ""),
947
+ attributes: {
948
+ name: String(payload?.name || "")
949
+ }
950
+ }
951
+ };
952
+ }
953
+ },
954
+ handler: async (_request, reply) => {
955
+ reply.code(200).send({
956
+ id: 7,
957
+ name: "Alice"
958
+ });
959
+ }
960
+ }
961
+ ]
962
+ });
963
+
964
+ const request = {};
965
+ const reply = createReplyStub();
966
+
967
+ await fastify.routes[0].handler(request, reply);
968
+
969
+ assert.equal(reply.statusCode, 200);
970
+ assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
971
+ assert.deepEqual(reply.payload, {
972
+ data: {
973
+ type: "contacts",
974
+ id: "7",
975
+ attributes: {
976
+ name: "Alice"
977
+ }
978
+ }
979
+ });
980
+ });
981
+
982
+ test("registerRoutes exposes full transport runtime on Fastify route config for pre-handler error serialization", () => {
983
+ const fastify = createFastifyStub();
984
+ const transport = {
985
+ kind: "jsonapi-resource",
986
+ contentType: "application/vnd.api+json",
987
+ error() {
988
+ return {
989
+ errors: []
990
+ };
991
+ }
992
+ };
993
+
994
+ registerRoutes(fastify, {
995
+ routes: [
996
+ {
997
+ method: "GET",
998
+ path: "/transport-runtime",
999
+ transport,
1000
+ handler: async (_request, reply) => {
1001
+ reply.code(200).send({ ok: true });
1002
+ }
1003
+ }
1004
+ ]
1005
+ });
1006
+
1007
+ assert.equal(fastify.routes[0].config.transport.kind, "jsonapi-resource");
1008
+ assert.equal(fastify.routes[0].config.transport.contentType, "application/vnd.api+json");
1009
+ assert.deepEqual(fastify.routes[0].config.transport.runtime, transport);
1010
+ });
1011
+
1012
+ test("registerRoutes keeps transport response payload when output transform returns undefined", async () => {
1013
+ const fastify = createFastifyStub();
1014
+
1015
+ registerRoutes(fastify, {
1016
+ routes: [
1017
+ {
1018
+ method: "GET",
1019
+ path: "/transport-response-output-undefined",
1020
+ transport: {
1021
+ kind: "jsonapi-resource",
1022
+ contentType: "application/vnd.api+json",
1023
+ response(payload) {
1024
+ return {
1025
+ data: {
1026
+ type: "contacts",
1027
+ id: String(payload?.id || ""),
1028
+ attributes: {
1029
+ name: String(payload?.name || "")
1030
+ }
1031
+ }
1032
+ };
1033
+ }
1034
+ },
1035
+ output() {
1036
+ return undefined;
1037
+ },
1038
+ handler: async (_request, reply) => {
1039
+ reply.code(200).send({
1040
+ id: 7,
1041
+ name: "Alice"
1042
+ });
1043
+ }
1044
+ }
1045
+ ]
1046
+ });
1047
+
1048
+ const request = {};
1049
+ const reply = createReplyStub();
1050
+
1051
+ await fastify.routes[0].handler(request, reply);
1052
+
1053
+ assert.equal(reply.statusCode, 200);
1054
+ assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
1055
+ assert.deepEqual(reply.payload, {
1056
+ data: {
1057
+ type: "contacts",
1058
+ id: "7",
1059
+ attributes: {
1060
+ name: "Alice"
1061
+ }
1062
+ }
1063
+ });
1064
+ });
1065
+
1066
+ test("registerRoutes sets transport content type on successful replies", async () => {
1067
+ const fastify = createFastifyStub();
1068
+
1069
+ registerRoutes(fastify, {
1070
+ routes: [
1071
+ {
1072
+ method: "GET",
1073
+ path: "/transport-content-type",
1074
+ transport: {
1075
+ kind: "jsonapi-resource",
1076
+ contentType: "application/vnd.api+json"
1077
+ },
1078
+ handler: async (_request, reply) => {
1079
+ reply.code(200).send({
1080
+ data: {
1081
+ type: "contacts",
1082
+ id: "7"
1083
+ }
1084
+ });
1085
+ }
1086
+ }
1087
+ ]
1088
+ });
1089
+
1090
+ const reply = createReplyStub();
1091
+ await fastify.routes[0].handler({}, reply);
1092
+
1093
+ assert.equal(reply.statusCode, 200);
1094
+ assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
1095
+ });
1096
+
743
1097
  test("registerRoutes rejects invalid route input transform definitions", () => {
744
1098
  const fastify = createFastifyStub();
745
1099
 
@@ -761,6 +1115,99 @@ test("registerRoutes rejects invalid route input transform definitions", () => {
761
1115
  );
762
1116
  });
763
1117
 
1118
+ test("registerRoutes rejects invalid transport definitions", () => {
1119
+ const fastify = createFastifyStub();
1120
+
1121
+ assert.throws(
1122
+ () =>
1123
+ registerRoutes(fastify, {
1124
+ routes: [
1125
+ {
1126
+ method: "GET",
1127
+ path: "/invalid-transport",
1128
+ transport: {
1129
+ kind: "weird"
1130
+ },
1131
+ handler: async () => {}
1132
+ }
1133
+ ]
1134
+ }),
1135
+ /transport\.kind must be one of: command, jsonapi-resource/
1136
+ );
1137
+ });
1138
+
1139
+ test("registerRoutes enforces request content type for unsafe transport routes", async () => {
1140
+ const fastify = createFastifyStub();
1141
+
1142
+ registerRoutes(fastify, {
1143
+ routes: [
1144
+ {
1145
+ method: "POST",
1146
+ path: "/transport-content-type-enforced",
1147
+ body: {
1148
+ type: "object",
1149
+ additionalProperties: true
1150
+ },
1151
+ transport: {
1152
+ kind: "jsonapi-resource",
1153
+ contentType: "application/vnd.api+json"
1154
+ },
1155
+ handler: async (_request, reply) => {
1156
+ reply.code(200).send({ ok: true });
1157
+ }
1158
+ }
1159
+ ]
1160
+ });
1161
+
1162
+ await assert.rejects(
1163
+ () =>
1164
+ fastify.routes[0].handler(
1165
+ {
1166
+ headers: {
1167
+ "content-type": "application/json"
1168
+ }
1169
+ },
1170
+ createReplyStub()
1171
+ ),
1172
+ (error) => {
1173
+ assert.equal(error?.status, 415);
1174
+ assert.equal(error?.code, "unsupported_media_type");
1175
+ return true;
1176
+ }
1177
+ );
1178
+ });
1179
+
1180
+ test("registerRoutes does not enforce request content type for bodyless unsafe transport routes", async () => {
1181
+ const fastify = createFastifyStub();
1182
+
1183
+ registerRoutes(fastify, {
1184
+ routes: [
1185
+ {
1186
+ method: "POST",
1187
+ path: "/transport-content-type-bodyless",
1188
+ transport: {
1189
+ kind: "jsonapi-resource",
1190
+ contentType: "application/vnd.api+json"
1191
+ },
1192
+ handler: async (_request, reply) => {
1193
+ reply.code(204).send();
1194
+ }
1195
+ }
1196
+ ]
1197
+ });
1198
+
1199
+ const reply = createReplyStub();
1200
+ await fastify.routes[0].handler(
1201
+ {
1202
+ headers: {}
1203
+ },
1204
+ reply
1205
+ );
1206
+
1207
+ assert.equal(reply.statusCode, 204);
1208
+ assert.equal(reply.payload, undefined);
1209
+ });
1210
+
764
1211
  test("registerRoutes resolves middleware aliases and groups", async () => {
765
1212
  const fastify = createFastifyStub();
766
1213
  const observed = {