json-rest-api 1.0.28 → 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 +9 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1 -1
- package/docs/GUIDE/GUIDE_Y_Appendices.md +17 -2
- package/package.json +1 -1
- package/plugins/core/file-handling-plugin.js +3 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +19 -13
- 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 +74 -8
- 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/storage-adapter.js +15 -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 +11 -1
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +3 -0
- package/plugins/core/rest-api-plugin-methods/get.js +2 -1
- package/plugins/core/rest-api-plugin-methods/query.js +2 -1
- package/tests/anyapi-cursor-pagination.test.js +1 -3
- package/tests/file-handling.test.js +37 -0
- package/tests/fixtures/api-configs.js +57 -0
- package/tests/pagination-boundaries.test.js +154 -0
- package/tests/pagination-contract.test.js +122 -0
- package/tests/pagination-cursor-multifield.test.js +1 -3
- package/tests/temporal-boundaries.test.js +208 -0
|
@@ -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
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
validateRequestContractOrThrow
|
|
15
15
|
} from '../lib/querying-writing/request-contracts.js'
|
|
16
16
|
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
|
|
17
|
+
import { isRestApiError } from '../../../lib/error-context.js'
|
|
17
18
|
|
|
18
19
|
export { normalizeReturnRecordMode as normalizeReturnValue }
|
|
19
20
|
|
|
@@ -583,6 +584,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
|
|
|
583
584
|
setterContext
|
|
584
585
|
)
|
|
585
586
|
} catch (error) {
|
|
587
|
+
if (isRestApiError(error)) throw error
|
|
586
588
|
const message = error instanceof Error ? error.message : String(error)
|
|
587
589
|
throw new RestApiValidationError(
|
|
588
590
|
`Setter for field '${fieldName}' failed: ${message}`,
|
|
@@ -640,6 +642,7 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
640
642
|
})
|
|
641
643
|
context.minimalRecord = currentRecord
|
|
642
644
|
} catch (error) {
|
|
645
|
+
if (isRestApiError(error)) throw error
|
|
643
646
|
enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
|
|
644
647
|
}
|
|
645
648
|
}
|
|
@@ -672,6 +675,11 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
672
675
|
}
|
|
673
676
|
await runHooks('finish')
|
|
674
677
|
await runHooks(`finish${methodSpecificHookSuffix}`)
|
|
678
|
+
context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
|
|
679
|
+
source: 'response',
|
|
680
|
+
simplified: context.simplified,
|
|
681
|
+
resourceType: scopeName
|
|
682
|
+
})
|
|
675
683
|
return context.responseRecord
|
|
676
684
|
}
|
|
677
685
|
|
|
@@ -692,7 +700,9 @@ export async function handleRecordReturnAfterWrite ({
|
|
|
692
700
|
await runHooks(`finish${methodSpecificHookSuffix}`)
|
|
693
701
|
|
|
694
702
|
context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
|
|
695
|
-
source: 'response'
|
|
703
|
+
source: 'response',
|
|
704
|
+
simplified: context.simplified,
|
|
705
|
+
resourceType: scopeName
|
|
696
706
|
})
|
|
697
707
|
|
|
698
708
|
// No transformation needed - GET already returns the correct format
|
|
@@ -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
|
|
|
@@ -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({
|
|
@@ -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: {
|
|
@@ -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
|
})
|
|
@@ -58,6 +58,63 @@ async function withTenantContext (tenantId, fn) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export async function createTemporalBoundaryApi (knex) {
|
|
62
|
+
const api = new Api({ name: 'temporal-boundaries', log: { level: 'error' } })
|
|
63
|
+
await api.use(RestApiPlugin, {
|
|
64
|
+
simplifiedApi: false,
|
|
65
|
+
simplifiedTransport: false,
|
|
66
|
+
returnRecordApi: { post: 'full', put: 'full', patch: 'full' },
|
|
67
|
+
queryDefaultLimit: 2,
|
|
68
|
+
queryMaxLimit: 3
|
|
69
|
+
})
|
|
70
|
+
await api.use(QueryProjectionsPlugin)
|
|
71
|
+
await useStoragePlugin(api, knex)
|
|
72
|
+
const producedAt = {
|
|
73
|
+
type: 'dateTime', temporalPrecision: 0, computed: true,
|
|
74
|
+
compute: () => new Date('2026-09-08T01:02:03.456Z')
|
|
75
|
+
}
|
|
76
|
+
await api.addResource('people', {
|
|
77
|
+
schema: { id: { type: 'id' }, name: { type: 'string' }, producedAt },
|
|
78
|
+
relationships: { events: { type: 'hasMany', target: 'events', foreignKey: 'personId' } },
|
|
79
|
+
tableName: 'temporal_people'
|
|
80
|
+
})
|
|
81
|
+
await api.resources.people.createKnexTable()
|
|
82
|
+
mapTable('temporal_people', 'people')
|
|
83
|
+
await api.addResource('events', {
|
|
84
|
+
sortableFields: ['id', 'name', 'occurredAt', 'day', 'atTime', 'observedAtMs'],
|
|
85
|
+
schema: {
|
|
86
|
+
id: { type: 'id' },
|
|
87
|
+
name: { type: 'string', required: true },
|
|
88
|
+
occurredAt: { type: 'dateTime', temporalPrecision: 6, nullable: true, search: true },
|
|
89
|
+
day: { type: 'date', nullable: true, search: true },
|
|
90
|
+
atTime: { type: 'time', temporalPrecision: 3, nullable: true, search: true },
|
|
91
|
+
observedAtMs: { type: 'epochMilliseconds', nullable: true, search: true },
|
|
92
|
+
observedAtSeconds: { type: 'epochSeconds', nullable: true, search: true },
|
|
93
|
+
serializedAt: {
|
|
94
|
+
type: 'dateTime', temporalPrecision: 6, nullable: true, search: true,
|
|
95
|
+
storage: { serialize: value => value == null ? null : value.replace('T', ' ').replace(/Z$/, '') }
|
|
96
|
+
},
|
|
97
|
+
personId: { type: 'id', belongsTo: 'people', as: 'person', nullable: true },
|
|
98
|
+
subjectId: { type: 'number', nullable: true },
|
|
99
|
+
subjectType: { type: 'string', nullable: true },
|
|
100
|
+
producedAt
|
|
101
|
+
},
|
|
102
|
+
queryFields: {
|
|
103
|
+
projectedAt: {
|
|
104
|
+
type: 'dateTime', temporalPrecision: 3, sortable: true,
|
|
105
|
+
select: ({ knex, column }) => knex.raw('??', [column('occurredAt')])
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
relationships: {
|
|
109
|
+
subject: { belongsToPolymorphic: { types: ['people'], typeField: 'subjectType', idField: 'subjectId' } }
|
|
110
|
+
},
|
|
111
|
+
tableName: 'temporal_events'
|
|
112
|
+
})
|
|
113
|
+
await api.resources.events.createKnexTable()
|
|
114
|
+
mapTable('temporal_events', 'events')
|
|
115
|
+
return api
|
|
116
|
+
}
|
|
117
|
+
|
|
61
118
|
/**
|
|
62
119
|
* Creates a basic API configuration with Countries, Publishers, Authors, Books
|
|
63
120
|
*/
|