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