json-rest-api 1.0.0 → 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.
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,1419 @@
1
+ import CreateSchema from 'jsonrestapi-schema'
2
+ import {
3
+ validateGetPayload,
4
+ validateQueryPayload,
5
+ validatePostPayload,
6
+ validatePutPayload,
7
+ validatePatchPayload
8
+ } from '../../lib/payload-validators.js';
9
+ import {
10
+ RestApiValidationError,
11
+ RestApiResourceError,
12
+ RestApiPayloadError
13
+ } from '../../lib/rest-api-errors.js';
14
+
15
+ export const RestApiPlugin = {
16
+ name: 'rest-api',
17
+
18
+ install({ helpers, addScopeMethod, vars, addHook, apiOptions, pluginOptions, api, setScopeAlias }) {
19
+
20
+ // Initialize the rest namespace for REST API functionality
21
+ api.rest = {};
22
+
23
+ // Set up REST-friendly aliases
24
+ setScopeAlias('resources', 'addResource');
25
+
26
+ // Initialize default vars for the plugin from pluginOptions
27
+ const restApiOptions = pluginOptions['rest-api'] || {};
28
+
29
+ vars.sortableFields = restApiOptions.sortableFields || [];
30
+ vars.defaultSort = restApiOptions.defaultSort || null;
31
+ vars.idProperty = restApiOptions.idProperty || 'id';
32
+ vars.pageSize = restApiOptions.pageSize || 20;
33
+ vars.maxPageSize = restApiOptions.maxPageSize || 100;
34
+ vars.loadRecordOnPut = !!restApiOptions.loadRecordOnPut;
35
+
36
+ // Return full record configuration
37
+ vars.returnFullRecord = {
38
+ post: restApiOptions.returnFullRecord?.post ?? false,
39
+ put: restApiOptions.returnFullRecord?.put ?? false,
40
+ patch: restApiOptions.returnFullRecord?.patch ?? false
41
+ };
42
+
43
+ addScopeMethod('enrichAttributes', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
44
+
45
+ // This will make sure that when this method calls "runHooks", the hooks will have the same context as api.post()
46
+ // although it will be a COPY
47
+ context.parentContext = params.parentContext
48
+ context.attributes = params.attributes
49
+
50
+ // Hooks will receive enrichRecord's context as context, and their jobs is to change context.record
51
+ runHooks('enrichAttributes')
52
+
53
+ return context.attributes
54
+ })
55
+
56
+
57
+ /**
58
+ * QUERY
59
+ * Retrieves a collection of resources (e.g., a list of articles) based on provided criteria.
60
+ * This function sends a GET request to /api/{resourceType}.
61
+ *
62
+ * @param {string} resourceType - The type of resource collection to fetch (e.g., "articles").
63
+ * @param {object} [queryParams={}] - Optional. An object to customize the query for the collection.
64
+ * @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload for each resource in the collection. These paths will be converted to a comma-separated string for the URL (e.g., `['author', 'comments.user']` becomes `author,comments.user`). Supports deep relationships (e.g., "publisher.country").
65
+ * @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets) for each resource in the collection and its included relationships. Keys are resource types, values are comma-separated field names.
66
+ * @param {object} [queryParams.filter] - An object to filter the collection. Keys are filter parameters (specific to your API's implementation, e.g., 'status', 'title'), values are the filter criteria.
67
+ * @param {string[]} [queryParams.sort=[]] - An optional array of fields to sort the collection by. Each string represents a field; prefix with '-' for descending order (e.g., `['title', '-published-date']` becomes `title,-published-date`).
68
+ * @param {object} [queryParams.page] - An object for pagination. Typically includes `number` (page number) and `size` (items per page). E.g., `{ number: 1, size: 10 }`.
69
+ * @returns {Promise<object>} A Promise that resolves to the JSON:API response document containing the resource collection.
70
+ *
71
+ * @example
72
+ * // Example 1: Get a paginated list of articles, including their authors.
73
+ * const articlesResponse = await api.getCollection('articles', {
74
+ * include: ['author'],
75
+ * fields: {
76
+ * articles: 'title,published-date',
77
+ * people: 'name'
78
+ * },
79
+ * page: { number: 1, size: 2 },
80
+ * sort: ['-published-date']
81
+ * });
82
+ * @see GET /api/articles?include=author&fields[articles]=title,published-date&fields[people]=name&page[number]=1&page[size]=2&sort=-published-date
83
+ * // Example Return Value for articlesResponse:
84
+ * // {
85
+ * // "data": [
86
+ * // {
87
+ * // "type": "articles", "id": "123",
88
+ * // "attributes": { "title": "First Article", "published-date": "2024-07-01T10:00:00Z" },
89
+ * // "relationships": { "author": { "data": { "type": "people", "id": "9" } } }
90
+ * // },
91
+ * // {
92
+ * // "type": "articles", "id": "124",
93
+ * // "attributes": { "title": "Second Article", "published-date": "2024-06-30T15:30:00Z" },
94
+ * // "relationships": { "author": { "data": { "type": "people", "id": "10" } } }
95
+ * // }
96
+ * // ],
97
+ * // "included": [
98
+ * // { "type": "people", "id": "9", "attributes": { "name": "John Doe" } },
99
+ * // { "type": "people", "id": "10", "attributes": { "name": "Jane Smith" } }
100
+ * // ]
101
+ * // }
102
+ *
103
+ * @example
104
+ * // Example 2: Get a filtered list of articles with deep relationships (publisher and its country).
105
+ * const complexArticles = await api.getCollection('articles', {
106
+ * filter: {
107
+ * status: 'published',
108
+ * author_id: '9'
109
+ * },
110
+ * include: ['publisher.country'],
111
+ * fields: {
112
+ * articles: 'title',
113
+ * publishers: 'name',
114
+ * countries: 'iso-code'
115
+ * },
116
+ * sort: ['title']
117
+ * });
118
+ * @see GET /api/articles?filter[status]=published&filter[author_id]=9&include=publisher.country&fields[articles]=title&fields[publishers]=name&fields[countries]=iso-code&sort=title
119
+ * // Example Return Value for complexArticles:
120
+ * // {
121
+ * // "data": [
122
+ * // {
123
+ * // "type": "articles",
124
+ * // "id": "1",
125
+ * // "attributes": { "title": "Advanced API Design" },
126
+ * // "relationships": {
127
+ * // "publisher": { "data": { "type": "publishers", "id": "pub-1" } }
128
+ * // }
129
+ * // },
130
+ * // {
131
+ * // "type": "articles",
132
+ * // "id": "2",
133
+ * // "attributes": { "title": "Understanding Filters" },
134
+ * // "relationships": {
135
+ * // "publisher": { "data": { "type": "publishers", "id": "pub-2" } }
136
+ * // }
137
+ * // }
138
+ * // ],
139
+ * // "included": [
140
+ * // { // The publisher resource
141
+ * // "type": "publishers", "id": "pub-1", "attributes": { "name": "Awesome Books Inc." },
142
+ * // "relationships": { // This relationship links to the country
143
+ * // "country": { "data": { "type": "countries", "id": "us" } }
144
+ * // }
145
+ * // },
146
+ * // { // The country resource, referenced by 'pub-1'
147
+ * // "type": "countries", "id": "us", "attributes": { "name": "United States", "iso-code": "US" }
148
+ * // },
149
+ * // { // Another publisher resource
150
+ * // "type": "publishers", "id": "pub-2", "attributes": { "name": "Global Publishers Ltd." },
151
+ * // "relationships": { // This relationship links to its country
152
+ * // "country": { "data": { "type": "countries", "id": "uk" } }
153
+ * // }
154
+ * // },
155
+ * // { // Another country resource, referenced by 'pub-2'
156
+ * // "type": "countries", "id": "uk", "attributes": { "name": "United Kingdom", "iso-code": "GB" }
157
+ * // }
158
+ * // ]
159
+ * // }
160
+ *
161
+ * @example
162
+ * // Example 3: Get articles by a specific author, with comments and their users.
163
+ * const authorArticles = await api.getCollection('articles', {
164
+ * filter: {
165
+ * author_id: '456'
166
+ * },
167
+ * include: ['comments.user'],
168
+ * fields: {
169
+ * articles: 'title',
170
+ * comments: 'body',
171
+ * users: 'username'
172
+ * },
173
+ * sort: ['created-at']
174
+ * });
175
+ * @see GET /api/articles?filter[author_id]=456&include=comments.user&fields[articles]=title&fields[comments]=body&fields[users]=username&sort=created-at
176
+ * // Example Return Value for authorArticles (truncated for brevity):
177
+ * // {
178
+ * // "data": [
179
+ * // {
180
+ * // "type": "articles", "id": "art-a",
181
+ * // "attributes": { "title": "Article A by Author 456" },
182
+ * // "relationships": { "comments": { "data": [{ "type": "comments", "id": "comment-1" }] } }
183
+ * // }
184
+ * // ],
185
+ * // "included": [
186
+ * // {
187
+ * // "type": "comments", "id": "comment-1", "attributes": { "body": "Great article!" },
188
+ * // "relationships": { "user": { "data": { "type": "users", "id": "user-a" } } }
189
+ * // },
190
+ * // { "type": "users", "id": "user-a", "attributes": { "username": "CommenterOne" } }
191
+ * // ]
192
+ * // }
193
+ */
194
+ addScopeMethod('query', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
195
+ // Make the method available to all hooks
196
+ context.method = 'query'
197
+
198
+ // Sanitise parameters
199
+ params.queryParams = params.queryParams || {}
200
+ params.queryParams.include = params.queryParams.include || []
201
+ params.queryParams.fields = params.queryParams.fields || {}
202
+ params.queryParams.filter = params.queryParams.filter || {}
203
+ params.queryParams.sort = params.queryParams.sort || []
204
+ params.queryParams.page = params.queryParams.page || {}
205
+
206
+ // Get scope-specific or global configuration
207
+ const sortableFields = scopeOptions.sortableFields || vars.sortableFields;
208
+ const defaultSort = scopeOptions.defaultSort || vars.defaultSort;
209
+ const idProperty = scopeOptions.idProperty || vars.idProperty;
210
+
211
+ // Apply default sort if no sort specified
212
+ if (params.queryParams.sort.length === 0 && defaultSort) {
213
+ params.queryParams.sort = Array.isArray(defaultSort) ? defaultSort : [defaultSort];
214
+ }
215
+
216
+ // Check payload with sortable fields validation
217
+ validateQueryPayload(params, sortableFields);
218
+
219
+ // Validate search/filter parameters against searchSchema
220
+ if (params.queryParams.filter && Object.keys(params.queryParams.filter).length > 0) {
221
+ // Use searchSchema if defined, otherwise fall back to the main schema
222
+ const schemaToValidate = scopeOptions.searchSchema || scopeOptions.schema;
223
+
224
+ if (schemaToValidate) {
225
+ // Create a schema instance for validation
226
+ const filterSchema = new CreateSchema(schemaToValidate);
227
+
228
+ // Validate the filter parameters
229
+ const { validatedObject, errors } = await filterSchema.validate(params.queryParams.filter, {
230
+ onlyObjectValues: true // Partial validation for filters
231
+ });
232
+
233
+ // If there are validation errors, throw an error
234
+ if (Object.keys(errors).length > 0) {
235
+ const violations = Object.entries(errors).map(([field, error]) => ({
236
+ field: `filter.${field}`,
237
+ rule: error.code || 'invalid_value',
238
+ message: error.message
239
+ }));
240
+
241
+ throw new RestApiValidationError(
242
+ 'Invalid filter parameters',
243
+ {
244
+ fields: Object.keys(errors).map(field => `filter.${field}`),
245
+ violations
246
+ }
247
+ );
248
+ }
249
+
250
+ // Replace filter with validated/transformed values
251
+ params.queryParams.filter = validatedObject;
252
+ }
253
+ }
254
+
255
+ runHooks('checkPermissions')
256
+ runHooks('checkPermissionsQuery')
257
+
258
+ runHooks('beforeData')
259
+ runHooks('beforeDataQuery')
260
+ context.record = await helpers.dataQuery({
261
+ scopeName,
262
+ queryParams: params.queryParams,
263
+ idProperty: vars.idProperty
264
+ })
265
+
266
+ // Make a backup
267
+ context.originalRecord = structuredClone(context.record)
268
+
269
+ // This will enhance record, which is the WHOLE JSON:API record
270
+ runHooks('enrichRecord')
271
+
272
+ // Run enrichAttributes for every single set of attribute, calling it from the right API
273
+ for (const entry of context.record.data) {
274
+ entry.attributes = await scope.enrichAttributes({attributes: entry.attributes, parentContext: context})
275
+ }
276
+ for (const entry of (context.record.included || [])) {
277
+ entry.attributes = await scopes[entry.type].enrichAttributes({attributes: entry.attributes, parentContext: context})
278
+ }
279
+
280
+ // The called hooks should NOT change context.record
281
+ runHooks('finish')
282
+ runHooks('finishQuery')
283
+ return context.record
284
+ })
285
+
286
+ /**
287
+ * GET
288
+ * Retrieves a single resource by its type and ID.
289
+ * @param {(string|number)} id - The unique ID of the resource to fetch.
290
+ * @param {object} [queryParams={}] - Optional. An object to customize the query.
291
+ * @param {string} [queryParams.include] - A comma-separated string of relationship paths to sideload (e.g., "authors,publisher").
292
+ * @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
293
+ * @returns {Promise<object>} A Promise that resolves to the JSON:API response document.
294
+ *
295
+ * @example
296
+ * // Example 1: Get a single article with its to-one 'author' relationship
297
+ * const articleResponse = await api.get('articles', '123', {
298
+ * include: 'author',
299
+ * fields: {
300
+ * articles: 'title,body,published-date',
301
+ * people: 'name'
302
+ * }
303
+ * });
304
+ * @see GET /api/articles/123?include=author&fields[articles]=title,body,published-date&fields[people]=name
305
+ * // Example Return Value for articleResponse:
306
+ * // {
307
+ * // "links": { "self": "/api/articles/123" },
308
+ * // "data": {
309
+ * // "type": "articles", "id": "123",
310
+ * // "attributes": { "title": "...", "body": "...", "published-date": "..." },
311
+ * // "relationships": { "author": { "data": { "type": "people", "id": "9" } } }
312
+ * // },
313
+ * // "included": [{ "type": "people", "id": "9", "attributes": { "name": "John Doe" } }]
314
+ * // }
315
+ *
316
+ * @example
317
+ * // Example 2: Get a single article with both to-many 'authors' and to-one 'publisher'
318
+ * const complexArticle = await api.get('articles', '123', {
319
+ * include: 'authors,publisher',
320
+ * fields: {
321
+ * articles: 'title',
322
+ * people: 'name',
323
+ * publishers: 'name,country-code'
324
+ * }
325
+ * });
326
+ * @see GET /api/articles/123?include=authors,publisher&fields[articles]=title&fields[people]=name&fields[publishers]=name,country-code
327
+ * // Example Return Value for complexArticle:
328
+ * // {
329
+ * // "links": { "self": "/api/articles/123" },
330
+ * // "data": {
331
+ * // "type": "articles",
332
+ * // "id": "123",
333
+ * // "attributes": { "title": "A Joint Effort" },
334
+ * // "relationships": {
335
+ * // "authors": {
336
+ * // "data": [
337
+ * // { "type": "people", "id": "9" },
338
+ * // { "type": "people", "id": "10" }
339
+ * // ]
340
+ * // },
341
+ * // "publisher": {
342
+ * // "data": { "type": "publishers", "id": "pub-1" }
343
+ * // }
344
+ * // }
345
+ * // },
346
+ * // "included": [
347
+ * // { "type": "people", "id": "9", "attributes": { "name": "John Doe" } },
348
+ * // { "type": "people", "id": "10", "attributes": { "name": "Richard Roe" } },
349
+ * // { "type": "publishers", "id": "pub-1", "attributes": { "name": "Awesome Books Inc.", "country-code": "US" } }
350
+ * // ]
351
+ * // }
352
+ */
353
+ addScopeMethod('get', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
354
+
355
+ // Make the method available to all hooks
356
+ context.method = 'get'
357
+
358
+
359
+ // Sanitise parameters
360
+ params.queryParams = params.queryParams || {}
361
+ params.queryParams.include = params.queryParams.include || []
362
+ params.queryParams.fields = params.queryParams.fields || {}
363
+
364
+ // Check payload
365
+ validateGetPayload(params);
366
+
367
+ runHooks('checkPermissions')
368
+ runHooks('checkPermissionsGet')
369
+
370
+ runHooks('beforeData')
371
+ runHooks('beforeDataGet')
372
+ context.record = await helpers.dataGet({
373
+ scopeName,
374
+ id: params.id,
375
+ queryParams: params.queryParams,
376
+ idProperty: vars.idProperty
377
+ })
378
+
379
+ // Check if record was found
380
+ if (!context.record || !context.record.data) {
381
+ throw new RestApiResourceError(
382
+ `Resource not found`,
383
+ {
384
+ subtype: 'not_found',
385
+ resourceType: scopeName,
386
+ resourceId: params.id
387
+ }
388
+ );
389
+ }
390
+
391
+ runHooks('checkDataPermissions')
392
+ runHooks('checkDataPermissionsGet')
393
+
394
+ // Make a backup
395
+ context.originalRecord = structuredClone(context.record)
396
+
397
+ // This will enhance record, which is the WHOLE JSON:API record
398
+ runHooks('enrichRecord')
399
+ context.record.data.attributes = await scope.enrichAttributes({attributes: context.record.data.attributes, parentContext: context})
400
+ for (const entry of (context.record.included || [])) {
401
+ entry.attributes = await scopes[entry.type].enrichAttributes({attributes: entry.attributes, parentContext: context})
402
+ }
403
+
404
+ // The called hooks should NOT change context.record
405
+ runHooks('finish')
406
+ runHooks('finishGet')
407
+ return context.record
408
+ })
409
+
410
+ /**
411
+ * POST
412
+ * Creates a new top-level resource. Can also create related resources in the same request, via data.included
413
+ *
414
+ * @param {object} inputRecord - The JSON:API document for the request. It must contain a `data` object for the primary resource. It can also include an `included` array for compound document creation.
415
+ * @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
416
+ * @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload in the response. These paths will be converted to a comma-separated string (e.g., `['author', 'comments.user']`).
417
+ * @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
418
+ * @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the newly created resource.
419
+ *
420
+ * @example
421
+ * // Case 1: Create a simple resource with only attributes.
422
+ * const simpleArticle = await api.post('articles', {
423
+ * "data": {
424
+ * "type": "articles",
425
+ * "attributes": {
426
+ * "title": "Standalone Post",
427
+ * "body": "This article has no relationships."
428
+ * }
429
+ * }
430
+ * });
431
+ * @see POST /api/articles
432
+ * // Example Return Value for simpleArticle:
433
+ * // {
434
+ * // "data": {
435
+ * // "type": "articles",
436
+ * // "id": "123",
437
+ * // "attributes": {
438
+ * // "title": "Standalone Post",
439
+ * // "body": "This article has no relationships."
440
+ * // }
441
+ * // }
442
+ * // }
443
+ *
444
+ * @example
445
+ * // Case 2: Create a resource and link it to MULTIPLE EXISTING relationships.
446
+ * const linkedArticle = await api.post('articles', {
447
+ * "data": {
448
+ * "type": "articles",
449
+ * "attributes": {
450
+ * "title": "Post by an Existing Team"
451
+ * },
452
+ * "relationships": {
453
+ * "authors": {
454
+ * "data": [
455
+ * { "type": "people", "id": "9" },
456
+ * { "type": "people", "id": "10" }
457
+ * ]
458
+ * },
459
+ * "publisher": {
460
+ * "data": { "type": "publishers", "id": "pub-1" }
461
+ * }
462
+ * }
463
+ * }
464
+ * });
465
+ * @see POST /api/articles
466
+ * // Example Return Value for linkedArticle:
467
+ * // {
468
+ * // "data": {
469
+ * // "type": "articles",
470
+ * // "id": "124",
471
+ * // "attributes": { "title": "Post by an Existing Team" },
472
+ * // "relationships": { ... }
473
+ * // }
474
+ * // }
475
+ * // There is NO 'included' in the response because all related resources already existed.
476
+ *
477
+ * @example
478
+ * // Case 3: Create a resource AND MULTIPLE NEW related resources (compound document).
479
+ * const compoundArticle = await api.post('articles', {
480
+ * "data": {
481
+ * "type": "articles",
482
+ * "attributes": {
483
+ * "title": "A New Team's First Post"
484
+ * },
485
+ * "relationships": {
486
+ * "authors": {
487
+ * "data": [
488
+ * { "type": "people", "id": "temp-author-1" },
489
+ * { "type": "people", "id": "temp-author-2" }
490
+ * ]
491
+ * },
492
+ * "publisher": {
493
+ * "data": { "type": "publishers", "id": "temp-pub-xyz" }
494
+ * }
495
+ * }
496
+ * },
497
+ * "included": [
498
+ * { "type": "people", "id": "temp-author-1", "attributes": { "name": "Jane Doe" } },
499
+ * { "type": "people", "id": "temp-author-2", "attributes": { "name": "Richard Roe" } },
500
+ * { "type": "publishers", "id": "temp-pub-xyz", "attributes": { "name": "Awesome Books Inc." } }
501
+ * ]
502
+ * });
503
+ * @see POST /api/articles
504
+ * // Example Return Value for compoundArticle:
505
+ * // {
506
+ * // "data": {
507
+ * // "type": "articles",
508
+ * // "id": "125",
509
+ * // "attributes": { ... },
510
+ * // "relationships": { ... }
511
+ * // },
512
+ * // "included": [
513
+ * // { "type": "people", "id": "11", ... },
514
+ * // { "type": "people", "id": "12", ... },
515
+ * // { "type": "publishers", "id": "55", ... }
516
+ * // ]
517
+ * // }
518
+ *
519
+ * @example
520
+ * // Case 4: Create a resource linking to both NEW and EXISTING resources (mixed).
521
+ * const mixedArticle = await api.post('articles', {
522
+ * "data": {
523
+ * "type": "articles",
524
+ * "attributes": {
525
+ * "title": "A New Member Joins the Team"
526
+ * },
527
+ * "relationships": {
528
+ * "authors": {
529
+ * "data": [
530
+ * { "type": "people", "id": "9" }, // Existing author
531
+ * { "type": "people", "id": "temp-author-3" } // New author
532
+ * ]
533
+ * }
534
+ * }
535
+ * },
536
+ * "included": [
537
+ * { "type": "people", "id": "temp-author-3", "attributes": { "name": "Sam Smith" } }
538
+ * ]
539
+ * });
540
+ * @see POST /api/articles
541
+ */
542
+
543
+ addScopeMethod('post', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
544
+
545
+ // Make the method available to all hooks
546
+ context.method = 'post'
547
+ context.scopeName = scopeName
548
+ context.params = params
549
+
550
+ // Run early hooks for pre-processing (e.g., file handling)
551
+ runHooks('beforeProcessing')
552
+ runHooks('beforeProcessingPost')
553
+
554
+ // Sanitise parameters and payload
555
+ params.queryParams = params.queryParams || {}
556
+ params.queryParams.fields = params.queryParams.fields || {}
557
+ params.queryParams.include = params.queryParams.include || []
558
+
559
+ // Place the record in the context
560
+ context.inputRecord = params.inputRecord
561
+ context.queryParams = params.queryParams
562
+
563
+ // Check payload with scope validation
564
+ validatePostPayload(params.inputRecord, scopes)
565
+
566
+ // Validate that the resource type matches the current scope
567
+ if (params.inputRecord.data.type !== scopeName) {
568
+ throw new RestApiValidationError(
569
+ `Resource type mismatch. Expected '${scopeName}' but got '${params.inputRecord.data.type}'`,
570
+ {
571
+ fields: ['data.type'],
572
+ violations: [{
573
+ field: 'data.type',
574
+ rule: 'resource_type_match',
575
+ message: `Resource type must be '${scopeName}'`
576
+ }]
577
+ }
578
+ );
579
+ }
580
+
581
+ // Create schema for validation
582
+ context.schema = CreateSchema(scopeOptions.insertSchema || scopeOptions.schema || {})
583
+
584
+ // Apply schema to the main attributes and to ALL of the included ones
585
+ runHooks('beforeSchemaValidate')
586
+ runHooks('beforeSchemaValidatePost')
587
+ // Validate main resource attributes
588
+ const { validatedObject: validatedAttrs, errors: mainErrors } = await context.schema.validate(context.inputRecord.data.attributes || {});
589
+ if (Object.keys(mainErrors).length > 0) {
590
+ const violations = Object.entries(mainErrors).map(([field, error]) => ({
591
+ field: `data.attributes.${field}`,
592
+ rule: error.code || 'invalid_value',
593
+ message: error.message
594
+ }));
595
+
596
+ throw new RestApiValidationError(
597
+ 'Schema validation failed for resource attributes',
598
+ {
599
+ fields: Object.keys(mainErrors).map(field => `data.attributes.${field}`),
600
+ violations
601
+ }
602
+ );
603
+ }
604
+ context.inputRecord.data.attributes = validatedAttrs;
605
+
606
+ // Validate included resources
607
+ context.schemas = {}
608
+ const includedResources = context.inputRecord.included || [];
609
+ for (let i = 0; i < includedResources.length; i++) {
610
+ const subInputRecord = includedResources[i];
611
+ const scopeConfig = scopes[subInputRecord.type];
612
+ // The validator already checked that the type exists, so scopeConfig should be valid
613
+ const schemaFromOptions = scopeConfig?.options?.insertSchema || scopeConfig?.options?.schema || {};
614
+ const schema = context.schemas[subInputRecord.type] = context.schemas[subInputRecord.type] || CreateSchema(schemaFromOptions);
615
+
616
+ const { validatedObject: subValidatedAttrs, errors: subErrors } = await schema.validate(subInputRecord.attributes || {});
617
+ if (Object.keys(subErrors).length > 0) {
618
+ const violations = Object.entries(subErrors).map(([field, error]) => ({
619
+ field: `included[${i}].attributes.${field}`,
620
+ rule: error.code || 'invalid_value',
621
+ message: error.message
622
+ }));
623
+
624
+ throw new RestApiValidationError(
625
+ `Schema validation failed for included resource of type '${subInputRecord.type}'`,
626
+ {
627
+ fields: Object.keys(subErrors).map(field => `included[${i}].attributes.${field}`),
628
+ violations
629
+ }
630
+ );
631
+ }
632
+ subInputRecord.attributes = subValidatedAttrs;
633
+ }
634
+ runHooks('afterSchemaValidatePost')
635
+ runHooks('afterSchemaValidate')
636
+
637
+ runHooks('checkPermissions')
638
+ runHooks('checkPermissionsPost')
639
+
640
+ runHooks('beforeDataCall')
641
+ runHooks('beforeDataCallPost')
642
+
643
+ // Create the record - storage helper should return the created record with its ID
644
+ context.record = await helpers.dataPost({
645
+ scopeName,
646
+ inputRecord: context.inputRecord,
647
+ idProperty: vars.idProperty
648
+ });
649
+
650
+ runHooks('afterDataCallPost')
651
+ runHooks('afterDataCall')
652
+
653
+ // Get the full record with relationships if requested
654
+ if (vars.returnFullRecord?.post !== false) {
655
+ context.returnRecord = await helpers.dataGet({
656
+ scopeName,
657
+ id: context.record.data.id,
658
+ queryParams: params.queryParams,
659
+ idProperty: vars.idProperty
660
+ });
661
+ } else {
662
+ context.returnRecord = context.record;
663
+ }
664
+
665
+ // Enrich the return record's attributes
666
+ if (context.returnRecord?.data?.attributes) {
667
+ context.returnRecord.data.attributes = await scope.enrichAttributes({
668
+ attributes: context.returnRecord.data.attributes,
669
+ parentContext: context
670
+ });
671
+ }
672
+
673
+ // Enrich included resources if any
674
+ for (const entry of (context.returnRecord?.included || [])) {
675
+ entry.attributes = await scopes[entry.type].enrichAttributes({
676
+ attributes: entry.attributes,
677
+ parentContext: context
678
+ });
679
+ }
680
+
681
+ runHooks('finish')
682
+ runHooks('finishPost')
683
+ return context.returnRecord
684
+ })
685
+
686
+ /**
687
+ * PUT
688
+ * Updates an existing top-level resource by completely replacing it.
689
+ * This method does NOT support creating new related resources via an `included` array.
690
+ *
691
+ * @param {string} id - The ID of the resource to update.
692
+ * @param {object} inputRecord - The JSON:API document for the request. It must contain a `data` object for the primary resource.
693
+ It CANNOT include an `included` array - all relationships must reference existing resources.
694
+ * @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
695
+ * @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload in the response. These paths
696
+ will be converted to a comma-separated string (e.g., `['author', 'comments.user']`).
697
+ * @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types,
698
+ values are comma-separated field names.
699
+ * @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the updated resource.
700
+ *
701
+ * @example
702
+ * // Case 1: Update a simple resource with only attributes.
703
+ * const updatedArticle = await api.put('articles', '123', {
704
+ * "data": {
705
+ * "type": "articles",
706
+ * "id": "123",
707
+ * "attributes": {
708
+ * "title": "Updated Title",
709
+ * "body": "This is the completely replaced content."
710
+ * }
711
+ * }
712
+ * });
713
+ * @see PUT /api/articles/123
714
+ * // Example Return Value for updatedArticle:
715
+ * // {
716
+ * // "data": {
717
+ * // "type": "articles",
718
+ * // "id": "123",
719
+ * // "attributes": {
720
+ * // "title": "Updated Title",
721
+ * // "body": "This is the completely replaced content."
722
+ * // }
723
+ * // }
724
+ * // }
725
+ *
726
+ * @example
727
+ * // Case 2: Replace a resource and update its relationships to EXISTING resources.
728
+ * const replacedArticle = await api.put('articles', '124', {
729
+ * "data": {
730
+ * "type": "articles",
731
+ * "id": "124",
732
+ * "attributes": {
733
+ * "title": "Completely New Title",
734
+ * "body": "Entirely new content"
735
+ * },
736
+ * "relationships": {
737
+ * "authors": {
738
+ * "data": [
739
+ * { "type": "people", "id": "15" },
740
+ * { "type": "people", "id": "16" }
741
+ * ]
742
+ * },
743
+ * "publisher": {
744
+ * "data": { "type": "publishers", "id": "pub-3" }
745
+ * }
746
+ * }
747
+ * }
748
+ * });
749
+ * @see PUT /api/articles/124
750
+ * // Example Return Value for replacedArticle:
751
+ * // {
752
+ * // "data": {
753
+ * // "type": "articles",
754
+ * // "id": "124",
755
+ * // "attributes": {
756
+ * // "title": "Completely New Title",
757
+ * // "body": "Entirely new content"
758
+ * // },
759
+ * // "relationships": {
760
+ * // "authors": {
761
+ * // "data": [
762
+ * // { "type": "people", "id": "15" },
763
+ * // { "type": "people", "id": "16" }
764
+ * // ]
765
+ * // },
766
+ * // "publisher": {
767
+ * // "data": { "type": "publishers", "id": "pub-3" }
768
+ * // }
769
+ * // }
770
+ * // }
771
+ * // }
772
+ *
773
+ * @example
774
+ * // Case 3: Replace a resource and include related resources in the response.
775
+ * const replacedWithIncluded = await api.put('articles', '125', {
776
+ * "data": {
777
+ * "type": "articles",
778
+ * "id": "125",
779
+ * "attributes": {
780
+ * "title": "Article with Author Included",
781
+ * "body": "Content that replaces the previous version"
782
+ * },
783
+ * "relationships": {
784
+ * "author": {
785
+ * "data": { "type": "people", "id": "20" }
786
+ * }
787
+ * }
788
+ * }
789
+ * }, {
790
+ * include: ['author'],
791
+ * fields: {
792
+ * articles: 'title,body',
793
+ * people: 'name,email'
794
+ * }
795
+ * });
796
+ * @see PUT /api/articles/125?include=author&fields[articles]=title,body&fields[people]=name,email
797
+ * // Example Return Value for replacedWithIncluded:
798
+ * // {
799
+ * // "data": {
800
+ * // "type": "articles",
801
+ * // "id": "125",
802
+ * // "attributes": {
803
+ * // "title": "Article with Author Included",
804
+ * // "body": "Content that replaces the previous version"
805
+ * // },
806
+ * // "relationships": {
807
+ * // "author": {
808
+ * // "data": { "type": "people", "id": "20" }
809
+ * // }
810
+ * // }
811
+ * // },
812
+ * // "included": [
813
+ * // {
814
+ * // "type": "people",
815
+ * // "id": "20",
816
+ * // "attributes": {
817
+ * // "name": "Alice Johnson",
818
+ * // "email": "alice@example.com"
819
+ * // }
820
+ * // }
821
+ * // ]
822
+ * // }
823
+ *
824
+ * @example
825
+ * // Case 4: Invalid - attempting to create new resources with PUT will fail.
826
+ * // This will throw an error because PUT cannot create new resources:
827
+ * try {
828
+ * await api.put('articles', '126', {
829
+ * "data": {
830
+ * "type": "articles",
831
+ * "id": "126",
832
+ * "attributes": {
833
+ * "title": "Invalid PUT"
834
+ * },
835
+ * "relationships": {
836
+ * "author": {
837
+ * "data": { "type": "people", "id": "temp-new-author" }
838
+ * }
839
+ * }
840
+ * },
841
+ * "included": [
842
+ * { "type": "people", "id": "temp-new-author", "attributes": { "name": "New Author" } }
843
+ * ]
844
+ * });
845
+ * } catch (error) {
846
+ * // Error: PUT requests cannot include an 'included' array for creating new resources
847
+ * }
848
+ */
849
+ addScopeMethod('put', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions,
850
+ scopeName }) => {
851
+
852
+ context.method = 'put'
853
+ context.scopeName = scopeName
854
+ context.params = params
855
+
856
+ // Run early hooks for pre-processing (e.g., file handling)
857
+ runHooks('beforeProcessing')
858
+ runHooks('beforeProcessingPut')
859
+
860
+ // Sanitise parameters
861
+ params.queryParams = params.queryParams || {}
862
+ params.queryParams.fields = params.queryParams.fields || {}
863
+ params.queryParams.include = params.queryParams.include || []
864
+
865
+ context.inputRecord = params.inputRecord
866
+ context.queryParams = params.queryParams
867
+ context.id = params.id
868
+
869
+ // Validate - PUT cannot have included
870
+ if (context.inputRecord.included) {
871
+ throw new RestApiPayloadError(
872
+ 'PUT requests cannot include an "included" array for creating new resources',
873
+ { path: 'included', expected: 'undefined', received: 'array' }
874
+ );
875
+ }
876
+
877
+ // Validate payload with scope validation
878
+ validatePutPayload(context.inputRecord, scopes)
879
+
880
+ // Validate that the resource type matches the current scope
881
+ if (context.inputRecord.data.type !== scopeName) {
882
+ throw new RestApiValidationError(
883
+ `Resource type mismatch. Expected '${scopeName}' but got '${context.inputRecord.data.type}'`,
884
+ {
885
+ fields: ['data.type'],
886
+ violations: [{
887
+ field: 'data.type',
888
+ rule: 'resource_type_match',
889
+ message: `Resource type must be '${scopeName}'`
890
+ }]
891
+ }
892
+ );
893
+ }
894
+
895
+ if (vars.loadRecordOnPut) {
896
+ context.recordBefore = await helpers.dataGet({
897
+ scopeName,
898
+ id: context.id,
899
+ idProperty: vars.idProperty
900
+ });
901
+
902
+ context.exists = !!context.recordBefore
903
+ } else {
904
+ // CHECK EXISTENCE FIRST - hooks need to know!
905
+ context.exists = await helpers.dataExists({
906
+ scopeName,
907
+ id: context.id,
908
+ idProperty: vars.idProperty
909
+ });
910
+ }
911
+ context.isCreate = !context.exists;
912
+ context.isUpdate = context.exists;
913
+
914
+ // Create schema based on operation type
915
+ context.schema = CreateSchema((context.isCreate ? scopeOptions.insertSchema : scopeOptions.updateSchema) || scopeOptions.schema || {})
916
+
917
+ // Schema validation can now be different for create vs update
918
+ runHooks('beforeSchemaValidate')
919
+ runHooks('beforeSchemaValidatePut')
920
+ runHooks(`beforeSchemaValidatePut${context.isCreate ? 'Create' : 'Update'}`)
921
+
922
+ const { validatedObject, errors } = await context.schema.validate(context.inputRecord.data.attributes || {});
923
+ if (Object.keys(errors).length > 0) {
924
+ const violations = Object.entries(errors).map(([field, error]) => ({
925
+ field: `data.attributes.${field}`,
926
+ rule: error.code || 'invalid_value',
927
+ message: error.message
928
+ }));
929
+
930
+ throw new RestApiValidationError(
931
+ 'Schema validation failed for resource attributes',
932
+ {
933
+ fields: Object.keys(errors).map(field => `data.attributes.${field}`),
934
+ violations
935
+ }
936
+ );
937
+ }
938
+ context.inputRecord.data.attributes = validatedObject;
939
+
940
+ runHooks('afterSchemaValidatePut')
941
+ runHooks('afterSchemaValidate')
942
+
943
+ // Permissions can now check differently
944
+ runHooks('checkPermissions')
945
+ runHooks('checkPermissionsPut')
946
+ runHooks(`checkPermissionsPut${context.isCreate ? 'Create' : 'Update'}`)
947
+
948
+ runHooks('beforeDataCall')
949
+ runHooks('beforeDataCallPut')
950
+ // Pass the operation type to the helper
951
+ context.record = await helpers.dataPut({
952
+ scopeName,
953
+ id: context.id,
954
+ schema: context.schema,
955
+ inputRecord: context.inputRecord,
956
+ isCreate: context.isCreate, // Helper knows what to do
957
+ idProperty: vars.idProperty
958
+ });
959
+ runHooks('afterDataCallPut')
960
+ runHooks('afterDataCall')
961
+
962
+ // If there was previous data, run a data permission check
963
+ if (context.recordBefore) {
964
+ runHooks('checkDataPermissions')
965
+ runHooks('checkDataPermissionsPut')
966
+ runHooks(`checkDataPermissionsPut${context.isCreate ? 'Create' : 'Update'}`)
967
+ }
968
+
969
+ // Get return record if needed
970
+ if (vars.returnFullRecord?.put !== false) {
971
+ context.returnRecord = await helpers.dataGet({
972
+ scopeName,
973
+ id: context.id,
974
+ queryParams: params.queryParams,
975
+ idProperty: vars.idProperty
976
+ });
977
+ } else {
978
+ context.returnRecord = context.record;
979
+ }
980
+
981
+ // Enrich the return record's attributes
982
+ if (context.returnRecord?.data?.attributes) {
983
+ context.returnRecord.data.attributes = await scope.enrichAttributes({
984
+ attributes: context.returnRecord.data.attributes,
985
+ parentContext: context
986
+ });
987
+ }
988
+
989
+ // Enrich included resources if any
990
+ for (const entry of (context.returnRecord?.included || [])) {
991
+ entry.attributes = await scopes[entry.type].enrichAttributes({
992
+ attributes: entry.attributes,
993
+ parentContext: context
994
+ });
995
+ }
996
+
997
+ runHooks('finish')
998
+ runHooks('finishPut')
999
+ return context.returnRecord
1000
+ })
1001
+
1002
+ /**
1003
+ * PATCH
1004
+ * Performs a partial update on an existing resource's attributes or relationships.
1005
+ * Unlike PUT, PATCH only updates the fields provided, leaving other fields unchanged.
1006
+ * Just like PUT, It CANNOT have the `included` in data
1007
+ *
1008
+ * @param {string} id - The ID of the resource to update.
1009
+ * @param {object} inputRecord - The JSON:API document for the request. It must contain a `data` object with partial updates. It CANNOT include an `included` array.
1010
+ * @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
1011
+ * @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload in the response. These paths will be converted to a comma-separated string (e.g., `['author', 'comments.user']`).
1012
+ * @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
1013
+ * @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the updated resource.
1014
+ *
1015
+ * @example
1016
+ * // Case 1: Update only specific attributes of a resource.
1017
+ * const patchedArticle = await api.patch('articles', '123', {
1018
+ * "data": {
1019
+ * "type": "articles",
1020
+ * "id": "123",
1021
+ * "attributes": {
1022
+ * "title": "Updated Title Only"
1023
+ * // Note: 'body' and other attributes remain unchanged
1024
+ * }
1025
+ * }
1026
+ * });
1027
+ * @see PATCH /api/articles/123
1028
+ * // Example Return Value for patchedArticle:
1029
+ * // {
1030
+ * // "data": {
1031
+ * // "type": "articles",
1032
+ * // "id": "123",
1033
+ * // "attributes": {
1034
+ * // "title": "Updated Title Only",
1035
+ * // "body": "Original body content remains",
1036
+ * // "published-date": "2024-01-01T00:00:00Z"
1037
+ * // }
1038
+ * // }
1039
+ * // }
1040
+ *
1041
+ * @example
1042
+ * // Case 2: Update attributes and relationships partially.
1043
+ * const patchedWithRelationships = await api.patch('articles', '124', {
1044
+ * "data": {
1045
+ * "type": "articles",
1046
+ * "id": "124",
1047
+ * "attributes": {
1048
+ * "status": "published"
1049
+ * // Other attributes like title, body remain unchanged
1050
+ * },
1051
+ * "relationships": {
1052
+ * "reviewer": {
1053
+ * "data": { "type": "people", "id": "30" }
1054
+ * }
1055
+ * // Other relationships like authors, publisher remain unchanged
1056
+ * }
1057
+ * }
1058
+ * });
1059
+ * @see PATCH /api/articles/124
1060
+ * // Example Return Value for patchedWithRelationships:
1061
+ * // {
1062
+ * // "data": {
1063
+ * // "type": "articles",
1064
+ * // "id": "124",
1065
+ * // "attributes": {
1066
+ * // "title": "Original Title",
1067
+ * // "body": "Original Content",
1068
+ * // "status": "published"
1069
+ * // },
1070
+ * // "relationships": {
1071
+ * // "authors": { "data": [{ "type": "people", "id": "9" }] },
1072
+ * // "reviewer": { "data": { "type": "people", "id": "30" } },
1073
+ * // "publisher": { "data": { "type": "publishers", "id": "pub-1" } }
1074
+ * // }
1075
+ * // }
1076
+ * // }
1077
+ *
1078
+ * @example
1079
+ * // Case 3: Patch with included resources in response.
1080
+ * const patchedWithIncluded = await api.patch('articles', '125', {
1081
+ * "data": {
1082
+ * "type": "articles",
1083
+ * "id": "125",
1084
+ * "attributes": {
1085
+ * "last-modified": "2024-07-01T12:00:00Z"
1086
+ * }
1087
+ * }
1088
+ * }, {
1089
+ * include: ['author', 'reviewer'],
1090
+ * fields: {
1091
+ * articles: 'title,last-modified',
1092
+ * people: 'name'
1093
+ * }
1094
+ * });
1095
+ * @see PATCH /api/articles/125?include=author,reviewer&fields[articles]=title,last-modified&fields[people]=name
1096
+ * // Example Return Value for patchedWithIncluded:
1097
+ * // {
1098
+ * // "data": {
1099
+ * // "type": "articles",
1100
+ * // "id": "125",
1101
+ * // "attributes": {
1102
+ * // "title": "Existing Article Title",
1103
+ * // "last-modified": "2024-07-01T12:00:00Z"
1104
+ * // },
1105
+ * // "relationships": {
1106
+ * // "author": { "data": { "type": "people", "id": "15" } },
1107
+ * // "reviewer": { "data": { "type": "people", "id": "20" } }
1108
+ * // }
1109
+ * // },
1110
+ * // "included": [
1111
+ * // { "type": "people", "id": "15", "attributes": { "name": "John Author" } },
1112
+ * // { "type": "people", "id": "20", "attributes": { "name": "Jane Reviewer" } }
1113
+ * // ]
1114
+ * // }
1115
+ *
1116
+ * @example
1117
+ * // Case 4: Clear a relationship using null.
1118
+ * const clearedRelationship = await api.patch('articles', '126', {
1119
+ * "data": {
1120
+ * "type": "articles",
1121
+ * "id": "126",
1122
+ * "relationships": {
1123
+ * "reviewer": {
1124
+ * "data": null // Clear the reviewer relationship
1125
+ * }
1126
+ * }
1127
+ * }
1128
+ * });
1129
+ * @see PATCH /api/articles/126
1130
+ * // Example Return Value for clearedRelationship:
1131
+ * // {
1132
+ * // "data": {
1133
+ * // "type": "articles",
1134
+ * // "id": "126",
1135
+ * // "attributes": { ... },
1136
+ * // "relationships": {
1137
+ * // "reviewer": { "data": null },
1138
+ * // "author": { "data": { "type": "people", "id": "5" } }
1139
+ * // }
1140
+ * // }
1141
+ * // }
1142
+ */
1143
+ addScopeMethod('patch', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
1144
+
1145
+ // Make the method available to all hooks
1146
+ context.method = 'patch'
1147
+ context.scopeName = scopeName
1148
+ context.params = params
1149
+
1150
+ // Run early hooks for pre-processing (e.g., file handling)
1151
+ runHooks('beforeProcessing')
1152
+ runHooks('beforeProcessingPatch')
1153
+
1154
+ // Sanitise parameters
1155
+ params.queryParams = params.queryParams || {}
1156
+ params.queryParams.fields = params.queryParams.fields || {}
1157
+ params.queryParams.include = params.queryParams.include || []
1158
+
1159
+ context.inputRecord = params.inputRecord
1160
+ context.queryParams = params.queryParams
1161
+ context.id = params.id
1162
+
1163
+ // Validate - PATCH cannot have included
1164
+ if (context.inputRecord.included) {
1165
+ throw new RestApiPayloadError(
1166
+ 'PATCH requests cannot include an "included" array for creating new resources',
1167
+ { path: 'included', expected: 'undefined', received: 'array' }
1168
+ );
1169
+ }
1170
+
1171
+ validatePatchPayload(params.inputRecord, scopes)
1172
+
1173
+ // Validate that the resource type matches the current scope
1174
+ if (params.inputRecord.data.type !== scopeName) {
1175
+ throw new RestApiValidationError(
1176
+ `Resource type mismatch. Expected '${scopeName}' but got '${params.inputRecord.data.type}'`,
1177
+ {
1178
+ fields: ['data.type'],
1179
+ violations: [{
1180
+ field: 'data.type',
1181
+ rule: 'resource_type_match',
1182
+ message: `Resource type must be '${scopeName}'`
1183
+ }]
1184
+ }
1185
+ );
1186
+ }
1187
+
1188
+ // Create schema for validation
1189
+ context.schema = CreateSchema(scopeOptions.updateSchema || scopeOptions.schema || {})
1190
+
1191
+ // Schema validation for partial updates
1192
+ runHooks('beforeSchemaValidate')
1193
+ runHooks('beforeSchemaValidatePatch')
1194
+
1195
+ // Validate only the provided attributes (partial validation)
1196
+ if (context.inputRecord.data.attributes) {
1197
+ const { validatedObject, errors } = await context.schema.validate(
1198
+ context.inputRecord.data.attributes,
1199
+ { onlyObjectValues: true }
1200
+ );
1201
+ if (Object.keys(errors).length > 0) {
1202
+ const violations = Object.entries(errors).map(([field, error]) => ({
1203
+ field: `data.attributes.${field}`,
1204
+ rule: error.code || 'invalid_value',
1205
+ message: error.message
1206
+ }));
1207
+
1208
+ throw new RestApiValidationError(
1209
+ 'Schema validation failed for resource attributes',
1210
+ {
1211
+ fields: Object.keys(errors).map(field => `data.attributes.${field}`),
1212
+ violations
1213
+ }
1214
+ );
1215
+ }
1216
+ context.inputRecord.data.attributes = validatedObject;
1217
+ }
1218
+
1219
+ runHooks('afterSchemaValidatePatch')
1220
+ runHooks('afterSchemaValidate')
1221
+
1222
+ // Permissions check
1223
+ runHooks('checkPermissions')
1224
+ runHooks('checkPermissionsPatch')
1225
+
1226
+ runHooks('beforeDataCall')
1227
+ runHooks('beforeDataCallPatch')
1228
+
1229
+ // Call the storage helper - should return the patched record
1230
+ context.record = await helpers.dataPatch({
1231
+ scopeName,
1232
+ id: context.id,
1233
+ inputRecord: context.inputRecord,
1234
+ idProperty: vars.idProperty
1235
+ });
1236
+
1237
+ runHooks('afterDataCallPatch')
1238
+ runHooks('afterDataCall')
1239
+
1240
+ runHooks('checkDataPermissions')
1241
+ runHooks('checkDataPermissionsPatch')
1242
+
1243
+ // Get return record if needed
1244
+ if (vars.returnFullRecord?.patch !== false) {
1245
+ context.returnRecord = await helpers.dataGet({
1246
+ scopeName,
1247
+ id: context.id,
1248
+ queryParams: params.queryParams,
1249
+ idProperty: vars.idProperty
1250
+ });
1251
+ } else {
1252
+ context.returnRecord = context.record;
1253
+ }
1254
+
1255
+ // Enrich the return record's attributes
1256
+ if (context.returnRecord?.data?.attributes) {
1257
+ context.returnRecord.data.attributes = await scope.enrichAttributes({
1258
+ attributes: context.returnRecord.data.attributes,
1259
+ parentContext: context
1260
+ });
1261
+ }
1262
+
1263
+ // Enrich included resources if any
1264
+ for (const entry of (context.returnRecord?.included || [])) {
1265
+ entry.attributes = await scopes[entry.type].enrichAttributes({
1266
+ attributes: entry.attributes,
1267
+ parentContext: context
1268
+ });
1269
+ }
1270
+
1271
+ runHooks('finish')
1272
+ runHooks('finishPatch')
1273
+ return context.returnRecord
1274
+ })
1275
+
1276
+ /**
1277
+ * DELETE
1278
+ * Permanently deletes a resource.
1279
+ *
1280
+ * @param {string} id - The ID of the resource to delete.
1281
+ * @returns {Promise<void>} A Promise that resolves when the deletion is complete. Typically returns no content (204).
1282
+ *
1283
+ * @example
1284
+ * // Case 1: Delete a simple resource
1285
+ * await api.scopes.articles.delete({ id: '123' });
1286
+ * @see DELETE /api/articles/123
1287
+ * // Returns: 204 No Content (no response body)
1288
+ *
1289
+ * @example
1290
+ * // Case 2: Delete with cascading relationships (handled by storage layer)
1291
+ * await api.scopes.authors.delete({ id: '456' });
1292
+ * @see DELETE /api/authors/456
1293
+ * // The storage layer may handle cascading deletes based on foreign key constraints
1294
+ * // Returns: 204 No Content
1295
+ *
1296
+ * @example
1297
+ * // Case 3: Attempting to delete non-existent resource
1298
+ * try {
1299
+ * await api.scopes.articles.delete({ id: 'non-existent' });
1300
+ * } catch (error) {
1301
+ * // Error: 404 Not Found - Resource does not exist
1302
+ * }
1303
+ */
1304
+ addScopeMethod('delete', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
1305
+ // Make the method available to all hooks
1306
+ context.method = 'delete'
1307
+
1308
+ // Set the ID in context
1309
+ context.id = params.id
1310
+
1311
+ // No payload validation needed for DELETE
1312
+ // No schema needed for DELETE
1313
+
1314
+ // Run permission checks
1315
+ runHooks('checkPermissions')
1316
+ runHooks('checkPermissionsDelete')
1317
+
1318
+ // Before data operations
1319
+ runHooks('beforeDataCall')
1320
+ runHooks('beforeDataCallDelete')
1321
+
1322
+ // Initialize record context for hooks
1323
+ context.record = {}
1324
+
1325
+ // Call the storage helper
1326
+ await helpers.dataDelete({
1327
+ scopeName,
1328
+ id: context.id,
1329
+ idProperty: vars.idProperty
1330
+ });
1331
+
1332
+ runHooks('afterDataCallDelete')
1333
+ runHooks('afterDataCall')
1334
+
1335
+ // No return record for DELETE (204 No Content)
1336
+
1337
+ runHooks('finish')
1338
+ runHooks('finishDelete')
1339
+
1340
+ // DELETE typically returns void/undefined (204 No Content)
1341
+ return;
1342
+ })
1343
+
1344
+
1345
+ // Define default storage helpers that throw errors
1346
+ helpers.dataExists = async function({ scopeName, id, idProperty }) {
1347
+ // Access scope configuration (example for storage plugin developers)
1348
+ const scope = this.scopes[scopeName];
1349
+ const schema = scope.schema;
1350
+ const relationships = scope.relationships;
1351
+ const tableName = schema.tableName || scopeName;
1352
+
1353
+ throw new Error(`No storage implementation for exists. Install a storage plugin.`);
1354
+ };
1355
+
1356
+ helpers.dataGet = async function({ scopeName, id, queryParams, idProperty }) {
1357
+ // Access scope configuration (example for storage plugin developers)
1358
+ const scope = this.scopes[scopeName];
1359
+ const schema = scope.schema;
1360
+ const relationships = scope.relationships;
1361
+ const tableName = schema.tableName || scopeName;
1362
+
1363
+ throw new Error(`No storage implementation for get. Install a storage plugin.`);
1364
+ };
1365
+
1366
+ helpers.dataQuery = async function({ scopeName, queryParams, idProperty }) {
1367
+ // Access scope configuration (example for storage plugin developers)
1368
+ const scope = this.scopes[scopeName];
1369
+ const schema = scope.schema;
1370
+ const relationships = scope.relationships;
1371
+ const tableName = schema.tableName || scopeName;
1372
+
1373
+ throw new Error(`No storage implementation for query. Install a storage plugin.`);
1374
+ };
1375
+
1376
+ helpers.dataPost = async function({ scopeName, inputRecord, idProperty }) {
1377
+ // Access scope configuration (example for storage plugin developers)
1378
+ const scope = this.scopes[scopeName];
1379
+ const schema = scope.schema;
1380
+ const relationships = scope.relationships;
1381
+ const tableName = schema.tableName || scopeName;
1382
+
1383
+ throw new Error(`No storage implementation for post. Install a storage plugin.`);
1384
+ };
1385
+
1386
+ helpers.dataPatch = async function({ scopeName, id, inputRecord, idProperty }) {
1387
+ // Access scope configuration (example for storage plugin developers)
1388
+ const scope = this.scopes[scopeName];
1389
+ const schema = scope.schema;
1390
+ const relationships = scope.relationships;
1391
+ const tableName = schema.tableName || scopeName;
1392
+
1393
+ throw new Error(`No storage implementation for patch. Install a storage plugin.`);
1394
+ };
1395
+
1396
+ helpers.dataPut = async function({ scopeName, id, schema, inputRecord, isCreate, idProperty }) {
1397
+ // Access scope configuration (example for storage plugin developers)
1398
+ const scope = this.scopes[scopeName];
1399
+ const schemaDefinition = scope.schema;
1400
+ const relationships = scope.relationships;
1401
+ const tableName = schemaDefinition.tableName || scopeName;
1402
+
1403
+ throw new Error(`No storage implementation for put. Install a storage plugin.`);
1404
+ };
1405
+
1406
+ helpers.dataDelete = async function({ scopeName, id, idProperty }) {
1407
+ // Access scope configuration (example for storage plugin developers)
1408
+ const scope = this.scopes[scopeName];
1409
+ const schema = scope.schema;
1410
+ const relationships = scope.relationships;
1411
+ const tableName = schema.tableName || scopeName;
1412
+
1413
+ throw new Error(`No storage implementation for delete. Install a storage plugin.`);
1414
+ };
1415
+
1416
+
1417
+
1418
+ }
1419
+ };