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
@@ -58,15 +58,15 @@ export const SocketIOPlugin = {
58
58
  continue;
59
59
  }
60
60
 
61
- // Check if filterUsing is a function
62
- if (typeof fieldDef.filterUsing === 'function') {
61
+ // Check if filterOperator is a function
62
+ if (typeof fieldDef.filterOperator === 'function') {
63
63
  // Must use filterRecord (validated during subscription)
64
64
  if (!fieldDef.filterRecord(record, filterValue)) {
65
65
  return false;
66
66
  }
67
67
  } else {
68
68
  // Use simple operator logic
69
- const operator = fieldDef.filterUsing || '=';
69
+ const operator = fieldDef.filterOperator || '=';
70
70
 
71
71
  switch (operator) {
72
72
  case 'like':
@@ -137,6 +137,13 @@ export const SocketIOPlugin = {
137
137
 
138
138
  // Main broadcast function
139
139
  async function performBroadcast({ method, scopeName, id, context }) {
140
+ console.log(`[SOCKETIO DEBUG] performBroadcast called:`, {
141
+ method,
142
+ scopeName,
143
+ id,
144
+ hasMinimalRecord: !!context.minimalRecord
145
+ });
146
+
140
147
  const scope = api.resources[scopeName];
141
148
  if (!scope) {
142
149
  log.error(`Scope ${scopeName} not found for broadcasting`);
@@ -212,8 +219,11 @@ export const SocketIOPlugin = {
212
219
 
213
220
  // Create Socket.IO server
214
221
  api.startSocketServer = async (server, options = {}) => {
222
+ // Default path respects mountPath for consistency with REST API
223
+ const defaultPath = vars.transport?.mountPath ? `${vars.transport.mountPath}/socket.io` : '/socket.io';
224
+
215
225
  const {
216
- path = '/socket.io',
226
+ path = defaultPath,
217
227
  cors = { origin: '*', methods: ['GET', 'POST'] },
218
228
  redis = null
219
229
  } = options;
@@ -345,7 +355,7 @@ export const SocketIOPlugin = {
345
355
  for (const filterKey of Object.keys(filters)) {
346
356
  const fieldDef = searchSchema.structure[filterKey];
347
357
 
348
- if (fieldDef && typeof fieldDef.filterUsing === 'function' && !fieldDef.filterRecord) {
358
+ if (fieldDef && typeof fieldDef.filterOperator === 'function' && !fieldDef.filterRecord) {
349
359
  const error = {
350
360
  code: 'UNSUPPORTED_FILTER',
351
361
  message: `Filter '${filterKey}' uses custom SQL logic and requires 'filterRecord' for real-time
@@ -605,23 +615,35 @@ subscriptions`
605
615
  addHook('finish', 'socketio-broadcast', {}, async ({ context }) => {
606
616
  const { method, scopeName, id } = context;
607
617
 
618
+ console.log(`[SOCKETIO DEBUG] finish hook called:`, {
619
+ method,
620
+ scopeName,
621
+ id,
622
+ hasMinimalRecord: !!context.minimalRecord,
623
+ hasTransaction: !!context.transaction
624
+ });
625
+
608
626
  // Only broadcast for write operations
609
627
  if (!['post', 'put', 'patch', 'delete'].includes(method)) {
628
+ log.debug(`[SOCKETIO DEBUG] Skipping broadcast for method: ${method}`);
610
629
  return;
611
630
  }
612
631
 
613
632
  // Skip if no ID (might happen in error cases)
614
633
  if (!id && method !== 'delete') {
634
+ log.debug(`[SOCKETIO DEBUG] Skipping broadcast - no ID for method: ${method}`);
615
635
  return;
616
636
  }
617
637
 
618
638
  // Skip if io not initialized
619
639
  if (!io) {
640
+ log.debug(`[SOCKETIO DEBUG] Skipping broadcast - io not initialized`);
620
641
  return;
621
642
  }
622
643
 
623
644
  // If there's a transaction, defer broadcasting
624
645
  if (context.transaction) {
646
+ console.log(`[SOCKETIO DEBUG] Deferring broadcast until transaction commit`);
625
647
  // Store broadcast info in WeakMap
626
648
  if (!pendingBroadcasts.has(context.transaction)) {
627
649
  pendingBroadcasts.set(context.transaction, []);
@@ -633,6 +655,7 @@ subscriptions`
633
655
  }
634
656
 
635
657
  // No transaction, broadcast immediately
658
+ console.log(`[SOCKETIO DEBUG] Broadcasting immediately (no transaction)`);
636
659
  await performBroadcast({ method, scopeName, id, context });
637
660
  });
638
661
 
@@ -11,7 +11,7 @@ import crypto from 'crypto';
11
11
  export class LocalStorage {
12
12
  constructor(options = {}) {
13
13
  this.directory = path.resolve(options.directory || './uploads');
14
- this.baseUrl = options.baseUrl || '/uploads';
14
+ this.fileBaseUrl = options.fileBaseUrl || '/uploads';
15
15
  this.nameStrategy = options.nameStrategy || 'hash'; // 'hash', 'timestamp', 'original', 'custom'
16
16
  this.nameGenerator = options.nameGenerator; // Custom function
17
17
  this.preserveExtension = options.preserveExtension !== false;
@@ -190,7 +190,7 @@ export class LocalStorage {
190
190
  }
191
191
 
192
192
  // Return public URL
193
- return `${this.baseUrl}/${filename}`;
193
+ return `${this.fileBaseUrl}/${filename}`;
194
194
  }
195
195
 
196
196
  /**
@@ -0,0 +1,483 @@
1
+ import { describe, it, before, beforeEach, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import knexLib from 'knex';
4
+ import { cleanTables } from './helpers/test-utils.js';
5
+ import { createComputedFieldsApi } from './fixtures/api-configs.js';
6
+
7
+ // Create Knex instance for tests
8
+ const knex = knexLib({
9
+ client: 'better-sqlite3',
10
+ connection: {
11
+ filename: ':memory:'
12
+ },
13
+ useNullAsDefault: true
14
+ });
15
+
16
+ describe('Computed Fields and Sparse Fieldsets', () => {
17
+ let api;
18
+ let testData = {};
19
+
20
+ before(async () => {
21
+ api = await createComputedFieldsApi(knex);
22
+ });
23
+
24
+ after(async () => {
25
+ await knex.destroy();
26
+ });
27
+
28
+ beforeEach(async () => {
29
+ await cleanTables(knex, ['test_products', 'test_reviews']);
30
+
31
+ // Create test data
32
+ const product = await api.resources.products.post({
33
+ name: 'Premium Widget',
34
+ price: 99.99,
35
+ cost: 45.00,
36
+ internal_notes: 'Supplier: ABC Corp'
37
+ });
38
+ testData.product = product;
39
+
40
+ const review1 = await api.resources.reviews.post({
41
+ product_id: product.id,
42
+ reviewer_name: 'Alice',
43
+ rating: 5,
44
+ comment: 'Excellent product!',
45
+ helpful_votes: 45,
46
+ total_votes: 50,
47
+ spam_score: 0.1
48
+ });
49
+ testData.review1 = review1;
50
+
51
+ const review2 = await api.resources.reviews.post({
52
+ product_id: product.id,
53
+ reviewer_name: 'Bob',
54
+ rating: 4,
55
+ comment: 'Good value',
56
+ helpful_votes: 8,
57
+ total_votes: 20,
58
+ spam_score: 0.2
59
+ });
60
+ testData.review2 = review2;
61
+ });
62
+
63
+ describe('Basic Computed Fields', () => {
64
+ it('should compute fields automatically when fetching', async () => {
65
+ const product = await api.resources.products.get({ id: testData.product.id });
66
+
67
+ assert.equal(product.name, 'Premium Widget');
68
+ assert.equal(product.price, 99.99);
69
+ assert.equal(product.profit_margin, 55.00);
70
+ assert.equal(product.profit_amount, 54.99);
71
+
72
+ // normallyHidden fields should not be included
73
+ assert.equal(product.cost, undefined);
74
+ assert.equal(product.internal_notes, undefined);
75
+ });
76
+
77
+ it('should handle division by zero in computed fields', async () => {
78
+ const freeProduct = await api.resources.products.post({
79
+ name: 'Free Sample',
80
+ price: 0,
81
+ cost: 0
82
+ });
83
+
84
+ const fetched = await api.resources.products.get({ id: freeProduct.id });
85
+ assert.equal(fetched.profit_margin, 0);
86
+ assert.equal(fetched.profit_amount, 0);
87
+ });
88
+
89
+ it('should handle null values in computed fields', async () => {
90
+ const review = await api.resources.reviews.post({
91
+ product_id: testData.product.id,
92
+ reviewer_name: 'Charlie',
93
+ rating: 3,
94
+ comment: 'Average',
95
+ helpful_votes: 0,
96
+ total_votes: 0
97
+ });
98
+
99
+ const fetched = await api.resources.reviews.get({ id: review.id });
100
+ assert.equal(fetched.helpfulness_score, null);
101
+ assert.equal(fetched.is_helpful, null);
102
+ });
103
+ });
104
+
105
+ describe('Sparse Fieldsets with Computed Fields', () => {
106
+ it('should return only requested fields including computed', async () => {
107
+ const product = await api.resources.products.get({
108
+ id: testData.product.id,
109
+ queryParams: {
110
+ fields: { products: 'name,profit_margin' }
111
+ }
112
+ });
113
+
114
+ assert.equal(product.id, testData.product.id);
115
+ assert.equal(product.name, 'Premium Widget');
116
+ assert.equal(product.profit_margin, 55.00);
117
+
118
+ // Other fields should not be included
119
+ assert.equal(product.price, undefined);
120
+ assert.equal(product.profit_amount, undefined);
121
+ assert.equal(product.cost, undefined);
122
+ });
123
+
124
+ it('should fetch dependencies but not include them in response', async () => {
125
+ const product = await api.resources.products.get({
126
+ id: testData.product.id,
127
+ queryParams: {
128
+ fields: { products: 'profit_margin' }
129
+ }
130
+ });
131
+
132
+ assert.equal(product.id, testData.product.id);
133
+ assert.equal(product.profit_margin, 55.00);
134
+
135
+ // Dependencies should not be included
136
+ assert.equal(product.price, undefined);
137
+ assert.equal(product.cost, undefined);
138
+ });
139
+
140
+ it('should allow explicitly requesting normallyHidden dependencies', async () => {
141
+ const product = await api.resources.products.get({
142
+ id: testData.product.id,
143
+ queryParams: {
144
+ fields: { products: 'name,cost,profit_margin' }
145
+ }
146
+ });
147
+
148
+ assert.equal(product.name, 'Premium Widget');
149
+ assert.equal(product.cost, 45); // Explicitly requested
150
+ assert.equal(product.profit_margin, 55.00);
151
+
152
+ // Price was not requested
153
+ assert.equal(product.price, undefined);
154
+ });
155
+
156
+ it('should handle multiple computed fields with overlapping dependencies', async () => {
157
+ const product = await api.resources.products.get({
158
+ id: testData.product.id,
159
+ queryParams: {
160
+ fields: { products: 'profit_margin,profit_amount' }
161
+ }
162
+ });
163
+
164
+ assert.equal(product.profit_margin, 55.00);
165
+ assert.equal(product.profit_amount, 54.99);
166
+
167
+ // Shared dependencies should not be included
168
+ assert.equal(product.price, undefined);
169
+ assert.equal(product.cost, undefined);
170
+ });
171
+ });
172
+
173
+ describe('Computed Fields in Included Resources', () => {
174
+ it('should compute fields in hasMany included resources', async () => {
175
+ const product = await api.resources.products.get({
176
+ id: testData.product.id,
177
+ queryParams: {
178
+ include: ['reviews']
179
+ }
180
+ });
181
+
182
+ assert.equal(product.reviews.length, 2);
183
+
184
+ const review1 = product.reviews.find(r => r.reviewer_name === 'Alice');
185
+ assert.equal(review1.helpfulness_score, 90);
186
+ assert.equal(review1.is_helpful, true);
187
+ assert.equal(review1.spam_score, undefined); // normallyHidden
188
+
189
+ const review2 = product.reviews.find(r => r.reviewer_name === 'Bob');
190
+ assert.equal(review2.helpfulness_score, 40);
191
+ assert.equal(review2.is_helpful, false); // Has enough votes but not helpful enough
192
+ });
193
+
194
+ it('should apply sparse fieldsets to included resources with computed fields', async () => {
195
+ const product = await api.resources.products.get({
196
+ id: testData.product.id,
197
+ queryParams: {
198
+ include: ['reviews'],
199
+ fields: {
200
+ products: 'name',
201
+ reviews: 'reviewer_name,rating,helpfulness_score'
202
+ }
203
+ }
204
+ });
205
+
206
+ assert.equal(product.name, 'Premium Widget');
207
+ assert.equal(product.price, undefined);
208
+ assert.equal(product.profit_margin, undefined);
209
+
210
+ const review = product.reviews[0];
211
+ assert.ok(review.reviewer_name);
212
+ assert.ok(review.rating);
213
+ assert.ok(review.helpfulness_score !== undefined);
214
+
215
+ // Other fields should not be included
216
+ assert.equal(review.comment, undefined);
217
+ assert.equal(review.helpful_votes, undefined);
218
+ assert.equal(review.total_votes, undefined);
219
+ assert.equal(review.is_helpful, undefined); // Not requested
220
+ });
221
+
222
+ it('should handle computed dependencies in included resources', async () => {
223
+ const product = await api.resources.products.get({
224
+ id: testData.product.id,
225
+ queryParams: {
226
+ include: ['reviews'],
227
+ fields: {
228
+ products: 'name',
229
+ reviews: 'is_helpful' // Depends on helpful_votes, total_votes, spam_score
230
+ }
231
+ }
232
+ });
233
+
234
+ const review = product.reviews.find(r => r.id === testData.review1.id);
235
+ assert.equal(review.is_helpful, true);
236
+
237
+ // Dependencies should not be included
238
+ assert.equal(review.helpful_votes, undefined);
239
+ assert.equal(review.total_votes, undefined);
240
+ assert.equal(review.spam_score, undefined);
241
+ });
242
+ });
243
+
244
+ describe('Collection Queries with Computed Fields', () => {
245
+ it('should compute fields for all records in collection', async () => {
246
+ const products = await api.resources.products.query();
247
+
248
+ assert.equal(products.data.length, 1);
249
+ const product = products.data[0];
250
+ assert.equal(product.profit_margin, 55.00);
251
+ assert.equal(product.profit_amount, 54.99);
252
+ });
253
+
254
+ it('should apply sparse fieldsets to collections', async () => {
255
+ const products = await api.resources.products.query({
256
+ queryParams: {
257
+ fields: { products: 'name,profit_margin' }
258
+ }
259
+ });
260
+
261
+ const product = products.data[0];
262
+ assert.equal(product.name, 'Premium Widget');
263
+ assert.equal(product.profit_margin, 55.00);
264
+ assert.equal(product.price, undefined);
265
+ assert.equal(product.cost, undefined);
266
+ });
267
+
268
+ it('should handle includes with computed fields in collections', async () => {
269
+ const products = await api.resources.products.query({
270
+ queryParams: {
271
+ include: ['reviews'],
272
+ fields: {
273
+ products: 'name',
274
+ reviews: 'rating,helpfulness_score'
275
+ }
276
+ }
277
+ });
278
+
279
+ const product = products.data[0];
280
+ assert.equal(product.reviews.length, 2);
281
+
282
+ product.reviews.forEach(review => {
283
+ assert.ok(review.rating);
284
+ assert.ok(review.helpfulness_score !== undefined);
285
+ assert.equal(review.reviewer_name, undefined);
286
+ assert.equal(review.comment, undefined);
287
+ });
288
+ });
289
+ });
290
+
291
+ describe('Relationship _ids Fields', () => {
292
+ it('should always include _ids fields regardless of sparse fieldsets', async () => {
293
+ const product = await api.resources.products.get({
294
+ id: testData.product.id,
295
+ queryParams: {
296
+ fields: { products: 'name' }
297
+ }
298
+ });
299
+
300
+ // Relationship _ids should still be included
301
+ assert.ok(Array.isArray(product.reviews_ids));
302
+ assert.equal(product.reviews_ids.length, 2);
303
+ });
304
+
305
+ it('should include _ids for empty relationships', async () => {
306
+ const newProduct = await api.resources.products.post({
307
+ name: 'New Product',
308
+ price: 50,
309
+ cost: 25
310
+ });
311
+
312
+ const fetched = await api.resources.products.get({
313
+ id: newProduct.id,
314
+ queryParams: {
315
+ fields: { products: 'name' }
316
+ }
317
+ });
318
+
319
+ assert.ok(Array.isArray(fetched.reviews_ids));
320
+ assert.equal(fetched.reviews_ids.length, 0);
321
+ });
322
+ });
323
+
324
+ describe('Error Handling', () => {
325
+ it('should handle errors in compute functions gracefully', async () => {
326
+ // Add a product resource with a faulty computed field
327
+ await api.addResource('faulty_products', {
328
+ schema: {
329
+ id: { type: 'id' },
330
+ value: { type: 'number' },
331
+ bad_compute: {
332
+ type: 'string',
333
+ computed: true,
334
+ dependencies: ['value'],
335
+ compute: ({ attributes }) => {
336
+ throw new Error('Computation failed');
337
+ }
338
+ }
339
+ },
340
+ tableName: 'test_faulty_products'
341
+ });
342
+ await api.resources.faulty_products.createKnexTable();
343
+
344
+ const faulty = await api.resources.faulty_products.post({ value: 42 });
345
+ const fetched = await api.resources.faulty_products.get({ id: faulty.id });
346
+
347
+ assert.equal(fetched.value, 42);
348
+ assert.equal(fetched.bad_compute, null); // Error results in null
349
+ });
350
+
351
+ it('should reject unknown fields in sparse fieldsets', async () => {
352
+ await assert.rejects(
353
+ api.resources.products.get({
354
+ id: testData.product.id,
355
+ queryParams: {
356
+ fields: { products: 'name,unknown_field' }
357
+ }
358
+ }),
359
+ /Unknown sparse field 'unknown_field'/
360
+ );
361
+ });
362
+
363
+ it('should reject requests for _ids fields in sparse fieldsets', async () => {
364
+ await assert.rejects(
365
+ api.resources.products.get({
366
+ id: testData.product.id,
367
+ queryParams: {
368
+ fields: { products: 'name,reviews_ids' }
369
+ }
370
+ }),
371
+ /Unknown sparse field 'reviews_ids'/
372
+ );
373
+ });
374
+ });
375
+
376
+ describe('Async Computed Fields', () => {
377
+ it('should support async compute functions', async () => {
378
+ // Add a resource with async computed field
379
+ await api.addResource('async_products', {
380
+ schema: {
381
+ id: { type: 'id' },
382
+ name: { type: 'string', required: true },
383
+ external_id: { type: 'string' },
384
+ external_data: {
385
+ type: 'string',
386
+ computed: true,
387
+ dependencies: ['external_id'],
388
+ compute: async ({ attributes }) => {
389
+ // Simulate async operation (e.g., external API call)
390
+ await new Promise(resolve => setTimeout(resolve, 10));
391
+ return `fetched-${attributes.external_id}`;
392
+ }
393
+ },
394
+ computed_name: {
395
+ type: 'string',
396
+ computed: true,
397
+ dependencies: ['name'],
398
+ compute: async ({ attributes }) => {
399
+ // Another async operation
400
+ await new Promise(resolve => setTimeout(resolve, 5));
401
+ return attributes.name.toUpperCase();
402
+ }
403
+ }
404
+ },
405
+ tableName: 'test_async_products'
406
+ });
407
+ await api.resources.async_products.createKnexTable();
408
+
409
+ const product = await api.resources.async_products.post({
410
+ name: 'Async Product',
411
+ external_id: 'ext-123'
412
+ });
413
+
414
+ const fetched = await api.resources.async_products.get({ id: product.id });
415
+ assert.equal(fetched.external_data, 'fetched-ext-123');
416
+ assert.equal(fetched.computed_name, 'ASYNC PRODUCT');
417
+ });
418
+
419
+ it('should handle errors in async compute functions', async () => {
420
+ // Add a resource with failing async computed field
421
+ await api.addResource('failing_async_products', {
422
+ schema: {
423
+ id: { type: 'id' },
424
+ value: { type: 'number' },
425
+ failing_async: {
426
+ type: 'string',
427
+ computed: true,
428
+ dependencies: ['value'],
429
+ compute: async ({ attributes }) => {
430
+ await new Promise(resolve => setTimeout(resolve, 5));
431
+ throw new Error('Async computation failed');
432
+ }
433
+ }
434
+ },
435
+ tableName: 'test_failing_async_products'
436
+ });
437
+ await api.resources.failing_async_products.createKnexTable();
438
+
439
+ const product = await api.resources.failing_async_products.post({ value: 42 });
440
+ const fetched = await api.resources.failing_async_products.get({ id: product.id });
441
+
442
+ assert.equal(fetched.value, 42);
443
+ assert.equal(fetched.failing_async, null); // Error results in null
444
+ });
445
+ });
446
+
447
+ describe('Performance Considerations', () => {
448
+ it('should only compute requested computed fields', async () => {
449
+ // Track compute calls
450
+ let profitMarginCalls = 0;
451
+ let profitAmountCalls = 0;
452
+
453
+ // Override compute functions to track calls
454
+ const originalProfitMargin = api.resources.products.vars.schemaInfo.computed.profit_margin.compute;
455
+ const originalProfitAmount = api.resources.products.vars.schemaInfo.computed.profit_amount.compute;
456
+
457
+ api.resources.products.vars.schemaInfo.computed.profit_margin.compute = (ctx) => {
458
+ profitMarginCalls++;
459
+ return originalProfitMargin(ctx);
460
+ };
461
+
462
+ api.resources.products.vars.schemaInfo.computed.profit_amount.compute = (ctx) => {
463
+ profitAmountCalls++;
464
+ return originalProfitAmount(ctx);
465
+ };
466
+
467
+ // Request only profit_margin
468
+ await api.resources.products.get({
469
+ id: testData.product.id,
470
+ queryParams: {
471
+ fields: { products: 'name,profit_margin' }
472
+ }
473
+ });
474
+
475
+ assert.equal(profitMarginCalls, 1);
476
+ assert.equal(profitAmountCalls, 0); // Should not be computed
477
+
478
+ // Restore original functions
479
+ api.resources.products.vars.schemaInfo.computed.profit_margin.compute = originalProfitMargin;
480
+ api.resources.products.vars.schemaInfo.computed.profit_amount.compute = originalProfitAmount;
481
+ });
482
+ });
483
+ });
@@ -48,7 +48,7 @@ describe('CORS Plugin Tests', { timeout: 30000 }, () => {
48
48
  await baseUrlApi.use((await import('../plugins/core/rest-api-knex-plugin.js')).RestApiKnexPlugin, { knex });
49
49
  await baseUrlApi.use((await import('../plugins/core/connectors/express-plugin.js')).ExpressPlugin, {
50
50
  app: baseUrlApp,
51
- basePath: '/v1' // Set a base path
51
+ mountPath: '/v1/api'
52
52
  });
53
53
 
54
54
  // Add basic scope
@@ -74,7 +74,7 @@ describe('CORS Plugin Tests', { timeout: 30000 }, () => {
74
74
  } catch (error) {
75
75
  console.log('[TEST] CORS installation error:', error.message);
76
76
  console.log('[TEST] vars.transport.matchAll:', baseUrlApi.vars.transport?.matchAll);
77
- console.log('[TEST] basePath from Express:', '/v1');
77
+ console.log('[TEST] mountPath from transport:', baseUrlApi.vars.transport?.mountPath);
78
78
  console.log('[TEST] Full wildcard path would be:', '/v1' + (baseUrlApi.vars.transport?.matchAll || '*'));
79
79
  throw error;
80
80
  }
@@ -369,7 +369,7 @@ describe('CORS Plugin Tests', { timeout: 30000 }, () => {
369
369
  .set('Content-Type', 'application/vnd.api+json')
370
370
  .send(putDoc);
371
371
 
372
- assert.equal(response.status, 200);
372
+ assert.equal(response.status, 204);
373
373
  assert.equal(response.headers['access-control-allow-origin'], 'https://example.com');
374
374
  assert.equal(response.headers['vary'], 'Origin');
375
375
  assert.equal(response.headers['access-control-expose-headers'], 'X-Total-Count, X-Page-Count, Link');
@@ -754,7 +754,7 @@ describe('CORS Plugin Tests', { timeout: 30000 }, () => {
754
754
  .set('Content-Type', 'application/vnd.api+json')
755
755
  .set('Accept', 'application/vnd.api+json');
756
756
 
757
- assert.equal(response.status, 200);
757
+ assert.equal(response.status, 204);
758
758
  assert.equal(response.headers['access-control-allow-origin'], 'https://example.com');
759
759
  assert.equal(response.headers['access-control-allow-credentials'], 'true');
760
760
  assert.equal(response.headers['vary'], 'Origin');
@@ -181,9 +181,10 @@ describe('CURL HTTP Abstraction Layer Tests', () => {
181
181
 
182
182
  const response = JSON.parse(result.stdout);
183
183
 
184
- // PATCH is configured to return only ID, not full record
185
- assert(response.id, 'Should return ID');
186
- assert.equal(response.id, String(testData.country.id), 'Should return correct ID');
184
+ // PATCH is configured to return minimal JSON:API response
185
+ assert(response.data, 'Should have data');
186
+ assert.equal(response.data.type, 'countries', 'Should have correct type');
187
+ assert.equal(response.data.id, String(testData.country.id), 'Should return correct ID');
187
188
 
188
189
  // Verify the update by fetching the record
189
190
  const verifyResult = await basicApi.resources.countries.get({
@@ -429,7 +430,7 @@ describe('CURL HTTP Abstraction Layer Tests', () => {
429
430
  validateJsonApiStructure(response, true);
430
431
 
431
432
  assert.equal(response.data.length, 3, 'Should return 3 books per page');
432
- // Note: Links are only included if resourceUrlPrefix is configured, which it isn't in basicApi
433
+ // Note: Links are now always included, using relative URLs when publicBaseUrl isn't configured
433
434
  });
434
435
 
435
436
  it('should handle sorting using CURL', async () => {