json-rest-api 1.0.27 → 1.0.29
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/docs/API.md +2 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +11 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +3 -3
- package/docs/GUIDE/GUIDE_Y_Appendices.md +57 -29
- package/index.js +1 -0
- package/lib/rest-api-errors.js +35 -0
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +12 -1
- package/plugins/core/file-handling-plugin.js +3 -0
- package/plugins/core/lib/anyapi/schema-utils.js +2 -1
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -2
- package/plugins/core/lib/dbTablesOperations.js +20 -10
- package/plugins/core/lib/querying/knex-pagination-helpers.js +29 -13
- package/plugins/core/lib/querying/knex-relationship-includes.js +18 -7
- package/plugins/core/lib/querying/query-field-sort-helpers.js +93 -15
- package/plugins/core/lib/querying/sort-helpers.js +8 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +297 -143
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +15 -1
- package/plugins/core/lib/querying-writing/request-contracts.js +16 -6
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
- package/plugins/core/lib/storage/storage-adapter.js +19 -3
- package/plugins/core/lib/storage/storage-mapping.js +7 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +36 -60
- package/plugins/core/rest-api-knex-plugin.js +28 -20
- package/plugins/core/rest-api-plugin-methods/common.js +15 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +3 -0
- package/plugins/core/rest-api-plugin-methods/get.js +8 -1
- package/plugins/core/rest-api-plugin-methods/query.js +8 -1
- package/tests/anyapi-cursor-pagination.test.js +1 -3
- package/tests/computed-fields.test.js +21 -0
- package/tests/db-table-operations.test.js +13 -1
- package/tests/file-handling.test.js +37 -0
- package/tests/fixtures/api-configs.js +140 -3
- package/tests/pagination-boundaries.test.js +154 -0
- package/tests/pagination-contract.test.js +122 -0
- package/tests/pagination-cursor-multifield.test.js +5 -3
- package/tests/relationship-include-storage-adapter.test.js +56 -0
- package/tests/storage-mapping.test.js +112 -10
- package/tests/temporal-boundaries.test.js +208 -0
|
@@ -4,6 +4,11 @@ import knexLib from 'knex'
|
|
|
4
4
|
import { Api } from 'hooked-api'
|
|
5
5
|
import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
|
|
6
6
|
import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
|
|
7
|
+
import {
|
|
8
|
+
REST_API_TEMPORAL_DATA_ERROR_CODE,
|
|
9
|
+
RestApiTemporalDataError
|
|
10
|
+
} from '../lib/rest-api-errors.js'
|
|
11
|
+
import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
|
|
7
12
|
import {
|
|
8
13
|
cleanTables,
|
|
9
14
|
createJsonApiDocument,
|
|
@@ -23,14 +28,16 @@ const knex = knexLib({
|
|
|
23
28
|
})
|
|
24
29
|
|
|
25
30
|
let api
|
|
31
|
+
const DEFAULT_PROFILE_CREATED_AT = '2026-08-25T12:34:56.789Z'
|
|
32
|
+
const COMPUTED_PROFILE_DATE = new Date('2026-08-25T23:45:01.987Z')
|
|
26
33
|
|
|
27
34
|
const serializeDateTimeForSql = (value) => {
|
|
28
35
|
if (value == null) return null
|
|
29
|
-
if (
|
|
30
|
-
throw new Error('serializeDateTimeForSql expects
|
|
36
|
+
if (typeof value !== 'string') {
|
|
37
|
+
throw new Error('serializeDateTimeForSql expects an RFC 3339 string after schema validation')
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
return value.toISOString().replace('T', ' ').slice(0, 19)
|
|
40
|
+
return new Date(value).toISOString().replace('T', ' ').slice(0, 19)
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
describe('Storage mapping', () => {
|
|
@@ -76,10 +83,27 @@ describe('Storage mapping', () => {
|
|
|
76
83
|
lastSeenAt: {
|
|
77
84
|
type: 'dateTime',
|
|
78
85
|
nullable: true,
|
|
86
|
+
temporalPrecision: 0,
|
|
87
|
+
getter: (value) => value == null ? value : new Date(value),
|
|
79
88
|
storage: {
|
|
80
89
|
serialize: serializeDateTimeForSql
|
|
81
90
|
}
|
|
82
91
|
},
|
|
92
|
+
createdAt: {
|
|
93
|
+
type: 'dateTime',
|
|
94
|
+
temporalPrecision: 3,
|
|
95
|
+
defaultTo: () => new Date(DEFAULT_PROFILE_CREATED_AT).toISOString()
|
|
96
|
+
},
|
|
97
|
+
calculatedAt: {
|
|
98
|
+
type: 'dateTime',
|
|
99
|
+
temporalPrecision: 0,
|
|
100
|
+
computed: true,
|
|
101
|
+
compute: () => new Date(COMPUTED_PROFILE_DATE)
|
|
102
|
+
},
|
|
103
|
+
birthDate: { type: 'date', nullable: true },
|
|
104
|
+
openingTime: { type: 'time', nullable: true, temporalPrecision: 2 },
|
|
105
|
+
observedAtMs: { type: 'epochMilliseconds', nullable: true },
|
|
106
|
+
observedAtSeconds: { type: 'epochSeconds', nullable: true },
|
|
83
107
|
countryId: {
|
|
84
108
|
type: 'id',
|
|
85
109
|
nullable: true,
|
|
@@ -126,7 +150,11 @@ describe('Storage mapping', () => {
|
|
|
126
150
|
{
|
|
127
151
|
displayName: 'Mercury',
|
|
128
152
|
loginCount: 7,
|
|
129
|
-
lastSeenAt: '2026-01-
|
|
153
|
+
lastSeenAt: '2026-01-02T11:04:05+08:00',
|
|
154
|
+
birthDate: '2020-02-29',
|
|
155
|
+
openingTime: '09:30:15.12',
|
|
156
|
+
observedAtMs: '1767323045000',
|
|
157
|
+
observedAtSeconds: '1767323045',
|
|
130
158
|
externalRef: 'legacy-001',
|
|
131
159
|
confirmationToken: 'secret-token'
|
|
132
160
|
},
|
|
@@ -142,8 +170,13 @@ describe('Storage mapping', () => {
|
|
|
142
170
|
loginCount: 7
|
|
143
171
|
})
|
|
144
172
|
assert.equal(createResult.data.attributes.confirmationToken, 'secret-token')
|
|
145
|
-
assert.
|
|
146
|
-
assert.equal(createResult.data.attributes.
|
|
173
|
+
assert.equal(createResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
|
|
174
|
+
assert.equal(createResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
|
|
175
|
+
assert.equal(createResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
|
|
176
|
+
assert.equal(createResult.data.attributes.birthDate, '2020-02-29')
|
|
177
|
+
assert.equal(createResult.data.attributes.openingTime, '09:30:15.12')
|
|
178
|
+
assert.equal(createResult.data.attributes.observedAtMs, 1767323045000)
|
|
179
|
+
assert.equal(createResult.data.attributes.observedAtSeconds, 1767323045)
|
|
147
180
|
assert.ok(!('display_name' in createResult.data.attributes))
|
|
148
181
|
assert.ok(!('country_id' in createResult.data.attributes))
|
|
149
182
|
|
|
@@ -151,6 +184,8 @@ describe('Storage mapping', () => {
|
|
|
151
184
|
assert.equal(dbRecord.display_name, 'Mercury')
|
|
152
185
|
assert.equal(dbRecord.login_count, 7)
|
|
153
186
|
assert.equal(dbRecord.last_seen_at, '2026-01-02 03:04:05')
|
|
187
|
+
assert.equal(dbRecord.observed_at_ms, 1767323045000)
|
|
188
|
+
assert.equal(dbRecord.observed_at_seconds, 1767323045)
|
|
154
189
|
assert.equal(String(dbRecord.country_id), country.data.id)
|
|
155
190
|
assert.equal(dbRecord.legacy_ref, 'legacy-001')
|
|
156
191
|
assert.ok(!('displayName' in dbRecord))
|
|
@@ -169,8 +204,13 @@ describe('Storage mapping', () => {
|
|
|
169
204
|
displayName: 'Mercury',
|
|
170
205
|
loginCount: 7
|
|
171
206
|
})
|
|
172
|
-
assert.
|
|
173
|
-
assert.equal(getResult.data.attributes.
|
|
207
|
+
assert.equal(getResult.data.attributes.lastSeenAt, '2026-01-02T03:04:05Z')
|
|
208
|
+
assert.equal(getResult.data.attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
|
|
209
|
+
assert.equal(getResult.data.attributes.calculatedAt, '2026-08-25T23:45:01Z')
|
|
210
|
+
assert.equal(getResult.data.attributes.birthDate, '2020-02-29')
|
|
211
|
+
assert.equal(getResult.data.attributes.openingTime, '09:30:15.12')
|
|
212
|
+
assert.equal(getResult.data.attributes.observedAtMs, 1767323045000)
|
|
213
|
+
assert.equal(getResult.data.attributes.observedAtSeconds, 1767323045)
|
|
174
214
|
assert.ok(!('display_name' in getResult.data.attributes))
|
|
175
215
|
assertResourceRelationship(
|
|
176
216
|
getResult.data,
|
|
@@ -193,6 +233,8 @@ describe('Storage mapping', () => {
|
|
|
193
233
|
displayName: 'Mercury',
|
|
194
234
|
externalRef: 'legacy-001'
|
|
195
235
|
})
|
|
236
|
+
assert.equal(queryResult.data[0].attributes.createdAt, DEFAULT_PROFILE_CREATED_AT)
|
|
237
|
+
assert.equal(queryResult.data[0].attributes.calculatedAt, '2026-08-25T23:45:01Z')
|
|
196
238
|
})
|
|
197
239
|
|
|
198
240
|
it('applies storage mapping on PATCH updates and sparse fieldsets', async () => {
|
|
@@ -228,8 +270,7 @@ describe('Storage mapping', () => {
|
|
|
228
270
|
assert.deepEqual(Object.keys(patchResult.data.attributes).sort(), ['confirmationToken', 'displayName', 'lastSeenAt'])
|
|
229
271
|
assert.equal(patchResult.data.attributes.displayName, 'After')
|
|
230
272
|
assert.equal(patchResult.data.attributes.confirmationToken, 'updated-token')
|
|
231
|
-
assert.
|
|
232
|
-
assert.equal(patchResult.data.attributes.lastSeenAt.toISOString(), '2026-02-03T04:05:06.000Z')
|
|
273
|
+
assert.equal(patchResult.data.attributes.lastSeenAt, '2026-02-03T04:05:06Z')
|
|
233
274
|
|
|
234
275
|
const dbRecord = await knex('mapped_profiles').where('id', created.data.id).first()
|
|
235
276
|
assert.equal(dbRecord.display_name, 'After')
|
|
@@ -237,6 +278,67 @@ describe('Storage mapping', () => {
|
|
|
237
278
|
assert.ok(!('displayName' in dbRecord))
|
|
238
279
|
})
|
|
239
280
|
|
|
281
|
+
it('rejects temporal values outside the public JSON contract before storage', async () => {
|
|
282
|
+
const invalidTemporalValues = [
|
|
283
|
+
{ lastSeenAt: '2026-01-02 03:04:05' },
|
|
284
|
+
{ lastSeenAt: new Date('2026-01-02T03:04:05Z') },
|
|
285
|
+
{ birthDate: '2026-02-30' },
|
|
286
|
+
{ openingTime: '09:30Z' }
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
for (const [index, temporalAttributes] of invalidTemporalValues.entries()) {
|
|
290
|
+
await assert.rejects(
|
|
291
|
+
api.resources.profiles.post({
|
|
292
|
+
inputRecord: createJsonApiDocument('profiles', {
|
|
293
|
+
displayName: `Invalid temporal value ${index}`,
|
|
294
|
+
...temporalAttributes
|
|
295
|
+
})
|
|
296
|
+
}),
|
|
297
|
+
(error) => error?.code === 'REST_API_VALIDATION'
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('fails loudly when storage returns an invalid non-null temporal value', async () => {
|
|
303
|
+
const insertedIds = await knex('mapped_profiles').insert({
|
|
304
|
+
display_name: 'Corrupt temporal row',
|
|
305
|
+
login_count: 0,
|
|
306
|
+
last_seen_at: 'not-a-date',
|
|
307
|
+
created_at: new Date(DEFAULT_PROFILE_CREATED_AT)
|
|
308
|
+
})
|
|
309
|
+
const profileId = insertedIds[0]
|
|
310
|
+
|
|
311
|
+
await assert.rejects(
|
|
312
|
+
api.resources.profiles.get({ id: profileId }),
|
|
313
|
+
(error) => {
|
|
314
|
+
assert(error instanceof RestApiTemporalDataError)
|
|
315
|
+
assert.equal(error.code, REST_API_TEMPORAL_DATA_ERROR_CODE)
|
|
316
|
+
assert.equal(
|
|
317
|
+
error.message,
|
|
318
|
+
"Invalid database value for dateTime field 'profiles.lastSeenAt'."
|
|
319
|
+
)
|
|
320
|
+
assert.deepEqual(error.details, {
|
|
321
|
+
field: 'lastSeenAt',
|
|
322
|
+
resourceType: 'profiles',
|
|
323
|
+
fieldType: 'dateTime',
|
|
324
|
+
source: 'database'
|
|
325
|
+
})
|
|
326
|
+
assert.deepEqual(mapRestApiErrorToHttp(error), {
|
|
327
|
+
status: 500,
|
|
328
|
+
body: {
|
|
329
|
+
errors: [{
|
|
330
|
+
status: '500',
|
|
331
|
+
code: REST_API_TEMPORAL_DATA_ERROR_CODE,
|
|
332
|
+
title: 'Invalid Temporal Data',
|
|
333
|
+
detail: "Invalid database value for dateTime field 'profiles.lastSeenAt'."
|
|
334
|
+
}]
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
|
|
240
342
|
it('can opt out of snake_case translation for an entire resource', async () => {
|
|
241
343
|
const created = await api.resources.verbatim_profiles.post({
|
|
242
344
|
inputRecord: createJsonApiDocument('verbatim_profiles', {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { after, before, beforeEach, describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { applyFieldSetters, handleRecordReturnAfterWrite } from '../plugins/core/rest-api-plugin-methods/common.js'
|
|
4
|
+
import knexLib from 'knex'
|
|
5
|
+
import { createTemporalBoundaryApi } from './fixtures/api-configs.js'
|
|
6
|
+
import { cleanTables, createJsonApiDocument } from './helpers/test-utils.js'
|
|
7
|
+
import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
|
|
8
|
+
import { normalizeAttributes, normalizeRecordAttributes } from '../plugins/core/lib/querying-writing/database-value-normalizers.js'
|
|
9
|
+
import { storageMode } from './helpers/storage-mode.js'
|
|
10
|
+
import { RestApiTemporalDataError } from '../lib/rest-api-errors.js'
|
|
11
|
+
|
|
12
|
+
const knex = knexLib({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })
|
|
13
|
+
let api
|
|
14
|
+
let finishValue
|
|
15
|
+
const document = (attributes = {}) => createJsonApiDocument('events', { name: 'Event', ...attributes })
|
|
16
|
+
const assertValidationError = (error) => {
|
|
17
|
+
assert.equal(error.code, 'REST_API_VALIDATION')
|
|
18
|
+
assert.equal(mapRestApiErrorToHttp(error).status, 422)
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('Temporal storage and response boundaries', () => {
|
|
23
|
+
before(async () => {
|
|
24
|
+
api = await createTemporalBoundaryApi(knex)
|
|
25
|
+
const handler = async ({ context }) => {
|
|
26
|
+
if (finishValue === undefined || context.scopeName !== 'events') return
|
|
27
|
+
const attributes = context.simplified ? context.responseRecord : context.responseRecord?.data?.attributes
|
|
28
|
+
if (attributes) attributes.producedAt = finishValue
|
|
29
|
+
if (context.simplified && attributes?.person) attributes.person.producedAt = finishValue
|
|
30
|
+
for (const included of context.responseRecord?.included || []) included.attributes.producedAt = finishValue
|
|
31
|
+
}
|
|
32
|
+
await api.customize({ hooks: {
|
|
33
|
+
finishPost: { functionName: 'temporal-finish-post', handler },
|
|
34
|
+
finishPut: { functionName: 'temporal-finish-put', handler },
|
|
35
|
+
finishPatch: { functionName: 'temporal-finish-patch', handler }
|
|
36
|
+
} })
|
|
37
|
+
})
|
|
38
|
+
after(async () => { await knex.destroy() })
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
finishValue = undefined
|
|
41
|
+
await cleanTables(knex, ['temporal_events', 'temporal_people'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('rejects submillisecond writes without inserting or changing data', async () => {
|
|
45
|
+
const precise = '2026-09-01T10:20:30.123456Z'
|
|
46
|
+
await assert.rejects(api.resources.events.post({ inputRecord: document({ occurredAt: precise }) }), assertValidationError)
|
|
47
|
+
assert.equal((await api.resources.events.query()).data.length, 0)
|
|
48
|
+
const created = await api.resources.events.post({ inputRecord: document({ occurredAt: '2026-09-01T10:20:30.123000Z' }) })
|
|
49
|
+
for (const method of ['put', 'patch']) {
|
|
50
|
+
const inputRecord = document({ occurredAt: precise })
|
|
51
|
+
inputRecord.data.id = created.data.id
|
|
52
|
+
await assert.rejects(api.resources.events[method]({ id: created.data.id, inputRecord }), assertValidationError)
|
|
53
|
+
const result = await api.resources.events.get({ id: created.data.id })
|
|
54
|
+
assert.equal(result.data.attributes.occurredAt, '2026-09-01T10:20:30.123Z')
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('retains submillisecond precision supplied by storage, including timezone offsets', () => {
|
|
59
|
+
for (const value of ['2026-09-01 10:20:30.123456', '2026-09-01T18:20:30.123456+08:00']) {
|
|
60
|
+
const result = normalizeAttributes({ occurredAt: value }, { occurredAt: { type: 'dateTime', temporalPrecision: 6 } })
|
|
61
|
+
assert.equal(result.occurredAt, '2026-09-01T10:20:30.123456Z')
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('normalizes numeric epoch values returned as strings or bigints by drivers', () => {
|
|
66
|
+
const schema = { milliseconds: { type: 'epochMilliseconds' }, seconds: { type: 'epochSeconds' } }
|
|
67
|
+
assert.deepEqual(normalizeAttributes({ milliseconds: '1788258030123', seconds: 1788258030n }, schema), {
|
|
68
|
+
milliseconds: 1788258030123, seconds: 1788258030
|
|
69
|
+
})
|
|
70
|
+
for (const value of ['bad', '8640000000000001', 1.25]) {
|
|
71
|
+
assert.throws(() => normalizeAttributes({ milliseconds: value }, schema), { code: 'REST_API_TEMPORAL_DATA_INVALID' })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('normalizes temporal query projections and follows their cursors', async () => {
|
|
76
|
+
for (let index = 0; index < 4; index++) {
|
|
77
|
+
await api.resources.events.post({ inputRecord: document({ occurredAt: `2026-09-01T10:20:3${index}.123Z` }) })
|
|
78
|
+
}
|
|
79
|
+
const queryParams = { sort: ['projectedAt'], fields: { events: 'projectedAt' }, page: { size: 2 } }
|
|
80
|
+
const first = await api.resources.events.query({ queryParams })
|
|
81
|
+
assert.equal(first.data[0].attributes.projectedAt, '2026-09-01T10:20:30.123Z')
|
|
82
|
+
const second = await api.resources.events.query({ queryParams: {
|
|
83
|
+
...queryParams, page: { size: 2, after: first.meta.pagination.cursor.next }
|
|
84
|
+
} })
|
|
85
|
+
assert.equal(second.data[0].attributes.projectedAt, '2026-09-01T10:20:32.123Z')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('uses a custom serializer consistently for writes and filters', { skip: storageMode.isAnyApi() }, async () => {
|
|
89
|
+
const value = '2026-09-01T10:20:30.123456Z'
|
|
90
|
+
const created = await api.resources.events.post({ inputRecord: document({ serializedAt: value }) })
|
|
91
|
+
assert.equal(created.data.attributes.serializedAt, value)
|
|
92
|
+
const filtered = await api.resources.events.query({ queryParams: { filters: { serializedAt: value } } })
|
|
93
|
+
assert.deepEqual(filtered.data.map(entry => entry.id), [created.data.id])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('validates included fieldsets even when the relationship or collection is empty', async () => {
|
|
97
|
+
const queryParams = { include: ['person'], fields: { people: 'missingField' } }
|
|
98
|
+
await assert.rejects(api.resources.events.query({ queryParams }), { code: 'REST_API_FIELDSET_INVALID' })
|
|
99
|
+
const created = await api.resources.events.post({ inputRecord: document() })
|
|
100
|
+
await assert.rejects(api.resources.events.get({ id: created.data.id, queryParams }), { code: 'REST_API_FIELDSET_INVALID' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('rejects inherited object names as unknown sparse fields', async () => {
|
|
104
|
+
for (const field of ['constructor', 'toString', '__proto__']) {
|
|
105
|
+
await assert.rejects(api.resources.events.query({ queryParams: { fields: { events: field } } }), {
|
|
106
|
+
code: 'REST_API_FIELDSET_INVALID'
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('normalizes nested simplified to-many and polymorphic resources without changing ordinary objects', () => {
|
|
112
|
+
const date = new Date('2026-09-08T11:22:33.987Z')
|
|
113
|
+
const child = { id: 'child', producedAt: date }
|
|
114
|
+
const payload = { id: 'event', person: { id: 'person', events: [child, child] },
|
|
115
|
+
subject: { id: 'person', _type: 'people', producedAt: date },
|
|
116
|
+
arbitrary: { producedAt: date, value: 'unchanged' } }
|
|
117
|
+
const result = normalizeRecordAttributes(payload, api.resources, { simplified: true, resourceType: 'events', source: 'response' })
|
|
118
|
+
assert.equal(result.person.events[0].producedAt, '2026-09-08T11:22:33Z')
|
|
119
|
+
assert.equal(result.person.events[0], result.person.events[1])
|
|
120
|
+
assert.equal(result.subject.producedAt, '2026-09-08T11:22:33Z')
|
|
121
|
+
assert.equal(result.subject._type, 'people')
|
|
122
|
+
assert.equal(result.arbitrary, payload.arbitrary)
|
|
123
|
+
assert.equal(child.producedAt, date)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('preserves typed API errors thrown by computed fields', async () => {
|
|
127
|
+
const created = await api.resources.events.post({ inputRecord: document() })
|
|
128
|
+
const definition = api.resources.events.vars.schemaInfo.computed.producedAt
|
|
129
|
+
const original = definition.compute
|
|
130
|
+
const error = new RestApiTemporalDataError({ field: 'producedAt', resourceType: 'events', fieldType: 'dateTime', source: 'response' })
|
|
131
|
+
definition.compute = () => { throw error }
|
|
132
|
+
try {
|
|
133
|
+
await assert.rejects(api.resources.events.get({ id: created.data.id }), actual => actual === error)
|
|
134
|
+
} finally {
|
|
135
|
+
definition.compute = original
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('preserves typed API errors thrown by getters', async () => {
|
|
140
|
+
const created = await api.resources.events.post({ inputRecord: document() })
|
|
141
|
+
const schemaInfo = api.resources.events.vars.schemaInfo
|
|
142
|
+
const originalGetters = schemaInfo.fieldGetters
|
|
143
|
+
const originalOrder = schemaInfo.sortedGetterFields
|
|
144
|
+
const error = new RestApiTemporalDataError({ field: 'name', resourceType: 'events', source: 'response' })
|
|
145
|
+
schemaInfo.fieldGetters = { name: { getter: () => { throw error } } }
|
|
146
|
+
schemaInfo.sortedGetterFields = ['name']
|
|
147
|
+
try {
|
|
148
|
+
await assert.rejects(api.resources.events.get({ id: created.data.id }), actual => actual === error)
|
|
149
|
+
} finally {
|
|
150
|
+
schemaInfo.fieldGetters = originalGetters
|
|
151
|
+
schemaInfo.sortedGetterFields = originalOrder
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('preserves typed errors from setters and post-write storage reads', async () => {
|
|
156
|
+
const error = new RestApiTemporalDataError({ field: 'occurredAt', resourceType: 'events' })
|
|
157
|
+
await assert.rejects(applyFieldSetters({ occurredAt: '2026-09-08T00:00:00Z' }, {
|
|
158
|
+
fieldSetters: { occurredAt: { setter: () => { throw error } } },
|
|
159
|
+
sortedSetterFields: ['occurredAt']
|
|
160
|
+
}, { scopeName: 'events' }), actual => actual === error)
|
|
161
|
+
await assert.rejects(handleRecordReturnAfterWrite({
|
|
162
|
+
context: { method: 'POST' }, scopeName: 'events',
|
|
163
|
+
helpers: { dataGetMinimal: async () => { throw error } },
|
|
164
|
+
log: { warn () {} }
|
|
165
|
+
}), actual => actual === error)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
for (const direction of ['after', 'before']) {
|
|
169
|
+
it(`rejects malformed temporal and numeric values in page[${direction}] as client errors`, async () => {
|
|
170
|
+
for (const [field, value] of [['occurredAt', 'garbage'], ['day', '2026-02-30'], ['atTime', '25:30'], ['observedAtMs', 'NaN']]) {
|
|
171
|
+
await assert.rejects(api.resources.events.query({ queryParams: {
|
|
172
|
+
sort: [field], page: { size: 2, [direction]: `${field}:${encodeURIComponent(value)},id:1` }
|
|
173
|
+
} }), (error) => {
|
|
174
|
+
assertValidationError(error)
|
|
175
|
+
assert.deepEqual(error.details.fields, [`page.${direction}`])
|
|
176
|
+
return true
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const simplified of [false, true]) {
|
|
183
|
+
for (const method of ['post', 'put', 'patch']) {
|
|
184
|
+
it(`normalizes ${method} finish values and included resources with simplified=${simplified}`, async () => {
|
|
185
|
+
const person = await api.resources.people.post({ inputRecord: createJsonApiDocument('people', { name: 'Person' }) })
|
|
186
|
+
const inputRecord = document()
|
|
187
|
+
inputRecord.data.relationships = { person: { data: { type: 'people', id: person.data.id } } }
|
|
188
|
+
const created = method === 'post' ? null : await api.resources.events.post({ inputRecord })
|
|
189
|
+
if (created) inputRecord.data.id = created.data.id
|
|
190
|
+
finishValue = new Date('2026-09-08T11:22:33.987Z')
|
|
191
|
+
const result = await api.resources.events[method]({
|
|
192
|
+
...(created ? { id: created.data.id } : {}), inputRecord,
|
|
193
|
+
queryParams: { include: ['person'] }, simplified
|
|
194
|
+
})
|
|
195
|
+
assert.equal((simplified ? result : result.data.attributes).producedAt, '2026-09-08T11:22:33Z')
|
|
196
|
+
assert.equal((simplified ? result.person : result.included[0].attributes).producedAt, '2026-09-08T11:22:33Z')
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
it(`rejects invalid finish values with simplified=${simplified}`, async () => {
|
|
200
|
+
finishValue = 'not-a-date'
|
|
201
|
+
await assert.rejects(api.resources.events.post({ inputRecord: document(), simplified }), (error) => {
|
|
202
|
+
assert.equal(error.code, 'REST_API_TEMPORAL_DATA_INVALID')
|
|
203
|
+
assert.equal(mapRestApiErrorToHttp(error).status, 500)
|
|
204
|
+
return true
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
})
|