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.
Files changed (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +99 -33
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -1,67 +1,40 @@
1
- import {
2
- validateGetPayload,
3
- validateQueryPayload,
4
- validatePostPayload,
5
- validatePutPayload,
6
- validatePatchPayload
7
- } from './lib/payload-validators.js';
8
- import {
9
- RestApiValidationError,
10
- RestApiResourceError,
11
- RestApiPayloadError
12
- } from '../../lib/rest-api-errors.js';
13
- import { ValidationError } from 'hooked-api';
14
- import { validatePolymorphicRelationships } from './lib/scope-validations.js';
15
- import { transformSimplifiedToJsonApi, transformJsonApiToSimplified, transformSingleJsonApiToSimplified } from './lib/simplified-helpers.js';
16
- import { processRelationships } from './lib/relationship-processor.js';
17
- import { updateManyToManyRelationship, createPivotRecords } from './lib/many-to-many-manipulations.js';
18
- import { createDefaultDataHelpers } from './lib/default-data-helpers.js';
19
- import { compileSchemas } from './lib/compile-schemas.js';
20
- import { createEnhancedLogger } from '../../lib/enhanced-logger.js';
21
- import { getRequestedComputedFields, filterHiddenFields } from './lib/knex-field-helpers.js';
22
- import { DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_INCLUDE_DEPTH_LIMIT, ERROR_SUBTYPES } from './utils/knex-constants.js';
23
- import { normalizeRecordAttributes } from './lib/database-value-normalizers.js';
24
- import { parseJsonApiQuery } from './utils/connectors-query-parser.js';
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
- // Enhance the logger to show full error details
61
- const enhancedLog = createEnhancedLogger(log, {
62
- logFullErrors: true,
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
- // Listen for scope creation to validate polymorphic relationships at startup.
73
- // This validates that polymorphic relationships are properly configured with valid types,
74
- // typeField/idField definitions, and that all referenced scope types actually exist.
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.resourceUrlPrefix = restApiOptions.resourceUrlPrefix || ''
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
- vars.returnFullRecord = {
202
- post: restApiOptions.returnFullRecord?.post ?? true,
203
- put: restApiOptions.returnFullRecord?.put ?? true,
204
- patch: restApiOptions.returnFullRecord?.patch ?? true,
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
- * Handles common schema validation logic for POST, PUT, and PATCH methods.
921
- *
922
- * @param {object} params - The params object from addScopeMethod.
923
- * @param {object} context - The context object from addScopeMethod.
924
- * @param {object} schema - The validation schema.
925
- * @param {object} belongsToUpdates - Object containing belongsTo foreign key updates.
926
- * @param {boolean} [isPartialValidation=false] - Whether to perform partial validation (for PATCH).
927
- * @returns {Promise<void>}
928
- * @throws {RestApiValidationError} If schema validation fails.
929
- */
930
-
931
- const validateResourceAttributesBeforeWrite = async ({
932
- context,
933
- schema,
934
- belongsToUpdates,
935
- runHooks,
936
- isPartialValidation = false
937
- }) => {
938
- // Dynamically get the method suffix
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
- // --- END OF MODIFICATION ---
1007
-
1008
- throw new RestApiValidationError(
1009
- 'Schema validation failed for resource attributes',
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
- * POST
1199
- * Creates a new resource with optional relationships to existing resources.
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
- * PUT
1495
- * Updates an existing top-level resource by completely replacing it.
1496
- * This method supports updating both attributes and relationships (1:1 and n:n).
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
- try {
1671
- const { schema, schemaStructure, schemaRelationships } = await setupCommonRequest({
1672
- params, context, vars, scopes, scopeOptions, scopeName, helpers
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
- await validateResourceAttributesBeforeWrite({
1700
- context,
1701
- schema,
1702
- belongsToUpdates,
1703
- runHooks,
1704
- });
128
+ addApiMethod('addRoute', addRouteMethod);
1705
129
 
1706
- // Check existence first
1707
- context.exists = await helpers.dataExists({
1708
- scopeName,
1709
- context
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
- context.isCreate = !context.exists;
1713
- context.isUpdate = context.exists;
1714
-
1715
- // Fetch minimal record for authorization checks (only for updates)
1716
- if (context.isUpdate) {
1717
- const minimalRecord = await helpers.dataGetMinimal({
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
- if (!minimalRecord) {
1724
- throw new RestApiResourceError(
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
- // Also check schema fields for belongsTo relationships
1756
- for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
1757
- if (fieldDef.as && (fieldDef.belongsTo || fieldDef.belongsToPolymorphic)) {
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
- // Centralised checkPermissions function
2083
- await scope.checkPermissions({
2084
- method: 'patch',
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
- const defaultHelpers = createDefaultDataHelpers(api);
2425
- Object.assign(helpers, defaultHelpers);
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
- return `/${scopeName}/${id}`;
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
  };