json-rest-api 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +99 -33
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
|
@@ -58,15 +58,15 @@ export const SocketIOPlugin = {
|
|
|
58
58
|
continue;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Check if
|
|
62
|
-
if (typeof fieldDef.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/tests/cors.test.js
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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,
|
|
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,
|
|
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');
|
package/tests/curl.test.js
CHANGED
|
@@ -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
|
|
185
|
-
assert(response.
|
|
186
|
-
assert.equal(response.
|
|
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
|
|
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 () => {
|