@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.
- package/package.json +3 -2
- package/server/actions/ActionRuntimeServiceProvider.test.js +23 -15
- package/server/http/lib/kernel.test.js +447 -0
- package/server/http/lib/routeRegistration.js +236 -15
- package/server/http/lib/routeTransport.js +126 -0
- package/server/http/lib/routeValidator.js +133 -198
- package/server/http/lib/routeValidator.test.js +385 -278
- package/server/http/lib/router.js +17 -2
- package/server/platform/providerRuntime.test.js +7 -7
- package/server/runtime/bootBootstrapRoutes.js +2 -18
- package/server/runtime/bootBootstrapRoutes.test.js +5 -14
- package/server/runtime/fastifyBootstrap.js +119 -0
- package/server/runtime/fastifyBootstrap.test.js +119 -1
- package/server/runtime/moduleConfig.js +32 -62
- package/server/runtime/moduleConfig.test.js +48 -24
- package/server/support/pageTargets.js +15 -9
- package/server/support/pageTargets.test.js +1 -1
- package/shared/actions/actionContributorHelpers.js +5 -11
- package/shared/actions/actionDefinitions.js +37 -150
- package/shared/actions/actionDefinitions.test.js +117 -136
- package/shared/actions/policies.js +25 -169
- package/shared/actions/policies.test.js +76 -87
- package/shared/actions/registry.test.js +24 -50
- package/shared/support/crudFieldContract.js +322 -0
- package/shared/support/crudFieldContract.test.js +67 -0
- package/shared/support/crudListFilters.js +582 -38
- package/shared/support/crudListFilters.test.js +178 -8
- package/shared/support/crudLookup.js +14 -7
- package/shared/support/crudLookup.test.js +91 -66
- package/shared/support/shellLayoutTargets.test.js +1 -1
- package/shared/validators/composeSchemaDefinitions.js +53 -0
- package/shared/validators/composeSchemaDefinitions.test.js +156 -0
- package/shared/validators/createCursorListValidator.js +22 -35
- package/shared/validators/createCursorListValidator.test.js +22 -23
- package/shared/validators/cursorPaginationQueryValidator.js +14 -24
- package/shared/validators/cursorPaginationQueryValidator.test.js +18 -8
- package/shared/validators/htmlTimeSchemas.js +6 -4
- package/shared/validators/index.js +15 -7
- package/shared/validators/jsonRestSchemaSupport.js +139 -0
- package/shared/validators/mergeObjectSchemas.js +44 -6
- package/shared/validators/mergeObjectSchemas.test.js +60 -35
- package/shared/validators/recordIdParamsValidator.js +19 -52
- package/shared/validators/recordIdParamsValidator.test.js +13 -8
- package/shared/validators/resourceRequiredMetadata.js +3 -3
- package/shared/validators/resourceRequiredMetadata.test.js +29 -16
- package/shared/validators/schemaDefinitions.js +126 -0
- package/shared/validators/schemaDefinitions.test.js +51 -0
- package/shared/validators/schemaPayloadValidation.js +65 -0
- package/test/barrelExposure.test.js +30 -0
- package/test/routeInputContractGuard.test.js +10 -6
- package/shared/validators/mergeValidators.js +0 -89
- package/shared/validators/mergeValidators.test.js +0 -116
- package/shared/validators/nestValidator.js +0 -53
- package/shared/validators/nestValidator.test.js +0 -60
- package/shared/validators/settingsFieldNormalization.js +0 -40
|
@@ -2,6 +2,7 @@ import { ensureNonEmptyText, normalizeObject, normalizeText } from "../../../sha
|
|
|
2
2
|
import { RouteDefinitionError } from "./errors.js";
|
|
3
3
|
import { resolveRouteValidatorOptions } from "./routeValidator.js";
|
|
4
4
|
import { normalizeMiddlewareStack as normalizeSharedMiddlewareStack } from "./routeSupport.js";
|
|
5
|
+
import { normalizeRouteOutputTransform, normalizeRouteTransport } from "./routeTransport.js";
|
|
5
6
|
|
|
6
7
|
function normalizeMethod(method) {
|
|
7
8
|
return ensureNonEmptyText(method, "route method").toUpperCase();
|
|
@@ -79,8 +80,21 @@ class HttpRouter {
|
|
|
79
80
|
const routeMiddleware = normalizeRouterMiddlewareStack(resolvedOptions.middleware, {
|
|
80
81
|
context: `Route ${input.method} ${input.path} middleware`
|
|
81
82
|
});
|
|
82
|
-
const routeInput = Object.
|
|
83
|
-
const routeOutput =
|
|
83
|
+
const routeInput = Object.hasOwn(resolvedOptions, "input") ? resolvedOptions.input : null;
|
|
84
|
+
const routeOutput = normalizeRouteOutputTransform(
|
|
85
|
+
Object.hasOwn(resolvedOptions, "output") ? resolvedOptions.output : null,
|
|
86
|
+
{
|
|
87
|
+
context: `Route ${input.method} ${input.path} output`,
|
|
88
|
+
ErrorType: RouteDefinitionError
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
const routeTransport = normalizeRouteTransport(
|
|
92
|
+
Object.hasOwn(resolvedOptions, "transport") ? resolvedOptions.transport : null,
|
|
93
|
+
{
|
|
94
|
+
context: `Route ${input.method} ${input.path} transport`,
|
|
95
|
+
ErrorType: RouteDefinitionError
|
|
96
|
+
}
|
|
97
|
+
);
|
|
84
98
|
|
|
85
99
|
const route = Object.freeze({
|
|
86
100
|
id: normalizeText(resolvedOptions.id),
|
|
@@ -89,6 +103,7 @@ class HttpRouter {
|
|
|
89
103
|
schema: resolvedOptions.schema,
|
|
90
104
|
input: routeInput,
|
|
91
105
|
output: routeOutput,
|
|
106
|
+
transport: routeTransport,
|
|
92
107
|
config: normalizeObject(resolvedOptions.config),
|
|
93
108
|
auth: resolvedOptions.auth,
|
|
94
109
|
contextPolicy: resolvedOptions.contextPolicy,
|
|
@@ -112,17 +112,17 @@ test("createProviderRuntimeFromApp discovers package providers from descriptor d
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
test("createProviderRuntimeFromApp ignores
|
|
116
|
-
const appRoot = await createTestAppRoot("kernel-provider-runtime-
|
|
115
|
+
test("createProviderRuntimeFromApp ignores app-local src/server/providers folder", async () => {
|
|
116
|
+
const appRoot = await createTestAppRoot("kernel-provider-runtime-app-local-");
|
|
117
117
|
try {
|
|
118
118
|
await mkdir(path.join(appRoot, "src", "server", "providers"), { recursive: true });
|
|
119
119
|
await writeFile(
|
|
120
|
-
path.join(appRoot, "src", "server", "providers", "
|
|
120
|
+
path.join(appRoot, "src", "server", "providers", "IgnoredProvider.js"),
|
|
121
121
|
[
|
|
122
|
-
"export default class
|
|
123
|
-
" static id = \"
|
|
122
|
+
"export default class IgnoredProvider {",
|
|
123
|
+
" static id = \"ignored.app.local\";",
|
|
124
124
|
" register(app) {",
|
|
125
|
-
" app.instance(\"
|
|
125
|
+
" app.instance(\"ignored.value\", true);",
|
|
126
126
|
" }",
|
|
127
127
|
" boot() {}",
|
|
128
128
|
"}"
|
|
@@ -139,7 +139,7 @@ test("createProviderRuntimeFromApp ignores legacy app local src/server/providers
|
|
|
139
139
|
assert.deepEqual(runtime.providerPackageOrder, []);
|
|
140
140
|
assert.equal(runtime.appLocalProviderOrder.length, 0);
|
|
141
141
|
assert.deepEqual(runtime.diagnostics.providerOrder, ["runtime.actions", "runtime.server"]);
|
|
142
|
-
assert.equal(runtime.app.has("
|
|
142
|
+
assert.equal(runtime.app.has("ignored.value"), false);
|
|
143
143
|
} finally {
|
|
144
144
|
await rm(appRoot, { recursive: true, force: true });
|
|
145
145
|
}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import { Type } from "typebox";
|
|
2
|
-
import { normalizeObjectInput } from "../../shared/validators/inputNormalization.js";
|
|
3
1
|
import { AUTH_POLICY_PUBLIC } from "../../shared/support/policies.js";
|
|
4
2
|
import { resolveBootstrapPayload } from "../registries/bootstrapPayloadContributorRegistry.js";
|
|
5
3
|
|
|
6
|
-
const bootstrapQueryValidator = Object.freeze({
|
|
7
|
-
schema: Type.Object({}, { additionalProperties: true }),
|
|
8
|
-
normalize: normalizeObjectInput
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
const bootstrapOutputValidator = Object.freeze({
|
|
12
|
-
schema: Type.Object({}, { additionalProperties: true }),
|
|
13
|
-
normalize: normalizeObjectInput
|
|
14
|
-
});
|
|
15
|
-
|
|
16
4
|
function bootBootstrapRoutes(app) {
|
|
17
5
|
const router = app.make("jskit.http.router");
|
|
18
6
|
|
|
@@ -24,17 +12,13 @@ function bootBootstrapRoutes(app) {
|
|
|
24
12
|
meta: {
|
|
25
13
|
tags: ["bootstrap"],
|
|
26
14
|
summary: "Resolve app bootstrap payload from registered contributors"
|
|
27
|
-
},
|
|
28
|
-
queryValidator: bootstrapQueryValidator,
|
|
29
|
-
responseValidators: {
|
|
30
|
-
200: bootstrapOutputValidator
|
|
31
15
|
}
|
|
32
16
|
},
|
|
33
17
|
async function (request, reply) {
|
|
34
18
|
const payload = await resolveBootstrapPayload(app, {
|
|
35
19
|
request,
|
|
36
20
|
reply,
|
|
37
|
-
query: request.
|
|
21
|
+
query: request.query || {}
|
|
38
22
|
});
|
|
39
23
|
|
|
40
24
|
reply.code(200).send(payload);
|
|
@@ -42,4 +26,4 @@ function bootBootstrapRoutes(app) {
|
|
|
42
26
|
);
|
|
43
27
|
}
|
|
44
28
|
|
|
45
|
-
export { bootBootstrapRoutes
|
|
29
|
+
export { bootBootstrapRoutes };
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { createContainer } from "../container/index.js";
|
|
4
4
|
import { registerBootstrapPayloadContributor } from "../registries/bootstrapPayloadContributorRegistry.js";
|
|
5
|
-
import { bootBootstrapRoutes
|
|
5
|
+
import { bootBootstrapRoutes } from "./bootBootstrapRoutes.js";
|
|
6
6
|
|
|
7
7
|
function createReplyDouble() {
|
|
8
8
|
return {
|
|
@@ -19,14 +19,6 @@ function createReplyDouble() {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
test("bootstrapQueryValidator normalizes generic query payloads", () => {
|
|
23
|
-
assert.deepEqual(bootstrapQueryValidator.normalize({}), {});
|
|
24
|
-
assert.deepEqual(bootstrapQueryValidator.normalize({ workspaceSlug: " AcMe ", page: "1" }), {
|
|
25
|
-
workspaceSlug: " AcMe ",
|
|
26
|
-
page: "1"
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
22
|
test("bootBootstrapRoutes registers GET /api/bootstrap and resolves contributors", async () => {
|
|
31
23
|
const app = createContainer();
|
|
32
24
|
const routes = [];
|
|
@@ -56,15 +48,14 @@ test("bootBootstrapRoutes registers GET /api/bootstrap and resolves contributors
|
|
|
56
48
|
|
|
57
49
|
const bootstrapRoute = routes.find((entry) => entry.method === "GET" && entry.path === "/api/bootstrap");
|
|
58
50
|
assert.ok(bootstrapRoute);
|
|
59
|
-
assert.equal(
|
|
51
|
+
assert.equal(Object.prototype.hasOwnProperty.call(bootstrapRoute.route, "query"), false);
|
|
52
|
+
assert.equal(Object.prototype.hasOwnProperty.call(bootstrapRoute.route, "responses"), false);
|
|
60
53
|
|
|
61
54
|
const reply = createReplyDouble();
|
|
62
55
|
await bootstrapRoute.handler(
|
|
63
56
|
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
workspaceSlug: "acme"
|
|
67
|
-
}
|
|
57
|
+
query: {
|
|
58
|
+
workspaceSlug: "acme"
|
|
68
59
|
}
|
|
69
60
|
},
|
|
70
61
|
reply
|
|
@@ -3,6 +3,9 @@ import { normalizeOpaqueId } from "../../shared/support/normalize.js";
|
|
|
3
3
|
import { isAppError } from "./errors.js";
|
|
4
4
|
import { resolveDefaultSurfaceId } from "../support/appConfig.js";
|
|
5
5
|
|
|
6
|
+
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
7
|
+
const JSON_API_CONTENT_TYPE_PARSER_MARKER = Symbol.for("jskit.fastify.jsonApiContentTypeParserRegistered");
|
|
8
|
+
|
|
6
9
|
function resolveLoggerLevel({ configuredLevel = "", nodeEnv = "development", allowedLevels = [] } = {}) {
|
|
7
10
|
const normalizedConfiguredLevel = String(configuredLevel || "")
|
|
8
11
|
.trim()
|
|
@@ -38,6 +41,48 @@ function createFastifyLoggerOptions({
|
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
function createFallbackJsonBodyParser() {
|
|
45
|
+
return function parseJsonBody(_request, body, done) {
|
|
46
|
+
const source = typeof body === "string" ? body.trim() : "";
|
|
47
|
+
if (!source) {
|
|
48
|
+
done(null, {});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
done(null, JSON.parse(source));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error && typeof error === "object" && !Array.isArray(error)) {
|
|
56
|
+
error.statusCode = 400;
|
|
57
|
+
}
|
|
58
|
+
done(error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function registerJsonApiContentTypeParser(fastify) {
|
|
64
|
+
if (!fastify || typeof fastify.addContentTypeParser !== "function") {
|
|
65
|
+
throw new TypeError("registerJsonApiContentTypeParser requires a Fastify instance.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER]) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof fastify.hasContentTypeParser === "function" && fastify.hasContentTypeParser(JSON_API_CONTENT_TYPE)) {
|
|
73
|
+
fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parser = typeof fastify.getDefaultJsonParser === "function"
|
|
78
|
+
? fastify.getDefaultJsonParser("ignore", "ignore")
|
|
79
|
+
: createFallbackJsonBodyParser();
|
|
80
|
+
|
|
81
|
+
fastify.addContentTypeParser(JSON_API_CONTENT_TYPE, { parseAs: "string" }, parser);
|
|
82
|
+
fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
41
86
|
function registerRequestLoggingHooks(
|
|
42
87
|
app,
|
|
43
88
|
{
|
|
@@ -124,6 +169,61 @@ function resolveValidationFieldErrors(error) {
|
|
|
124
169
|
return fieldErrors;
|
|
125
170
|
}
|
|
126
171
|
|
|
172
|
+
function resolveRequestRouteTransport(request) {
|
|
173
|
+
const directTransport =
|
|
174
|
+
request?.routeTransport && typeof request.routeTransport === "object" && !Array.isArray(request.routeTransport)
|
|
175
|
+
? request.routeTransport
|
|
176
|
+
: null;
|
|
177
|
+
if (directTransport) {
|
|
178
|
+
return directTransport;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const configTransport =
|
|
182
|
+
request?.routeOptions?.config?.transport &&
|
|
183
|
+
typeof request.routeOptions.config.transport === "object" &&
|
|
184
|
+
!Array.isArray(request.routeOptions.config.transport)
|
|
185
|
+
? request.routeOptions.config.transport
|
|
186
|
+
: null;
|
|
187
|
+
const runtimeTransport =
|
|
188
|
+
configTransport?.runtime && typeof configTransport.runtime === "object" && !Array.isArray(configTransport.runtime)
|
|
189
|
+
? configTransport.runtime
|
|
190
|
+
: null;
|
|
191
|
+
if (runtimeTransport) {
|
|
192
|
+
return runtimeTransport;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return configTransport;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function applyRouteTransportErrorResponse(reply, request, error, {
|
|
199
|
+
statusCode = 500,
|
|
200
|
+
normalizedErrorCode = ""
|
|
201
|
+
} = {}) {
|
|
202
|
+
const transport = resolveRequestRouteTransport(request);
|
|
203
|
+
const errorSerializer = transport && typeof transport.error === "function" ? transport.error : null;
|
|
204
|
+
if (!errorSerializer) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const payload = errorSerializer(error, {
|
|
209
|
+
request,
|
|
210
|
+
reply,
|
|
211
|
+
statusCode,
|
|
212
|
+
code: normalizedErrorCode
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (payload && typeof payload.then === "function") {
|
|
216
|
+
throw new TypeError("Route transport error serializer must return synchronously.");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (transport.contentType) {
|
|
220
|
+
reply.header("Content-Type", transport.contentType);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
reply.code(statusCode).send(payload);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
127
227
|
function registerApiErrorHandler(
|
|
128
228
|
app,
|
|
129
229
|
{
|
|
@@ -158,6 +258,12 @@ function registerApiErrorHandler(
|
|
|
158
258
|
if (Array.isArray(error?.validation)) {
|
|
159
259
|
const fieldErrors = resolveValidationFieldErrors(error);
|
|
160
260
|
const validationErrorCode = normalizedErrorCode || "validation_failed";
|
|
261
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
262
|
+
statusCode: 400,
|
|
263
|
+
normalizedErrorCode: validationErrorCode
|
|
264
|
+
})) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
161
267
|
reply.code(400).send({
|
|
162
268
|
error: "Validation failed.",
|
|
163
269
|
code: validationErrorCode,
|
|
@@ -170,6 +276,12 @@ function registerApiErrorHandler(
|
|
|
170
276
|
}
|
|
171
277
|
|
|
172
278
|
if (isAppError(error) || error instanceof ActionRuntimeError) {
|
|
279
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
280
|
+
statusCode: error.status,
|
|
281
|
+
normalizedErrorCode: normalizedErrorCode || "app_error"
|
|
282
|
+
})) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
173
285
|
if (error.status >= 500) {
|
|
174
286
|
recordDbError(error);
|
|
175
287
|
captureServerError(request, error, error.status);
|
|
@@ -222,6 +334,12 @@ function registerApiErrorHandler(
|
|
|
222
334
|
code: normalizedErrorCode
|
|
223
335
|
};
|
|
224
336
|
}
|
|
337
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
338
|
+
statusCode,
|
|
339
|
+
normalizedErrorCode: fallbackErrorCode
|
|
340
|
+
})) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
225
343
|
reply.code(statusCode).send(payload);
|
|
226
344
|
});
|
|
227
345
|
}
|
|
@@ -364,6 +482,7 @@ async function runGracefulShutdown({
|
|
|
364
482
|
export {
|
|
365
483
|
resolveLoggerLevel,
|
|
366
484
|
createFastifyLoggerOptions,
|
|
485
|
+
registerJsonApiContentTypeParser,
|
|
367
486
|
registerRequestLoggingHooks,
|
|
368
487
|
registerApiErrorHandler,
|
|
369
488
|
ensureApiErrorHandling,
|
|
@@ -2,12 +2,13 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
4
|
import { AppError, isAppError } from "./errors.js";
|
|
5
|
-
import { registerApiErrorHandler, registerRequestLoggingHooks } from "./fastifyBootstrap.js";
|
|
5
|
+
import { registerApiErrorHandler, registerJsonApiContentTypeParser, registerRequestLoggingHooks } from "./fastifyBootstrap.js";
|
|
6
6
|
|
|
7
7
|
function createFastifyStub() {
|
|
8
8
|
return {
|
|
9
9
|
errorHandler: null,
|
|
10
10
|
hooks: {},
|
|
11
|
+
contentTypeParsers: new Map(),
|
|
11
12
|
log: {
|
|
12
13
|
error() {}
|
|
13
14
|
},
|
|
@@ -16,6 +17,15 @@ function createFastifyStub() {
|
|
|
16
17
|
},
|
|
17
18
|
addHook(name, handler) {
|
|
18
19
|
this.hooks[name] = handler;
|
|
20
|
+
},
|
|
21
|
+
hasContentTypeParser(contentType) {
|
|
22
|
+
return this.contentTypeParsers.has(String(contentType || "").trim().toLowerCase());
|
|
23
|
+
},
|
|
24
|
+
addContentTypeParser(contentType, options, parser) {
|
|
25
|
+
this.contentTypeParsers.set(String(contentType || "").trim().toLowerCase(), {
|
|
26
|
+
options,
|
|
27
|
+
parser
|
|
28
|
+
});
|
|
19
29
|
}
|
|
20
30
|
};
|
|
21
31
|
}
|
|
@@ -157,6 +167,114 @@ test("registerApiErrorHandler keeps known error code for non-app errors", () =>
|
|
|
157
167
|
});
|
|
158
168
|
});
|
|
159
169
|
|
|
170
|
+
test("registerApiErrorHandler uses route transport error serializer when present", () => {
|
|
171
|
+
const fastify = createFastifyStub();
|
|
172
|
+
registerApiErrorHandler(fastify, { isAppError });
|
|
173
|
+
|
|
174
|
+
const reply = createReplyStub();
|
|
175
|
+
const error = new AppError(422, "Validation failed.", {
|
|
176
|
+
code: "invalid_contact",
|
|
177
|
+
details: {
|
|
178
|
+
fieldErrors: {
|
|
179
|
+
name: "Name is required."
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
fastify.errorHandler(
|
|
185
|
+
error,
|
|
186
|
+
{
|
|
187
|
+
routeTransport: {
|
|
188
|
+
contentType: "application/vnd.api+json",
|
|
189
|
+
error(currentError, { statusCode, code }) {
|
|
190
|
+
return {
|
|
191
|
+
errors: [
|
|
192
|
+
{
|
|
193
|
+
status: String(statusCode),
|
|
194
|
+
code,
|
|
195
|
+
title: currentError.message
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
reply
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
assert.equal(reply.statusCode, 422);
|
|
206
|
+
assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
|
|
207
|
+
assert.deepEqual(reply.payload, {
|
|
208
|
+
errors: [
|
|
209
|
+
{
|
|
210
|
+
status: "422",
|
|
211
|
+
code: "invalid_contact",
|
|
212
|
+
title: "Validation failed."
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("registerApiErrorHandler uses route config transport runtime serializer before handler attachment", () => {
|
|
219
|
+
const fastify = createFastifyStub();
|
|
220
|
+
registerApiErrorHandler(fastify, { isAppError });
|
|
221
|
+
|
|
222
|
+
const reply = createReplyStub();
|
|
223
|
+
const error = new AppError(401, "Authentication required.", {
|
|
224
|
+
code: "AUTH_POLICY_ERROR"
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
fastify.errorHandler(
|
|
228
|
+
error,
|
|
229
|
+
{
|
|
230
|
+
routeOptions: {
|
|
231
|
+
config: {
|
|
232
|
+
transport: {
|
|
233
|
+
kind: "jsonapi-resource",
|
|
234
|
+
contentType: "application/vnd.api+json",
|
|
235
|
+
runtime: {
|
|
236
|
+
contentType: "application/vnd.api+json",
|
|
237
|
+
error(currentError, { statusCode, code }) {
|
|
238
|
+
return {
|
|
239
|
+
errors: [
|
|
240
|
+
{
|
|
241
|
+
status: String(statusCode),
|
|
242
|
+
code,
|
|
243
|
+
title: currentError.message
|
|
244
|
+
}
|
|
245
|
+
]
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
reply
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
assert.equal(reply.statusCode, 401);
|
|
257
|
+
assert.equal(reply.headers["Content-Type"], "application/vnd.api+json");
|
|
258
|
+
assert.deepEqual(reply.payload, {
|
|
259
|
+
errors: [
|
|
260
|
+
{
|
|
261
|
+
status: "401",
|
|
262
|
+
code: "AUTH_POLICY_ERROR",
|
|
263
|
+
title: "Authentication required."
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("registerJsonApiContentTypeParser installs the JSON:API media type parser once", () => {
|
|
270
|
+
const fastify = createFastifyStub();
|
|
271
|
+
|
|
272
|
+
assert.equal(registerJsonApiContentTypeParser(fastify), true);
|
|
273
|
+
assert.equal(fastify.hasContentTypeParser("application/vnd.api+json"), true);
|
|
274
|
+
assert.equal(registerJsonApiContentTypeParser(fastify), false);
|
|
275
|
+
assert.equal(fastify.contentTypeParsers.size, 1);
|
|
276
|
+
});
|
|
277
|
+
|
|
160
278
|
test("registerRequestLoggingHooks uses configured default surface when getSurface is absent", async () => {
|
|
161
279
|
const fastify = createFastifyStub();
|
|
162
280
|
let loggedPayload = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Check, Errors, Parse } from "typebox/value";
|
|
2
1
|
import { deepFreeze } from "../../shared/support/deepFreeze.js";
|
|
3
2
|
import { normalizeObject, normalizeText } from "../../shared/support/normalize.js";
|
|
3
|
+
import { validateSchemaPayload } from "../../shared/validators/schemaPayloadValidation.js";
|
|
4
4
|
|
|
5
5
|
function normalizeModuleId(value) {
|
|
6
6
|
const moduleId = normalizeText(value);
|
|
@@ -17,46 +17,6 @@ function normalizeSchema(value) {
|
|
|
17
17
|
return value;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function normalizeIssuePath(issue) {
|
|
21
|
-
const fromInstancePath = normalizeText(issue?.instancePath || issue?.path).replace(/^\//, "").replace(/\//g, ".");
|
|
22
|
-
if (fromInstancePath) {
|
|
23
|
-
return fromInstancePath;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const missingProperty = normalizeText(issue?.params?.missingProperty);
|
|
27
|
-
if (missingProperty) {
|
|
28
|
-
return missingProperty;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const additionalProperties = issue?.params?.additionalProperties;
|
|
32
|
-
if (Array.isArray(additionalProperties) && additionalProperties.length > 0) {
|
|
33
|
-
const firstAdditional = normalizeText(additionalProperties[0]);
|
|
34
|
-
if (firstAdditional) {
|
|
35
|
-
return firstAdditional;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const oneAdditional = normalizeText(additionalProperties);
|
|
40
|
-
if (oneAdditional) {
|
|
41
|
-
return oneAdditional;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return "(root)";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function normalizeIssueMessage(issue) {
|
|
48
|
-
const message = normalizeText(issue?.message || issue?.error || issue?.description);
|
|
49
|
-
return message || "Invalid value.";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normalizeValidationIssues(rawIssues = []) {
|
|
53
|
-
return rawIssues.map((issue) => ({
|
|
54
|
-
path: normalizeIssuePath(issue),
|
|
55
|
-
message: normalizeIssueMessage(issue),
|
|
56
|
-
keyword: normalizeText(issue?.keyword)
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
20
|
function formatIssues(issues = []) {
|
|
61
21
|
return issues
|
|
62
22
|
.slice(0, 8)
|
|
@@ -142,28 +102,38 @@ class ModuleConfigError extends Error {
|
|
|
142
102
|
}
|
|
143
103
|
|
|
144
104
|
function validateSchemaOrThrow({ moduleId, schema, value, coerce = false }) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
105
|
+
const schemaDefinition = {
|
|
106
|
+
schema,
|
|
107
|
+
mode: "replace"
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
return validateSchemaPayload(schemaDefinition, value, {
|
|
112
|
+
phase: "input",
|
|
113
|
+
context: `module config "${moduleId}"`
|
|
114
|
+
});
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
const fieldErrors = normalizeObject(cause?.fieldErrors);
|
|
117
|
+
const issues = Object.keys(fieldErrors).length > 0
|
|
118
|
+
? Object.keys(fieldErrors).map((path) => ({
|
|
119
|
+
path,
|
|
120
|
+
message: normalizeText(fieldErrors[path], { fallback: "Invalid value." }),
|
|
121
|
+
keyword: ""
|
|
122
|
+
}))
|
|
123
|
+
: [
|
|
124
|
+
{
|
|
125
|
+
path: "(root)",
|
|
126
|
+
message: normalizeText(cause?.message, { fallback: "Invalid value." }),
|
|
127
|
+
keyword: ""
|
|
128
|
+
}
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
throw new ModuleConfigError(buildInvalidConfigMessage(moduleId, issues), {
|
|
132
|
+
moduleId,
|
|
133
|
+
issues,
|
|
134
|
+
cause
|
|
135
|
+
});
|
|
160
136
|
}
|
|
161
|
-
|
|
162
|
-
const issues = normalizeValidationIssues([...Errors(schema, value)]);
|
|
163
|
-
throw new ModuleConfigError(buildInvalidConfigMessage(moduleId, issues), {
|
|
164
|
-
moduleId,
|
|
165
|
-
issues
|
|
166
|
-
});
|
|
167
137
|
}
|
|
168
138
|
|
|
169
139
|
function defineModuleConfig({
|