@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,28 +1,137 @@
1
- import { normalizeArray, normalizeObject } from "../../../shared/support/normalize.js";
1
+ import { normalizeArray, normalizeObject, normalizeText } from "../../../shared/support/normalize.js";
2
+ import { AppError } from "../../runtime/errors.js";
2
3
  import { defaultApplyRoutePolicy } from "../../support/routePolicyConfig.js";
3
4
  import { resolveDefaultSurfaceId } from "../../support/appConfig.js";
4
5
  import { defaultMissingHandler } from "../../support/defaultMissingHandler.js";
6
+ import { registerJsonApiContentTypeParser } from "../../runtime/fastifyBootstrap.js";
5
7
  import { RouteRegistrationError } from "./errors.js";
6
8
  import { executeMiddlewareStack, normalizeRuntimeMiddlewareConfig, resolveRouteMiddlewareHandlers } from "./middlewareRuntime.js";
7
9
  import { attachRequestScope } from "./requestScope.js";
8
10
  import { attachRequestActionExecutor } from "./requestActionExecutor.js";
11
+ import { normalizeRouteOutputTransform, normalizeRouteTransport } from "./routeTransport.js";
9
12
 
10
13
  const { structuredClone: cloneRouteSchema } = globalThis;
14
+ const UNSAFE_BODY_METHODS = Object.freeze(["POST", "PUT", "PATCH"]);
15
+ const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
11
16
 
12
17
  function toFastifyRouteOptions(route) {
13
18
  const sourceRoute = normalizeObject(route);
14
19
  const schema = cloneRouteSchema(sourceRoute.schema);
20
+ const existingConfig = normalizeObject(sourceRoute.config);
21
+ const transportKind = normalizeText(sourceRoute?.transport?.kind).toLowerCase();
22
+ const existingTransportConfig =
23
+ existingConfig.transport && typeof existingConfig.transport === "object" && !Array.isArray(existingConfig.transport)
24
+ ? normalizeObject(existingConfig.transport)
25
+ : {};
15
26
  return {
16
27
  method: sourceRoute.method,
17
28
  url: sourceRoute.path,
18
29
  ...(schema ? { schema } : {}),
19
30
  ...(sourceRoute.bodyLimit ? { bodyLimit: sourceRoute.bodyLimit } : {}),
20
31
  config: {
21
- ...(sourceRoute.config || {})
32
+ ...existingConfig,
33
+ ...(transportKind
34
+ ? {
35
+ transport: {
36
+ ...existingTransportConfig,
37
+ kind: transportKind,
38
+ runtime: sourceRoute.transport,
39
+ ...(normalizeText(sourceRoute?.transport?.contentType)
40
+ ? {
41
+ contentType: normalizeText(sourceRoute.transport.contentType)
42
+ }
43
+ : {})
44
+ }
45
+ }
46
+ : {})
22
47
  }
23
48
  };
24
49
  }
25
50
 
51
+ function normalizeHeaderValue(value) {
52
+ if (Array.isArray(value)) {
53
+ return String(value[0] || "").trim();
54
+ }
55
+ return String(value || "").trim();
56
+ }
57
+
58
+ function normalizeMediaType(value = "") {
59
+ return normalizeHeaderValue(value)
60
+ .split(";")[0]
61
+ .trim()
62
+ .toLowerCase();
63
+ }
64
+
65
+ function routeDefinesRequestBody(route = null) {
66
+ if (!route || typeof route !== "object") {
67
+ return false;
68
+ }
69
+
70
+ return route.body != null || route.schema?.body != null;
71
+ }
72
+
73
+ function shouldEnforceRequestContentType(method = "", transport = null, route = null) {
74
+ return (
75
+ UNSAFE_BODY_METHODS.includes(String(method || "").toUpperCase()) &&
76
+ normalizeText(transport?.contentType).length > 0 &&
77
+ routeDefinesRequestBody(route)
78
+ );
79
+ }
80
+
81
+ function routeRequiresJsonApiContentTypeParser(route = null) {
82
+ return routeDefinesRequestBody(route) && normalizeMediaType(route?.transport?.contentType) === JSON_API_CONTENT_TYPE;
83
+ }
84
+
85
+ function enforceRequestContentType({ request = null, route = null, transport = null } = {}) {
86
+ if (!shouldEnforceRequestContentType(route?.method, transport, route)) {
87
+ return;
88
+ }
89
+
90
+ const expectedContentType = normalizeMediaType(transport?.contentType);
91
+ const actualContentType = normalizeMediaType(request?.headers?.["content-type"]);
92
+ if (actualContentType === expectedContentType) {
93
+ return;
94
+ }
95
+
96
+ throw new AppError(415, `Content-Type must be ${transport.contentType}.`, {
97
+ code: "unsupported_media_type"
98
+ });
99
+ }
100
+
101
+ function attachRouteTransport(request, transport = null) {
102
+ if (!request || !transport) {
103
+ return;
104
+ }
105
+
106
+ Object.defineProperty(request, "routeTransport", {
107
+ value: transport,
108
+ enumerable: false,
109
+ configurable: true,
110
+ writable: false
111
+ });
112
+ }
113
+
114
+ function replyHasHeader(reply, name = "") {
115
+ const normalizedName = String(name || "").trim().toLowerCase();
116
+ if (!normalizedName || !reply) {
117
+ return false;
118
+ }
119
+
120
+ if (typeof reply.hasHeader === "function") {
121
+ return reply.hasHeader(normalizedName);
122
+ }
123
+
124
+ if (typeof reply.getHeader === "function") {
125
+ return reply.getHeader(normalizedName) != null;
126
+ }
127
+
128
+ if (reply.headers && typeof reply.headers === "object") {
129
+ return Object.keys(reply.headers).some((key) => String(key || "").trim().toLowerCase() === normalizedName);
130
+ }
131
+
132
+ return false;
133
+ }
134
+
26
135
  function normalizeRouteInputTransforms(route) {
27
136
  const routeInput = route?.input;
28
137
  if (routeInput == null) {
@@ -37,7 +146,7 @@ function normalizeRouteInputTransforms(route) {
37
146
 
38
147
  const normalized = {};
39
148
  for (const key of ["body", "query", "params"]) {
40
- if (!Object.prototype.hasOwnProperty.call(routeInput, key)) {
149
+ if (!Object.hasOwn(routeInput, key)) {
41
150
  continue;
42
151
  }
43
152
 
@@ -58,12 +167,34 @@ function normalizeRouteInputTransforms(route) {
58
167
  return Object.freeze(normalized);
59
168
  }
60
169
 
61
- function buildRequestInput({ request = null, inputTransforms = null } = {}) {
170
+ async function buildRequestInput({ request = null, inputTransforms = null, transportInputTransforms = null } = {}) {
62
171
  const transforms = inputTransforms && typeof inputTransforms === "object" ? inputTransforms : {};
172
+ const transportTransforms =
173
+ transportInputTransforms && typeof transportInputTransforms === "object" ? transportInputTransforms : {};
63
174
 
64
- const body = typeof transforms.body === "function" ? transforms.body(request?.body, request) : request?.body;
65
- const query = typeof transforms.query === "function" ? transforms.query(request?.query, request) : request?.query;
66
- const params = typeof transforms.params === "function" ? transforms.params(request?.params, request) : request?.params;
175
+ let body = request?.body;
176
+ if (body !== undefined && typeof transportTransforms.body === "function") {
177
+ body = await transportTransforms.body(body, request);
178
+ }
179
+ if (typeof transforms.body === "function") {
180
+ body = await transforms.body(body, request);
181
+ }
182
+
183
+ let query = request?.query;
184
+ if (typeof transportTransforms.query === "function") {
185
+ query = await transportTransforms.query(query, request);
186
+ }
187
+ if (typeof transforms.query === "function") {
188
+ query = await transforms.query(query, request);
189
+ }
190
+
191
+ let params = request?.params;
192
+ if (typeof transportTransforms.params === "function") {
193
+ params = await transportTransforms.params(params, request);
194
+ }
195
+ if (typeof transforms.params === "function") {
196
+ params = await transforms.params(params, request);
197
+ }
67
198
 
68
199
  return Object.freeze({
69
200
  body,
@@ -72,6 +203,62 @@ function buildRequestInput({ request = null, inputTransforms = null } = {}) {
72
203
  });
73
204
  }
74
205
 
206
+ function wrapReplySend({ reply = null, request = null, route = null, outputTransform = null, transport = null } = {}) {
207
+ if (!reply || typeof reply.send !== "function") {
208
+ return;
209
+ }
210
+
211
+ const originalSend = reply.send.bind(reply);
212
+ reply.send = function transformedSend(payload) {
213
+ let nextPayload = payload;
214
+ if (typeof transport?.response === "function") {
215
+ const transportedPayload = transport.response(payload, {
216
+ request,
217
+ reply,
218
+ route,
219
+ transport,
220
+ statusCode: Number(reply?.statusCode || 200)
221
+ });
222
+
223
+ if (transportedPayload && typeof transportedPayload.then === "function") {
224
+ throw new RouteRegistrationError(
225
+ `Route ${String(route?.method || "<unknown>")} ${String(route?.path || "<unknown>")} transport.response must return synchronously.`
226
+ );
227
+ }
228
+
229
+ nextPayload = transportedPayload === undefined ? nextPayload : transportedPayload;
230
+ }
231
+
232
+ if (typeof outputTransform === "function") {
233
+ const transformedPayload = outputTransform(nextPayload, {
234
+ request,
235
+ reply,
236
+ route,
237
+ transport,
238
+ statusCode: Number(reply?.statusCode || 200)
239
+ });
240
+
241
+ if (transformedPayload && typeof transformedPayload.then === "function") {
242
+ throw new RouteRegistrationError(
243
+ `Route ${String(route?.method || "<unknown>")} ${String(route?.path || "<unknown>")} output transform must return synchronously.`
244
+ );
245
+ }
246
+
247
+ nextPayload = transformedPayload === undefined ? nextPayload : transformedPayload;
248
+ }
249
+
250
+ if (
251
+ normalizeText(transport?.contentType) &&
252
+ Number(reply?.statusCode || 200) !== 204 &&
253
+ !replyHasHeader(reply, "content-type")
254
+ ) {
255
+ reply.header("Content-Type", transport.contentType);
256
+ }
257
+
258
+ return originalSend(nextPayload);
259
+ };
260
+ }
261
+
75
262
  function registerRoutes(
76
263
  fastify,
77
264
  {
@@ -99,12 +286,29 @@ function registerRoutes(
99
286
  const fallbackHandler = typeof missingHandler === "function" ? missingHandler : defaultMissingHandler;
100
287
  const runtimeMiddlewareConfig = normalizeRuntimeMiddlewareConfig(middleware);
101
288
 
289
+ if (normalizedRoutes.some((route) => routeRequiresJsonApiContentTypeParser(route))) {
290
+ registerJsonApiContentTypeParser(fastify);
291
+ }
292
+
102
293
  for (const route of normalizedRoutes) {
103
- const baseOptions = toFastifyRouteOptions(route);
104
- const routeOptions = policyApplier(baseOptions, route);
105
- const routeHandler = typeof route?.handler === "function" ? route.handler : fallbackHandler;
106
- const resolvedMiddlewareHandlers = resolveRouteMiddlewareHandlers(route, runtimeMiddlewareConfig);
107
- const routeInputTransforms = normalizeRouteInputTransforms(route);
294
+ const routeTransport = normalizeRouteTransport(route?.transport, {
295
+ context: `Route ${String(route?.method || "<unknown>")} ${String(route?.path || "<unknown>")} transport`,
296
+ ErrorType: RouteRegistrationError
297
+ });
298
+ const routeOutputTransform = normalizeRouteOutputTransform(route?.output, {
299
+ context: `Route ${String(route?.method || "<unknown>")} ${String(route?.path || "<unknown>")} output`,
300
+ ErrorType: RouteRegistrationError
301
+ });
302
+ const normalizedRoute = {
303
+ ...route,
304
+ transport: routeTransport,
305
+ output: routeOutputTransform
306
+ };
307
+ const baseOptions = toFastifyRouteOptions(normalizedRoute);
308
+ const routeOptions = policyApplier(baseOptions, normalizedRoute);
309
+ const routeHandler = typeof normalizedRoute?.handler === "function" ? normalizedRoute.handler : fallbackHandler;
310
+ const resolvedMiddlewareHandlers = resolveRouteMiddlewareHandlers(normalizedRoute, runtimeMiddlewareConfig);
311
+ const routeInputTransforms = normalizeRouteInputTransforms(normalizedRoute);
108
312
  const routeActionDefaultSurface = resolveDefaultSurfaceId(null, {
109
313
  defaultSurfaceId: route?.surface || routeOptions?.config?.surface || requestActionDefaultSurface
110
314
  });
@@ -141,13 +345,30 @@ function registerRoutes(
141
345
  defaultSurfaceId: routeActionDefaultSurface
142
346
  });
143
347
 
144
- if (routeInputTransforms) {
145
- request.input = buildRequestInput({
348
+ attachRouteTransport(request, routeTransport);
349
+ enforceRequestContentType({
350
+ request,
351
+ route: normalizedRoute,
352
+ transport: routeTransport
353
+ });
354
+
355
+ const transportInputTransforms = routeTransport?.request || null;
356
+ if (routeInputTransforms || transportInputTransforms) {
357
+ request.input = await buildRequestInput({
146
358
  request,
147
- inputTransforms: routeInputTransforms
359
+ inputTransforms: routeInputTransforms,
360
+ transportInputTransforms
148
361
  });
149
362
  }
150
363
 
364
+ wrapReplySend({
365
+ reply,
366
+ request,
367
+ route: normalizedRoute,
368
+ outputTransform: routeOutputTransform,
369
+ transport: routeTransport
370
+ });
371
+
151
372
  await executeMiddlewareStack(resolvedMiddlewareHandlers, request, reply);
152
373
  if (reply?.sent) {
153
374
  return;
@@ -0,0 +1,126 @@
1
+ import { normalizeObject, normalizeText } from "../../../shared/support/normalize.js";
2
+
3
+ const ROUTE_TRANSPORT_KINDS = Object.freeze([
4
+ "command",
5
+ "jsonapi-resource"
6
+ ]);
7
+
8
+ function normalizeRouteOutputTransform(value, { context = "route output", ErrorType = Error } = {}) {
9
+ if (value == null) {
10
+ return null;
11
+ }
12
+
13
+ if (typeof value !== "function") {
14
+ throw new ErrorType(`${context} must be a function.`);
15
+ }
16
+
17
+ return value;
18
+ }
19
+
20
+ function normalizeRouteTransport(value, { context = "route transport", ErrorType = Error } = {}) {
21
+ if (value == null) {
22
+ return null;
23
+ }
24
+
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ throw new ErrorType(`${context} must be an object.`);
27
+ }
28
+
29
+ const source = normalizeObject(value);
30
+ const unsupportedKeys = Object.keys(source).filter(
31
+ (key) => !["kind", "request", "response", "error", "contentType"].includes(key)
32
+ );
33
+ if (unsupportedKeys.length > 0) {
34
+ throw new ErrorType(`${context}.${unsupportedKeys[0]} is not supported.`);
35
+ }
36
+
37
+ const normalized = {};
38
+
39
+ if (Object.hasOwn(source, "kind")) {
40
+ const kind = normalizeText(source.kind).toLowerCase();
41
+ if (!kind) {
42
+ throw new ErrorType(`${context}.kind must be a non-empty string.`);
43
+ }
44
+ if (!ROUTE_TRANSPORT_KINDS.includes(kind)) {
45
+ throw new ErrorType(
46
+ `${context}.kind must be one of: ${ROUTE_TRANSPORT_KINDS.join(", ")}.`
47
+ );
48
+ }
49
+ normalized.kind = kind;
50
+ }
51
+
52
+ if (Object.hasOwn(source, "request")) {
53
+ const request = source.request;
54
+ if (request != null) {
55
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
56
+ throw new ErrorType(`${context}.request must be an object.`);
57
+ }
58
+
59
+ const requestSource = normalizeObject(request);
60
+ const unsupportedRequestKeys = Object.keys(requestSource).filter(
61
+ (key) => !["body", "query", "params"].includes(key)
62
+ );
63
+ if (unsupportedRequestKeys.length > 0) {
64
+ throw new ErrorType(`${context}.request.${unsupportedRequestKeys[0]} is not supported.`);
65
+ }
66
+
67
+ const normalizedRequest = {};
68
+ for (const key of ["body", "query", "params"]) {
69
+ if (!Object.hasOwn(requestSource, key)) {
70
+ continue;
71
+ }
72
+
73
+ const transform = requestSource[key];
74
+ if (transform == null) {
75
+ continue;
76
+ }
77
+
78
+ if (typeof transform !== "function") {
79
+ throw new ErrorType(`${context}.request.${key} must be a function.`);
80
+ }
81
+
82
+ normalizedRequest[key] = transform;
83
+ }
84
+
85
+ if (Object.keys(normalizedRequest).length > 0) {
86
+ normalized.request = Object.freeze(normalizedRequest);
87
+ }
88
+ }
89
+ }
90
+
91
+ if (Object.hasOwn(source, "response")) {
92
+ const responseTransform = source.response;
93
+ if (responseTransform != null && typeof responseTransform !== "function") {
94
+ throw new ErrorType(`${context}.response must be a function.`);
95
+ }
96
+ if (responseTransform) {
97
+ normalized.response = responseTransform;
98
+ }
99
+ }
100
+
101
+ if (Object.hasOwn(source, "error")) {
102
+ const errorTransform = source.error;
103
+ if (errorTransform != null && typeof errorTransform !== "function") {
104
+ throw new ErrorType(`${context}.error must be a function.`);
105
+ }
106
+ if (errorTransform) {
107
+ normalized.error = errorTransform;
108
+ }
109
+ }
110
+
111
+ if (Object.hasOwn(source, "contentType")) {
112
+ const contentType = normalizeText(source.contentType);
113
+ if (!contentType) {
114
+ throw new ErrorType(`${context}.contentType must be a non-empty string.`);
115
+ }
116
+ normalized.contentType = contentType;
117
+ }
118
+
119
+ return Object.freeze(normalized);
120
+ }
121
+
122
+ export {
123
+ ROUTE_TRANSPORT_KINDS,
124
+ normalizeRouteOutputTransform,
125
+ normalizeRouteTransport
126
+ };