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
package/docs/API.md
CHANGED
|
@@ -2549,6 +2549,8 @@ The library uses standard JSON:API error format:
|
|
|
2549
2549
|
|
|
2550
2550
|
### Custom Error Handling
|
|
2551
2551
|
|
|
2552
|
+
Typed API errors thrown by getters, setters, computed fields, file detectors, or upload adapters retain their original code and details. For example, throwing `new RestApiResourceError('Upload forbidden', { subtype: 'forbidden' })` from an upload adapter produces a 403 response and still runs temporary-file cleanup.
|
|
2553
|
+
|
|
2552
2554
|
```javascript
|
|
2553
2555
|
// In hooks
|
|
2554
2556
|
api.resource('articles').hook('beforeDataPost', async (context) => {
|
|
@@ -96,6 +96,8 @@ await api.resources.post_tags.createKnexTable();
|
|
|
96
96
|
|
|
97
97
|
**Basic Pagination**
|
|
98
98
|
|
|
99
|
+
When `sortableFields` is omitted or empty, visible stored schema fields and relationship aliases can be sorted. Query fields marked `sortable: true` are added to that set. Unknown fields and ordinary computed fields are rejected with 422. If a sort field is repeated, its first occurrence determines the direction.
|
|
100
|
+
|
|
99
101
|
The API supports two types of pagination:
|
|
100
102
|
|
|
101
103
|
1. **Offset-based pagination** using `page[number]` and `page[size]` parameters
|
|
@@ -671,6 +673,13 @@ Next multiField page: {
|
|
|
671
673
|
|
|
672
674
|
**Cursor Pagination Notes:**
|
|
673
675
|
- Cursors are simple strings containing the sort field values (format: `field:value,field2:value2`)
|
|
676
|
+
- Sparse fieldsets may omit sort fields from the response; the Knex query still fetches those values internally to construct a complete cursor.
|
|
677
|
+
- A cursor missing any active sort field is rejected with a validation error. Keep the same sort order when following a cursor.
|
|
678
|
+
- Cursor values are validated against the sort field types. Invalid dates, numbers, or duplicate cursor fields produce HTTP 422.
|
|
679
|
+
- `page[size]` and `page[number]` must be positive integers. Choose one of `page[number]`, `page[after]`, or `page[before]`; these cannot be combined.
|
|
680
|
+
- Null sort values appear last in either sort direction. Generated cursors distinguish null from the literal string `"null"`; treat cursor strings as opaque values.
|
|
681
|
+
- `page[before]` returns the adjacent preceding records in the requested sort order. Use a cursor for the first record of the current page to step back. When a backward response has more preceding records, its `links.next` and `meta.pagination.cursor.next` continue in the `before` direction.
|
|
682
|
+
- Requested page sizes above `queryMaxLimit` are capped consistently for returned records, pagination metadata, and links.
|
|
674
683
|
- The API automatically generates appropriate WHERE clauses based on sort direction
|
|
675
684
|
- Cursor pagination is more efficient for large datasets as it doesn't need to count all records
|
|
676
685
|
- Works seamlessly with any sort field, not just timestamps
|
|
@@ -132,7 +132,7 @@ await api.addResource('users', {
|
|
|
132
132
|
});
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
If a setter throws or rejects, the write fails
|
|
135
|
+
If a setter throws or rejects, the write fails and the transaction rolls back. Typed API errors retain their code and details; other setter failures become validation errors. Getters and computed fields also propagate typed API errors, including `RestApiTemporalDataError`, so invalid output cannot be silently accepted.
|
|
136
136
|
|
|
137
137
|
### Getters - Transform After Retrieval
|
|
138
138
|
|
|
@@ -86,6 +86,20 @@ JavaScript `Date` objects, SQL datetime strings, Unix timestamps, locale text, a
|
|
|
86
86
|
**Storage Format:**
|
|
87
87
|
Schema validation preserves valid JSON temporal strings exactly. The built-in storage adapters convert `date` and `dateTime` strings to database-native `Date` values at the database boundary, while `time` remains a string. A custom field-level `storage.serialize` function receives the validated string and owns any custom database conversion.
|
|
88
88
|
|
|
89
|
+
The built-in `dateTime` conversion supports millisecond precision. A value such
|
|
90
|
+
as `2026-09-01T10:20:30.123456Z` fails with HTTP 422 before the write, because
|
|
91
|
+
converting it to `Date` would lose information. Trailing zeroes beyond three
|
|
92
|
+
digits are accepted (`.123000Z` represents exactly `.123Z`). To retain finer
|
|
93
|
+
precision in table-backed storage, use a custom `storage.serialize` function
|
|
94
|
+
and a database column and driver that preserve it. The same serializer is used
|
|
95
|
+
for filters and cursor comparisons, with `operation: 'filter'` and `context: null`.
|
|
96
|
+
|
|
97
|
+
Response normalization preserves fractional digits returned as strings by
|
|
98
|
+
storage, up to the field's declared precision, including during conversion of
|
|
99
|
+
timezone offsets to UTC. It also converts valid epoch strings and `bigint`
|
|
100
|
+
values from database drivers to JSON numbers, rejecting invalid or out-of-range
|
|
101
|
+
epoch data.
|
|
102
|
+
|
|
89
103
|
## Output Normalization
|
|
90
104
|
|
|
91
105
|
### Database to API Response
|
|
@@ -115,8 +129,9 @@ All date/time values are normalized when returned from the API:
|
|
|
115
129
|
3. **UTC Assumption**: MySQL DATETIME values (which lack timezone info) are assumed to be UTC
|
|
116
130
|
|
|
117
131
|
Normalization runs once after the database read and again at the final response
|
|
118
|
-
boundary. The final pass covers getters, computed fields,
|
|
119
|
-
that produce native `Date` values
|
|
132
|
+
boundary. The final pass covers getters, computed fields, query projections,
|
|
133
|
+
and finishing hooks that produce native `Date` values, including nested
|
|
134
|
+
relationships in simplified write responses. A malformed non-null temporal value is not
|
|
120
135
|
reported as `null`; it fails with `REST_API_TEMPORAL_DATA_INVALID` and HTTP 500
|
|
121
136
|
so stored-data or enrichment defects remain visible.
|
|
122
137
|
|
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { RestApiValidationError } from '../../lib/rest-api-errors.js'
|
|
36
|
+
import { isRestApiError } from '../../lib/error-context.js'
|
|
36
37
|
|
|
37
38
|
export const FileHandlingPlugin = {
|
|
38
39
|
name: 'file-handling',
|
|
@@ -165,6 +166,7 @@ export const FileHandlingPlugin = {
|
|
|
165
166
|
break
|
|
166
167
|
}
|
|
167
168
|
} catch (error) {
|
|
169
|
+
if (isRestApiError(error)) throw error
|
|
168
170
|
log.warn(`Detector '${detector.name}' failed:`, error)
|
|
169
171
|
}
|
|
170
172
|
}
|
|
@@ -269,6 +271,7 @@ export const FileHandlingPlugin = {
|
|
|
269
271
|
})
|
|
270
272
|
log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
|
|
271
273
|
} catch (error) {
|
|
274
|
+
if (isRestApiError(error)) throw error
|
|
272
275
|
throw new RestApiValidationError(
|
|
273
276
|
`Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
|
|
274
277
|
{
|
|
@@ -158,7 +158,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
158
158
|
|
|
159
159
|
if (pageCount !== undefined) {
|
|
160
160
|
links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
|
|
161
|
-
links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
|
|
161
|
+
links.last = buildJsonApiLink(baseUrl, queryParams, { number: Math.max(1, pageCount), size: pageSize })
|
|
162
162
|
|
|
163
163
|
if (page > 1) {
|
|
164
164
|
links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
|
|
@@ -234,13 +234,17 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
234
234
|
* 4. Cursor used in 'next' link for fetching subsequent pages
|
|
235
235
|
* 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
|
|
236
236
|
*/
|
|
237
|
-
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
237
|
+
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null, definitions = {} } = {}) => {
|
|
238
238
|
const parts = []
|
|
239
239
|
sortFields.forEach(field => {
|
|
240
240
|
let value = schemaInfo
|
|
241
241
|
? getFieldValue(record, schemaInfo, field)
|
|
242
242
|
: record[field]
|
|
243
|
-
const definition = schemaInfo?.schemaStructure?.[field]
|
|
243
|
+
const definition = definitions[field] || schemaInfo?.schemaStructure?.[field]
|
|
244
|
+
if (value === null) {
|
|
245
|
+
parts.push(`${field}:~null`)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
244
248
|
if (['date', 'dateTime', 'time'].includes(definition?.type) && value != null) {
|
|
245
249
|
value = normalizeDateValue(value, definition.type, {
|
|
246
250
|
temporalPrecision: definition.temporalPrecision,
|
|
@@ -251,7 +255,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
|
|
|
251
255
|
}
|
|
252
256
|
if (value !== undefined) {
|
|
253
257
|
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
254
|
-
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
258
|
+
parts.push(`${field}:${encodeURIComponent(stringValue).replace(/^~/, '%7E')}`)
|
|
255
259
|
}
|
|
256
260
|
})
|
|
257
261
|
return parts.join(',')
|
|
@@ -311,7 +315,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
|
|
|
311
315
|
*/
|
|
312
316
|
export const parseCursor = (cursor) => {
|
|
313
317
|
try {
|
|
314
|
-
const data =
|
|
318
|
+
const data = Object.create(null)
|
|
315
319
|
if (!cursor || cursor.trim() === '') {
|
|
316
320
|
throw new Error('Empty cursor')
|
|
317
321
|
}
|
|
@@ -331,7 +335,8 @@ export const parseCursor = (cursor) => {
|
|
|
331
335
|
throw new Error('Invalid cursor format: empty field name')
|
|
332
336
|
}
|
|
333
337
|
|
|
334
|
-
data
|
|
338
|
+
if (Object.hasOwn(data, field)) throw new Error(`Duplicate cursor field '${field}'`)
|
|
339
|
+
data[field] = encodedValue === '~null' ? null : decodeURIComponent(encodedValue)
|
|
335
340
|
}
|
|
336
341
|
|
|
337
342
|
return data
|
|
@@ -423,7 +428,7 @@ export const generateCursorPaginationLinks = (
|
|
|
423
428
|
pageSize,
|
|
424
429
|
hasMore,
|
|
425
430
|
sortFields = ['id'],
|
|
426
|
-
{ schemaInfo = null } = {}
|
|
431
|
+
{ schemaInfo = null, definitions = {} } = {}
|
|
427
432
|
) => {
|
|
428
433
|
// Allow empty urlPrefix to generate relative links
|
|
429
434
|
if (!records.length) return null
|
|
@@ -442,9 +447,10 @@ export const generateCursorPaginationLinks = (
|
|
|
442
447
|
links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
|
|
443
448
|
|
|
444
449
|
if (hasMore && records.length > 0) {
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
|
|
450
|
+
const direction = queryParams.page?.before ? 'before' : 'after'
|
|
451
|
+
const boundary = direction === 'before' ? records[0] : records[records.length - 1]
|
|
452
|
+
const nextCursor = createCursor(boundary, sortFields, { schemaInfo, definitions })
|
|
453
|
+
links.next = buildJsonApiLink(baseUrl, queryParams, { size: pageSize, [direction]: nextCursor })
|
|
448
454
|
}
|
|
449
455
|
|
|
450
456
|
return links
|
|
@@ -507,16 +513,16 @@ export const generateCursorPaginationLinks = (
|
|
|
507
513
|
* 3. Added to response.meta.pagination
|
|
508
514
|
* 4. Clients can use cursor directly or use the next link
|
|
509
515
|
*/
|
|
510
|
-
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
516
|
+
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null, definitions = {}, before = false } = {}) => {
|
|
511
517
|
const meta = {
|
|
512
518
|
pageSize,
|
|
513
519
|
hasMore
|
|
514
520
|
}
|
|
515
521
|
|
|
516
522
|
if (hasMore && records.length > 0) {
|
|
517
|
-
const lastRecord = records[records.length - 1]
|
|
523
|
+
const lastRecord = before ? records[0] : records[records.length - 1]
|
|
518
524
|
meta.cursor = {
|
|
519
|
-
next: createCursor(lastRecord, sortFields, { schemaInfo })
|
|
525
|
+
next: createCursor(lastRecord, sortFields, { schemaInfo, definitions })
|
|
520
526
|
}
|
|
521
527
|
}
|
|
522
528
|
|
|
@@ -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) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSchema } from 'json-rest-schema'
|
|
2
|
-
import { RestApiTemporalDataError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { RestApiTemporalDataError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
3
3
|
|
|
4
4
|
const DATE_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
5
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)$/
|
|
@@ -71,12 +71,12 @@ const parseEpochDatabaseValue = (value) => {
|
|
|
71
71
|
|
|
72
72
|
const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }) => {
|
|
73
73
|
if (value instanceof Date) {
|
|
74
|
-
if (!Number.isNaN(value.getTime())) return value
|
|
74
|
+
if (!Number.isNaN(value.getTime())) return value.toISOString()
|
|
75
75
|
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const epochDate = parseEpochDatabaseValue(value)
|
|
79
|
-
if (epochDate) return epochDate
|
|
79
|
+
if (epochDate) return epochDate.toISOString()
|
|
80
80
|
|
|
81
81
|
if (typeof value !== 'string') {
|
|
82
82
|
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
@@ -106,7 +106,11 @@ const parseKnownDatabaseDate = (value, { fieldName, resourceType, type, source }
|
|
|
106
106
|
if (Number.isNaN(parsed.getTime())) {
|
|
107
107
|
throwTemporalDataError({ fieldName, resourceType, type, source })
|
|
108
108
|
}
|
|
109
|
-
|
|
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()
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
/**
|
|
@@ -146,8 +150,7 @@ export function normalizeDateValue (value, type, {
|
|
|
146
150
|
})
|
|
147
151
|
}
|
|
148
152
|
|
|
149
|
-
const
|
|
150
|
-
const isoValue = dateValue.toISOString()
|
|
153
|
+
const isoValue = parseKnownDatabaseDate(value, { fieldName, resourceType, type, source })
|
|
151
154
|
const normalized = type === 'date'
|
|
152
155
|
? isoValue.slice(0, 10)
|
|
153
156
|
: limitFractionalSecondPrecision(isoValue, temporalPrecision)
|
|
@@ -202,6 +205,20 @@ export function normalizeValueForDatabaseStorage (value, type, {
|
|
|
202
205
|
resourceType,
|
|
203
206
|
source
|
|
204
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
|
+
}
|
|
205
222
|
const databaseValue = new Date(
|
|
206
223
|
type === 'date' ? `${validatedValue}T00:00:00Z` : validatedValue
|
|
207
224
|
)
|
|
@@ -323,6 +340,12 @@ export function normalizeAttributes (attributes, schemaStructure, {
|
|
|
323
340
|
resourceType,
|
|
324
341
|
source
|
|
325
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
|
+
)
|
|
326
349
|
}
|
|
327
350
|
}
|
|
328
351
|
|
|
@@ -420,11 +443,19 @@ export function normalizeAttributes (attributes, schemaStructure, {
|
|
|
420
443
|
* 4. Processes included array the same way
|
|
421
444
|
* 5. Returns complete response with normalized values
|
|
422
445
|
*/
|
|
423
|
-
export function normalizeRecordAttributes (record, scopes, {
|
|
446
|
+
export function normalizeRecordAttributes (record, scopes, {
|
|
447
|
+
source = 'database',
|
|
448
|
+
simplified = false,
|
|
449
|
+
resourceType
|
|
450
|
+
} = {}) {
|
|
424
451
|
if (!record || !scopes) {
|
|
425
452
|
return record
|
|
426
453
|
}
|
|
427
454
|
|
|
455
|
+
if (simplified) {
|
|
456
|
+
return normalizeSimplifiedRecord(record, resourceType, scopes, { source })
|
|
457
|
+
}
|
|
458
|
+
|
|
428
459
|
const normalizeEntry = (entry) => {
|
|
429
460
|
const schemaInfo = scopes[entry?.type]?.vars?.schemaInfo
|
|
430
461
|
if (!schemaInfo || !entry?.attributes) return
|
|
@@ -433,7 +464,8 @@ export function normalizeRecordAttributes (record, scopes, { source = 'database'
|
|
|
433
464
|
entry.attributes,
|
|
434
465
|
{
|
|
435
466
|
...(schemaInfo.schemaStructure || {}),
|
|
436
|
-
...(schemaInfo.computed || {})
|
|
467
|
+
...(schemaInfo.computed || {}),
|
|
468
|
+
...(scopes[entry.type]?.vars?.queryFields || {})
|
|
437
469
|
},
|
|
438
470
|
{
|
|
439
471
|
resourceType: entry.type,
|
|
@@ -464,3 +496,37 @@ export function normalizeRecordAttributes (record, scopes, { source = 'database'
|
|
|
464
496
|
|
|
465
497
|
return record
|
|
466
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,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'
|
|
@@ -177,7 +178,20 @@ const createLegacyAdapter = ({ knex, schemaInfo }) => {
|
|
|
177
178
|
const getFieldValue = (record, fieldName) => getLegacyFieldValue(record, schemaInfo, fieldName)
|
|
178
179
|
const translateFilterValue = (field, value) => {
|
|
179
180
|
const searchField = schemaInfo.searchSchemaStructure?.[field]
|
|
180
|
-
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
|
+
}
|
|
181
195
|
const definition = searchField || schemaField || {}
|
|
182
196
|
const isRelationship = Boolean(
|
|
183
197
|
searchField?.isRelationship ||
|