@jskit-ai/json-rest-api-core 0.1.129 → 0.1.131
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 +8 -6
- package/src/server/jsonRestApiHost.js +21 -235
- package/test/entrypoints.boundary.test.js +180 -232
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/json-rest-api-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.131",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.0.0"
|
|
7
|
+
},
|
|
5
8
|
"scripts": {
|
|
6
9
|
"test": "node --test"
|
|
7
10
|
},
|
|
@@ -9,10 +12,9 @@
|
|
|
9
12
|
"./server/jsonRestApiHost": "./src/server/jsonRestApiHost.js"
|
|
10
13
|
},
|
|
11
14
|
"dependencies": {
|
|
12
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
14
|
-
"
|
|
15
|
-
"json-rest-api": "^1.0.29"
|
|
15
|
+
"@jskit-ai/database-runtime": "0.1.187",
|
|
16
|
+
"@jskit-ai/resource-crud-core": "0.1.129",
|
|
17
|
+
"json-rest-api": "2.0.0"
|
|
16
18
|
},
|
|
17
19
|
"description": "Shared internal json-rest-api host runtime with autofilter, query-projection, and row-policy support.",
|
|
18
20
|
"jskit": {
|
|
@@ -50,6 +52,6 @@
|
|
|
50
52
|
}
|
|
51
53
|
},
|
|
52
54
|
"peerDependencies": {
|
|
53
|
-
"@jskit-ai/kernel": "0.1.
|
|
55
|
+
"@jskit-ai/kernel": "0.1.187"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Api } from "hooked-api";
|
|
2
1
|
import {
|
|
3
2
|
AutoFilterPlugin,
|
|
3
|
+
JsonRestApi,
|
|
4
4
|
QueryProjectionsPlugin,
|
|
5
5
|
REST_API_FIELDSET_ERROR_CODE,
|
|
6
6
|
RestApiKnexPlugin,
|
|
@@ -51,9 +51,9 @@ const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
|
|
|
51
51
|
"sort",
|
|
52
52
|
"fields"
|
|
53
53
|
]));
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
const JSON_REST_DEFAULT_LOGGER = Object.freeze({
|
|
55
|
+
error: (...args) => console.error(...args)
|
|
56
|
+
});
|
|
57
57
|
|
|
58
58
|
function isPlainJsonRestObject(value) {
|
|
59
59
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -106,132 +106,6 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
|
|
|
106
106
|
return next;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function resolveCanonicalCalendarDate(value) {
|
|
110
|
-
if (value instanceof Date) {
|
|
111
|
-
return Number.isNaN(value.getTime())
|
|
112
|
-
? ""
|
|
113
|
-
: value.toISOString().slice(0, 10);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const normalized = typeof value === "string" ? value.trim() : "";
|
|
117
|
-
const candidate = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/u)?.[1] || "";
|
|
118
|
-
if (!JSON_REST_CALENDAR_DATE_PATTERN.test(candidate)) {
|
|
119
|
-
return "";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const parsed = new Date(`${candidate}T00:00:00.000Z`);
|
|
123
|
-
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== candidate) {
|
|
124
|
-
return "";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return candidate;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function serializeJsonRestCalendarDate(value) {
|
|
131
|
-
if (value == null) {
|
|
132
|
-
return value;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const canonical = resolveCanonicalCalendarDate(value);
|
|
136
|
-
if (!canonical) {
|
|
137
|
-
throw new TypeError("json-rest-api calendar date must be a valid YYYY-MM-DD value.");
|
|
138
|
-
}
|
|
139
|
-
return canonical;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function resolveCanonicalDateTime(value) {
|
|
143
|
-
if (value instanceof Date) {
|
|
144
|
-
return Number.isNaN(value.getTime()) ? "" : value.toISOString();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const normalized = typeof value === "string" ? value.trim() : "";
|
|
148
|
-
if (JSON_REST_DATE_TIME_PATTERN.test(normalized)) {
|
|
149
|
-
return normalized;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const databaseMatch = JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN.exec(normalized);
|
|
153
|
-
return databaseMatch ? `${databaseMatch[1]}T${databaseMatch[2]}Z` : "";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
|
|
157
|
-
const schema = normalizeJsonRestObject(scopeOptions.schema);
|
|
158
|
-
for (const fieldDefinition of Object.values(schema)) {
|
|
159
|
-
if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const storage = normalizeJsonRestObject(fieldDefinition.storage);
|
|
167
|
-
if (typeof storage.serialize === "function") {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
fieldDefinition.storage = {
|
|
171
|
-
...storage,
|
|
172
|
-
serialize: serializeJsonRestCalendarDate
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function normalizeJsonRestTemporalEntry(entry = null, scopes = {}) {
|
|
178
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
179
|
-
return entry;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const schema = normalizeJsonRestObject(
|
|
183
|
-
scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
|
|
184
|
-
);
|
|
185
|
-
const attributes = normalizeJsonRestObject(entry.attributes);
|
|
186
|
-
for (const [fieldName, definition] of Object.entries(schema)) {
|
|
187
|
-
if (!Object.hasOwn(attributes, fieldName) || attributes[fieldName] == null) {
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const fieldType = normalizeJsonRestText(definition?.type).toLowerCase();
|
|
192
|
-
const canonical = fieldType === "date"
|
|
193
|
-
? resolveCanonicalCalendarDate(attributes[fieldName])
|
|
194
|
-
: fieldType === "datetime"
|
|
195
|
-
? resolveCanonicalDateTime(attributes[fieldName])
|
|
196
|
-
: "";
|
|
197
|
-
if (canonical) {
|
|
198
|
-
attributes[fieldName] = canonical;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return entry;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function normalizeJsonRestTemporalDocument(document = null, scopes = {}) {
|
|
206
|
-
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
207
|
-
return document;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const data = Array.isArray(document.data) ? document.data : [document.data];
|
|
211
|
-
for (const entry of data) {
|
|
212
|
-
normalizeJsonRestTemporalEntry(entry, scopes);
|
|
213
|
-
}
|
|
214
|
-
for (const entry of Array.isArray(document.included) ? document.included : []) {
|
|
215
|
-
normalizeJsonRestTemporalEntry(entry, scopes);
|
|
216
|
-
}
|
|
217
|
-
return document;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const JsonRestTemporalPlugin = Object.freeze({
|
|
221
|
-
name: "jskit-temporal",
|
|
222
|
-
dependencies: ["rest-api"],
|
|
223
|
-
install({ addHook, scopes }) {
|
|
224
|
-
addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
|
|
225
|
-
if (context?.record) {
|
|
226
|
-
normalizeJsonRestTemporalDocument(context.record, scopes);
|
|
227
|
-
}
|
|
228
|
-
if (context?.responseRecord) {
|
|
229
|
-
normalizeJsonRestTemporalDocument(context.responseRecord, scopes);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
|
|
235
109
|
async function addResourceIfMissing(api, scopeName, resourceConfig) {
|
|
236
110
|
if (api?.resources?.[scopeName]) {
|
|
237
111
|
return api.resources[scopeName];
|
|
@@ -572,94 +446,11 @@ function buildJsonRestQueryParams(resourceType = "", query = {}, { include = und
|
|
|
572
446
|
return queryParams;
|
|
573
447
|
}
|
|
574
448
|
|
|
575
|
-
function extractJsonRestCollectionRows(payload
|
|
576
|
-
if (Array.isArray(payload)) {
|
|
577
|
-
|
|
449
|
+
function extractJsonRestCollectionRows(payload) {
|
|
450
|
+
if (!isPlainJsonRestObject(payload) || !Array.isArray(payload.data)) {
|
|
451
|
+
throw new TypeError("json-rest-api collection response must contain a data array.");
|
|
578
452
|
}
|
|
579
|
-
|
|
580
|
-
const source = normalizeJsonRestObject(payload);
|
|
581
|
-
return Array.isArray(source.data) ? source.data : [];
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function extractJsonApiInputRelationships(attributes = {}, resource = null, relationships = null) {
|
|
585
|
-
const normalizedAttributes = {
|
|
586
|
-
...normalizeJsonRestObject(attributes)
|
|
587
|
-
};
|
|
588
|
-
const normalizedRelationships = {
|
|
589
|
-
...normalizeJsonRestObject(relationships)
|
|
590
|
-
};
|
|
591
|
-
const resourceSchema = normalizeJsonRestObject(resource?.schema);
|
|
592
|
-
|
|
593
|
-
for (const [fieldName, fieldDefinition] of Object.entries(resourceSchema)) {
|
|
594
|
-
if (!Object.hasOwn(normalizedAttributes, fieldName)) {
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const normalizedFieldDefinition = normalizeJsonRestObject(fieldDefinition);
|
|
599
|
-
const relationshipType = normalizeJsonRestText(normalizedFieldDefinition.belongsTo);
|
|
600
|
-
if (!relationshipType) {
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const relationshipName = normalizeJsonRestText(normalizedFieldDefinition.as, {
|
|
605
|
-
fallback: fieldName
|
|
606
|
-
});
|
|
607
|
-
if (!relationshipName) {
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const relationshipValue = normalizedAttributes[fieldName];
|
|
612
|
-
delete normalizedAttributes[fieldName];
|
|
613
|
-
|
|
614
|
-
if (relationshipValue === undefined) {
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (!Object.hasOwn(normalizedRelationships, relationshipName)) {
|
|
619
|
-
normalizedRelationships[relationshipName] = createJsonApiRelationship(
|
|
620
|
-
relationshipType,
|
|
621
|
-
relationshipValue
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
return {
|
|
627
|
-
attributes: normalizedAttributes,
|
|
628
|
-
relationships: normalizedRelationships
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function createJsonApiInputRecord(
|
|
633
|
-
resourceType = "",
|
|
634
|
-
attributes = {},
|
|
635
|
-
{ id = null, relationships = null, resource = null } = {}
|
|
636
|
-
) {
|
|
637
|
-
const normalizedInput = extractJsonApiInputRelationships(attributes, resource, relationships);
|
|
638
|
-
return {
|
|
639
|
-
data: {
|
|
640
|
-
type: normalizeJsonRestText(resourceType),
|
|
641
|
-
...(id == null ? {} : { id: String(id) }),
|
|
642
|
-
attributes: normalizedInput.attributes,
|
|
643
|
-
...(Object.keys(normalizedInput.relationships).length < 1
|
|
644
|
-
? {}
|
|
645
|
-
: { relationships: normalizedInput.relationships })
|
|
646
|
-
}
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function createJsonApiRelationship(resourceType = "", id = null) {
|
|
651
|
-
if (id == null) {
|
|
652
|
-
return {
|
|
653
|
-
data: null
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return {
|
|
658
|
-
data: {
|
|
659
|
-
type: normalizeJsonRestText(resourceType),
|
|
660
|
-
id: String(id)
|
|
661
|
-
}
|
|
662
|
-
};
|
|
453
|
+
return payload.data;
|
|
663
454
|
}
|
|
664
455
|
|
|
665
456
|
function createJsonRestResourceScopeOptions(
|
|
@@ -675,7 +466,6 @@ function createJsonRestResourceScopeOptions(
|
|
|
675
466
|
const scopeOptions = cloneJsonRestResourceValue(resource, {
|
|
676
467
|
writeSerializers: normalizeJsonRestObject(writeSerializers)
|
|
677
468
|
});
|
|
678
|
-
applyJsonRestCalendarDateWriteSerializers(scopeOptions);
|
|
679
469
|
if (isPlainJsonRestObject(searchSchema)) {
|
|
680
470
|
scopeOptions.searchSchema = {
|
|
681
471
|
...normalizeJsonRestObject(scopeOptions.searchSchema),
|
|
@@ -767,7 +557,8 @@ async function returnNullWhenJsonRestResourceMissing(run) {
|
|
|
767
557
|
try {
|
|
768
558
|
return await run();
|
|
769
559
|
} catch (error) {
|
|
770
|
-
if (isJsonRestResourceMissingError(error)
|
|
560
|
+
if (isJsonRestResourceMissingError(error) &&
|
|
561
|
+
(!error.transactionOutcome || ["none", "rolledBack"].includes(error.transactionOutcome))) {
|
|
771
562
|
return null;
|
|
772
563
|
}
|
|
773
564
|
|
|
@@ -791,32 +582,30 @@ async function returnBadRequestWhenJsonRestFieldsetInvalid(run) {
|
|
|
791
582
|
throw error;
|
|
792
583
|
}
|
|
793
584
|
|
|
794
|
-
|
|
585
|
+
const mapped = new AppError(400, error.message, {
|
|
795
586
|
code: "JSON_API_FIELDSET_INVALID"
|
|
796
587
|
});
|
|
588
|
+
Object.defineProperty(mapped, "cause", { value: error, configurable: true });
|
|
589
|
+
if (error.transactionOutcome) {
|
|
590
|
+
mapped.transactionOutcome = error.transactionOutcome;
|
|
591
|
+
}
|
|
592
|
+
throw mapped;
|
|
797
593
|
}
|
|
798
594
|
}
|
|
799
595
|
|
|
800
|
-
async function createJsonRestApiHost({ knex }) {
|
|
596
|
+
async function createJsonRestApiHost({ knex, logger = JSON_REST_DEFAULT_LOGGER }) {
|
|
801
597
|
if (typeof knex !== "function") {
|
|
802
598
|
throw new TypeError("createJsonRestApiHost requires knex.");
|
|
803
599
|
}
|
|
804
600
|
|
|
805
|
-
const api = new
|
|
601
|
+
const api = new JsonRestApi({
|
|
806
602
|
name: "jskit-internal-json-rest-api",
|
|
807
|
-
|
|
808
|
-
level: "error"
|
|
809
|
-
}
|
|
603
|
+
logger
|
|
810
604
|
});
|
|
811
605
|
|
|
812
606
|
await api.use(RestApiPlugin, {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
returnRecordApi: {
|
|
816
|
-
post: "full",
|
|
817
|
-
put: "full",
|
|
818
|
-
patch: "full"
|
|
819
|
-
},
|
|
607
|
+
format: "plain",
|
|
608
|
+
returning: "full",
|
|
820
609
|
normalizeId: normalizeRecordId
|
|
821
610
|
});
|
|
822
611
|
|
|
@@ -830,7 +619,6 @@ async function createJsonRestApiHost({ knex }) {
|
|
|
830
619
|
},
|
|
831
620
|
presets: JSON_REST_AUTOFILTER_PRESETS
|
|
832
621
|
});
|
|
833
|
-
await api.use(JsonRestTemporalPlugin);
|
|
834
622
|
|
|
835
623
|
return api;
|
|
836
624
|
}
|
|
@@ -839,8 +627,6 @@ export {
|
|
|
839
627
|
JSON_REST_AUTOFILTER_PRESETS,
|
|
840
628
|
addResourceIfMissing,
|
|
841
629
|
buildJsonRestQueryParams,
|
|
842
|
-
createJsonApiInputRecord,
|
|
843
|
-
createJsonApiRelationship,
|
|
844
630
|
createJsonRestResourceScopeOptions,
|
|
845
631
|
createJsonRestContext,
|
|
846
632
|
extractJsonRestCollectionRows,
|
|
@@ -2,14 +2,13 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import knexLib from "knex";
|
|
5
6
|
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
6
|
-
import { RestApiFieldsetError } from "json-rest-api";
|
|
7
|
+
import { JsonRestApi, RestApiFieldsetError, RestApiWriteError } from "json-rest-api";
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
addResourceIfMissing,
|
|
10
11
|
buildJsonRestQueryParams,
|
|
11
|
-
createJsonApiInputRecord,
|
|
12
|
-
createJsonApiRelationship,
|
|
13
12
|
createJsonRestResourceScopeOptions,
|
|
14
13
|
createJsonRestContext,
|
|
15
14
|
createJsonRestApiHost,
|
|
@@ -27,19 +26,18 @@ test("package exports include explicit server jsonRestApiHost entrypoint only",
|
|
|
27
26
|
const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
|
|
28
27
|
assert.equal(exportsMap["./server/jsonRestApiHost"], "./src/server/jsonRestApiHost.js");
|
|
29
28
|
assert.equal(exportsMap["./server"], undefined);
|
|
30
|
-
assert.equal(packageJson.dependencies?.["json-rest-api"], "^1.0.29");
|
|
31
29
|
});
|
|
32
30
|
|
|
33
31
|
test("server jsonRestApiHost entrypoint exposes only the focused host API", async () => {
|
|
34
32
|
const hostModule = await import("../src/server/jsonRestApiHost.js");
|
|
35
33
|
assert.equal(Object.hasOwn(hostModule, "simplifyJsonApiDocument"), false);
|
|
34
|
+
assert.equal(Object.hasOwn(hostModule, "createJsonApiInputRecord"), false);
|
|
35
|
+
assert.equal(Object.hasOwn(hostModule, "createJsonApiRelationship"), false);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("server entrypoint exports shared host helpers", () => {
|
|
39
39
|
assert.equal(typeof addResourceIfMissing, "function");
|
|
40
40
|
assert.equal(typeof buildJsonRestQueryParams, "function");
|
|
41
|
-
assert.equal(typeof createJsonApiInputRecord, "function");
|
|
42
|
-
assert.equal(typeof createJsonApiRelationship, "function");
|
|
43
41
|
assert.equal(typeof createJsonRestResourceScopeOptions, "function");
|
|
44
42
|
assert.equal(typeof createJsonRestContext, "function");
|
|
45
43
|
assert.equal(typeof createJsonRestApiHost, "function");
|
|
@@ -95,7 +93,6 @@ test("createJsonRestContext returns an empty mutable object when source context
|
|
|
95
93
|
test("extractJsonRestCollectionRows understands the internal collection-document contract", () => {
|
|
96
94
|
const rows = [{ id: "1" }, { id: "2" }];
|
|
97
95
|
|
|
98
|
-
assert.deepEqual(extractJsonRestCollectionRows(rows), rows);
|
|
99
96
|
assert.deepEqual(
|
|
100
97
|
extractJsonRestCollectionRows({
|
|
101
98
|
data: rows,
|
|
@@ -105,8 +102,10 @@ test("extractJsonRestCollectionRows understands the internal collection-document
|
|
|
105
102
|
}),
|
|
106
103
|
rows
|
|
107
104
|
);
|
|
108
|
-
assert.deepEqual(extractJsonRestCollectionRows({ data:
|
|
109
|
-
|
|
105
|
+
assert.deepEqual(extractJsonRestCollectionRows({ data: [] }), []);
|
|
106
|
+
for (const malformed of [rows, { data: null }, {}, null, undefined]) {
|
|
107
|
+
assert.throws(() => extractJsonRestCollectionRows(malformed), /collection response must contain a data array/);
|
|
108
|
+
}
|
|
110
109
|
});
|
|
111
110
|
|
|
112
111
|
test("createJsonRestApiHost installs normalizeRecordId as the default resource id normalizer", async () => {
|
|
@@ -136,7 +135,7 @@ test("createJsonRestApiHost installs normalizeRecordId as the default resource i
|
|
|
136
135
|
assert.equal(api.vars.normalizeId(7.5), null);
|
|
137
136
|
});
|
|
138
137
|
|
|
139
|
-
test("createJsonRestApiHost
|
|
138
|
+
test("createJsonRestApiHost uses the v2 runtime, error-only default logging and an injected logger", async (t) => {
|
|
140
139
|
const fakeKnex = Object.assign(() => {}, {
|
|
141
140
|
client: {
|
|
142
141
|
config: {
|
|
@@ -153,11 +152,25 @@ test("createJsonRestApiHost configures the internal json-rest logger at error le
|
|
|
153
152
|
transaction() {}
|
|
154
153
|
});
|
|
155
154
|
|
|
155
|
+
const messages = [];
|
|
156
156
|
const api = await createJsonRestApiHost({
|
|
157
|
-
knex: fakeKnex
|
|
157
|
+
knex: fakeKnex,
|
|
158
|
+
logger: { error: (...args) => messages.push(args) }
|
|
158
159
|
});
|
|
159
160
|
|
|
160
|
-
assert.
|
|
161
|
+
assert.ok(api instanceof JsonRestApi);
|
|
162
|
+
assert.equal(api.vars.format, "plain");
|
|
163
|
+
assert.equal(api.vars.returning, "full");
|
|
164
|
+
assert.equal(api.options.logging, undefined);
|
|
165
|
+
api.log.error("failure", { code: "example" });
|
|
166
|
+
assert.deepEqual(messages, [["failure", { code: "example" }]]);
|
|
167
|
+
|
|
168
|
+
const errorSink = t.mock.method(console, "error", () => {});
|
|
169
|
+
const defaultApi = await createJsonRestApiHost({ knex: fakeKnex });
|
|
170
|
+
defaultApi.log.info("quiet");
|
|
171
|
+
defaultApi.log.error("default failure");
|
|
172
|
+
assert.equal(errorSink.mock.callCount(), 1);
|
|
173
|
+
assert.deepEqual(errorSink.mock.calls[0].arguments, ["default failure"]);
|
|
161
174
|
});
|
|
162
175
|
|
|
163
176
|
test("createJsonRestApiHost installs row policies before resources are added", async () => {
|
|
@@ -200,7 +213,7 @@ test("createJsonRestApiHost installs row policies before resources are added", a
|
|
|
200
213
|
assert.equal(scopeOptions.rowPolicy, rowPolicy);
|
|
201
214
|
});
|
|
202
215
|
|
|
203
|
-
test("shared query
|
|
216
|
+
test("shared query helper builds json-rest-api request shapes", () => {
|
|
204
217
|
assert.deepEqual(
|
|
205
218
|
buildJsonRestQueryParams("contacts", {
|
|
206
219
|
q: "Merc",
|
|
@@ -232,70 +245,6 @@ test("shared query/document helpers build json-rest-api request shapes", () => {
|
|
|
232
245
|
}
|
|
233
246
|
);
|
|
234
247
|
|
|
235
|
-
assert.deepEqual(
|
|
236
|
-
createJsonApiInputRecord("contacts", {
|
|
237
|
-
name: "Merc"
|
|
238
|
-
}, {
|
|
239
|
-
id: 7,
|
|
240
|
-
relationships: {
|
|
241
|
-
workspace: createJsonApiRelationship("workspaces", 9)
|
|
242
|
-
}
|
|
243
|
-
}),
|
|
244
|
-
{
|
|
245
|
-
data: {
|
|
246
|
-
type: "contacts",
|
|
247
|
-
id: "7",
|
|
248
|
-
attributes: {
|
|
249
|
-
name: "Merc"
|
|
250
|
-
},
|
|
251
|
-
relationships: {
|
|
252
|
-
workspace: {
|
|
253
|
-
data: {
|
|
254
|
-
type: "workspaces",
|
|
255
|
-
id: "9"
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
assert.deepEqual(
|
|
264
|
-
createJsonApiInputRecord("products", {
|
|
265
|
-
serviceId: "9",
|
|
266
|
-
name: "Style Groom"
|
|
267
|
-
}, {
|
|
268
|
-
resource: {
|
|
269
|
-
schema: {
|
|
270
|
-
serviceId: {
|
|
271
|
-
type: "id",
|
|
272
|
-
belongsTo: "services",
|
|
273
|
-
as: "service"
|
|
274
|
-
},
|
|
275
|
-
name: {
|
|
276
|
-
type: "string"
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}),
|
|
281
|
-
{
|
|
282
|
-
data: {
|
|
283
|
-
type: "products",
|
|
284
|
-
attributes: {
|
|
285
|
-
name: "Style Groom"
|
|
286
|
-
},
|
|
287
|
-
relationships: {
|
|
288
|
-
service: {
|
|
289
|
-
data: {
|
|
290
|
-
type: "services",
|
|
291
|
-
id: "9"
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
);
|
|
298
|
-
|
|
299
248
|
});
|
|
300
249
|
|
|
301
250
|
test("buildJsonRestQueryParams preserves structured filter values", () => {
|
|
@@ -424,16 +373,7 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
|
|
|
424
373
|
assert.equal(result.schema.createdAt.storage.serialize, serializer);
|
|
425
374
|
assert.equal(result.schema.createdAt.storage.serialize(null), null);
|
|
426
375
|
assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
|
|
427
|
-
assert.equal(
|
|
428
|
-
result.schema.publishedOn.storage.serialize(new Date("2024-02-29T00:00:00.000Z")),
|
|
429
|
-
"2024-02-29"
|
|
430
|
-
);
|
|
431
|
-
assert.equal(result.schema.publishedOn.storage.serialize("2024-02-29"), "2024-02-29");
|
|
432
|
-
assert.equal(result.schema.publishedOn.storage.serialize(null), null);
|
|
433
|
-
assert.throws(
|
|
434
|
-
() => result.schema.publishedOn.storage.serialize("2023-02-29"),
|
|
435
|
-
/valid YYYY-MM-DD/
|
|
436
|
-
);
|
|
376
|
+
assert.equal(result.schema.publishedOn.storage, undefined);
|
|
437
377
|
assert.equal(result.schema.bookingSteps.virtual, true);
|
|
438
378
|
assert.equal(result.schema.pets.virtual, true);
|
|
439
379
|
assert.equal(result.normalizeId, normalizeId);
|
|
@@ -604,22 +544,18 @@ test("createJsonRestResourceScopeOptions rejects query field names for column-ba
|
|
|
604
544
|
);
|
|
605
545
|
});
|
|
606
546
|
|
|
607
|
-
test("createJsonRestApiHost installs json-rest-api query projections", async () => {
|
|
608
|
-
const
|
|
609
|
-
client:
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
},
|
|
614
|
-
raw(sql) {
|
|
615
|
-
if (String(sql || "").includes("sqlite_version")) {
|
|
616
|
-
return [{ version: "3.35.5" }];
|
|
617
|
-
}
|
|
618
|
-
return { sql };
|
|
619
|
-
},
|
|
620
|
-
async transaction() {}
|
|
547
|
+
test("createJsonRestApiHost installs json-rest-api query projections", async (t) => {
|
|
548
|
+
const knex = knexLib({
|
|
549
|
+
client: "better-sqlite3",
|
|
550
|
+
connection: { filename: ":memory:" },
|
|
551
|
+
useNullAsDefault: true
|
|
621
552
|
});
|
|
622
|
-
|
|
553
|
+
t.after(() => knex.destroy());
|
|
554
|
+
await knex.schema.createTable("projection_contacts", (table) => {
|
|
555
|
+
table.increments("id");
|
|
556
|
+
});
|
|
557
|
+
await knex("projection_contacts").insert({ id: 1 });
|
|
558
|
+
const api = await createJsonRestApiHost({ knex });
|
|
623
559
|
|
|
624
560
|
await api.addResource("projectionContacts", {
|
|
625
561
|
tableName: "projection_contacts",
|
|
@@ -636,130 +572,119 @@ test("createJsonRestApiHost installs json-rest-api query projections", async ()
|
|
|
636
572
|
}
|
|
637
573
|
});
|
|
638
574
|
|
|
639
|
-
|
|
575
|
+
const result = await api.resources.projectionContacts.query({ format: "plain" });
|
|
576
|
+
assert.deepEqual(result.data, [{ id: "1", displayName: "Display" }]);
|
|
640
577
|
});
|
|
641
578
|
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
client:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
},
|
|
649
|
-
async raw() {
|
|
650
|
-
return [{ version: "3.35.5" }];
|
|
651
|
-
},
|
|
652
|
-
transaction() {}
|
|
579
|
+
async function createTemporalFixture() {
|
|
580
|
+
const knex = knexLib({
|
|
581
|
+
client: "better-sqlite3",
|
|
582
|
+
connection: { filename: ":memory:" },
|
|
583
|
+
useNullAsDefault: true
|
|
653
584
|
});
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const scheduledAt = new Date("2024-02-29T12:34:56.000Z");
|
|
674
|
-
const record = {
|
|
675
|
-
data: {
|
|
676
|
-
type: "books",
|
|
677
|
-
id: "1",
|
|
678
|
-
attributes: {
|
|
679
|
-
publishedOn: new Date("2024-02-29T00:00:00.000Z"),
|
|
680
|
-
scheduledAt,
|
|
681
|
-
opensAt: "09:45:00"
|
|
585
|
+
try {
|
|
586
|
+
await knex.schema.createTable("holidays", (table) => {
|
|
587
|
+
table.increments("id");
|
|
588
|
+
table.date("observed_on");
|
|
589
|
+
});
|
|
590
|
+
await knex.schema.createTable("books", (table) => {
|
|
591
|
+
table.increments("id");
|
|
592
|
+
table.date("published_on");
|
|
593
|
+
table.dateTime("scheduled_at");
|
|
594
|
+
table.time("opens_at");
|
|
595
|
+
table.integer("holiday_id").references("id").inTable("holidays");
|
|
596
|
+
});
|
|
597
|
+
const api = await createJsonRestApiHost({ knex });
|
|
598
|
+
await api.addResource("holidays", createJsonRestResourceScopeOptions({
|
|
599
|
+
tableName: "holidays",
|
|
600
|
+
schema: {
|
|
601
|
+
id: { type: "id", primary: true },
|
|
602
|
+
observedOn: { type: "date" }
|
|
682
603
|
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
604
|
+
}));
|
|
605
|
+
await api.addResource("books", createJsonRestResourceScopeOptions({
|
|
606
|
+
tableName: "books",
|
|
607
|
+
schema: {
|
|
608
|
+
id: { type: "id", primary: true },
|
|
609
|
+
publishedOn: { type: "date" },
|
|
610
|
+
scheduledAt: { type: "dateTime" },
|
|
611
|
+
opensAt: { type: "time" },
|
|
612
|
+
holidayId: { type: "id", belongsTo: "holidays", as: "holiday", nullable: true }
|
|
689
613
|
}
|
|
690
|
-
}
|
|
691
|
-
|
|
614
|
+
}));
|
|
615
|
+
return { api, knex };
|
|
616
|
+
} catch (error) {
|
|
617
|
+
await knex.destroy();
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
692
621
|
|
|
693
|
-
|
|
694
|
-
|
|
622
|
+
test("createJsonRestApiHost uses core temporal normalization for writes, reads and includes", async (t) => {
|
|
623
|
+
const { api, knex } = await createTemporalFixture();
|
|
624
|
+
t.after(() => knex.destroy());
|
|
625
|
+
const holiday = await api.resources.holidays.post({ data: { observedOn: "2024-03-01" } });
|
|
626
|
+
const book = await api.resources.books.post({
|
|
627
|
+
data: {
|
|
628
|
+
publishedOn: "2024-02-29",
|
|
629
|
+
scheduledAt: "2024-02-29T20:34:56+08:00",
|
|
630
|
+
opensAt: "09:45",
|
|
631
|
+
holiday: holiday.id
|
|
632
|
+
}
|
|
695
633
|
});
|
|
696
634
|
|
|
697
|
-
assert.equal(
|
|
698
|
-
assert.equal(
|
|
699
|
-
assert.equal(
|
|
700
|
-
|
|
635
|
+
assert.equal(book.publishedOn, "2024-02-29");
|
|
636
|
+
assert.equal(book.scheduledAt, "2024-02-29T12:34:56.000Z");
|
|
637
|
+
assert.equal(book.opensAt, "09:45:00");
|
|
638
|
+
const document = await api.resources.books.get({
|
|
639
|
+
id: book.id,
|
|
640
|
+
format: "jsonapi",
|
|
641
|
+
queryParams: { include: ["holiday"] }
|
|
642
|
+
});
|
|
643
|
+
assert.equal(document.data.attributes.publishedOn, "2024-02-29");
|
|
644
|
+
assert.equal(document.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
|
|
645
|
+
assert.equal(document.included[0].attributes.observedOn, "2024-03-01");
|
|
646
|
+
await assert.rejects(
|
|
647
|
+
() => api.resources.books.patch({ id: book.id, data: { publishedOn: "2023-02-29" } }),
|
|
648
|
+
(error) => error.code === "REST_API_VALIDATION"
|
|
649
|
+
);
|
|
701
650
|
});
|
|
702
651
|
|
|
703
652
|
test("calendar date writes and responses keep leap day across server time zones", () => {
|
|
704
|
-
const hostModuleUrl = new URL(
|
|
705
|
-
"../src/server/jsonRestApiHost.js",
|
|
706
|
-
import.meta.url
|
|
707
|
-
).href;
|
|
653
|
+
const hostModuleUrl = new URL("../src/server/jsonRestApiHost.js", import.meta.url).href;
|
|
708
654
|
const script = `
|
|
709
|
-
import
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const api = await createJsonRestApiHost({ knex: fakeKnex });
|
|
719
|
-
const scopeOptions = createJsonRestResourceScopeOptions({
|
|
720
|
-
tableName: "books",
|
|
721
|
-
schema: {
|
|
722
|
-
id: { type: "id", primary: true },
|
|
723
|
-
publishedOn: { type: "date" },
|
|
724
|
-
scheduledAt: { type: "dateTime" },
|
|
725
|
-
opensAt: { type: "time" }
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
await api.addResource("books", scopeOptions);
|
|
729
|
-
const record = {
|
|
730
|
-
data: {
|
|
731
|
-
type: "books",
|
|
732
|
-
id: "1",
|
|
733
|
-
attributes: {
|
|
734
|
-
publishedOn: new Date("2024-02-29T00:00:00.000Z"),
|
|
735
|
-
scheduledAt: new Date("2024-02-29T12:34:56.000Z"),
|
|
655
|
+
import knexLib from "knex";
|
|
656
|
+
import { createJsonRestApiHost, createJsonRestResourceScopeOptions } from ${JSON.stringify(hostModuleUrl)};
|
|
657
|
+
${createTemporalFixture.toString()}
|
|
658
|
+
const { api, knex } = await createTemporalFixture();
|
|
659
|
+
try {
|
|
660
|
+
const book = await api.resources.books.post({
|
|
661
|
+
data: {
|
|
662
|
+
publishedOn: "2024-02-29",
|
|
663
|
+
scheduledAt: "2024-02-29T12:34:56.000Z",
|
|
736
664
|
opensAt: "09:45:00"
|
|
737
665
|
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
666
|
+
});
|
|
667
|
+
const row = await knex("books").where({ id: book.id }).first();
|
|
668
|
+
process.stdout.write(JSON.stringify({
|
|
669
|
+
write: row.published_on,
|
|
670
|
+
publishedOn: book.publishedOn,
|
|
671
|
+
scheduledAt: book.scheduledAt,
|
|
672
|
+
opensAt: book.opensAt
|
|
673
|
+
}));
|
|
674
|
+
} finally {
|
|
675
|
+
await knex.destroy();
|
|
676
|
+
}
|
|
749
677
|
`;
|
|
750
678
|
|
|
751
679
|
for (const timezone of ["UTC", "Australia/Perth", "America/Los_Angeles", "Pacific/Kiritimati"]) {
|
|
752
680
|
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
753
681
|
encoding: "utf8",
|
|
754
|
-
env: {
|
|
755
|
-
...process.env,
|
|
756
|
-
TZ: timezone
|
|
757
|
-
}
|
|
682
|
+
env: { ...process.env, TZ: timezone }
|
|
758
683
|
});
|
|
759
684
|
|
|
760
685
|
assert.equal(result.status, 0, result.stderr);
|
|
761
686
|
assert.deepEqual(JSON.parse(result.stdout), {
|
|
762
|
-
write: "2024-02-
|
|
687
|
+
write: Date.parse("2024-02-29T00:00:00.000Z"),
|
|
763
688
|
publishedOn: "2024-02-29",
|
|
764
689
|
scheduledAt: "2024-02-29T12:34:56.000Z",
|
|
765
690
|
opensAt: "09:45:00"
|
|
@@ -767,36 +692,17 @@ test("calendar date writes and responses keep leap day across server time zones"
|
|
|
767
692
|
}
|
|
768
693
|
});
|
|
769
694
|
|
|
770
|
-
test("createJsonRestApiHost maps
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
695
|
+
test("createJsonRestApiHost maps native database datetime strings through core reads", async (t) => {
|
|
696
|
+
const { api, knex } = await createTemporalFixture();
|
|
697
|
+
t.after(() => knex.destroy());
|
|
698
|
+
await knex("books").insert({
|
|
699
|
+
id: 1,
|
|
700
|
+
published_on: "2024-02-29",
|
|
701
|
+
scheduled_at: "2024-02-29 12:34:56.123",
|
|
702
|
+
opens_at: "09:45:00"
|
|
777
703
|
});
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
await api.addResource("jobs", createJsonRestResourceScopeOptions({
|
|
781
|
-
tableName: "jobs",
|
|
782
|
-
schema: {
|
|
783
|
-
id: { type: "id", primary: true },
|
|
784
|
-
scheduledAt: { type: "dateTime" }
|
|
785
|
-
}
|
|
786
|
-
}));
|
|
787
|
-
const record = {
|
|
788
|
-
data: {
|
|
789
|
-
type: "jobs",
|
|
790
|
-
id: "1",
|
|
791
|
-
attributes: {
|
|
792
|
-
scheduledAt: "2024-02-29 12:34:56.123"
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
};
|
|
796
|
-
|
|
797
|
-
await api.runHooks("finish", { record });
|
|
798
|
-
|
|
799
|
-
assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.123Z");
|
|
704
|
+
const book = await api.resources.books.get({ id: "1" });
|
|
705
|
+
assert.equal(book.scheduledAt, "2024-02-29T12:34:56.123Z");
|
|
800
706
|
});
|
|
801
707
|
|
|
802
708
|
test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
|
|
@@ -841,6 +747,7 @@ test("typed invalid sparse fields become a stable 400 without message matching",
|
|
|
841
747
|
assert.equal(error.status, 400);
|
|
842
748
|
assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
|
|
843
749
|
assert.equal(error.message, sparseFieldError.message);
|
|
750
|
+
assert.equal(error.cause, sparseFieldError);
|
|
844
751
|
return true;
|
|
845
752
|
}
|
|
846
753
|
);
|
|
@@ -862,6 +769,47 @@ test("typed invalid sparse fields become a stable 400 without message matching",
|
|
|
862
769
|
);
|
|
863
770
|
});
|
|
864
771
|
|
|
772
|
+
test("missing-resource mapping preserves failures from unresolved or committed writes", async () => {
|
|
773
|
+
const cause = Object.freeze({ code: "REST_API_RESOURCE", subtype: "not_found" });
|
|
774
|
+
for (const transactionOutcome of ["pending", "committed", "unknown"]) {
|
|
775
|
+
const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
|
|
776
|
+
await assert.rejects(
|
|
777
|
+
() => returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)),
|
|
778
|
+
(error) => error === failure
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
for (const transactionOutcome of ["none", "rolledBack"]) {
|
|
782
|
+
const failure = new RestApiWriteError("Missing resource", { cause, transactionOutcome });
|
|
783
|
+
assert.equal(await returnNullWhenJsonRestResourceMissing(() => Promise.reject(failure)), null);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("fieldset mapping retains write outcome and the original error chain", async () => {
|
|
788
|
+
const cause = Object.freeze(new RestApiFieldsetError({ field: "privateNotes", resourceType: "books" }));
|
|
789
|
+
const failure = new RestApiWriteError(cause.message, { cause, transactionOutcome: "committed" });
|
|
790
|
+
await assert.rejects(
|
|
791
|
+
() => returnBadRequestWhenJsonRestFieldsetInvalid(() => Promise.reject(failure)),
|
|
792
|
+
(error) => {
|
|
793
|
+
assert.equal(error.code, "JSON_API_FIELDSET_INVALID");
|
|
794
|
+
assert.equal(error.transactionOutcome, "committed");
|
|
795
|
+
assert.equal(error.cause, failure);
|
|
796
|
+
assert.equal(error.cause.cause, cause);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("unrelated primitive failures remain unchanged through host error mapping", async () => {
|
|
803
|
+
for (const failure of [null, undefined, "failure", 7]) {
|
|
804
|
+
for (const mapError of [returnNullWhenJsonRestResourceMissing, returnBadRequestWhenJsonRestFieldsetInvalid]) {
|
|
805
|
+
await assert.rejects(
|
|
806
|
+
() => mapError(() => Promise.reject(failure)),
|
|
807
|
+
(error) => error === failure
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
865
813
|
test("scope resolvers understand explicit scopeValues and JSKIT visibilityContext", () => {
|
|
866
814
|
assert.equal(resolveWorkspaceScopeValue({
|
|
867
815
|
scopeValues: {
|