json-rest-api 1.0.7 → 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 +90 -32
  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
@@ -0,0 +1,39 @@
1
+ export default async function validateIncludeConfigurations({ context, scopes, log }) {
2
+ const { scopeName } = context;
3
+ const scope = scopes[scopeName];
4
+ const relationships = scope.vars.schemaInfo?.schemaRelationships;
5
+
6
+ if (!relationships) return;
7
+
8
+ // Check each relationship for include configuration
9
+ for (const [relName, relDef] of Object.entries(relationships)) {
10
+ if (relDef.include?.strategy === 'window') {
11
+ // This relationship requires window functions
12
+ // We'll validate this at query time since the database might not be connected yet
13
+ log.debug(`Relationship ${scopeName}.${relName} configured for window function includes`);
14
+ }
15
+
16
+ // Validate include configuration
17
+ if (relDef.include?.limit) {
18
+ if (typeof relDef.include.limit !== 'number') {
19
+ throw new Error(
20
+ `Invalid include limit for ${scopeName}.${relName}: limit must be a number`
21
+ );
22
+ }
23
+ // Check against queryMaxLimit if available
24
+ const maxLimit = scope.vars?.queryMaxLimit;
25
+ if (maxLimit && relDef.include.limit > maxLimit) {
26
+ throw new Error(
27
+ `Invalid include limit for ${scopeName}.${relName}: ` +
28
+ `limit (${relDef.include.limit}) exceeds queryMaxLimit (${maxLimit})`
29
+ );
30
+ }
31
+ }
32
+
33
+ if (relDef.include?.orderBy && !Array.isArray(relDef.include.orderBy)) {
34
+ throw new Error(
35
+ `Invalid include orderBy for ${scopeName}.${relName}: orderBy must be an array`
36
+ );
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,23 @@
1
+ import { ValidationError } from 'hooked-api';
2
+
3
+ /**
4
+ * addRoute
5
+ * Registers a new route with the transport layer
6
+ */
7
+ export default async ({ params, context, runHooks }) => {
8
+ const { method, path, handler } = params;
9
+
10
+ // Validate route configuration
11
+ if (!method || !path || !handler) {
12
+ throw new ValidationError('Route requires method, path, and handler');
13
+ }
14
+
15
+ // debugger
16
+ // Create context for enrichAttributes hooks
17
+ Object.assign(context, params)
18
+
19
+ // Run the addRoute hook to notify transport plugins
20
+ await runHooks('addRoute');
21
+
22
+ return { registered: true, method, path };
23
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * checkPermissions
3
+ * Check if ther are permissions to access a resource.
4
+ *
5
+ */
6
+ export default async function checkPermissionsMethod({ context, params, runHooks, scopeName, scopes, helpers }) {
7
+
8
+ Object.assign(context, {
9
+ method: params.method,
10
+ isUpdate: params.isUpdate,
11
+ id: params.id,
12
+ auth: params.auth,
13
+ transaction: params.transaction,
14
+ minimalRecord: params.minimalRecord
15
+ })
16
+
17
+ await runHooks('checkPermissions');
18
+ }
@@ -0,0 +1,605 @@
1
+ import {
2
+ RestApiValidationError,
3
+ RestApiPayloadError
4
+ } from '../../../lib/rest-api-errors.js';
5
+ import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js';
6
+ import { createEnhancedLogger } from '../../../lib/enhanced-logger.js';
7
+
8
+ /**
9
+ * Gets an enhanced logger instance with full error details and stack traces
10
+ * @param {Object} log - The base logger instance
11
+ * @returns {Object} Enhanced logger instance
12
+ */
13
+ export const getEnhancedLogger = (log) => {
14
+ return createEnhancedLogger(log, {
15
+ logFullErrors: true,
16
+ includeStack: true
17
+ });
18
+ };
19
+
20
+ /**
21
+ * Cascades configuration values through multiple sources with fallback to default
22
+ * @param {string} settingName - The setting name to look for
23
+ * @param {Array} sources - Array of objects to search through
24
+ * @param {*} defaultValue - Default value if not found
25
+ * @returns {*} The first defined value found or the default
26
+ */
27
+ export const cascadeConfig = (settingName, sources, defaultValue) =>
28
+ sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue;
29
+
30
+ /**
31
+ * Normalizes return record settings to valid values
32
+ * @param {string|boolean} value - The value to normalize
33
+ * @returns {string} Normalized value: 'no', 'minimal', or 'full'
34
+ */
35
+ export function normalizeReturnValue(value) {
36
+ if (['no', 'minimal', 'full'].includes(value)) return value;
37
+ return 'no'; // default
38
+ }
39
+
40
+ /**
41
+ * Sets up common request context for REST API methods
42
+ * Handles simplified mode, transaction setup, and initial validation
43
+ *
44
+ * @param {Object} params - The method parameters
45
+ * @param {Object} context - The request context
46
+ * @param {Object} vars - Plugin variables
47
+ * @param {Object} scopes - All available scopes
48
+ * @param {Object} scopeOptions - Scope-specific options
49
+ * @param {string} scopeName - The name of the current scope
50
+ * @param {Object} api - The API instance
51
+ * @param {Object} helpers - Helper functions
52
+ * @returns {Object} An object containing schema-related shortcuts
53
+ */
54
+ export async function setupCommonRequest({ params, context, vars, scopes, scopeOptions, scopeName, api, helpers }) {
55
+ // Determine which simplified setting to use based on transport
56
+ const isTransport = params.isTransport === true;
57
+
58
+ // Use vars which automatically cascade from scope to global
59
+ const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
60
+
61
+ // Get simplified setting - from params only (per-call override) or use default
62
+ context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified;
63
+
64
+ // Special case: if no inputRecord provided, force simplified mode
65
+ if (!params.inputRecord && context.simplified === false) {
66
+ context.simplified = true;
67
+ }
68
+
69
+ // Params is totally bypassed in simplified mode
70
+ if (context.simplified) {
71
+ if (params.inputRecord) {
72
+ context.inputRecord = params.inputRecord;
73
+ context.params = params;
74
+ } else {
75
+ context.inputRecord = params;
76
+ // Preserve returnFullRecord if specified in params
77
+ context.params = params.returnFullRecord ? { returnFullRecord: params.returnFullRecord } : {};
78
+ }
79
+ } else {
80
+ context.inputRecord = params.inputRecord;
81
+ context.params = params;
82
+ }
83
+
84
+ // Assign common context properties
85
+ context.schemaInfo = scopes[scopeName].vars.schemaInfo;
86
+
87
+ // Use vars which automatically cascade from scope to global
88
+ const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi;
89
+
90
+ // Get return record setting - from params only (per-call override) or use default
91
+ const returnFullRecordRaw = context.params.returnFullRecord !== undefined
92
+ ? context.params.returnFullRecord
93
+ : defaultReturnFullRecord;
94
+
95
+ // Normalize return record setting to always be an object with method keys
96
+ if (typeof returnFullRecordRaw === 'object' && returnFullRecordRaw !== null) {
97
+ // It's already an object, normalize the values
98
+ context.returnRecordSetting = {
99
+ post: normalizeReturnValue(returnFullRecordRaw.post),
100
+ put: normalizeReturnValue(returnFullRecordRaw.put),
101
+ patch: normalizeReturnValue(returnFullRecordRaw.patch)
102
+ };
103
+ } else {
104
+ // It's a single value (string or boolean), apply to all methods
105
+ const normalized = normalizeReturnValue(returnFullRecordRaw);
106
+ context.returnRecordSetting = {
107
+ post: normalized,
108
+ put: normalized,
109
+ patch: normalized
110
+ };
111
+ }
112
+
113
+ // Helper function to normalize return values (same as above)
114
+ function normalizeReturnValue(value) {
115
+ if (['no', 'minimal', 'full'].includes(value)) return value;
116
+ return 'no'; // default
117
+ }
118
+
119
+ // These only make sense as parameter per query, not in vars etc.
120
+ context.queryParams = params.queryParams || {};
121
+ context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {});
122
+ context.queryParams.include = cascadeConfig('include', [context.queryParams], []);
123
+
124
+ context.scopeName = scopeName;
125
+
126
+ // Transaction handling
127
+ context.transaction = params.transaction ||
128
+ (helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
129
+ context.shouldCommit = !params.transaction && !!context.transaction;
130
+ context.db = context.transaction || api.knex.instance;
131
+
132
+ // These are just shortcuts used in this function and will be returned
133
+ const schema = context.schemaInfo.schema;
134
+ const schemaStructure = context.schemaInfo.schema.structure;
135
+ const schemaRelationships = context.schemaInfo.schemaRelationships;
136
+
137
+ // Transform input if in simplified mode
138
+ if (context.simplified) {
139
+ context.inputRecord = transformSimplifiedToJsonApi(
140
+ { inputRecord: context.inputRecord },
141
+ { context: { scopeName, schemaStructure, schemaRelationships } }
142
+ );
143
+ } else {
144
+ // Strict mode: validate no belongsTo fields in attributes
145
+ if (context.inputRecord?.data?.attributes) {
146
+ for (const [key, fieldDef] of Object.entries(schemaStructure)) {
147
+ if (fieldDef.belongsTo && key in context.inputRecord.data.attributes) {
148
+ throw new RestApiValidationError(
149
+ `Field '${key}' is a foreign key and must be set via relationships, not attributes`,
150
+ { fields: [`data.attributes.${key}`] }
151
+ );
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ if (context.inputRecord.data.type !== scopeName) {
158
+ throw new RestApiValidationError(
159
+ `Resource type mismatch. Expected '${scopeName}' but got '${context.inputRecord.data.type}'`,
160
+ {
161
+ fields: ['data.type'],
162
+ violations: [{
163
+ field: 'data.type',
164
+ rule: 'resource_type_match',
165
+ message: `Resource type must be '${scopeName}'`
166
+ }]
167
+ }
168
+ );
169
+ }
170
+
171
+ // Remove included validation since JSON:API doesn't support it
172
+ if (context.inputRecord.included) {
173
+ throw new RestApiPayloadError(
174
+ context.method + ' requests cannot include an "included" array. JSON:API does not support creating multiple resources in a single request.',
175
+ { path: 'included', expected: 'undefined', received: 'array' }
176
+ );
177
+ }
178
+
179
+ // If both URL path ID and request body ID are provided, they must match
180
+ // Convert both to strings for comparison since databases may return numeric IDs
181
+ if (context.id && context.inputRecord.data.id && String(context.id) !== String(context.inputRecord.data.id)) {
182
+ throw new RestApiValidationError(
183
+ `ID mismatch. URL path ID '${context.id}' does not match request body ID '${context.inputRecord.data.id}'`,
184
+ {
185
+ fields: ['data.id'],
186
+ violations: [{
187
+ field: 'data.id',
188
+ rule: 'id_consistency',
189
+ message: `Request body ID must match URL path ID when both are provided`
190
+ }]
191
+ }
192
+ );
193
+ }
194
+
195
+ // Return key schema-related objects for direct use in the main methods
196
+ return { schema, schemaStructure, schemaRelationships };
197
+ }
198
+
199
+ /**
200
+ * Handles error cleanup and logging for write methods (POST, PUT, PATCH)
201
+ *
202
+ * @param {Error} error - The error that was caught
203
+ * @param {Object} context - The request context
204
+ * @param {string} method - The HTTP method name (POST, PUT, PATCH)
205
+ * @param {string} scopeName - The name of the resource scope
206
+ * @param {Object} log - The logger instance
207
+ * @param {Function} runHooks - Function to run hooks
208
+ * @throws {Error} Re-throws the original error after cleanup
209
+ */
210
+ export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
211
+ // Rollback transaction if we created it
212
+ if (context.shouldCommit) {
213
+ await context.transaction.rollback();
214
+ await runHooks('afterRollback');
215
+ }
216
+
217
+ // Create enhanced logger for error logging
218
+ const enhancedLog = getEnhancedLogger(log);
219
+
220
+ // Log the full error details
221
+ enhancedLog.logError(`Error in ${method} method`, error, {
222
+ scopeName,
223
+ method: method.toLowerCase(),
224
+ inputRecord: context.inputRecord
225
+ });
226
+
227
+ throw error;
228
+ }
229
+
230
+ /**
231
+ * Validates that a pivot resource exists for many-to-many relationships
232
+ *
233
+ * @param {Object} scopes - All available scopes/resources
234
+ * @param {Object} relDef - The relationship definition
235
+ * @param {string} relName - The relationship name
236
+ * @throws {RestApiValidationError} If the pivot resource doesn't exist
237
+ */
238
+ export const validatePivotResource = (scopes, relDef, relName) => {
239
+ if (!scopes[relDef.through]) {
240
+ throw new RestApiValidationError(
241
+ `Pivot resource '${relDef.through}' not found for relationship '${relName}'`,
242
+ {
243
+ fields: [`relationships.${relName}`],
244
+ violations: [{
245
+ field: `relationships.${relName}`,
246
+ rule: 'missing_pivot_resource',
247
+ message: `Pivot resource '${relDef.through}' must be defined`
248
+ }]
249
+ }
250
+ );
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Gets the appropriate hook suffix based on HTTP method
256
+ *
257
+ * @param {string} method - The HTTP method (e.g., 'post', 'get')
258
+ * @returns {string} The capitalized method name for hook naming
259
+ */
260
+ export const getMethodHookSuffix = (method) => {
261
+ return method.charAt(0).toUpperCase() + method.slice(1);
262
+ }
263
+
264
+ /**
265
+ * Validates resource attributes before write operations
266
+ *
267
+ * @param {Object} params - Validation parameters
268
+ * @param {Object} params.context - The request context
269
+ * @param {Object} params.schema - The resource schema
270
+ * @param {Object} params.belongsToUpdates - BelongsTo relationship updates
271
+ * @param {Function} params.runHooks - Function to run hooks
272
+ * @param {boolean} params.isPartialValidation - Whether this is partial validation (for PATCH)
273
+ * @throws {RestApiValidationError} If validation fails
274
+ */
275
+ export const validateResourceAttributesBeforeWrite = async ({
276
+ context,
277
+ schema,
278
+ belongsToUpdates,
279
+ runHooks,
280
+ isPartialValidation = false
281
+ }) => {
282
+ // Dynamically get the method suffix
283
+ const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
284
+
285
+ await runHooks('beforeSchemaValidate');
286
+ await runHooks(`beforeSchemaValidate${methodSpecificHookSuffix}`);
287
+
288
+ // Store original input attributes before validation adds defaults (primarily for POST)
289
+ // Only if it's not already set and the current method is POST
290
+ if (!context.originalInputAttributes && context.method === 'post') {
291
+ context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) };
292
+ }
293
+
294
+ // Merge belongsTo updates with attributes for validation
295
+ const attributesToValidate = {
296
+ ...context.inputRecord.data.attributes,
297
+ ...belongsToUpdates
298
+ };
299
+
300
+ // Extract computed fields before validation (they should be rejected)
301
+ const computedFields = {};
302
+ const schemaStructure = context.schemaInfo.schemaStructure || {};
303
+ Object.entries(attributesToValidate).forEach(([key, value]) => {
304
+ const fieldDef = schemaStructure[key];
305
+ // Computed fields should be stripped from input
306
+ if (fieldDef && fieldDef.computed === true && value !== undefined) {
307
+ computedFields[key] = value;
308
+ }
309
+ });
310
+
311
+ // Warn if computed fields were sent in the input
312
+ if (Object.keys(computedFields).length > 0) {
313
+ const fieldNames = Object.keys(computedFields).join(', ');
314
+ console.warn(`Computed fields [${fieldNames}] were sent in input for resource '${context.scopeName}' but will be ignored as they are output-only`);
315
+ }
316
+
317
+ // Filter out ONLY computed fields from validation
318
+ // Virtual fields MUST go through validation
319
+ const attributesForValidation = Object.entries(attributesToValidate)
320
+ .filter(([key, _]) => {
321
+ const fieldDef = schemaStructure[key];
322
+ return !fieldDef || fieldDef.computed !== true;
323
+ })
324
+ .reduce((acc, [key, value]) => {
325
+ acc[key] = value;
326
+ return acc;
327
+ }, {});
328
+
329
+ const validationOptions = isPartialValidation ? { onlyObjectValues: true } : {};
330
+
331
+ const { validatedObject, errors } = await schema.validate(attributesForValidation, validationOptions);
332
+
333
+ if (Object.keys(errors).length > 0) {
334
+ // --- START OF MODIFICATION ---
335
+ const schemaStructure = context.schemaInfo.schema.structure; // Get the schema structure for lookup
336
+
337
+ const violations = Object.entries(errors).map(([field, error]) => {
338
+ let fieldPath = `data.attributes.${field}`; // Default path for attributes
339
+
340
+ // Check if this field is a foreign key that has an 'as' alias
341
+ const fieldDef = schemaStructure[field];
342
+ if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
343
+ // If it's a belongsTo field with an alias, rewrite the path to the relationship alias
344
+ fieldPath = `data.relationships.${fieldDef.as}.data.id`;
345
+ }
346
+ // For many-to-many relationships, the original `transformSimplifiedToJsonApi`
347
+ // already puts them under `relationships.relName.data`, so `field` here
348
+ // would already be the relationship name, not a foreign key.
349
+ // However, if a validation error somehow slips through for a pivot table field
350
+ // that doesn't have an 'as' alias but is a foreign key, you might need
351
+ // more sophisticated mapping. For now, this covers belongsTo.
352
+
353
+ return {
354
+ field: fieldPath,
355
+ rule: error.code || 'invalid_value',
356
+ message: error.message
357
+ };
358
+ });
359
+ // --- END OF MODIFICATION ---
360
+
361
+ throw new RestApiValidationError(
362
+ 'Schema validation failed for resource attributes',
363
+ {
364
+ fields: violations.map(v => v.field), // Use the potentially rewritten fields
365
+ violations
366
+ }
367
+ );
368
+ }
369
+
370
+ // Update attributes with validated values
371
+ // Virtual fields have now been validated and cast properly
372
+ context.inputRecord.data.attributes = validatedObject;
373
+
374
+ await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`);
375
+ await runHooks('afterSchemaValidate');
376
+ }
377
+
378
+ /**
379
+ * Validates that the user has access to all resources referenced in relationships
380
+ *
381
+ * @param {object} context - The context object containing authentication info
382
+ * @param {object} inputRecord - The input record containing relationships to validate
383
+ * @param {object} helpers - Data helpers including dataGetMinimal
384
+ * @param {function} runHooks - Function to run hooks
385
+ * @param {object} api - API instance to access resources
386
+ * @throws {Error} If user doesn't have access to any related resource
387
+ */
388
+ export const validateRelationshipAccess = async (context, inputRecord, helpers, runHooks, api) => {
389
+ if (!inputRecord?.data?.relationships) return;
390
+
391
+ for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
392
+ if (!relData?.data) continue;
393
+
394
+ // Handle both single and array relationships
395
+ const relatedItems = Array.isArray(relData.data) ? relData.data : [relData.data];
396
+
397
+ for (const item of relatedItems) {
398
+ // Get the scope for the related resource
399
+ const relatedScope = api.resources[item.type];
400
+ if (!relatedScope) {
401
+ throw new Error(`Unknown resource type: ${item.type}`);
402
+ }
403
+
404
+ // Create context for dataGetMinimal
405
+ const getContext = {
406
+ ...context,
407
+ id: item.id,
408
+ schemaInfo: relatedScope.vars.schemaInfo,
409
+ scopeName: item.type,
410
+ method: 'get', // We're checking read permission
411
+ isUpdate: false
412
+ };
413
+
414
+
415
+ // Get the minimal record
416
+ const record = await helpers.dataGetMinimal({
417
+ scopeName: item.type,
418
+ context: getContext,
419
+ runHooks
420
+ });
421
+
422
+ if (!record) {
423
+ throw new Error(`Cannot create relationship to non-existent ${item.type} with id ${item.id}`);
424
+ }
425
+
426
+ // Check permissions using the related scope's checkPermissions
427
+ await relatedScope.checkPermissions({
428
+ method: 'get',
429
+ auth: context.auth,
430
+ id: item.id,
431
+ minimalRecord: record,
432
+ transaction: context.transaction
433
+ });
434
+ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Applies field setters to transform attribute values before storage
440
+ * Setters are applied in dependency order to handle interdependent fields
441
+ *
442
+ * @param {object} attributes - The validated attributes to transform
443
+ * @param {object} schemaInfo - Schema information including fieldSetters
444
+ * @param {object} context - The request context
445
+ * @param {object} api - The API instance
446
+ * @param {object} helpers - Helper functions
447
+ * @returns {object} Transformed attributes ready for storage
448
+ */
449
+ export const applyFieldSetters = async (attributes, schemaInfo, context, api, helpers) => {
450
+ const fieldSetters = schemaInfo.fieldSetters || {};
451
+ const sortedSetterFields = schemaInfo.sortedSetterFields || [];
452
+
453
+ // No setters to apply
454
+ if (sortedSetterFields.length === 0) {
455
+ return attributes;
456
+ }
457
+
458
+ let transformedAttributes = { ...attributes };
459
+
460
+ for (const fieldName of sortedSetterFields) {
461
+ // Only process fields that exist in the attributes
462
+ if (fieldName in transformedAttributes) {
463
+ const setterInfo = fieldSetters[fieldName];
464
+ try {
465
+ const setterContext = {
466
+ attributes: transformedAttributes, // Current state with previous setters applied
467
+ fieldName,
468
+ originalValue: attributes[fieldName],
469
+ originalAttributes: attributes,
470
+ scopeName: context.scopeName,
471
+ method: context.method,
472
+ api,
473
+ helpers,
474
+ auth: context.auth
475
+ };
476
+ transformedAttributes[fieldName] = await setterInfo.setter(
477
+ transformedAttributes[fieldName],
478
+ setterContext
479
+ );
480
+ } catch (error) {
481
+ console.warn(`Setter for field '${fieldName}' failed:`, error);
482
+ // Keep original value if setter fails
483
+ }
484
+ }
485
+ }
486
+
487
+ return transformedAttributes;
488
+ }
489
+
490
+ export async function handleRecordReturnAfterWrite({
491
+ context,
492
+ scopeName,
493
+ api,
494
+ scopes,
495
+ schemaStructure,
496
+ schemaRelationships,
497
+ scopeOptions,
498
+ vars,
499
+ runHooks,
500
+ helpers,
501
+ log
502
+ }) {
503
+ // Create enhanced logger for warnings
504
+ const enhancedLog = getEnhancedLogger(log);
505
+ const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
506
+
507
+ // Step 1: Set up record state for hooks
508
+ // Handle originalMinimalRecord and minimalRecord based on method type
509
+ if (context.method === 'DELETE') {
510
+ // For DELETE, keep the deleted record reference
511
+ if (context.minimalRecord) {
512
+ context.originalMinimalRecord = context.minimalRecord;
513
+ }
514
+ } else {
515
+ // For POST, PUT, PATCH - save the original state if it exists
516
+ if (context.minimalRecord) {
517
+ context.originalMinimalRecord = context.minimalRecord;
518
+ }
519
+
520
+ // Fetch the current state of the record after the write operation
521
+ try {
522
+ const currentRecord = await helpers.dataGetMinimal({
523
+ scopeName,
524
+ context,
525
+ runHooks
526
+ });
527
+ context.minimalRecord = currentRecord;
528
+ } catch (error) {
529
+ enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id });
530
+ }
531
+ }
532
+
533
+ // Step 2: Determine what to return based on configuration
534
+ const returnMode = context.returnRecordSetting[context.method];
535
+
536
+ // Case 1: Return nothing (204 No Content)
537
+ if (returnMode === 'no') {
538
+ context.responseRecord = undefined;
539
+ await runHooks('finish');
540
+ await runHooks(`finish${methodSpecificHookSuffix}`);
541
+ return undefined;
542
+ }
543
+
544
+ // Case 2: Return minimal record (just type and id)
545
+ if (returnMode === 'minimal') {
546
+ if (context.simplified) {
547
+ context.responseRecord = {
548
+ id: String(context.id),
549
+ type: scopeName
550
+ };
551
+ } else {
552
+ context.responseRecord = {
553
+ data: {
554
+ type: scopeName,
555
+ id: String(context.id)
556
+ }
557
+ };
558
+ }
559
+ await runHooks('finish');
560
+ await runHooks(`finish${methodSpecificHookSuffix}`);
561
+ return context.responseRecord;
562
+ }
563
+
564
+ // Case 3: Return full record
565
+ if (returnMode === 'full') {
566
+ // Fetch the complete record using the GET method
567
+ const fullRecord = await api.resources[scopeName].get({
568
+ id: context.id,
569
+ queryParams: context.queryParams,
570
+ transaction: context.transaction,
571
+ simplified: context.simplified
572
+ }, {...context });
573
+
574
+ context.responseRecord = fullRecord || undefined;
575
+
576
+ // Run finish hooks
577
+ await runHooks('finish');
578
+ await runHooks(`finish${methodSpecificHookSuffix}`);
579
+
580
+ // No transformation needed - GET already returns the correct format
581
+ // based on context.simplified
582
+ return context.responseRecord;
583
+ }
584
+
585
+ // This should never be reached, but just in case
586
+ throw new Error(`Invalid returnMode: ${returnMode}`);
587
+ }
588
+
589
+ export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
590
+ // First check schemaRelationships (for relationships defined in relationships object)
591
+ const relDef = schemaInfo.schemaRelationships?.[relationshipName];
592
+ if (relDef) {
593
+ return relDef;
594
+ }
595
+
596
+ // Then check schema fields for belongsTo relationships with matching 'as' property
597
+ for (const [fieldName, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
598
+ if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
599
+ return fieldDef;
600
+ }
601
+ }
602
+
603
+ return null;
604
+ };
605
+