@jskit-ai/json-rest-api-core 0.1.89 → 0.1.91
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.91",
|
|
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.91",
|
|
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.146"
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -53,6 +53,7 @@ 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;
|
|
56
57
|
|
|
57
58
|
function isPlainJsonRestObject(value) {
|
|
58
59
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -105,6 +106,117 @@ function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
|
|
|
105
106
|
return next;
|
|
106
107
|
}
|
|
107
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 applyJsonRestCalendarDateWriteSerializers(scopeOptions = {}) {
|
|
143
|
+
const schema = normalizeJsonRestObject(scopeOptions.schema);
|
|
144
|
+
for (const fieldDefinition of Object.values(schema)) {
|
|
145
|
+
if (normalizeJsonRestText(fieldDefinition?.type).toLowerCase() !== "date") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (fieldDefinition?.virtual === true || fieldDefinition?.storage?.virtual === true) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const storage = normalizeJsonRestObject(fieldDefinition.storage);
|
|
153
|
+
if (typeof storage.serialize === "function") {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
fieldDefinition.storage = {
|
|
157
|
+
...storage,
|
|
158
|
+
serialize: serializeJsonRestCalendarDate
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeJsonRestCalendarDateEntry(entry = null, scopes = {}) {
|
|
164
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
165
|
+
return entry;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const schema = normalizeJsonRestObject(
|
|
169
|
+
scopes?.[normalizeJsonRestText(entry.type)]?.vars?.schemaInfo?.schemaStructure
|
|
170
|
+
);
|
|
171
|
+
const attributes = normalizeJsonRestObject(entry.attributes);
|
|
172
|
+
for (const [fieldName, definition] of Object.entries(schema)) {
|
|
173
|
+
if (
|
|
174
|
+
normalizeJsonRestText(definition?.type).toLowerCase() !== "date" ||
|
|
175
|
+
!Object.hasOwn(attributes, fieldName) ||
|
|
176
|
+
attributes[fieldName] == null
|
|
177
|
+
) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const canonical = resolveCanonicalCalendarDate(attributes[fieldName]);
|
|
182
|
+
if (canonical) {
|
|
183
|
+
attributes[fieldName] = canonical;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return entry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeJsonRestCalendarDateDocument(document = null, scopes = {}) {
|
|
191
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
192
|
+
return document;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const data = Array.isArray(document.data) ? document.data : [document.data];
|
|
196
|
+
for (const entry of data) {
|
|
197
|
+
normalizeJsonRestCalendarDateEntry(entry, scopes);
|
|
198
|
+
}
|
|
199
|
+
for (const entry of Array.isArray(document.included) ? document.included : []) {
|
|
200
|
+
normalizeJsonRestCalendarDateEntry(entry, scopes);
|
|
201
|
+
}
|
|
202
|
+
return document;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const JsonRestCalendarDatePlugin = Object.freeze({
|
|
206
|
+
name: "jskit-calendar-date",
|
|
207
|
+
dependencies: ["rest-api"],
|
|
208
|
+
install({ addHook, scopes }) {
|
|
209
|
+
addHook("finish", "normalizeCalendarDateDocuments", {}, ({ context }) => {
|
|
210
|
+
if (context?.record) {
|
|
211
|
+
normalizeJsonRestCalendarDateDocument(context.record, scopes);
|
|
212
|
+
}
|
|
213
|
+
if (context?.responseRecord) {
|
|
214
|
+
normalizeJsonRestCalendarDateDocument(context.responseRecord, scopes);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
108
220
|
async function addResourceIfMissing(api, scopeName, resourceConfig) {
|
|
109
221
|
if (api?.resources?.[scopeName]) {
|
|
110
222
|
return api.resources[scopeName];
|
|
@@ -548,6 +660,7 @@ function createJsonRestResourceScopeOptions(
|
|
|
548
660
|
const scopeOptions = cloneJsonRestResourceValue(resource, {
|
|
549
661
|
writeSerializers: normalizeJsonRestObject(writeSerializers)
|
|
550
662
|
});
|
|
663
|
+
applyJsonRestCalendarDateWriteSerializers(scopeOptions);
|
|
551
664
|
if (isPlainJsonRestObject(searchSchema)) {
|
|
552
665
|
scopeOptions.searchSchema = {
|
|
553
666
|
...normalizeJsonRestObject(scopeOptions.searchSchema),
|
|
@@ -702,6 +815,7 @@ async function createJsonRestApiHost({ knex }) {
|
|
|
702
815
|
},
|
|
703
816
|
presets: JSON_REST_AUTOFILTER_PRESETS
|
|
704
817
|
});
|
|
818
|
+
await api.use(JsonRestCalendarDatePlugin);
|
|
705
819
|
|
|
706
820
|
return api;
|
|
707
821
|
}
|
|
@@ -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,134 @@ 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 calendar dates as YYYY-MM-DD without changing date-time or time fields", 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, scheduledAt);
|
|
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
|
+
|
|
625
772
|
test("returnNullWhenJsonRestResourceMissing only swallows missing-resource errors", async () => {
|
|
626
773
|
await assert.doesNotReject(async () => {
|
|
627
774
|
const result = await returnNullWhenJsonRestResourceMissing(async () => {
|