json-rest-api 1.0.0 → 1.0.2

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.
Files changed (67) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/LICENSE +674 -0
  3. package/README.md +655 -309
  4. package/RELATIONSHIPS.md +1013 -0
  5. package/TODO_rest-api-plugin-extras.js +142 -0
  6. package/connectors_ideas/express-plugin.js +120 -0
  7. package/connectors_ideas/fastify.js +219 -0
  8. package/connectors_ideas/http-plugin.js +138 -0
  9. package/connectors_ideas/koa-plugin.js +113 -0
  10. package/docs/fileUploads.md +722 -0
  11. package/docs/mini-guides/file-uploads-complete.md +159 -0
  12. package/examples/.gitkeep +0 -0
  13. package/examples/express-example.js +262 -0
  14. package/examples/express-plugin.js +366 -0
  15. package/examples/file-upload-example.js +205 -0
  16. package/examples/filename-strategies.js +140 -0
  17. package/examples/knex-basic.js +66 -0
  18. package/index.js +31 -56
  19. package/lib/payload-validators.js +685 -0
  20. package/lib/rest-api-errors.js +58 -0
  21. package/package.json +41 -14
  22. package/payloadValdators.js +686 -0
  23. package/plugins/core/connectors/express-plugin.js +449 -0
  24. package/plugins/core/connectors/http-plugin.js +450 -0
  25. package/plugins/core/file-handling-plugin.js +338 -0
  26. package/plugins/core/lib/busboy-detector.js +167 -0
  27. package/plugins/core/lib/formidable-detector.js +130 -0
  28. package/plugins/core/rest-api-knex-plugin.js +200 -0
  29. package/plugins/core/rest-api-plugin.js +1419 -0
  30. package/plugins/storage/local-storage.js +218 -0
  31. package/plugins/storage/s3-storage.js +125 -0
  32. package/test-examples/package-lock.json +906 -0
  33. package/test-examples/package.json +15 -0
  34. package/test-examples/test-basic-api.js +11 -0
  35. package/test-examples/test-books-resource.js +116 -0
  36. package/test-examples/test-complete-file-upload.js +140 -0
  37. package/test-examples/test-debug.js +28 -0
  38. package/test-examples/test-express-plugin.js +177 -0
  39. package/test-examples/test-express-simple.js +28 -0
  40. package/test-examples/test-express-with-import.js +71 -0
  41. package/test-examples/test-file-upload.js +132 -0
  42. package/test-examples/test-http-plugin.js +159 -0
  43. package/test-examples/test-programmatic-api.js +188 -0
  44. package/test-examples/test-rest-api-plugin.js +13 -0
  45. package/TODO.todo +0 -113
  46. package/api.js +0 -628
  47. package/docs/API.md +0 -1306
  48. package/docs/FIXES_APPLIED.md +0 -59
  49. package/docs/GUIDES.md +0 -1792
  50. package/docs/RESOURCES_IN_EXPRESS.md +0 -456
  51. package/docs/example-versioning.js +0 -168
  52. package/docs/example.js +0 -195
  53. package/plugins/API guide.md +0 -364
  54. package/plugins/api-registry.js +0 -142
  55. package/plugins/api-versioning.md +0 -135
  56. package/plugins/http.js +0 -273
  57. package/plugins/logging.js +0 -255
  58. package/plugins/memory.js +0 -182
  59. package/plugins/mysql.js +0 -753
  60. package/plugins/openapi.js +0 -401
  61. package/plugins/positioning.js +0 -220
  62. package/plugins/security.js +0 -261
  63. package/plugins/validation.js +0 -173
  64. package/plugins/versioning.js +0 -288
  65. package/resource-helper.js +0 -79
  66. package/schema.js +0 -481
  67. package/test-basic.js +0 -107
@@ -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
+ }