json-rest-api 1.0.6 → 1.0.8
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/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +99 -33
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
|
@@ -1,67 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
import
|
|
24
|
-
import
|
|
1
|
+
import { validateRelationships } from './lib/querying-writing/scope-validations.js';
|
|
2
|
+
|
|
3
|
+
// Import hook functions
|
|
4
|
+
import compileResourceSchemas from './rest-api-plugin-hooks/compile-resource-schemas.js';
|
|
5
|
+
import validateIncludeConfigurations from './rest-api-plugin-hooks/validate-include-configurations.js';
|
|
6
|
+
import turnScopeInitIntoVars from './rest-api-plugin-hooks/turn-scope-init-into-vars.js';
|
|
7
|
+
import registerScopeRoutes from './rest-api-plugin-hooks/register-scope-routes.js';
|
|
8
|
+
import registerRelationshipRoutes from './rest-api-plugin-hooks/register-relationship-routes.js';
|
|
9
|
+
|
|
10
|
+
// Import method functions
|
|
11
|
+
import queryMethod from './rest-api-plugin-methods/query.js';
|
|
12
|
+
import getMethod from './rest-api-plugin-methods/get.js';
|
|
13
|
+
import postMethod from './rest-api-plugin-methods/post.js';
|
|
14
|
+
import putMethod from './rest-api-plugin-methods/put.js';
|
|
15
|
+
import patchMethod from './rest-api-plugin-methods/patch.js';
|
|
16
|
+
import deleteMethod from './rest-api-plugin-methods/delete.js';
|
|
17
|
+
import enrichAttributesMethod from './rest-api-plugin-methods/enrich-attributes.js';
|
|
18
|
+
import checkPermissionsMethod from './rest-api-plugin-methods/check-permissions.js';
|
|
19
|
+
import addRouteMethod from './rest-api-plugin-methods/add-route.js';
|
|
20
|
+
import { defaultDataHelpers } from './lib/querying-writing/default-data-helpers.js';
|
|
21
|
+
import { DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_INCLUDE_DEPTH_LIMIT } from './lib/querying-writing/knex-constants.js';
|
|
22
|
+
|
|
23
|
+
import getRelatedMethod from './rest-api-plugin-methods/get-related.js';
|
|
24
|
+
import postRelationshipMethod from './rest-api-plugin-methods/post-relationship.js';
|
|
25
|
+
import getRelationshipMethod from './rest-api-plugin-methods/get-relationship.js';
|
|
26
|
+
import patchRelationshipMethod from './rest-api-plugin-methods/patch-relationship.js';
|
|
27
|
+
import deleteRelationshipMethod from './rest-api-plugin-methods/delete-relationship.js';
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
const cascadeConfig = (settingName, sources, defaultValue) =>
|
|
28
|
-
sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Determines which simplified setting to use based on request context
|
|
32
|
-
* @param {Object} params - Request parameters
|
|
33
|
-
* @param {Object} scopeOptions - Scope-specific options
|
|
34
|
-
* @param {Object} vars - Plugin variables
|
|
35
|
-
* @returns {boolean} Whether to use simplified mode
|
|
36
|
-
*/
|
|
37
|
-
const getSimplifiedSetting = (params, scopeOptions, vars) => {
|
|
38
|
-
|
|
39
|
-
// If there was no params.inputRecord, then simplified must be true regardless
|
|
40
|
-
// The rest of this function is based on `params` actually being `params`,
|
|
41
|
-
// so it will be skipped
|
|
42
|
-
const configSimplified = cascadeConfig('simplified', [scopeOptions], false)
|
|
43
|
-
if (!params.inputRecord && !configSimplified) return true
|
|
44
|
-
|
|
45
|
-
// Check if this request comes from a transport (HTTP/Express)
|
|
46
|
-
const isTransport = params.isTransport === true;
|
|
47
|
-
|
|
48
|
-
// Determine which default to use
|
|
49
|
-
const defaultSetting = isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
50
|
-
|
|
51
|
-
// Allow override at request/scope level
|
|
52
|
-
return cascadeConfig('simplified', [params, scopeOptions], defaultSetting);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
30
|
export const RestApiPlugin = {
|
|
56
31
|
name: 'rest-api',
|
|
57
32
|
|
|
58
33
|
install({ helpers, addScopeMethod, addApiMethod, vars, addHook, runHooks, apiOptions, pluginOptions, api, setScopeAlias, scopes, log, on }) {
|
|
59
34
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
includeStack: true
|
|
64
|
-
});
|
|
35
|
+
// **************
|
|
36
|
+
// Initial setup
|
|
37
|
+
// **************
|
|
65
38
|
|
|
66
39
|
// Initialize the rest namespace for REST API functionality
|
|
67
40
|
api.rest = {};
|
|
@@ -69,110 +42,9 @@ export const RestApiPlugin = {
|
|
|
69
42
|
// Set up REST-friendly aliases
|
|
70
43
|
setScopeAlias('resources', 'addResource');
|
|
71
44
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// Example: For comments that can belong to posts or videos, it ensures commentable_type field
|
|
76
|
-
// exists, commentable_id field exists, and that 'posts' and 'videos' are registered scopes.
|
|
77
|
-
// Catches config errors early before any requests are made.
|
|
78
|
-
addHook('scope:added', 'validatePolymorphicRelationships', {}, validatePolymorphicRelationships);
|
|
79
|
-
|
|
80
|
-
// Listen for scope creation to compile schemas immediately.
|
|
81
|
-
// This ensures schemas are compiled and cached before any scope methods are called,
|
|
82
|
-
// making them available for queries and other operations that need schema information.
|
|
83
|
-
addHook('scope:added', 'compileResourceSchemas', {}, async ({context, scopes, runHooks}) => {
|
|
84
|
-
const scope = scopes[context.scopeName];
|
|
85
|
-
// Pass scopeOptions and vars from context since scope object structure is different
|
|
86
|
-
return compileSchemas({ ...scope, scopeOptions: context.scopeOptions, vars: context.vars }, { context: { scopeName: context.scopeName }, runHooks });
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Validate include configurations in relationships after schemas are compiled
|
|
90
|
-
addHook('scope:added', 'validateIncludeConfigurations', {}, async ({context, scopes}) => {
|
|
91
|
-
const { scopeName } = context;
|
|
92
|
-
const scope = scopes[scopeName];
|
|
93
|
-
const relationships = scope.vars.schemaInfo?.schemaRelationships;
|
|
94
|
-
|
|
95
|
-
if (!relationships) return;
|
|
96
|
-
|
|
97
|
-
// Check each relationship for include configuration
|
|
98
|
-
for (const [relName, relDef] of Object.entries(relationships)) {
|
|
99
|
-
if (relDef.include?.strategy === 'window') {
|
|
100
|
-
// This relationship requires window functions
|
|
101
|
-
// We'll validate this at query time since the database might not be connected yet
|
|
102
|
-
log.debug(`Relationship ${scopeName}.${relName} configured for window function includes`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Validate include configuration
|
|
106
|
-
if (relDef.include?.limit) {
|
|
107
|
-
if (typeof relDef.include.limit !== 'number') {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`Invalid include limit for ${scopeName}.${relName}: limit must be a number`
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
// Check against queryMaxLimit if available
|
|
113
|
-
const maxLimit = scope.vars?.queryMaxLimit;
|
|
114
|
-
if (maxLimit && relDef.include.limit > maxLimit) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`Invalid include limit for ${scopeName}.${relName}: ` +
|
|
117
|
-
`limit (${relDef.include.limit}) exceeds queryMaxLimit (${maxLimit})`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (relDef.include?.orderBy && !Array.isArray(relDef.include.orderBy)) {
|
|
123
|
-
throw new Error(
|
|
124
|
-
`Invalid include orderBy for ${scopeName}.${relName}: orderBy must be an array`
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Set scope variables based on the the passed options
|
|
132
|
-
// sortableFiels and defaultSort are always coming from options
|
|
133
|
-
// and queryDefaultLimit and queryMaxLimit will be set if passed -- if not, the `vars`
|
|
134
|
-
// proxy will point to the api's values (set as defaults)
|
|
135
|
-
addHook('scope:added', 'turnScopeInitIntoVars', {}, async ({context, scopes, vars: apiVars}) => {
|
|
136
|
-
// Refer to the scope's vars
|
|
137
|
-
const scope = scopes[context.scopeName];
|
|
138
|
-
const scopeOptions = scope?.scopeOptions || context.scopeOptions || {};
|
|
139
|
-
const vars = scope?.vars || apiVars
|
|
140
|
-
|
|
141
|
-
// The scope-specific ones
|
|
142
|
-
vars.sortableFields = scopeOptions.sortableFields || [];
|
|
143
|
-
vars.defaultSort = scopeOptions.defaultSort || null;
|
|
144
|
-
|
|
145
|
-
// The general ones that are also set at api level, but overrideable
|
|
146
|
-
if (typeof scopeOptions.queryDefaultLimit !== 'undefined') vars.queryDefaultLimit = scopeOptions.queryDefaultLimit
|
|
147
|
-
if (typeof scopeOptions.queryMaxLimit !== 'undefined') vars.queryMaxLimit = scopeOptions.queryMaxLimit
|
|
148
|
-
if (typeof scopeOptions.includeDepthLimit !== 'undefined') vars.includeDepthLimit = scopeOptions.includeDepthLimit
|
|
149
|
-
if (typeof scopeOptions.resourceUrlPrefix !== 'undefined') vars.resourceUrlPrefix = scopeOptions.resourceUrlPrefix
|
|
150
|
-
if (typeof scopeOptions.enablePaginationCounts !== 'undefined') vars.enablePaginationCounts = scopeOptions.enablePaginationCounts
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// Add validation for query limits
|
|
154
|
-
if (vars.queryDefaultLimit && vars.queryMaxLimit) {
|
|
155
|
-
if (vars.queryDefaultLimit > vars.queryMaxLimit) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`Invalid scope '${eventData.scopeName}' configuration: ` +
|
|
158
|
-
`queryDefaultLimit (${vars.queryDefaultLimit}) cannot exceed queryMaxLimit (${vars.queryMaxLimit})`
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Validate relationship include limits at scope creation time
|
|
164
|
-
Object.entries(scopeOptions.relationships || {}).forEach(([relName, relDef]) => {
|
|
165
|
-
if (relDef.include?.limit && vars.queryMaxLimit) {
|
|
166
|
-
if (relDef.include.limit > vars.queryMaxLimit) {
|
|
167
|
-
throw new Error(
|
|
168
|
-
`Invalid relationship '${eventData.scopeName}.${relName}' configuration: ` +
|
|
169
|
-
`include.limit (${relDef.include.limit}) cannot exceed queryMaxLimit (${vars.queryMaxLimit})`
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
})
|
|
175
|
-
// compileSchemas(eventData.scope, eventData.scopeName));
|
|
45
|
+
// **********
|
|
46
|
+
// Variables
|
|
47
|
+
// **********
|
|
176
48
|
|
|
177
49
|
// Initialize default vars for the plugin from pluginOptions
|
|
178
50
|
const restApiOptions = pluginOptions || {};
|
|
@@ -182,10 +54,9 @@ export const RestApiPlugin = {
|
|
|
182
54
|
vars.queryDefaultLimit = restApiOptions.queryDefaultLimit || DEFAULT_QUERY_LIMIT
|
|
183
55
|
vars.queryMaxLimit = restApiOptions.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT
|
|
184
56
|
vars.includeDepthLimit = restApiOptions.includeDepthLimit || DEFAULT_INCLUDE_DEPTH_LIMIT
|
|
185
|
-
vars.
|
|
57
|
+
vars.publicBaseUrl = restApiOptions.publicBaseUrl || ''
|
|
186
58
|
vars.enablePaginationCounts = restApiOptions.enablePaginationCounts || true
|
|
187
59
|
|
|
188
|
-
|
|
189
60
|
// New simplified settings
|
|
190
61
|
vars.simplifiedTransport = restApiOptions.simplifiedTransport !== undefined
|
|
191
62
|
? restApiOptions.simplifiedTransport
|
|
@@ -197,2362 +68,101 @@ export const RestApiPlugin = {
|
|
|
197
68
|
|
|
198
69
|
vars.idProperty = restApiOptions.idProperty || 'id'
|
|
199
70
|
|
|
200
|
-
// Return full record configuration
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
allowRemoteOverride: restApiOptions.returnFullRecord?.allowRemoteOverride ?? false
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
// Schema cache vars
|
|
209
|
-
vars.schemaProcessed = false;
|
|
210
|
-
vars.schema = null;
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* QUERY
|
|
214
|
-
* Retrieves a collection of resources (e.g., a list of articles) based on provided criteria.
|
|
215
|
-
* This function sends a GET request to /api/{resourceType}.
|
|
216
|
-
*
|
|
217
|
-
* @param {string} resourceType - The type of resource collection to fetch (e.g., "articles").
|
|
218
|
-
* @param {object} [queryParams={}] - Optional. An object to customize the query for the collection.
|
|
219
|
-
* @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").
|
|
220
|
-
* @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.
|
|
221
|
-
* @param {object} [queryParams.filters] - 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.
|
|
222
|
-
* @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`).
|
|
223
|
-
* @param {object} [queryParams.page] - An object for pagination. Typically includes `number` (page number) and `size` (items per page). E.g., `{ number: 1, size: 10 }`.
|
|
224
|
-
* @returns {Promise<object>} A Promise that resolves to the JSON:API response document containing the resource collection.
|
|
225
|
-
*
|
|
226
|
-
* @example
|
|
227
|
-
* // Example 1: Get a paginated list of articles, including their authors.
|
|
228
|
-
* const articlesResponse = await api.getCollection('articles', {
|
|
229
|
-
* include: ['author'],
|
|
230
|
-
* fields: {
|
|
231
|
-
* articles: 'title,published-date',
|
|
232
|
-
* people: 'name'
|
|
233
|
-
* },
|
|
234
|
-
* page: { number: 1, size: 2 },
|
|
235
|
-
* sort: ['-published-date']
|
|
236
|
-
* });
|
|
237
|
-
* @see GET /api/articles?include=author&fields[articles]=title,published-date&fields[people]=name&page[number]=1&page[size]=2&sort=-published-date
|
|
238
|
-
* // Example Return Value for articlesResponse:
|
|
239
|
-
* // {
|
|
240
|
-
* // "data": [
|
|
241
|
-
* // {
|
|
242
|
-
* // "type": "articles", "id": "123",
|
|
243
|
-
* // "attributes": { "title": "First Article", "published-date": "2024-07-01T10:00:00Z" },
|
|
244
|
-
* // "relationships": { "author": { "data": { "type": "people", "id": "9" } } }
|
|
245
|
-
* // },
|
|
246
|
-
* // {
|
|
247
|
-
* // "type": "articles", "id": "124",
|
|
248
|
-
* // "attributes": { "title": "Second Article", "published-date": "2024-06-30T15:30:00Z" },
|
|
249
|
-
* // "relationships": { "author": { "data": { "type": "people", "id": "10" } } }
|
|
250
|
-
* // }
|
|
251
|
-
* // ],
|
|
252
|
-
* // "included": [
|
|
253
|
-
* // { "type": "people", "id": "9", "attributes": { "name": "John Doe" } },
|
|
254
|
-
* // { "type": "people", "id": "10", "attributes": { "name": "Jane Smith" } }
|
|
255
|
-
* // ]
|
|
256
|
-
* // }
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* // Example 2: Get a filtered list of articles with deep relationships (publisher and its country).
|
|
260
|
-
* const complexArticles = await api.getCollection('articles', {
|
|
261
|
-
* filter: {
|
|
262
|
-
* status: 'published',
|
|
263
|
-
* author_id: '9'
|
|
264
|
-
* },
|
|
265
|
-
* include: ['publisher.country'],
|
|
266
|
-
* fields: {
|
|
267
|
-
* articles: 'title',
|
|
268
|
-
* publishers: 'name',
|
|
269
|
-
* countries: 'iso-code'
|
|
270
|
-
* },
|
|
271
|
-
* sort: ['title']
|
|
272
|
-
* });
|
|
273
|
-
* @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
|
|
274
|
-
* // Example Return Value for complexArticles:
|
|
275
|
-
* // {
|
|
276
|
-
* // "data": [
|
|
277
|
-
* // {
|
|
278
|
-
* // "type": "articles",
|
|
279
|
-
* // "id": "1",
|
|
280
|
-
* // "attributes": { "title": "Advanced API Design" },
|
|
281
|
-
* // "relationships": {
|
|
282
|
-
* // "publisher": { "data": { "type": "publishers", "id": "pub-1" } }
|
|
283
|
-
* // }
|
|
284
|
-
* // },
|
|
285
|
-
* // {
|
|
286
|
-
* // "type": "articles",
|
|
287
|
-
* // "id": "2",
|
|
288
|
-
* // "attributes": { "title": "Understanding Filters" },
|
|
289
|
-
* // "relationships": {
|
|
290
|
-
* // "publisher": { "data": { "type": "publishers", "id": "pub-2" } }
|
|
291
|
-
* // }
|
|
292
|
-
* // }
|
|
293
|
-
* // ],
|
|
294
|
-
* // "included": [
|
|
295
|
-
* // { // The publisher resource
|
|
296
|
-
* // "type": "publishers", "id": "pub-1", "attributes": { "name": "Awesome Books Inc." },
|
|
297
|
-
* // "relationships": { // This relationship links to the country
|
|
298
|
-
* // "country": { "data": { "type": "countries", "id": "us" } }
|
|
299
|
-
* // }
|
|
300
|
-
* // },
|
|
301
|
-
* // { // The country resource, referenced by 'pub-1'
|
|
302
|
-
* // "type": "countries", "id": "us", "attributes": { "name": "United States", "iso-code": "US" }
|
|
303
|
-
* // },
|
|
304
|
-
* // { // Another publisher resource
|
|
305
|
-
* // "type": "publishers", "id": "pub-2", "attributes": { "name": "Global Publishers Ltd." },
|
|
306
|
-
* // "relationships": { // This relationship links to its country
|
|
307
|
-
* // "country": { "data": { "type": "countries", "id": "uk" } }
|
|
308
|
-
* // }
|
|
309
|
-
* // },
|
|
310
|
-
* // { // Another country resource, referenced by 'pub-2'
|
|
311
|
-
* // "type": "countries", "id": "uk", "attributes": { "name": "United Kingdom", "iso-code": "GB" }
|
|
312
|
-
* // }
|
|
313
|
-
* // ]
|
|
314
|
-
* // }
|
|
315
|
-
*
|
|
316
|
-
* @example
|
|
317
|
-
* // Example 3: Get articles by a specific author, with comments and their users.
|
|
318
|
-
* const authorArticles = await api.getCollection('articles', {
|
|
319
|
-
* filters: {
|
|
320
|
-
* author_id: '456'
|
|
321
|
-
* },
|
|
322
|
-
* include: ['comments.user'],
|
|
323
|
-
* fields: {
|
|
324
|
-
* articles: 'title',
|
|
325
|
-
* comments: 'body',
|
|
326
|
-
* users: 'username'
|
|
327
|
-
* },
|
|
328
|
-
* sort: ['created-at']
|
|
329
|
-
* });
|
|
330
|
-
* @see GET /api/articles?filters[author_id]=456&include=comments.user&fields[articles]=title&fields[comments]=body&fields[users]=username&sort=created-at
|
|
331
|
-
* // Example Return Value for authorArticles (truncated for brevity):
|
|
332
|
-
* // {
|
|
333
|
-
* // "data": [
|
|
334
|
-
* // {
|
|
335
|
-
* // "type": "articles", "id": "art-a",
|
|
336
|
-
* // "attributes": { "title": "Article A by Author 456" },
|
|
337
|
-
* // "relationships": { "comments": { "data": [{ "type": "comments", "id": "comment-1" }] } }
|
|
338
|
-
* // }
|
|
339
|
-
* // ],
|
|
340
|
-
* // "included": [
|
|
341
|
-
* // {
|
|
342
|
-
* // "type": "comments", "id": "comment-1", "attributes": { "body": "Great article!" },
|
|
343
|
-
* // "relationships": { "user": { "data": { "type": "users", "id": "user-a" } } }
|
|
344
|
-
* // },
|
|
345
|
-
* // { "type": "users", "id": "user-a", "attributes": { "username": "CommenterOne" } }
|
|
346
|
-
* // ]
|
|
347
|
-
* // }
|
|
348
|
-
*/
|
|
349
|
-
addScopeMethod('query', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName, log }) => {
|
|
350
|
-
context.method = 'query'
|
|
351
|
-
|
|
352
|
-
const defaultSimplifiedSetting = params.isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
353
|
-
context.simplified = cascadeConfig('simplified', [params, scopeOptions], defaultSimplifiedSetting);
|
|
354
|
-
|
|
355
|
-
// Assign common context properties
|
|
356
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo; // This is the object variable created by compileSchemas
|
|
357
|
-
context.returnFullRecord = cascadeConfig('returnFullRecord', [params, scopeOptions, vars], false);
|
|
358
|
-
context.queryParams = params.queryParams || {};
|
|
359
|
-
|
|
360
|
-
// These only make sense as parameter per query
|
|
361
|
-
context.queryParams.fields = cascadeConfig('fields', [params.queryParams], {});
|
|
362
|
-
context.queryParams.include = cascadeConfig('include', [params.queryParams], []);
|
|
363
|
-
context.queryParams.sort = cascadeConfig('sort', [params.queryParams], [])
|
|
364
|
-
context.queryParams.page = cascadeConfig('page', [params.queryParams], {})
|
|
365
|
-
|
|
366
|
-
context.transaction = params.transaction;
|
|
367
|
-
context.db = context.transaction || api.knex.instance;
|
|
368
|
-
|
|
369
|
-
context.scopeName = scopeName;
|
|
370
|
-
|
|
371
|
-
// These are just shortcuts used in this function and will be returned
|
|
372
|
-
const searchSchema = context.schemaInfo.searchSchema;
|
|
373
|
-
const schemaStructure = context.schemaInfo.schema.structure;
|
|
374
|
-
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
375
|
-
|
|
376
|
-
// Sortable fields and sort (mab)
|
|
377
|
-
context.sortableFields = vars.sortableFields
|
|
378
|
-
// Apply default sort if no sort specified
|
|
379
|
-
if (context.queryParams.sort.length === 0 && vars.defaultSort) {
|
|
380
|
-
context.queryParams.sort = Array.isArray(vars.defaultSort) ? vars.defaultSort : [vars.defaultSort];
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Validate query parameters to ensure they follow JSON:API specification and security rules.
|
|
384
|
-
// This checks that filters are valid field names, sort fields exist in sortableFields array
|
|
385
|
-
// (preventing SQL injection), pagination uses valid page[size]/page[number] format, and include
|
|
386
|
-
// paths reference real relationships. Example: sort: ['-createdAt', 'title'] is checked against
|
|
387
|
-
// sortableFields to ensure users can't sort by sensitive fields like 'password_hash'.
|
|
388
|
-
validateQueryPayload({ queryParams: context.queryParams }, context.sortableFields, vars.includeDepthLimit);
|
|
389
|
-
|
|
390
|
-
// Validate search/filter parameters against searchSchema
|
|
391
|
-
if (context.queryParams.filters && Object.keys(context.queryParams.filters).length > 0) {
|
|
392
|
-
// Only allow filtering if searchSchema is defined
|
|
393
|
-
if (!searchSchema) {
|
|
394
|
-
throw new RestApiValidationError(
|
|
395
|
-
`Filtering is not enabled for resource '${scopeName}'. To enable filtering, add 'search: true' to schema fields or define a searchSchema.`,
|
|
396
|
-
{
|
|
397
|
-
fields: Object.keys(context.queryParams.filters).map(field => `filters.${field}`),
|
|
398
|
-
violations: [{
|
|
399
|
-
field: 'filters',
|
|
400
|
-
rule: 'filtering_not_enabled',
|
|
401
|
-
message: 'Resource does not have searchable fields defined'
|
|
402
|
-
}]
|
|
403
|
-
}
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Validate the filter parameters searchSchema
|
|
408
|
-
const { validatedObject, errors } = await searchSchema.validate(context.queryParams.filters, {
|
|
409
|
-
onlyObjectValues: true // Partial validation for filters
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// If there are validation errors, throw an error
|
|
413
|
-
if (Object.keys(errors).length > 0) {
|
|
414
|
-
const violations = Object.entries(errors).map(([field, error]) => ({
|
|
415
|
-
field: `filters.${field}`,
|
|
416
|
-
rule: error.code || 'invalid_value',
|
|
417
|
-
message: error.message
|
|
418
|
-
}));
|
|
419
|
-
|
|
420
|
-
throw new RestApiValidationError(
|
|
421
|
-
'Invalid filter parameters',
|
|
422
|
-
{
|
|
423
|
-
fields: Object.keys(errors).map(field => `filters.${field}`),
|
|
424
|
-
violations
|
|
425
|
-
}
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Replace filter with validated/transformed values
|
|
430
|
-
context.queryParams.filters = validatedObject;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Centralised checkPermissions function
|
|
434
|
-
await scope.checkPermissions({
|
|
435
|
-
method: 'query',
|
|
436
|
-
auth: context.auth,
|
|
437
|
-
transaction: context.transaction
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
await runHooks ('beforeData')
|
|
441
|
-
await runHooks ('beforeDataQuery')
|
|
442
|
-
context.record = await helpers.dataQuery({
|
|
443
|
-
scopeName,
|
|
444
|
-
context,
|
|
445
|
-
runHooks
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
// Normalize database values (e.g., convert 1/0 to true/false for booleans)
|
|
449
|
-
context.record = normalizeRecordAttributes(context.record, scopes);
|
|
450
|
-
|
|
451
|
-
// Make a backup
|
|
452
|
-
try {
|
|
453
|
-
context.originalRecord = structuredClone(context.record)
|
|
454
|
-
} catch (e) {
|
|
455
|
-
log.error('Failed to clone record:', {
|
|
456
|
-
error: e.message,
|
|
457
|
-
recordKeys: Object.keys(context.record || {}),
|
|
458
|
-
hasHttpRequest: !!context.raw?.req
|
|
459
|
-
});
|
|
460
|
-
throw e;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// This will enhance record, which is the WHOLE JSON:API record
|
|
464
|
-
await runHooks ('enrichRecord')
|
|
465
|
-
|
|
466
|
-
// Get computed field information for main resource
|
|
467
|
-
const computedFields = scope.vars.schemaInfo?.computed || {};
|
|
468
|
-
const requestedFields = context.queryParams.fields?.[scopeName];
|
|
469
|
-
const requestedComputedFields = getRequestedComputedFields(scopeName, requestedFields, computedFields);
|
|
470
|
-
|
|
471
|
-
// Run enrichAttributes for every single set of attribute, calling it from the right scope
|
|
472
|
-
for (const entry of context.record.data) {
|
|
473
|
-
entry.attributes = await scope.enrichAttributes({
|
|
474
|
-
attributes: entry.attributes,
|
|
475
|
-
parentContext: context,
|
|
476
|
-
requestedComputedFields: requestedComputedFields,
|
|
477
|
-
isMainResource: true,
|
|
478
|
-
computedDependencies: context.computedDependencies
|
|
479
|
-
})
|
|
480
|
-
}
|
|
481
|
-
for (const entry of (context.record.included || [])) {
|
|
482
|
-
const entryScope = scopes[entry.type];
|
|
483
|
-
const entryComputed = entryScope.vars.schemaInfo?.computed || {};
|
|
484
|
-
const entryRequestedFields = context.queryParams.fields?.[entry.type];
|
|
485
|
-
const entryRequestedComputed = getRequestedComputedFields(
|
|
486
|
-
entry.type,
|
|
487
|
-
entryRequestedFields,
|
|
488
|
-
entryComputed
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
entry.attributes = await entryScope.enrichAttributes({
|
|
492
|
-
attributes: entry.attributes,
|
|
493
|
-
parentContext: context,
|
|
494
|
-
requestedComputedFields: entryRequestedComputed,
|
|
495
|
-
isMainResource: false
|
|
496
|
-
})
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// The called hooks should NOT change context.record
|
|
500
|
-
await runHooks ('finish')
|
|
501
|
-
await runHooks ('finishQuery')
|
|
502
|
-
|
|
503
|
-
// Transform output if in simplified mode
|
|
504
|
-
if (context.simplified) {
|
|
505
|
-
// Convert JSON:API response back to simplified format
|
|
506
|
-
// Example: {data: {type: 'posts', id: '1', attributes: {title: 'My Post'}, relationships: {author: {data: {type: 'users', id: '123'}}}}}
|
|
507
|
-
// becomes: {id: '1', title: 'My Post', author_id: '123'} - flattens structure and restores foreign keys
|
|
508
|
-
return transformJsonApiToSimplified(
|
|
509
|
-
{ record: context.record },
|
|
510
|
-
{ context: { schemaStructure, schemaRelationships } }
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
return context.record
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* GET
|
|
519
|
-
* Retrieves a single resource by its type and ID.
|
|
520
|
-
* @param {(string|number)} id - The unique ID of the resource to fetch.
|
|
521
|
-
* @param {object} [queryParams={}] - Optional. An object to customize the query.
|
|
522
|
-
* @param {string} [queryParams.include] - A comma-separated string of relationship paths to sideload (e.g., "authors,publisher").
|
|
523
|
-
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
|
|
524
|
-
* @returns {Promise<object>} A Promise that resolves to the JSON:API response document.
|
|
525
|
-
*
|
|
526
|
-
* @example
|
|
527
|
-
* // Example 1: Get a single article with its to-one 'author' relationship
|
|
528
|
-
* const articleResponse = await api.get('articles', '123', {
|
|
529
|
-
* include: 'author',
|
|
530
|
-
* fields: {
|
|
531
|
-
* articles: 'title,body,published-date',
|
|
532
|
-
* people: 'name'
|
|
533
|
-
* }
|
|
534
|
-
* });
|
|
535
|
-
* @see GET /api/articles/123?include=author&fields[articles]=title,body,published-date&fields[people]=name
|
|
536
|
-
* // Example Return Value for articleResponse:
|
|
537
|
-
* // {
|
|
538
|
-
* // "links": { "self": "/api/articles/123" },
|
|
539
|
-
* // "data": {
|
|
540
|
-
* // "type": "articles", "id": "123",
|
|
541
|
-
* // "attributes": { "title": "...", "body": "...", "published-date": "..." },
|
|
542
|
-
* // "relationships": { "author": { "data": { "type": "people", "id": "9" } } }
|
|
543
|
-
* // },
|
|
544
|
-
* // "included": [{ "type": "people", "id": "9", "attributes": { "name": "John Doe" } }]
|
|
545
|
-
* // }
|
|
546
|
-
*
|
|
547
|
-
* @example
|
|
548
|
-
* // Example 2: Get a single article with both to-many 'authors' and to-one 'publisher'
|
|
549
|
-
* const complexArticle = await api.get('articles', '123', {
|
|
550
|
-
* include: 'authors,publisher',
|
|
551
|
-
* fields: {
|
|
552
|
-
* articles: 'title',
|
|
553
|
-
* people: 'name',
|
|
554
|
-
* publishers: 'name,country-code'
|
|
555
|
-
* }
|
|
556
|
-
* });
|
|
557
|
-
* @see GET /api/articles/123?include=authors,publisher&fields[articles]=title&fields[people]=name&fields[publishers]=name,country-code
|
|
558
|
-
* // Example Return Value for complexArticle:
|
|
559
|
-
* // {
|
|
560
|
-
* // "links": { "self": "/api/articles/123" },
|
|
561
|
-
* // "data": {
|
|
562
|
-
* // "type": "articles",
|
|
563
|
-
* // "id": "123",
|
|
564
|
-
* // "attributes": { "title": "A Joint Effort" },
|
|
565
|
-
* // "relationships": {
|
|
566
|
-
* // "authors": {
|
|
567
|
-
* // "data": [
|
|
568
|
-
* // { "type": "people", "id": "9" },
|
|
569
|
-
* // { "type": "people", "id": "10" }
|
|
570
|
-
* // ]
|
|
571
|
-
* // },
|
|
572
|
-
* // "publisher": {
|
|
573
|
-
* // "data": { "type": "publishers", "id": "pub-1" }
|
|
574
|
-
* // }
|
|
575
|
-
* // }
|
|
576
|
-
* // },
|
|
577
|
-
* // "included": [
|
|
578
|
-
* // { "type": "people", "id": "9", "attributes": { "name": "John Doe" } },
|
|
579
|
-
* // { "type": "people", "id": "10", "attributes": { "name": "Richard Roe" } },
|
|
580
|
-
* // { "type": "publishers", "id": "pub-1", "attributes": { "name": "Awesome Books Inc.", "country-code": "US" } }
|
|
581
|
-
* // ]
|
|
582
|
-
* // }
|
|
583
|
-
*/
|
|
584
|
-
addScopeMethod('get', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
|
|
585
|
-
context.method = 'get'
|
|
586
|
-
|
|
587
|
-
const defaultSimplifiedSetting = params.isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
588
|
-
context.simplified = cascadeConfig('simplified', [params, scopeOptions], defaultSimplifiedSetting);
|
|
589
|
-
|
|
590
|
-
// Assign common context properties
|
|
591
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
592
|
-
context.returnFullRecord = cascadeConfig('returnFullRecord', [context.params, scopeOptions, vars], false);
|
|
593
|
-
context.queryParams = params.queryParams || {};
|
|
594
|
-
|
|
595
|
-
// These only make sense as parameter per query
|
|
596
|
-
context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {});
|
|
597
|
-
context.queryParams.include = cascadeConfig('include', [context.queryParams], []);
|
|
598
|
-
|
|
599
|
-
context.transaction = params.transaction;
|
|
600
|
-
context.db = context.transaction || api.knex.instance;
|
|
601
|
-
|
|
602
|
-
context.scopeName = scopeName;
|
|
603
|
-
|
|
604
|
-
// These are just shortcuts used in this function and will be returned
|
|
605
|
-
// const schema = context.schemaInfo.schema; // Not needed
|
|
606
|
-
const schemaStructure = context.schemaInfo.schema.structure;
|
|
607
|
-
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
608
|
-
|
|
609
|
-
context.id = params.id;
|
|
610
|
-
|
|
611
|
-
// Validate GET request to ensure required parameters are present and properly formatted.
|
|
612
|
-
// This checks that 'id' parameter exists and is not empty (you can't GET without an ID),
|
|
613
|
-
// validates 'include' contains valid relationship names (not arbitrary fields), and ensures
|
|
614
|
-
// 'fields' for sparse fieldsets follow the format fields[type]=comma,separated,list.
|
|
615
|
-
// Example: validates id: '123' exists, include: ['author', 'tags'] are real relationships.
|
|
616
|
-
validateGetPayload({ id: context.id, queryParams: context.queryParams }, vars.includeDepthLimit);
|
|
617
|
-
|
|
618
|
-
// Fetch minimal record for authorization checks
|
|
619
|
-
const minimalRecord = await helpers.dataGetMinimal({
|
|
620
|
-
scopeName,
|
|
621
|
-
context,
|
|
622
|
-
runHooks
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
if (!minimalRecord) {
|
|
626
|
-
throw new RestApiResourceError(
|
|
627
|
-
`Resource not found`,
|
|
628
|
-
{
|
|
629
|
-
subtype: 'not_found',
|
|
630
|
-
resourceType: scopeName,
|
|
631
|
-
resourceId: context.id
|
|
632
|
-
}
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
context.minimalRecord = minimalRecord;
|
|
637
|
-
|
|
638
|
-
// Centralised checkPermissions function
|
|
639
|
-
await scope.checkPermissions({
|
|
640
|
-
method: 'get',
|
|
641
|
-
auth: context.auth,
|
|
642
|
-
id: context.id,
|
|
643
|
-
minimalRecord: context.minimalRecord,
|
|
644
|
-
transaction: context.transaction
|
|
645
|
-
})
|
|
646
|
-
|
|
647
|
-
await runHooks('beforeData')
|
|
648
|
-
await runHooks('beforeDataGet')
|
|
649
|
-
|
|
650
|
-
context.record = await helpers.dataGet({
|
|
651
|
-
scopeName,
|
|
652
|
-
context,
|
|
653
|
-
runHooks
|
|
654
|
-
})
|
|
655
|
-
|
|
656
|
-
// Check if record was found - storage layer returns null/undefined for non-existent records.
|
|
657
|
-
// This generates a proper 404 error with JSON:API error format instead of returning empty data.
|
|
658
|
-
if (!context.record || !context.record.data) {
|
|
659
|
-
throw new RestApiResourceError(
|
|
660
|
-
`Resource not found`,
|
|
661
|
-
{
|
|
662
|
-
subtype: 'not_found',
|
|
663
|
-
resourceType: scopeName,
|
|
664
|
-
resourceId: context.id
|
|
665
|
-
}
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Normalize database values (e.g., convert 1/0 to true/false for booleans)
|
|
670
|
-
context.record = normalizeRecordAttributes(context.record, scopes);
|
|
671
|
-
|
|
672
|
-
await runHooks ('checkDataPermissions')
|
|
673
|
-
await runHooks ('checkDataPermissionsGet')
|
|
674
|
-
|
|
675
|
-
// Make a backup
|
|
676
|
-
context.originalRecord = structuredClone(context.record)
|
|
677
|
-
|
|
678
|
-
// This will enhance record, which is the WHOLE JSON:API record
|
|
679
|
-
await runHooks ('enrichRecord')
|
|
680
|
-
|
|
681
|
-
// Get computed field information for main resource
|
|
682
|
-
const computedFields = scope.vars.schemaInfo?.computed || {};
|
|
683
|
-
const requestedFields = context.queryParams.fields?.[scopeName];
|
|
684
|
-
const requestedComputedFields = getRequestedComputedFields(scopeName, requestedFields, computedFields);
|
|
685
|
-
|
|
686
|
-
// Enrich attributes for the main resource
|
|
687
|
-
// Pass computedDependencies from context (set by dataGet in Knex plugin)
|
|
688
|
-
// This tells enrichAttributes which fields to remove after computation
|
|
689
|
-
context.record.data.attributes = await scope.enrichAttributes({
|
|
690
|
-
attributes: context.record.data.attributes,
|
|
691
|
-
parentContext: context,
|
|
692
|
-
requestedComputedFields: requestedComputedFields,
|
|
693
|
-
isMainResource: true,
|
|
694
|
-
computedDependencies: context.computedDependencies // Fields to remove if not requested
|
|
695
|
-
})
|
|
696
|
-
for (const entry of (context.record.included || [])) {
|
|
697
|
-
const entryScope = scopes[entry.type];
|
|
698
|
-
const entryComputed = entryScope.vars.schemaInfo?.computed || {};
|
|
699
|
-
const entryRequestedFields = context.queryParams.fields?.[entry.type];
|
|
700
|
-
const entryRequestedComputed = getRequestedComputedFields(
|
|
701
|
-
entry.type,
|
|
702
|
-
entryRequestedFields,
|
|
703
|
-
entryComputed
|
|
704
|
-
);
|
|
705
|
-
|
|
706
|
-
entry.attributes = await entryScope.enrichAttributes({
|
|
707
|
-
attributes: entry.attributes,
|
|
708
|
-
parentContext: context,
|
|
709
|
-
requestedComputedFields: entryRequestedComputed,
|
|
710
|
-
isMainResource: false
|
|
711
|
-
})
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
await runHooks ('enrichRecordWithRelationships')
|
|
715
|
-
|
|
716
|
-
// The called hooks should NOT change context.record
|
|
717
|
-
await runHooks ('finish')
|
|
718
|
-
await runHooks ('finishGet')
|
|
719
|
-
|
|
720
|
-
// Get schema info for transformation
|
|
721
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo
|
|
722
|
-
|
|
723
|
-
// Transform output if in simplified mode
|
|
724
|
-
if (context.simplified) {
|
|
725
|
-
// Convert JSON:API response back to simplified format
|
|
726
|
-
// Example: {data: {type: 'posts', id: '1', attributes: {title: 'My Post'}, relationships: {author: {data: {type: 'users', id: '123'}}}}}
|
|
727
|
-
// becomes: {id: '1', title: 'My Post', author_id: '123'} - flattens structure and restores foreign keys
|
|
728
|
-
return transformJsonApiToSimplified(
|
|
729
|
-
{ record: context.record },
|
|
730
|
-
{ context: { schemaStructure, schemaRelationships } }
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return context.record
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Performs common initial setup for POST, PUT, and PATCH methods.
|
|
740
|
-
* This includes setting up context, handling simplified mode,
|
|
741
|
-
* and extracting schema-related information.
|
|
742
|
-
*
|
|
743
|
-
* @param {object} args - The arguments object passed to the addScopeMethod function.
|
|
744
|
-
* @param {object} args.params - Request parameters.
|
|
745
|
-
* @param {object} args.context - The operation context object.
|
|
746
|
-
* @param {object} args.vars - Variables accessible to the scope.
|
|
747
|
-
* @param {object} args.scopes - All available scopes/resource definitions.
|
|
748
|
-
* @param {object} args.scopeOptions - Options specific to the current scope.
|
|
749
|
-
* @param {string} args.scopeName - The name of the current scope.
|
|
750
|
-
* @returns {object} An object containing schema-related shortcuts.
|
|
751
|
-
*/
|
|
752
|
-
async function setupCommonRequest({ params, context, vars, scopes, scopeOptions, scopeName }) {
|
|
753
|
-
// Get configuration values using new helper
|
|
754
|
-
context.simplified = getSimplifiedSetting(params, scopeOptions, vars);
|
|
755
|
-
|
|
756
|
-
// Params is totally bypassed in simplified mode
|
|
757
|
-
if (context.simplified) {
|
|
758
|
-
if (params.inputRecord) {
|
|
759
|
-
context.inputRecord = params.inputRecord;
|
|
760
|
-
} else {
|
|
761
|
-
context.inputRecord = params;
|
|
762
|
-
context.params = {}; // Ensure params is an empty object if simplified
|
|
763
|
-
}
|
|
764
|
-
} else {
|
|
765
|
-
context.inputRecord = params.inputRecord;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// Assign common context properties
|
|
769
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
770
|
-
context.returnFullRecord = cascadeConfig('returnFullRecord', [context.params, scopeOptions, vars], false);
|
|
771
|
-
|
|
772
|
-
// These only make sense as parameter per query, not in vars etc.
|
|
773
|
-
context.queryParams = params.queryParams || {};
|
|
774
|
-
context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {});
|
|
775
|
-
context.queryParams.include = cascadeConfig('include', [context.queryParams], []);
|
|
776
|
-
|
|
777
|
-
context.scopeName = scopeName;
|
|
778
|
-
|
|
779
|
-
// Transaction handling
|
|
780
|
-
context.transaction = params.transaction ||
|
|
781
|
-
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
782
|
-
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
783
|
-
context.db = context.transaction || api.knex.instance;
|
|
784
|
-
|
|
785
|
-
// These are just shortcuts used in this function and will be returned
|
|
786
|
-
const schema = context.schemaInfo.schema;
|
|
787
|
-
const schemaStructure = context.schemaInfo.schema.structure;
|
|
788
|
-
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
789
|
-
|
|
790
|
-
// Transform input if in simplified mode
|
|
791
|
-
if (context.simplified) {
|
|
792
|
-
context.inputRecord = transformSimplifiedToJsonApi(
|
|
793
|
-
{ inputRecord: context.inputRecord },
|
|
794
|
-
{ context: { scopeName, schemaStructure, schemaRelationships } }
|
|
795
|
-
);
|
|
796
|
-
} else {
|
|
797
|
-
// Strict mode: validate no belongsTo fields in attributes
|
|
798
|
-
if (context.inputRecord?.data?.attributes) {
|
|
799
|
-
for (const [key, fieldDef] of Object.entries(schemaStructure)) {
|
|
800
|
-
if (fieldDef.belongsTo && key in context.inputRecord.data.attributes) {
|
|
801
|
-
throw new RestApiValidationError(
|
|
802
|
-
`Field '${key}' is a foreign key and must be set via relationships, not attributes`,
|
|
803
|
-
{ fields: [`data.attributes.${key}`] }
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
if (!context.inputRecord) debugger
|
|
811
|
-
if (context.inputRecord.data.type !== scopeName) {
|
|
812
|
-
throw new RestApiValidationError(
|
|
813
|
-
`Resource type mismatch. Expected '${scopeName}' but got '${context.inputRecord.data.type}'`,
|
|
814
|
-
{
|
|
815
|
-
fields: ['data.type'],
|
|
816
|
-
violations: [{
|
|
817
|
-
field: 'data.type',
|
|
818
|
-
rule: 'resource_type_match',
|
|
819
|
-
message: `Resource type must be '${scopeName}'`
|
|
820
|
-
}]
|
|
821
|
-
}
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Remove included validation since JSON:API doesn't support it
|
|
826
|
-
if (context.inputRecord.included) {
|
|
827
|
-
throw new RestApiPayloadError(
|
|
828
|
-
context.method + ' requests cannot include an "included" array. JSON:API does not support creating multiple resources in a single request.',
|
|
829
|
-
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// If both URL path ID and request body ID are provided, they must match
|
|
834
|
-
// Convert both to strings for comparison since databases may return numeric IDs
|
|
835
|
-
if (context.id && context.inputRecord.data.id && String(context.id) !== String(context.inputRecord.data.id)) {
|
|
836
|
-
throw new RestApiValidationError(
|
|
837
|
-
`ID mismatch. URL path ID '${context.id}' does not match request body ID '${context.inputRecord.data.id}'`,
|
|
838
|
-
{
|
|
839
|
-
fields: ['data.id'],
|
|
840
|
-
violations: [{
|
|
841
|
-
field: 'data.id',
|
|
842
|
-
rule: 'id_consistency',
|
|
843
|
-
message: `Request body ID must match URL path ID when both are provided`
|
|
844
|
-
}]
|
|
845
|
-
}
|
|
846
|
-
);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
// Return key schema-related objects for direct use in the main methods
|
|
852
|
-
return { schema, schemaStructure, schemaRelationships };
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* Common helper functions for REST API methods to reduce code duplication
|
|
857
|
-
*/
|
|
858
|
-
|
|
859
|
-
/**
|
|
860
|
-
* Handles error cleanup and logging for write methods (POST, PUT, PATCH)
|
|
861
|
-
*
|
|
862
|
-
* @param {Error} error - The error that was caught
|
|
863
|
-
* @param {Object} context - The request context
|
|
864
|
-
* @param {string} method - The HTTP method name (POST, PUT, PATCH)
|
|
865
|
-
* @param {string} scopeName - The name of the resource scope
|
|
866
|
-
* @param {Object} enhancedLog - The enhanced logger instance
|
|
867
|
-
* @throws {Error} Re-throws the original error after cleanup
|
|
868
|
-
*/
|
|
869
|
-
const handleWriteMethodError = async (error, context, method, scopeName, enhancedLog) => {
|
|
870
|
-
// Rollback transaction if we created it
|
|
871
|
-
if (context.shouldCommit) {
|
|
872
|
-
await context.transaction.rollback();
|
|
873
|
-
await runHooks('afterRollback');
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Log the full error details
|
|
877
|
-
enhancedLog.logError(`Error in ${method} method`, error, {
|
|
878
|
-
scopeName,
|
|
879
|
-
method: method.toLowerCase(),
|
|
880
|
-
inputRecord: context.inputRecord
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
throw error;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Validates that a pivot resource exists for many-to-many relationships
|
|
888
|
-
*
|
|
889
|
-
* @param {Object} scopes - All available scopes/resources
|
|
890
|
-
* @param {Object} relDef - The relationship definition
|
|
891
|
-
* @param {string} relName - The relationship name
|
|
892
|
-
* @throws {RestApiValidationError} If the pivot resource doesn't exist
|
|
893
|
-
*/
|
|
894
|
-
const validatePivotResource = (scopes, relDef, relName) => {
|
|
895
|
-
if (!scopes[relDef.through]) {
|
|
896
|
-
throw new RestApiValidationError(
|
|
897
|
-
`Pivot resource '${relDef.through}' not found for relationship '${relName}'`,
|
|
898
|
-
{
|
|
899
|
-
fields: [`relationships.${relName}`],
|
|
900
|
-
violations: [{
|
|
901
|
-
field: `relationships.${relName}`,
|
|
902
|
-
rule: 'missing_pivot_resource',
|
|
903
|
-
message: `Pivot resource '${relDef.through}' must be defined`
|
|
904
|
-
}]
|
|
905
|
-
}
|
|
906
|
-
);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const getMethodHookSuffix = (method) => {
|
|
913
|
-
if (!method) {
|
|
914
|
-
return ''; // Or throw an error, depending on desired strictness
|
|
915
|
-
}
|
|
916
|
-
return method.charAt(0).toUpperCase() + method.slice(1);
|
|
71
|
+
// Return full record configuration for API and Transport
|
|
72
|
+
// Support values: 'no', 'minimal', 'full'
|
|
73
|
+
const normalizeReturnValue = (value, defaultValue) => {
|
|
74
|
+
if (['no', 'minimal', 'full'].includes(value)) return value;
|
|
75
|
+
return defaultValue;
|
|
917
76
|
};
|
|
918
77
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
|
|
940
|
-
|
|
941
|
-
await runHooks ('beforeSchemaValidate');
|
|
942
|
-
await runHooks (`beforeSchemaValidate${methodSpecificHookSuffix}`);
|
|
943
|
-
|
|
944
|
-
// Store original input attributes before validation adds defaults (primarily for POST)
|
|
945
|
-
// Only if it's not already set and the current method is POST
|
|
946
|
-
if (!context.originalInputAttributes && context.method === 'post') {
|
|
947
|
-
context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) };
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Get virtual fields from schema info
|
|
951
|
-
const virtualFields = context.schemaInfo?.virtual || {};
|
|
952
|
-
|
|
953
|
-
// Separate virtual fields from regular attributes
|
|
954
|
-
const inputAttributes = context.inputRecord.data.attributes || {};
|
|
955
|
-
const virtualFieldValues = {};
|
|
956
|
-
const attributesForValidation = {};
|
|
957
|
-
|
|
958
|
-
// Split attributes into virtual and regular
|
|
959
|
-
Object.entries(inputAttributes).forEach(([key, value]) => {
|
|
960
|
-
if (key in virtualFields) {
|
|
961
|
-
virtualFieldValues[key] = value;
|
|
962
|
-
} else {
|
|
963
|
-
attributesForValidation[key] = value;
|
|
964
|
-
}
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
// Store virtual fields in context for later use
|
|
968
|
-
context.virtualFieldValues = virtualFieldValues;
|
|
969
|
-
|
|
970
|
-
// Merge belongsTo updates with non-virtual attributes for validation
|
|
971
|
-
const attributesToValidate = {
|
|
972
|
-
...attributesForValidation,
|
|
973
|
-
...belongsToUpdates
|
|
974
|
-
};
|
|
975
|
-
|
|
976
|
-
const validationOptions = isPartialValidation ? { onlyObjectValues: true } : {};
|
|
977
|
-
|
|
978
|
-
const { validatedObject, errors } = await schema.validate(attributesToValidate, validationOptions);
|
|
979
|
-
|
|
980
|
-
if (Object.keys(errors).length > 0) {
|
|
981
|
-
// --- START OF MODIFICATION ---
|
|
982
|
-
const schemaStructure = context.schemaInfo.schema.structure; // Get the schema structure for lookup
|
|
983
|
-
|
|
984
|
-
const violations = Object.entries(errors).map(([field, error]) => {
|
|
985
|
-
let fieldPath = `data.attributes.${field}`; // Default path for attributes
|
|
986
|
-
|
|
987
|
-
// Check if this field is a foreign key that has an 'as' alias
|
|
988
|
-
const fieldDef = schemaStructure[field];
|
|
989
|
-
if (fieldDef && (fieldDef.belongsTo || fieldDef.belongsToPolymorphic) && fieldDef.as) {
|
|
990
|
-
// If it's a belongsTo field with an alias, rewrite the path to the relationship alias
|
|
991
|
-
fieldPath = `data.relationships.${fieldDef.as}.data.id`;
|
|
992
|
-
}
|
|
993
|
-
// For many-to-many relationships, the original `transformSimplifiedToJsonApi`
|
|
994
|
-
// already puts them under `relationships.relName.data`, so `field` here
|
|
995
|
-
// would already be the relationship name, not a foreign key.
|
|
996
|
-
// However, if a validation error somehow slips through for a pivot table field
|
|
997
|
-
// that doesn't have an 'as' alias but is a foreign key, you might need
|
|
998
|
-
// more sophisticated mapping. For now, this covers belongsTo.
|
|
999
|
-
|
|
1000
|
-
return {
|
|
1001
|
-
field: fieldPath,
|
|
1002
|
-
rule: error.code || 'invalid_value',
|
|
1003
|
-
message: error.message
|
|
78
|
+
const optionConfigs = [
|
|
79
|
+
{
|
|
80
|
+
propName: 'returnRecordApi',
|
|
81
|
+
defaultValue: 'full',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
propName: 'returnRecordTransport',
|
|
85
|
+
defaultValue: 'no',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const config of optionConfigs) {
|
|
90
|
+
const optionValue = restApiOptions[config.propName];
|
|
91
|
+
let processedValue;
|
|
92
|
+
|
|
93
|
+
if (typeof optionValue === 'object' && optionValue !== null) {
|
|
94
|
+
processedValue = {
|
|
95
|
+
post: normalizeReturnValue(optionValue.post, config.defaultValue),
|
|
96
|
+
put: normalizeReturnValue(optionValue.put, config.defaultValue),
|
|
97
|
+
patch: normalizeReturnValue(optionValue.patch, config.defaultValue),
|
|
1004
98
|
};
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
{
|
|
1011
|
-
fields: violations.map(v => v.field), // Use the potentially rewritten fields
|
|
1012
|
-
violations
|
|
1013
|
-
}
|
|
1014
|
-
);
|
|
99
|
+
} else if (optionValue !== undefined) {
|
|
100
|
+
const normalized = normalizeReturnValue(optionValue, config.defaultValue);
|
|
101
|
+
processedValue = { post: normalized, put: normalized, patch: normalized };
|
|
102
|
+
} else {
|
|
103
|
+
processedValue = { post: config.defaultValue, put: config.defaultValue, patch: config.defaultValue };
|
|
1015
104
|
}
|
|
1016
|
-
|
|
1017
|
-
// Combine validated attributes with virtual fields
|
|
1018
|
-
context.inputRecord.data.attributes = {
|
|
1019
|
-
...validatedObject,
|
|
1020
|
-
...virtualFieldValues
|
|
1021
|
-
};
|
|
1022
|
-
|
|
1023
|
-
await runHooks (`afterSchemaValidate${methodSpecificHookSuffix}`);
|
|
1024
|
-
await runHooks ('afterSchemaValidate');
|
|
1025
|
-
};
|
|
1026
|
-
|
|
1027
|
-
/**
|
|
1028
|
-
* Validates that the user has read access to all related resources in relationships.
|
|
1029
|
-
* This ensures users can only create relationships to resources they can access.
|
|
1030
|
-
*
|
|
1031
|
-
* @param {object} context - The context object from addScopeMethod
|
|
1032
|
-
* @param {object} inputRecord - The JSON:API input record with relationships
|
|
1033
|
-
* @param {object} helpers - Data helpers including dataGetMinimal
|
|
1034
|
-
* @param {function} runHooks - Function to run hooks
|
|
1035
|
-
* @param {object} api - API instance to access resources
|
|
1036
|
-
* @throws {Error} If user doesn't have access to any related resource
|
|
1037
|
-
*/
|
|
1038
|
-
const validateRelationshipAccess = async (context, inputRecord) => {
|
|
1039
|
-
if (!inputRecord?.data?.relationships) return;
|
|
1040
|
-
|
|
1041
|
-
for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
|
|
1042
|
-
if (!relData?.data) continue;
|
|
1043
|
-
|
|
1044
|
-
// Handle both single and array relationships
|
|
1045
|
-
const relatedItems = Array.isArray(relData.data) ? relData.data : [relData.data];
|
|
1046
|
-
|
|
1047
|
-
for (const item of relatedItems) {
|
|
1048
|
-
// Get the scope for the related resource
|
|
1049
|
-
const relatedScope = api.resources[item.type];
|
|
1050
|
-
if (!relatedScope) {
|
|
1051
|
-
throw new Error(`Unknown resource type: ${item.type}`);
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// Create context for dataGetMinimal
|
|
1055
|
-
const getContext = {
|
|
1056
|
-
...context,
|
|
1057
|
-
id: item.id,
|
|
1058
|
-
schemaInfo: relatedScope.vars.schemaInfo,
|
|
1059
|
-
scopeName: item.type,
|
|
1060
|
-
method: 'get', // We're checking read permission
|
|
1061
|
-
isUpdate: false
|
|
1062
|
-
};
|
|
1063
|
-
|
|
1064
|
-
// Get the minimal record
|
|
1065
|
-
const record = await helpers.dataGetMinimal({
|
|
1066
|
-
scopeName: item.type,
|
|
1067
|
-
context: getContext,
|
|
1068
|
-
runHooks
|
|
1069
|
-
});
|
|
1070
|
-
|
|
1071
|
-
if (!record) {
|
|
1072
|
-
throw new Error(`Related ${item.type} with id ${item.id} not found`);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// Now check permissions on the related record
|
|
1076
|
-
getContext.minimalRecord = record;
|
|
1077
|
-
await relatedScope.checkPermissions({
|
|
1078
|
-
method: 'get',
|
|
1079
|
-
auth: getContext.auth,
|
|
1080
|
-
id: item.id,
|
|
1081
|
-
minimalRecord: record,
|
|
1082
|
-
transaction: getContext.transaction
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
};
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
// Function: handleRecordReturn
|
|
1090
|
-
// Location: You should place this in a common utility file that can be imported
|
|
1091
|
-
// by your API scope methods (post, put, patch).
|
|
1092
|
-
// A good place might be in './lib/commonResponseHelpers.js' or similar,
|
|
1093
|
-
// and then import it into the main plugin file where your addScopeMethods are defined.
|
|
1094
|
-
|
|
1095
|
-
async function handleRecordReturnAfterWrite({
|
|
1096
|
-
context,
|
|
1097
|
-
scopeName,
|
|
1098
|
-
api,
|
|
1099
|
-
scopes, // Required because api.resources[scopeName].get() uses 'scopes' internally.
|
|
1100
|
-
schemaStructure,
|
|
1101
|
-
schemaRelationships,
|
|
1102
|
-
scopeOptions, // Used for cascadeConfig
|
|
1103
|
-
vars, // Used for cascadeConfig
|
|
1104
|
-
runHooks, // Used to run finish hooks
|
|
1105
|
-
helpers, // Used to fetch minimal records
|
|
1106
|
-
enhancedLog // Used for logging warnings
|
|
1107
|
-
}) {
|
|
1108
|
-
const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
|
|
1109
|
-
|
|
1110
|
-
// Set up originalMinimalRecord and minimalRecord for hooks
|
|
1111
|
-
// Based on the method type and operation
|
|
1112
|
-
if (context.method === 'DELETE') {
|
|
1113
|
-
// For DELETE, rename existing minimalRecord to originalMinimalRecord
|
|
1114
|
-
// and keep minimalRecord as the same (deleted record)
|
|
1115
|
-
if (context.minimalRecord) {
|
|
1116
|
-
context.originalMinimalRecord = context.minimalRecord;
|
|
1117
|
-
// minimalRecord stays the same for DELETE
|
|
1118
|
-
}
|
|
1119
|
-
} else {
|
|
1120
|
-
// For POST, PUT, PATCH - handle record state
|
|
1121
|
-
if (context.minimalRecord) {
|
|
1122
|
-
// This means we have a pre-existing record (PUT update or PATCH)
|
|
1123
|
-
context.originalMinimalRecord = context.minimalRecord;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
// Fetch the current/created record for POST, PUT, PATCH
|
|
1127
|
-
try {
|
|
1128
|
-
const currentRecord = await helpers.dataGetMinimal({
|
|
1129
|
-
scopeName,
|
|
1130
|
-
context,
|
|
1131
|
-
runHooks
|
|
1132
|
-
});
|
|
1133
|
-
context.minimalRecord = currentRecord;
|
|
1134
|
-
} catch (error) {
|
|
1135
|
-
// If we can't fetch the record, keep the existing minimalRecord
|
|
1136
|
-
// This handles edge cases where the record might not exist
|
|
1137
|
-
enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id });
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// Determine if we should return the full record based on configurations.
|
|
1142
|
-
const shouldReturnRecord = context.returnFullRecord[context.method]
|
|
1143
|
-
|
|
1144
|
-
if (shouldReturnRecord) {
|
|
1145
|
-
// If a full record is requested, use the API's own 'get' method.
|
|
1146
|
-
// The 'get' method itself is responsible for fetching the data,
|
|
1147
|
-
// applying its own enrichment hooks, and formatting to JSON:API.
|
|
1148
|
-
// Therefore, NO further enrichment is needed here.
|
|
1149
|
-
context.returnRecord = await api.resources[scopeName].get({
|
|
1150
|
-
id: context.id, // context.id is now reliably set by POST, PUT, and PATCH
|
|
1151
|
-
queryParams: context.queryParams,
|
|
1152
|
-
transaction: context.transaction,
|
|
1153
|
-
simplified: false // Request the full JSON:API format from GET for internal processing
|
|
1154
|
-
}, {...context });
|
|
1155
|
-
|
|
1156
|
-
// If the 'get' method returns null/undefined (e.g., resource was deleted between write and read),
|
|
1157
|
-
// we might still need to handle a potential 404 or just return undefined.
|
|
1158
|
-
// For this specific flow (POST/PUT/PATCH response), the record *should* exist.
|
|
1159
|
-
// If it doesn't, it implies a deeper issue or a race condition.
|
|
1160
|
-
if (!context.returnRecord) {
|
|
1161
|
-
// Decide how to handle this edge case. Returning undefined is consistent with
|
|
1162
|
-
// the 'else' branch if nothing is supposed to be returned.
|
|
1163
|
-
// Or you could throw an error if a record *must* be returned.
|
|
1164
|
-
context.returnRecord = undefined;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
} else {
|
|
1168
|
-
// If 'shouldReturnRecord' is false, return minimal response with just the ID
|
|
1169
|
-
// Always return in simplified format for consistency - transport can use result.id
|
|
1170
|
-
context.returnRecord = { id: context.id };
|
|
1171
|
-
// Early return to skip transformation logic below
|
|
1172
|
-
await runHooks('finish');
|
|
1173
|
-
await runHooks(`finish${methodSpecificHookSuffix}`);
|
|
1174
|
-
return context.returnRecord;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Run common finish hooks.
|
|
1178
|
-
await runHooks('finish');
|
|
1179
|
-
// Dynamically call method-specific finish hooks (e.g., 'finishPost', 'finishPut', 'finishPatch').
|
|
1180
|
-
await runHooks(`finish${methodSpecificHookSuffix}`);
|
|
1181
|
-
|
|
1182
|
-
// Transform output if in simplified mode and there is a record to transform.
|
|
1183
|
-
if (context.simplified && context.returnRecord) {
|
|
1184
|
-
// Ensure we are transforming the 'returnRecord' which comes from the 'get' call,
|
|
1185
|
-
// as it contains the fully enriched and structured JSON:API data.
|
|
1186
|
-
return transformJsonApiToSimplified(
|
|
1187
|
-
{ record: context.returnRecord },
|
|
1188
|
-
{ context: { schemaStructure, schemaRelationships } }
|
|
1189
|
-
);
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// Return the final record (will be undefined if shouldReturnRecord was false).
|
|
1193
|
-
return context.returnRecord;
|
|
105
|
+
vars[config.propName] = processedValue;
|
|
1194
106
|
}
|
|
1195
107
|
|
|
108
|
+
log.debug('returnRecordApi configuration:', vars.returnRecordApi);
|
|
109
|
+
log.debug('returnRecordTransport configuration:', vars.returnRecordTransport);
|
|
1196
110
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
*
|
|
1201
|
-
* This method follows the JSON:API specification for resource creation. It supports:
|
|
1202
|
-
* - Creating a resource with attributes only
|
|
1203
|
-
* - Creating a resource with one-to-one relationships (belongsTo) to existing resources
|
|
1204
|
-
* - Creating a resource with many-to-many relationships to existing resources
|
|
1205
|
-
*
|
|
1206
|
-
* NOTE: This method does NOT support creating multiple resources in a single request.
|
|
1207
|
-
* The JSON:API specification does not define 'included' for POST requests.
|
|
1208
|
-
*
|
|
1209
|
-
* @param {object} inputRecord - The JSON:API document for the request. It must contain a `data` object for the primary resource.
|
|
1210
|
-
* @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
|
|
1211
|
-
* @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', 'tags']`).
|
|
1212
|
-
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
|
|
1213
|
-
* @param {object} [transaction] - Optional. An existing database transaction to use for this operation.
|
|
1214
|
-
* @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the newly created resource.
|
|
1215
|
-
*
|
|
1216
|
-
* @example
|
|
1217
|
-
* // Case 1: Create a simple resource with only attributes
|
|
1218
|
-
* const article = await api.resources.articles.post({
|
|
1219
|
-
* inputRecord: {
|
|
1220
|
-
* "data": {
|
|
1221
|
-
* "type": "articles",
|
|
1222
|
-
* "attributes": {
|
|
1223
|
-
* "title": "My Article",
|
|
1224
|
-
* "body": "This article has no relationships.",
|
|
1225
|
-
* "status": "draft"
|
|
1226
|
-
* }
|
|
1227
|
-
* }
|
|
1228
|
-
* }
|
|
1229
|
-
* });
|
|
1230
|
-
* @see POST /api/articles
|
|
1231
|
-
* // Returns:
|
|
1232
|
-
* // {
|
|
1233
|
-
* // "data": {
|
|
1234
|
-
* // "type": "articles",
|
|
1235
|
-
* // "id": "123",
|
|
1236
|
-
* // "attributes": {
|
|
1237
|
-
* // "title": "My Article",
|
|
1238
|
-
* // "body": "This article has no relationships.",
|
|
1239
|
-
* // "status": "draft"
|
|
1240
|
-
* // }
|
|
1241
|
-
* // }
|
|
1242
|
-
* // }
|
|
1243
|
-
*
|
|
1244
|
-
* @example
|
|
1245
|
-
* // Case 2: Create a resource with a one-to-one relationship (belongsTo)
|
|
1246
|
-
* const article = await api.resources.articles.post({
|
|
1247
|
-
* inputRecord: {
|
|
1248
|
-
* "data": {
|
|
1249
|
-
* "type": "articles",
|
|
1250
|
-
* "attributes": {
|
|
1251
|
-
* "title": "Article with Author",
|
|
1252
|
-
* "body": "Content here"
|
|
1253
|
-
* },
|
|
1254
|
-
* "relationships": {
|
|
1255
|
-
* "author": {
|
|
1256
|
-
* "data": { "type": "people", "id": "42" } // Must be existing person
|
|
1257
|
-
* }
|
|
1258
|
-
* }
|
|
1259
|
-
* }
|
|
1260
|
-
* }
|
|
1261
|
-
* });
|
|
1262
|
-
* @see POST /api/articles
|
|
1263
|
-
* // Returns:
|
|
1264
|
-
* // {
|
|
1265
|
-
* // "data": {
|
|
1266
|
-
* // "type": "articles",
|
|
1267
|
-
* // "id": "124",
|
|
1268
|
-
* // "attributes": {
|
|
1269
|
-
* // "title": "Article with Author",
|
|
1270
|
-
* // "body": "Content here"
|
|
1271
|
-
* // },
|
|
1272
|
-
* // "relationships": {
|
|
1273
|
-
* // "author": {
|
|
1274
|
-
* // "data": { "type": "people", "id": "42" }
|
|
1275
|
-
* // }
|
|
1276
|
-
* // }
|
|
1277
|
-
* // }
|
|
1278
|
-
* // }
|
|
1279
|
-
*
|
|
1280
|
-
* @example
|
|
1281
|
-
* // Case 3: Create a resource with many-to-many relationships
|
|
1282
|
-
* const article = await api.resources.articles.post({
|
|
1283
|
-
* inputRecord: {
|
|
1284
|
-
* "data": {
|
|
1285
|
-
* "type": "articles",
|
|
1286
|
-
* "attributes": {
|
|
1287
|
-
* "title": "Tagged Article",
|
|
1288
|
-
* "body": "Article with multiple tags"
|
|
1289
|
-
* },
|
|
1290
|
-
* "relationships": {
|
|
1291
|
-
* "author": {
|
|
1292
|
-
* "data": { "type": "people", "id": "42" } // One-to-one
|
|
1293
|
-
* },
|
|
1294
|
-
* "tags": {
|
|
1295
|
-
* "data": [ // Many-to-many
|
|
1296
|
-
* { "type": "tags", "id": "1" }, // Must be existing tags
|
|
1297
|
-
* { "type": "tags", "id": "2" },
|
|
1298
|
-
* { "type": "tags", "id": "3" }
|
|
1299
|
-
* ]
|
|
1300
|
-
* }
|
|
1301
|
-
* }
|
|
1302
|
-
* }
|
|
1303
|
-
* },
|
|
1304
|
-
* queryParams: {
|
|
1305
|
-
* include: ['author', 'tags'] // Include related resources in response
|
|
1306
|
-
* }
|
|
1307
|
-
* });
|
|
1308
|
-
* @see POST /api/articles?include=author,tags
|
|
1309
|
-
* // Returns:
|
|
1310
|
-
* // {
|
|
1311
|
-
* // "data": {
|
|
1312
|
-
* // "type": "articles",
|
|
1313
|
-
* // "id": "125",
|
|
1314
|
-
* // "attributes": {
|
|
1315
|
-
* // "title": "Tagged Article",
|
|
1316
|
-
* // "body": "Article with multiple tags"
|
|
1317
|
-
* // },
|
|
1318
|
-
* // "relationships": {
|
|
1319
|
-
* // "author": {
|
|
1320
|
-
* // "data": { "type": "people", "id": "42" }
|
|
1321
|
-
* // },
|
|
1322
|
-
* // "tags": {
|
|
1323
|
-
* // "data": [
|
|
1324
|
-
* // { "type": "tags", "id": "1" },
|
|
1325
|
-
* // { "type": "tags", "id": "2" },
|
|
1326
|
-
* // { "type": "tags", "id": "3" }
|
|
1327
|
-
* // ]
|
|
1328
|
-
* // }
|
|
1329
|
-
* // }
|
|
1330
|
-
* // },
|
|
1331
|
-
* // "included": [
|
|
1332
|
-
* // {
|
|
1333
|
-
* // "type": "people",
|
|
1334
|
-
* // "id": "42",
|
|
1335
|
-
* // "attributes": { "name": "John Doe", "email": "john@example.com" }
|
|
1336
|
-
* // },
|
|
1337
|
-
* // {
|
|
1338
|
-
* // "type": "tags",
|
|
1339
|
-
* // "id": "1",
|
|
1340
|
-
* // "attributes": { "name": "javascript", "slug": "javascript" }
|
|
1341
|
-
* // },
|
|
1342
|
-
* // {
|
|
1343
|
-
* // "type": "tags",
|
|
1344
|
-
* // "id": "2",
|
|
1345
|
-
* // "attributes": { "name": "api", "slug": "api" }
|
|
1346
|
-
* // },
|
|
1347
|
-
* // {
|
|
1348
|
-
* // "type": "tags",
|
|
1349
|
-
* // "id": "3",
|
|
1350
|
-
* // "attributes": { "name": "rest", "slug": "rest" }
|
|
1351
|
-
* // }
|
|
1352
|
-
* // ]
|
|
1353
|
-
* // }
|
|
1354
|
-
*
|
|
1355
|
-
* @example
|
|
1356
|
-
* // Case 4: Using transactions for atomic operations
|
|
1357
|
-
* const trx = await api.knex.instance.transaction();
|
|
1358
|
-
* try {
|
|
1359
|
-
* const author = await api.resources.people.post({
|
|
1360
|
-
* transaction: trx,
|
|
1361
|
-
* inputRecord: {
|
|
1362
|
-
* "data": {
|
|
1363
|
-
* "type": "people",
|
|
1364
|
-
* "attributes": { "name": "Jane Author" }
|
|
1365
|
-
* }
|
|
1366
|
-
* }
|
|
1367
|
-
* });
|
|
1368
|
-
*
|
|
1369
|
-
* const article = await api.resources.articles.post({
|
|
1370
|
-
* transaction: trx,
|
|
1371
|
-
* inputRecord: {
|
|
1372
|
-
* "data": {
|
|
1373
|
-
* "type": "articles",
|
|
1374
|
-
* "attributes": { "title": "Jane's First Article" },
|
|
1375
|
-
* "relationships": {
|
|
1376
|
-
* "author": {
|
|
1377
|
-
* "data": { "type": "people", "id": author.data.id }
|
|
1378
|
-
* }
|
|
1379
|
-
* }
|
|
1380
|
-
* }
|
|
1381
|
-
* }
|
|
1382
|
-
* });
|
|
1383
|
-
*
|
|
1384
|
-
* await trx.commit();
|
|
1385
|
-
* } catch (error) {
|
|
1386
|
-
* await trx.rollback();
|
|
1387
|
-
* throw error;
|
|
1388
|
-
* }
|
|
1389
|
-
*/
|
|
1390
|
-
|
|
1391
|
-
addScopeMethod('post', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
|
|
1392
|
-
context.method = 'post'
|
|
1393
|
-
|
|
1394
|
-
try {
|
|
1395
|
-
const { schema, schemaStructure, schemaRelationships } = await setupCommonRequest({
|
|
1396
|
-
params, context, vars, scopes, scopeOptions, scopeName, helpers
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
// Run early hooks for pre-processing (e.g., file handling)
|
|
1400
|
-
await runHooks('beforeProcessing')
|
|
1401
|
-
await runHooks('beforeProcessingPost')
|
|
1402
|
-
|
|
1403
|
-
// Validate POST payload to ensure it follows JSON:API format and references valid resources.
|
|
1404
|
-
// This checks the payload has required 'data' object with 'type' and 'attributes', validates
|
|
1405
|
-
// that data.type matches a real resource type (preventing creation of non-existent resources),
|
|
1406
|
-
// and ensures any relationships reference valid resource types with proper ID format.
|
|
1407
|
-
// Example: data.type: 'articles' must be a registered scope, relationships.author must reference 'users'.
|
|
1408
|
-
validatePostPayload(context.inputRecord, scopes)
|
|
1409
|
-
|
|
1410
|
-
// Validate that user has read access to all related resources
|
|
1411
|
-
// This ensures users can only create relationships to resources they can access
|
|
1412
|
-
await validateRelationshipAccess(context, context.inputRecord);
|
|
1413
|
-
|
|
1414
|
-
// Extract foreign keys from JSON:API relationships and prepare many-to-many operations
|
|
1415
|
-
// Example: relationships.author -> author_id: '123' for storage
|
|
1416
|
-
// Example: relationships.tags -> array of pivot records to create later
|
|
1417
|
-
const { belongsToUpdates, manyToManyRelationships } = await processRelationships(
|
|
1418
|
-
scope,
|
|
1419
|
-
{ context }
|
|
1420
|
-
);
|
|
1421
|
-
|
|
1422
|
-
await validateResourceAttributesBeforeWrite({
|
|
1423
|
-
context,
|
|
1424
|
-
schema,
|
|
1425
|
-
belongsToUpdates,
|
|
1426
|
-
runHooks ,
|
|
1427
|
-
});
|
|
1428
|
-
|
|
1429
|
-
// Centralised checkPermissions function
|
|
1430
|
-
await scope.checkPermissions({
|
|
1431
|
-
method: 'post',
|
|
1432
|
-
auth: context.auth,
|
|
1433
|
-
transaction: context.transaction
|
|
1434
|
-
})
|
|
1435
|
-
|
|
1436
|
-
await runHooks ('beforeDataCall')
|
|
1437
|
-
await runHooks ('beforeDataCallPost')
|
|
1438
|
-
|
|
1439
|
-
// Create the main record - storage helper should return the created record with its ID
|
|
1440
|
-
context.id = await helpers.dataPost({
|
|
1441
|
-
scopeName,
|
|
1442
|
-
context
|
|
1443
|
-
});
|
|
1444
|
-
|
|
1445
|
-
await runHooks ('afterDataCallPost')
|
|
1446
|
-
await runHooks ('afterDataCall')
|
|
1447
|
-
|
|
1448
|
-
// Process many-to-many relationships after main record creation
|
|
1449
|
-
for (const { relName, relDef, relData } of manyToManyRelationships) {
|
|
1450
|
-
// Validate pivot resource exists
|
|
1451
|
-
validatePivotResource(scopes, relDef, relName);
|
|
1452
|
-
|
|
1453
|
-
// Create pivot records in the through table to establish many-to-many relationships.
|
|
1454
|
-
// This creates records in the intermediary table (like 'article_tags') that link the main
|
|
1455
|
-
// resource to each related resource. Before creating each pivot record, it validates that
|
|
1456
|
-
// the related resource actually exists (preventing orphaned relationships). If validateExists
|
|
1457
|
-
// is false, it skips validation for performance in bulk operations.
|
|
1458
|
-
// Example: article.tags: [{id: '1'}, {id: '2'}] creates two article_tags records:
|
|
1459
|
-
// {article_id: 100, tag_id: 1} and {article_id: 100, tag_id: 2}
|
|
1460
|
-
await createPivotRecords(api, context.id, relDef, relData, context.transaction);
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
const ret = await handleRecordReturnAfterWrite({
|
|
1464
|
-
context,
|
|
1465
|
-
scopeName,
|
|
1466
|
-
api,
|
|
1467
|
-
scopes,
|
|
1468
|
-
schemaStructure,
|
|
1469
|
-
schemaRelationships,
|
|
1470
|
-
scopeOptions,
|
|
1471
|
-
vars,
|
|
1472
|
-
runHooks,
|
|
1473
|
-
helpers,
|
|
1474
|
-
enhancedLog
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
// Commit transaction if we created it
|
|
1479
|
-
if (context.shouldCommit) {
|
|
1480
|
-
await context.transaction.commit();
|
|
1481
|
-
await runHooks('afterCommit');
|
|
1482
|
-
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
return ret
|
|
1486
|
-
|
|
1487
|
-
} catch (error) {
|
|
1488
|
-
await handleWriteMethodError(error, context, 'POST', scopeName, enhancedLog);
|
|
1489
|
-
}
|
|
1490
|
-
})
|
|
111
|
+
// Schema cache vars
|
|
112
|
+
vars.schemaProcessed = false;
|
|
113
|
+
vars.schema = null;
|
|
1491
114
|
|
|
115
|
+
// ******************************
|
|
116
|
+
// Scope (resources) added hooks
|
|
117
|
+
// ******************************
|
|
1492
118
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
* PUT is a complete replacement - any relationships not provided will be removed/nulled.
|
|
1498
|
-
* This method does NOT support creating new related resources via an `included` array.
|
|
1499
|
-
*
|
|
1500
|
-
* @param {string} id - The ID of the resource to update.
|
|
1501
|
-
* @param {object} inputRecord - The JSON:API document for the request. It must contain a `data` object for the primary resource.
|
|
1502
|
-
* It CANNOT include an `included` array - all relationships must reference existing resources.
|
|
1503
|
-
* @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
|
|
1504
|
-
* @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload in the response. These paths
|
|
1505
|
-
* will be converted to a comma-separated string (e.g., `['author', 'comments.user']`).
|
|
1506
|
-
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types,
|
|
1507
|
-
* values are comma-separated field names.
|
|
1508
|
-
* @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the updated resource.
|
|
1509
|
-
*
|
|
1510
|
-
* @example
|
|
1511
|
-
* // Case 1: Update a simple resource with only attributes.
|
|
1512
|
-
* const updatedArticle = await api.resources.articles.put({
|
|
1513
|
-
* inputRecord: {
|
|
1514
|
-
* "data": {
|
|
1515
|
-
* "type": "articles",
|
|
1516
|
-
* "id": "123",
|
|
1517
|
-
* "attributes": {
|
|
1518
|
-
* "title": "Updated Title",
|
|
1519
|
-
* "body": "This is the completely replaced content."
|
|
1520
|
-
* }
|
|
1521
|
-
* }
|
|
1522
|
-
* }
|
|
1523
|
-
* });
|
|
1524
|
-
* @see PUT /api/articles/123
|
|
1525
|
-
* // Example Return Value for updatedArticle:
|
|
1526
|
-
* // {
|
|
1527
|
-
* // "data": {
|
|
1528
|
-
* // "type": "articles",
|
|
1529
|
-
* // "id": "123",
|
|
1530
|
-
* // "attributes": {
|
|
1531
|
-
* // "title": "Updated Title",
|
|
1532
|
-
* // "body": "This is the completely replaced content."
|
|
1533
|
-
* // }
|
|
1534
|
-
* // }
|
|
1535
|
-
* // }
|
|
1536
|
-
*
|
|
1537
|
-
* @example
|
|
1538
|
-
* // Case 2: Replace a resource and update its relationships to EXISTING resources.
|
|
1539
|
-
* const replacedArticle = await api.resources.articles.put({
|
|
1540
|
-
* inputRecord: {
|
|
1541
|
-
* "data": {
|
|
1542
|
-
* "type": "articles",
|
|
1543
|
-
* "id": "124",
|
|
1544
|
-
* "attributes": {
|
|
1545
|
-
* "title": "Completely New Title",
|
|
1546
|
-
* "body": "Entirely new content"
|
|
1547
|
-
* },
|
|
1548
|
-
* "relationships": {
|
|
1549
|
-
* "authors": {
|
|
1550
|
-
* "data": [
|
|
1551
|
-
* { "type": "people", "id": "15" },
|
|
1552
|
-
* { "type": "people", "id": "16" }
|
|
1553
|
-
* ]
|
|
1554
|
-
* },
|
|
1555
|
-
* "publisher": {
|
|
1556
|
-
* "data": { "type": "publishers", "id": "pub-3" }
|
|
1557
|
-
* }
|
|
1558
|
-
* }
|
|
1559
|
-
* }
|
|
1560
|
-
* }
|
|
1561
|
-
* });
|
|
1562
|
-
* @see PUT /api/articles/124
|
|
1563
|
-
* // Example Return Value for replacedArticle:
|
|
1564
|
-
* // {
|
|
1565
|
-
* // "data": {
|
|
1566
|
-
* // "type": "articles",
|
|
1567
|
-
* // "id": "124",
|
|
1568
|
-
* // "attributes": {
|
|
1569
|
-
* // "title": "Completely New Title",
|
|
1570
|
-
* // "body": "Entirely new content"
|
|
1571
|
-
* // },
|
|
1572
|
-
* // "relationships": {
|
|
1573
|
-
* // "authors": {
|
|
1574
|
-
* // "data": [
|
|
1575
|
-
* // { "type": "people", "id": "15" },
|
|
1576
|
-
* // { "type": "people", "id": "16" }
|
|
1577
|
-
* // ]
|
|
1578
|
-
* // },
|
|
1579
|
-
* // "publisher": {
|
|
1580
|
-
* // "data": { "type": "publishers", "id": "pub-3" }
|
|
1581
|
-
* // }
|
|
1582
|
-
* // }
|
|
1583
|
-
* // }
|
|
1584
|
-
* // }
|
|
1585
|
-
*
|
|
1586
|
-
* @example
|
|
1587
|
-
* // Case 3: Replace a resource and include related resources in the response.
|
|
1588
|
-
* const replacedWithIncluded = await api.resources.articles.put({
|
|
1589
|
-
* inputRecord: {
|
|
1590
|
-
* "data": {
|
|
1591
|
-
* "type": "articles",
|
|
1592
|
-
* "id": "125",
|
|
1593
|
-
* "attributes": {
|
|
1594
|
-
* "title": "Article with Author Included",
|
|
1595
|
-
* "body": "Content that replaces the previous version"
|
|
1596
|
-
* },
|
|
1597
|
-
* "relationships": {
|
|
1598
|
-
* "author": {
|
|
1599
|
-
* "data": { "type": "people", "id": "20" }
|
|
1600
|
-
* }
|
|
1601
|
-
* }
|
|
1602
|
-
* }
|
|
1603
|
-
* },
|
|
1604
|
-
* queryParams: {
|
|
1605
|
-
* include: ['author'],
|
|
1606
|
-
* fields: {
|
|
1607
|
-
* articles: 'title,body',
|
|
1608
|
-
* people: 'name,email'
|
|
1609
|
-
* }
|
|
1610
|
-
* }
|
|
1611
|
-
* });
|
|
1612
|
-
* @see PUT /api/articles/125?include=author&fields[articles]=title,body&fields[people]=name,email
|
|
1613
|
-
* // Example Return Value for replacedWithIncluded:
|
|
1614
|
-
* // {
|
|
1615
|
-
* // "data": {
|
|
1616
|
-
* // "type": "articles",
|
|
1617
|
-
* // "id": "125",
|
|
1618
|
-
* // "attributes": {
|
|
1619
|
-
* // "title": "Article with Author Included",
|
|
1620
|
-
* // "body": "Content that replaces the previous version"
|
|
1621
|
-
* // },
|
|
1622
|
-
* // "relationships": {
|
|
1623
|
-
* // "author": {
|
|
1624
|
-
* // "data": { "type": "people", "id": "20" }
|
|
1625
|
-
* // }
|
|
1626
|
-
* // }
|
|
1627
|
-
* // },
|
|
1628
|
-
* // "included": [
|
|
1629
|
-
* // {
|
|
1630
|
-
* // "type": "people",
|
|
1631
|
-
* // "id": "20",
|
|
1632
|
-
* // "attributes": {
|
|
1633
|
-
* // "name": "Alice Johnson",
|
|
1634
|
-
* // "email": "alice@example.com"
|
|
1635
|
-
* // }
|
|
1636
|
-
* // }
|
|
1637
|
-
* // ]
|
|
1638
|
-
* // }
|
|
1639
|
-
*
|
|
1640
|
-
* @example
|
|
1641
|
-
* // Case 4: Invalid - attempting to create new resources with PUT will fail.
|
|
1642
|
-
* // This will throw an error because PUT cannot create new resources:
|
|
1643
|
-
* try {
|
|
1644
|
-
* await api.resources.articles.put({
|
|
1645
|
-
* inputRecord: {
|
|
1646
|
-
* "data": {
|
|
1647
|
-
* "type": "articles",
|
|
1648
|
-
* "id": "126",
|
|
1649
|
-
* "attributes": {
|
|
1650
|
-
* "title": "Invalid PUT"
|
|
1651
|
-
* },
|
|
1652
|
-
* "relationships": {
|
|
1653
|
-
* "author": {
|
|
1654
|
-
* "data": { "type": "people", "id": "temp-new-author" }
|
|
1655
|
-
* }
|
|
1656
|
-
* }
|
|
1657
|
-
* },
|
|
1658
|
-
* "included": [
|
|
1659
|
-
* { "type": "people", "id": "temp-new-author", "attributes": { "name": "New Author" } }
|
|
1660
|
-
* ]
|
|
1661
|
-
* });
|
|
1662
|
-
* } catch (error) {
|
|
1663
|
-
* // Error: PUT requests cannot include an 'included' array for creating new resources
|
|
1664
|
-
* }
|
|
1665
|
-
*/
|
|
1666
|
-
addScopeMethod('put', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions,
|
|
1667
|
-
scopeName }) => {
|
|
1668
|
-
context.method = 'put'
|
|
119
|
+
addHook('scope:added', 'validateRelationships', {}, validateRelationships);
|
|
120
|
+
addHook('scope:added', 'compileResourceSchemas', {}, compileResourceSchemas);
|
|
121
|
+
addHook('scope:added', 'validateIncludeConfigurations', {}, validateIncludeConfigurations);
|
|
122
|
+
addHook('scope:added', 'turnScopeInitIntoVars', {}, turnScopeInitIntoVars)
|
|
1669
123
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
});
|
|
1674
|
-
context.id = context.inputRecord.data.id;
|
|
1675
|
-
// Run early hooks for pre-processing (e.g., file handling)
|
|
1676
|
-
await runHooks('beforeProcessing')
|
|
1677
|
-
await runHooks('beforeProcessingPut')
|
|
1678
|
-
|
|
1679
|
-
// Validate PUT payload to ensure it's a complete resource replacement operation.
|
|
1680
|
-
// PUT requires the full resource representation including ID (unlike POST which generates ID).
|
|
1681
|
-
// It validates that data.id matches the URL parameter, prevents 'included' array (which is
|
|
1682
|
-
// read-only), and ensures the payload represents a complete replacement. Any fields not
|
|
1683
|
-
// provided will be removed or reset to defaults - this is the key difference from PATCH.
|
|
1684
|
-
// Example: PUT to /articles/123 must have data.id: '123' and all required fields.
|
|
1685
|
-
validatePutPayload(context.inputRecord, scopes)
|
|
1686
|
-
|
|
1687
|
-
// Validate that user has read access to all related resources
|
|
1688
|
-
// This ensures users can only create relationships to resources they can access
|
|
1689
|
-
await validateRelationshipAccess(context, context.inputRecord, helpers, runHooks, api);
|
|
1690
|
-
|
|
1691
|
-
// Extract foreign keys from JSON:API relationships and prepare many-to-many operations
|
|
1692
|
-
// Example: relationships.author -> author_id: '123' for storage
|
|
1693
|
-
// Example: relationships.tags -> array of pivot records to create later
|
|
1694
|
-
const { belongsToUpdates, manyToManyRelationships } = processRelationships(
|
|
1695
|
-
scope,
|
|
1696
|
-
{ context }
|
|
1697
|
-
);
|
|
124
|
+
// *********
|
|
125
|
+
// Methods
|
|
126
|
+
// *********
|
|
1698
127
|
|
|
1699
|
-
|
|
1700
|
-
context,
|
|
1701
|
-
schema,
|
|
1702
|
-
belongsToUpdates,
|
|
1703
|
-
runHooks,
|
|
1704
|
-
});
|
|
128
|
+
addApiMethod('addRoute', addRouteMethod);
|
|
1705
129
|
|
|
1706
|
-
//
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
130
|
+
// Main REST methods
|
|
131
|
+
addScopeMethod('query', queryMethod);
|
|
132
|
+
addScopeMethod('get', getMethod);
|
|
133
|
+
addScopeMethod('post', postMethod);
|
|
134
|
+
addScopeMethod('put', putMethod);
|
|
135
|
+
addScopeMethod('patch', patchMethod);
|
|
136
|
+
addScopeMethod('delete', deleteMethod);
|
|
1711
137
|
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
scopeName,
|
|
1719
|
-
context,
|
|
1720
|
-
runHooks
|
|
1721
|
-
});
|
|
138
|
+
// Relationship methods
|
|
139
|
+
addScopeMethod('getRelationship', getRelationshipMethod);
|
|
140
|
+
addScopeMethod('getRelated', getRelatedMethod);
|
|
141
|
+
addScopeMethod('postRelationship', postRelationshipMethod)
|
|
142
|
+
addScopeMethod('patchRelationship', patchRelationshipMethod);
|
|
143
|
+
addScopeMethod('deleteRelationship', deleteRelationshipMethod );
|
|
1722
144
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
`Resource not found: ${scopeName}/${context.id}`,
|
|
1726
|
-
ERROR_SUBTYPES.NOT_FOUND
|
|
1727
|
-
);
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
context.minimalRecord = minimalRecord;
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// For PUT, we also need to handle relationships that are NOT provided
|
|
1734
|
-
// (they should be set to null/empty as PUT is a complete replacement)
|
|
1735
|
-
const allRelationships = {};
|
|
1736
|
-
|
|
1737
|
-
// Collect all defined relationships for this resource
|
|
1738
|
-
for (const [relName, relDef] of Object.entries(schemaRelationships || {})) {
|
|
1739
|
-
if (relDef.manyToMany) {
|
|
1740
|
-
allRelationships[relName] = { type: 'manyToMany', relDef: relDef.manyToMany };
|
|
1741
|
-
}
|
|
1742
|
-
// Also recognize hasMany with through as many-to-many
|
|
1743
|
-
else if (relDef.hasMany && relDef.through) {
|
|
1744
|
-
allRelationships[relName] = {
|
|
1745
|
-
type: 'manyToMany',
|
|
1746
|
-
relDef: {
|
|
1747
|
-
through: relDef.through,
|
|
1748
|
-
foreignKey: relDef.foreignKey,
|
|
1749
|
-
otherKey: relDef.otherKey
|
|
1750
|
-
}
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
145
|
+
addHook('scope:added', 'registerRelationshipRoutes', {}, registerRelationshipRoutes);
|
|
146
|
+
addHook('scope:added', 'registerScopeRoutes', {}, registerScopeRoutes);
|
|
1754
147
|
|
|
1755
|
-
//
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
allRelationships[fieldDef.as] = {
|
|
1759
|
-
type: fieldDef.belongsToPolymorphic ? 'polymorphic' : 'belongsTo',
|
|
1760
|
-
fieldName,
|
|
1761
|
-
fieldDef
|
|
1762
|
-
};
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
// Process missing relationships (PUT should null them out only if relationships object exists)
|
|
1767
|
-
const hasRelationshipsObject = context.inputRecord.data.relationships !== undefined;
|
|
1768
|
-
const providedRelationships = new Set(Object.keys(context.inputRecord.data.relationships || {}));
|
|
1769
|
-
|
|
1770
|
-
// Only null out missing relationships if a relationships object was provided
|
|
1771
|
-
if (hasRelationshipsObject) {
|
|
1772
|
-
for (const [relName, relInfo] of Object.entries(allRelationships)) {
|
|
1773
|
-
if (!providedRelationships.has(relName)) {
|
|
1774
|
-
if (relInfo.type === 'belongsTo') {
|
|
1775
|
-
belongsToUpdates[relInfo.fieldName] = null;
|
|
1776
|
-
} else if (relInfo.type === 'polymorphic') {
|
|
1777
|
-
const { typeField, idField } = relInfo.fieldDef.belongsToPolymorphic;
|
|
1778
|
-
belongsToUpdates[typeField] = null;
|
|
1779
|
-
belongsToUpdates[idField] = null;
|
|
1780
|
-
} else if (relInfo.type === 'manyToMany') {
|
|
1781
|
-
// Add to manyToManyRelationships with empty array
|
|
1782
|
-
manyToManyRelationships.push({
|
|
1783
|
-
relName,
|
|
1784
|
-
relDef: relInfo.relDef,
|
|
1785
|
-
relData: [] // Empty array means delete all
|
|
1786
|
-
});
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
// Merge belongsTo updates with attributes
|
|
1793
|
-
if (Object.keys(belongsToUpdates).length > 0) {
|
|
1794
|
-
context.inputRecord.data.attributes = {
|
|
1795
|
-
...context.inputRecord.data.attributes,
|
|
1796
|
-
...belongsToUpdates
|
|
1797
|
-
};
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
// Centralised checkPermissions function
|
|
1801
|
-
await scope.checkPermissions({
|
|
1802
|
-
method: 'put',
|
|
1803
|
-
auth: context.auth,
|
|
1804
|
-
id: context.id,
|
|
1805
|
-
minimalRecord: context.minimalRecord,
|
|
1806
|
-
isUpdate: context.isUpdate,
|
|
1807
|
-
transaction: context.transaction
|
|
1808
|
-
})
|
|
1809
|
-
|
|
1810
|
-
await runHooks ('beforeDataCall')
|
|
1811
|
-
await runHooks ('beforeDataCallPut')
|
|
1812
|
-
// Pass the operation type to the helper
|
|
1813
|
-
await helpers.dataPut({
|
|
1814
|
-
scopeName,
|
|
1815
|
-
context
|
|
1816
|
-
});
|
|
1817
|
-
await runHooks ('afterDataCallPut')
|
|
1818
|
-
await runHooks ('afterDataCall')
|
|
1819
|
-
|
|
1820
|
-
// Process many-to-many relationships after main record update/creation
|
|
1821
|
-
for (const { relName, relDef, relData } of manyToManyRelationships) {
|
|
1822
|
-
// Validate pivot resource exists
|
|
1823
|
-
await validatePivotResource(scopes, relDef, relName);
|
|
1824
|
-
|
|
1825
|
-
// Use smart sync for updates (like industry standard ORMs)
|
|
1826
|
-
if (context.isUpdate) {
|
|
1827
|
-
// Update many-to-many relationships using intelligent synchronization
|
|
1828
|
-
// This preserves existing pivot data while efficiently updating relationships
|
|
1829
|
-
await updateManyToManyRelationship(null, {
|
|
1830
|
-
api,
|
|
1831
|
-
context: {
|
|
1832
|
-
resourceId: context.id,
|
|
1833
|
-
relDef,
|
|
1834
|
-
relData,
|
|
1835
|
-
transaction: context.transaction
|
|
1836
|
-
}
|
|
1837
|
-
});
|
|
1838
|
-
} else {
|
|
1839
|
-
// For new records, just create the pivot records
|
|
1840
|
-
if (relData.length > 0) {
|
|
1841
|
-
await createPivotRecords(api, context.id, relDef, relData, context.transaction);
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const ret = await handleRecordReturnAfterWrite({
|
|
1847
|
-
context,
|
|
1848
|
-
scopeName,
|
|
1849
|
-
api,
|
|
1850
|
-
scopes,
|
|
1851
|
-
schemaStructure,
|
|
1852
|
-
schemaRelationships,
|
|
1853
|
-
scopeOptions,
|
|
1854
|
-
vars,
|
|
1855
|
-
runHooks,
|
|
1856
|
-
helpers,
|
|
1857
|
-
enhancedLog
|
|
1858
|
-
});
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
// Commit transaction if we created it
|
|
1862
|
-
if (context.shouldCommit) {
|
|
1863
|
-
await context.transaction.commit();
|
|
1864
|
-
await runHooks('afterCommit');
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
return ret
|
|
1868
|
-
|
|
1869
|
-
} catch (error) {
|
|
1870
|
-
await handleWriteMethodError(error, context, 'PUT', scopeName, enhancedLog);
|
|
1871
|
-
}
|
|
1872
|
-
})
|
|
1873
|
-
|
|
1874
|
-
/**
|
|
1875
|
-
* PATCH
|
|
1876
|
-
* Performs a partial update on an existing resource's attributes or relationships.
|
|
1877
|
-
* Unlike PUT, PATCH only updates the fields provided, leaving other fields unchanged.
|
|
1878
|
-
* This method supports updating both attributes and relationships (1:1 and n:n).
|
|
1879
|
-
* For relationships, only the ones explicitly provided will be updated.
|
|
1880
|
-
* Just like PUT, it CANNOT have the `included` array in data.
|
|
1881
|
-
*
|
|
1882
|
-
* @param {string} id - The ID of the resource to update.
|
|
1883
|
-
* @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.
|
|
1884
|
-
* @param {object} [queryParams={}] - Optional. An object to customize the query for the returned document.
|
|
1885
|
-
* @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']`).
|
|
1886
|
-
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
|
|
1887
|
-
* @returns {Promise<object>} A Promise that resolves to the JSON:API document containing the updated resource.
|
|
1888
|
-
*
|
|
1889
|
-
* @example
|
|
1890
|
-
* // Case 1: Update only specific attributes of a resource.
|
|
1891
|
-
* const patchedArticle = await api.resources.articles.patch({
|
|
1892
|
-
* inputRecord: {
|
|
1893
|
-
* "data": {
|
|
1894
|
-
* "type": "articles",
|
|
1895
|
-
* "id": "123",
|
|
1896
|
-
* "attributes": {
|
|
1897
|
-
* "title": "Updated Title Only"
|
|
1898
|
-
* // Note: 'body' and other attributes remain unchanged
|
|
1899
|
-
* }
|
|
1900
|
-
* }
|
|
1901
|
-
* }
|
|
1902
|
-
* });
|
|
1903
|
-
* @see PATCH /api/articles/123
|
|
1904
|
-
* // Example Return Value for patchedArticle:
|
|
1905
|
-
* // {
|
|
1906
|
-
* // "data": {
|
|
1907
|
-
* // "type": "articles",
|
|
1908
|
-
* // "id": "123",
|
|
1909
|
-
* // "attributes": {
|
|
1910
|
-
* // "title": "Updated Title Only",
|
|
1911
|
-
* // "body": "Original body content remains",
|
|
1912
|
-
* // "published-date": "2024-01-01T00:00:00Z"
|
|
1913
|
-
* // }
|
|
1914
|
-
* // }
|
|
1915
|
-
* // }
|
|
1916
|
-
*
|
|
1917
|
-
* @example
|
|
1918
|
-
* // Case 2: Update attributes and relationships partially.
|
|
1919
|
-
* const patchedWithRelationships = await api.resources.articles.patch({
|
|
1920
|
-
* inputRecord: {
|
|
1921
|
-
* "data": {
|
|
1922
|
-
* "type": "articles",
|
|
1923
|
-
* "id": "124",
|
|
1924
|
-
* "attributes": {
|
|
1925
|
-
* "status": "published"
|
|
1926
|
-
* // Other attributes like title, body remain unchanged
|
|
1927
|
-
* },
|
|
1928
|
-
* "relationships": {
|
|
1929
|
-
* "reviewer": {
|
|
1930
|
-
* "data": { "type": "people", "id": "30" }
|
|
1931
|
-
* }
|
|
1932
|
-
* // Other relationships like authors, publisher remain unchanged
|
|
1933
|
-
* }
|
|
1934
|
-
* }
|
|
1935
|
-
* }
|
|
1936
|
-
* });
|
|
1937
|
-
* @see PATCH /api/articles/124
|
|
1938
|
-
* // Example Return Value for patchedWithRelationships:
|
|
1939
|
-
* // {
|
|
1940
|
-
* // "data": {
|
|
1941
|
-
* // "type": "articles",
|
|
1942
|
-
* // "id": "124",
|
|
1943
|
-
* // "attributes": {
|
|
1944
|
-
* // "title": "Original Title",
|
|
1945
|
-
* // "body": "Original Content",
|
|
1946
|
-
* // "status": "published"
|
|
1947
|
-
* // },
|
|
1948
|
-
* // "relationships": {
|
|
1949
|
-
* // "authors": { "data": [{ "type": "people", "id": "9" }] },
|
|
1950
|
-
* // "reviewer": { "data": { "type": "people", "id": "30" } },
|
|
1951
|
-
* // "publisher": { "data": { "type": "publishers", "id": "pub-1" } }
|
|
1952
|
-
* // }
|
|
1953
|
-
* // }
|
|
1954
|
-
* // }
|
|
1955
|
-
*
|
|
1956
|
-
* @example
|
|
1957
|
-
* // Case 3: Patch with included resources in response.
|
|
1958
|
-
* const patchedWithIncluded = await api.resources.articles.patch({
|
|
1959
|
-
* inputRecord: {
|
|
1960
|
-
* "data": {
|
|
1961
|
-
* "type": "articles",
|
|
1962
|
-
* "id": "125",
|
|
1963
|
-
* "attributes": {
|
|
1964
|
-
* "last-modified": "2024-07-01T12:00:00Z"
|
|
1965
|
-
* }
|
|
1966
|
-
* }
|
|
1967
|
-
* },
|
|
1968
|
-
* queryParams: {
|
|
1969
|
-
* include: ['author', 'reviewer'],
|
|
1970
|
-
* fields: {
|
|
1971
|
-
* articles: 'title,last-modified',
|
|
1972
|
-
* people: 'name'
|
|
1973
|
-
* }
|
|
1974
|
-
* }
|
|
1975
|
-
* });
|
|
1976
|
-
* @see PATCH /api/articles/125?include=author,reviewer&fields[articles]=title,last-modified&fields[people]=name
|
|
1977
|
-
* // Example Return Value for patchedWithIncluded:
|
|
1978
|
-
* // {
|
|
1979
|
-
* // "data": {
|
|
1980
|
-
* // "type": "articles",
|
|
1981
|
-
* // "id": "125",
|
|
1982
|
-
* // "attributes": {
|
|
1983
|
-
* // "title": "Existing Article Title",
|
|
1984
|
-
* // "last-modified": "2024-07-01T12:00:00Z"
|
|
1985
|
-
* // },
|
|
1986
|
-
* // "relationships": {
|
|
1987
|
-
* // "author": { "data": { "type": "people", "id": "15" } },
|
|
1988
|
-
* // "reviewer": { "data": { "type": "people", "id": "20" } }
|
|
1989
|
-
* // }
|
|
1990
|
-
* // },
|
|
1991
|
-
* // "included": [
|
|
1992
|
-
* // { "type": "people", "id": "15", "attributes": { "name": "John Author" } },
|
|
1993
|
-
* // { "type": "people", "id": "20", "attributes": { "name": "Jane Reviewer" } }
|
|
1994
|
-
* // ]
|
|
1995
|
-
* // }
|
|
1996
|
-
*
|
|
1997
|
-
* @example
|
|
1998
|
-
* // Case 4: Clear a relationship using null.
|
|
1999
|
-
* const clearedRelationship = await api.resources.articles.patch({
|
|
2000
|
-
* inputRecord: {
|
|
2001
|
-
* "data": {
|
|
2002
|
-
* "type": "articles",
|
|
2003
|
-
* "id": "126",
|
|
2004
|
-
* "relationships": {
|
|
2005
|
-
* "reviewer": {
|
|
2006
|
-
* "data": null // Clear the reviewer relationship
|
|
2007
|
-
* }
|
|
2008
|
-
* }
|
|
2009
|
-
* }
|
|
2010
|
-
* }
|
|
2011
|
-
* });
|
|
2012
|
-
* @see PATCH /api/articles/126
|
|
2013
|
-
* // Example Return Value for clearedRelationship:
|
|
2014
|
-
* // {
|
|
2015
|
-
* // "data": {
|
|
2016
|
-
* // "type": "articles",
|
|
2017
|
-
* // "id": "126",
|
|
2018
|
-
* // "attributes": { ... },
|
|
2019
|
-
* // "relationships": {
|
|
2020
|
-
* // "reviewer": { "data": null },
|
|
2021
|
-
* // "author": { "data": { "type": "people", "id": "5" } }
|
|
2022
|
-
* // }
|
|
2023
|
-
* // }
|
|
2024
|
-
* // }
|
|
2025
|
-
*/
|
|
2026
|
-
addScopeMethod('patch', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
|
|
2027
|
-
context.method = 'patch'
|
|
2028
|
-
|
|
2029
|
-
try {
|
|
2030
|
-
const { schema, schemaStructure, schemaRelationships } = await setupCommonRequest({
|
|
2031
|
-
params, context, vars, scopes, scopeOptions, scopeName, helpers
|
|
2032
|
-
});
|
|
2033
|
-
context.id = context.inputRecord.data.id;
|
|
2034
|
-
// Run early hooks for pre-processing (e.g., file handling)
|
|
2035
|
-
await runHooks('beforeProcessing')
|
|
2036
|
-
await runHooks('beforeProcessingPatch')
|
|
2037
|
-
|
|
2038
|
-
// Validate PATCH payload to ensure the partial update actually contains changes.
|
|
2039
|
-
// PATCH requests must include either attributes to update or relationships to modify -
|
|
2040
|
-
// an empty PATCH is invalid. This prevents accidental no-op requests and ensures clients
|
|
2041
|
-
// are explicit about what they want to change. Unlike PUT, PATCH preserves all fields
|
|
2042
|
-
// not mentioned in the request.
|
|
2043
|
-
// Example: data must have either attributes: {title: 'New'} or relationships: {author: {...}}
|
|
2044
|
-
validatePatchPayload(context.inputRecord, scopes)
|
|
2045
|
-
|
|
2046
|
-
// Validate that user has read access to all related resources
|
|
2047
|
-
// This ensures users can only create relationships to resources they can access
|
|
2048
|
-
await validateRelationshipAccess(context, context.inputRecord);
|
|
2049
|
-
|
|
2050
|
-
// Extract foreign keys from JSON:API relationships and prepare many-to-many operations
|
|
2051
|
-
// Example: relationships.author -> author_id: '123' for storage
|
|
2052
|
-
// Example: relationships.tags -> array of pivot records to create later (only for provided relationships in PATCH)
|
|
2053
|
-
const { belongsToUpdates, manyToManyRelationships } = processRelationships(
|
|
2054
|
-
scope,
|
|
2055
|
-
{ context }
|
|
2056
|
-
);
|
|
2057
|
-
|
|
2058
|
-
await validateResourceAttributesBeforeWrite({
|
|
2059
|
-
context,
|
|
2060
|
-
schema,
|
|
2061
|
-
belongsToUpdates,
|
|
2062
|
-
runHooks,
|
|
2063
|
-
isPartialValidation: true
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
// Fetch minimal record for authorization checks
|
|
2067
|
-
const minimalRecord = await helpers.dataGetMinimal({
|
|
2068
|
-
scopeName,
|
|
2069
|
-
context,
|
|
2070
|
-
runHooks
|
|
2071
|
-
});
|
|
2072
|
-
|
|
2073
|
-
if (!minimalRecord) {
|
|
2074
|
-
throw new RestApiResourceError(
|
|
2075
|
-
`Resource not found: ${scopeName}/${context.id}`,
|
|
2076
|
-
ERROR_SUBTYPES.NOT_FOUND
|
|
2077
|
-
);
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
context.minimalRecord = minimalRecord;
|
|
148
|
+
// Non-URL methods
|
|
149
|
+
addScopeMethod('enrichAttributes', enrichAttributesMethod);
|
|
150
|
+
addScopeMethod('checkPermissions', checkPermissionsMethod);
|
|
2081
151
|
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
auth: context.auth,
|
|
2086
|
-
id: context.id,
|
|
2087
|
-
minimalRecord: context.minimalRecord,
|
|
2088
|
-
transaction: context.transaction
|
|
2089
|
-
})
|
|
2090
|
-
|
|
2091
|
-
await runHooks ('beforeDataCall')
|
|
2092
|
-
await runHooks ('beforeDataCallPatch')
|
|
2093
|
-
|
|
2094
|
-
// Call the storage helper - should return the patched record
|
|
2095
|
-
await helpers.dataPatch({
|
|
2096
|
-
scopeName,
|
|
2097
|
-
context
|
|
2098
|
-
});
|
|
2099
|
-
|
|
2100
|
-
await runHooks ('afterDataCallPatch')
|
|
2101
|
-
await runHooks ('afterDataCall')
|
|
2102
|
-
|
|
2103
|
-
// Process many-to-many relationships after main record update
|
|
2104
|
-
// For PATCH, we only update the relationships that were explicitly provided
|
|
2105
|
-
for (const { relName, relDef, relData } of manyToManyRelationships) {
|
|
2106
|
-
|
|
2107
|
-
// Validate pivot resource exists
|
|
2108
|
-
validatePivotResource(scopes, relDef, relName);
|
|
2109
|
-
|
|
2110
|
-
// Update many-to-many relationships using intelligent synchronization that preserves pivot data.
|
|
2111
|
-
// This compares current relationships with desired state: removes records no longer needed,
|
|
2112
|
-
// adds new relationships, and crucially preserves existing pivot records with their metadata
|
|
2113
|
-
// (like created_at timestamps or extra pivot fields). This is superior to delete-all-recreate
|
|
2114
|
-
// because it maintains audit trails and custom pivot data.
|
|
2115
|
-
// Example: If article has tags [1,2,3] and you update to [2,3,4], it keeps the pivot records
|
|
2116
|
-
// for tags 2&3 (preserving their created_at), deletes tag 1, and adds new record for tag 4.
|
|
2117
|
-
// Example: If article has tags [1,2,3] and update sends [2,3,4], tag 1 is removed, tags 2,3 kept, tag 4 added
|
|
2118
|
-
await updateManyToManyRelationship(null, {
|
|
2119
|
-
api,
|
|
2120
|
-
context: {
|
|
2121
|
-
resourceId: context.id,
|
|
2122
|
-
relDef,
|
|
2123
|
-
relData,
|
|
2124
|
-
transaction: context.transaction
|
|
2125
|
-
}
|
|
2126
|
-
});
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
const ret = await handleRecordReturnAfterWrite({
|
|
2130
|
-
context,
|
|
2131
|
-
scopeName,
|
|
2132
|
-
api,
|
|
2133
|
-
scopes,
|
|
2134
|
-
schemaStructure,
|
|
2135
|
-
schemaRelationships,
|
|
2136
|
-
scopeOptions,
|
|
2137
|
-
vars,
|
|
2138
|
-
runHooks,
|
|
2139
|
-
helpers,
|
|
2140
|
-
enhancedLog
|
|
2141
|
-
});
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
// Commit transaction if we created it
|
|
2145
|
-
if (context.shouldCommit) {
|
|
2146
|
-
await context.transaction.commit();
|
|
2147
|
-
await runHooks('afterCommit');
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
return ret
|
|
2151
|
-
|
|
2152
|
-
} catch (error) {
|
|
2153
|
-
await handleWriteMethodError(error, context, 'PATCH', scopeName, enhancedLog);
|
|
2154
|
-
}
|
|
2155
|
-
})
|
|
2156
|
-
|
|
2157
|
-
/**
|
|
2158
|
-
* DELETE
|
|
2159
|
-
* Permanently deletes a resource.
|
|
2160
|
-
*
|
|
2161
|
-
* @param {string} id - The ID of the resource to delete.
|
|
2162
|
-
* @returns {Promise<void>} A Promise that resolves when the deletion is complete. Typically returns no content (204).
|
|
2163
|
-
*
|
|
2164
|
-
* @example
|
|
2165
|
-
* // Case 1: Delete a simple resource
|
|
2166
|
-
* await api.scopes.articles.delete({ id: '123' });
|
|
2167
|
-
* @see DELETE /api/articles/123
|
|
2168
|
-
* // Returns: 204 No Content (no response body)
|
|
2169
|
-
*
|
|
2170
|
-
* @example
|
|
2171
|
-
* // Case 2: Delete with cascading relationships (handled by storage layer)
|
|
2172
|
-
* await api.scopes.authors.delete({ id: '456' });
|
|
2173
|
-
* @see DELETE /api/authors/456
|
|
2174
|
-
* // The storage layer may handle cascading deletes based on foreign key constraints
|
|
2175
|
-
* // Returns: 204 No Content
|
|
2176
|
-
*
|
|
2177
|
-
* @example
|
|
2178
|
-
* // Case 3: Attempting to delete non-existent resource
|
|
2179
|
-
* try {
|
|
2180
|
-
* await api.scopes.articles.delete({ id: 'non-existent' });
|
|
2181
|
-
* } catch (error) {
|
|
2182
|
-
* // Error: 404 Not Found - Resource does not exist
|
|
2183
|
-
* }
|
|
2184
|
-
*/
|
|
2185
|
-
addScopeMethod('delete', async ({ params, context, vars, helpers, scope, scopes, runHooks, apiOptions, pluginOptions, scopeOptions, scopeName }) => {
|
|
2186
|
-
|
|
2187
|
-
// Make the method available to all hooks
|
|
2188
|
-
context.method = 'delete'
|
|
2189
|
-
|
|
2190
|
-
// Set the ID in context
|
|
2191
|
-
context.id = params.id
|
|
2192
|
-
|
|
2193
|
-
// Set scopeName in context (needed for broadcasting)
|
|
2194
|
-
context.scopeName = scopeName
|
|
2195
|
-
|
|
2196
|
-
// Set schema info even for DELETE (needed by storage layer)
|
|
2197
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
2198
|
-
|
|
2199
|
-
// Transaction handling
|
|
2200
|
-
context.transaction = params.transaction ||
|
|
2201
|
-
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
2202
|
-
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
2203
|
-
context.db = context.transaction || api.knex.instance;
|
|
2204
|
-
|
|
2205
|
-
try {
|
|
2206
|
-
// No payload validation needed for DELETE
|
|
2207
|
-
|
|
2208
|
-
// Fetch minimal record for authorization and logging
|
|
2209
|
-
const minimalRecord = await helpers.dataGetMinimal({
|
|
2210
|
-
scopeName,
|
|
2211
|
-
context,
|
|
2212
|
-
runHooks
|
|
2213
|
-
});
|
|
2214
|
-
|
|
2215
|
-
if (!minimalRecord) {
|
|
2216
|
-
throw new RestApiResourceError(
|
|
2217
|
-
`Resource not found`,
|
|
2218
|
-
{
|
|
2219
|
-
subtype: 'not_found',
|
|
2220
|
-
resourceType: scopeName,
|
|
2221
|
-
resourceId: context.id
|
|
2222
|
-
}
|
|
2223
|
-
);
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
context.originalMinimalRecord = minimalRecord;
|
|
2227
|
-
context.minimalRecord = minimalRecord;
|
|
2228
|
-
|
|
2229
|
-
// Centralised checkPermissions function
|
|
2230
|
-
await scope.checkPermissions({
|
|
2231
|
-
method: 'delete',
|
|
2232
|
-
auth: context.auth,
|
|
2233
|
-
id: context.id,
|
|
2234
|
-
minimalRecord: context.minimalRecord,
|
|
2235
|
-
transaction: context.transaction
|
|
2236
|
-
})
|
|
2237
|
-
|
|
2238
|
-
// Before data operations
|
|
2239
|
-
await runHooks ('beforeDataCall')
|
|
2240
|
-
await runHooks ('beforeDataCallDelete')
|
|
2241
|
-
|
|
2242
|
-
// Initialize record context for hooks
|
|
2243
|
-
context.record = {}
|
|
2244
|
-
|
|
2245
|
-
// Call the storage helper
|
|
2246
|
-
await helpers.dataDelete({
|
|
2247
|
-
scopeName,
|
|
2248
|
-
context
|
|
2249
|
-
});
|
|
2250
|
-
|
|
2251
|
-
await runHooks ('afterDataCallDelete')
|
|
2252
|
-
await runHooks ('afterDataCall')
|
|
2253
|
-
|
|
2254
|
-
// No return record for DELETE (204 No Content)
|
|
2255
|
-
|
|
2256
|
-
await runHooks ('finish')
|
|
2257
|
-
await runHooks ('finishDelete')
|
|
2258
|
-
|
|
2259
|
-
// Commit transaction if we created it
|
|
2260
|
-
if (context.shouldCommit) {
|
|
2261
|
-
await context.transaction.commit();
|
|
2262
|
-
await runHooks('afterCommit');
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
// DELETE typically returns void/undefined (204 No Content)
|
|
2266
|
-
return;
|
|
2267
|
-
|
|
2268
|
-
} catch (error) {
|
|
2269
|
-
await handleWriteMethodError(error, context, 'DELETE', scopeName, enhancedLog);
|
|
2270
|
-
}
|
|
2271
|
-
})
|
|
2272
|
-
|
|
2273
|
-
/**
|
|
2274
|
-
* enrichAttributes
|
|
2275
|
-
* Runs the enrichAttributes hook for a specific scope to allow plugins to modify attributes
|
|
2276
|
-
* before they are returned to the client. This is a scope method so each resource type
|
|
2277
|
-
* can have its own attribute enrichment logic.
|
|
2278
|
-
*
|
|
2279
|
-
*/
|
|
2280
|
-
addScopeMethod('enrichAttributes', async ({ context, params, runHooks, scopeName, scopes, api, helpers }) => {
|
|
2281
|
-
// Extract parameters passed to enrichAttributes
|
|
2282
|
-
// - attributes: The raw attributes from database
|
|
2283
|
-
// - parentContext: The context from the calling method (has queryParams, transaction, etc.)
|
|
2284
|
-
// - requestedComputedFields: Which computed fields to calculate (from sparse fieldsets)
|
|
2285
|
-
// - isMainResource: Whether this is the main resource or an included one
|
|
2286
|
-
// - computedDependencies: Fields fetched only for computation (to be removed)
|
|
2287
|
-
const { attributes, parentContext, requestedComputedFields, isMainResource, computedDependencies } = params || {};
|
|
2288
|
-
|
|
2289
|
-
// Return empty object if no attributes provided
|
|
2290
|
-
if (!attributes) {
|
|
2291
|
-
return {};
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
// Get schema, computed field, and virtual field definitions
|
|
2295
|
-
const schemaStructure = scopes[scopeName]?.vars?.schemaInfo?.schemaStructure || {};
|
|
2296
|
-
const computedFields = scopes[scopeName]?.vars?.schemaInfo?.computed || {};
|
|
2297
|
-
const virtualFields = scopes[scopeName]?.vars?.schemaInfo?.virtual || {};
|
|
2298
|
-
|
|
2299
|
-
// Filter hidden fields from attributes based on visibility rules
|
|
2300
|
-
// This removes hidden:true fields and normallyHidden:true fields (unless requested)
|
|
2301
|
-
const requestedFields = parentContext?.queryParams?.fields?.[scopeName];
|
|
2302
|
-
const filteredAttributes = filterHiddenFields(attributes, { structure: schemaStructure }, requestedFields);
|
|
2303
|
-
|
|
2304
|
-
// Add virtual fields from context if they were provided in the request
|
|
2305
|
-
// Virtual fields come from user input and are passed through the context
|
|
2306
|
-
if (parentContext?.virtualFieldValues) {
|
|
2307
|
-
// Only include virtual fields that are defined in the schema
|
|
2308
|
-
Object.entries(parentContext.virtualFieldValues).forEach(([fieldName, value]) => {
|
|
2309
|
-
if (fieldName in virtualFields) {
|
|
2310
|
-
// Check if this virtual field should be included based on sparse fieldsets
|
|
2311
|
-
if (!requestedFields ||
|
|
2312
|
-
requestedFields.length === 0 ||
|
|
2313
|
-
(typeof requestedFields === 'string' ? requestedFields.split(',').map(f => f.trim()) : requestedFields).includes(fieldName)) {
|
|
2314
|
-
filteredAttributes[fieldName] = value;
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
});
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
// Determine which computed fields to calculate
|
|
2321
|
-
// We only compute fields that are requested to optimize performance
|
|
2322
|
-
let fieldsToCompute = [];
|
|
2323
|
-
if (requestedComputedFields) {
|
|
2324
|
-
// Explicit list provided (from sparse fieldsets)
|
|
2325
|
-
// Example: ?fields[products]=name,profit_margin -> only compute profit_margin
|
|
2326
|
-
fieldsToCompute = requestedComputedFields;
|
|
2327
|
-
} else if (isMainResource || !parentContext?.queryParams?.fields) {
|
|
2328
|
-
// No sparse fieldsets or this is the main resource - compute all fields
|
|
2329
|
-
// This ensures all computed fields are available when no filtering is applied
|
|
2330
|
-
fieldsToCompute = Object.keys(computedFields);
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
|
-
// Create compute context with all available resources
|
|
2334
|
-
// IMPORTANT: We pass the original attributes (including dependencies) to compute functions
|
|
2335
|
-
// This ensures computed fields can access normallyHidden dependencies like 'cost'
|
|
2336
|
-
// Example: profit_margin compute function gets access to both 'price' and 'cost'
|
|
2337
|
-
const computeContext = {
|
|
2338
|
-
attributes: attributes, // All attributes including dependencies
|
|
2339
|
-
record: { ...attributes }, // Full record for convenience
|
|
2340
|
-
context: parentContext,
|
|
2341
|
-
helpers, // API helpers for complex operations
|
|
2342
|
-
api, // Full API instance
|
|
2343
|
-
};
|
|
2344
|
-
|
|
2345
|
-
// Auto-compute fields that have compute functions
|
|
2346
|
-
for (const fieldName of fieldsToCompute) {
|
|
2347
|
-
const fieldDef = computedFields[fieldName];
|
|
2348
|
-
if (fieldDef && fieldDef.compute) {
|
|
2349
|
-
try {
|
|
2350
|
-
// Call the compute function with full context
|
|
2351
|
-
// Example: profit_margin compute gets { attributes: { price: 100, cost: 60 } }
|
|
2352
|
-
// and returns: ((100 - 60) / 100 * 100) = "40.00"
|
|
2353
|
-
filteredAttributes[fieldName] = await fieldDef.compute(computeContext);
|
|
2354
|
-
} catch (error) {
|
|
2355
|
-
// Log error but don't fail the request - computed fields shouldn't break API
|
|
2356
|
-
console.error(`Error computing field '${fieldName}' for ${scopeName}:`, error);
|
|
2357
|
-
filteredAttributes[fieldName] = null;
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
// Remove fields that were only fetched as dependencies
|
|
2363
|
-
// This is the key to the dependency resolution feature:
|
|
2364
|
-
// 1. We fetched dependencies from DB (e.g., 'cost' for profit_margin)
|
|
2365
|
-
// 2. We used them in compute functions
|
|
2366
|
-
// 3. Now we remove them if they weren't explicitly requested
|
|
2367
|
-
// Example: User requests profit_margin, we fetch cost, compute, then remove cost
|
|
2368
|
-
const finalAttributes = { ...filteredAttributes };
|
|
2369
|
-
if (requestedFields && computedDependencies && computedDependencies.length > 0) {
|
|
2370
|
-
// Parse requested fields if it's a string
|
|
2371
|
-
const requested = typeof requestedFields === 'string'
|
|
2372
|
-
? requestedFields.split(',').map(f => f.trim()).filter(f => f)
|
|
2373
|
-
: requestedFields;
|
|
2374
|
-
|
|
2375
|
-
for (const dep of computedDependencies) {
|
|
2376
|
-
// Only remove if it wasn't explicitly requested
|
|
2377
|
-
// Example: 'cost' is removed unless user explicitly asked for it
|
|
2378
|
-
if (!requested.includes(dep)) {
|
|
2379
|
-
delete finalAttributes[dep];
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
// Create context for enrichAttributes hooks
|
|
2385
|
-
Object.assign(context, {
|
|
2386
|
-
parentContext,
|
|
2387
|
-
attributes: finalAttributes, // Use the final attributes after dependency removal
|
|
2388
|
-
computedFields,
|
|
2389
|
-
requestedComputedFields: fieldsToCompute,
|
|
2390
|
-
scopeName,
|
|
2391
|
-
helpers,
|
|
2392
|
-
api
|
|
2393
|
-
});
|
|
2394
|
-
|
|
2395
|
-
// Run enrichAttributes hooks for additional/override computations
|
|
2396
|
-
await runHooks('enrichAttributes');
|
|
2397
|
-
|
|
2398
|
-
// Return the attributes from context, which hooks may have modified
|
|
2399
|
-
return context.attributes;
|
|
2400
|
-
});
|
|
2401
|
-
|
|
2402
|
-
/**
|
|
2403
|
-
* checkPermissions
|
|
2404
|
-
* Check if ther are permissions to access a resource.
|
|
2405
|
-
*
|
|
2406
|
-
*/
|
|
2407
|
-
addScopeMethod('checkPermissions', async ({ context, params, runHooks, scopeName, scopes, helpers }) => {
|
|
2408
|
-
|
|
2409
|
-
Object.assign(context, {
|
|
2410
|
-
method: params.method,
|
|
2411
|
-
isUpdate: params.isUpdate,
|
|
2412
|
-
id: params.id,
|
|
2413
|
-
auth: params.auth,
|
|
2414
|
-
transaction: params.transaction,
|
|
2415
|
-
minimalRecord: params.minimalRecord
|
|
2416
|
-
})
|
|
2417
|
-
|
|
2418
|
-
await runHooks('checkPermissions');
|
|
2419
|
-
});
|
|
152
|
+
// *********
|
|
153
|
+
// Helpers
|
|
154
|
+
// *********
|
|
2420
155
|
|
|
2421
156
|
// Initialize default data helpers that throw errors until a storage plugin is installed
|
|
2422
157
|
// These placeholders show storage plugin developers what methods to implement
|
|
2423
158
|
// Example: helpers.dataGet, helpers.dataPost, etc. will throw "No storage implementation" errors
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
159
|
+
Object.assign(helpers, defaultDataHelpers);
|
|
160
|
+
|
|
2427
161
|
// Add default getLocation helper for generating resource URLs
|
|
2428
162
|
// This can be overridden by storage plugins if needed
|
|
2429
|
-
helpers.getLocation = ({ scopeName, id }) => {
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
/**
|
|
2434
|
-
* Route registration for transport plugins
|
|
2435
|
-
* This creates a protocol-agnostic interface for registering HTTP-like routes
|
|
2436
|
-
*/
|
|
2437
|
-
addApiMethod('addRoute', async ({ params }) => {
|
|
2438
|
-
const { method, path, handler } = params;
|
|
2439
|
-
|
|
2440
|
-
// Validate route configuration
|
|
2441
|
-
if (!method || !path || !handler) {
|
|
2442
|
-
throw new ValidationError('Route requires method, path, and handler');
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
// This function converts the passed parameters into the hook's context
|
|
2446
|
-
const context = params
|
|
2447
|
-
|
|
2448
|
-
// Run the addRoute hook to notify transport plugins
|
|
2449
|
-
await runHooks('addRoute', context);
|
|
2450
|
-
|
|
2451
|
-
return { registered: true, method, path };
|
|
2452
|
-
});
|
|
2453
|
-
|
|
2454
|
-
// Listen for scope additions to register routes
|
|
2455
|
-
addHook('scope:added', 'registerScopeRoutes', {}, async ({ context }) => {
|
|
2456
|
-
const { scopeName } = context;
|
|
2457
|
-
const basePath = vars.resourceUrlPrefix || '';
|
|
2458
|
-
|
|
2459
|
-
// Helper to create route handlers
|
|
2460
|
-
const createRouteHandler = (scopeName, methodName) => {
|
|
2461
|
-
return async ({ queryString, headers, params, body, context }) => {
|
|
2462
|
-
const scope = api.scopes[scopeName];
|
|
2463
|
-
if (!scope) {
|
|
2464
|
-
throw new RestApiResourceError(
|
|
2465
|
-
`Scope '${scopeName}' not found`,
|
|
2466
|
-
{
|
|
2467
|
-
subtype: 'not_found',
|
|
2468
|
-
resourceType: 'scope',
|
|
2469
|
-
resourceId: scopeName
|
|
2470
|
-
}
|
|
2471
|
-
);
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
// Build parameters for the scope method
|
|
2475
|
-
const methodParams = {};
|
|
2476
|
-
|
|
2477
|
-
// Add ID for single-resource operations
|
|
2478
|
-
if (['get', 'put', 'patch', 'delete'].includes(methodName)) {
|
|
2479
|
-
methodParams.id = params.id;
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
// Parse query parameters for read operations
|
|
2483
|
-
if (['query', 'get'].includes(methodName)) {
|
|
2484
|
-
methodParams.queryParams = parseJsonApiQuery(queryString);
|
|
2485
|
-
methodParams.isTransport = true;
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
// Add body for write operations
|
|
2489
|
-
if (['post', 'put', 'patch'].includes(methodName)) {
|
|
2490
|
-
methodParams.inputRecord = body;
|
|
2491
|
-
methodParams.isTransport = true;
|
|
2492
|
-
|
|
2493
|
-
// Add query params for includes/fields on write operations
|
|
2494
|
-
if (queryString) {
|
|
2495
|
-
methodParams.queryParams = parseJsonApiQuery(queryString);
|
|
2496
|
-
}
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
// Call the scope method
|
|
2500
|
-
const result = await scope[methodName](methodParams, context);
|
|
2501
|
-
|
|
2502
|
-
// Return the result (transport plugin handles response formatting)
|
|
2503
|
-
return result;
|
|
2504
|
-
};
|
|
2505
|
-
};
|
|
2506
|
-
|
|
2507
|
-
const scopePath = `${basePath}/api/${scopeName}`;
|
|
2508
|
-
|
|
2509
|
-
// Register routes for each HTTP method
|
|
2510
|
-
// GET /api/{scope} - Query collection
|
|
2511
|
-
await api.addRoute({
|
|
2512
|
-
method: 'GET',
|
|
2513
|
-
path: scopePath,
|
|
2514
|
-
handler: createRouteHandler(scopeName, 'query')
|
|
2515
|
-
});
|
|
2516
|
-
|
|
2517
|
-
// GET /api/{scope}/{id} - Get single resource
|
|
2518
|
-
await api.addRoute({
|
|
2519
|
-
method: 'GET',
|
|
2520
|
-
path: `${scopePath}/:id`,
|
|
2521
|
-
handler: createRouteHandler(scopeName, 'get')
|
|
2522
|
-
});
|
|
2523
|
-
|
|
2524
|
-
// POST /api/{scope} - Create resource
|
|
2525
|
-
await api.addRoute({
|
|
2526
|
-
method: 'POST',
|
|
2527
|
-
path: scopePath,
|
|
2528
|
-
handler: createRouteHandler(scopeName, 'post')
|
|
2529
|
-
});
|
|
2530
|
-
|
|
2531
|
-
// PUT /api/{scope}/{id} - Replace resource
|
|
2532
|
-
await api.addRoute({
|
|
2533
|
-
method: 'PUT',
|
|
2534
|
-
path: `${scopePath}/:id`,
|
|
2535
|
-
handler: createRouteHandler(scopeName, 'put')
|
|
2536
|
-
});
|
|
2537
|
-
|
|
2538
|
-
// PATCH /api/{scope}/{id} - Update resource
|
|
2539
|
-
await api.addRoute({
|
|
2540
|
-
method: 'PATCH',
|
|
2541
|
-
path: `${scopePath}/:id`,
|
|
2542
|
-
handler: createRouteHandler(scopeName, 'patch')
|
|
2543
|
-
});
|
|
2544
|
-
|
|
2545
|
-
// DELETE /api/{scope}/{id} - Delete resource
|
|
2546
|
-
await api.addRoute({
|
|
2547
|
-
method: 'DELETE',
|
|
2548
|
-
path: `${scopePath}/:id`,
|
|
2549
|
-
handler: createRouteHandler(scopeName, 'delete')
|
|
2550
|
-
});
|
|
2551
|
-
|
|
2552
|
-
log.info(`Routes registered for scope '${scopeName}'`);
|
|
2553
|
-
});
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
163
|
+
helpers.getLocation = ({ scopeName, id }) => `/${scopeName}/${id}`
|
|
164
|
+
|
|
165
|
+
// Helper to get the URL prefix for generating links
|
|
166
|
+
helpers.getUrlPrefix = ({ scope }) => scope?.vars?.publicBaseUrl || vars.publicBaseUrl || vars.transport?.mountPath || ''
|
|
2557
167
|
}
|
|
2558
168
|
};
|