@jskit-ai/http-runtime 0.1.182 → 0.1.184
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 +2 -2
- package/src/shared/clientRuntime/errors.js +5 -0
- package/src/shared/clientRuntime/jsonApiResourceTransport.js +3 -1
- package/src/shared/clientRuntime/retry.js +10 -1
- package/src/shared/validators/errorResponses.js +4 -0
- package/src/shared/validators/jsonApiRouteTransport.js +7 -4
- package/src/shared/validators/jsonApiTransport.js +7 -1
- package/test/client.test.js +76 -0
- package/test/errorResponses.test.js +8 -0
- package/test/jsonApiRouteTransport.test.js +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/http-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.184",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -78,6 +78,6 @@
|
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@jskit-ai/kernel": "0.1.
|
|
81
|
+
"@jskit-ai/kernel": "0.1.186"
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
|
|
1
2
|
import { isRecord, resolveFieldErrors } from "../support/fieldErrors.js";
|
|
2
3
|
import { createJsonApiClientErrorPayload } from "./jsonApiResourceTransport.js";
|
|
3
4
|
|
|
@@ -20,6 +21,10 @@ function createHttpError(response, data = {}) {
|
|
|
20
21
|
const normalizedFieldErrors = resolveFieldErrors(payload);
|
|
21
22
|
error.status = Number(response?.status || 0);
|
|
22
23
|
error.code = String(payload.code || "").trim() || null;
|
|
24
|
+
const transactionOutcome = normalizeTransactionOutcome(payload.transactionOutcome);
|
|
25
|
+
if (transactionOutcome) {
|
|
26
|
+
error.transactionOutcome = transactionOutcome;
|
|
27
|
+
}
|
|
23
28
|
error.fieldErrors = Object.keys(normalizedFieldErrors).length > 0 ? normalizedFieldErrors : null;
|
|
24
29
|
if (isRecord(payload.details)) {
|
|
25
30
|
error.details = payload.details;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeArray, normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
1
|
+
import { normalizeArray, normalizeObject, normalizeText, normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import {
|
|
3
3
|
JSON_API_CONTENT_TYPE,
|
|
4
4
|
createJsonApiDocument,
|
|
@@ -331,12 +331,14 @@ function createJsonApiClientErrorPayload(payload = {}) {
|
|
|
331
331
|
|
|
332
332
|
const firstError = normalizeArray(document.errors)[0] || {};
|
|
333
333
|
const fieldErrors = decodeJsonApiErrorFieldErrors(payload);
|
|
334
|
+
const transactionOutcome = normalizeTransactionOutcome(firstError.meta?.transactionOutcome);
|
|
334
335
|
|
|
335
336
|
return {
|
|
336
337
|
error: normalizeText(firstError.detail || firstError.title, {
|
|
337
338
|
fallback: "Request failed."
|
|
338
339
|
}),
|
|
339
340
|
code: normalizeText(firstError.code) || null,
|
|
341
|
+
...(transactionOutcome ? { transactionOutcome } : {}),
|
|
340
342
|
...(Object.keys(fieldErrors).length > 0
|
|
341
343
|
? {
|
|
342
344
|
fieldErrors,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import { createJsonApiClientErrorPayload } from "./jsonApiResourceTransport.js";
|
|
3
|
+
|
|
1
4
|
const DEFAULT_RETRYABLE_CSRF_ERROR_CODES = Object.freeze(["FST_CSRF_INVALID_TOKEN", "FST_CSRF_MISSING_SECRET"]);
|
|
2
5
|
|
|
3
6
|
function toUpperStringSet(values, fallback = []) {
|
|
@@ -28,7 +31,13 @@ function shouldRetryForCsrfFailure({
|
|
|
28
31
|
return false;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
const
|
|
34
|
+
const payload = createJsonApiClientErrorPayload(data) || data;
|
|
35
|
+
const transactionOutcome = normalizeTransactionOutcome(payload?.transactionOutcome);
|
|
36
|
+
if (["pending", "committed", "unknown"].includes(transactionOutcome)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const code = String(payload?.details?.code || payload?.code || "")
|
|
32
41
|
.trim()
|
|
33
42
|
.toUpperCase();
|
|
34
43
|
const retryableCodes = toUpperStringSet(retryableErrorCodes, DEFAULT_RETRYABLE_CSRF_ERROR_CODES);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
|
+
import { TRANSACTION_OUTCOMES } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
3
|
import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|
|
3
4
|
import { createEmbeddableTransportSchemaDocument } from "./transportSchemaEmbedding.js";
|
|
4
5
|
|
|
@@ -28,6 +29,7 @@ const apiErrorOutputValidator = deepFreeze({
|
|
|
28
29
|
schema: createSchema({
|
|
29
30
|
error: { type: "string", required: true, minLength: 1 },
|
|
30
31
|
code: { type: "string", required: false, minLength: 1 },
|
|
32
|
+
transactionOutcome: { type: "string", required: false, enum: TRANSACTION_OUTCOMES },
|
|
31
33
|
details: {
|
|
32
34
|
type: "object",
|
|
33
35
|
required: false,
|
|
@@ -46,6 +48,7 @@ const apiValidationErrorOutputValidator = deepFreeze({
|
|
|
46
48
|
schema: createSchema({
|
|
47
49
|
error: { type: "string", required: true, minLength: 1 },
|
|
48
50
|
code: { type: "string", required: false, minLength: 1 },
|
|
51
|
+
transactionOutcome: { type: "string", required: false, enum: TRANSACTION_OUTCOMES },
|
|
49
52
|
fieldErrors: {
|
|
50
53
|
...fieldErrorsFieldDefinition,
|
|
51
54
|
required: true
|
|
@@ -77,6 +80,7 @@ const fastifyDefaultErrorTransportSchema = {
|
|
|
77
80
|
error: { type: "string", minLength: 1 },
|
|
78
81
|
message: { type: "string", minLength: 1 },
|
|
79
82
|
code: { type: "string", minLength: 1 },
|
|
83
|
+
transactionOutcome: { type: "string", enum: TRANSACTION_OUTCOMES },
|
|
80
84
|
details: {},
|
|
81
85
|
fieldErrors: {
|
|
82
86
|
type: "object",
|
|
@@ -864,9 +864,11 @@ function createJsonApiResourceRouteTransport({
|
|
|
864
864
|
},
|
|
865
865
|
error(error, {
|
|
866
866
|
statusCode = 500,
|
|
867
|
-
code = ""
|
|
867
|
+
code = "",
|
|
868
|
+
message = error?.message,
|
|
869
|
+
exposeDetails = true
|
|
868
870
|
} = {}) {
|
|
869
|
-
const fieldErrors = isRecord(error?.fieldErrors)
|
|
871
|
+
const fieldErrors = !exposeDetails ? {} : isRecord(error?.fieldErrors)
|
|
870
872
|
? error.fieldErrors
|
|
871
873
|
: isRecord(error?.details?.fieldErrors)
|
|
872
874
|
? error.details.fieldErrors
|
|
@@ -875,9 +877,10 @@ function createJsonApiResourceRouteTransport({
|
|
|
875
877
|
return createJsonApiErrorDocumentFromFailure({
|
|
876
878
|
statusCode,
|
|
877
879
|
code,
|
|
878
|
-
message
|
|
880
|
+
message,
|
|
881
|
+
transactionOutcome: error?.transactionOutcome,
|
|
879
882
|
fieldErrors,
|
|
880
|
-
validationIssues: Array.isArray(error?.validation) ? error.validation : [],
|
|
883
|
+
validationIssues: exposeDetails && Array.isArray(error?.validation) ? error.validation : [],
|
|
881
884
|
validationContext: String(error?.validationContext || "").trim(),
|
|
882
885
|
pointerPrefix
|
|
883
886
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeArray, normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
1
|
+
import { normalizeArray, normalizeObject, normalizeText, normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { simplifyJsonApiResourceWithRelationshipIds } from "../support/jsonApiSimplify.js";
|
|
3
3
|
|
|
4
4
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
@@ -388,11 +388,14 @@ function createJsonApiErrorDocumentFromFailure({
|
|
|
388
388
|
statusCode = 500,
|
|
389
389
|
code = "",
|
|
390
390
|
message = "",
|
|
391
|
+
transactionOutcome,
|
|
391
392
|
fieldErrors = {},
|
|
392
393
|
validationIssues = [],
|
|
393
394
|
validationContext = "",
|
|
394
395
|
pointerPrefix = "/data/attributes"
|
|
395
396
|
} = {}) {
|
|
397
|
+
const outcome = normalizeTransactionOutcome(transactionOutcome);
|
|
398
|
+
const meta = outcome ? { transactionOutcome: outcome } : undefined;
|
|
396
399
|
const normalizedStatus = String(Number(statusCode) || 500);
|
|
397
400
|
const normalizedCode = String(code || "").trim();
|
|
398
401
|
const normalizedMessage = String(message || "").trim() || "Request failed.";
|
|
@@ -404,6 +407,7 @@ function createJsonApiErrorDocumentFromFailure({
|
|
|
404
407
|
errors: issues.map((issue) =>
|
|
405
408
|
createJsonApiErrorObject({
|
|
406
409
|
status: normalizedStatus,
|
|
410
|
+
meta,
|
|
407
411
|
code: normalizedCode,
|
|
408
412
|
title: normalizedMessage,
|
|
409
413
|
detail: String(issue?.message || "Invalid value.").trim() || "Invalid value.",
|
|
@@ -419,6 +423,7 @@ function createJsonApiErrorDocumentFromFailure({
|
|
|
419
423
|
errors: fieldEntries.map(([field, detail]) =>
|
|
420
424
|
createJsonApiErrorObject({
|
|
421
425
|
status: normalizedStatus,
|
|
426
|
+
meta,
|
|
422
427
|
code: normalizedCode,
|
|
423
428
|
title: normalizedMessage,
|
|
424
429
|
detail: String(detail || "Invalid value.").trim() || "Invalid value.",
|
|
@@ -434,6 +439,7 @@ function createJsonApiErrorDocumentFromFailure({
|
|
|
434
439
|
errors: [
|
|
435
440
|
createJsonApiErrorObject({
|
|
436
441
|
status: normalizedStatus,
|
|
442
|
+
meta,
|
|
437
443
|
code: normalizedCode,
|
|
438
444
|
title: normalizedMessage
|
|
439
445
|
})
|
package/test/client.test.js
CHANGED
|
@@ -21,6 +21,82 @@ function mockResponse({ status = 200, data = {}, contentType = "application/json
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
test("request retains standard and JSON:API write outcomes without retrying", async () => {
|
|
25
|
+
for (const jsonapi of [false, true]) {
|
|
26
|
+
for (const outcome of ["committed", "rolledBack", "unknown", "invalid"]) {
|
|
27
|
+
let requests = 0;
|
|
28
|
+
const data = jsonapi
|
|
29
|
+
? { errors: [{ status: "500", title: "Internal server error.", meta: { transactionOutcome: outcome } }] }
|
|
30
|
+
: { error: "Internal server error.", transactionOutcome: outcome };
|
|
31
|
+
const client = createHttpClient({
|
|
32
|
+
fetchImpl: async () => {
|
|
33
|
+
requests += 1;
|
|
34
|
+
return mockResponse({ status: 500, data, contentType: jsonapi ? "application/vnd.api+json" : "application/json" });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await assert.rejects(
|
|
38
|
+
() => client.request("/api/books/1", { method: "PATCH", body: { title: "Dune" }, csrf: false }),
|
|
39
|
+
(error) => {
|
|
40
|
+
assert.equal(error.status, 500);
|
|
41
|
+
assert.equal(error.transactionOutcome, outcome === "invalid" ? undefined : outcome);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
assert.equal(requests, 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("CSRF recovery replays writes only when their transaction outcome permits it", async (t) => {
|
|
51
|
+
for (const requestMethod of ["request", "requestStream"]) {
|
|
52
|
+
for (const jsonapi of [false, true]) {
|
|
53
|
+
for (const outcome of ["none", "rolledBack", "pending", "committed", "unknown"]) {
|
|
54
|
+
await t.test(`${requestMethod} ${jsonapi ? "JSON:API" : "plain"} ${outcome}`, async () => {
|
|
55
|
+
const calls = [];
|
|
56
|
+
let writes = 0;
|
|
57
|
+
const client = createHttpClient({
|
|
58
|
+
fetchImpl: async (url) => {
|
|
59
|
+
calls.push(url);
|
|
60
|
+
if (url === "/api/session") {
|
|
61
|
+
return mockResponse({ data: { csrfToken: "csrf-token" } });
|
|
62
|
+
}
|
|
63
|
+
writes += 1;
|
|
64
|
+
if (writes > 1) {
|
|
65
|
+
return mockResponse({ data: { ok: true } });
|
|
66
|
+
}
|
|
67
|
+
return mockResponse({
|
|
68
|
+
status: 403,
|
|
69
|
+
contentType: jsonapi ? "application/vnd.api+json" : "application/json",
|
|
70
|
+
data: jsonapi
|
|
71
|
+
? { errors: [{ status: "403", code: "FST_CSRF_INVALID_TOKEN", title: "Invalid CSRF token.", meta: { transactionOutcome: outcome } }] }
|
|
72
|
+
: { error: "Invalid CSRF token.", details: { code: "FST_CSRF_INVALID_TOKEN" }, transactionOutcome: outcome }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const request = () => client[requestMethod]("/api/books", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: { title: "Dune" }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (["none", "rolledBack"].includes(outcome)) {
|
|
82
|
+
await request();
|
|
83
|
+
assert.equal(writes, 2);
|
|
84
|
+
assert.deepEqual(calls, ["/api/session", "/api/books", "/api/session", "/api/books"]);
|
|
85
|
+
} else {
|
|
86
|
+
await assert.rejects(request, (error) => {
|
|
87
|
+
assert.equal(error.status, 403);
|
|
88
|
+
assert.equal(error.transactionOutcome, outcome);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
assert.equal(writes, 1);
|
|
92
|
+
assert.deepEqual(calls, ["/api/session", "/api/books"]);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
24
100
|
test("request serializes json body and injects csrf token for unsafe methods", async () => {
|
|
25
101
|
const calls = [];
|
|
26
102
|
const fetchImpl = async (url, options) => {
|
|
@@ -118,3 +118,11 @@ test("error response validators export transport schemas from the same contracts
|
|
|
118
118
|
true
|
|
119
119
|
);
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
test("standard error schemas preserve optional transaction outcomes during serialization", () => {
|
|
123
|
+
const outcomes = ["none", "pending", "committed", "rolledBack", "unknown"];
|
|
124
|
+
for (const schema of [apiErrorTransportSchema, apiValidationErrorTransportSchema, fastifyDefaultErrorTransportSchema]) {
|
|
125
|
+
assert.deepEqual(schema.properties.transactionOutcome.enum, outcomes);
|
|
126
|
+
assert.equal(schema.required.includes("transactionOutcome"), false);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import knexLib from "knex";
|
|
5
|
+
import {
|
|
6
|
+
RestApiFieldsetError,
|
|
7
|
+
RestApiIncludeError,
|
|
8
|
+
RestApiPayloadError,
|
|
9
|
+
RestApiPreconditionFailedError,
|
|
10
|
+
RestApiResourceError,
|
|
11
|
+
RestApiTemporalDataError,
|
|
12
|
+
RestApiValidationError,
|
|
13
|
+
RestApiVersionConflictError,
|
|
14
|
+
RestApiWriteError
|
|
15
|
+
} from "json-rest-api";
|
|
3
16
|
|
|
4
17
|
import {
|
|
5
18
|
JSON_API_CONTENT_TYPE,
|
|
19
|
+
apiErrorTransportSchema,
|
|
6
20
|
encodeJsonApiResourceQueryObject,
|
|
7
21
|
createJsonApiResourceQueryTransportSchema,
|
|
8
22
|
createJsonApiResourceRequestBodyTransportSchema,
|
|
@@ -15,6 +29,10 @@ import {
|
|
|
15
29
|
} from "../src/shared/index.js";
|
|
16
30
|
import { createSchema } from "../../kernel/shared/validators/index.js";
|
|
17
31
|
import { resolveRouteValidatorOptions } from "../../kernel/server/http/lib/routeValidator.js";
|
|
32
|
+
import { registerApiErrorHandler } from "../../kernel/server/runtime/fastifyBootstrap.js";
|
|
33
|
+
import { AppError, isAppError } from "../../kernel/server/runtime/errors.js";
|
|
34
|
+
import { createHttpError } from "../src/shared/clientRuntime/errors.js";
|
|
35
|
+
import { createJsonRestApiHost } from "../../json-rest-api-core/src/server/jsonRestApiHost.js";
|
|
18
36
|
|
|
19
37
|
const CONTACT_BODY_SCHEMA = Object.freeze({
|
|
20
38
|
schema: createSchema({
|
|
@@ -197,6 +215,189 @@ test("createJsonApiResourceRouteTransport unwraps request payloads and wraps res
|
|
|
197
215
|
assert.equal(errorPayload.errors[0].source.pointer, "/data/attributes/name");
|
|
198
216
|
});
|
|
199
217
|
|
|
218
|
+
test("real HTTP responses classify typed JSON REST read and write errors", async (t) => {
|
|
219
|
+
const app = Fastify();
|
|
220
|
+
t.after(() => app.close());
|
|
221
|
+
registerApiErrorHandler(app, { isAppError });
|
|
222
|
+
const transport = createJsonApiResourceRouteTransport({ type: "books" });
|
|
223
|
+
const cases = [
|
|
224
|
+
["validation", () => new RestApiValidationError("Title is required."), 422],
|
|
225
|
+
["missing", () => new RestApiResourceError("Book not found.", { subtype: "not_found" }), 404],
|
|
226
|
+
["forbidden", () => new RestApiResourceError("Book is forbidden.", { subtype: "forbidden" }), 403],
|
|
227
|
+
["conflict", () => new RestApiResourceError("Book conflicts.", { subtype: "conflict" }), 409],
|
|
228
|
+
["resource", () => new RestApiResourceError("Invalid resource."), 400],
|
|
229
|
+
["version", () => new RestApiVersionConflictError({ resourceType: "books", resourceId: "1" }), 409],
|
|
230
|
+
["precondition", () => new RestApiPreconditionFailedError({ resourceType: "books", resourceId: "1" }), 412],
|
|
231
|
+
["fieldset", () => new RestApiFieldsetError({ resourceType: "books", field: "missing" }), 400],
|
|
232
|
+
["include", () => new RestApiIncludeError({ resourceType: "books", path: "missing" }), 400],
|
|
233
|
+
["payload", () => new RestApiPayloadError("Invalid document."), 400],
|
|
234
|
+
["large-payload", () => new RestApiPayloadError("Document too large.", { statusCode: 413 }), 413],
|
|
235
|
+
["temporal", () => new RestApiTemporalDataError({ resourceType: "private_table", field: "private_column", fieldType: "date" }), 500],
|
|
236
|
+
["custom-status", () => Object.assign(new Error("Custom error."), { status: 418 }), 418],
|
|
237
|
+
["server-status", () => Object.assign(new Error("private server failure"), { statusCode: 503 }), 503],
|
|
238
|
+
["invalid-status", () => Object.assign(new Error("private invalid status"), { statusCode: 200 }), 500]
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const jsonapi of [false, true]) {
|
|
242
|
+
for (const wrapped of [false, true]) {
|
|
243
|
+
const prefix = `/${jsonapi ? "jsonapi" : "plain"}/${wrapped ? "write" : "read"}`;
|
|
244
|
+
app.get(`${prefix}/:kind`, {
|
|
245
|
+
config: jsonapi ? { transport: { runtime: transport } } : {},
|
|
246
|
+
...(jsonapi ? {} : { schema: { response: { "4xx": apiErrorTransportSchema, "5xx": apiErrorTransportSchema } } })
|
|
247
|
+
}, async (request) => {
|
|
248
|
+
const [, createError] = cases.find(([name]) => name === request.params.kind);
|
|
249
|
+
const cause = createError();
|
|
250
|
+
if (wrapped) {
|
|
251
|
+
throw new RestApiWriteError(cause.message, { cause, transactionOutcome: "rolledBack" });
|
|
252
|
+
}
|
|
253
|
+
throw cause;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const jsonapi of [false, true]) {
|
|
259
|
+
for (const wrapped of [false, true]) {
|
|
260
|
+
for (const [name, createError, expectedStatus] of cases) {
|
|
261
|
+
const url = `/${jsonapi ? "jsonapi" : "plain"}/${wrapped ? "write" : "read"}/${name}`;
|
|
262
|
+
const response = await app.inject({ method: "GET", url });
|
|
263
|
+
assert.equal(response.statusCode, expectedStatus, url);
|
|
264
|
+
const payload = response.json();
|
|
265
|
+
const clientError = createHttpError({ status: response.statusCode }, payload);
|
|
266
|
+
assert.equal(clientError.status, expectedStatus, url);
|
|
267
|
+
assert.equal(clientError.transactionOutcome, wrapped ? "rolledBack" : undefined, url);
|
|
268
|
+
assert.equal(clientError.message, expectedStatus >= 500 ? "Internal server error." : createError().message, url);
|
|
269
|
+
assert.equal(clientError.cause, undefined);
|
|
270
|
+
if (jsonapi) {
|
|
271
|
+
assert.equal(payload.errors[0].status, String(expectedStatus));
|
|
272
|
+
assert.match(response.headers["content-type"], /^application\/vnd\.api\+json/u);
|
|
273
|
+
}
|
|
274
|
+
if (expectedStatus >= 500) {
|
|
275
|
+
assert.doesNotMatch(response.body, /private/u);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("real resource validation, permission hooks and completion failures retain HTTP semantics", async (t) => {
|
|
283
|
+
const app = Fastify();
|
|
284
|
+
t.after(() => app.close());
|
|
285
|
+
const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
286
|
+
t.after(() => knex.destroy());
|
|
287
|
+
await knex.schema.createTable("books", (table) => {
|
|
288
|
+
table.increments("id");
|
|
289
|
+
table.string("title").notNullable();
|
|
290
|
+
});
|
|
291
|
+
const api = await createJsonRestApiHost({ knex, logger: { error() {} } });
|
|
292
|
+
await api.addResource("books", { schema: { title: { type: "string", required: true } } });
|
|
293
|
+
await api.customize({ hooks: {
|
|
294
|
+
checkPermissions({ context }) {
|
|
295
|
+
if ((context.originalContext ?? context).deny) {
|
|
296
|
+
throw new RestApiResourceError("Book access denied.", { subtype: "forbidden" });
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
afterCommit({ context }) {
|
|
300
|
+
if (context.failAfterCommit) {
|
|
301
|
+
throw new Error("private notification failure");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} });
|
|
305
|
+
registerApiErrorHandler(app, { isAppError });
|
|
306
|
+
const transport = createJsonApiResourceRouteTransport({ type: "books" });
|
|
307
|
+
for (const jsonapi of [false, true]) {
|
|
308
|
+
const prefix = `/${jsonapi ? "jsonapi" : "plain"}`;
|
|
309
|
+
const options = { config: jsonapi ? { transport: { runtime: transport } } : {} };
|
|
310
|
+
app.get(`${prefix}/denied`, options, async () => api.resources.books.query({}, { deny: true }));
|
|
311
|
+
app.post(`${prefix}/invalid`, options, async (request) => api.resources.books.post({ data: request.body }));
|
|
312
|
+
app.post(`${prefix}/committed`, options, async (request) => api.resources.books.post({ data: request.body }, { failAfterCommit: true }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const jsonapi of [false, true]) {
|
|
316
|
+
const prefix = `/${jsonapi ? "jsonapi" : "plain"}`;
|
|
317
|
+
for (const [path, method, payload, status, code, outcome] of [
|
|
318
|
+
["denied", "GET", undefined, 403, "REST_API_RESOURCE", undefined],
|
|
319
|
+
["invalid", "POST", {}, 422, "REST_API_VALIDATION", "rolledBack"],
|
|
320
|
+
["committed", "POST", { title: "Stored book" }, 500, "REST_API_WRITE", "committed"]
|
|
321
|
+
]) {
|
|
322
|
+
const response = await app.inject({ method, url: `${prefix}/${path}`, ...(payload ? { payload } : {}) });
|
|
323
|
+
assert.equal(response.statusCode, status, response.body);
|
|
324
|
+
const clientError = createHttpError({ status: response.statusCode }, response.json());
|
|
325
|
+
assert.equal(clientError.status, status);
|
|
326
|
+
assert.equal(clientError.code, code);
|
|
327
|
+
assert.equal(clientError.transactionOutcome, outcome);
|
|
328
|
+
if (status === 500) {
|
|
329
|
+
assert.equal(clientError.message, "Internal server error.");
|
|
330
|
+
assert.doesNotMatch(response.body, /private/u);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
assert.deepEqual(await knex("books").pluck("title"), ["Stored book", "Stored book"]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("error handler, JSON:API transport and client preserve outcomes without disclosing internal failures", () => {
|
|
338
|
+
const transport = createJsonApiResourceRouteTransport({ type: "books" });
|
|
339
|
+
const app = { log: { error() {} }, setErrorHandler(handler) { this.errorHandler = handler; } };
|
|
340
|
+
registerApiErrorHandler(app, { isAppError });
|
|
341
|
+
|
|
342
|
+
for (const transactionOutcome of ["none", "pending", "committed", "rolledBack", "unknown", "invalid", undefined]) {
|
|
343
|
+
const failure = Object.assign(new Error("Private SQL statement"), {
|
|
344
|
+
transactionOutcome,
|
|
345
|
+
code: "REST_API_WRITE",
|
|
346
|
+
fieldErrors: { secret: "Private SQL value" },
|
|
347
|
+
cause: { secret: "Private cause" },
|
|
348
|
+
cleanupErrors: [{ message: "Private cleanup" }]
|
|
349
|
+
});
|
|
350
|
+
for (const jsonapi of [false, true]) {
|
|
351
|
+
const reply = {
|
|
352
|
+
statusCode: 200,
|
|
353
|
+
headers: {},
|
|
354
|
+
code(value) { this.statusCode = value; return this; },
|
|
355
|
+
header(name, value) { this.headers[name] = value; return this; },
|
|
356
|
+
send(payload) { this.payload = payload; return this; }
|
|
357
|
+
};
|
|
358
|
+
app.errorHandler(failure, jsonapi ? { routeTransport: transport } : {}, reply);
|
|
359
|
+
assert.equal(reply.statusCode, 500);
|
|
360
|
+
const clientError = createHttpError({ status: reply.statusCode }, reply.payload);
|
|
361
|
+
assert.equal(clientError.message, "Internal server error.");
|
|
362
|
+
assert.equal(clientError.code, "REST_API_WRITE");
|
|
363
|
+
assert.equal(clientError.transactionOutcome, transactionOutcome === "invalid" ? undefined : transactionOutcome);
|
|
364
|
+
assert.equal(clientError.cause, undefined);
|
|
365
|
+
assert.equal(clientError.fieldErrors, null);
|
|
366
|
+
assert.equal(JSON.stringify(reply.payload).includes("Private"), false);
|
|
367
|
+
if (jsonapi) {
|
|
368
|
+
assert.equal(reply.headers["Content-Type"], JSON_API_CONTENT_TYPE);
|
|
369
|
+
assert.equal(reply.payload.errors[0].meta?.transactionOutcome, clientError.transactionOutcome);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("JSON:API field errors retain outcomes and permission details remain private", () => {
|
|
376
|
+
const transport = createJsonApiResourceRouteTransport({ type: "books" });
|
|
377
|
+
const payload = transport.error({
|
|
378
|
+
message: "Validation failed.",
|
|
379
|
+
transactionOutcome: "rolledBack",
|
|
380
|
+
fieldErrors: { title: "Required", author: "Missing" }
|
|
381
|
+
}, { statusCode: 422 });
|
|
382
|
+
assert.equal(payload.errors.length, 2);
|
|
383
|
+
assert.ok(payload.errors.every((error) => error.meta.transactionOutcome === "rolledBack"));
|
|
384
|
+
|
|
385
|
+
const app = { log: { error() {} }, setErrorHandler(handler) { this.errorHandler = handler; } };
|
|
386
|
+
registerApiErrorHandler(app, { isAppError });
|
|
387
|
+
const reply = {
|
|
388
|
+
code() { return this; },
|
|
389
|
+
header() { return this; },
|
|
390
|
+
send(value) { this.payload = value; return this; }
|
|
391
|
+
};
|
|
392
|
+
app.errorHandler(new AppError(403, "Forbidden.", {
|
|
393
|
+
code: "ACTION_PERMISSION_DENIED",
|
|
394
|
+
details: { fieldErrors: { secret: "Required private permission" } }
|
|
395
|
+
}), { routeTransport: transport }, reply);
|
|
396
|
+
assert.deepEqual(reply.payload, {
|
|
397
|
+
errors: [{ status: "403", code: "ACTION_PERMISSION_DENIED", title: "Forbidden." }]
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
200
401
|
test("createJsonApiResourceQueryTransportSchema and route transport map list query params to JSON:API", () => {
|
|
201
402
|
const schema = createJsonApiResourceQueryTransportSchema({
|
|
202
403
|
query: CONTACT_LIST_QUERY_SCHEMA,
|