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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
2
|
+
|
|
1
3
|
const EMPTY_STORAGE_INFO = Object.freeze({
|
|
2
4
|
idColumn: 'id',
|
|
3
5
|
storage: Object.freeze({
|
|
@@ -242,7 +244,11 @@ export const translateAttributesForStorage = (attributes, schemaInfo = {}, optio
|
|
|
242
244
|
context,
|
|
243
245
|
operation
|
|
244
246
|
})
|
|
245
|
-
: value
|
|
247
|
+
: normalizeValueForDatabaseStorage(value, fieldStorage?.definition?.type, {
|
|
248
|
+
temporalPrecision: fieldStorage?.definition?.temporalPrecision,
|
|
249
|
+
fieldName,
|
|
250
|
+
resourceType: context?.scopeName || schemaInfo.tableName
|
|
251
|
+
})
|
|
246
252
|
|
|
247
253
|
translated[columnName] = serializedValue
|
|
248
254
|
return translated
|
|
@@ -18,7 +18,6 @@ import { getUrlPrefix, buildResourceUrl, buildRelationshipUrl } from './lib/quer
|
|
|
18
18
|
import {
|
|
19
19
|
normalizeId,
|
|
20
20
|
resolveFieldInfo,
|
|
21
|
-
coerceValueForDefinition,
|
|
22
21
|
normalizeFilterValues,
|
|
23
22
|
ensureFilterableField,
|
|
24
23
|
} from './lib/anyapi/utils/descriptor-helpers.js'
|
|
@@ -48,13 +47,15 @@ import {
|
|
|
48
47
|
translateCanonicalRecordFromStorage,
|
|
49
48
|
} from './lib/storage/canonical-storage-mapping.js'
|
|
50
49
|
import {
|
|
51
|
-
|
|
50
|
+
applyCursorPredicate,
|
|
51
|
+
validateCursorValues,
|
|
52
52
|
applyQueryFieldOrder,
|
|
53
53
|
buildEffectiveSortList,
|
|
54
54
|
parseSortEntry
|
|
55
55
|
} from './lib/querying/query-field-sort-helpers.js'
|
|
56
56
|
import { unwrapQueryBuilderState } from './lib/querying/query-builder-utils.js'
|
|
57
57
|
import { serializeJsonApiQuery } from './lib/querying-writing/connectors-query-parser.js'
|
|
58
|
+
import { normalizeValueForDatabaseStorage } from './lib/querying-writing/database-value-normalizers.js'
|
|
58
59
|
|
|
59
60
|
const DEFAULT_TENANT = 'default'
|
|
60
61
|
const LINKS_TABLE = 'any_links'
|
|
@@ -237,7 +238,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
const applySortingToQuery = ({ query, sort, descriptor, scope, queryFieldRuntimeByField = new Map() }) => {
|
|
241
|
+
const applySortingToQuery = ({ query, sort, descriptor, scope, adapter, before = false, queryFieldRuntimeByField = new Map() }) => {
|
|
241
242
|
const effectiveSort = buildEffectiveSortList(sort, {
|
|
242
243
|
defaultSort: scope?.vars?.defaultSort,
|
|
243
244
|
idField: 'id'
|
|
@@ -245,10 +246,11 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
245
246
|
const descriptors = []
|
|
246
247
|
|
|
247
248
|
for (const entry of effectiveSort) {
|
|
248
|
-
const { field, direction
|
|
249
|
+
const { field, direction } = parseSortEntry(entry)
|
|
250
|
+
const queryDirection = before ? (direction === 'asc' ? 'desc' : 'asc') : direction
|
|
249
251
|
const queryFieldRuntime = queryFieldRuntimeByField.get(field)
|
|
250
252
|
if (queryFieldRuntime) {
|
|
251
|
-
applyQueryFieldOrder(query, queryFieldRuntime,
|
|
253
|
+
applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), before ? 'first' : 'last')
|
|
252
254
|
descriptors.push({
|
|
253
255
|
field,
|
|
254
256
|
direction,
|
|
@@ -261,7 +263,8 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
261
263
|
|
|
262
264
|
const fieldInfo = resolveFieldInfo(descriptor, field)
|
|
263
265
|
if (!fieldInfo?.column) continue
|
|
264
|
-
query.
|
|
266
|
+
query.orderByRaw(`?? IS NULL ${before ? 'DESC' : 'ASC'}`, [adapter.translateColumn(fieldInfo.column)])
|
|
267
|
+
query.orderBy(fieldInfo.column, queryDirection)
|
|
265
268
|
descriptors.push({
|
|
266
269
|
field,
|
|
267
270
|
column: fieldInfo.column,
|
|
@@ -328,55 +331,21 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
328
331
|
}
|
|
329
332
|
}
|
|
330
333
|
|
|
331
|
-
const coerceCursorValues = (cursorMap) => {
|
|
332
|
-
const typed = {}
|
|
333
|
-
for (const descriptor of descriptors) {
|
|
334
|
-
if (descriptor.field === undefined) continue
|
|
335
|
-
const raw = cursorMap[descriptor.field]
|
|
336
|
-
if (raw === undefined) continue
|
|
337
|
-
typed[descriptor.field] = coerceValueForDefinition(
|
|
338
|
-
raw,
|
|
339
|
-
descriptor.definition,
|
|
340
|
-
{ isRelationship: descriptor.isRelationship }
|
|
341
|
-
)
|
|
342
|
-
}
|
|
343
|
-
return typed
|
|
344
|
-
}
|
|
345
|
-
|
|
346
334
|
const applyRawCursorPredicate = (cursorValues, operatorSelector) => {
|
|
347
|
-
|
|
335
|
+
applyCursorPredicate(
|
|
336
|
+
query,
|
|
348
337
|
descriptors,
|
|
349
338
|
cursorValues,
|
|
350
339
|
operatorSelector,
|
|
351
|
-
|
|
340
|
+
(builder, descriptor, operator, value) => builder.where(
|
|
341
|
+
descriptor.column,
|
|
342
|
+
operator,
|
|
343
|
+
normalizeValueForDatabaseStorage(
|
|
344
|
+
value, descriptor.isRelationship ? null : descriptor.definition?.type,
|
|
345
|
+
{ temporalPrecision: descriptor.definition?.temporalPrecision }
|
|
346
|
+
)
|
|
347
|
+
)
|
|
352
348
|
)
|
|
353
|
-
|
|
354
|
-
if (predicateChains.length === 0) {
|
|
355
|
-
return
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const predicateParts = []
|
|
359
|
-
const predicateBindings = []
|
|
360
|
-
|
|
361
|
-
predicateChains.forEach((chain) => {
|
|
362
|
-
const chainParts = chain.map(({ descriptor, operator, value }) => {
|
|
363
|
-
const normalizedValue = adapter?.translateFilterValue
|
|
364
|
-
? adapter.translateFilterValue(descriptor.field, value)
|
|
365
|
-
: value
|
|
366
|
-
if (descriptor.queryFieldRuntime) {
|
|
367
|
-
predicateBindings.push(...(descriptor.queryFieldRuntime.bindings || []), normalizedValue)
|
|
368
|
-
return `(${descriptor.queryFieldRuntime.sql}) ${operator} ?`
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const columnRef = adapter ? adapter.translateColumn(descriptor.column) : descriptor.column
|
|
372
|
-
predicateBindings.push(normalizedValue)
|
|
373
|
-
return `${columnRef} ${operator} ?`
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
predicateParts.push(`(${chainParts.join(' AND ')})`)
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
query.whereRaw(`(${predicateParts.join(' OR ')})`, predicateBindings)
|
|
380
349
|
}
|
|
381
350
|
|
|
382
351
|
const ensurePageSize = () => {
|
|
@@ -414,11 +383,11 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
414
383
|
|
|
415
384
|
if (pageParams.after) {
|
|
416
385
|
const cursorMap = parseCursorOrThrow(pageParams.after, 'after')
|
|
417
|
-
const cursorValues =
|
|
386
|
+
const cursorValues = validateCursorValues(descriptors, cursorMap, 'after')
|
|
418
387
|
applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '<' : '>'))
|
|
419
388
|
} else if (pageParams.before) {
|
|
420
389
|
const cursorMap = parseCursorOrThrow(pageParams.before, 'before')
|
|
421
|
-
const cursorValues =
|
|
390
|
+
const cursorValues = validateCursorValues(descriptors, cursorMap, 'before')
|
|
422
391
|
applyRawCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '>' : '<'))
|
|
423
392
|
}
|
|
424
393
|
|
|
@@ -445,11 +414,11 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
445
414
|
}
|
|
446
415
|
}
|
|
447
416
|
|
|
448
|
-
query.limit(defaultLimit)
|
|
417
|
+
query.limit(Math.min(defaultLimit, maxLimit))
|
|
449
418
|
return {
|
|
450
419
|
mode: 'default',
|
|
451
420
|
page: 1,
|
|
452
|
-
pageSize: defaultLimit,
|
|
421
|
+
pageSize: Math.min(defaultLimit, maxLimit),
|
|
453
422
|
sortDescriptors: descriptors,
|
|
454
423
|
}
|
|
455
424
|
}
|
|
@@ -2186,6 +2155,8 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
2186
2155
|
sort: queryParams.sort,
|
|
2187
2156
|
descriptor,
|
|
2188
2157
|
scope,
|
|
2158
|
+
adapter,
|
|
2159
|
+
before: Boolean(queryParams.page?.before),
|
|
2189
2160
|
queryFieldRuntimeByField: selectionState.queryFieldRuntimeByField
|
|
2190
2161
|
})
|
|
2191
2162
|
|
|
@@ -2222,6 +2193,10 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
2222
2193
|
rows = rows.slice(0, pageSize)
|
|
2223
2194
|
cursorRecords = cursorRecords.slice(0, pageSize)
|
|
2224
2195
|
}
|
|
2196
|
+
if (queryParams.page?.before) {
|
|
2197
|
+
rows.reverse()
|
|
2198
|
+
cursorRecords.reverse()
|
|
2199
|
+
}
|
|
2225
2200
|
}
|
|
2226
2201
|
|
|
2227
2202
|
const data = rows.map((row) => {
|
|
@@ -2313,14 +2288,17 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
2313
2288
|
)
|
|
2314
2289
|
} else if (paginationInfo.mode === 'cursor' && cursorRecords) {
|
|
2315
2290
|
const cursorFields = paginationInfo.sortDescriptors.map((descriptorEntry) => descriptorEntry.field)
|
|
2291
|
+
const cursorOptions = {
|
|
2292
|
+
schemaInfo,
|
|
2293
|
+
definitions: Object.fromEntries(paginationInfo.sortDescriptors.map(({ field, definition }) => [field, definition])),
|
|
2294
|
+
before: Boolean(queryParams.page?.before)
|
|
2295
|
+
}
|
|
2316
2296
|
const paginationMeta = buildCursorMeta(
|
|
2317
2297
|
cursorRecords,
|
|
2318
2298
|
paginationInfo.pageSize,
|
|
2319
2299
|
hasMore,
|
|
2320
2300
|
cursorFields,
|
|
2321
|
-
|
|
2322
|
-
schemaInfo
|
|
2323
|
-
}
|
|
2301
|
+
cursorOptions
|
|
2324
2302
|
)
|
|
2325
2303
|
context.returnMeta.paginationMeta = paginationMeta
|
|
2326
2304
|
const urlPrefix = getUrlPrefix(context, scope)
|
|
@@ -2332,9 +2310,7 @@ export const RestApiAnyapiKnexPlugin = {
|
|
|
2332
2310
|
paginationInfo.pageSize,
|
|
2333
2311
|
hasMore,
|
|
2334
2312
|
cursorFields,
|
|
2335
|
-
|
|
2336
|
-
schemaInfo
|
|
2337
|
-
}
|
|
2313
|
+
cursorOptions
|
|
2338
2314
|
)
|
|
2339
2315
|
}
|
|
2340
2316
|
|
|
@@ -33,6 +33,7 @@ import { getUrlPrefix } from './lib/querying/url-helpers.js'
|
|
|
33
33
|
import { createStorageAdapter } from './lib/storage/storage-adapter.js'
|
|
34
34
|
import {
|
|
35
35
|
applyCursorPredicate,
|
|
36
|
+
validateCursorValues,
|
|
36
37
|
applyQueryFieldOrder,
|
|
37
38
|
buildEffectiveSortList,
|
|
38
39
|
parseSortEntry
|
|
@@ -113,6 +114,7 @@ export const RestApiKnexPlugin = {
|
|
|
113
114
|
sortableFields,
|
|
114
115
|
storageAdapter,
|
|
115
116
|
defaultSort,
|
|
117
|
+
before = false,
|
|
116
118
|
queryFieldRuntimeByField = new Map()
|
|
117
119
|
}) => {
|
|
118
120
|
const effectiveSort = buildEffectiveSortList(sort, { defaultSort, idField: 'id' })
|
|
@@ -120,6 +122,7 @@ export const RestApiKnexPlugin = {
|
|
|
120
122
|
|
|
121
123
|
for (const sortEntry of effectiveSort) {
|
|
122
124
|
const { field, direction, sqlDirection } = parseSortEntry(sortEntry)
|
|
125
|
+
const queryDirection = before ? (direction === 'asc' ? 'desc' : 'asc') : direction
|
|
123
126
|
|
|
124
127
|
if (field !== 'id' && sortableFields?.length > 0 && !sortableFields.includes(field)) {
|
|
125
128
|
log.warn(`Ignoring non-sortable field: ${field}`)
|
|
@@ -128,7 +131,7 @@ export const RestApiKnexPlugin = {
|
|
|
128
131
|
|
|
129
132
|
const queryFieldRuntime = queryFieldRuntimeByField.get(field)
|
|
130
133
|
if (queryFieldRuntime) {
|
|
131
|
-
applyQueryFieldOrder(query, queryFieldRuntime,
|
|
134
|
+
applyQueryFieldOrder(query, queryFieldRuntime, queryDirection.toUpperCase(), before ? 'first' : 'last')
|
|
132
135
|
descriptors.push({
|
|
133
136
|
field,
|
|
134
137
|
direction: sqlDirection,
|
|
@@ -145,11 +148,15 @@ export const RestApiKnexPlugin = {
|
|
|
145
148
|
dbField = searchField.actualField
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
const
|
|
151
|
+
const storageColumn = storageAdapter?.translateColumn
|
|
149
152
|
? storageAdapter.translateColumn(dbField)
|
|
150
153
|
: dbField
|
|
154
|
+
const translatedField = storageColumn.includes('.')
|
|
155
|
+
? storageColumn
|
|
156
|
+
: `${storageAdapter?.getTableName?.() || schemaInfo.tableName}.${storageColumn}`
|
|
151
157
|
|
|
152
|
-
query.
|
|
158
|
+
query.orderByRaw(`?? IS NULL ${before ? 'DESC' : 'ASC'}`, [translatedField])
|
|
159
|
+
query.orderBy(translatedField, queryDirection)
|
|
153
160
|
descriptors.push({
|
|
154
161
|
field,
|
|
155
162
|
direction: sqlDirection,
|
|
@@ -659,6 +666,7 @@ export const RestApiKnexPlugin = {
|
|
|
659
666
|
schemaInfo,
|
|
660
667
|
sortableFields,
|
|
661
668
|
defaultSort: scope.vars.defaultSort,
|
|
669
|
+
before: Boolean(queryParams.page?.before),
|
|
662
670
|
storageAdapter,
|
|
663
671
|
queryFieldRuntimeByField
|
|
664
672
|
})
|
|
@@ -671,13 +679,13 @@ export const RestApiKnexPlugin = {
|
|
|
671
679
|
queryParams.page.after !== undefined ||
|
|
672
680
|
queryParams.page.before !== undefined)
|
|
673
681
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
)
|
|
682
|
+
const requestedSize = queryParams.page?.size || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
|
|
683
|
+
const pageSize = Math.min(
|
|
684
|
+
requestedSize,
|
|
685
|
+
scope.vars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT
|
|
686
|
+
)
|
|
680
687
|
|
|
688
|
+
if (hasPageParams) {
|
|
681
689
|
// Validate page size
|
|
682
690
|
if (requestedSize <= 0) {
|
|
683
691
|
throw new RestApiValidationError(
|
|
@@ -721,6 +729,7 @@ export const RestApiKnexPlugin = {
|
|
|
721
729
|
)
|
|
722
730
|
}
|
|
723
731
|
|
|
732
|
+
cursorData = validateCursorValues(sortDescriptors, cursorData, 'after')
|
|
724
733
|
applyCursorPredicate(
|
|
725
734
|
query,
|
|
726
735
|
sortDescriptors,
|
|
@@ -755,6 +764,7 @@ export const RestApiKnexPlugin = {
|
|
|
755
764
|
)
|
|
756
765
|
}
|
|
757
766
|
|
|
767
|
+
cursorData = validateCursorValues(sortDescriptors, cursorData, 'before')
|
|
758
768
|
applyCursorPredicate(
|
|
759
769
|
query,
|
|
760
770
|
sortDescriptors,
|
|
@@ -778,8 +788,7 @@ export const RestApiKnexPlugin = {
|
|
|
778
788
|
}
|
|
779
789
|
} else {
|
|
780
790
|
// No pagination params provided - apply default limit
|
|
781
|
-
|
|
782
|
-
query.limit(defaultLimit)
|
|
791
|
+
query.limit(pageSize)
|
|
783
792
|
}
|
|
784
793
|
|
|
785
794
|
// Execute query
|
|
@@ -793,7 +802,6 @@ export const RestApiKnexPlugin = {
|
|
|
793
802
|
// Execute count query for pagination if offset-based pagination is used
|
|
794
803
|
if (queryParams.page?.number !== undefined || (queryParams.page?.size !== undefined && !queryParams.page?.after && !queryParams.page?.before)) {
|
|
795
804
|
const page = parseInt(queryParams.page?.number) || 1
|
|
796
|
-
const pageSize = parseInt(queryParams.page?.size) || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
|
|
797
805
|
|
|
798
806
|
// Only execute count query if enabled
|
|
799
807
|
if (scope.vars.enablePaginationCounts) {
|
|
@@ -855,8 +863,6 @@ export const RestApiKnexPlugin = {
|
|
|
855
863
|
// Generate cursor metadata when using cursor parameters OR when only size is specified (no page number)
|
|
856
864
|
if (queryParams.page?.after || queryParams.page?.before ||
|
|
857
865
|
(queryParams.page?.size && queryParams.page?.number === undefined)) {
|
|
858
|
-
const pageSize = parseInt(queryParams.page?.size) || scope.vars.queryDefaultLimit || DEFAULT_QUERY_LIMIT
|
|
859
|
-
|
|
860
866
|
// Check if there are more records
|
|
861
867
|
// We fetched pageSize + 1 records to detect if there are more
|
|
862
868
|
const hasMore = records.length > pageSize
|
|
@@ -865,12 +871,16 @@ export const RestApiKnexPlugin = {
|
|
|
865
871
|
if (hasMore) {
|
|
866
872
|
records.pop()
|
|
867
873
|
}
|
|
874
|
+
if (queryParams.page?.before) records.reverse()
|
|
868
875
|
|
|
869
876
|
const sortFields = sortDescriptors.map((descriptor) => descriptor.field)
|
|
877
|
+
const cursorOptions = {
|
|
878
|
+
schemaInfo,
|
|
879
|
+
definitions: Object.fromEntries(sortDescriptors.map(({ field, definition }) => [field, definition])),
|
|
880
|
+
before: Boolean(queryParams.page?.before)
|
|
881
|
+
}
|
|
870
882
|
|
|
871
|
-
context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields,
|
|
872
|
-
schemaInfo
|
|
873
|
-
})
|
|
883
|
+
context.returnMeta.paginationMeta = buildCursorMeta(records, pageSize, hasMore, sortFields, cursorOptions)
|
|
874
884
|
const urlPrefix = getUrlPrefix(context, scope)
|
|
875
885
|
context.returnMeta.paginationLinks = generateCursorPaginationLinks(
|
|
876
886
|
urlPrefix,
|
|
@@ -880,9 +890,7 @@ export const RestApiKnexPlugin = {
|
|
|
880
890
|
pageSize,
|
|
881
891
|
hasMore,
|
|
882
892
|
sortFields,
|
|
883
|
-
|
|
884
|
-
schemaInfo
|
|
885
|
-
}
|
|
893
|
+
cursorOptions
|
|
886
894
|
)
|
|
887
895
|
}
|
|
888
896
|
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
getRequestContracts,
|
|
14
14
|
validateRequestContractOrThrow
|
|
15
15
|
} from '../lib/querying-writing/request-contracts.js'
|
|
16
|
+
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
|
|
17
|
+
import { isRestApiError } from '../../../lib/error-context.js'
|
|
16
18
|
|
|
17
19
|
export { normalizeReturnRecordMode as normalizeReturnValue }
|
|
18
20
|
|
|
@@ -582,6 +584,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
|
|
|
582
584
|
setterContext
|
|
583
585
|
)
|
|
584
586
|
} catch (error) {
|
|
587
|
+
if (isRestApiError(error)) throw error
|
|
585
588
|
const message = error instanceof Error ? error.message : String(error)
|
|
586
589
|
throw new RestApiValidationError(
|
|
587
590
|
`Setter for field '${fieldName}' failed: ${message}`,
|
|
@@ -639,6 +642,7 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
639
642
|
})
|
|
640
643
|
context.minimalRecord = currentRecord
|
|
641
644
|
} catch (error) {
|
|
645
|
+
if (isRestApiError(error)) throw error
|
|
642
646
|
enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
|
|
643
647
|
}
|
|
644
648
|
}
|
|
@@ -671,6 +675,11 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
671
675
|
}
|
|
672
676
|
await runHooks('finish')
|
|
673
677
|
await runHooks(`finish${methodSpecificHookSuffix}`)
|
|
678
|
+
context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
|
|
679
|
+
source: 'response',
|
|
680
|
+
simplified: context.simplified,
|
|
681
|
+
resourceType: scopeName
|
|
682
|
+
})
|
|
674
683
|
return context.responseRecord
|
|
675
684
|
}
|
|
676
685
|
|
|
@@ -690,6 +699,12 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
690
699
|
await runHooks('finish')
|
|
691
700
|
await runHooks(`finish${methodSpecificHookSuffix}`)
|
|
692
701
|
|
|
702
|
+
context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
|
|
703
|
+
source: 'response',
|
|
704
|
+
simplified: context.simplified,
|
|
705
|
+
resourceType: scopeName
|
|
706
|
+
})
|
|
707
|
+
|
|
693
708
|
// No transformation needed - GET already returns the correct format
|
|
694
709
|
// based on context.simplified
|
|
695
710
|
return context.responseRecord
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { filterHiddenFields } from '../lib/querying-writing/field-utils.js'
|
|
2
|
+
import { isRestApiError } from '../../../lib/error-context.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* enrichAttributes
|
|
@@ -64,6 +65,7 @@ export default async function enrichAttributesMethod ({ context, params, runHook
|
|
|
64
65
|
getterContext
|
|
65
66
|
)
|
|
66
67
|
} catch (error) {
|
|
68
|
+
if (isRestApiError(error)) throw error
|
|
67
69
|
console.error(`Error in getter for field '${fieldName}' in ${scopeName}:`, error)
|
|
68
70
|
// Keep current value on error (don't break the whole request)
|
|
69
71
|
}
|
|
@@ -144,6 +146,7 @@ export default async function enrichAttributesMethod ({ context, params, runHook
|
|
|
144
146
|
// and returns: ((100 - 60) / 100 * 100) = "40.00"
|
|
145
147
|
filteredAttributes[fieldName] = await fieldDef.compute(computeContext)
|
|
146
148
|
} catch (error) {
|
|
149
|
+
if (isRestApiError(error)) throw error
|
|
147
150
|
// Log error but don't fail the request - computed fields shouldn't break API
|
|
148
151
|
console.error(`Error computing field '${fieldName}' for ${scopeName}:`, error)
|
|
149
152
|
filteredAttributes[fieldName] = null
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
|
|
2
2
|
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
|
|
3
|
-
import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
|
|
3
|
+
import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/querying-writing/knex-field-helpers.js'
|
|
4
4
|
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
|
|
5
5
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
6
6
|
import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
|
|
@@ -106,6 +106,7 @@ export default async function getMethod ({
|
|
|
106
106
|
originalContext: context,
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
await validateRequestedFieldsets(context, scopes)
|
|
109
110
|
await runHooks('beforeData')
|
|
110
111
|
await runHooks('beforeDataGet')
|
|
111
112
|
|
|
@@ -181,6 +182,12 @@ export default async function getMethod ({
|
|
|
181
182
|
await runHooks('finish')
|
|
182
183
|
await runHooks('finishGet')
|
|
183
184
|
|
|
185
|
+
// Enrichers and finish hooks run after the database pass and may introduce
|
|
186
|
+
// native values. Reassert the public temporal contract at the return boundary.
|
|
187
|
+
context.record = normalizeRecordAttributes(context.record, scopes, {
|
|
188
|
+
source: 'response'
|
|
189
|
+
})
|
|
190
|
+
|
|
184
191
|
// Get schema info for transformation
|
|
185
192
|
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
186
193
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
|
|
2
|
-
import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
|
|
2
|
+
import { getRequestedComputedFields, validateRequestedFieldsets } from '../lib/querying-writing/knex-field-helpers.js'
|
|
3
3
|
import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
|
|
4
4
|
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
|
|
5
5
|
import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
|
|
@@ -90,6 +90,7 @@ export default async function queryMethod ({
|
|
|
90
90
|
originalContext: context,
|
|
91
91
|
})
|
|
92
92
|
|
|
93
|
+
await validateRequestedFieldsets(context, scopes)
|
|
93
94
|
await runHooks('beforeData')
|
|
94
95
|
await runHooks('beforeDataQuery')
|
|
95
96
|
context.record = await helpers.dataQuery({
|
|
@@ -154,6 +155,12 @@ export default async function queryMethod ({
|
|
|
154
155
|
await runHooks('finish')
|
|
155
156
|
await runHooks('finishQuery')
|
|
156
157
|
|
|
158
|
+
// Enrichers and finish hooks run after the database pass and may introduce
|
|
159
|
+
// native values. Reassert the public temporal contract at the return boundary.
|
|
160
|
+
context.record = normalizeRecordAttributes(context.record, scopes, {
|
|
161
|
+
source: 'response'
|
|
162
|
+
})
|
|
163
|
+
|
|
157
164
|
// Transform output if in simplified mode
|
|
158
165
|
if (context.simplified) {
|
|
159
166
|
// Convert JSON:API response back to simplified format
|
|
@@ -127,9 +127,7 @@ maybeDescribe('AnyAPI Cursor Pagination', () => {
|
|
|
127
127
|
simplified: false,
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
const beforeCursor =
|
|
131
|
-
secondPage.meta.pagination.cursor?.next ||
|
|
132
|
-
`id:${encodeURIComponent(secondPage.data[0].id)}`
|
|
130
|
+
const beforeCursor = `id:${encodeURIComponent(secondPage.data[0].id)}`
|
|
133
131
|
|
|
134
132
|
const previousPage = await api.resources.books.query({
|
|
135
133
|
queryParams: {
|
|
@@ -86,12 +86,31 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
86
86
|
assert.equal(product.price, 99.99)
|
|
87
87
|
assert.equal(product.profit_margin, 55.00)
|
|
88
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')
|
|
89
91
|
|
|
90
92
|
// normallyHidden fields should not be included
|
|
91
93
|
assert.equal(product.cost, undefined)
|
|
92
94
|
assert.equal(product.internal_notes, undefined)
|
|
93
95
|
})
|
|
94
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
|
+
|
|
95
114
|
it('should handle division by zero in computed fields', async () => {
|
|
96
115
|
const freeProduct = await api.resources.products.post({
|
|
97
116
|
name: 'Free Sample',
|
|
@@ -132,6 +151,8 @@ describe('Computed Fields and Sparse Fieldsets', () => {
|
|
|
132
151
|
assert.equal(product.id, testData.product.id)
|
|
133
152
|
assert.equal(product.name, 'Premium Widget')
|
|
134
153
|
assert.equal(product.profit_margin, 55.00)
|
|
154
|
+
assert.equal(product.calculated_at, undefined)
|
|
155
|
+
assert.equal(product.calculated_time, undefined)
|
|
135
156
|
|
|
136
157
|
// Other fields should not be included
|
|
137
158
|
assert.equal(product.price, undefined)
|
|
@@ -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
|
})
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import knexLib from 'knex'
|
|
4
4
|
import { cleanTables } from './helpers/test-utils.js'
|
|
5
5
|
import { createFileUploadApi } from './fixtures/api-configs.js'
|
|
6
|
+
import { RestApiPayloadError, RestApiResourceError } from '../lib/rest-api-errors.js'
|
|
6
7
|
|
|
7
8
|
const knex = knexLib({
|
|
8
9
|
client: 'better-sqlite3',
|
|
@@ -115,4 +116,40 @@ describe('File handling cleanup', () => {
|
|
|
115
116
|
assert.deepEqual(storage.uploaded, ['/uploads/valid.png'])
|
|
116
117
|
assert.deepEqual(storage.deleted, ['/uploads/valid.png'])
|
|
117
118
|
})
|
|
119
|
+
|
|
120
|
+
it('preserves typed errors from file detectors', async () => {
|
|
121
|
+
const detector = api.rest.fileDetectors[0]
|
|
122
|
+
const originalParse = detector.parse
|
|
123
|
+
const error = new RestApiPayloadError('Malformed upload', { path: 'attachment' })
|
|
124
|
+
detectorState.payload = { fields: { title: 'Upload' }, files: {} }
|
|
125
|
+
detector.parse = async () => { throw error }
|
|
126
|
+
try {
|
|
127
|
+
await assert.rejects(api.resources.documents.post({
|
|
128
|
+
inputRecord: { data: { type: 'documents', attributes: { title: 'Upload' } } }, simplified: false
|
|
129
|
+
}), actual => actual === error)
|
|
130
|
+
assert.equal((await api.resources.documents.query({ simplified: false })).data.length, 0)
|
|
131
|
+
} finally {
|
|
132
|
+
detector.parse = originalParse
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('preserves typed upload errors and still cleans temporary files', async () => {
|
|
137
|
+
const originalUpload = storage.upload
|
|
138
|
+
const error = new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' })
|
|
139
|
+
let cleanupCalls = 0
|
|
140
|
+
detectorState.payload = {
|
|
141
|
+
fields: { title: 'Upload' },
|
|
142
|
+
files: { attachment: createTestFile({ cleanup: async () => { cleanupCalls++ } }) }
|
|
143
|
+
}
|
|
144
|
+
storage.upload = async () => { throw error }
|
|
145
|
+
try {
|
|
146
|
+
await assert.rejects(api.resources.documents.post({
|
|
147
|
+
inputRecord: { data: { type: 'documents', attributes: {} } }, simplified: false
|
|
148
|
+
}), actual => actual === error)
|
|
149
|
+
assert.equal(cleanupCalls, 1)
|
|
150
|
+
assert.equal((await api.resources.documents.query({ simplified: false })).data.length, 0)
|
|
151
|
+
} finally {
|
|
152
|
+
storage.upload = originalUpload
|
|
153
|
+
}
|
|
154
|
+
})
|
|
118
155
|
})
|