json-rest-api 1.0.27 → 1.0.28

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.
@@ -334,6 +334,68 @@ export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
334
334
  return api
335
335
  }
336
336
 
337
+ export async function createRelationshipIncludeStorageAdapterApi (knex) {
338
+ const api = new Api({
339
+ name: 'relationship-include-storage-adapter-test',
340
+ log: { level: process.env.LOG_LEVEL || 'info' }
341
+ })
342
+
343
+ await api.use(RestApiPlugin, {
344
+ simplifiedApi: false,
345
+ simplifiedTransport: false
346
+ })
347
+ await api.use(RestApiKnexPlugin, { knex })
348
+
349
+ await knex.schema.createTable('uninitialized_pets', table => {
350
+ table.increments('id').primary()
351
+ table.string('first_name').notNullable()
352
+ table.string('last_name').notNullable()
353
+ })
354
+ await knex.schema.createTable('uninitialized_bookings', table => {
355
+ table.increments('id').primary()
356
+ table.string('reference').notNullable()
357
+ table.integer('pet_id').notNullable()
358
+ })
359
+
360
+ await api.addResource('pets', {
361
+ schema: {
362
+ id: { type: 'id' },
363
+ firstName: { type: 'string', required: true },
364
+ lastName: { type: 'string', required: true }
365
+ },
366
+ tableName: 'uninitialized_pets'
367
+ })
368
+
369
+ await api.addResource('bookings', {
370
+ schema: {
371
+ id: { type: 'id' },
372
+ reference: { type: 'string', required: true },
373
+ petId: {
374
+ type: 'id',
375
+ required: true,
376
+ belongsTo: 'pets',
377
+ as: 'pet'
378
+ }
379
+ },
380
+ tableName: 'uninitialized_bookings'
381
+ })
382
+
383
+ return api
384
+ }
385
+
386
+ export async function seedRelationshipIncludeStorageAdapterApi (knex) {
387
+ await knex('uninitialized_pets').insert({
388
+ id: 7,
389
+ first_name: 'Coco',
390
+ last_name: 'Spaniel'
391
+ })
392
+ await knex('uninitialized_bookings').insert({
393
+ id: 41,
394
+ reference: 'BOOK-41',
395
+ pet_id: 7
396
+ })
397
+ }
398
+
337
399
  /**
338
400
  * Creates a small API focused on resource ID normalization behavior.
339
401
  */
@@ -641,7 +703,7 @@ export async function createExtendedApi (knex) {
641
703
  title: { type: 'string', max: 200 },
642
704
  content: { type: 'string', required: true, max: 5000 },
643
705
  reviewer_name: { type: 'string', required: true, max: 100 },
644
- review_date: { type: 'dateTime', default: 'now()' },
706
+ review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
645
707
  helpful_count: { type: 'number', default: 0 },
646
708
  reviewable_type: { type: 'string', required: true },
647
709
  reviewable_id: { type: 'number', required: true }
@@ -976,6 +1038,18 @@ export async function createComputedFieldsApi (knex, pluginOptions = {}) {
976
1038
  compute: ({ attributes }) => {
977
1039
  return Number((attributes.price - attributes.cost).toFixed(2))
978
1040
  }
1041
+ },
1042
+ calculated_at: {
1043
+ type: 'dateTime',
1044
+ temporalPrecision: 0,
1045
+ computed: true,
1046
+ compute: () => new Date('2026-08-25T23:45:01.987Z')
1047
+ },
1048
+ calculated_time: {
1049
+ type: 'time',
1050
+ temporalPrecision: 3,
1051
+ computed: true,
1052
+ compute: () => new Date('2026-08-25T23:45:01.987Z')
979
1053
  }
980
1054
  },
981
1055
  relationships: {
@@ -1016,6 +1090,12 @@ export async function createComputedFieldsApi (knex, pluginOptions = {}) {
1016
1090
  const helpfulnessScore = (attributes.helpful_votes / attributes.total_votes) * 100
1017
1091
  return helpfulnessScore > 70 && attributes.spam_score < 0.5
1018
1092
  }
1093
+ },
1094
+ calculated_at: {
1095
+ type: 'dateTime',
1096
+ temporalPrecision: 0,
1097
+ computed: true,
1098
+ compute: () => new Date('2026-08-25T23:46:02.654Z')
1019
1099
  }
1020
1100
  },
1021
1101
  tableName: 'test_reviews'
@@ -2090,7 +2170,7 @@ export async function createCustomIdPropertyApi (knex, pluginOptions = {}) {
2090
2170
  title: { type: 'string', max: 200 },
2091
2171
  content: { type: 'string', required: true, max: 5000 },
2092
2172
  reviewer_name: { type: 'string', required: true, max: 100 },
2093
- review_date: { type: 'dateTime', default: 'now()' },
2173
+ review_date: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
2094
2174
  reviewable_type: { type: 'string', required: true, search: true },
2095
2175
  reviewable_id: { type: 'number', required: true },
2096
2176
  // Define the polymorphic field in schema
@@ -2164,7 +2244,7 @@ export async function createCursorPaginationApi (knex) {
2164
2244
  price: { type: 'number', required: true },
2165
2245
  sku: { type: 'string', required: true, max: 50, unique: true },
2166
2246
  status: { type: 'string', max: 50, default: 'active' },
2167
- createdAt: { type: 'dateTime', default: 'now()' }
2247
+ createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
2168
2248
  },
2169
2249
  tableName: 'cursor_products'
2170
2250
  })
@@ -337,6 +337,10 @@ describe('Multi-field Cursor Pagination', () => {
337
337
  firstPage.data.map((record) => record.attributes.name),
338
338
  ['Newest Product', 'Recent Product']
339
339
  )
340
+ assert.deepEqual(
341
+ firstPage.data.map((record) => record.attributes.createdAt),
342
+ ['2026-05-03T10:00:00.000Z', '2026-05-03T09:00:00.000Z']
343
+ )
340
344
  assert.deepEqual(
341
345
  secondPage.data.map((record) => record.attributes.name),
342
346
  ['Older Product']
@@ -0,0 +1,56 @@
1
+ import { after, before, beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import {
5
+ createRelationshipIncludeStorageAdapterApi,
6
+ seedRelationshipIncludeStorageAdapterApi
7
+ } from './fixtures/api-configs.js'
8
+ import { cleanTables } from './helpers/test-utils.js'
9
+
10
+ const knex = knexLib({
11
+ client: 'better-sqlite3',
12
+ connection: {
13
+ filename: ':memory:'
14
+ },
15
+ useNullAsDefault: true
16
+ })
17
+
18
+ describe('Relationship include storage adapters', () => {
19
+ let api
20
+
21
+ before(async () => {
22
+ api = await createRelationshipIncludeStorageAdapterApi(knex)
23
+ })
24
+
25
+ beforeEach(async () => {
26
+ await cleanTables(knex, [
27
+ 'uninitialized_bookings',
28
+ 'uninitialized_pets'
29
+ ])
30
+ await seedRelationshipIncludeStorageAdapterApi(knex)
31
+ })
32
+
33
+ after(async () => {
34
+ await knex.destroy()
35
+ })
36
+
37
+ it('maps sparse fields for an included scope whose adapter is not initialized', async () => {
38
+ assert.equal(api.resources.pets.vars.storageAdapter, undefined)
39
+
40
+ const result = await api.resources.bookings.get({
41
+ id: 41,
42
+ queryParams: {
43
+ include: ['pet'],
44
+ fields: {
45
+ bookings: 'reference,petId',
46
+ pets: 'firstName'
47
+ }
48
+ },
49
+ simplified: false
50
+ })
51
+
52
+ const includedPet = result.included.find(resource => resource.type === 'pets')
53
+ assert(includedPet)
54
+ assert.deepEqual(includedPet.attributes, { firstName: 'Coco' })
55
+ })
56
+ })
@@ -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 (!(value instanceof Date)) {
30
- throw new Error('serializeDateTimeForSql expects a Date value after schema normalization')
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-02T03:04:05Z',
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.ok(createResult.data.attributes.lastSeenAt instanceof Date)
146
- assert.equal(createResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
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.ok(getResult.data.attributes.lastSeenAt instanceof Date)
173
- assert.equal(getResult.data.attributes.lastSeenAt.toISOString(), '2026-01-02T03:04:05.000Z')
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.ok(patchResult.data.attributes.lastSeenAt instanceof Date)
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', {