json-rest-api 0.1.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,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
|
+
};
|