json-rest-api 1.0.14 → 1.0.16
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/connectors_ideas/fastify.js +66 -68
- package/connectors_ideas/koa-plugin.js +50 -51
- package/eslint.config.js +11 -2
- package/examples/url-override-example.js +63 -63
- package/index.js +13 -13
- package/lib/enhanced-logger.js +51 -51
- package/lib/error-formatter.js +61 -61
- package/lib/rest-api-errors.js +28 -28
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +142 -145
- package/plugins/core/connectors/express-plugin.js +210 -217
- package/plugins/core/connectors/lib/busboy-detector.js +84 -84
- package/plugins/core/connectors/lib/formidable-detector.js +50 -50
- package/plugins/core/connectors/lib/request-helpers.js +74 -74
- package/plugins/core/file-handling-plugin.js +125 -125
- package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
- package/plugins/core/lib/anyapi/schema-utils.js +101 -101
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
- package/plugins/core/lib/dbTablesOperations.js +134 -135
- package/plugins/core/lib/fractional-positioning.js +74 -76
- package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
- package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
- package/plugins/core/lib/querying/knex-process-includes.js +50 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
- package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
- package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
- package/plugins/core/lib/querying/knex-window-queries.js +89 -89
- package/plugins/core/lib/querying/url-helpers.js +35 -35
- package/plugins/core/lib/querying-writing/compile-schemas.js +99 -99
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
- package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
- package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
- package/plugins/core/lib/querying-writing/field-utils.js +44 -42
- package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
- package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
- package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
- package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
- package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
- package/plugins/core/lib/storage/storage-adapter.js +68 -68
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
- package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
- package/plugins/core/lib/writing/relationship-processor.js +58 -58
- package/plugins/core/rest-api-access.js +11 -15
- package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
- package/plugins/core/rest-api-cors-plugin.js +170 -171
- package/plugins/core/rest-api-knex-plugin.js +482 -493
- package/plugins/core/rest-api-multihome-plugin.js +247 -251
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
- package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
- package/plugins/core/rest-api-plugin-methods/common.js +295 -297
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
- package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
- package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
- package/plugins/core/rest-api-plugin-methods/get.js +86 -86
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
- package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
- package/plugins/core/rest-api-plugin-methods/post.js +61 -62
- package/plugins/core/rest-api-plugin-methods/put.js +86 -87
- package/plugins/core/rest-api-plugin-methods/query.js +88 -88
- package/plugins/core/rest-api-plugin-methods/release.js +5 -6
- package/plugins/core/rest-api-plugin.js +76 -78
- package/plugins/core/rest-api-positioning-plugin.js +251 -252
- package/plugins/core/socketio-plugin.js +240 -236
- package/plugins/storage/local-storage.js +103 -103
- package/plugins/storage/s3-storage.js +41 -42
- package/quickTest.js +37 -37
- package/scripts/debug-anyapi-review-filter.mjs +18 -18
- package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
- package/scripts/inspect-anyapi-descriptor.mjs +10 -10
- package/scripts/run-docs.js +112 -113
- package/test-debug.js +28 -28
- package/tests/TEST_TEMPLATE.test.js +51 -51
- package/tests/anyapi-basic.test.js +42 -42
- package/tests/anyapi-cursor-pagination.test.js +48 -48
- package/tests/bulk-operations.test.js +208 -208
- package/tests/computed-fields.test.js +204 -204
- package/tests/cors.test.js +412 -413
- package/tests/curl.test.js +275 -276
- package/tests/custom-idproperty-relationships.test.js +266 -266
- package/tests/field-getters.test.js +132 -132
- package/tests/field-setters.test.js +110 -110
- package/tests/fixtures/api-configs.js +699 -699
- package/tests/helpers/storage-mode.js +32 -32
- package/tests/helpers/test-utils.js +76 -76
- package/tests/include-depth-validation.test.js +76 -76
- package/tests/includes.test.js +145 -145
- package/tests/multihome.test.js +175 -175
- package/tests/nested-includes.test.js +255 -255
- package/tests/pagination-cursor-multifield.test.js +164 -164
- package/tests/pagination-enhanced.test.js +225 -225
- package/tests/polymorphic-relationships.test.js +96 -96
- package/tests/polymorphic-simple.test.js +44 -44
- package/tests/positioning-advanced.test.js +226 -226
- package/tests/positioning-extended.test.js +399 -399
- package/tests/positioning-multifilter.test.js +204 -204
- package/tests/positioning.test.js +266 -266
- package/tests/queries.test.js +184 -184
- package/tests/query-limits.test.js +154 -155
- package/tests/relationship-endpoints.test.js +127 -127
- package/tests/relationships.test.js +178 -179
- package/tests/rest-api-access.test.js +86 -86
- package/tests/rest-api.test.js +103 -103
- package/tests/searchschema-merge.test.js +93 -93
- package/tests/socketio.test.js +326 -326
- package/tests/virtual-fields.test.js +164 -164
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
import { RestApiValidationError, RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
|
|
1
|
+
import { RestApiValidationError, RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Validates that include paths don't exceed maximum depth
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
6
|
* @param {string[]} includes - Array of include paths to validate
|
|
7
7
|
* @param {number} maxDepth - Maximum allowed depth
|
|
8
8
|
* @returns {void}
|
|
9
9
|
* @throws {RestApiValidationError} If depth is exceeded
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
11
|
* @example
|
|
12
12
|
* // Input: Valid include paths
|
|
13
13
|
* validateIncludeDepth(['author', 'comments.author'], 3);
|
|
14
14
|
* // Output: Validation succeeds (no error thrown)
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
16
|
* @example
|
|
17
17
|
* // Input: Path exceeding depth limit
|
|
18
18
|
* validateIncludeDepth(['author.company.employees.manager'], 3);
|
|
19
19
|
* // Throws: RestApiValidationError
|
|
20
20
|
* // "Include path 'author.company.employees.manager' exceeds maximum depth of 3"
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
22
|
* @private
|
|
23
23
|
*/
|
|
24
|
-
function validateIncludeDepth(includes, maxDepth) {
|
|
24
|
+
function validateIncludeDepth (includes, maxDepth) {
|
|
25
25
|
if (!includes || !Array.isArray(includes)) {
|
|
26
|
-
return
|
|
26
|
+
return
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
for (const includePath of includes) {
|
|
30
30
|
if (typeof includePath !== 'string') {
|
|
31
|
-
continue
|
|
31
|
+
continue // Let other validators handle type errors
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
const depth = includePath.split('.').length
|
|
33
|
+
|
|
34
|
+
const depth = includePath.split('.').length
|
|
35
35
|
if (depth > maxDepth) {
|
|
36
36
|
throw new RestApiValidationError(
|
|
37
37
|
`Include path '${includePath}' exceeds maximum depth of ${maxDepth}`,
|
|
38
|
-
{
|
|
39
|
-
fields: ['include'],
|
|
40
|
-
violations: [{
|
|
41
|
-
field: 'include',
|
|
42
|
-
rule: 'max_depth',
|
|
43
|
-
message: `Path '${includePath}' has depth ${depth}, maximum allowed is ${maxDepth}`
|
|
44
|
-
}]
|
|
38
|
+
{
|
|
39
|
+
fields: ['include'],
|
|
40
|
+
violations: [{
|
|
41
|
+
field: 'include',
|
|
42
|
+
rule: 'max_depth',
|
|
43
|
+
message: `Path '${includePath}' has depth ${depth}, maximum allowed is ${maxDepth}`
|
|
44
|
+
}]
|
|
45
45
|
}
|
|
46
|
-
)
|
|
46
|
+
)
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Validates a JSON:API resource identifier object
|
|
53
|
-
*
|
|
54
|
-
* @param {Object} identifier - Resource identifier to validate
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} identifier - Resource identifier to validate
|
|
55
55
|
* @param {string} context - Context for error messages
|
|
56
56
|
* @param {Object} scopes - Scopes proxy to verify resource types
|
|
57
57
|
* @returns {boolean} True if valid
|
|
58
58
|
* @throws {RestApiPayloadError|RestApiValidationError} If validation fails
|
|
59
|
-
*
|
|
59
|
+
*
|
|
60
60
|
* @example
|
|
61
61
|
* // Input: Valid identifier
|
|
62
62
|
* validateResourceIdentifier(
|
|
@@ -65,8 +65,8 @@ function validateIncludeDepth(includes, maxDepth) {
|
|
|
65
65
|
* scopes
|
|
66
66
|
* );
|
|
67
67
|
* // Output: true
|
|
68
|
-
*
|
|
69
|
-
* @example
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
70
|
* // Input: Missing type field
|
|
71
71
|
* validateResourceIdentifier(
|
|
72
72
|
* { id: '123' },
|
|
@@ -75,7 +75,7 @@ function validateIncludeDepth(includes, maxDepth) {
|
|
|
75
75
|
* );
|
|
76
76
|
* // Throws: RestApiPayloadError
|
|
77
77
|
* // "Relationship 'comments': Resource identifier must have a non-empty 'type' string"
|
|
78
|
-
*
|
|
78
|
+
*
|
|
79
79
|
* @example
|
|
80
80
|
* // Input: Unknown resource type
|
|
81
81
|
* validateResourceIdentifier(
|
|
@@ -85,70 +85,70 @@ function validateIncludeDepth(includes, maxDepth) {
|
|
|
85
85
|
* );
|
|
86
86
|
* // Throws: RestApiValidationError
|
|
87
87
|
* // "Included resource: Unknown resource type 'unicorns'. No scope with this name exists."
|
|
88
|
-
*
|
|
88
|
+
*
|
|
89
89
|
* @description
|
|
90
90
|
* Used by:
|
|
91
91
|
* - validateRelationship to check relationship data
|
|
92
92
|
* - validatePostPayload to validate included resources
|
|
93
93
|
* - Internal validation of resource references
|
|
94
|
-
*
|
|
94
|
+
*
|
|
95
95
|
* Purpose:
|
|
96
96
|
* - Ensures JSON:API compliance for resource identifiers
|
|
97
97
|
* - Validates resource types exist in the system
|
|
98
98
|
* - Provides clear error context for debugging
|
|
99
|
-
*
|
|
99
|
+
*
|
|
100
100
|
* @private
|
|
101
101
|
*/
|
|
102
|
-
function validateResourceIdentifier(identifier, context, scopes = null) {
|
|
102
|
+
function validateResourceIdentifier (identifier, context, scopes = null) {
|
|
103
103
|
if (!identifier || typeof identifier !== 'object') {
|
|
104
104
|
throw new RestApiPayloadError(
|
|
105
105
|
`${context}: Resource identifier must be an object`,
|
|
106
106
|
{ path: context, expected: 'object', received: typeof identifier }
|
|
107
|
-
)
|
|
107
|
+
)
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
|
|
110
110
|
if (typeof identifier.type !== 'string' || !identifier.type) {
|
|
111
111
|
throw new RestApiPayloadError(
|
|
112
112
|
`${context}: Resource identifier must have a non-empty 'type' string`,
|
|
113
113
|
{ path: `${context}.type`, expected: 'non-empty string', received: identifier.type }
|
|
114
|
-
)
|
|
114
|
+
)
|
|
115
115
|
}
|
|
116
|
-
|
|
116
|
+
|
|
117
117
|
// Check if type is valid by checking if scope exists
|
|
118
118
|
if (scopes && !scopes[identifier.type]) {
|
|
119
119
|
throw new RestApiValidationError(
|
|
120
120
|
`${context}: Unknown resource type '${identifier.type}'. No scope with this name exists.`,
|
|
121
|
-
{
|
|
121
|
+
{
|
|
122
122
|
fields: [`${context}.type`],
|
|
123
123
|
violations: [{ field: `${context}.type`, rule: 'valid_resource_type', message: `Resource type '${identifier.type}' does not exist` }]
|
|
124
124
|
}
|
|
125
|
-
)
|
|
125
|
+
)
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
|
|
128
128
|
if (!('id' in identifier)) {
|
|
129
129
|
throw new RestApiPayloadError(
|
|
130
130
|
`${context}: Resource identifier must have an 'id' property`,
|
|
131
131
|
{ path: `${context}.id`, expected: 'property exists', received: 'missing' }
|
|
132
|
-
)
|
|
132
|
+
)
|
|
133
133
|
}
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
if (identifier.id !== null && typeof identifier.id !== 'string' && typeof identifier.id !== 'number') {
|
|
136
136
|
throw new RestApiPayloadError(
|
|
137
137
|
`${context}: Resource identifier 'id' must be a string, number, or null`,
|
|
138
138
|
{ path: `${context}.id`, expected: 'string, number, or null', received: typeof identifier.id }
|
|
139
|
-
)
|
|
139
|
+
)
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* Validates a relationship object in JSON:API format
|
|
145
|
-
*
|
|
145
|
+
*
|
|
146
146
|
* @param {Object} relationship - Relationship object to validate
|
|
147
147
|
* @param {string} relationshipName - Name for error context
|
|
148
148
|
* @param {Object} scopes - Scopes proxy to verify resource types
|
|
149
149
|
* @returns {void}
|
|
150
150
|
* @throws {RestApiPayloadError} If validation fails
|
|
151
|
-
*
|
|
151
|
+
*
|
|
152
152
|
* @example
|
|
153
153
|
* // Input: To-one relationship
|
|
154
154
|
* validateRelationship(
|
|
@@ -157,21 +157,21 @@ function validateResourceIdentifier(identifier, context, scopes = null) {
|
|
|
157
157
|
* scopes
|
|
158
158
|
* );
|
|
159
159
|
* // Output: Validation succeeds (no error thrown)
|
|
160
|
-
*
|
|
160
|
+
*
|
|
161
161
|
* @example
|
|
162
|
-
* // Input: To-many relationship
|
|
162
|
+
* // Input: To-many relationship
|
|
163
163
|
* validateRelationship(
|
|
164
|
-
* {
|
|
164
|
+
* {
|
|
165
165
|
* data: [
|
|
166
166
|
* { type: 'tags', id: '1' },
|
|
167
167
|
* { type: 'tags', id: '2' }
|
|
168
|
-
* ]
|
|
168
|
+
* ]
|
|
169
169
|
* },
|
|
170
170
|
* 'tags',
|
|
171
171
|
* scopes
|
|
172
172
|
* );
|
|
173
173
|
* // Output: Validation succeeds (no error thrown)
|
|
174
|
-
*
|
|
174
|
+
*
|
|
175
175
|
* @example
|
|
176
176
|
* // Input: Null relationship (remove association)
|
|
177
177
|
* validateRelationship(
|
|
@@ -180,7 +180,7 @@ function validateResourceIdentifier(identifier, context, scopes = null) {
|
|
|
180
180
|
* scopes
|
|
181
181
|
* );
|
|
182
182
|
* // Output: Validation succeeds (null is valid for clearing)
|
|
183
|
-
*
|
|
183
|
+
*
|
|
184
184
|
* @example
|
|
185
185
|
* // Input: Missing data property
|
|
186
186
|
* validateRelationship(
|
|
@@ -190,62 +190,62 @@ function validateResourceIdentifier(identifier, context, scopes = null) {
|
|
|
190
190
|
* );
|
|
191
191
|
* // Throws: RestApiPayloadError
|
|
192
192
|
* // "Relationship 'author' must have a 'data' property"
|
|
193
|
-
*
|
|
193
|
+
*
|
|
194
194
|
* @description
|
|
195
195
|
* Used by:
|
|
196
196
|
* - validatePostPayload for new resource relationships
|
|
197
197
|
* - validatePutPayload for relationship replacement
|
|
198
198
|
* - validatePatchPayload for relationship updates
|
|
199
|
-
*
|
|
199
|
+
*
|
|
200
200
|
* Purpose:
|
|
201
201
|
* - Ensures JSON:API relationship structure
|
|
202
202
|
* - Validates both to-one and to-many relationships
|
|
203
203
|
* - Handles null/empty cases for clearing relationships
|
|
204
204
|
* - Validates each resource identifier in arrays
|
|
205
|
-
*
|
|
205
|
+
*
|
|
206
206
|
* @private
|
|
207
207
|
*/
|
|
208
|
-
function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
208
|
+
function validateRelationship (relationship, relationshipName, scopes = null) {
|
|
209
209
|
if (!relationship || typeof relationship !== 'object') {
|
|
210
210
|
throw new RestApiPayloadError(
|
|
211
211
|
`Relationship '${relationshipName}' must be an object`,
|
|
212
212
|
{ path: `relationships.${relationshipName}`, expected: 'object', received: typeof relationship }
|
|
213
|
-
)
|
|
213
|
+
)
|
|
214
214
|
}
|
|
215
|
-
|
|
215
|
+
|
|
216
216
|
if (!('data' in relationship)) {
|
|
217
217
|
throw new RestApiPayloadError(
|
|
218
218
|
`Relationship '${relationshipName}' must have a 'data' property`,
|
|
219
219
|
{ path: `relationships.${relationshipName}.data`, expected: 'property exists', received: 'missing' }
|
|
220
|
-
)
|
|
220
|
+
)
|
|
221
221
|
}
|
|
222
|
-
|
|
223
|
-
const { data } = relationship
|
|
224
|
-
|
|
222
|
+
|
|
223
|
+
const { data } = relationship
|
|
224
|
+
|
|
225
225
|
// data can be null (empty to-one relationship)
|
|
226
226
|
if (data === null) {
|
|
227
|
-
return
|
|
227
|
+
return
|
|
228
228
|
}
|
|
229
|
-
|
|
229
|
+
|
|
230
230
|
// data can be a single resource identifier (to-one)
|
|
231
231
|
if (!Array.isArray(data)) {
|
|
232
|
-
validateResourceIdentifier(data, `Relationship '${relationshipName}'`, scopes)
|
|
233
|
-
return
|
|
232
|
+
validateResourceIdentifier(data, `Relationship '${relationshipName}'`, scopes)
|
|
233
|
+
return
|
|
234
234
|
}
|
|
235
|
-
|
|
235
|
+
|
|
236
236
|
// data can be an array of resource identifiers (to-many)
|
|
237
237
|
if (data.length === 0) {
|
|
238
|
-
return
|
|
238
|
+
return // Empty to-many relationship is valid
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
|
|
241
241
|
data.forEach((identifier, index) => {
|
|
242
|
-
validateResourceIdentifier(identifier, `Relationship '${relationshipName}[${index}]'`, scopes)
|
|
243
|
-
})
|
|
242
|
+
validateResourceIdentifier(identifier, `Relationship '${relationshipName}[${index}]'`, scopes)
|
|
243
|
+
})
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
247
|
* Validates query parameters for GET requests (single resource retrieval)
|
|
248
|
-
*
|
|
248
|
+
*
|
|
249
249
|
* @param {Object} params - Parameters containing id and queryParams
|
|
250
250
|
* @param {string|number} params.id - Resource ID to fetch
|
|
251
251
|
* @param {Object} [params.queryParams] - Optional query parameters
|
|
@@ -254,14 +254,14 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
254
254
|
* @param {number} maxIncludeDepth - Maximum allowed include depth
|
|
255
255
|
* @returns {boolean} True if valid
|
|
256
256
|
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
257
|
-
*
|
|
257
|
+
*
|
|
258
258
|
* @example
|
|
259
259
|
* // Input: Basic GET request
|
|
260
260
|
* validateGetPayload({
|
|
261
261
|
* id: '123'
|
|
262
262
|
* });
|
|
263
263
|
* // Output: true
|
|
264
|
-
*
|
|
264
|
+
*
|
|
265
265
|
* @example
|
|
266
266
|
* // Input: GET with nested includes
|
|
267
267
|
* validateGetPayload({
|
|
@@ -271,7 +271,7 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
271
271
|
* }
|
|
272
272
|
* });
|
|
273
273
|
* // Output: true (include depths are 1 and 2)
|
|
274
|
-
*
|
|
274
|
+
*
|
|
275
275
|
* @example
|
|
276
276
|
* // Input: GET with sparse fieldsets
|
|
277
277
|
* validateGetPayload({
|
|
@@ -285,7 +285,7 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
285
285
|
* }
|
|
286
286
|
* });
|
|
287
287
|
* // Output: true
|
|
288
|
-
*
|
|
288
|
+
*
|
|
289
289
|
* @example
|
|
290
290
|
* // Input: Missing ID parameter
|
|
291
291
|
* validateGetPayload({
|
|
@@ -293,7 +293,7 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
293
293
|
* });
|
|
294
294
|
* // Throws: RestApiValidationError
|
|
295
295
|
* // "GET request must include an id parameter"
|
|
296
|
-
*
|
|
296
|
+
*
|
|
297
297
|
* @example
|
|
298
298
|
* // Input: Invalid field specification
|
|
299
299
|
* validateGetPayload({
|
|
@@ -304,21 +304,21 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
304
304
|
* }
|
|
305
305
|
* }
|
|
306
306
|
* });
|
|
307
|
-
* // Throws: RestApiPayloadError
|
|
307
|
+
* // Throws: RestApiPayloadError
|
|
308
308
|
* // "queryParams.fields['articles'] must be a comma-separated string"
|
|
309
|
-
*
|
|
309
|
+
*
|
|
310
310
|
* @description
|
|
311
311
|
* Used by:
|
|
312
312
|
* - rest-api-plugin.get() method before data fetching
|
|
313
313
|
* - Validates parameters before passing to storage layer
|
|
314
|
-
*
|
|
314
|
+
*
|
|
315
315
|
* Purpose:
|
|
316
316
|
* - Ensures required ID parameter is present and valid
|
|
317
317
|
* - Validates JSON:API query parameter structure
|
|
318
318
|
* - Prevents invalid queries from reaching database
|
|
319
319
|
* - Enforces include depth limits for performance
|
|
320
320
|
* - Provides consistent error messages across storage backends
|
|
321
|
-
*
|
|
321
|
+
*
|
|
322
322
|
* Data flow:
|
|
323
323
|
* 1. Checks params object structure
|
|
324
324
|
* 2. Validates required ID parameter
|
|
@@ -326,88 +326,88 @@ function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
|
326
326
|
* 4. Validates include paths don't exceed depth limit
|
|
327
327
|
* 5. Returns true or throws descriptive error
|
|
328
328
|
*/
|
|
329
|
-
export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
329
|
+
export function validateGetPayload (params, maxIncludeDepth = 3) {
|
|
330
330
|
if (!params || typeof params !== 'object') {
|
|
331
331
|
throw new RestApiPayloadError(
|
|
332
332
|
'GET parameters must be an object',
|
|
333
333
|
{ path: 'params', expected: 'object', received: typeof params }
|
|
334
|
-
)
|
|
334
|
+
)
|
|
335
335
|
}
|
|
336
|
-
|
|
336
|
+
|
|
337
337
|
// Validate ID
|
|
338
338
|
if (!('id' in params)) {
|
|
339
339
|
throw new RestApiValidationError(
|
|
340
340
|
'GET request must include an id parameter',
|
|
341
341
|
{ fields: ['id'], violations: [{ field: 'id', rule: 'required', message: 'ID parameter is required' }] }
|
|
342
|
-
)
|
|
342
|
+
)
|
|
343
343
|
}
|
|
344
|
-
|
|
344
|
+
|
|
345
345
|
if (params.id === null || params.id === undefined || params.id === '') {
|
|
346
346
|
throw new RestApiValidationError(
|
|
347
347
|
'GET request id cannot be null, undefined, or empty',
|
|
348
348
|
{ fields: ['id'], violations: [{ field: 'id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
349
|
-
)
|
|
349
|
+
)
|
|
350
350
|
}
|
|
351
|
-
|
|
351
|
+
|
|
352
352
|
// Validate queryParams if present
|
|
353
353
|
if (params.queryParams) {
|
|
354
354
|
if (typeof params.queryParams !== 'object') {
|
|
355
355
|
throw new RestApiPayloadError(
|
|
356
356
|
'queryParams must be an object',
|
|
357
357
|
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
358
|
-
)
|
|
358
|
+
)
|
|
359
359
|
}
|
|
360
|
-
|
|
361
|
-
const { include, fields } = params.queryParams
|
|
362
|
-
|
|
360
|
+
|
|
361
|
+
const { include, fields } = params.queryParams
|
|
362
|
+
|
|
363
363
|
// Validate include
|
|
364
364
|
if (include !== undefined) {
|
|
365
365
|
if (!Array.isArray(include)) {
|
|
366
366
|
throw new RestApiPayloadError(
|
|
367
367
|
'queryParams.include must be an array of strings',
|
|
368
368
|
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
369
|
-
)
|
|
369
|
+
)
|
|
370
370
|
}
|
|
371
|
-
|
|
371
|
+
|
|
372
372
|
include.forEach((path, index) => {
|
|
373
373
|
if (typeof path !== 'string') {
|
|
374
374
|
throw new RestApiPayloadError(
|
|
375
375
|
`queryParams.include[${index}] must be a string`,
|
|
376
376
|
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
377
|
-
)
|
|
377
|
+
)
|
|
378
378
|
}
|
|
379
|
-
})
|
|
380
|
-
|
|
379
|
+
})
|
|
380
|
+
|
|
381
381
|
// Validate include depth
|
|
382
|
-
validateIncludeDepth(include, maxIncludeDepth)
|
|
382
|
+
validateIncludeDepth(include, maxIncludeDepth)
|
|
383
383
|
}
|
|
384
|
-
|
|
384
|
+
|
|
385
385
|
// Validate fields
|
|
386
386
|
if (fields !== undefined) {
|
|
387
387
|
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
388
388
|
throw new RestApiPayloadError(
|
|
389
389
|
'queryParams.fields must be an object',
|
|
390
390
|
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
391
|
-
)
|
|
391
|
+
)
|
|
392
392
|
}
|
|
393
|
-
|
|
393
|
+
|
|
394
394
|
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
395
395
|
if (typeof fieldList !== 'string') {
|
|
396
396
|
throw new RestApiPayloadError(
|
|
397
397
|
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
398
398
|
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
399
|
-
)
|
|
399
|
+
)
|
|
400
400
|
}
|
|
401
|
-
})
|
|
401
|
+
})
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
|
-
|
|
405
|
-
return true
|
|
404
|
+
|
|
405
|
+
return true
|
|
406
406
|
}
|
|
407
407
|
|
|
408
408
|
/**
|
|
409
409
|
* Validates query parameters for collection requests (query/list operations)
|
|
410
|
-
*
|
|
410
|
+
*
|
|
411
411
|
* @param {Object} params - Parameters object
|
|
412
412
|
* @param {Object} [params.queryParams] - Query parameters for the collection
|
|
413
413
|
* @param {string[]} [params.queryParams.include] - Related resources to include
|
|
@@ -419,12 +419,12 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
419
419
|
* @param {number} maxIncludeDepth - Maximum allowed include depth
|
|
420
420
|
* @returns {boolean} True if valid
|
|
421
421
|
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
422
|
-
*
|
|
422
|
+
*
|
|
423
423
|
* @example
|
|
424
424
|
* // Input: Empty query (fetch all)
|
|
425
425
|
* validateQueryPayload({}, ['title', 'createdAt']);
|
|
426
426
|
* // Output: true
|
|
427
|
-
*
|
|
427
|
+
*
|
|
428
428
|
* @example
|
|
429
429
|
* // Input: Query with filters and sorting
|
|
430
430
|
* validateQueryPayload({
|
|
@@ -437,7 +437,7 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
437
437
|
* }
|
|
438
438
|
* }, ['publishedAt', 'title', 'createdAt']);
|
|
439
439
|
* // Output: true
|
|
440
|
-
*
|
|
440
|
+
*
|
|
441
441
|
* @example
|
|
442
442
|
* // Input: Different pagination styles
|
|
443
443
|
* // Page-based:
|
|
@@ -447,7 +447,7 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
447
447
|
* }
|
|
448
448
|
* }, []);
|
|
449
449
|
* // Output: true
|
|
450
|
-
*
|
|
450
|
+
*
|
|
451
451
|
* // Offset-based:
|
|
452
452
|
* validateQueryPayload({
|
|
453
453
|
* queryParams: {
|
|
@@ -455,7 +455,7 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
455
455
|
* }
|
|
456
456
|
* }, []);
|
|
457
457
|
* // Output: true
|
|
458
|
-
*
|
|
458
|
+
*
|
|
459
459
|
* @example
|
|
460
460
|
* // Input: Complex query with all features
|
|
461
461
|
* validateQueryPayload({
|
|
@@ -475,7 +475,7 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
475
475
|
* }
|
|
476
476
|
* }, ['publishedAt', 'title', 'updatedAt']);
|
|
477
477
|
* // Output: true (all parameters valid)
|
|
478
|
-
*
|
|
478
|
+
*
|
|
479
479
|
* @example
|
|
480
480
|
* // Input: Invalid sort field
|
|
481
481
|
* validateQueryPayload({
|
|
@@ -485,19 +485,19 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
485
485
|
* }, ['title', 'createdAt']);
|
|
486
486
|
* // Throws: RestApiValidationError
|
|
487
487
|
* // "Field 'password' is not sortable. Sortable fields are: title, createdAt"
|
|
488
|
-
*
|
|
488
|
+
*
|
|
489
489
|
* @description
|
|
490
490
|
* Used by:
|
|
491
491
|
* - rest-api-plugin.query() method before data fetching
|
|
492
492
|
* - Validates collection request parameters
|
|
493
|
-
*
|
|
493
|
+
*
|
|
494
494
|
* Purpose:
|
|
495
495
|
* - Validates all JSON:API query parameters for collections
|
|
496
496
|
* - Prevents SQL injection via filter key validation
|
|
497
497
|
* - Enforces sortable field restrictions for performance
|
|
498
498
|
* - Ensures consistent pagination across storage backends
|
|
499
499
|
* - Validates include depth limits
|
|
500
|
-
*
|
|
500
|
+
*
|
|
501
501
|
* Data flow:
|
|
502
502
|
* 1. Validates params object structure
|
|
503
503
|
* 2. For each query parameter type, validates format
|
|
@@ -505,150 +505,150 @@ export function validateGetPayload(params, maxIncludeDepth = 3) {
|
|
|
505
505
|
* 4. Validates pagination parameters are numbers/strings
|
|
506
506
|
* 5. Returns true or throws descriptive error
|
|
507
507
|
*/
|
|
508
|
-
export function validateQueryPayload(params, sortableFields = [], maxIncludeDepth = 3) {
|
|
508
|
+
export function validateQueryPayload (params, sortableFields = [], maxIncludeDepth = 3) {
|
|
509
509
|
if (!params || typeof params !== 'object') {
|
|
510
510
|
throw new RestApiPayloadError(
|
|
511
511
|
'Query parameters must be an object',
|
|
512
512
|
{ path: 'params', expected: 'object', received: typeof params }
|
|
513
|
-
)
|
|
513
|
+
)
|
|
514
514
|
}
|
|
515
|
-
|
|
515
|
+
|
|
516
516
|
// queryParams is optional but if present must be an object
|
|
517
517
|
if (params.queryParams) {
|
|
518
518
|
if (typeof params.queryParams !== 'object') {
|
|
519
519
|
throw new RestApiPayloadError(
|
|
520
520
|
'queryParams must be an object',
|
|
521
521
|
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
522
|
-
)
|
|
522
|
+
)
|
|
523
523
|
}
|
|
524
|
-
|
|
525
|
-
const { include, fields, filters, sort, page } = params.queryParams
|
|
526
|
-
|
|
524
|
+
|
|
525
|
+
const { include, fields, filters, sort, page } = params.queryParams
|
|
526
|
+
|
|
527
527
|
// Validate include
|
|
528
528
|
if (include !== undefined) {
|
|
529
529
|
if (!Array.isArray(include)) {
|
|
530
530
|
throw new RestApiPayloadError(
|
|
531
531
|
'queryParams.include must be an array of strings',
|
|
532
532
|
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
533
|
-
)
|
|
533
|
+
)
|
|
534
534
|
}
|
|
535
|
-
|
|
535
|
+
|
|
536
536
|
include.forEach((path, index) => {
|
|
537
537
|
if (typeof path !== 'string') {
|
|
538
538
|
throw new RestApiPayloadError(
|
|
539
539
|
`queryParams.include[${index}] must be a string`,
|
|
540
540
|
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
541
|
-
)
|
|
541
|
+
)
|
|
542
542
|
}
|
|
543
|
-
})
|
|
544
|
-
|
|
543
|
+
})
|
|
544
|
+
|
|
545
545
|
// Validate include depth
|
|
546
|
-
validateIncludeDepth(include, maxIncludeDepth)
|
|
546
|
+
validateIncludeDepth(include, maxIncludeDepth)
|
|
547
547
|
}
|
|
548
|
-
|
|
548
|
+
|
|
549
549
|
// Validate fields
|
|
550
550
|
if (fields !== undefined) {
|
|
551
551
|
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
552
552
|
throw new RestApiPayloadError(
|
|
553
553
|
'queryParams.fields must be an object',
|
|
554
554
|
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
555
|
-
)
|
|
555
|
+
)
|
|
556
556
|
}
|
|
557
|
-
|
|
557
|
+
|
|
558
558
|
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
559
559
|
if (typeof fieldList !== 'string') {
|
|
560
560
|
throw new RestApiPayloadError(
|
|
561
561
|
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
562
562
|
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
563
|
-
)
|
|
563
|
+
)
|
|
564
564
|
}
|
|
565
|
-
})
|
|
565
|
+
})
|
|
566
566
|
}
|
|
567
|
-
|
|
567
|
+
|
|
568
568
|
// Validate filters
|
|
569
569
|
if (filters !== undefined) {
|
|
570
570
|
if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) {
|
|
571
571
|
throw new RestApiPayloadError(
|
|
572
572
|
'queryParams.filters must be an object',
|
|
573
573
|
{ path: 'queryParams.filters', expected: 'object', received: Array.isArray(filters) ? 'array' : typeof filters }
|
|
574
|
-
)
|
|
574
|
+
)
|
|
575
575
|
}
|
|
576
576
|
}
|
|
577
|
-
|
|
577
|
+
|
|
578
578
|
// Validate sort
|
|
579
579
|
if (sort !== undefined) {
|
|
580
580
|
if (!Array.isArray(sort)) {
|
|
581
581
|
throw new RestApiPayloadError(
|
|
582
582
|
'queryParams.sort must be an array of strings',
|
|
583
583
|
{ path: 'queryParams.sort', expected: 'array', received: typeof sort }
|
|
584
|
-
)
|
|
584
|
+
)
|
|
585
585
|
}
|
|
586
|
-
|
|
586
|
+
|
|
587
587
|
sort.forEach((field, index) => {
|
|
588
588
|
if (typeof field !== 'string') {
|
|
589
589
|
throw new RestApiPayloadError(
|
|
590
590
|
`queryParams.sort[${index}] must be a string`,
|
|
591
591
|
{ path: `queryParams.sort[${index}]`, expected: 'string', received: typeof field }
|
|
592
|
-
)
|
|
592
|
+
)
|
|
593
593
|
}
|
|
594
|
-
|
|
594
|
+
|
|
595
595
|
// Check if field is sortable (remove leading - for descending sort)
|
|
596
|
-
const fieldName = field.startsWith('-') ? field.substring(1) : field
|
|
596
|
+
const fieldName = field.startsWith('-') ? field.substring(1) : field
|
|
597
597
|
if (sortableFields.length > 0 && !sortableFields.includes(fieldName)) {
|
|
598
598
|
throw new RestApiValidationError(
|
|
599
599
|
`Field '${fieldName}' is not sortable. Sortable fields are: ${sortableFields.join(', ')}`,
|
|
600
600
|
{ fields: ['sort'], violations: [{ field: 'sort', rule: 'sortable_field', message: `Field '${fieldName}' is not in the list of sortable fields` }] }
|
|
601
|
-
)
|
|
601
|
+
)
|
|
602
602
|
}
|
|
603
|
-
})
|
|
603
|
+
})
|
|
604
604
|
}
|
|
605
|
-
|
|
605
|
+
|
|
606
606
|
// Validate page
|
|
607
607
|
if (page !== undefined) {
|
|
608
608
|
if (typeof page !== 'object' || page === null || Array.isArray(page)) {
|
|
609
609
|
throw new RestApiPayloadError(
|
|
610
610
|
'queryParams.page must be an object',
|
|
611
611
|
{ path: 'queryParams.page', expected: 'object', received: Array.isArray(page) ? 'array' : typeof page }
|
|
612
|
-
)
|
|
612
|
+
)
|
|
613
613
|
}
|
|
614
|
-
|
|
614
|
+
|
|
615
615
|
// Common pagination parameters
|
|
616
616
|
if ('number' in page && typeof page.number !== 'number' && typeof page.number !== 'string') {
|
|
617
617
|
throw new RestApiPayloadError(
|
|
618
618
|
'queryParams.page.number must be a number or string',
|
|
619
619
|
{ path: 'queryParams.page.number', expected: 'number or string', received: typeof page.number }
|
|
620
|
-
)
|
|
620
|
+
)
|
|
621
621
|
}
|
|
622
|
-
|
|
622
|
+
|
|
623
623
|
if ('size' in page && typeof page.size !== 'number' && typeof page.size !== 'string') {
|
|
624
624
|
throw new RestApiPayloadError(
|
|
625
625
|
'queryParams.page.size must be a number or string',
|
|
626
626
|
{ path: 'queryParams.page.size', expected: 'number or string', received: typeof page.size }
|
|
627
|
-
)
|
|
627
|
+
)
|
|
628
628
|
}
|
|
629
|
-
|
|
629
|
+
|
|
630
630
|
if ('limit' in page && typeof page.limit !== 'number' && typeof page.limit !== 'string') {
|
|
631
631
|
throw new RestApiPayloadError(
|
|
632
632
|
'queryParams.page.limit must be a number or string',
|
|
633
633
|
{ path: 'queryParams.page.limit', expected: 'number or string', received: typeof page.limit }
|
|
634
|
-
)
|
|
634
|
+
)
|
|
635
635
|
}
|
|
636
|
-
|
|
636
|
+
|
|
637
637
|
if ('offset' in page && typeof page.offset !== 'number' && typeof page.offset !== 'string') {
|
|
638
638
|
throw new RestApiPayloadError(
|
|
639
639
|
'queryParams.page.offset must be a number or string',
|
|
640
640
|
{ path: 'queryParams.page.offset', expected: 'number or string', received: typeof page.offset }
|
|
641
|
-
)
|
|
641
|
+
)
|
|
642
642
|
}
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
|
-
|
|
646
|
-
return true
|
|
645
|
+
|
|
646
|
+
return true
|
|
647
647
|
}
|
|
648
648
|
|
|
649
649
|
/**
|
|
650
650
|
* Validates a JSON:API document for POST requests (resource creation)
|
|
651
|
-
*
|
|
651
|
+
*
|
|
652
652
|
* @param {Object} inputRecord - JSON:API document to validate
|
|
653
653
|
* @param {Object} inputRecord.data - Primary resource to create
|
|
654
654
|
* @param {string} inputRecord.data.type - Resource type (must match a scope)
|
|
@@ -659,7 +659,7 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
659
659
|
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
660
660
|
* @returns {boolean} True if valid
|
|
661
661
|
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
662
|
-
*
|
|
662
|
+
*
|
|
663
663
|
* @example
|
|
664
664
|
* // Input: Simple resource creation
|
|
665
665
|
* validatePostPayload({
|
|
@@ -672,7 +672,7 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
672
672
|
* }
|
|
673
673
|
* }, scopes);
|
|
674
674
|
* // Output: true
|
|
675
|
-
*
|
|
675
|
+
*
|
|
676
676
|
* @example
|
|
677
677
|
* // Input: Create with relationships
|
|
678
678
|
* validatePostPayload({
|
|
@@ -695,7 +695,7 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
695
695
|
* }
|
|
696
696
|
* }, scopes);
|
|
697
697
|
* // Output: true (validates each relationship)
|
|
698
|
-
*
|
|
698
|
+
*
|
|
699
699
|
* @example
|
|
700
700
|
* // Input: Client-generated ID
|
|
701
701
|
* validatePostPayload({
|
|
@@ -708,7 +708,7 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
708
708
|
* }
|
|
709
709
|
* }, scopes);
|
|
710
710
|
* // Output: true (ID is optional for POST)
|
|
711
|
-
*
|
|
711
|
+
*
|
|
712
712
|
* @example
|
|
713
713
|
* // Input: Compound document with included
|
|
714
714
|
* validatePostPayload({
|
|
@@ -733,7 +733,7 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
733
733
|
* ]
|
|
734
734
|
* }, scopes);
|
|
735
735
|
* // Output: true (included resources validated)
|
|
736
|
-
*
|
|
736
|
+
*
|
|
737
737
|
* @example
|
|
738
738
|
* // Input: Missing required type
|
|
739
739
|
* validatePostPayload({
|
|
@@ -743,19 +743,19 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
743
743
|
* }, scopes);
|
|
744
744
|
* // Throws: RestApiPayloadError
|
|
745
745
|
* // 'POST request "data" must have a non-empty "type" string'
|
|
746
|
-
*
|
|
746
|
+
*
|
|
747
747
|
* @description
|
|
748
748
|
* Used by:
|
|
749
749
|
* - rest-api-plugin.post() method before creating resources
|
|
750
750
|
* - Validates complete document structure
|
|
751
|
-
*
|
|
751
|
+
*
|
|
752
752
|
* Purpose:
|
|
753
753
|
* - Ensures JSON:API compliance for resource creation
|
|
754
754
|
* - Validates resource types exist in system
|
|
755
755
|
* - Checks relationship references are valid
|
|
756
756
|
* - Validates included resources have required fields
|
|
757
757
|
* - Enables compound document creation patterns
|
|
758
|
-
*
|
|
758
|
+
*
|
|
759
759
|
* Data flow:
|
|
760
760
|
* 1. Validates document has required 'data' property
|
|
761
761
|
* 2. Checks primary resource type exists in scopes
|
|
@@ -764,142 +764,142 @@ export function validateQueryPayload(params, sortableFields = [], maxIncludeDept
|
|
|
764
764
|
* 5. Validates included resources array if present
|
|
765
765
|
* 6. Returns true or throws descriptive error
|
|
766
766
|
*/
|
|
767
|
-
export function validatePostPayload(inputRecord, scopes = null) {
|
|
767
|
+
export function validatePostPayload (inputRecord, scopes = null) {
|
|
768
768
|
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
769
769
|
throw new RestApiPayloadError(
|
|
770
770
|
'POST request body must be a JSON:API document object',
|
|
771
771
|
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
772
|
-
)
|
|
772
|
+
)
|
|
773
773
|
}
|
|
774
|
-
|
|
774
|
+
|
|
775
775
|
// Validate required 'data' property
|
|
776
776
|
if (!('data' in inputRecord)) {
|
|
777
777
|
throw new RestApiPayloadError(
|
|
778
778
|
'POST request body must have a "data" property',
|
|
779
779
|
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
780
|
-
)
|
|
780
|
+
)
|
|
781
781
|
}
|
|
782
|
-
|
|
783
|
-
const { data, included } = inputRecord
|
|
784
|
-
|
|
782
|
+
|
|
783
|
+
const { data, included } = inputRecord
|
|
784
|
+
|
|
785
785
|
// Validate primary data
|
|
786
786
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
787
787
|
throw new RestApiPayloadError(
|
|
788
788
|
'POST request "data" must be a single resource object',
|
|
789
789
|
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
790
|
-
)
|
|
790
|
+
)
|
|
791
791
|
}
|
|
792
|
-
|
|
792
|
+
|
|
793
793
|
if (typeof data.type !== 'string' || !data.type) {
|
|
794
794
|
throw new RestApiPayloadError(
|
|
795
795
|
'POST request "data" must have a non-empty "type" string',
|
|
796
796
|
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
797
|
-
)
|
|
797
|
+
)
|
|
798
798
|
}
|
|
799
|
-
|
|
799
|
+
|
|
800
800
|
// Check if primary resource type is valid
|
|
801
801
|
if (scopes && !scopes[data.type]) {
|
|
802
802
|
throw new RestApiValidationError(
|
|
803
803
|
`POST request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
804
804
|
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
805
|
-
)
|
|
805
|
+
)
|
|
806
806
|
}
|
|
807
|
-
|
|
807
|
+
|
|
808
808
|
// For POST, id is optional (server may generate it)
|
|
809
809
|
if ('id' in data && data.id !== null && typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
810
810
|
throw new RestApiPayloadError(
|
|
811
811
|
'POST request "data.id" if present must be a string, number, or null',
|
|
812
812
|
{ path: 'data.id', expected: 'string, number, or null', received: typeof data.id }
|
|
813
|
-
)
|
|
813
|
+
)
|
|
814
814
|
}
|
|
815
|
-
|
|
815
|
+
|
|
816
816
|
// Validate attributes if present
|
|
817
817
|
if ('attributes' in data) {
|
|
818
818
|
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
819
819
|
throw new RestApiPayloadError(
|
|
820
820
|
'POST request "data.attributes" must be an object',
|
|
821
821
|
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
822
|
-
)
|
|
822
|
+
)
|
|
823
823
|
}
|
|
824
824
|
}
|
|
825
|
-
|
|
825
|
+
|
|
826
826
|
// Validate relationships if present
|
|
827
827
|
if ('relationships' in data) {
|
|
828
828
|
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
829
829
|
throw new RestApiPayloadError(
|
|
830
830
|
'POST request "data.relationships" must be an object',
|
|
831
831
|
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
832
|
-
)
|
|
832
|
+
)
|
|
833
833
|
}
|
|
834
|
-
|
|
834
|
+
|
|
835
835
|
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
836
|
-
validateRelationship(relationship, relName, scopes)
|
|
837
|
-
})
|
|
836
|
+
validateRelationship(relationship, relName, scopes)
|
|
837
|
+
})
|
|
838
838
|
}
|
|
839
|
-
|
|
839
|
+
|
|
840
840
|
// Validate included resources if present
|
|
841
841
|
if (included !== undefined) {
|
|
842
842
|
if (!Array.isArray(included)) {
|
|
843
843
|
throw new RestApiPayloadError(
|
|
844
844
|
'POST request "included" must be an array',
|
|
845
845
|
{ path: 'included', expected: 'array', received: typeof included }
|
|
846
|
-
)
|
|
846
|
+
)
|
|
847
847
|
}
|
|
848
|
-
|
|
848
|
+
|
|
849
849
|
included.forEach((resource, index) => {
|
|
850
850
|
if (!resource || typeof resource !== 'object') {
|
|
851
851
|
throw new RestApiPayloadError(
|
|
852
852
|
`POST request "included[${index}]" must be a resource object`,
|
|
853
853
|
{ path: `included[${index}]`, expected: 'object', received: typeof resource }
|
|
854
|
-
)
|
|
854
|
+
)
|
|
855
855
|
}
|
|
856
|
-
|
|
856
|
+
|
|
857
857
|
if (typeof resource.type !== 'string' || !resource.type) {
|
|
858
858
|
throw new RestApiPayloadError(
|
|
859
859
|
`POST request "included[${index}]" must have a non-empty "type" string`,
|
|
860
860
|
{ path: `included[${index}].type`, expected: 'non-empty string', received: resource.type || 'empty' }
|
|
861
|
-
)
|
|
861
|
+
)
|
|
862
862
|
}
|
|
863
|
-
|
|
863
|
+
|
|
864
864
|
// Check if included resource type is valid
|
|
865
865
|
if (scopes && !scopes[resource.type]) {
|
|
866
866
|
throw new RestApiValidationError(
|
|
867
867
|
`POST request "included[${index}].type" '${resource.type}' is not a valid resource type. No scope with this name exists.`,
|
|
868
868
|
{ fields: [`included[${index}].type`], violations: [{ field: `included[${index}].type`, rule: 'valid_resource_type', message: `Resource type '${resource.type}' does not exist` }] }
|
|
869
|
-
)
|
|
869
|
+
)
|
|
870
870
|
}
|
|
871
|
-
|
|
871
|
+
|
|
872
872
|
if (!('id' in resource) || resource.id === null || resource.id === undefined) {
|
|
873
873
|
throw new RestApiPayloadError(
|
|
874
874
|
`POST request "included[${index}]" must have a non-null "id"`,
|
|
875
875
|
{ path: `included[${index}].id`, expected: 'non-null value', received: 'null or undefined' }
|
|
876
|
-
)
|
|
876
|
+
)
|
|
877
877
|
}
|
|
878
|
-
|
|
878
|
+
|
|
879
879
|
if (typeof resource.id !== 'string' && typeof resource.id !== 'number') {
|
|
880
880
|
throw new RestApiPayloadError(
|
|
881
881
|
`POST request "included[${index}].id" must be a string or number`,
|
|
882
882
|
{ path: `included[${index}].id`, expected: 'string or number', received: typeof resource.id }
|
|
883
|
-
)
|
|
883
|
+
)
|
|
884
884
|
}
|
|
885
|
-
|
|
885
|
+
|
|
886
886
|
if ('attributes' in resource) {
|
|
887
887
|
if (typeof resource.attributes !== 'object' || resource.attributes === null || Array.isArray(resource.attributes)) {
|
|
888
888
|
throw new RestApiPayloadError(
|
|
889
889
|
`POST request "included[${index}].attributes" must be an object`,
|
|
890
890
|
{ path: `included[${index}].attributes`, expected: 'object', received: Array.isArray(resource.attributes) ? 'array' : typeof resource.attributes }
|
|
891
|
-
)
|
|
891
|
+
)
|
|
892
892
|
}
|
|
893
893
|
}
|
|
894
|
-
})
|
|
894
|
+
})
|
|
895
895
|
}
|
|
896
|
-
|
|
897
|
-
return true
|
|
896
|
+
|
|
897
|
+
return true
|
|
898
898
|
}
|
|
899
899
|
|
|
900
900
|
/**
|
|
901
901
|
* Validates a JSON:API document for PUT requests (full resource replacement)
|
|
902
|
-
*
|
|
902
|
+
*
|
|
903
903
|
* @param {Object} inputRecord - JSON:API document to validate
|
|
904
904
|
* @param {Object} inputRecord.data - Complete resource representation
|
|
905
905
|
* @param {string} inputRecord.data.type - Resource type (must match existing)
|
|
@@ -909,7 +909,7 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
909
909
|
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
910
910
|
* @returns {boolean} True if valid
|
|
911
911
|
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
912
|
-
*
|
|
912
|
+
*
|
|
913
913
|
* @example
|
|
914
914
|
* // Input: Complete resource replacement
|
|
915
915
|
* validatePutPayload({
|
|
@@ -924,7 +924,7 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
924
924
|
* }
|
|
925
925
|
* }, scopes);
|
|
926
926
|
* // Output: true
|
|
927
|
-
*
|
|
927
|
+
*
|
|
928
928
|
* @example
|
|
929
929
|
* // Input: Replace with new relationships
|
|
930
930
|
* validatePutPayload({
|
|
@@ -953,7 +953,7 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
953
953
|
* }
|
|
954
954
|
* }, scopes);
|
|
955
955
|
* // Output: true (complete replacement)
|
|
956
|
-
*
|
|
956
|
+
*
|
|
957
957
|
* @example
|
|
958
958
|
* // Input: Missing required ID
|
|
959
959
|
* validatePutPayload({
|
|
@@ -964,7 +964,7 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
964
964
|
* }, scopes);
|
|
965
965
|
* // Throws: RestApiPayloadError
|
|
966
966
|
* // 'PUT request "data" must have an "id" property'
|
|
967
|
-
*
|
|
967
|
+
*
|
|
968
968
|
* @example
|
|
969
969
|
* // Input: Includes not allowed
|
|
970
970
|
* validatePutPayload({
|
|
@@ -979,19 +979,19 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
979
979
|
* }, scopes);
|
|
980
980
|
* // Throws: RestApiPayloadError
|
|
981
981
|
* // 'PUT requests cannot include an "included" array for creating new resources'
|
|
982
|
-
*
|
|
982
|
+
*
|
|
983
983
|
* @description
|
|
984
984
|
* Used by:
|
|
985
985
|
* - rest-api-plugin.put() method for full replacement
|
|
986
986
|
* - Enforces PUT semantics vs PATCH
|
|
987
|
-
*
|
|
987
|
+
*
|
|
988
988
|
* Purpose:
|
|
989
989
|
* - Ensures complete resource replacement semantics
|
|
990
990
|
* - Requires ID to match URL parameter
|
|
991
991
|
* - Prevents included resources (no side effects)
|
|
992
992
|
* - Maintains idempotency of PUT operations
|
|
993
993
|
* - Differentiates from PATCH partial updates
|
|
994
|
-
*
|
|
994
|
+
*
|
|
995
995
|
* Data flow:
|
|
996
996
|
* 1. Validates document structure with data property
|
|
997
997
|
* 2. Ensures no included array present
|
|
@@ -1000,107 +1000,107 @@ export function validatePostPayload(inputRecord, scopes = null) {
|
|
|
1000
1000
|
* 5. Validates attributes and relationships if present
|
|
1001
1001
|
* 6. Returns true or throws descriptive error
|
|
1002
1002
|
*/
|
|
1003
|
-
export function validatePutPayload(inputRecord, scopes = null) {
|
|
1003
|
+
export function validatePutPayload (inputRecord, scopes = null) {
|
|
1004
1004
|
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
1005
1005
|
throw new RestApiPayloadError(
|
|
1006
1006
|
'PUT request body must be a JSON:API document object',
|
|
1007
1007
|
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
1008
|
-
)
|
|
1008
|
+
)
|
|
1009
1009
|
}
|
|
1010
|
-
|
|
1010
|
+
|
|
1011
1011
|
// Validate required 'data' property
|
|
1012
1012
|
if (!('data' in inputRecord)) {
|
|
1013
1013
|
throw new RestApiPayloadError(
|
|
1014
1014
|
'PUT request body must have a "data" property',
|
|
1015
1015
|
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
1016
|
-
)
|
|
1016
|
+
)
|
|
1017
1017
|
}
|
|
1018
|
-
|
|
1019
|
-
const { data, included } = inputRecord
|
|
1020
|
-
|
|
1018
|
+
|
|
1019
|
+
const { data, included } = inputRecord
|
|
1020
|
+
|
|
1021
1021
|
// PUT cannot have included array
|
|
1022
1022
|
if (included !== undefined) {
|
|
1023
1023
|
throw new RestApiPayloadError(
|
|
1024
1024
|
'PUT requests cannot include an "included" array for creating new resources',
|
|
1025
1025
|
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
1026
|
-
)
|
|
1026
|
+
)
|
|
1027
1027
|
}
|
|
1028
|
-
|
|
1028
|
+
|
|
1029
1029
|
// Validate primary data
|
|
1030
1030
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1031
1031
|
throw new RestApiPayloadError(
|
|
1032
1032
|
'PUT request "data" must be a single resource object',
|
|
1033
1033
|
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
1034
|
-
)
|
|
1034
|
+
)
|
|
1035
1035
|
}
|
|
1036
|
-
|
|
1036
|
+
|
|
1037
1037
|
if (typeof data.type !== 'string' || !data.type) {
|
|
1038
1038
|
throw new RestApiPayloadError(
|
|
1039
1039
|
'PUT request "data" must have a non-empty "type" string',
|
|
1040
1040
|
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
1041
|
-
)
|
|
1041
|
+
)
|
|
1042
1042
|
}
|
|
1043
|
-
|
|
1043
|
+
|
|
1044
1044
|
// Check if resource type is valid
|
|
1045
1045
|
if (scopes && !scopes[data.type]) {
|
|
1046
1046
|
throw new RestApiValidationError(
|
|
1047
1047
|
`PUT request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
1048
1048
|
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
1049
|
-
)
|
|
1049
|
+
)
|
|
1050
1050
|
}
|
|
1051
|
-
|
|
1051
|
+
|
|
1052
1052
|
// For PUT, id is required
|
|
1053
1053
|
if (!('id' in data)) {
|
|
1054
1054
|
throw new RestApiPayloadError(
|
|
1055
1055
|
'PUT request "data" must have an "id" property',
|
|
1056
1056
|
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
1057
|
-
)
|
|
1057
|
+
)
|
|
1058
1058
|
}
|
|
1059
|
-
|
|
1059
|
+
|
|
1060
1060
|
if (data.id === null || data.id === undefined || data.id === '') {
|
|
1061
1061
|
throw new RestApiValidationError(
|
|
1062
1062
|
'PUT request "data.id" cannot be null, undefined, or empty',
|
|
1063
1063
|
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
1064
|
-
)
|
|
1064
|
+
)
|
|
1065
1065
|
}
|
|
1066
|
-
|
|
1066
|
+
|
|
1067
1067
|
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
1068
1068
|
throw new RestApiPayloadError(
|
|
1069
1069
|
'PUT request "data.id" must be a string or number',
|
|
1070
1070
|
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
1071
|
-
)
|
|
1071
|
+
)
|
|
1072
1072
|
}
|
|
1073
|
-
|
|
1073
|
+
|
|
1074
1074
|
// Validate attributes if present
|
|
1075
1075
|
if ('attributes' in data) {
|
|
1076
1076
|
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
1077
1077
|
throw new RestApiPayloadError(
|
|
1078
1078
|
'PUT request "data.attributes" must be an object',
|
|
1079
1079
|
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
1080
|
-
)
|
|
1080
|
+
)
|
|
1081
1081
|
}
|
|
1082
1082
|
}
|
|
1083
|
-
|
|
1083
|
+
|
|
1084
1084
|
// Validate relationships if present
|
|
1085
1085
|
if ('relationships' in data) {
|
|
1086
1086
|
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
1087
1087
|
throw new RestApiPayloadError(
|
|
1088
1088
|
'PUT request "data.relationships" must be an object',
|
|
1089
1089
|
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
1090
|
-
)
|
|
1090
|
+
)
|
|
1091
1091
|
}
|
|
1092
|
-
|
|
1092
|
+
|
|
1093
1093
|
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
1094
|
-
validateRelationship(relationship, relName, scopes)
|
|
1095
|
-
})
|
|
1094
|
+
validateRelationship(relationship, relName, scopes)
|
|
1095
|
+
})
|
|
1096
1096
|
}
|
|
1097
|
-
|
|
1098
|
-
return true
|
|
1097
|
+
|
|
1098
|
+
return true
|
|
1099
1099
|
}
|
|
1100
1100
|
|
|
1101
1101
|
/**
|
|
1102
1102
|
* Validates a JSON:API document for PATCH requests (partial resource updates)
|
|
1103
|
-
*
|
|
1103
|
+
*
|
|
1104
1104
|
* @param {Object} inputRecord - JSON:API document to validate
|
|
1105
1105
|
* @param {Object} inputRecord.data - Partial resource representation
|
|
1106
1106
|
* @param {string} inputRecord.data.type - Resource type (must match existing)
|
|
@@ -1110,7 +1110,7 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1110
1110
|
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
1111
1111
|
* @returns {boolean} True if valid
|
|
1112
1112
|
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
1113
|
-
*
|
|
1113
|
+
*
|
|
1114
1114
|
* @example
|
|
1115
1115
|
* // Input: Update single field
|
|
1116
1116
|
* validatePatchPayload({
|
|
@@ -1124,7 +1124,7 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1124
1124
|
* }
|
|
1125
1125
|
* }, scopes);
|
|
1126
1126
|
* // Output: true
|
|
1127
|
-
*
|
|
1127
|
+
*
|
|
1128
1128
|
* @example
|
|
1129
1129
|
* // Input: Update multiple fields
|
|
1130
1130
|
* validatePatchPayload({
|
|
@@ -1139,7 +1139,7 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1139
1139
|
* }
|
|
1140
1140
|
* }, scopes);
|
|
1141
1141
|
* // Output: true (partial update)
|
|
1142
|
-
*
|
|
1142
|
+
*
|
|
1143
1143
|
* @example
|
|
1144
1144
|
* // Input: Update relationships only
|
|
1145
1145
|
* validatePatchPayload({
|
|
@@ -1155,7 +1155,7 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1155
1155
|
* }
|
|
1156
1156
|
* }, scopes);
|
|
1157
1157
|
* // Output: true
|
|
1158
|
-
*
|
|
1158
|
+
*
|
|
1159
1159
|
* @example
|
|
1160
1160
|
* // Input: Clear relationships
|
|
1161
1161
|
* validatePatchPayload({
|
|
@@ -1173,7 +1173,7 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1173
1173
|
* }
|
|
1174
1174
|
* }, scopes);
|
|
1175
1175
|
* // Output: true (clearing is valid)
|
|
1176
|
-
*
|
|
1176
|
+
*
|
|
1177
1177
|
* @example
|
|
1178
1178
|
* // Input: No changes specified
|
|
1179
1179
|
* validatePatchPayload({
|
|
@@ -1185,19 +1185,19 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1185
1185
|
* }, scopes);
|
|
1186
1186
|
* // Throws: RestApiValidationError
|
|
1187
1187
|
* // 'PATCH request "data" must have at least one of "attributes" or "relationships"'
|
|
1188
|
-
*
|
|
1188
|
+
*
|
|
1189
1189
|
* @description
|
|
1190
1190
|
* Used by:
|
|
1191
1191
|
* - rest-api-plugin.patch() method for partial updates
|
|
1192
1192
|
* - Enforces PATCH semantics vs PUT
|
|
1193
|
-
*
|
|
1193
|
+
*
|
|
1194
1194
|
* Purpose:
|
|
1195
1195
|
* - Enables efficient partial updates (send only changes)
|
|
1196
1196
|
* - Requires at least one change (attributes or relationships)
|
|
1197
1197
|
* - Prevents included resources (no side effects)
|
|
1198
1198
|
* - Reduces bandwidth for large resources
|
|
1199
1199
|
* - Prevents race conditions in concurrent updates
|
|
1200
|
-
*
|
|
1200
|
+
*
|
|
1201
1201
|
* Data flow:
|
|
1202
1202
|
* 1. Validates document structure with data property
|
|
1203
1203
|
* 2. Ensures no included array present
|
|
@@ -1207,108 +1207,108 @@ export function validatePutPayload(inputRecord, scopes = null) {
|
|
|
1207
1207
|
* 6. Validates each if present
|
|
1208
1208
|
* 7. Returns true or throws descriptive error
|
|
1209
1209
|
*/
|
|
1210
|
-
export function validatePatchPayload(inputRecord, scopes = null) {
|
|
1210
|
+
export function validatePatchPayload (inputRecord, scopes = null) {
|
|
1211
1211
|
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
1212
1212
|
throw new RestApiPayloadError(
|
|
1213
1213
|
'PATCH request body must be a JSON:API document object',
|
|
1214
1214
|
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
1215
|
-
)
|
|
1215
|
+
)
|
|
1216
1216
|
}
|
|
1217
|
-
|
|
1217
|
+
|
|
1218
1218
|
// Validate required 'data' property
|
|
1219
1219
|
if (!('data' in inputRecord)) {
|
|
1220
1220
|
throw new RestApiPayloadError(
|
|
1221
1221
|
'PATCH request body must have a "data" property',
|
|
1222
1222
|
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
1223
|
-
)
|
|
1223
|
+
)
|
|
1224
1224
|
}
|
|
1225
|
-
|
|
1226
|
-
const { data, included } = inputRecord
|
|
1227
|
-
|
|
1225
|
+
|
|
1226
|
+
const { data, included } = inputRecord
|
|
1227
|
+
|
|
1228
1228
|
// PATCH cannot have included array
|
|
1229
1229
|
if (included !== undefined) {
|
|
1230
1230
|
throw new RestApiPayloadError(
|
|
1231
1231
|
'PATCH requests cannot include an "included" array for creating new resources',
|
|
1232
1232
|
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
1233
|
-
)
|
|
1233
|
+
)
|
|
1234
1234
|
}
|
|
1235
|
-
|
|
1235
|
+
|
|
1236
1236
|
// Validate primary data
|
|
1237
1237
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1238
1238
|
throw new RestApiPayloadError(
|
|
1239
1239
|
'PATCH request "data" must be a single resource object',
|
|
1240
1240
|
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
1241
|
-
)
|
|
1241
|
+
)
|
|
1242
1242
|
}
|
|
1243
|
-
|
|
1243
|
+
|
|
1244
1244
|
if (typeof data.type !== 'string' || !data.type) {
|
|
1245
1245
|
throw new RestApiPayloadError(
|
|
1246
1246
|
'PATCH request "data" must have a non-empty "type" string',
|
|
1247
1247
|
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
1248
|
-
)
|
|
1248
|
+
)
|
|
1249
1249
|
}
|
|
1250
|
-
|
|
1250
|
+
|
|
1251
1251
|
// Check if resource type is valid
|
|
1252
1252
|
if (scopes && !scopes[data.type]) {
|
|
1253
1253
|
throw new RestApiValidationError(
|
|
1254
1254
|
`PATCH request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
1255
1255
|
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
1256
|
-
)
|
|
1256
|
+
)
|
|
1257
1257
|
}
|
|
1258
|
-
|
|
1258
|
+
|
|
1259
1259
|
// For PATCH, id is required
|
|
1260
1260
|
if (!('id' in data)) {
|
|
1261
1261
|
throw new RestApiPayloadError(
|
|
1262
1262
|
'PATCH request "data" must have an "id" property',
|
|
1263
1263
|
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
1264
|
-
)
|
|
1264
|
+
)
|
|
1265
1265
|
}
|
|
1266
|
-
|
|
1266
|
+
|
|
1267
1267
|
if (data.id === null || data.id === undefined || data.id === '') {
|
|
1268
1268
|
throw new RestApiValidationError(
|
|
1269
1269
|
'PATCH request "data.id" cannot be null, undefined, or empty',
|
|
1270
1270
|
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
1271
|
-
)
|
|
1271
|
+
)
|
|
1272
1272
|
}
|
|
1273
|
-
|
|
1273
|
+
|
|
1274
1274
|
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
1275
1275
|
throw new RestApiPayloadError(
|
|
1276
1276
|
'PATCH request "data.id" must be a string or number',
|
|
1277
1277
|
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
1278
|
-
)
|
|
1278
|
+
)
|
|
1279
1279
|
}
|
|
1280
|
-
|
|
1280
|
+
|
|
1281
1281
|
// For PATCH, at least one of attributes or relationships should be present
|
|
1282
1282
|
if (!('attributes' in data) && !('relationships' in data)) {
|
|
1283
1283
|
throw new RestApiValidationError(
|
|
1284
1284
|
'PATCH request "data" must have at least one of "attributes" or "relationships"',
|
|
1285
1285
|
{ fields: ['data'], violations: [{ field: 'data', rule: 'partial_update', message: 'Must include at least one of attributes or relationships' }] }
|
|
1286
|
-
)
|
|
1286
|
+
)
|
|
1287
1287
|
}
|
|
1288
|
-
|
|
1288
|
+
|
|
1289
1289
|
// Validate attributes if present
|
|
1290
1290
|
if ('attributes' in data && data.attributes !== undefined) {
|
|
1291
1291
|
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
1292
1292
|
throw new RestApiPayloadError(
|
|
1293
1293
|
'PATCH request "data.attributes" must be an object',
|
|
1294
1294
|
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
1295
|
-
)
|
|
1295
|
+
)
|
|
1296
1296
|
}
|
|
1297
1297
|
}
|
|
1298
|
-
|
|
1298
|
+
|
|
1299
1299
|
// Validate relationships if present
|
|
1300
1300
|
if ('relationships' in data && data.relationships !== undefined) {
|
|
1301
1301
|
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
1302
1302
|
throw new RestApiPayloadError(
|
|
1303
1303
|
'PATCH request "data.relationships" must be an object',
|
|
1304
1304
|
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
1305
|
-
)
|
|
1305
|
+
)
|
|
1306
1306
|
}
|
|
1307
|
-
|
|
1307
|
+
|
|
1308
1308
|
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
1309
|
-
validateRelationship(relationship, relName, scopes)
|
|
1310
|
-
})
|
|
1309
|
+
validateRelationship(relationship, relName, scopes)
|
|
1310
|
+
})
|
|
1311
1311
|
}
|
|
1312
|
-
|
|
1313
|
-
return true
|
|
1314
|
-
}
|
|
1312
|
+
|
|
1313
|
+
return true
|
|
1314
|
+
}
|