json-rest-api 1.0.26 → 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.
- package/docs/API.md +5 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +2 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -2
- package/docs/GUIDE/GUIDE_Y_Appendices.md +42 -29
- package/index.js +2 -0
- package/lib/error-context.js +21 -0
- package/lib/rest-api-errors.js +70 -0
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +21 -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 +11 -1
- package/plugins/core/lib/querying/knex-process-includes.js +11 -5
- package/plugins/core/lib/querying/knex-relationship-includes.js +63 -46
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +231 -143
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -1
- package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -1
- package/plugins/core/lib/storage/storage-adapter.js +4 -2
- package/plugins/core/lib/storage/storage-mapping.js +7 -1
- package/plugins/core/rest-api-plugin-methods/common.js +5 -0
- package/plugins/core/rest-api-plugin-methods/get.js +6 -0
- package/plugins/core/rest-api-plugin-methods/query.js +6 -0
- package/tests/computed-fields.test.js +90 -2
- package/tests/db-table-operations.test.js +13 -1
- package/tests/fixtures/api-configs.js +83 -3
- package/tests/includes.test.js +49 -0
- package/tests/pagination-cursor-multifield.test.js +4 -0
- package/tests/relationship-include-storage-adapter.test.js +56 -0
- package/tests/storage-mapping.test.js +112 -10
|
@@ -4,6 +4,23 @@ import knexLib from 'knex'
|
|
|
4
4
|
import { cleanTables } from './helpers/test-utils.js'
|
|
5
5
|
import { storageMode } from './helpers/storage-mode.js'
|
|
6
6
|
import { createComputedFieldsApi } from './fixtures/api-configs.js'
|
|
7
|
+
import {
|
|
8
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
9
|
+
RestApiFieldsetError
|
|
10
|
+
} from '../index.js'
|
|
11
|
+
import { mapRestApiErrorToHttp } from '../plugins/core/connectors/lib/transport-http-helpers.js'
|
|
12
|
+
|
|
13
|
+
function assertFieldsetError (error, { field, resourceType }) {
|
|
14
|
+
assert(error instanceof RestApiFieldsetError)
|
|
15
|
+
assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
|
|
16
|
+
assert.equal(error.statusCode, 400)
|
|
17
|
+
assert.deepEqual(error.details, { field, resourceType })
|
|
18
|
+
assert.equal(
|
|
19
|
+
error.message,
|
|
20
|
+
`Unknown sparse field '${field}' requested for '${resourceType}'`
|
|
21
|
+
)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
// Create Knex instance for tests
|
|
9
26
|
const knex = knexLib({
|
|
@@ -69,12 +86,31 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
69
86
|
assert.equal(product.price, 99.99)
|
|
70
87
|
assert.equal(product.profit_margin, 55.00)
|
|
71
88
|
assert.equal(product.profit_amount, 54.99)
|
|
89
|
+
assert.equal(product.calculated_at, '2026-08-25T23:45:01Z')
|
|
90
|
+
assert.equal(product.calculated_time, '23:45:01.987')
|
|
72
91
|
|
|
73
92
|
// normallyHidden fields should not be included
|
|
74
93
|
assert.equal(product.cost, undefined)
|
|
75
94
|
assert.equal(product.internal_notes, undefined)
|
|
76
95
|
})
|
|
77
96
|
|
|
97
|
+
it('should normalize computed temporal values at the final return boundary', async () => {
|
|
98
|
+
const product = await api.resources.products.get({
|
|
99
|
+
id: testData.product.id,
|
|
100
|
+
queryParams: { include: ['reviews'] }
|
|
101
|
+
})
|
|
102
|
+
assert.equal(product.calculated_at, '2026-08-25T23:45:01Z')
|
|
103
|
+
assert.equal(product.calculated_time, '23:45:01.987')
|
|
104
|
+
assert.equal(product.reviews.length, 2)
|
|
105
|
+
for (const review of product.reviews) {
|
|
106
|
+
assert.equal(review.calculated_at, '2026-08-25T23:46:02Z')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const products = await api.resources.products.query()
|
|
110
|
+
assert.equal(products.data[0].calculated_at, '2026-08-25T23:45:01Z')
|
|
111
|
+
assert.equal(products.data[0].calculated_time, '23:45:01.987')
|
|
112
|
+
})
|
|
113
|
+
|
|
78
114
|
it('should handle division by zero in computed fields', async () => {
|
|
79
115
|
const freeProduct = await api.resources.products.post({
|
|
80
116
|
name: 'Free Sample',
|
|
@@ -115,6 +151,8 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
115
151
|
assert.equal(product.id, testData.product.id)
|
|
116
152
|
assert.equal(product.name, 'Premium Widget')
|
|
117
153
|
assert.equal(product.profit_margin, 55.00)
|
|
154
|
+
assert.equal(product.calculated_at, undefined)
|
|
155
|
+
assert.equal(product.calculated_time, undefined)
|
|
118
156
|
|
|
119
157
|
// Other fields should not be included
|
|
120
158
|
assert.equal(product.price, undefined)
|
|
@@ -365,7 +403,10 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
365
403
|
fields: { products: 'name,unknown_field' }
|
|
366
404
|
}
|
|
367
405
|
}),
|
|
368
|
-
|
|
406
|
+
(error) => assertFieldsetError(error, {
|
|
407
|
+
field: 'unknown_field',
|
|
408
|
+
resourceType: 'products'
|
|
409
|
+
})
|
|
369
410
|
)
|
|
370
411
|
})
|
|
371
412
|
|
|
@@ -377,9 +418,56 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
377
418
|
fields: { products: 'name,reviews_ids' }
|
|
378
419
|
}
|
|
379
420
|
}),
|
|
380
|
-
|
|
421
|
+
(error) => assertFieldsetError(error, {
|
|
422
|
+
field: 'reviews_ids',
|
|
423
|
+
resourceType: 'products'
|
|
424
|
+
})
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('should preserve fieldset errors from empty included relationships', async () => {
|
|
429
|
+
const productWithoutReviews = await api.resources.products.post({
|
|
430
|
+
name: 'No Reviews',
|
|
431
|
+
price: 25,
|
|
432
|
+
cost: 10
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
await assert.rejects(
|
|
436
|
+
api.resources.products.get({
|
|
437
|
+
id: productWithoutReviews.id,
|
|
438
|
+
queryParams: {
|
|
439
|
+
include: ['reviews'],
|
|
440
|
+
fields: {
|
|
441
|
+
products: 'name',
|
|
442
|
+
reviews: 'definitelyHidden'
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}),
|
|
446
|
+
(error) => assertFieldsetError(error, {
|
|
447
|
+
field: 'definitelyHidden',
|
|
448
|
+
resourceType: 'reviews'
|
|
449
|
+
})
|
|
381
450
|
)
|
|
382
451
|
})
|
|
452
|
+
|
|
453
|
+
it('should map fieldset errors to an HTTP 400 response', () => {
|
|
454
|
+
const error = new RestApiFieldsetError({
|
|
455
|
+
field: 'definitelyHidden',
|
|
456
|
+
resourceType: 'reviews'
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
assert.deepEqual(mapRestApiErrorToHttp(error), {
|
|
460
|
+
status: 400,
|
|
461
|
+
body: {
|
|
462
|
+
errors: [{
|
|
463
|
+
status: '400',
|
|
464
|
+
code: REST_API_FIELDSET_ERROR_CODE,
|
|
465
|
+
title: 'Invalid Sparse Fieldset',
|
|
466
|
+
detail: "Unknown sparse field 'definitelyHidden' requested for 'reviews'"
|
|
467
|
+
}]
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
})
|
|
383
471
|
})
|
|
384
472
|
|
|
385
473
|
describe('Async Computed Fields', () => {
|
|
@@ -176,7 +176,9 @@ describe('dbTablesOperations.createKnexTable', () => {
|
|
|
176
176
|
it('defaults storage columns to snake_case and keeps per-field overrides', async () => {
|
|
177
177
|
const schema = makeTableSchema({
|
|
178
178
|
displayName: { type: 'string', required: true },
|
|
179
|
-
lastSeenAt: { type: 'dateTime' },
|
|
179
|
+
lastSeenAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
|
|
180
|
+
observedAtMs: { type: 'epochMilliseconds' },
|
|
181
|
+
observedAtSeconds: { type: 'epochSeconds' },
|
|
180
182
|
externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
|
|
181
183
|
})
|
|
182
184
|
|
|
@@ -186,6 +188,9 @@ describe('dbTablesOperations.createKnexTable', () => {
|
|
|
186
188
|
assert.ok(info.id)
|
|
187
189
|
assert.ok(info.display_name)
|
|
188
190
|
assert.ok(info.last_seen_at)
|
|
191
|
+
assert.equal(info.last_seen_at.defaultValue, null)
|
|
192
|
+
assert.ok(info.observed_at_ms)
|
|
193
|
+
assert.ok(info.observed_at_seconds)
|
|
189
194
|
assert.ok(info.legacy_ref)
|
|
190
195
|
assert.equal(info.displayName, undefined)
|
|
191
196
|
assert.equal(info.external_ref, undefined)
|
|
@@ -363,6 +368,9 @@ describe('dbTablesOperations.generateKnexMigration', () => {
|
|
|
363
368
|
const schema = makeTableSchema({
|
|
364
369
|
displayName: { type: 'string', required: true },
|
|
365
370
|
loginCount: { type: 'number', defaultTo: 0 },
|
|
371
|
+
createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
|
|
372
|
+
observedAtMs: { type: 'epochMilliseconds' },
|
|
373
|
+
observedAtSeconds: { type: 'epochSeconds' },
|
|
366
374
|
externalRef: { type: 'string', storage: { column: 'legacy_ref' } }
|
|
367
375
|
})
|
|
368
376
|
|
|
@@ -370,6 +378,10 @@ describe('dbTablesOperations.generateKnexMigration', () => {
|
|
|
370
378
|
|
|
371
379
|
assert.ok(migration.includes("table.string('display_name').notNullable()"))
|
|
372
380
|
assert.ok(migration.includes("table.float('login_count').defaultTo(0)"))
|
|
381
|
+
assert.ok(migration.includes("table.datetime('created_at', { precision: 3 })"))
|
|
382
|
+
assert.equal(migration.includes("table.datetime('created_at', { precision: 3 }).defaultTo"), false)
|
|
383
|
+
assert.ok(migration.includes("table.bigInteger('observed_at_ms')"))
|
|
384
|
+
assert.ok(migration.includes("table.bigInteger('observed_at_seconds')"))
|
|
373
385
|
assert.ok(migration.includes("table.string('legacy_ref')"))
|
|
374
386
|
assert.ok(!migration.includes("table.string('displayName')"))
|
|
375
387
|
})
|
|
@@ -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',
|
|
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',
|
|
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',
|
|
2247
|
+
createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
|
|
2168
2248
|
},
|
|
2169
2249
|
tableName: 'cursor_products'
|
|
2170
2250
|
})
|
package/tests/includes.test.js
CHANGED
|
@@ -2,6 +2,10 @@ import { describe, it, before, beforeEach, after } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import knexLib from 'knex'
|
|
4
4
|
import { createBasicApi, createExtendedApi } from './fixtures/api-configs.js'
|
|
5
|
+
import {
|
|
6
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
7
|
+
RestApiFieldsetError
|
|
8
|
+
} from '../index.js'
|
|
5
9
|
import {
|
|
6
10
|
validateJsonApiStructure,
|
|
7
11
|
resourceIdentifier,
|
|
@@ -165,6 +169,51 @@ describe('Include/Sideloading Operations', () => {
|
|
|
165
169
|
assert(includedTypes.includes('publishers'))
|
|
166
170
|
assert(includedTypes.includes('countries'))
|
|
167
171
|
})
|
|
172
|
+
|
|
173
|
+
it('should preserve typed fieldset errors across included relationships', async () => {
|
|
174
|
+
const requests = [
|
|
175
|
+
{
|
|
176
|
+
resource: basicApi.resources.countries,
|
|
177
|
+
id: testData.country.id,
|
|
178
|
+
include: 'books',
|
|
179
|
+
primaryType: 'countries',
|
|
180
|
+
includedType: 'books'
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
resource: basicApi.resources.books,
|
|
184
|
+
id: testData.book.id,
|
|
185
|
+
include: 'publisher',
|
|
186
|
+
primaryType: 'books',
|
|
187
|
+
includedType: 'publishers'
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
for (const request of requests) {
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
request.resource.get({
|
|
194
|
+
id: request.id,
|
|
195
|
+
queryParams: {
|
|
196
|
+
include: [request.include],
|
|
197
|
+
fields: {
|
|
198
|
+
[request.primaryType]: 'id',
|
|
199
|
+
[request.includedType]: 'definitelyHidden'
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
simplified: false
|
|
203
|
+
}),
|
|
204
|
+
(error) => {
|
|
205
|
+
assert(error instanceof RestApiFieldsetError)
|
|
206
|
+
assert.equal(error.code, REST_API_FIELDSET_ERROR_CODE)
|
|
207
|
+
assert.equal(error.statusCode, 400)
|
|
208
|
+
assert.deepEqual(error.details, {
|
|
209
|
+
field: 'definitelyHidden',
|
|
210
|
+
resourceType: request.includedType
|
|
211
|
+
})
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
168
217
|
})
|
|
169
218
|
|
|
170
219
|
describe('Nested Includes', () => {
|
|
@@ -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 (
|
|
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', {
|