json-rest-api 0.1.1 → 1.0.1
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/.claude/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
|
|
2
|
+
import { RestApiValidationError, RestApiPayloadError } from './rest-api-errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates a JSON:API resource identifier object
|
|
6
|
+
* @param {Object} identifier - The resource identifier to validate
|
|
7
|
+
* @param {string} context - Context for error messages
|
|
8
|
+
* @param {Object} scopes - The scopes proxy object to check if type exists
|
|
9
|
+
* @returns {boolean} True if valid
|
|
10
|
+
* @throws {RestApiPayloadError|RestApiValidationError} If validation fails
|
|
11
|
+
*/
|
|
12
|
+
function validateResourceIdentifier(identifier, context, scopes = null) {
|
|
13
|
+
if (!identifier || typeof identifier !== 'object') {
|
|
14
|
+
throw new RestApiPayloadError(
|
|
15
|
+
`${context}: Resource identifier must be an object`,
|
|
16
|
+
{ path: context, expected: 'object', received: typeof identifier }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof identifier.type !== 'string' || !identifier.type) {
|
|
21
|
+
throw new RestApiPayloadError(
|
|
22
|
+
`${context}: Resource identifier must have a non-empty 'type' string`,
|
|
23
|
+
{ path: `${context}.type`, expected: 'non-empty string', received: identifier.type }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check if type is valid by checking if scope exists
|
|
28
|
+
if (scopes && !scopes[identifier.type]) {
|
|
29
|
+
throw new RestApiValidationError(
|
|
30
|
+
`${context}: Unknown resource type '${identifier.type}'. No scope with this name exists.`,
|
|
31
|
+
{
|
|
32
|
+
fields: [`${context}.type`],
|
|
33
|
+
violations: [{ field: `${context}.type`, rule: 'valid_resource_type', message: `Resource type '${identifier.type}' does not exist` }]
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!('id' in identifier)) {
|
|
39
|
+
throw new RestApiPayloadError(
|
|
40
|
+
`${context}: Resource identifier must have an 'id' property`,
|
|
41
|
+
{ path: `${context}.id`, expected: 'property exists', received: 'missing' }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (identifier.id !== null && typeof identifier.id !== 'string' && typeof identifier.id !== 'number') {
|
|
46
|
+
throw new RestApiPayloadError(
|
|
47
|
+
`${context}: Resource identifier 'id' must be a string, number, or null`,
|
|
48
|
+
{ path: `${context}.id`, expected: 'string, number, or null', received: typeof identifier.id }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validates a relationship object
|
|
57
|
+
* @param {Object} relationship - The relationship to validate
|
|
58
|
+
* @param {string} relationshipName - Name of the relationship for error context
|
|
59
|
+
* @param {Object} scopes - The scopes proxy object to check if type exists
|
|
60
|
+
* @returns {boolean} True if valid
|
|
61
|
+
* @throws {Error} If validation fails
|
|
62
|
+
*/
|
|
63
|
+
function validateRelationship(relationship, relationshipName, scopes = null) {
|
|
64
|
+
if (!relationship || typeof relationship !== 'object') {
|
|
65
|
+
throw new RestApiPayloadError(
|
|
66
|
+
`Relationship '${relationshipName}' must be an object`,
|
|
67
|
+
{ path: `relationships.${relationshipName}`, expected: 'object', received: typeof relationship }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!('data' in relationship)) {
|
|
72
|
+
throw new RestApiPayloadError(
|
|
73
|
+
`Relationship '${relationshipName}' must have a 'data' property`,
|
|
74
|
+
{ path: `relationships.${relationshipName}.data`, expected: 'property exists', received: 'missing' }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { data } = relationship;
|
|
79
|
+
|
|
80
|
+
// data can be null (empty to-one relationship)
|
|
81
|
+
if (data === null) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// data can be a single resource identifier (to-one)
|
|
86
|
+
if (!Array.isArray(data)) {
|
|
87
|
+
validateResourceIdentifier(data, `Relationship '${relationshipName}'`, scopes);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// data can be an array of resource identifiers (to-many)
|
|
92
|
+
if (data.length === 0) {
|
|
93
|
+
return true; // Empty to-many relationship is valid
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
data.forEach((identifier, index) => {
|
|
97
|
+
validateResourceIdentifier(identifier, `Relationship '${relationshipName}[${index}]'`, scopes);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validates query parameters for GET requests
|
|
105
|
+
* @param {Object} params - The parameters object containing id and queryParams
|
|
106
|
+
* @returns {boolean} True if valid
|
|
107
|
+
* @throws {Error} If validation fails
|
|
108
|
+
*/
|
|
109
|
+
export function validateGetPayload(params) {
|
|
110
|
+
if (!params || typeof params !== 'object') {
|
|
111
|
+
throw new RestApiPayloadError(
|
|
112
|
+
'GET parameters must be an object',
|
|
113
|
+
{ path: 'params', expected: 'object', received: typeof params }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validate ID
|
|
118
|
+
if (!('id' in params)) {
|
|
119
|
+
throw new RestApiValidationError(
|
|
120
|
+
'GET request must include an id parameter',
|
|
121
|
+
{ fields: ['id'], violations: [{ field: 'id', rule: 'required', message: 'ID parameter is required' }] }
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (params.id === null || params.id === undefined || params.id === '') {
|
|
126
|
+
throw new RestApiValidationError(
|
|
127
|
+
'GET request id cannot be null, undefined, or empty',
|
|
128
|
+
{ fields: ['id'], violations: [{ field: 'id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate queryParams if present
|
|
133
|
+
if (params.queryParams) {
|
|
134
|
+
if (typeof params.queryParams !== 'object') {
|
|
135
|
+
throw new RestApiPayloadError(
|
|
136
|
+
'queryParams must be an object',
|
|
137
|
+
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { include, fields } = params.queryParams;
|
|
142
|
+
|
|
143
|
+
// Validate include
|
|
144
|
+
if (include !== undefined) {
|
|
145
|
+
if (!Array.isArray(include)) {
|
|
146
|
+
throw new RestApiPayloadError(
|
|
147
|
+
'queryParams.include must be an array of strings',
|
|
148
|
+
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
include.forEach((path, index) => {
|
|
153
|
+
if (typeof path !== 'string') {
|
|
154
|
+
throw new RestApiPayloadError(
|
|
155
|
+
`queryParams.include[${index}] must be a string`,
|
|
156
|
+
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Validate fields
|
|
163
|
+
if (fields !== undefined) {
|
|
164
|
+
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
165
|
+
throw new RestApiPayloadError(
|
|
166
|
+
'queryParams.fields must be an object',
|
|
167
|
+
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
172
|
+
if (typeof fieldList !== 'string') {
|
|
173
|
+
throw new RestApiPayloadError(
|
|
174
|
+
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
175
|
+
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validates query parameters for collection requests
|
|
187
|
+
* @param {Object} params - The parameters object
|
|
188
|
+
* @param {string[]} sortableFields - Array of fields that can be sorted
|
|
189
|
+
* @returns {boolean} True if valid
|
|
190
|
+
* @throws {Error} If validation fails
|
|
191
|
+
*/
|
|
192
|
+
export function validateQueryPayload(params, sortableFields = []) {
|
|
193
|
+
if (!params || typeof params !== 'object') {
|
|
194
|
+
throw new RestApiPayloadError(
|
|
195
|
+
'Query parameters must be an object',
|
|
196
|
+
{ path: 'params', expected: 'object', received: typeof params }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// queryParams is optional but if present must be an object
|
|
201
|
+
if (params.queryParams) {
|
|
202
|
+
if (typeof params.queryParams !== 'object') {
|
|
203
|
+
throw new RestApiPayloadError(
|
|
204
|
+
'queryParams must be an object',
|
|
205
|
+
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { include, fields, filter, sort, page } = params.queryParams;
|
|
210
|
+
|
|
211
|
+
// Validate include
|
|
212
|
+
if (include !== undefined) {
|
|
213
|
+
if (!Array.isArray(include)) {
|
|
214
|
+
throw new RestApiPayloadError(
|
|
215
|
+
'queryParams.include must be an array of strings',
|
|
216
|
+
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
include.forEach((path, index) => {
|
|
221
|
+
if (typeof path !== 'string') {
|
|
222
|
+
throw new RestApiPayloadError(
|
|
223
|
+
`queryParams.include[${index}] must be a string`,
|
|
224
|
+
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Validate fields
|
|
231
|
+
if (fields !== undefined) {
|
|
232
|
+
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
233
|
+
throw new RestApiPayloadError(
|
|
234
|
+
'queryParams.fields must be an object',
|
|
235
|
+
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
240
|
+
if (typeof fieldList !== 'string') {
|
|
241
|
+
throw new RestApiPayloadError(
|
|
242
|
+
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
243
|
+
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Validate filter
|
|
250
|
+
if (filter !== undefined) {
|
|
251
|
+
if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) {
|
|
252
|
+
throw new RestApiPayloadError(
|
|
253
|
+
'queryParams.filter must be an object',
|
|
254
|
+
{ path: 'queryParams.filter', expected: 'object', received: Array.isArray(filter) ? 'array' : typeof filter }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate sort
|
|
260
|
+
if (sort !== undefined) {
|
|
261
|
+
if (!Array.isArray(sort)) {
|
|
262
|
+
throw new RestApiPayloadError(
|
|
263
|
+
'queryParams.sort must be an array of strings',
|
|
264
|
+
{ path: 'queryParams.sort', expected: 'array', received: typeof sort }
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sort.forEach((field, index) => {
|
|
269
|
+
if (typeof field !== 'string') {
|
|
270
|
+
throw new RestApiPayloadError(
|
|
271
|
+
`queryParams.sort[${index}] must be a string`,
|
|
272
|
+
{ path: `queryParams.sort[${index}]`, expected: 'string', received: typeof field }
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check if field is sortable (remove leading - for descending sort)
|
|
277
|
+
const fieldName = field.startsWith('-') ? field.substring(1) : field;
|
|
278
|
+
if (sortableFields.length > 0 && !sortableFields.includes(fieldName)) {
|
|
279
|
+
throw new RestApiValidationError(
|
|
280
|
+
`Field '${fieldName}' is not sortable. Sortable fields are: ${sortableFields.join(', ')}`,
|
|
281
|
+
{ fields: ['sort'], violations: [{ field: 'sort', rule: 'sortable_field', message: `Field '${fieldName}' is not in the list of sortable fields` }] }
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Validate page
|
|
288
|
+
if (page !== undefined) {
|
|
289
|
+
if (typeof page !== 'object' || page === null || Array.isArray(page)) {
|
|
290
|
+
throw new RestApiPayloadError(
|
|
291
|
+
'queryParams.page must be an object',
|
|
292
|
+
{ path: 'queryParams.page', expected: 'object', received: Array.isArray(page) ? 'array' : typeof page }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Common pagination parameters
|
|
297
|
+
if ('number' in page && typeof page.number !== 'number' && typeof page.number !== 'string') {
|
|
298
|
+
throw new RestApiPayloadError(
|
|
299
|
+
'queryParams.page.number must be a number or string',
|
|
300
|
+
{ path: 'queryParams.page.number', expected: 'number or string', received: typeof page.number }
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if ('size' in page && typeof page.size !== 'number' && typeof page.size !== 'string') {
|
|
305
|
+
throw new RestApiPayloadError(
|
|
306
|
+
'queryParams.page.size must be a number or string',
|
|
307
|
+
{ path: 'queryParams.page.size', expected: 'number or string', received: typeof page.size }
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if ('limit' in page && typeof page.limit !== 'number' && typeof page.limit !== 'string') {
|
|
312
|
+
throw new RestApiPayloadError(
|
|
313
|
+
'queryParams.page.limit must be a number or string',
|
|
314
|
+
{ path: 'queryParams.page.limit', expected: 'number or string', received: typeof page.limit }
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if ('offset' in page && typeof page.offset !== 'number' && typeof page.offset !== 'string') {
|
|
319
|
+
throw new RestApiPayloadError(
|
|
320
|
+
'queryParams.page.offset must be a number or string',
|
|
321
|
+
{ path: 'queryParams.page.offset', expected: 'number or string', received: typeof page.offset }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Validates a JSON:API document for POST requests
|
|
332
|
+
* @param {Object} inputRecord - The JSON:API document to validate
|
|
333
|
+
* @param {Object} scopes - The scopes proxy object to check if type exists
|
|
334
|
+
* @returns {boolean} True if valid
|
|
335
|
+
* @throws {Error} If validation fails
|
|
336
|
+
*/
|
|
337
|
+
export function validatePostPayload(inputRecord, scopes = null) {
|
|
338
|
+
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
339
|
+
throw new RestApiPayloadError(
|
|
340
|
+
'POST request body must be a JSON:API document object',
|
|
341
|
+
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Validate required 'data' property
|
|
346
|
+
if (!('data' in inputRecord)) {
|
|
347
|
+
throw new RestApiPayloadError(
|
|
348
|
+
'POST request body must have a "data" property',
|
|
349
|
+
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { data, included } = inputRecord;
|
|
354
|
+
|
|
355
|
+
// Validate primary data
|
|
356
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
357
|
+
throw new RestApiPayloadError(
|
|
358
|
+
'POST request "data" must be a single resource object',
|
|
359
|
+
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (typeof data.type !== 'string' || !data.type) {
|
|
364
|
+
throw new RestApiPayloadError(
|
|
365
|
+
'POST request "data" must have a non-empty "type" string',
|
|
366
|
+
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Check if primary resource type is valid
|
|
371
|
+
if (scopes && !scopes[data.type]) {
|
|
372
|
+
throw new RestApiValidationError(
|
|
373
|
+
`POST request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
374
|
+
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// For POST, id is optional (server may generate it)
|
|
379
|
+
if ('id' in data && data.id !== null && typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
380
|
+
throw new RestApiPayloadError(
|
|
381
|
+
'POST request "data.id" if present must be a string, number, or null',
|
|
382
|
+
{ path: 'data.id', expected: 'string, number, or null', received: typeof data.id }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Validate attributes if present
|
|
387
|
+
if ('attributes' in data) {
|
|
388
|
+
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
389
|
+
throw new RestApiPayloadError(
|
|
390
|
+
'POST request "data.attributes" must be an object',
|
|
391
|
+
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Validate relationships if present
|
|
397
|
+
if ('relationships' in data) {
|
|
398
|
+
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
399
|
+
throw new RestApiPayloadError(
|
|
400
|
+
'POST request "data.relationships" must be an object',
|
|
401
|
+
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
406
|
+
validateRelationship(relationship, relName, scopes);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Validate included resources if present
|
|
411
|
+
if (included !== undefined) {
|
|
412
|
+
if (!Array.isArray(included)) {
|
|
413
|
+
throw new RestApiPayloadError(
|
|
414
|
+
'POST request "included" must be an array',
|
|
415
|
+
{ path: 'included', expected: 'array', received: typeof included }
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
included.forEach((resource, index) => {
|
|
420
|
+
if (!resource || typeof resource !== 'object') {
|
|
421
|
+
throw new RestApiPayloadError(
|
|
422
|
+
`POST request "included[${index}]" must be a resource object`,
|
|
423
|
+
{ path: `included[${index}]`, expected: 'object', received: typeof resource }
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (typeof resource.type !== 'string' || !resource.type) {
|
|
428
|
+
throw new RestApiPayloadError(
|
|
429
|
+
`POST request "included[${index}]" must have a non-empty "type" string`,
|
|
430
|
+
{ path: `included[${index}].type`, expected: 'non-empty string', received: resource.type || 'empty' }
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Check if included resource type is valid
|
|
435
|
+
if (scopes && !scopes[resource.type]) {
|
|
436
|
+
throw new RestApiValidationError(
|
|
437
|
+
`POST request "included[${index}].type" '${resource.type}' is not a valid resource type. No scope with this name exists.`,
|
|
438
|
+
{ fields: [`included[${index}].type`], violations: [{ field: `included[${index}].type`, rule: 'valid_resource_type', message: `Resource type '${resource.type}' does not exist` }] }
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!('id' in resource) || resource.id === null || resource.id === undefined) {
|
|
443
|
+
throw new RestApiPayloadError(
|
|
444
|
+
`POST request "included[${index}]" must have a non-null "id"`,
|
|
445
|
+
{ path: `included[${index}].id`, expected: 'non-null value', received: 'null or undefined' }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (typeof resource.id !== 'string' && typeof resource.id !== 'number') {
|
|
450
|
+
throw new RestApiPayloadError(
|
|
451
|
+
`POST request "included[${index}].id" must be a string or number`,
|
|
452
|
+
{ path: `included[${index}].id`, expected: 'string or number', received: typeof resource.id }
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if ('attributes' in resource) {
|
|
457
|
+
if (typeof resource.attributes !== 'object' || resource.attributes === null || Array.isArray(resource.attributes)) {
|
|
458
|
+
throw new RestApiPayloadError(
|
|
459
|
+
`POST request "included[${index}].attributes" must be an object`,
|
|
460
|
+
{ path: `included[${index}].attributes`, expected: 'object', received: Array.isArray(resource.attributes) ? 'array' : typeof resource.attributes }
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Validates a JSON:API document for PUT requests
|
|
472
|
+
* @param {Object} inputRecord - The JSON:API document to validate
|
|
473
|
+
* @param {Object} scopes - The scopes proxy object to check if type exists
|
|
474
|
+
* @returns {boolean} True if valid
|
|
475
|
+
* @throws {Error} If validation fails
|
|
476
|
+
*/
|
|
477
|
+
export function validatePutPayload(inputRecord, scopes = null) {
|
|
478
|
+
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
479
|
+
throw new RestApiPayloadError(
|
|
480
|
+
'PUT request body must be a JSON:API document object',
|
|
481
|
+
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Validate required 'data' property
|
|
486
|
+
if (!('data' in inputRecord)) {
|
|
487
|
+
throw new RestApiPayloadError(
|
|
488
|
+
'PUT request body must have a "data" property',
|
|
489
|
+
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const { data, included } = inputRecord;
|
|
494
|
+
|
|
495
|
+
// PUT cannot have included array
|
|
496
|
+
if (included !== undefined) {
|
|
497
|
+
throw new RestApiPayloadError(
|
|
498
|
+
'PUT requests cannot include an "included" array for creating new resources',
|
|
499
|
+
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Validate primary data
|
|
504
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
505
|
+
throw new RestApiPayloadError(
|
|
506
|
+
'PUT request "data" must be a single resource object',
|
|
507
|
+
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (typeof data.type !== 'string' || !data.type) {
|
|
512
|
+
throw new RestApiPayloadError(
|
|
513
|
+
'PUT request "data" must have a non-empty "type" string',
|
|
514
|
+
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check if resource type is valid
|
|
519
|
+
if (scopes && !scopes[data.type]) {
|
|
520
|
+
throw new RestApiValidationError(
|
|
521
|
+
`PUT request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
522
|
+
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// For PUT, id is required
|
|
527
|
+
if (!('id' in data)) {
|
|
528
|
+
throw new RestApiPayloadError(
|
|
529
|
+
'PUT request "data" must have an "id" property',
|
|
530
|
+
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (data.id === null || data.id === undefined || data.id === '') {
|
|
535
|
+
throw new RestApiValidationError(
|
|
536
|
+
'PUT request "data.id" cannot be null, undefined, or empty',
|
|
537
|
+
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
542
|
+
throw new RestApiPayloadError(
|
|
543
|
+
'PUT request "data.id" must be a string or number',
|
|
544
|
+
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Validate attributes if present
|
|
549
|
+
if ('attributes' in data) {
|
|
550
|
+
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
551
|
+
throw new RestApiPayloadError(
|
|
552
|
+
'PUT request "data.attributes" must be an object',
|
|
553
|
+
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Validate relationships if present
|
|
559
|
+
if ('relationships' in data) {
|
|
560
|
+
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
561
|
+
throw new RestApiPayloadError(
|
|
562
|
+
'PUT request "data.relationships" must be an object',
|
|
563
|
+
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
568
|
+
validateRelationship(relationship, relName, scopes);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Validates a JSON:API document for PATCH requests
|
|
577
|
+
* @param {Object} inputRecord - The JSON:API document to validate
|
|
578
|
+
* @param {Object} scopes - The scopes proxy object to check if type exists
|
|
579
|
+
* @returns {boolean} True if valid
|
|
580
|
+
* @throws {Error} If validation fails
|
|
581
|
+
*/
|
|
582
|
+
export function validatePatchPayload(inputRecord, scopes = null) {
|
|
583
|
+
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
584
|
+
throw new RestApiPayloadError(
|
|
585
|
+
'PATCH request body must be a JSON:API document object',
|
|
586
|
+
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Validate required 'data' property
|
|
591
|
+
if (!('data' in inputRecord)) {
|
|
592
|
+
throw new RestApiPayloadError(
|
|
593
|
+
'PATCH request body must have a "data" property',
|
|
594
|
+
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const { data, included } = inputRecord;
|
|
599
|
+
|
|
600
|
+
// PATCH cannot have included array
|
|
601
|
+
if (included !== undefined) {
|
|
602
|
+
throw new RestApiPayloadError(
|
|
603
|
+
'PATCH requests cannot include an "included" array for creating new resources',
|
|
604
|
+
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Validate primary data
|
|
609
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
610
|
+
throw new RestApiPayloadError(
|
|
611
|
+
'PATCH request "data" must be a single resource object',
|
|
612
|
+
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (typeof data.type !== 'string' || !data.type) {
|
|
617
|
+
throw new RestApiPayloadError(
|
|
618
|
+
'PATCH request "data" must have a non-empty "type" string',
|
|
619
|
+
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Check if resource type is valid
|
|
624
|
+
if (scopes && !scopes[data.type]) {
|
|
625
|
+
throw new RestApiValidationError(
|
|
626
|
+
`PATCH request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
627
|
+
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// For PATCH, id is required
|
|
632
|
+
if (!('id' in data)) {
|
|
633
|
+
throw new RestApiPayloadError(
|
|
634
|
+
'PATCH request "data" must have an "id" property',
|
|
635
|
+
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (data.id === null || data.id === undefined || data.id === '') {
|
|
640
|
+
throw new RestApiValidationError(
|
|
641
|
+
'PATCH request "data.id" cannot be null, undefined, or empty',
|
|
642
|
+
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
647
|
+
throw new RestApiPayloadError(
|
|
648
|
+
'PATCH request "data.id" must be a string or number',
|
|
649
|
+
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// For PATCH, at least one of attributes or relationships should be present
|
|
654
|
+
if (!('attributes' in data) && !('relationships' in data)) {
|
|
655
|
+
throw new RestApiValidationError(
|
|
656
|
+
'PATCH request "data" must have at least one of "attributes" or "relationships"',
|
|
657
|
+
{ fields: ['data'], violations: [{ field: 'data', rule: 'partial_update', message: 'Must include at least one of attributes or relationships' }] }
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Validate attributes if present
|
|
662
|
+
if ('attributes' in data && data.attributes !== undefined) {
|
|
663
|
+
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
664
|
+
throw new RestApiPayloadError(
|
|
665
|
+
'PATCH request "data.attributes" must be an object',
|
|
666
|
+
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Validate relationships if present
|
|
672
|
+
if ('relationships' in data && data.relationships !== undefined) {
|
|
673
|
+
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
674
|
+
throw new RestApiPayloadError(
|
|
675
|
+
'PATCH request "data.relationships" must be an object',
|
|
676
|
+
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
681
|
+
validateRelationship(relationship, relName, scopes);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return true;
|
|
686
|
+
}
|