@jskit-ai/json-rest-api-core 0.1.90 → 0.1.92
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.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/json-rest-api-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.92",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Shared internal json-rest-api host runtime with autofilter, query-projection, and row-policy support.",
|
|
7
7
|
dependsOn: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/json-rest-api-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.92",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"hooked-api": "1.x.x",
|
|
13
13
|
"json-rest-api": "^1.0.27",
|
|
14
|
-
"@jskit-ai/kernel": "0.1.
|
|
14
|
+
"@jskit-ai/kernel": "0.1.147"
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -53,6 +53,9 @@ const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
|
|
|
53
53
|
"sort",
|
|
54
54
|
"fields"
|
|
55
55
|
]));
|
|
56
|
+
const JSON_REST_CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
|
|
57
|
+
const JSON_REST_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
|
|
58
|
+
const JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) ((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?)$/u;
|
|
56
59
|
|
|
57
60
|
function isPlainJsonRestObject(value) {
|
|
58
61
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -105,6 +108,132 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
|
|
|
105
108
|
return next;
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
function resolveCanonicalCalendarDate(value) {
|
|
112
|
+
if (value instanceof Date) {
|
|
113
|
+
return Number.isNaN(value.getTime())
|
|
114
|
+
? ""
|
|
115
|
+
: value.toISOString().slice(0, 10);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
119
|
+
const candidate = normalized.match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/u)?.[1] || "";
|
|
120
|
+
if (!JSON_REST_CALENDAR_DATE_PATTERN.test(candidate)) {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed = new Date(`${candidate}T00:00:00.000Z`);
|
|
125
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== candidate) {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return candidate;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function serializeJsonRestCalendarDate(value) {
|
|
133
|
+
if (value == null) {
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const canonical = resolveCanonicalCalendarDate(value);
|
|
138
|
+
if (!canonical) {
|
|
139
|
+
throw new TypeError("json-rest-api calendar date must be a valid YYYY-MM-DD value.");
|
|
140
|
+
}
|
|
141
|
+
return canonical;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveCanonicalDateTime(value) {
|
|
145
|
+
if (value instanceof Date) {
|
|
146
|
+
return Number.isNaN(value.getTime()) ? "" : value.toISOString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
150
|
+
if (JSON_REST_DATE_TIME_PATTERN.test(normalized)) {
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const databaseMatch = JSON_REST_DATABASE_UTC_DATE_TIME_PATTERN.exec(normalized);
|
|
155
|
+
return databaseMatch ? `${databaseMatch[1]}T${databaseMatch[2]}Z` : "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
|
|
159
|
+
const schema = normalizeJsonRestObject(scopeOptions.schema);
|
|
160
|
+
for (const fieldDefinition of Object.values(schema)) {
|
|
161
|
+
if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const storage = normalizeJsonRestObject(fieldDefinition.storage);
|
|
169
|
+
if (typeof storage.serialize === "function") {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
fieldDefinition.storage = {
|
|
173
|
+
...storage,
|
|
174
|
+
serialize: serializeJsonRestCalendarDate
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeJsonRestTemporalEntry(entry = null, scopes = {}) {
|
|
180
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
181
|
+
return entry;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const schema = normalizeJsonRestObject(
|
|
185
|
+
scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
|
|
186
|
+
);
|
|
187
|
+
const attributes = normalizeJsonRestObject(entry.attributes);
|
|
188
|
+
for (const [fieldName, definition] of Object.entries(schema)) {
|
|
189
|
+
if (!Object.hasOwn(attributes, fieldName) || attributes[fieldName] == null) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const fieldType = normalizeJsonRestText(definition?.type).toLowerCase();
|
|
194
|
+
const canonical = fieldType === "date"
|
|
195
|
+
? resolveCanonicalCalendarDate(attributes[fieldName])
|
|
196
|
+
: fieldType === "datetime"
|
|
197
|
+
? resolveCanonicalDateTime(attributes[fieldName])
|
|
198
|
+
: "";
|
|
199
|
+
if (canonical) {
|
|
200
|
+
attributes[fieldName] = canonical;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeJsonRestTemporalDocument(document = null, scopes = {}) {
|
|
208
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
209
|
+
return document;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const data = Array.isArray(document.data) ? document.data : [document.data];
|
|
213
|
+
for (const entry of data) {
|
|
214
|
+
normalizeJsonRestTemporalEntry(entry, scopes);
|
|
215
|
+
}
|
|
216
|
+
for (const entry of Array.isArray(document.included) ? document.included : []) {
|
|
217
|
+
normalizeJsonRestTemporalEntry(entry, scopes);
|
|
218
|
+
}
|
|
219
|
+
return document;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const JsonRestTemporalPlugin = Object.freeze({
|
|
223
|
+
name: "jskit-temporal",
|
|
224
|
+
dependencies: ["rest-api"],
|
|
225
|
+
install({ addHook, scopes }) {
|
|
226
|
+
addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
|
|
227
|
+
if (context?.record) {
|
|
228
|
+
normalizeJsonRestTemporalDocument(context.record, scopes);
|
|
229
|
+
}
|
|
230
|
+
if (context?.responseRecord) {
|
|
231
|
+
normalizeJsonRestTemporalDocument(context.responseRecord, scopes);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
108
237
|
async function addResourceIfMissing(api, scopeName, resourceConfig) {
|
|
109
238
|
if (api?.resources?.[scopeName]) {
|
|
110
239
|
return api.resources[scopeName];
|
|
@@ -548,6 +677,7 @@ function createJsonRestResourceScopeOptions(
|
|
|
548
677
|
const scopeOptions = cloneJsonRestResourceValue(resource, {
|
|
549
678
|
writeSerializers: normalizeJsonRestObject(writeSerializers)
|
|
550
679
|
});
|
|
680
|
+
applyJsonRestCalendarDateWriteSerializers(scopeOptions);
|
|
551
681
|
if (isPlainJsonRestObject(searchSchema)) {
|
|
552
682
|
scopeOptions.searchSchema = {
|
|
553
683
|
...normalizeJsonRestObject(scopeOptions.searchSchema),
|
|
@@ -702,6 +832,7 @@ async function createJsonRestApiHost({ knex }) {
|
|
|
702
832
|
},
|
|
703
833
|
presets: JSON_REST_AUTOFILTER_PRESETS
|
|
704
834
|
});
|
|
835
|
+
await api.use(JsonRestTemporalPlugin);
|
|
705
836
|
|
|
706
837
|
return api;
|
|
707
838
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import test from "node:test";
|
|
4
5
|
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
@@ -363,6 +364,14 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
|
|
|
363
364
|
})
|
|
364
365
|
})
|
|
365
366
|
}),
|
|
367
|
+
publishedOn: Object.freeze({
|
|
368
|
+
type: "date",
|
|
369
|
+
operations: Object.freeze({
|
|
370
|
+
output: Object.freeze({
|
|
371
|
+
required: true
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
}),
|
|
366
375
|
bookingSteps: Object.freeze({
|
|
367
376
|
type: "array",
|
|
368
377
|
storage: Object.freeze({
|
|
@@ -417,6 +426,16 @@ test("createJsonRestResourceScopeOptions clones canonical resource metadata and
|
|
|
417
426
|
assert.equal(result.schema.createdAt.storage.serialize, serializer);
|
|
418
427
|
assert.equal(result.schema.createdAt.storage.serialize(null), null);
|
|
419
428
|
assert.equal(result.schema.createdAt.storage.writeSerializer, undefined);
|
|
429
|
+
assert.equal(
|
|
430
|
+
result.schema.publishedOn.storage.serialize(new Date("2024-02-29T00:00:00.000Z")),
|
|
431
|
+
"2024-02-29"
|
|
432
|
+
);
|
|
433
|
+
assert.equal(result.schema.publishedOn.storage.serialize("2024-02-29"), "2024-02-29");
|
|
434
|
+
assert.equal(result.schema.publishedOn.storage.serialize(null), null);
|
|
435
|
+
assert.throws(
|
|
436
|
+
() => result.schema.publishedOn.storage.serialize("2023-02-29"),
|
|
437
|
+
/valid YYYY-MM-DD/
|
|
438
|
+
);
|
|
420
439
|
assert.equal(result.schema.bookingSteps.virtual, true);
|
|
421
440
|
assert.equal(result.schema.pets.virtual, true);
|
|
422
441
|
assert.equal(result.normalizeId, normalizeId);
|
|
@@ -622,6 +641,166 @@ test("createJsonRestApiHost installs json-rest-api query projections", async ()
|
|
|
622
641
|
assert.equal(typeof api.resources.projectionContacts.vars.queryFields.displayName.select, "function");
|
|
623
642
|
});
|
|
624
643
|
|
|
644
|
+
test("createJsonRestApiHost returns JSON-native temporal values from database records", async () => {
|
|
645
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
646
|
+
client: {
|
|
647
|
+
config: {
|
|
648
|
+
client: "sqlite3"
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
async raw() {
|
|
652
|
+
return [{ version: "3.35.5" }];
|
|
653
|
+
},
|
|
654
|
+
transaction() {}
|
|
655
|
+
});
|
|
656
|
+
const api = await createJsonRestApiHost({ knex: fakeKnex });
|
|
657
|
+
|
|
658
|
+
await api.addResource("books", createJsonRestResourceScopeOptions({
|
|
659
|
+
tableName: "books",
|
|
660
|
+
schema: {
|
|
661
|
+
id: { type: "id", primary: true },
|
|
662
|
+
publishedOn: { type: "date" },
|
|
663
|
+
scheduledAt: { type: "dateTime" },
|
|
664
|
+
opensAt: { type: "time" }
|
|
665
|
+
}
|
|
666
|
+
}));
|
|
667
|
+
await api.addResource("holidays", createJsonRestResourceScopeOptions({
|
|
668
|
+
tableName: "holidays",
|
|
669
|
+
schema: {
|
|
670
|
+
id: { type: "id", primary: true },
|
|
671
|
+
observedOn: { type: "date" }
|
|
672
|
+
}
|
|
673
|
+
}));
|
|
674
|
+
|
|
675
|
+
const scheduledAt = new Date("2024-02-29T12:34:56.000Z");
|
|
676
|
+
const record = {
|
|
677
|
+
data: {
|
|
678
|
+
type: "books",
|
|
679
|
+
id: "1",
|
|
680
|
+
attributes: {
|
|
681
|
+
publishedOn: new Date("2024-02-29T00:00:00.000Z"),
|
|
682
|
+
scheduledAt,
|
|
683
|
+
opensAt: "09:45:00"
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
included: [{
|
|
687
|
+
type: "holidays",
|
|
688
|
+
id: "7",
|
|
689
|
+
attributes: {
|
|
690
|
+
observedOn: new Date("2024-03-01T00:00:00.000Z")
|
|
691
|
+
}
|
|
692
|
+
}]
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
await api.runHooks("finish", {
|
|
696
|
+
record
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
assert.equal(record.data.attributes.publishedOn, "2024-02-29");
|
|
700
|
+
assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.000Z");
|
|
701
|
+
assert.equal(record.data.attributes.opensAt, "09:45:00");
|
|
702
|
+
assert.equal(record.included[0].attributes.observedOn, "2024-03-01");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("calendar date writes and responses keep leap day across server time zones", () => {
|
|
706
|
+
const hostModuleUrl = new URL(
|
|
707
|
+
"../src/server/jsonRestApiHost.js",
|
|
708
|
+
import.meta.url
|
|
709
|
+
).href;
|
|
710
|
+
const script = `
|
|
711
|
+
import {
|
|
712
|
+
createJsonRestApiHost,
|
|
713
|
+
createJsonRestResourceScopeOptions
|
|
714
|
+
} from ${JSON.stringify(hostModuleUrl)};
|
|
715
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
716
|
+
client: { config: { client: "sqlite3" } },
|
|
717
|
+
async raw() { return [{ version: "3.35.5" }]; },
|
|
718
|
+
transaction() {}
|
|
719
|
+
});
|
|
720
|
+
const api = await createJsonRestApiHost({ knex: fakeKnex });
|
|
721
|
+
const scopeOptions = createJsonRestResourceScopeOptions({
|
|
722
|
+
tableName: "books",
|
|
723
|
+
schema: {
|
|
724
|
+
id: { type: "id", primary: true },
|
|
725
|
+
publishedOn: { type: "date" },
|
|
726
|
+
scheduledAt: { type: "dateTime" },
|
|
727
|
+
opensAt: { type: "time" }
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
await api.addResource("books", scopeOptions);
|
|
731
|
+
const record = {
|
|
732
|
+
data: {
|
|
733
|
+
type: "books",
|
|
734
|
+
id: "1",
|
|
735
|
+
attributes: {
|
|
736
|
+
publishedOn: new Date("2024-02-29T00:00:00.000Z"),
|
|
737
|
+
scheduledAt: new Date("2024-02-29T12:34:56.000Z"),
|
|
738
|
+
opensAt: "09:45:00"
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
await api.runHooks("finish", { record });
|
|
743
|
+
process.stdout.write(JSON.stringify({
|
|
744
|
+
write: scopeOptions.schema.publishedOn.storage.serialize(
|
|
745
|
+
new Date("2024-02-29T00:00:00.000Z")
|
|
746
|
+
),
|
|
747
|
+
publishedOn: record.data.attributes.publishedOn,
|
|
748
|
+
scheduledAt: record.data.attributes.scheduledAt,
|
|
749
|
+
opensAt: record.data.attributes.opensAt
|
|
750
|
+
}));
|
|
751
|
+
`;
|
|
752
|
+
|
|
753
|
+
for (const timezone of ["UTC", "Australia/Perth", "America/Los_Angeles", "Pacific/Kiritimati"]) {
|
|
754
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script], {
|
|
755
|
+
encoding: "utf8",
|
|
756
|
+
env: {
|
|
757
|
+
...process.env,
|
|
758
|
+
TZ: timezone
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
assert.equal(result.status, 0, result.stderr);
|
|
763
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
764
|
+
write: "2024-02-29",
|
|
765
|
+
publishedOn: "2024-02-29",
|
|
766
|
+
scheduledAt: "2024-02-29T12:34:56.000Z",
|
|
767
|
+
opensAt: "09:45:00"
|
|
768
|
+
}, timezone);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("createJsonRestApiHost maps UTC database datetimes to RFC 3339 strings", async () => {
|
|
773
|
+
const fakeKnex = Object.assign(() => {}, {
|
|
774
|
+
client: { config: { client: "sqlite3" } },
|
|
775
|
+
async raw() {
|
|
776
|
+
return [{ version: "3.35.5" }];
|
|
777
|
+
},
|
|
778
|
+
transaction() {}
|
|
779
|
+
});
|
|
780
|
+
const api = await createJsonRestApiHost({ knex: fakeKnex });
|
|
781
|
+
|
|
782
|
+
await api.addResource("jobs", createJsonRestResourceScopeOptions({
|
|
783
|
+
tableName: "jobs",
|
|
784
|
+
schema: {
|
|
785
|
+
id: { type: "id", primary: true },
|
|
786
|
+
scheduledAt: { type: "dateTime" }
|
|
787
|
+
}
|
|
788
|
+
}));
|
|
789
|
+
const record = {
|
|
790
|
+
data: {
|
|
791
|
+
type: "jobs",
|
|
792
|
+
id: "1",
|
|
793
|
+
attributes: {
|
|
794
|
+
scheduledAt: "2024-02-29 12:34:56.123"
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
await api.runHooks("finish", { record });
|
|
800
|
+
|
|
801
|
+
assert.equal(record.data.attributes.scheduledAt, "2024-02-29T12:34:56.123Z");
|
|
802
|
+
});
|
|
803
|
+
|
|
625
804
|
test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
|
|
626
805
|
await assert.doesNotReject(async () => {
|
|
627
806
|
const result = await returnNullWhenJsonRestResourceMissing(async () => {
|