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
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) => {
|
|
@@ -13,7 +13,7 @@ await api.addResource('posts', {
|
|
|
13
13
|
content: { type: 'string', required: true },
|
|
14
14
|
view_count: { type: 'number', default: 0, indexed: true },
|
|
15
15
|
published_at: { type: 'dateTime', indexed: true },
|
|
16
|
-
created_at: { type: 'dateTime', defaultTo: Date.
|
|
16
|
+
created_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString(), indexed: true }
|
|
17
17
|
},
|
|
18
18
|
relationships: {
|
|
19
19
|
comments: {
|
|
@@ -54,7 +54,7 @@ await api.addResource('comments', {
|
|
|
54
54
|
schema: {
|
|
55
55
|
content: { type: 'string', required: true },
|
|
56
56
|
author_name: { type: 'string', required: true, max: 100 },
|
|
57
|
-
created_at: { type: 'dateTime', defaultTo: Date.
|
|
57
|
+
created_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString(), indexed: true },
|
|
58
58
|
likes: { type: 'number', default: 0, indexed: true },
|
|
59
59
|
post_id: { type: 'id', belongsTo: 'posts', as: 'post', required: true }
|
|
60
60
|
},
|
|
@@ -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
|
|
|
@@ -970,7 +970,7 @@ await api.addResource('posts', {
|
|
|
970
970
|
title: { type: 'string', required: true },
|
|
971
971
|
content: { type: 'string', required: true },
|
|
972
972
|
author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
|
|
973
|
-
published_at: { type: 'dateTime',
|
|
973
|
+
published_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
|
|
974
974
|
},
|
|
975
975
|
tableName: 'posts'
|
|
976
976
|
});
|
|
@@ -1064,7 +1064,7 @@ await api.addResource('posts', {
|
|
|
1064
1064
|
getter: (value) => value?.trim()
|
|
1065
1065
|
},
|
|
1066
1066
|
author_id: { type: 'number', belongsTo: 'authors', as: 'author' },
|
|
1067
|
-
published_at: { type: 'dateTime',
|
|
1067
|
+
published_at: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() }
|
|
1068
1068
|
},
|
|
1069
1069
|
tableName: 'posts'
|
|
1070
1070
|
});
|
|
@@ -6,7 +6,7 @@ This document explains how the JSON REST API handles date and time values throug
|
|
|
6
6
|
|
|
7
7
|
## Supported Date/Time Types
|
|
8
8
|
|
|
9
|
-
The API supports three temporal
|
|
9
|
+
The API supports three string temporal types and two numeric epoch types in schemas:
|
|
10
10
|
|
|
11
11
|
### 1. `date`
|
|
12
12
|
- **Format**: `YYYY-MM-DD`
|
|
@@ -16,8 +16,8 @@ The API supports three temporal data types in schemas:
|
|
|
16
16
|
- **JSON Output**: ISO 8601 date string
|
|
17
17
|
|
|
18
18
|
### 2. `dateTime`
|
|
19
|
-
- **Format**: `
|
|
20
|
-
- **Example Input**: `2024-01-
|
|
19
|
+
- **Format**: RFC 3339 with seconds and an explicit `Z` or `±HH:MM` offset
|
|
20
|
+
- **Example Input**: `2024-01-15T14:30:00Z`
|
|
21
21
|
- **Example Output**: `2024-01-15T14:30:00.000Z`
|
|
22
22
|
- **Usage**: Timestamps, created/updated times, or any date with time
|
|
23
23
|
- **Database Storage**: DATETIME (MySQL) or TIMESTAMP (PostgreSQL)
|
|
@@ -30,6 +30,13 @@ The API supports three temporal data types in schemas:
|
|
|
30
30
|
- **Database Storage**: TIME column type
|
|
31
31
|
- **JSON Output**: ISO 8601 time string
|
|
32
32
|
|
|
33
|
+
### 4. `epochMilliseconds` and `epochSeconds`
|
|
34
|
+
- **Format**: An integer number or canonical base-10 integer string
|
|
35
|
+
- **Example**: `1767323045000` (`epochMilliseconds`) or `1767323045` (`epochSeconds`)
|
|
36
|
+
- **Usage**: Systems whose temporal contract is explicitly a Unix epoch value
|
|
37
|
+
- **Database Storage**: BIGINT for table-backed resources
|
|
38
|
+
- **JSON Output**: Integer number in the same unit declared by the schema type
|
|
39
|
+
|
|
33
40
|
## Schema Definition
|
|
34
41
|
|
|
35
42
|
Define date/time fields in your schema like this:
|
|
@@ -37,12 +44,18 @@ Define date/time fields in your schema like this:
|
|
|
37
44
|
```javascript
|
|
38
45
|
const articleSchema = {
|
|
39
46
|
publishedDate: { type: 'date', required: true },
|
|
40
|
-
createdAt: { type: 'dateTime', defaultTo:
|
|
41
|
-
updatedAt: { type: 'dateTime', defaultTo:
|
|
47
|
+
createdAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
|
|
48
|
+
updatedAt: { type: 'dateTime', temporalPrecision: 3, defaultTo: () => new Date().toISOString() },
|
|
42
49
|
dailyPostTime: { type: 'time', nullable: true }
|
|
43
50
|
};
|
|
44
51
|
```
|
|
45
52
|
|
|
53
|
+
`Date.prototype.toISOString()` always emits three fractional-second digits, so
|
|
54
|
+
use it directly with unspecified precision or `temporalPrecision: 3` (or
|
|
55
|
+
higher). A lower precision requires a producer that returns no more than the
|
|
56
|
+
configured number of digits. Validation rejects excess input precision rather
|
|
57
|
+
than silently truncating it.
|
|
58
|
+
|
|
46
59
|
## Input Validation
|
|
47
60
|
|
|
48
61
|
### On Write Operations (POST/PUT/PATCH)
|
|
@@ -64,21 +77,28 @@ The API validates and normalizes date/time inputs:
|
|
|
64
77
|
```
|
|
65
78
|
|
|
66
79
|
**Accepted Input Formats:**
|
|
67
|
-
- **date**:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- ISO 8601 strings (`2024-01-15T14:30:00Z`) - recommended
|
|
73
|
-
- `YYYY-MM-DD HH:MM:SS` (assumed UTC)
|
|
74
|
-
- JavaScript Date parseable strings
|
|
75
|
-
- Unix timestamps (as numbers)
|
|
76
|
-
- **time**:
|
|
77
|
-
- `HH:MM:SS` or `HH:MM`
|
|
78
|
-
- Extracted from datetime strings
|
|
80
|
+
- **date**: a real calendar date in `YYYY-MM-DD` form
|
|
81
|
+
- **dateTime**: an RFC 3339 string with seconds and an explicit timezone, such as `2024-01-15T14:30:00Z` or `2024-01-15T22:30:00+08:00`
|
|
82
|
+
- **time**: an offset-free `HH:MM[:SS[.fraction]]` string
|
|
83
|
+
|
|
84
|
+
JavaScript `Date` objects, SQL datetime strings, Unix timestamps, locale text, and other values accepted by `Date.parse()` are not valid JSON temporal inputs. Use `epochMilliseconds` or `epochSeconds` schema fields for Unix timestamps.
|
|
79
85
|
|
|
80
86
|
**Storage Format:**
|
|
81
|
-
|
|
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
|
+
|
|
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.
|
|
82
102
|
|
|
83
103
|
## Output Normalization
|
|
84
104
|
|
|
@@ -105,9 +125,16 @@ All date/time values are normalized when returned from the API:
|
|
|
105
125
|
### Key Normalization Behaviors:
|
|
106
126
|
|
|
107
127
|
1. **Boolean Normalization**: Database values of `1`/`0` are converted to `true`/`false`
|
|
108
|
-
2. **
|
|
128
|
+
2. **JSON Temporal Values**: `date`, `dateTime`, and `time` attributes are returned as strings that match the same public schema contract accepted on input
|
|
109
129
|
3. **UTC Assumption**: MySQL DATETIME values (which lack timezone info) are assumed to be UTC
|
|
110
130
|
|
|
131
|
+
Normalization runs once after the database read and again at the final response
|
|
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
|
|
135
|
+
reported as `null`; it fails with `REST_API_TEMPORAL_DATA_INVALID` and HTTP 500
|
|
136
|
+
so stored-data or enrichment defects remain visible.
|
|
137
|
+
|
|
111
138
|
## Database-Specific Handling
|
|
112
139
|
|
|
113
140
|
### MySQL
|
|
@@ -164,7 +191,7 @@ If migrating from a system that stores dates differently:
|
|
|
164
191
|
|
|
165
192
|
1. **Local Time Storage**: Convert all dates to UTC before importing
|
|
166
193
|
2. **String Storage**: Ensure strings match expected formats
|
|
167
|
-
3. **Numeric Timestamps**: Use `
|
|
194
|
+
3. **Numeric Timestamps**: Use `epochMilliseconds` or `epochSeconds`, matching the producer's actual unit
|
|
168
195
|
|
|
169
196
|
### Database Configuration
|
|
170
197
|
|
|
@@ -206,23 +233,24 @@ For optimal date handling, configure your database connection:
|
|
|
206
233
|
|
|
207
234
|
### Issue 3: Time Values Need Date Context
|
|
208
235
|
**Symptom**: Can't perform date arithmetic on time-only values
|
|
209
|
-
**Cause**: Time values
|
|
210
|
-
**Solution**:
|
|
236
|
+
**Cause**: Time values intentionally have no date or timezone context
|
|
237
|
+
**Solution**: Keep recurring wall-clock values as `time`; use `dateTime` when an instant is required
|
|
211
238
|
|
|
212
239
|
## Technical Implementation Details
|
|
213
240
|
|
|
214
241
|
The date/time handling is implemented in two key areas:
|
|
215
242
|
|
|
216
243
|
1. **Input Validation** (`json-rest-schema`):
|
|
217
|
-
- Validates
|
|
218
|
-
-
|
|
219
|
-
-
|
|
220
|
-
- Returns Date objects for storage (Knex handles DB-specific formatting)
|
|
244
|
+
- Validates strict JSON temporal strings on write operations
|
|
245
|
+
- Preserves accepted strings exactly
|
|
246
|
+
- Rejects JavaScript `Date` objects and permissive legacy date formats
|
|
221
247
|
|
|
222
|
-
2. **Output Normalization** (`database-value-normalizers.js`):
|
|
248
|
+
2. **Storage and Output Normalization** (`storage` adapters and `database-value-normalizers.js`):
|
|
249
|
+
- Converts validated `date` and `dateTime` strings at the database boundary
|
|
250
|
+
- Uses the same database-native representation for writes and query comparisons
|
|
223
251
|
- Handles database-specific quirks (MySQL timezone issues)
|
|
224
|
-
-
|
|
252
|
+
- Parses database date values before emitting schema-valid JSON strings
|
|
225
253
|
- Fixes MySQL datetime strings by assuming UTC
|
|
226
254
|
- Maintains consistency across different database engines
|
|
227
255
|
|
|
228
|
-
This
|
|
256
|
+
This separation keeps the public JSON contract strict and serializable while still giving database drivers native values at the storage boundary.
|
package/index.js
CHANGED
package/lib/rest-api-errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HookedApiError } from 'hooked-api'
|
|
2
2
|
|
|
3
3
|
export const REST_API_FIELDSET_ERROR_CODE = 'REST_API_FIELDSET_INVALID'
|
|
4
|
+
export const REST_API_TEMPORAL_DATA_ERROR_CODE = 'REST_API_TEMPORAL_DATA_INVALID'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* REST API specific error classes that can be mapped to HTTP status codes
|
|
@@ -125,3 +126,37 @@ export class RestApiFieldsetError extends HookedApiError {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A non-null temporal value produced by storage or response enrichment does
|
|
132
|
+
* not satisfy the resource's public temporal contract.
|
|
133
|
+
*/
|
|
134
|
+
export class RestApiTemporalDataError extends HookedApiError {
|
|
135
|
+
constructor ({ field, resourceType, fieldType, source = 'database' } = {}) {
|
|
136
|
+
const fieldPath = [resourceType, field].filter(Boolean).join('.') || 'unknown field'
|
|
137
|
+
super(
|
|
138
|
+
`Invalid ${source} value for ${fieldType || 'temporal'} field '${fieldPath}'.`,
|
|
139
|
+
REST_API_TEMPORAL_DATA_ERROR_CODE
|
|
140
|
+
)
|
|
141
|
+
this.type = 'rest_api_temporal_data'
|
|
142
|
+
this.statusCode = 500
|
|
143
|
+
this.details = {
|
|
144
|
+
field,
|
|
145
|
+
resourceType,
|
|
146
|
+
fieldType,
|
|
147
|
+
source
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toJSON () {
|
|
152
|
+
return {
|
|
153
|
+
name: this.name,
|
|
154
|
+
message: this.message,
|
|
155
|
+
code: this.code,
|
|
156
|
+
type: this.type,
|
|
157
|
+
statusCode: this.statusCode,
|
|
158
|
+
details: this.details,
|
|
159
|
+
stack: this.stack
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "json-rest-api",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.29",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"description": "REST API plugin for hooked-api with JSON:API compliance",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"hooked-api": "^1.0.24",
|
|
32
|
-
"json-rest-schema": "^1.0.
|
|
32
|
+
"json-rest-schema": "^1.0.17",
|
|
33
33
|
"semver": "^7.7.2"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
3
|
+
REST_API_TEMPORAL_DATA_ERROR_CODE
|
|
4
|
+
} from '../../../../lib/rest-api-errors.js'
|
|
2
5
|
|
|
3
6
|
const JSON_API_WRITE_CONTENT_TYPES = [
|
|
4
7
|
'application/vnd.api+json',
|
|
@@ -73,6 +76,14 @@ export function mapRestApiErrorToHttp (error) {
|
|
|
73
76
|
title: 'Invalid Sparse Fieldset',
|
|
74
77
|
detail: error.message
|
|
75
78
|
}]
|
|
79
|
+
} else if (error.code === REST_API_TEMPORAL_DATA_ERROR_CODE) {
|
|
80
|
+
status = 500
|
|
81
|
+
errors = [{
|
|
82
|
+
status: '500',
|
|
83
|
+
code: error.code,
|
|
84
|
+
title: 'Invalid Temporal Data',
|
|
85
|
+
detail: error.message
|
|
86
|
+
}]
|
|
76
87
|
} else if (error.code === 'REST_API_VALIDATION') {
|
|
77
88
|
status = 422
|
|
78
89
|
if (error.details?.violations?.length) {
|
|
@@ -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
|
{
|
|
@@ -219,8 +219,9 @@ export const TYPE_TO_POOL = new Map([
|
|
|
219
219
|
['date', 'date'],
|
|
220
220
|
['datetime', 'date'],
|
|
221
221
|
['dateTime', 'date'],
|
|
222
|
-
['timestamp', 'date'],
|
|
223
222
|
['time', 'date'],
|
|
223
|
+
['epochMilliseconds', 'number'],
|
|
224
|
+
['epochSeconds', 'number'],
|
|
224
225
|
['json', 'json'],
|
|
225
226
|
['object', 'json'],
|
|
226
227
|
])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RestApiValidationError } from '../../../../../lib/rest-api-errors.js'
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeValueForDatabaseStorage } from '../../querying-writing/database-value-normalizers.js'
|
|
3
3
|
|
|
4
4
|
export const normalizeId = (value) => (value === null || value === undefined ? null : String(value))
|
|
5
5
|
|
|
@@ -93,7 +93,9 @@ export const coerceValueForDefinition = (value, definition, { isRelationship } =
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if (['date', 'dateTime', 'time'].includes(type)) {
|
|
96
|
-
return
|
|
96
|
+
return normalizeValueForDatabaseStorage(value, type, {
|
|
97
|
+
temporalPrecision: definition?.temporalPrecision
|
|
98
|
+
})
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
return value
|
|
@@ -300,7 +300,8 @@ function normalizeColumnShape (definition = {}) {
|
|
|
300
300
|
return 'datetime'
|
|
301
301
|
case 'time':
|
|
302
302
|
return 'time'
|
|
303
|
-
case '
|
|
303
|
+
case 'epochMilliseconds':
|
|
304
|
+
case 'epochSeconds':
|
|
304
305
|
return 'integer'
|
|
305
306
|
case 'array':
|
|
306
307
|
case 'object':
|
|
@@ -344,7 +345,8 @@ function normalizeDesiredTypeKind (definition = {}) {
|
|
|
344
345
|
case 'blob':
|
|
345
346
|
case 'file':
|
|
346
347
|
return 'binary'
|
|
347
|
-
case '
|
|
348
|
+
case 'epochMilliseconds':
|
|
349
|
+
case 'epochSeconds':
|
|
348
350
|
return 'integer'
|
|
349
351
|
case 'string':
|
|
350
352
|
case 'none':
|
|
@@ -361,6 +363,10 @@ function normalizeComparableDefault (value) {
|
|
|
361
363
|
return String(value)
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
const hasStaticDefault = (definition = {}) => (
|
|
367
|
+
definition.defaultTo !== undefined && typeof definition.defaultTo !== 'function'
|
|
368
|
+
)
|
|
369
|
+
|
|
364
370
|
function normalizeDesiredColumn (tableContext, fieldName, definition, options = {}) {
|
|
365
371
|
const nullable = definition.nullable === true
|
|
366
372
|
? true
|
|
@@ -375,8 +381,10 @@ function normalizeDesiredColumn (tableContext, fieldName, definition, options =
|
|
|
375
381
|
typeKind: normalizeDesiredTypeKind(definition),
|
|
376
382
|
nullable,
|
|
377
383
|
required: definition.required === true,
|
|
378
|
-
hasDefault: definition
|
|
379
|
-
defaultValue:
|
|
384
|
+
hasDefault: hasStaticDefault(definition),
|
|
385
|
+
defaultValue: hasStaticDefault(definition)
|
|
386
|
+
? normalizeComparableDefault(definition.defaultTo)
|
|
387
|
+
: undefined,
|
|
380
388
|
unsigned: definition.type === 'id' ? definition.unsigned !== false : definition.unsigned === true,
|
|
381
389
|
maxLength: definition.maxLength ?? null,
|
|
382
390
|
numericPrecision: definition.precision ?? null,
|
|
@@ -499,8 +507,9 @@ function mapTypeToKnex (table, columnName, definition, options = {}) {
|
|
|
499
507
|
? table.time(columnName, { precision: temporalPrecision })
|
|
500
508
|
: table.time(columnName)
|
|
501
509
|
|
|
502
|
-
case '
|
|
503
|
-
|
|
510
|
+
case 'epochMilliseconds':
|
|
511
|
+
case 'epochSeconds':
|
|
512
|
+
return table.bigInteger(columnName)
|
|
504
513
|
|
|
505
514
|
case 'array':
|
|
506
515
|
case 'object':
|
|
@@ -524,7 +533,7 @@ function applyColumnConstraints (column, definition) {
|
|
|
524
533
|
column.notNullable()
|
|
525
534
|
}
|
|
526
535
|
|
|
527
|
-
if (definition
|
|
536
|
+
if (hasStaticDefault(definition)) {
|
|
528
537
|
column.defaultTo(definition.defaultTo)
|
|
529
538
|
}
|
|
530
539
|
|
|
@@ -631,8 +640,9 @@ function buildColumnBuilderCode (columnName, definition, options = {}) {
|
|
|
631
640
|
}
|
|
632
641
|
break
|
|
633
642
|
|
|
634
|
-
case '
|
|
635
|
-
|
|
643
|
+
case 'epochMilliseconds':
|
|
644
|
+
case 'epochSeconds':
|
|
645
|
+
line = `table.bigInteger(${quoteJsString(columnName)})`
|
|
636
646
|
break
|
|
637
647
|
|
|
638
648
|
case 'array':
|
|
@@ -658,7 +668,7 @@ function buildColumnBuilderCode (columnName, definition, options = {}) {
|
|
|
658
668
|
line += '.notNullable()'
|
|
659
669
|
}
|
|
660
670
|
|
|
661
|
-
if (definition
|
|
671
|
+
if (hasStaticDefault(definition)) {
|
|
662
672
|
line += `.defaultTo(${formatCodeLiteral(definition.defaultTo)})`
|
|
663
673
|
}
|
|
664
674
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getFieldValue } from '../storage/storage-mapping.js'
|
|
2
2
|
import { serializeJsonApiQuery } from '../querying-writing/connectors-query-parser.js'
|
|
3
|
+
import { normalizeDateValue } from '../querying-writing/database-value-normalizers.js'
|
|
3
4
|
|
|
4
5
|
const buildJsonApiLink = (baseUrl, queryParams, page) => {
|
|
5
6
|
const queryString = serializeJsonApiQuery(queryParams, { page })
|
|
@@ -157,7 +158,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
157
158
|
|
|
158
159
|
if (pageCount !== undefined) {
|
|
159
160
|
links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
|
|
160
|
-
links.last = buildJsonApiLink(baseUrl, queryParams, { number: pageCount, size: pageSize })
|
|
161
|
+
links.last = buildJsonApiLink(baseUrl, queryParams, { number: Math.max(1, pageCount), size: pageSize })
|
|
161
162
|
|
|
162
163
|
if (page > 1) {
|
|
163
164
|
links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
|
|
@@ -233,15 +234,28 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
233
234
|
* 4. Cursor used in 'next' link for fetching subsequent pages
|
|
234
235
|
* 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
|
|
235
236
|
*/
|
|
236
|
-
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
237
|
+
export const createCursor = (record, sortFields = ['id'], { schemaInfo = null, definitions = {} } = {}) => {
|
|
237
238
|
const parts = []
|
|
238
239
|
sortFields.forEach(field => {
|
|
239
|
-
|
|
240
|
+
let value = schemaInfo
|
|
240
241
|
? getFieldValue(record, schemaInfo, field)
|
|
241
242
|
: record[field]
|
|
243
|
+
const definition = definitions[field] || schemaInfo?.schemaStructure?.[field]
|
|
244
|
+
if (value === null) {
|
|
245
|
+
parts.push(`${field}:~null`)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (['date', 'dateTime', 'time'].includes(definition?.type) && value != null) {
|
|
249
|
+
value = normalizeDateValue(value, definition.type, {
|
|
250
|
+
temporalPrecision: definition.temporalPrecision,
|
|
251
|
+
fieldName: field,
|
|
252
|
+
resourceType: schemaInfo?.scopeName || schemaInfo?.tableName,
|
|
253
|
+
source: 'cursor'
|
|
254
|
+
})
|
|
255
|
+
}
|
|
242
256
|
if (value !== undefined) {
|
|
243
257
|
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
244
|
-
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
258
|
+
parts.push(`${field}:${encodeURIComponent(stringValue).replace(/^~/, '%7E')}`)
|
|
245
259
|
}
|
|
246
260
|
})
|
|
247
261
|
return parts.join(',')
|
|
@@ -301,7 +315,7 @@ export const createCursor = (record, sortFields = ['id'], { schemaInfo = null }
|
|
|
301
315
|
*/
|
|
302
316
|
export const parseCursor = (cursor) => {
|
|
303
317
|
try {
|
|
304
|
-
const data =
|
|
318
|
+
const data = Object.create(null)
|
|
305
319
|
if (!cursor || cursor.trim() === '') {
|
|
306
320
|
throw new Error('Empty cursor')
|
|
307
321
|
}
|
|
@@ -321,7 +335,8 @@ export const parseCursor = (cursor) => {
|
|
|
321
335
|
throw new Error('Invalid cursor format: empty field name')
|
|
322
336
|
}
|
|
323
337
|
|
|
324
|
-
data
|
|
338
|
+
if (Object.hasOwn(data, field)) throw new Error(`Duplicate cursor field '${field}'`)
|
|
339
|
+
data[field] = encodedValue === '~null' ? null : decodeURIComponent(encodedValue)
|
|
325
340
|
}
|
|
326
341
|
|
|
327
342
|
return data
|
|
@@ -413,7 +428,7 @@ export const generateCursorPaginationLinks = (
|
|
|
413
428
|
pageSize,
|
|
414
429
|
hasMore,
|
|
415
430
|
sortFields = ['id'],
|
|
416
|
-
{ schemaInfo = null } = {}
|
|
431
|
+
{ schemaInfo = null, definitions = {} } = {}
|
|
417
432
|
) => {
|
|
418
433
|
// Allow empty urlPrefix to generate relative links
|
|
419
434
|
if (!records.length) return null
|
|
@@ -432,9 +447,10 @@ export const generateCursorPaginationLinks = (
|
|
|
432
447
|
links.first = buildJsonApiLink(baseUrl, queryParams, { size: pageSize })
|
|
433
448
|
|
|
434
449
|
if (hasMore && records.length > 0) {
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
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 })
|
|
438
454
|
}
|
|
439
455
|
|
|
440
456
|
return links
|
|
@@ -497,16 +513,16 @@ export const generateCursorPaginationLinks = (
|
|
|
497
513
|
* 3. Added to response.meta.pagination
|
|
498
514
|
* 4. Clients can use cursor directly or use the next link
|
|
499
515
|
*/
|
|
500
|
-
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null } = {}) => {
|
|
516
|
+
export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'], { schemaInfo = null, definitions = {}, before = false } = {}) => {
|
|
501
517
|
const meta = {
|
|
502
518
|
pageSize,
|
|
503
519
|
hasMore
|
|
504
520
|
}
|
|
505
521
|
|
|
506
522
|
if (hasMore && records.length > 0) {
|
|
507
|
-
const lastRecord = records[records.length - 1]
|
|
523
|
+
const lastRecord = before ? records[0] : records[records.length - 1]
|
|
508
524
|
meta.cursor = {
|
|
509
|
-
next: createCursor(lastRecord, sortFields, { schemaInfo })
|
|
525
|
+
next: createCursor(lastRecord, sortFields, { schemaInfo, definitions })
|
|
510
526
|
}
|
|
511
527
|
}
|
|
512
528
|
|