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
|
@@ -90,9 +90,20 @@ import {
|
|
|
90
90
|
getIdColumn as getStorageIdColumn,
|
|
91
91
|
getStorageColumn as getMappedStorageColumn,
|
|
92
92
|
} from '../storage/storage-mapping.js'
|
|
93
|
+
import { createStorageAdapter } from '../storage/storage-adapter.js'
|
|
93
94
|
|
|
94
95
|
const getScopeStorageAdapter = (scopes, scopeName) => scopes[scopeName]?.vars?.storageAdapter || null
|
|
95
96
|
|
|
97
|
+
const resolveScopeStorageAdapter = (scopes, scopeName, knex) => {
|
|
98
|
+
const configuredAdapter = getScopeStorageAdapter(scopes, scopeName)
|
|
99
|
+
if (configuredAdapter) return configuredAdapter
|
|
100
|
+
|
|
101
|
+
const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
|
|
102
|
+
if (!schemaInfo) return null
|
|
103
|
+
|
|
104
|
+
return createStorageAdapter({ knex, schemaInfo })
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
const getIdColumnForScope = (scopes, scopeName) => {
|
|
97
108
|
const adapter = getScopeStorageAdapter(scopes, scopeName)
|
|
98
109
|
if (adapter?.getIdColumn) {
|
|
@@ -162,7 +173,7 @@ const applyScopeFiltersToIncludeQuery = async ({
|
|
|
162
173
|
return { query }
|
|
163
174
|
}
|
|
164
175
|
|
|
165
|
-
const storageAdapter =
|
|
176
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, scopeName, db)
|
|
166
177
|
|
|
167
178
|
const queryState = await scopeObject.applyQueryFilters({
|
|
168
179
|
query,
|
|
@@ -503,7 +514,7 @@ export const loadBelongsTo = async (scope, deps) => {
|
|
|
503
514
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
504
515
|
// Build field selection for sparse fieldsets
|
|
505
516
|
const targetScopeObject = scopes[targetScope]
|
|
506
|
-
const storageAdapter =
|
|
517
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
|
|
507
518
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
508
519
|
? await buildFieldSelection(targetScopeObject, {
|
|
509
520
|
context: {
|
|
@@ -745,7 +756,7 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
745
756
|
|
|
746
757
|
// Step 3: Build field selection for sparse fieldsets
|
|
747
758
|
const targetScopeObject = scopes[targetScope]
|
|
748
|
-
const storageAdapter =
|
|
759
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
|
|
749
760
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
750
761
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
751
762
|
? await buildFieldSelection(targetScopeObject, {
|
|
@@ -974,7 +985,7 @@ export const loadHasMany = async (scope, deps) => {
|
|
|
974
985
|
|
|
975
986
|
// Build field selection for sparse fieldsets
|
|
976
987
|
const targetScopeObject = scopes[targetScope]
|
|
977
|
-
const storageAdapter =
|
|
988
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
|
|
978
989
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
979
990
|
? await buildFieldSelection(targetScopeObject, {
|
|
980
991
|
context: {
|
|
@@ -1224,7 +1235,7 @@ export const loadHasOne = async (scope, deps) => {
|
|
|
1224
1235
|
|
|
1225
1236
|
// Build query for hasOne - expects single result per parent
|
|
1226
1237
|
const targetScopeObject = scopes[targetScope]
|
|
1227
|
-
const storageAdapter =
|
|
1238
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
|
|
1228
1239
|
|
|
1229
1240
|
// Build field selection for sparse fieldsets
|
|
1230
1241
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
@@ -1396,7 +1407,7 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
|
|
|
1396
1407
|
|
|
1397
1408
|
// Build field selection for sparse fieldsets
|
|
1398
1409
|
const targetScopeObject = scopes[targetType]
|
|
1399
|
-
const storageAdapter =
|
|
1410
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetType, knex)
|
|
1400
1411
|
const fieldSelectionInfo = fields?.[targetType]
|
|
1401
1412
|
? await buildFieldSelection(targetScopeObject, {
|
|
1402
1413
|
context: {
|
|
@@ -1624,7 +1635,7 @@ export const loadReversePolymorphic = async (scope, deps) => {
|
|
|
1624
1635
|
|
|
1625
1636
|
// Build field selection for sparse fieldsets
|
|
1626
1637
|
const targetScopeObject = scopes[targetScope]
|
|
1627
|
-
const storageAdapter =
|
|
1638
|
+
const storageAdapter = resolveScopeStorageAdapter(scopes, targetScope, knex)
|
|
1628
1639
|
const targetIdColumn = getIdColumnForScope(scopes, targetScope)
|
|
1629
1640
|
const fieldSelectionInfo = fields?.[targetScope]
|
|
1630
1641
|
? await buildFieldSelection(targetScopeObject, {
|
|
@@ -1,9 +1,55 @@
|
|
|
1
1
|
import { normalizeStableSort, parseSortEntry } from './sort-helpers.js'
|
|
2
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
+
import { createSchema } from 'json-rest-schema'
|
|
4
|
+
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
5
|
+
|
|
6
|
+
export const validateCursorValues = (descriptors, cursorValues, parameter) => {
|
|
7
|
+
const validated = Object.create(null)
|
|
8
|
+
for (const { field, definition = {}, isRelationship } of descriptors) {
|
|
9
|
+
if (!Object.hasOwn(cursorValues, field)) continue
|
|
10
|
+
const value = cursorValues[field]
|
|
11
|
+
// Public resource IDs may be opaque strings, even when the storage schema
|
|
12
|
+
// uses the numeric id type for ordinary attributes.
|
|
13
|
+
const type = field === 'id' || isRelationship || definition?.type === 'id'
|
|
14
|
+
? 'string'
|
|
15
|
+
: definition?.type
|
|
16
|
+
if (!type) {
|
|
17
|
+
validated[field] = value
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
const contract = createSchema({
|
|
21
|
+
value: {
|
|
22
|
+
type,
|
|
23
|
+
noTrim: true,
|
|
24
|
+
nullable: field !== 'id',
|
|
25
|
+
...(definition?.temporalPrecision !== undefined
|
|
26
|
+
? { temporalPrecision: definition.temporalPrecision }
|
|
27
|
+
: {})
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
const { validatedObject, errors } = contract.patch({ value })
|
|
31
|
+
if (Object.keys(errors).length > 0 || (field === 'id' && !String(value ?? '').trim())) {
|
|
32
|
+
const path = `page.${parameter}`
|
|
33
|
+
throw new RestApiValidationError(`Invalid cursor value for sort field '${field}'.`, {
|
|
34
|
+
fields: [path],
|
|
35
|
+
violations: [{ field: path, rule: 'invalid_cursor_value', message: `Invalid ${type} value for '${field}'.` }]
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
validated[field] = validatedObject.value
|
|
39
|
+
}
|
|
40
|
+
return validated
|
|
41
|
+
}
|
|
2
42
|
|
|
3
43
|
export const getEffectiveSortableFields = (vars = {}) => {
|
|
4
|
-
const
|
|
44
|
+
const schema = vars.schemaInfo?.schemaStructure || {}
|
|
45
|
+
const baseSortableFields = Array.isArray(vars.sortableFields) && vars.sortableFields.length > 0
|
|
5
46
|
? vars.sortableFields
|
|
6
|
-
: [
|
|
47
|
+
: ['id', ...Object.keys(schema).filter((field) => {
|
|
48
|
+
const definition = schema[field]
|
|
49
|
+
return !definition.hidden && !definition.virtual && !definition.computed && !definition.belongsToPolymorphic
|
|
50
|
+
}), ...Object.entries(vars.schemaInfo?.searchSchemaStructure || {})
|
|
51
|
+
.filter(([, definition]) => definition.isRelationship && schema[definition.actualField]?.hidden !== true)
|
|
52
|
+
.map(([field]) => field)]
|
|
7
53
|
const queryFieldSortableFields = Object.entries(vars.queryFields || {})
|
|
8
54
|
.filter(([, fieldDef]) => fieldDef?.sortable === true)
|
|
9
55
|
.map(([fieldName]) => fieldName)
|
|
@@ -29,15 +75,37 @@ export const buildEffectiveSortList = (sort, { defaultSort, idField = 'id' } = {
|
|
|
29
75
|
return normalizeStableSort(sortList.length > 0 ? sortList : [idField], { idField })
|
|
30
76
|
}
|
|
31
77
|
|
|
32
|
-
export const applyQueryFieldOrder = (query, queryFieldRuntime, direction) => {
|
|
78
|
+
export const applyQueryFieldOrder = (query, queryFieldRuntime, direction, nulls = 'last') => {
|
|
79
|
+
query.orderByRaw(`(${queryFieldRuntime.sql}) IS NULL ${nulls === 'last' ? 'ASC' : 'DESC'}`, queryFieldRuntime.bindings)
|
|
33
80
|
query.orderByRaw(`(${queryFieldRuntime.sql}) ${direction}`, queryFieldRuntime.bindings)
|
|
34
81
|
}
|
|
35
82
|
|
|
36
83
|
export const applyQueryFieldPredicate = (builder, queryFieldRuntime, operator, value) => {
|
|
37
|
-
|
|
84
|
+
const definition = queryFieldRuntime.definition || {}
|
|
85
|
+
const normalizedValue = normalizeValueForDatabaseStorage(value, definition.type, {
|
|
86
|
+
temporalPrecision: definition.temporalPrecision
|
|
87
|
+
})
|
|
88
|
+
builder.whereRaw(`(${queryFieldRuntime.sql}) ${operator} ?`, [...queryFieldRuntime.bindings, normalizedValue])
|
|
38
89
|
}
|
|
39
90
|
|
|
40
|
-
export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate) => {
|
|
91
|
+
export const applySortDescriptorPredicate = (builder, descriptor, operator, value, applyPlainPredicate, includeNull = false) => {
|
|
92
|
+
if (includeNull) {
|
|
93
|
+
return builder.where(function () {
|
|
94
|
+
applySortDescriptorPredicate(this, descriptor, operator, value, applyPlainPredicate)
|
|
95
|
+
if (descriptor.queryFieldRuntime) {
|
|
96
|
+
this.orWhereRaw(`(${descriptor.queryFieldRuntime.sql}) IS NULL`, descriptor.queryFieldRuntime.bindings)
|
|
97
|
+
} else {
|
|
98
|
+
this.orWhereNull(descriptor.column)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
if (value === null) {
|
|
103
|
+
const isNotNull = operator === 'IS NOT NULL'
|
|
104
|
+
if (descriptor.queryFieldRuntime) {
|
|
105
|
+
return builder.whereRaw(`(${descriptor.queryFieldRuntime.sql}) IS ${isNotNull ? 'NOT ' : ''}NULL`, descriptor.queryFieldRuntime.bindings)
|
|
106
|
+
}
|
|
107
|
+
return isNotNull ? builder.whereNotNull(descriptor.column) : builder.whereNull(descriptor.column)
|
|
108
|
+
}
|
|
41
109
|
if (descriptor?.queryFieldRuntime) {
|
|
42
110
|
return applyQueryFieldPredicate(builder, descriptor.queryFieldRuntime, operator, value)
|
|
43
111
|
}
|
|
@@ -62,19 +130,27 @@ export const buildCursorPredicateChains = (
|
|
|
62
130
|
|
|
63
131
|
if (cursorValue === undefined) {
|
|
64
132
|
onMissingValue?.(descriptor.field)
|
|
65
|
-
|
|
133
|
+
throw new RestApiValidationError(`Cursor is missing a value for sort field '${descriptor.field}'.`, {
|
|
134
|
+
fields: [descriptor.field],
|
|
135
|
+
violations: [{
|
|
136
|
+
field: descriptor.field,
|
|
137
|
+
rule: 'missing_cursor_value',
|
|
138
|
+
message: 'Cursor must include every sort field.'
|
|
139
|
+
}]
|
|
140
|
+
})
|
|
66
141
|
}
|
|
67
142
|
|
|
143
|
+
const operator = operatorSelector(descriptor.direction)
|
|
144
|
+
const forward = operator === (descriptor.direction.toUpperCase() === 'DESC' ? '<' : '>')
|
|
145
|
+
// Nulls sort last. There are no values after null at this sort position,
|
|
146
|
+
// but later tie breakers can still advance within the null group.
|
|
147
|
+
if (cursorValue === null && forward) return
|
|
148
|
+
|
|
68
149
|
const chain = []
|
|
69
150
|
for (let i = 0; i < index; i += 1) {
|
|
70
151
|
const previousDescriptor = descriptors[i]
|
|
71
152
|
const previousValue = cursorValues?.[previousDescriptor.field]
|
|
72
153
|
|
|
73
|
-
if (previousValue === undefined) {
|
|
74
|
-
onMissingValue?.(previousDescriptor.field)
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
154
|
chain.push({
|
|
79
155
|
descriptor: previousDescriptor,
|
|
80
156
|
operator: '=',
|
|
@@ -84,8 +160,9 @@ export const buildCursorPredicateChains = (
|
|
|
84
160
|
|
|
85
161
|
chain.push({
|
|
86
162
|
descriptor,
|
|
87
|
-
operator:
|
|
88
|
-
value: cursorValue
|
|
163
|
+
operator: cursorValue === null ? 'IS NOT NULL' : operator,
|
|
164
|
+
value: cursorValue,
|
|
165
|
+
includeNull: cursorValue !== null && forward
|
|
89
166
|
})
|
|
90
167
|
|
|
91
168
|
predicateChains.push(chain)
|
|
@@ -116,13 +193,14 @@ export const applyCursorPredicate = (
|
|
|
116
193
|
query.where(function () {
|
|
117
194
|
predicateChains.forEach((chain) => {
|
|
118
195
|
this.orWhere(function () {
|
|
119
|
-
chain.forEach(({ descriptor, operator, value }) => {
|
|
196
|
+
chain.forEach(({ descriptor, operator, value, includeNull }) => {
|
|
120
197
|
applySortDescriptorPredicate(
|
|
121
198
|
this,
|
|
122
199
|
descriptor,
|
|
123
200
|
operator,
|
|
124
201
|
value,
|
|
125
|
-
applyPlainPredicate
|
|
202
|
+
applyPlainPredicate,
|
|
203
|
+
includeNull
|
|
126
204
|
)
|
|
127
205
|
})
|
|
128
206
|
})
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export function normalizeStableSort (sort = [], { idField = 'id' } = {}) {
|
|
2
|
+
const seenFields = new Set()
|
|
2
3
|
const sortList = Array.isArray(sort)
|
|
3
|
-
? sort.filter((entry) =>
|
|
4
|
+
? sort.filter((entry) => {
|
|
5
|
+
if (typeof entry !== 'string' || !entry.trim()) return false
|
|
6
|
+
const field = entry.startsWith('-') ? entry.slice(1) : entry
|
|
7
|
+
if (seenFields.has(field)) return false
|
|
8
|
+
seenFields.add(field)
|
|
9
|
+
return true
|
|
10
|
+
})
|
|
4
11
|
: []
|
|
5
12
|
|
|
6
13
|
if (sortList.length === 0) {
|