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,132 +1,231 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* @param {*} value - The date value from the database
|
|
5
|
-
* @param {string} type - The field type ('date', 'dateTime', or 'time')
|
|
6
|
-
* @returns {Date|string|null} Date object for date/dateTime, string for time, or null
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* // Input: Already a Date object
|
|
10
|
-
* normalizeDateValue(new Date('2024-01-15T10:30:00Z'), 'dateTime');
|
|
11
|
-
* // Output: Date object (unchanged)
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* // Input: MySQL datetime string without timezone
|
|
15
|
-
* normalizeDateValue('2024-01-15 10:30:00', 'dateTime');
|
|
16
|
-
* // Output: Date object parsed as UTC (2024-01-15T10:30:00Z)
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* // Input: Date-only string
|
|
20
|
-
* normalizeDateValue('2024-01-15', 'date');
|
|
21
|
-
* // Output: Date object at UTC midnight (2024-01-15T00:00:00Z)
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* // Input: Time field (always returns string)
|
|
25
|
-
* normalizeDateValue('14:30:45', 'time');
|
|
26
|
-
* // Output: "14:30:45" (string)
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* // Input: Date object for time field
|
|
30
|
-
* normalizeDateValue(new Date('2024-01-15T14:30:45Z'), 'time');
|
|
31
|
-
* // Output: "14:30:45" (extracts time portion)
|
|
32
|
-
*
|
|
33
|
-
* @description
|
|
34
|
-
* Used by:
|
|
35
|
-
* - normalizeAttributes to process individual field values
|
|
36
|
-
* - Applied to all date/time fields fetched from database
|
|
37
|
-
*
|
|
38
|
-
* Purpose:
|
|
39
|
-
* - Handles database-specific date formats (especially MySQL)
|
|
40
|
-
* - Prevents timezone shifts for date-only fields
|
|
41
|
-
* - Ensures consistent Date objects across databases
|
|
42
|
-
* - Keeps time fields as HH:MM:SS strings
|
|
43
|
-
*
|
|
44
|
-
* Data flow:
|
|
45
|
-
* 1. Checks for null/undefined (returns null)
|
|
46
|
-
* 2. For time fields: ensures HH:MM:SS string format
|
|
47
|
-
* 3. For date/dateTime: converts to Date objects
|
|
48
|
-
* 4. Handles MySQL datetime format (no T separator)
|
|
49
|
-
* 5. Forces UTC interpretation to prevent timezone issues
|
|
50
|
-
*/
|
|
51
|
-
export function normalizeDateValue (value, type) {
|
|
52
|
-
// Handle null/undefined
|
|
53
|
-
if (value === null || value === undefined) {
|
|
54
|
-
return null
|
|
55
|
-
}
|
|
1
|
+
import { createSchema } from 'json-rest-schema'
|
|
2
|
+
import { RestApiTemporalDataError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
56
3
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
4
|
+
const DATE_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
5
|
+
const RFC3339_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/
|
|
6
|
+
const SQL_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) ((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?)$/
|
|
7
|
+
const INTEGER_VALUE_PATTERN = /^-?\d+$/
|
|
8
|
+
const temporalContractCache = new Map()
|
|
9
|
+
|
|
10
|
+
const limitFractionalSecondPrecision = (value, temporalPrecision) => {
|
|
11
|
+
if (!Number.isInteger(temporalPrecision)) return value
|
|
12
|
+
|
|
13
|
+
return value.replace(
|
|
14
|
+
/(\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z)?$/,
|
|
15
|
+
(_match, wholeSeconds, fraction = '', utcSuffix = '') => {
|
|
16
|
+
const limitedFraction = fraction.slice(0, temporalPrecision)
|
|
17
|
+
return `${wholeSeconds}${limitedFraction ? `.${limitedFraction}` : ''}${utcSuffix}`
|
|
66
18
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const getTemporalContract = (type, temporalPrecision) => {
|
|
23
|
+
const cacheKey = `${type}:${Number.isInteger(temporalPrecision) ? temporalPrecision : 'default'}`
|
|
24
|
+
if (!temporalContractCache.has(cacheKey)) {
|
|
25
|
+
temporalContractCache.set(cacheKey, createSchema({
|
|
26
|
+
value: {
|
|
27
|
+
type,
|
|
28
|
+
...(Number.isInteger(temporalPrecision) ? { temporalPrecision } : {})
|
|
72
29
|
}
|
|
73
|
-
}
|
|
74
|
-
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
return temporalContractCache.get(cacheKey)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const throwTemporalDataError = ({ fieldName, resourceType, type, source }) => {
|
|
36
|
+
throw new RestApiTemporalDataError({
|
|
37
|
+
field: fieldName,
|
|
38
|
+
resourceType,
|
|
39
|
+
fieldType: type,
|
|
40
|
+
source
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const validateTemporalJsonValue = (value, type, {
|
|
45
|
+
temporalPrecision,
|
|
46
|
+
fieldName,
|
|
47
|
+
resourceType,
|
|
48
|
+
source
|
|
49
|
+
} = {}) => {
|
|
50
|
+
const { validatedObject, errors } = getTemporalContract(type, temporalPrecision).patch({ value })
|
|
51
|
+
if (Object.keys(errors).length > 0) {
|
|
52
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
53
|
+
}
|
|
54
|
+
return validatedObject.value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parseEpochDatabaseValue = (value) => {
|
|
58
|
+
let epochValue = null
|
|
59
|
+
if (typeof value === 'number' && Number.isInteger(value)) {
|
|
60
|
+
epochValue = value
|
|
61
|
+
} else if (typeof value === 'bigint') {
|
|
62
|
+
epochValue = Number(value)
|
|
63
|
+
} else if (typeof value === 'string' && INTEGER_VALUE_PATTERN.test(value.trim())) {
|
|
64
|
+
epochValue = Number(value.trim())
|
|
75
65
|
}
|
|
76
66
|
|
|
77
|
-
|
|
67
|
+
if (!Number.isSafeInteger(epochValue)) return null
|
|
68
|
+
const parsed = new Date(epochValue)
|
|
69
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }) => {
|
|
78
73
|
if (value instanceof Date) {
|
|
79
|
-
return value
|
|
74
|
+
if (!Number.isNaN(value.getTime())) return value.toISOString()
|
|
75
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
const trimmed = value.trim()
|
|
78
|
+
const epochDate = parseEpochDatabaseValue(value)
|
|
79
|
+
if (epochDate) return epochDate.toISOString()
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return numericDate
|
|
90
|
-
}
|
|
91
|
-
}
|
|
81
|
+
if (typeof value !== 'string') {
|
|
82
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
83
|
+
}
|
|
92
84
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
85
|
+
const trimmed = value.trim()
|
|
86
|
+
const sqlMatch = SQL_DATETIME_PATTERN.exec(trimmed)
|
|
87
|
+
const rfc3339Match = RFC3339_DATETIME_PATTERN.exec(trimmed)
|
|
88
|
+
let dateTimeValue = null
|
|
89
|
+
|
|
90
|
+
if (sqlMatch) {
|
|
91
|
+
dateTimeValue = `${sqlMatch[1]}T${sqlMatch[2]}Z`
|
|
92
|
+
} else if (rfc3339Match) {
|
|
93
|
+
dateTimeValue = trimmed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!dateTimeValue) {
|
|
97
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
validateTemporalJsonValue(dateTimeValue, 'dateTime', {
|
|
101
|
+
fieldName,
|
|
102
|
+
resourceType,
|
|
103
|
+
source
|
|
104
|
+
})
|
|
105
|
+
const parsed = new Date(dateTimeValue)
|
|
106
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
107
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
108
|
+
}
|
|
109
|
+
// Date handles the offset, but cannot retain digits beyond milliseconds.
|
|
110
|
+
const fraction = /\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/.exec(dateTimeValue)?.[1]
|
|
111
|
+
return fraction
|
|
112
|
+
? parsed.toISOString().replace(/\.\d{3}Z$/, `.${fraction}Z`)
|
|
113
|
+
: parsed.toISOString()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Converts a database temporal value to the public JSON representation used by
|
|
118
|
+
* json-rest-schema: YYYY-MM-DD for date, RFC 3339 for dateTime, and an
|
|
119
|
+
* offset-free string for time.
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeDateValue (value, type, {
|
|
122
|
+
temporalPrecision,
|
|
123
|
+
fieldName,
|
|
124
|
+
resourceType,
|
|
125
|
+
source = 'database'
|
|
126
|
+
} = {}) {
|
|
127
|
+
if (value === null || value === undefined) return null
|
|
107
128
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
129
|
+
if (type === 'time') {
|
|
130
|
+
let timeValue = null
|
|
131
|
+
if (typeof value === 'string') {
|
|
132
|
+
timeValue = limitFractionalSecondPrecision(value.trim(), temporalPrecision)
|
|
133
|
+
}
|
|
134
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
135
|
+
timeValue = limitFractionalSecondPrecision(value.toISOString().slice(11, -1), temporalPrecision)
|
|
112
136
|
}
|
|
137
|
+
return validateTemporalJsonValue(timeValue, type, {
|
|
138
|
+
temporalPrecision,
|
|
139
|
+
fieldName,
|
|
140
|
+
resourceType,
|
|
141
|
+
source
|
|
142
|
+
})
|
|
113
143
|
}
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
145
|
+
if (type === 'date' && typeof value === 'string' && DATE_VALUE_PATTERN.test(value.trim())) {
|
|
146
|
+
return validateTemporalJsonValue(value.trim(), type, {
|
|
147
|
+
fieldName,
|
|
148
|
+
resourceType,
|
|
149
|
+
source
|
|
150
|
+
})
|
|
118
151
|
}
|
|
119
152
|
|
|
120
|
-
|
|
121
|
-
const
|
|
153
|
+
const isoValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
|
|
154
|
+
const normalized = type === 'date'
|
|
155
|
+
? isoValue.slice(0, 10)
|
|
156
|
+
: limitFractionalSecondPrecision(isoValue, temporalPrecision)
|
|
157
|
+
return validateTemporalJsonValue(normalized, type, {
|
|
158
|
+
temporalPrecision,
|
|
159
|
+
fieldName,
|
|
160
|
+
resourceType,
|
|
161
|
+
source
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Converts validated JSON temporal values to the native values expected by
|
|
167
|
+
* database drivers. JSON schema validation keeps date and dateTime values as
|
|
168
|
+
* strings; database writes and comparisons must normalize them consistently.
|
|
169
|
+
* Time values remain strings because database drivers accept their wire shape.
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeValueForDatabaseStorage (value, type, {
|
|
172
|
+
temporalPrecision,
|
|
173
|
+
fieldName,
|
|
174
|
+
resourceType,
|
|
175
|
+
source = 'storage'
|
|
176
|
+
} = {}) {
|
|
177
|
+
if (!['date', 'dateTime', 'time'].includes(type)) return value
|
|
178
|
+
if (value === null || value === undefined) return null
|
|
122
179
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
180
|
+
if (type === 'time') {
|
|
181
|
+
if (typeof value !== 'string') {
|
|
182
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
183
|
+
}
|
|
184
|
+
return validateTemporalJsonValue(value, type, {
|
|
185
|
+
temporalPrecision,
|
|
186
|
+
fieldName,
|
|
187
|
+
resourceType,
|
|
188
|
+
source
|
|
189
|
+
})
|
|
127
190
|
}
|
|
128
191
|
|
|
129
|
-
|
|
192
|
+
let publicValue = value
|
|
193
|
+
if (value instanceof Date) {
|
|
194
|
+
if (Number.isNaN(value.getTime())) {
|
|
195
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
196
|
+
}
|
|
197
|
+
publicValue = type === 'date'
|
|
198
|
+
? value.toISOString().slice(0, 10)
|
|
199
|
+
: limitFractionalSecondPrecision(value.toISOString(), temporalPrecision)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const validatedValue = validateTemporalJsonValue(publicValue, type, {
|
|
203
|
+
temporalPrecision,
|
|
204
|
+
fieldName,
|
|
205
|
+
resourceType,
|
|
206
|
+
source
|
|
207
|
+
})
|
|
208
|
+
const fraction = type === 'dateTime'
|
|
209
|
+
? /\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/.exec(validatedValue)?.[1]
|
|
210
|
+
: null
|
|
211
|
+
if (fraction && /[1-9]/.test(fraction.slice(3))) {
|
|
212
|
+
const field = fieldName ? `data.attributes.${fieldName}` : 'value'
|
|
213
|
+
throw new RestApiValidationError('Built-in dateTime storage supports millisecond precision.', {
|
|
214
|
+
fields: [field],
|
|
215
|
+
violations: [{
|
|
216
|
+
field,
|
|
217
|
+
rule: 'storage_precision',
|
|
218
|
+
message: 'Submillisecond values require a custom storage serializer.'
|
|
219
|
+
}]
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
const databaseValue = new Date(
|
|
223
|
+
type === 'date' ? `${validatedValue}T00:00:00Z` : validatedValue
|
|
224
|
+
)
|
|
225
|
+
if (Number.isNaN(databaseValue.getTime())) {
|
|
226
|
+
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
227
|
+
}
|
|
228
|
+
return databaseValue
|
|
130
229
|
}
|
|
131
230
|
|
|
132
231
|
/**
|
|
@@ -155,13 +254,13 @@ export function normalizeDateValue (value, type) {
|
|
|
155
254
|
*
|
|
156
255
|
* const normalized = normalizeAttributes(attributes, schema);
|
|
157
256
|
*
|
|
158
|
-
* // Output:
|
|
257
|
+
* // Output: JSON-safe values
|
|
159
258
|
* // {
|
|
160
259
|
* // id: 1,
|
|
161
260
|
* // is_active: true, // 1 → true
|
|
162
|
-
* // created_at:
|
|
163
|
-
* // birth_date:
|
|
164
|
-
* // shift_time: '09:00:00'
|
|
261
|
+
* // created_at: '2024-01-15T10:30:00.000Z',
|
|
262
|
+
* // birth_date: '1990-05-20',
|
|
263
|
+
* // shift_time: '09:00:00'
|
|
165
264
|
* // }
|
|
166
265
|
*
|
|
167
266
|
* @example
|
|
@@ -206,7 +305,10 @@ export function normalizeDateValue (value, type) {
|
|
|
206
305
|
* 4. Normalizes dates using normalizeDateValue
|
|
207
306
|
* 5. Returns new object with normalized values
|
|
208
307
|
*/
|
|
209
|
-
export function normalizeAttributes (attributes, schemaStructure
|
|
308
|
+
export function normalizeAttributes (attributes, schemaStructure, {
|
|
309
|
+
resourceType,
|
|
310
|
+
source = 'database'
|
|
311
|
+
} = {}) {
|
|
210
312
|
if (!attributes || !schemaStructure) {
|
|
211
313
|
return attributes
|
|
212
314
|
}
|
|
@@ -232,7 +334,18 @@ export function normalizeAttributes (attributes, schemaStructure) {
|
|
|
232
334
|
|
|
233
335
|
// Normalize date/dateTime/time values
|
|
234
336
|
if (fieldDef.type === 'date' || fieldDef.type === 'dateTime' || fieldDef.type === 'time') {
|
|
235
|
-
normalized[fieldName] = normalizeDateValue(value, fieldDef.type
|
|
337
|
+
normalized[fieldName] = normalizeDateValue(value, fieldDef.type, {
|
|
338
|
+
temporalPrecision: fieldDef.temporalPrecision,
|
|
339
|
+
fieldName,
|
|
340
|
+
resourceType,
|
|
341
|
+
source
|
|
342
|
+
})
|
|
343
|
+
} else if (['epochMilliseconds', 'epochSeconds'].includes(fieldDef.type) && value != null) {
|
|
344
|
+
normalized[fieldName] = validateTemporalJsonValue(
|
|
345
|
+
typeof value === 'bigint' ? String(value) : value,
|
|
346
|
+
fieldDef.type,
|
|
347
|
+
{ fieldName, resourceType, source }
|
|
348
|
+
)
|
|
236
349
|
}
|
|
237
350
|
}
|
|
238
351
|
|
|
@@ -278,8 +391,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
|
|
|
278
391
|
* // id: '1',
|
|
279
392
|
* // attributes: {
|
|
280
393
|
* // title: 'My Article',
|
|
281
|
-
* // is_published: true,
|
|
282
|
-
* // published_at:
|
|
394
|
+
* // is_published: true,
|
|
395
|
+
* // published_at: '2024-01-15T10:00:00.000Z'
|
|
283
396
|
* // }
|
|
284
397
|
* // },
|
|
285
398
|
* // included: [{
|
|
@@ -287,8 +400,8 @@ export function normalizeAttributes (attributes, schemaStructure) {
|
|
|
287
400
|
* // id: '10',
|
|
288
401
|
* // attributes: {
|
|
289
402
|
* // name: 'John',
|
|
290
|
-
* // is_admin: false,
|
|
291
|
-
* // last_login:
|
|
403
|
+
* // is_admin: false,
|
|
404
|
+
* // last_login: '2024-01-14T15:30:00.000Z'
|
|
292
405
|
* // }
|
|
293
406
|
* // }]
|
|
294
407
|
* // }
|
|
@@ -330,49 +443,90 @@ export function normalizeAttributes (attributes, schemaStructure) {
|
|
|
330
443
|
* 4. Processes included array the same way
|
|
331
444
|
* 5. Returns complete response with normalized values
|
|
332
445
|
*/
|
|
333
|
-
export function normalizeRecordAttributes (record, scopes
|
|
446
|
+
export function normalizeRecordAttributes (record, scopes, {
|
|
447
|
+
source = 'database',
|
|
448
|
+
simplified = false,
|
|
449
|
+
resourceType
|
|
450
|
+
} = {}) {
|
|
334
451
|
if (!record || !scopes) {
|
|
335
452
|
return record
|
|
336
453
|
}
|
|
337
454
|
|
|
455
|
+
if (simplified) {
|
|
456
|
+
return normalizeSimplifiedRecord(record, resourceType, scopes, { source })
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const normalizeEntry = (entry) => {
|
|
460
|
+
const schemaInfo = scopes[entry?.type]?.vars?.schemaInfo
|
|
461
|
+
if (!schemaInfo || !entry?.attributes) return
|
|
462
|
+
|
|
463
|
+
entry.attributes = normalizeAttributes(
|
|
464
|
+
entry.attributes,
|
|
465
|
+
{
|
|
466
|
+
...(schemaInfo.schemaStructure || {}),
|
|
467
|
+
...(schemaInfo.computed || {}),
|
|
468
|
+
...(scopes[entry.type]?.vars?.queryFields || {})
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
resourceType: entry.type,
|
|
472
|
+
source
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
338
477
|
// Normalize main data records
|
|
339
478
|
if (record.data) {
|
|
340
479
|
if (Array.isArray(record.data)) {
|
|
341
480
|
// Handle array of records (query result)
|
|
342
481
|
for (const entry of record.data) {
|
|
343
|
-
|
|
344
|
-
if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
|
|
345
|
-
entry.attributes = normalizeAttributes(
|
|
346
|
-
entry.attributes,
|
|
347
|
-
scope.vars.schemaInfo.schemaStructure
|
|
348
|
-
)
|
|
349
|
-
}
|
|
482
|
+
normalizeEntry(entry)
|
|
350
483
|
}
|
|
351
484
|
} else {
|
|
352
485
|
// Handle single record (get result)
|
|
353
|
-
|
|
354
|
-
const scope = scopes[entry.type]
|
|
355
|
-
if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
|
|
356
|
-
entry.attributes = normalizeAttributes(
|
|
357
|
-
entry.attributes,
|
|
358
|
-
scope.vars.schemaInfo.schemaStructure
|
|
359
|
-
)
|
|
360
|
-
}
|
|
486
|
+
normalizeEntry(record.data)
|
|
361
487
|
}
|
|
362
488
|
}
|
|
363
489
|
|
|
364
490
|
// Normalize included records
|
|
365
491
|
if (record.included && Array.isArray(record.included)) {
|
|
366
492
|
for (const entry of record.included) {
|
|
367
|
-
|
|
368
|
-
if (scope?.vars?.schemaInfo?.schemaStructure && entry.attributes) {
|
|
369
|
-
entry.attributes = normalizeAttributes(
|
|
370
|
-
entry.attributes,
|
|
371
|
-
scope.vars.schemaInfo.schemaStructure
|
|
372
|
-
)
|
|
373
|
-
}
|
|
493
|
+
normalizeEntry(entry)
|
|
374
494
|
}
|
|
375
495
|
}
|
|
376
496
|
|
|
377
497
|
return record
|
|
378
498
|
}
|
|
499
|
+
|
|
500
|
+
function normalizeSimplifiedRecord (record, resourceType, scopes, options, visited = new WeakMap()) {
|
|
501
|
+
if (!record || typeof record !== 'object') return record
|
|
502
|
+
if (visited.has(record)) return visited.get(record)
|
|
503
|
+
const schemaInfo = scopes[resourceType]?.vars?.schemaInfo
|
|
504
|
+
if (!schemaInfo) return record
|
|
505
|
+
|
|
506
|
+
const schema = { ...schemaInfo.schemaStructure, ...schemaInfo.computed, ...scopes[resourceType]?.vars?.queryFields }
|
|
507
|
+
const normalized = normalizeAttributes(record, schema, { ...options, resourceType })
|
|
508
|
+
visited.set(record, normalized)
|
|
509
|
+
const relationships = { ...schemaInfo.schemaRelationships }
|
|
510
|
+
for (const definition of Object.values(schema)) {
|
|
511
|
+
if (definition.as && (definition.belongsTo || definition.belongsToPolymorphic)) {
|
|
512
|
+
relationships[definition.as] = definition
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const [name, definition] of Object.entries(relationships)) {
|
|
516
|
+
const normalizeRelated = (entry) => normalizeSimplifiedRecord(
|
|
517
|
+
entry,
|
|
518
|
+
definition.belongsToPolymorphic
|
|
519
|
+
? entry?._type
|
|
520
|
+
: definition.belongsTo || definition.target || name,
|
|
521
|
+
scopes,
|
|
522
|
+
options,
|
|
523
|
+
visited
|
|
524
|
+
)
|
|
525
|
+
if (Object.hasOwn(normalized, name)) {
|
|
526
|
+
normalized[name] = Array.isArray(normalized[name])
|
|
527
|
+
? normalized[name].map(normalizeRelated)
|
|
528
|
+
: normalizeRelated(normalized[name])
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return normalized
|
|
532
|
+
}
|
|
@@ -3,6 +3,15 @@ import { createSelectTranslator } from '../storage/storage-adapter.js'
|
|
|
3
3
|
import { buildQueryFieldRuntimes } from './query-field-helpers.js'
|
|
4
4
|
import { RestApiFieldsetError } from '../../../../lib/rest-api-errors.js'
|
|
5
5
|
|
|
6
|
+
export const validateRequestedFieldsets = async (context, scopes) => {
|
|
7
|
+
for (const scopeName of Object.keys(context.queryParams?.fields || {})) {
|
|
8
|
+
if (!scopes[scopeName]?.vars?.schemaInfo) continue
|
|
9
|
+
await buildFieldSelection(scopes[scopeName], {
|
|
10
|
+
context: { scopeName, queryParams: { fields: context.queryParams.fields } }
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
16
|
* Checks if a field exists only in code, not in the database
|
|
8
17
|
*
|
|
@@ -212,7 +221,7 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
212
221
|
if (nonDatabaseFields.has(field)) return
|
|
213
222
|
|
|
214
223
|
const fieldDef = schemaStructure[field]
|
|
215
|
-
if (!fieldDef) {
|
|
224
|
+
if (!Object.hasOwn(schemaStructure, field) || !fieldDef) {
|
|
216
225
|
throw new RestApiFieldsetError({
|
|
217
226
|
field,
|
|
218
227
|
resourceType: scopeName
|
|
@@ -325,6 +334,11 @@ export const buildFieldSelection = async (scope, deps) => {
|
|
|
325
334
|
|
|
326
335
|
if (queryFieldNames.has(normalizedField)) {
|
|
327
336
|
queryFieldsToSelect.add(normalizedField)
|
|
337
|
+
} else if (schemaStructure[normalizedField] &&
|
|
338
|
+
schemaStructure[normalizedField].hidden !== true &&
|
|
339
|
+
!nonDatabaseFields.has(normalizedField)) {
|
|
340
|
+
// Cursor generation needs sort values even when the response omits them.
|
|
341
|
+
fieldsToSelect.add(normalizedField)
|
|
328
342
|
}
|
|
329
343
|
}
|
|
330
344
|
|
|
@@ -76,12 +76,12 @@ function buildFieldMapJsonSchemaFragment () {
|
|
|
76
76
|
|
|
77
77
|
function buildKnownPageSchema () {
|
|
78
78
|
return createSchema({
|
|
79
|
-
number: { type: '
|
|
80
|
-
size: { type: '
|
|
81
|
-
limit: { type: '
|
|
82
|
-
offset: { type: '
|
|
83
|
-
after: { type: 'string' },
|
|
84
|
-
before: { type: 'string' },
|
|
79
|
+
number: { type: 'integer', min: 1 },
|
|
80
|
+
size: { type: 'integer', min: 1 },
|
|
81
|
+
limit: { type: 'integer', min: 1 },
|
|
82
|
+
offset: { type: 'integer', min: 0 },
|
|
83
|
+
after: { type: 'string', min: 1 },
|
|
84
|
+
before: { type: 'string', min: 1 },
|
|
85
85
|
cursor: { type: 'string' }
|
|
86
86
|
})
|
|
87
87
|
}
|
|
@@ -313,6 +313,16 @@ function installRequestContractSupport () {
|
|
|
313
313
|
const firstError = pickFirstError(errors)
|
|
314
314
|
context.throwParamError(firstError?.code || 'INVALID_PAGE', firstError?.message || 'Invalid page parameters.')
|
|
315
315
|
}
|
|
316
|
+
for (const direction of ['after', 'before']) {
|
|
317
|
+
if (Object.hasOwn(knownPageParams, direction) && !validatedObject[direction]?.trim()) {
|
|
318
|
+
context.throwParamError('INVALID_PAGE', `page[${direction}] must contain a cursor.`)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if ((validatedObject.after !== undefined && validatedObject.before !== undefined) ||
|
|
323
|
+
(validatedObject.number !== undefined && (validatedObject.after !== undefined || validatedObject.before !== undefined))) {
|
|
324
|
+
context.throwParamError('INVALID_PAGE', 'Use either page[number], page[after], or page[before].')
|
|
325
|
+
}
|
|
316
326
|
|
|
317
327
|
return {
|
|
318
328
|
...context.value,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
2
|
+
|
|
1
3
|
export const translateCanonicalAttributesForStorage = (attributes = {}, descriptor = {}) => {
|
|
2
4
|
const row = {}
|
|
3
5
|
|
|
@@ -8,7 +10,15 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
|
|
|
8
10
|
if (slot.slotType === 'belongsTo') {
|
|
9
11
|
row[slot.slot] = value == null ? null : String(value)
|
|
10
12
|
} else {
|
|
11
|
-
row[slot.slot] =
|
|
13
|
+
row[slot.slot] = normalizeValueForDatabaseStorage(
|
|
14
|
+
value,
|
|
15
|
+
descriptor.schema?.[fieldName]?.type,
|
|
16
|
+
{
|
|
17
|
+
temporalPrecision: descriptor.schema?.[fieldName]?.temporalPrecision,
|
|
18
|
+
fieldName,
|
|
19
|
+
resourceType: descriptor.resource
|
|
20
|
+
}
|
|
21
|
+
)
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
if (slot.slotType === 'belongsTo') {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getFieldValue as getLegacyFieldValue,
|
|
3
3
|
getIdColumn,
|
|
4
|
+
getStorageInfo,
|
|
4
5
|
getStorageColumn,
|
|
5
6
|
translateAttributesForStorage,
|
|
6
7
|
} from './storage-mapping.js'
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
getCanonicalResourceIdColumn,
|
|
10
11
|
translateCanonicalAttributesForStorage,
|
|
11
12
|
} from './canonical-storage-mapping.js'
|
|
12
|
-
import {
|
|
13
|
+
import { normalizeValueForDatabaseStorage } from '../querying-writing/database-value-normalizers.js'
|
|
13
14
|
|
|
14
15
|
const passthrough = (value) => value
|
|
15
16
|
const identityTranslate = (_field, value) => value
|
|
@@ -77,7 +78,9 @@ const normalizeFilterValueForDefinition = (value, definition = {}, { isRelations
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
if (['date', 'dateTime', 'time'].includes(type)) {
|
|
80
|
-
return
|
|
81
|
+
return normalizeValueForDatabaseStorage(value, type, {
|
|
82
|
+
temporalPrecision: definition?.temporalPrecision
|
|
83
|
+
})
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
return value
|
|
@@ -175,7 +178,20 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
|
|
|
175
178
|
const getFieldValue = (record, fieldName) => getLegacyFieldValue(record, schemaInfo, fieldName)
|
|
176
179
|
const translateFilterValue = (field, value) => {
|
|
177
180
|
const searchField = schemaInfo.searchSchemaStructure?.[field]
|
|
178
|
-
const
|
|
181
|
+
const fieldName = searchField?.actualField || field
|
|
182
|
+
const schemaField = schemaInfo.schemaStructure?.[fieldName]
|
|
183
|
+
const fieldStorage = getStorageInfo(schemaInfo).fields[fieldName]
|
|
184
|
+
if (fieldStorage?.serialize) {
|
|
185
|
+
const serialize = (entry) => fieldStorage.serialize(entry, {
|
|
186
|
+
fieldName,
|
|
187
|
+
columnName: fieldStorage.column,
|
|
188
|
+
definition: fieldStorage.definition,
|
|
189
|
+
schemaInfo,
|
|
190
|
+
context: null,
|
|
191
|
+
operation: 'filter'
|
|
192
|
+
})
|
|
193
|
+
return Array.isArray(value) ? value.map(serialize) : serialize(value)
|
|
194
|
+
}
|
|
179
195
|
const definition = searchField || schemaField || {}
|
|
180
196
|
const isRelationship = Boolean(
|
|
181
197
|
searchField?.isRelationship ||
|