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.
- 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 +90 -32
- 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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { RestApiResourceError } from '../../../lib/rest-api-errors.js';
|
|
2
|
+
import { validatePutPayload } from '../lib/querying-writing/payload-validators.js';
|
|
3
|
+
import { processRelationships } from '../lib/writing/relationship-processor.js';
|
|
4
|
+
import { updateManyToManyRelationship, createPivotRecords } from '../lib/writing/many-to-many-manipulations.js';
|
|
5
|
+
import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js';
|
|
6
|
+
import {
|
|
7
|
+
setupCommonRequest,
|
|
8
|
+
validateResourceAttributesBeforeWrite,
|
|
9
|
+
validateRelationshipAccess,
|
|
10
|
+
applyFieldSetters,
|
|
11
|
+
validatePivotResource,
|
|
12
|
+
handleRecordReturnAfterWrite,
|
|
13
|
+
handleWriteMethodError
|
|
14
|
+
} from './common.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PUT
|
|
18
|
+
* Updates an existing top-level resource by completely replacing it.
|
|
19
|
+
* This method supports updating both attributes and relationships (1:1 and n:n).
|
|
20
|
+
* PUT is a complete replacement - any relationships not provided will be removed/nulled.
|
|
21
|
+
* This method does NOT support creating new related resources via an `included` array.
|
|
22
|
+
*/
|
|
23
|
+
export default async function putMethod({
|
|
24
|
+
params,
|
|
25
|
+
context,
|
|
26
|
+
vars,
|
|
27
|
+
helpers,
|
|
28
|
+
scope,
|
|
29
|
+
scopes,
|
|
30
|
+
runHooks,
|
|
31
|
+
apiOptions,
|
|
32
|
+
pluginOptions,
|
|
33
|
+
scopeOptions,
|
|
34
|
+
scopeName,
|
|
35
|
+
api,
|
|
36
|
+
log
|
|
37
|
+
}) {
|
|
38
|
+
context.method = 'put';
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { schema, schemaStructure, schemaRelationships } = await setupCommonRequest({
|
|
42
|
+
params,
|
|
43
|
+
context,
|
|
44
|
+
vars,
|
|
45
|
+
scopes,
|
|
46
|
+
scopeOptions,
|
|
47
|
+
scopeName,
|
|
48
|
+
api,
|
|
49
|
+
helpers
|
|
50
|
+
});
|
|
51
|
+
context.id = context.inputRecord.data.id;
|
|
52
|
+
|
|
53
|
+
// Run early hooks for pre-processing (e.g., file handling)
|
|
54
|
+
await runHooks('beforeProcessing');
|
|
55
|
+
await runHooks('beforeProcessingPut');
|
|
56
|
+
|
|
57
|
+
// Validate PUT payload to ensure it's a complete resource replacement operation.
|
|
58
|
+
// PUT requires the full resource representation including ID (unlike POST which generates ID).
|
|
59
|
+
// It validates that data.id matches the URL parameter, prevents 'included' array (which is
|
|
60
|
+
// read-only), and ensures the payload represents a complete replacement. Any fields not
|
|
61
|
+
// provided will be removed or reset to defaults - this is the key difference from PATCH.
|
|
62
|
+
// Example: PUT to /articles/123 must have data.id: '123' and all required fields.
|
|
63
|
+
validatePutPayload(context.inputRecord, scopes);
|
|
64
|
+
|
|
65
|
+
// Validate that user has read access to all related resources
|
|
66
|
+
// This ensures users can only create relationships to resources they can access
|
|
67
|
+
await validateRelationshipAccess(context, context.inputRecord, helpers, runHooks, api);
|
|
68
|
+
|
|
69
|
+
// Extract foreign keys from JSON:API relationships and prepare many-to-many operations
|
|
70
|
+
// Example: relationships.author -> author_id: '123' for storage
|
|
71
|
+
// Example: relationships.tags -> array of pivot records to create later
|
|
72
|
+
const { belongsToUpdates, manyToManyRelationships } = processRelationships(
|
|
73
|
+
scope,
|
|
74
|
+
{ context }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await validateResourceAttributesBeforeWrite({
|
|
78
|
+
context,
|
|
79
|
+
schema,
|
|
80
|
+
belongsToUpdates,
|
|
81
|
+
runHooks
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check existence first
|
|
85
|
+
context.exists = await helpers.dataExists({
|
|
86
|
+
scopeName,
|
|
87
|
+
context
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
context.isCreate = !context.exists;
|
|
91
|
+
context.isUpdate = context.exists;
|
|
92
|
+
|
|
93
|
+
// Fetch minimal record for authorization checks (only for updates)
|
|
94
|
+
if (context.isUpdate) {
|
|
95
|
+
const minimalRecord = await helpers.dataGetMinimal({
|
|
96
|
+
scopeName,
|
|
97
|
+
context,
|
|
98
|
+
runHooks
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!minimalRecord) {
|
|
102
|
+
throw new RestApiResourceError(
|
|
103
|
+
`Resource not found: ${scopeName}/${context.id}`,
|
|
104
|
+
ERROR_SUBTYPES.NOT_FOUND
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
context.minimalRecord = minimalRecord;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For PUT, we also need to handle relationships that are NOT provided
|
|
112
|
+
// (they should be set to null/empty as PUT is a complete replacement)
|
|
113
|
+
const allRelationships = {};
|
|
114
|
+
|
|
115
|
+
// Collect all defined relationships for this resource
|
|
116
|
+
for (const [relName, relDef] of Object.entries(schemaRelationships || {})) {
|
|
117
|
+
if (relDef.manyToMany) {
|
|
118
|
+
allRelationships[relName] = { type: 'manyToMany', relDef: relDef.manyToMany };
|
|
119
|
+
}
|
|
120
|
+
// Also recognize hasMany with through as many-to-many
|
|
121
|
+
else if (relDef.hasMany && relDef.through) {
|
|
122
|
+
allRelationships[relName] = {
|
|
123
|
+
type: 'manyToMany',
|
|
124
|
+
relDef: {
|
|
125
|
+
through: relDef.through,
|
|
126
|
+
foreignKey: relDef.foreignKey,
|
|
127
|
+
otherKey: relDef.otherKey
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Also check schema fields for belongsTo relationships
|
|
134
|
+
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
135
|
+
if (fieldDef.as && fieldDef.belongsTo) {
|
|
136
|
+
allRelationships[fieldDef.as] = {
|
|
137
|
+
type: 'belongsTo',
|
|
138
|
+
fieldName,
|
|
139
|
+
fieldDef
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Process missing relationships (PUT should null them out only if relationships object exists)
|
|
145
|
+
const hasRelationshipsObject = context.inputRecord.data.relationships !== undefined;
|
|
146
|
+
const providedRelationships = new Set(Object.keys(context.inputRecord.data.relationships || {}));
|
|
147
|
+
|
|
148
|
+
// Only null out missing relationships if a relationships object was provided
|
|
149
|
+
if (hasRelationshipsObject) {
|
|
150
|
+
for (const [relName, relInfo] of Object.entries(allRelationships)) {
|
|
151
|
+
if (!providedRelationships.has(relName)) {
|
|
152
|
+
if (relInfo.type === 'belongsTo') {
|
|
153
|
+
belongsToUpdates[relInfo.fieldName] = null;
|
|
154
|
+
} else if (relInfo.type === 'manyToMany') {
|
|
155
|
+
// Add to manyToManyRelationships with empty array
|
|
156
|
+
manyToManyRelationships.push({
|
|
157
|
+
relName,
|
|
158
|
+
relDef: relInfo.relDef,
|
|
159
|
+
relData: [] // Empty array means delete all
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Merge belongsTo updates with attributes
|
|
167
|
+
if (Object.keys(belongsToUpdates).length > 0) {
|
|
168
|
+
context.inputRecord.data.attributes = {
|
|
169
|
+
...context.inputRecord.data.attributes,
|
|
170
|
+
...belongsToUpdates
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Centralised checkPermissions function
|
|
175
|
+
await scope.checkPermissions({
|
|
176
|
+
method: 'put',
|
|
177
|
+
auth: context.auth,
|
|
178
|
+
id: context.id,
|
|
179
|
+
minimalRecord: context.minimalRecord,
|
|
180
|
+
isUpdate: context.isUpdate,
|
|
181
|
+
transaction: context.transaction
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await runHooks('beforeDataCall');
|
|
185
|
+
await runHooks('beforeDataCallPut');
|
|
186
|
+
|
|
187
|
+
// Apply field setters after validation and before storage
|
|
188
|
+
if (context.inputRecord?.data?.attributes) {
|
|
189
|
+
context.inputRecord.data.attributes = await applyFieldSetters(
|
|
190
|
+
context.inputRecord.data.attributes,
|
|
191
|
+
context.schemaInfo,
|
|
192
|
+
context,
|
|
193
|
+
api,
|
|
194
|
+
helpers
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Pass the operation type to the helper
|
|
199
|
+
await helpers.dataPut({
|
|
200
|
+
scopeName,
|
|
201
|
+
context
|
|
202
|
+
});
|
|
203
|
+
await runHooks('afterDataCallPut');
|
|
204
|
+
await runHooks('afterDataCall');
|
|
205
|
+
|
|
206
|
+
// Process many-to-many relationships after main record update/creation
|
|
207
|
+
for (const { relName, relDef, relData } of manyToManyRelationships) {
|
|
208
|
+
// Validate pivot resource exists
|
|
209
|
+
await validatePivotResource(scopes, relDef, relName);
|
|
210
|
+
|
|
211
|
+
// Use smart sync for updates (like industry standard ORMs)
|
|
212
|
+
if (context.isUpdate) {
|
|
213
|
+
// Update many-to-many relationships using intelligent synchronization
|
|
214
|
+
// This preserves existing pivot data while efficiently updating relationships
|
|
215
|
+
await updateManyToManyRelationship(null, {
|
|
216
|
+
api,
|
|
217
|
+
context: {
|
|
218
|
+
resourceId: context.id,
|
|
219
|
+
relDef,
|
|
220
|
+
relData,
|
|
221
|
+
transaction: context.transaction
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
// For new records, just create the pivot records
|
|
226
|
+
if (relData.length > 0) {
|
|
227
|
+
await createPivotRecords(api, context.id, relDef, relData, context.transaction);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ret = await handleRecordReturnAfterWrite({
|
|
233
|
+
context,
|
|
234
|
+
scopeName,
|
|
235
|
+
api,
|
|
236
|
+
scopes,
|
|
237
|
+
schemaStructure,
|
|
238
|
+
schemaRelationships,
|
|
239
|
+
scopeOptions,
|
|
240
|
+
vars,
|
|
241
|
+
runHooks,
|
|
242
|
+
helpers,
|
|
243
|
+
log
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Commit transaction if we created it
|
|
247
|
+
if (context.shouldCommit) {
|
|
248
|
+
await context.transaction.commit();
|
|
249
|
+
await runHooks('afterCommit');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return ret;
|
|
253
|
+
|
|
254
|
+
} catch (error) {
|
|
255
|
+
await handleWriteMethodError(error, context, 'PUT', scopeName, log, runHooks);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { RestApiValidationError } from '../../../lib/rest-api-errors.js';
|
|
2
|
+
import { validateQueryPayload } from '../lib/querying-writing/payload-validators.js';
|
|
3
|
+
import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js';
|
|
4
|
+
import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js';
|
|
5
|
+
import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js';
|
|
6
|
+
import { cascadeConfig } from './common.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* QUERY
|
|
10
|
+
* Retrieves a collection of resources (e.g., a list of articles) based on provided criteria.
|
|
11
|
+
* This function sends a GET request to /api/{resourceType}.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} resourceType - The type of resource collection to fetch (e.g., "articles").
|
|
14
|
+
* @param {object} [queryParams={}] - Optional. An object to customize the query for the collection.
|
|
15
|
+
* @param {string[]} [queryParams.include=[]] - An optional array of relationship paths to sideload for each resource in the collection. These paths will be converted to a comma-separated string for the URL (e.g., `['author', 'comments.user']` becomes `author,comments.user`). Supports deep relationships (e.g., "publisher.country").
|
|
16
|
+
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets) for each resource in the collection and its included relationships. Keys are resource types, values are comma-separated field names.
|
|
17
|
+
* @param {object} [queryParams.filters] - An object to filter the collection. Keys are filter parameters (specific to your API's implementation, e.g., 'status', 'title'), values are the filter criteria.
|
|
18
|
+
* @param {string[]} [queryParams.sort=[]] - An optional array of fields to sort the collection by. Each string represents a field; prefix with '-' for descending order (e.g., `['title', '-published-date']` becomes `title,-published-date`).
|
|
19
|
+
* @param {object} [queryParams.page] - An object for pagination. Typically includes `number` (page number) and `size` (items per page). E.g., `{ number: 1, size: 10 }`.
|
|
20
|
+
* @returns {Promise<object>} A Promise that resolves to the JSON:API response document containing the resource collection.
|
|
21
|
+
*/
|
|
22
|
+
export default async function queryMethod({
|
|
23
|
+
params,
|
|
24
|
+
context,
|
|
25
|
+
vars,
|
|
26
|
+
helpers,
|
|
27
|
+
scope,
|
|
28
|
+
scopes,
|
|
29
|
+
runHooks,
|
|
30
|
+
apiOptions,
|
|
31
|
+
pluginOptions,
|
|
32
|
+
scopeOptions,
|
|
33
|
+
scopeName,
|
|
34
|
+
log,
|
|
35
|
+
api
|
|
36
|
+
}) {
|
|
37
|
+
context.method = 'query';
|
|
38
|
+
|
|
39
|
+
// Determine which simplified setting to use based on transport
|
|
40
|
+
const isTransport = params.isTransport === true;
|
|
41
|
+
|
|
42
|
+
// Use vars which automatically cascade from scope to global
|
|
43
|
+
const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
44
|
+
|
|
45
|
+
// Get simplified setting - from params only (per-call override) or use default
|
|
46
|
+
context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified;
|
|
47
|
+
|
|
48
|
+
// Assign common context properties
|
|
49
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo; // This is the object variable created by compileSchemas
|
|
50
|
+
context.queryParams = params.queryParams || {};
|
|
51
|
+
|
|
52
|
+
// These only make sense as parameter per query
|
|
53
|
+
context.queryParams.fields = cascadeConfig('fields', [params.queryParams], {});
|
|
54
|
+
context.queryParams.include = cascadeConfig('include', [params.queryParams], []);
|
|
55
|
+
context.queryParams.sort = cascadeConfig('sort', [params.queryParams], []);
|
|
56
|
+
context.queryParams.page = cascadeConfig('page', [params.queryParams], {});
|
|
57
|
+
|
|
58
|
+
context.transaction = params.transaction;
|
|
59
|
+
context.db = context.transaction || api.knex.instance;
|
|
60
|
+
|
|
61
|
+
context.scopeName = scopeName;
|
|
62
|
+
|
|
63
|
+
// These are just shortcuts used in this function and will be returned
|
|
64
|
+
const searchSchema = context.schemaInfo.searchSchema;
|
|
65
|
+
const schemaStructure = context.schemaInfo.schema.structure;
|
|
66
|
+
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
67
|
+
|
|
68
|
+
// Sortable fields and sort (mab)
|
|
69
|
+
context.sortableFields = vars.sortableFields;
|
|
70
|
+
// Apply default sort if no sort specified
|
|
71
|
+
if (context.queryParams.sort.length === 0 && vars.defaultSort) {
|
|
72
|
+
context.queryParams.sort = Array.isArray(vars.defaultSort) ? vars.defaultSort : [vars.defaultSort];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Validate query parameters to ensure they follow JSON:API specification and security rules.
|
|
76
|
+
// This checks that filters are valid field names, sort fields exist in sortableFields array
|
|
77
|
+
// (preventing SQL injection), pagination uses valid page[size]/page[number] format, and include
|
|
78
|
+
// paths reference real relationships. Example: sort: ['-createdAt', 'title'] is checked against
|
|
79
|
+
// sortableFields to ensure users can't sort by sensitive fields like 'password_hash'.
|
|
80
|
+
validateQueryPayload({ queryParams: context.queryParams }, context.sortableFields, vars.includeDepthLimit);
|
|
81
|
+
|
|
82
|
+
// Validate search/filter parameters against searchSchema
|
|
83
|
+
if (context.queryParams.filters && Object.keys(context.queryParams.filters).length > 0) {
|
|
84
|
+
// Only allow filtering if searchSchema is defined
|
|
85
|
+
if (!searchSchema) {
|
|
86
|
+
throw new RestApiValidationError(
|
|
87
|
+
`Filtering is not enabled for resource '${scopeName}'. To enable filtering, add 'search: true' to schema fields or define a searchSchema.`,
|
|
88
|
+
{
|
|
89
|
+
fields: Object.keys(context.queryParams.filters).map(field => `filters.${field}`),
|
|
90
|
+
violations: [{
|
|
91
|
+
field: 'filters',
|
|
92
|
+
rule: 'filtering_not_enabled',
|
|
93
|
+
message: 'Resource does not have searchable fields defined'
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate the filter parameters searchSchema
|
|
100
|
+
const { validatedObject, errors } = await searchSchema.validate(context.queryParams.filters, {
|
|
101
|
+
onlyObjectValues: true // Partial validation for filters
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// If there are validation errors, throw an error
|
|
105
|
+
if (Object.keys(errors).length > 0) {
|
|
106
|
+
const violations = Object.entries(errors).map(([field, error]) => ({
|
|
107
|
+
field: `filters.${field}`,
|
|
108
|
+
rule: error.code || 'invalid_value',
|
|
109
|
+
message: error.message
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
throw new RestApiValidationError(
|
|
113
|
+
'Invalid filter parameters',
|
|
114
|
+
{
|
|
115
|
+
fields: Object.keys(errors).map(field => `filters.${field}`),
|
|
116
|
+
violations
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Replace filter with validated/transformed values
|
|
122
|
+
context.queryParams.filters = validatedObject;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Centralised checkPermissions function
|
|
126
|
+
await scope.checkPermissions({
|
|
127
|
+
method: 'query',
|
|
128
|
+
auth: context.auth,
|
|
129
|
+
transaction: context.transaction
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await runHooks('beforeData');
|
|
133
|
+
await runHooks('beforeDataQuery');
|
|
134
|
+
context.record = await helpers.dataQuery({
|
|
135
|
+
scopeName,
|
|
136
|
+
context,
|
|
137
|
+
runHooks
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Normalize database values (e.g., convert 1/0 to true/false for booleans)
|
|
141
|
+
context.record = normalizeRecordAttributes(context.record, scopes);
|
|
142
|
+
|
|
143
|
+
// Make a backup
|
|
144
|
+
try {
|
|
145
|
+
context.originalRecord = structuredClone(context.record);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
log.error('Failed to clone record:', {
|
|
148
|
+
error: e.message,
|
|
149
|
+
recordKeys: Object.keys(context.record || {}),
|
|
150
|
+
hasHttpRequest: !!context.raw?.req
|
|
151
|
+
});
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// This will enhance record, which is the WHOLE JSON:API record
|
|
156
|
+
await runHooks('enrichRecord');
|
|
157
|
+
|
|
158
|
+
// Get computed field information for main resource
|
|
159
|
+
const computedFields = scope.vars.schemaInfo?.computed || {};
|
|
160
|
+
const requestedFields = context.queryParams.fields?.[scopeName];
|
|
161
|
+
const requestedComputedFields = getRequestedComputedFields(scopeName, requestedFields, computedFields);
|
|
162
|
+
|
|
163
|
+
// Run enrichAttributes for every single set of attribute, calling it from the right scope
|
|
164
|
+
for (const entry of context.record.data) {
|
|
165
|
+
entry.attributes = await scope.enrichAttributes({
|
|
166
|
+
attributes: entry.attributes,
|
|
167
|
+
parentContext: context,
|
|
168
|
+
requestedComputedFields: requestedComputedFields,
|
|
169
|
+
isMainResource: true,
|
|
170
|
+
computedDependencies: context.computedDependencies
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const entry of (context.record.included || [])) {
|
|
174
|
+
const entryScope = scopes[entry.type];
|
|
175
|
+
const entryComputed = entryScope.vars.schemaInfo?.computed || {};
|
|
176
|
+
const entryRequestedFields = context.queryParams.fields?.[entry.type];
|
|
177
|
+
const entryRequestedComputed = getRequestedComputedFields(
|
|
178
|
+
entry.type,
|
|
179
|
+
entryRequestedFields,
|
|
180
|
+
entryComputed
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
entry.attributes = await entryScope.enrichAttributes({
|
|
184
|
+
attributes: entry.attributes,
|
|
185
|
+
parentContext: context,
|
|
186
|
+
requestedComputedFields: entryRequestedComputed,
|
|
187
|
+
isMainResource: false,
|
|
188
|
+
computedDependencies: entry.__$jsonrestapi_computed_deps$__
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The called hooks should NOT change context.record
|
|
193
|
+
await runHooks('finish');
|
|
194
|
+
await runHooks('finishQuery');
|
|
195
|
+
|
|
196
|
+
// Transform output if in simplified mode
|
|
197
|
+
if (context.simplified) {
|
|
198
|
+
// Convert JSON:API response back to simplified format
|
|
199
|
+
// Example: {data: {type: 'posts', id: '1', attributes: {title: 'My Post'}, relationships: {author: {data: {type: 'users', id: '123'}}}}}
|
|
200
|
+
// becomes: {id: '1', title: 'My Post', author_id: '123'} - flattens structure and restores foreign keys
|
|
201
|
+
return transformJsonApiToSimplified(
|
|
202
|
+
{ record: context.record },
|
|
203
|
+
{ context: { schemaStructure, schemaRelationships, scopes } }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return context.record;
|
|
208
|
+
}
|