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
|
@@ -1,700 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
RestApiResourceError,
|
|
3
|
-
RestApiValidationError,
|
|
4
|
-
RestApiPayloadError
|
|
5
|
-
} from '../../lib/rest-api-errors.js';
|
|
6
|
-
import { createPivotRecords } from './lib/many-to-many-manipulations.js';
|
|
7
|
-
import { parseJsonApiQuery } from './utils/connectors-query-parser.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Helper function to get relationship definition from either schemaRelationships or schema fields
|
|
11
|
-
*/
|
|
12
|
-
const findRelationshipDefinition = (schemaInfo, relationshipName) => {
|
|
13
|
-
// First check schemaRelationships (for relationships defined in relationships object)
|
|
14
|
-
const relDef = schemaInfo.schemaRelationships?.[relationshipName];
|
|
15
|
-
if (relDef) {
|
|
16
|
-
return relDef;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Then check schema fields for belongsTo relationships with matching 'as' property
|
|
20
|
-
for (const [fieldName, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
|
|
21
|
-
if (fieldDef.as === relationshipName && (fieldDef.belongsTo || fieldDef.belongsToPolymorphic)) {
|
|
22
|
-
return fieldDef;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return null;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Helper function to handle errors in write methods
|
|
31
|
-
*/
|
|
32
|
-
const handleWriteMethodError = async (error, context, method, scopeName, log) => {
|
|
33
|
-
if (context.shouldCommit && context.transaction) {
|
|
34
|
-
try {
|
|
35
|
-
await context.transaction.rollback();
|
|
36
|
-
} catch (rollbackError) {
|
|
37
|
-
log.error(`Failed to rollback transaction after error in ${method}:`, rollbackError);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
throw error;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const RestApiRelationshipsPlugin = {
|
|
44
|
-
name: 'rest-api-relationships',
|
|
45
|
-
dependencies: ['rest-api', 'rest-api-knex'],
|
|
46
|
-
|
|
47
|
-
async install({ api, addScopeMethod, helpers, on, log, runHooks, addHook, scopes }) {
|
|
48
|
-
|
|
49
|
-
// Store api reference for use in scope methods
|
|
50
|
-
const apiRef = api;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* GET RELATIONSHIP
|
|
54
|
-
* Retrieves relationship linkage data (just resource identifiers)
|
|
55
|
-
* GET /api/articles/1/relationships/author
|
|
56
|
-
*
|
|
57
|
-
* @param {string} id - The ID of the resource
|
|
58
|
-
* @param {string} relationshipName - The name of the relationship
|
|
59
|
-
* @returns {Promise<object>} Relationship linkage with links
|
|
60
|
-
*/
|
|
61
|
-
addScopeMethod('getRelationship', async ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) => {
|
|
62
|
-
context.method = 'getRelationship';
|
|
63
|
-
context.id = params.id;
|
|
64
|
-
context.relationshipName = params.relationshipName;
|
|
65
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
66
|
-
|
|
67
|
-
// Validate the relationship exists
|
|
68
|
-
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
69
|
-
|
|
70
|
-
if (!relDef) {
|
|
71
|
-
throw new RestApiResourceError(
|
|
72
|
-
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
73
|
-
{ subtype: 'relationship_not_found' }
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Check permissions
|
|
78
|
-
await runHooks('checkPermissions');
|
|
79
|
-
await runHooks('checkPermissionsGetRelationship');
|
|
80
|
-
|
|
81
|
-
// Reuse existing get method with minimal fields
|
|
82
|
-
const fullRecord = await scope.get({
|
|
83
|
-
id: context.id,
|
|
84
|
-
queryParams: {
|
|
85
|
-
include: [context.relationshipName],
|
|
86
|
-
fields: { [scopeName]: vars.idProperty || 'id' }
|
|
87
|
-
},
|
|
88
|
-
transaction: context.transaction,
|
|
89
|
-
simplified: false,
|
|
90
|
-
isTransport: params.isTransport
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
if (!fullRecord || !fullRecord.data) {
|
|
94
|
-
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Extract just the relationship data
|
|
98
|
-
const relationshipData = fullRecord.data.relationships?.[context.relationshipName];
|
|
99
|
-
|
|
100
|
-
// Build response with links
|
|
101
|
-
return {
|
|
102
|
-
links: {
|
|
103
|
-
self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/relationships/${context.relationshipName}`,
|
|
104
|
-
related: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}`
|
|
105
|
-
},
|
|
106
|
-
data: relationshipData?.data || (relDef.hasMany || relDef.manyToMany ? [] : null)
|
|
107
|
-
};
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* GET RELATED
|
|
112
|
-
* Retrieves the actual related resources (full data)
|
|
113
|
-
* GET /api/articles/1/comments
|
|
114
|
-
*
|
|
115
|
-
* @param {string} id - The ID of the parent resource
|
|
116
|
-
* @param {string} relationshipName - The name of the relationship
|
|
117
|
-
* @param {object} queryParams - Standard query parameters
|
|
118
|
-
* @returns {Promise<object>} Related resources with full data
|
|
119
|
-
*/
|
|
120
|
-
addScopeMethod('getRelated', async ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) => {
|
|
121
|
-
context.method = 'getRelated';
|
|
122
|
-
context.id = params.id;
|
|
123
|
-
context.relationshipName = params.relationshipName;
|
|
124
|
-
context.queryParams = params.queryParams || {};
|
|
125
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
126
|
-
context.transaction = params.transaction;
|
|
127
|
-
context.db = context.transaction || api.knex.instance
|
|
128
|
-
|
|
129
|
-
// Validate the relationship exists
|
|
130
|
-
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
131
|
-
|
|
132
|
-
if (!relDef) {
|
|
133
|
-
throw new RestApiResourceError(
|
|
134
|
-
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
135
|
-
{ subtype: 'relationship_not_found' }
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Determine target type - relationships are stored as strings
|
|
140
|
-
const targetType = relDef.belongsTo || relDef.hasMany ||
|
|
141
|
-
relDef.manyToMany || relDef.hasOne;
|
|
142
|
-
|
|
143
|
-
if (!targetType || !scopes[targetType]) {
|
|
144
|
-
throw new RestApiResourceError(
|
|
145
|
-
`Related resource type '${targetType}' not found`,
|
|
146
|
-
{ subtype: 'related_type_not_found' }
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Check permissions
|
|
151
|
-
await runHooks('checkPermissions');
|
|
152
|
-
await runHooks('checkPermissionsGetRelated');
|
|
153
|
-
|
|
154
|
-
// Verify parent exists
|
|
155
|
-
const exists = await helpers.dataExists({
|
|
156
|
-
scopeName,
|
|
157
|
-
context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo }
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
if (!exists) {
|
|
161
|
-
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Handle to-one relationships (belongsTo and hasOne)
|
|
165
|
-
// For example: GET /api/books/1/country or GET /api/books/1/publisher
|
|
166
|
-
if (relDef.belongsTo || relDef.hasOne) {
|
|
167
|
-
// OPTIMIZATION: Detect if we actually need to make two API calls
|
|
168
|
-
//
|
|
169
|
-
// The naive approach always makes 2 calls:
|
|
170
|
-
// 1. Get parent with relationship included (fetches FULL related record)
|
|
171
|
-
// 2. Extract just the ID and fetch the same record again with queryParams
|
|
172
|
-
//
|
|
173
|
-
// This optimization checks if there are queryParams that would affect
|
|
174
|
-
// the related resource. If not, we can use the data from the first call.
|
|
175
|
-
const hasRelevantQueryParams = context.queryParams && (
|
|
176
|
-
// Check for includes on the related resource (e.g., ?include=some.nested.relation)
|
|
177
|
-
context.queryParams.include?.length > 0 ||
|
|
178
|
-
// Check for field selection on the related resource (e.g., ?fields[countries]=name,code)
|
|
179
|
-
context.queryParams.fields?.[targetType] ||
|
|
180
|
-
// Note: Filters and sorting don't make sense for a single to-one relationship
|
|
181
|
-
// so we don't check for them
|
|
182
|
-
false
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
if (hasRelevantQueryParams) {
|
|
186
|
-
// CASE 1: Has queryParams that affect the related resource
|
|
187
|
-
// We need to make two calls to properly apply the queryParams
|
|
188
|
-
|
|
189
|
-
// First call: Get parent with minimal data (just need the related ID)
|
|
190
|
-
const parent = await scope.get({
|
|
191
|
-
id: context.id,
|
|
192
|
-
queryParams: {
|
|
193
|
-
include: [context.relationshipName],
|
|
194
|
-
fields: { [scopeName]: vars.idProperty || 'id' } // Only fetch parent ID to minimize data
|
|
195
|
-
},
|
|
196
|
-
transaction: context.transaction,
|
|
197
|
-
simplified: false,
|
|
198
|
-
isTransport: params.isTransport
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id;
|
|
202
|
-
if (!relatedId) {
|
|
203
|
-
return {
|
|
204
|
-
links: { self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
205
|
-
data: null
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Second call: Get the related resource with all queryParams applied
|
|
210
|
-
const related = await apiRef.resources[targetType].get({
|
|
211
|
-
id: relatedId,
|
|
212
|
-
queryParams: context.queryParams,
|
|
213
|
-
transaction: context.transaction,
|
|
214
|
-
simplified: false,
|
|
215
|
-
isTransport: params.isTransport
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
links: { self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
220
|
-
data: related.data,
|
|
221
|
-
included: related.included
|
|
222
|
-
};
|
|
223
|
-
} else {
|
|
224
|
-
// CASE 2: No queryParams that affect the related resource
|
|
225
|
-
// We can get everything in one call and extract from included
|
|
226
|
-
|
|
227
|
-
// Single call: Get parent with full related resource included
|
|
228
|
-
const parent = await scope.get({
|
|
229
|
-
id: context.id,
|
|
230
|
-
queryParams: {
|
|
231
|
-
include: [context.relationshipName],
|
|
232
|
-
fields: context.queryParams.fields // Respect any field selections for the parent
|
|
233
|
-
},
|
|
234
|
-
transaction: context.transaction,
|
|
235
|
-
simplified: false,
|
|
236
|
-
isTransport: params.isTransport
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// Extract the related resource from the parent's relationships
|
|
240
|
-
const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id;
|
|
241
|
-
if (!relatedId) {
|
|
242
|
-
return {
|
|
243
|
-
links: { self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
244
|
-
data: null
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Find the full related resource in the included array
|
|
249
|
-
// The include system already fetched it for us!
|
|
250
|
-
const relatedResource = parent.included?.find(
|
|
251
|
-
r => r.type === targetType && r.id === relatedId
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
links: { self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
256
|
-
data: relatedResource || null
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Handle simple hasMany (one-to-many)
|
|
262
|
-
if (relDef.hasMany && !relDef.through) {
|
|
263
|
-
const filters = {
|
|
264
|
-
...context.queryParams.filters,
|
|
265
|
-
[relDef.foreignKey]: context.id
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const result = await apiRef.resources[targetType].query({
|
|
269
|
-
queryParams: { ...context.queryParams, filters },
|
|
270
|
-
transaction: context.transaction,
|
|
271
|
-
simplified: false,
|
|
272
|
-
isTransport: params.isTransport
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
if (!result.links) {
|
|
276
|
-
result.links = {};
|
|
277
|
-
}
|
|
278
|
-
result.links.self = `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}`;
|
|
279
|
-
return result;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Handle many-to-many relationships (hasMany with through)
|
|
283
|
-
// For example: GET /api/authors/1/books (where authors and books are linked via book_authors)
|
|
284
|
-
if (relDef.manyToMany || (relDef.hasMany && relDef.through)) {
|
|
285
|
-
// APPROACH: Query the pivot table directly
|
|
286
|
-
//
|
|
287
|
-
// Instead of:
|
|
288
|
-
// 1. Getting parent with relationship included to find related IDs
|
|
289
|
-
// 2. Trying to filter by id:[array] which doesn't work
|
|
290
|
-
//
|
|
291
|
-
// We query the pivot table (e.g., book_authors) filtered by the parent ID
|
|
292
|
-
// and include the target resources. This is simpler and uses existing
|
|
293
|
-
// API functionality without needing special array filter support.
|
|
294
|
-
|
|
295
|
-
const pivotResource = relDef.through;
|
|
296
|
-
const foreignKey = relDef.foreignKey || `${scopeName.slice(0, -1)}_id`;
|
|
297
|
-
const otherKey = relDef.otherKey || `${targetType.slice(0, -1)}_id`;
|
|
298
|
-
|
|
299
|
-
// We need to find the relationship name on the pivot table that points to the target
|
|
300
|
-
// The pivot table should have a belongsTo relationship to the target resource
|
|
301
|
-
const pivotScope = scopes[pivotResource];
|
|
302
|
-
if (!pivotScope) {
|
|
303
|
-
throw new RestApiResourceError(
|
|
304
|
-
`Pivot table resource '${pivotResource}' not found`,
|
|
305
|
-
{ subtype: 'pivot_table_not_found' }
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Find the relationship name on the pivot table that points to the target
|
|
310
|
-
let targetRelationshipName = null;
|
|
311
|
-
const pivotSchema = pivotScope.vars.schemaInfo.schemaStructure;
|
|
312
|
-
for (const [fieldName, fieldDef] of Object.entries(pivotSchema)) {
|
|
313
|
-
if (fieldDef.belongsTo === targetType && fieldDef.as) {
|
|
314
|
-
targetRelationshipName = fieldDef.as;
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (!targetRelationshipName) {
|
|
320
|
-
throw new RestApiResourceError(
|
|
321
|
-
`Cannot find relationship to '${targetType}' in pivot table '${pivotResource}'`,
|
|
322
|
-
{ subtype: 'pivot_relationship_not_found' }
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Build filters for the pivot table
|
|
327
|
-
const pivotFilters = {
|
|
328
|
-
[foreignKey]: context.id
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// Query the pivot table with the target included
|
|
332
|
-
const pivotResult = await apiRef.resources[pivotResource].query({
|
|
333
|
-
queryParams: {
|
|
334
|
-
filters: pivotFilters,
|
|
335
|
-
include: [targetRelationshipName],
|
|
336
|
-
// Pass through other query params that should apply to the included resources
|
|
337
|
-
fields: context.queryParams.fields,
|
|
338
|
-
sort: context.queryParams.sort,
|
|
339
|
-
page: context.queryParams.page
|
|
340
|
-
},
|
|
341
|
-
transaction: context.transaction,
|
|
342
|
-
simplified: false,
|
|
343
|
-
isTransport: params.isTransport
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Extract the included target resources
|
|
347
|
-
const includedResources = pivotResult.included?.filter(r => r.type === targetType) || [];
|
|
348
|
-
|
|
349
|
-
// Build the response
|
|
350
|
-
return {
|
|
351
|
-
links: {
|
|
352
|
-
self: `${vars.resourceUrlPrefix}/${scopeName}/${context.id}/${context.relationshipName}`
|
|
353
|
-
},
|
|
354
|
-
data: includedResources,
|
|
355
|
-
meta: pivotResult.meta
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* POST RELATIONSHIP
|
|
362
|
-
* Adds members to a to-many relationship
|
|
363
|
-
* POST /api/articles/1/relationships/tags
|
|
364
|
-
*
|
|
365
|
-
* @param {string} id - The ID of the resource
|
|
366
|
-
* @param {string} relationshipName - The name of the relationship
|
|
367
|
-
* @param {array} relationshipData - Array of resource identifiers to add
|
|
368
|
-
* @returns {Promise<void>} 204 No Content
|
|
369
|
-
*/
|
|
370
|
-
addScopeMethod('postRelationship', async ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) => {
|
|
371
|
-
context.method = 'postRelationship';
|
|
372
|
-
context.id = params.id;
|
|
373
|
-
context.relationshipName = params.relationshipName;
|
|
374
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
375
|
-
|
|
376
|
-
// Transaction handling
|
|
377
|
-
context.transaction = params.transaction ||
|
|
378
|
-
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
379
|
-
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
380
|
-
|
|
381
|
-
context.db = context.transaction || api.knex.instance
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
// Validate
|
|
385
|
-
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
386
|
-
if (!relDef) {
|
|
387
|
-
throw new RestApiResourceError(
|
|
388
|
-
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
389
|
-
{ subtype: 'relationship_not_found' }
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (!relDef.hasMany && !relDef.manyToMany) {
|
|
394
|
-
throw new RestApiValidationError(
|
|
395
|
-
`Cannot POST to to-one relationship '${context.relationshipName}'. Use PATCH instead.`,
|
|
396
|
-
{ fields: ['data'] }
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (!Array.isArray(params.relationshipData)) {
|
|
401
|
-
throw new RestApiPayloadError('POST to relationship requires array of resource identifiers');
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Check permissions
|
|
405
|
-
await runHooks('checkPermissions');
|
|
406
|
-
await runHooks('checkPermissionsPostRelationship');
|
|
407
|
-
|
|
408
|
-
// Verify parent exists
|
|
409
|
-
const exists = await helpers.dataExists({
|
|
410
|
-
scopeName,
|
|
411
|
-
context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo },
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
if (!exists) {
|
|
415
|
-
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Add relationships
|
|
419
|
-
if (relDef.manyToMany || (relDef.hasMany && relDef.through)) {
|
|
420
|
-
const manyToManyDef = relDef.manyToMany || {
|
|
421
|
-
through: relDef.through,
|
|
422
|
-
foreignKey: relDef.foreignKey,
|
|
423
|
-
otherKey: relDef.otherKey
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
await createPivotRecords(apiRef, context.id, manyToManyDef, params.relationshipData, context.transaction);
|
|
427
|
-
} else {
|
|
428
|
-
// Update foreign keys for hasMany
|
|
429
|
-
const targetType = relDef.hasMany;
|
|
430
|
-
for (const identifier of params.relationshipData) {
|
|
431
|
-
await apiRef.resources[targetType].patch({
|
|
432
|
-
id: identifier.id,
|
|
433
|
-
inputRecord: {
|
|
434
|
-
data: {
|
|
435
|
-
type: targetType,
|
|
436
|
-
id: identifier.id,
|
|
437
|
-
attributes: { [relDef.hasMany.foreignKey]: context.id }
|
|
438
|
-
}
|
|
439
|
-
},
|
|
440
|
-
transaction: context.transaction,
|
|
441
|
-
simplified: false
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
await runHooks('finish');
|
|
447
|
-
await runHooks('finishPostRelationship');
|
|
448
|
-
|
|
449
|
-
if (context.shouldCommit) {
|
|
450
|
-
await context.transaction.commit();
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return; // 204 No Content
|
|
454
|
-
|
|
455
|
-
} catch (error) {
|
|
456
|
-
await handleWriteMethodError(error, context, 'POST_RELATIONSHIP', scopeName, log);
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* PATCH RELATIONSHIP
|
|
462
|
-
* Completely replaces a relationship
|
|
463
|
-
* PATCH /api/articles/1/relationships/author
|
|
464
|
-
*
|
|
465
|
-
* @param {string} id - The ID of the resource
|
|
466
|
-
* @param {string} relationshipName - The name of the relationship
|
|
467
|
-
* @param {object|array} relationshipData - New relationship data
|
|
468
|
-
* @returns {Promise<void>} 204 No Content
|
|
469
|
-
*/
|
|
470
|
-
addScopeMethod('patchRelationship', async ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) => {
|
|
471
|
-
context.method = 'patchRelationship';
|
|
472
|
-
context.id = params.id;
|
|
473
|
-
context.relationshipName = params.relationshipName;
|
|
474
|
-
|
|
475
|
-
// Transaction handling
|
|
476
|
-
context.transaction = params.transaction ||
|
|
477
|
-
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
478
|
-
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
479
|
-
context.db = context.transaction || api.knex.instance
|
|
480
|
-
|
|
481
|
-
try {
|
|
482
|
-
// Check permissions
|
|
483
|
-
await runHooks('checkPermissions');
|
|
484
|
-
await runHooks('checkPermissionsPatchRelationship');
|
|
485
|
-
|
|
486
|
-
// Reuse existing patch with relationship data
|
|
487
|
-
await scope.patch({
|
|
488
|
-
id: context.id,
|
|
489
|
-
inputRecord: {
|
|
490
|
-
data: {
|
|
491
|
-
type: scopeName,
|
|
492
|
-
id: context.id,
|
|
493
|
-
relationships: {
|
|
494
|
-
[params.relationshipName]: { data: params.relationshipData }
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
},
|
|
498
|
-
transaction: context.transaction,
|
|
499
|
-
simplified: false,
|
|
500
|
-
isTransport: params.isTransport
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
await runHooks('finish');
|
|
504
|
-
await runHooks('finishPatchRelationship');
|
|
505
|
-
|
|
506
|
-
if (context.shouldCommit) {
|
|
507
|
-
await context.transaction.commit();
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return; // 204 No Content
|
|
511
|
-
|
|
512
|
-
} catch (error) {
|
|
513
|
-
await handleWriteMethodError(error, context, 'PATCH_RELATIONSHIP', scopeName, log);
|
|
514
|
-
}
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* DELETE RELATIONSHIP
|
|
519
|
-
* Removes specific members from a to-many relationship
|
|
520
|
-
* DELETE /api/articles/1/relationships/tags
|
|
521
|
-
*
|
|
522
|
-
* @param {string} id - The ID of the resource
|
|
523
|
-
* @param {string} relationshipName - The name of the relationship
|
|
524
|
-
* @param {array} relationshipData - Array of resource identifiers to remove
|
|
525
|
-
* @returns {Promise<void>} 204 No Content
|
|
526
|
-
*/
|
|
527
|
-
addScopeMethod('deleteRelationship', async ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName }) => {
|
|
528
|
-
context.method = 'deleteRelationship';
|
|
529
|
-
context.id = params.id;
|
|
530
|
-
context.relationshipName = params.relationshipName;
|
|
531
|
-
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
532
|
-
|
|
533
|
-
// Transaction handling
|
|
534
|
-
context.transaction = params.transaction ||
|
|
535
|
-
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
536
|
-
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
537
|
-
context.db = context.transaction || api.knex.instance
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
// Validate
|
|
541
|
-
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
542
|
-
if (!relDef) {
|
|
543
|
-
throw new RestApiResourceError(
|
|
544
|
-
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
545
|
-
{ subtype: 'relationship_not_found' }
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (!relDef.hasMany && !relDef.manyToMany) {
|
|
550
|
-
throw new RestApiValidationError(
|
|
551
|
-
`Cannot DELETE from to-one relationship '${context.relationshipName}'`,
|
|
552
|
-
{ fields: ['data'] }
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (!Array.isArray(params.relationshipData)) {
|
|
557
|
-
throw new RestApiPayloadError('DELETE from relationship requires array of resource identifiers');
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Check permissions
|
|
561
|
-
await runHooks('checkPermissions');
|
|
562
|
-
await runHooks('checkPermissionsDeleteRelationship');
|
|
563
|
-
|
|
564
|
-
// Verify parent exists
|
|
565
|
-
const exists = await helpers.dataExists({
|
|
566
|
-
scopeName,
|
|
567
|
-
context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo }
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
if (!exists) {
|
|
571
|
-
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Remove relationships
|
|
575
|
-
if (relDef.manyToMany || (relDef.hasMany && relDef.through)) {
|
|
576
|
-
const manyToManyDef = relDef.manyToMany || {
|
|
577
|
-
through: relDef.through,
|
|
578
|
-
foreignKey: relDef.foreignKey,
|
|
579
|
-
otherKey: relDef.otherKey
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
const knex = apiRef.knex?.instance || helpers.db;
|
|
583
|
-
const pivotResource = manyToManyDef.through;
|
|
584
|
-
const pivotScope = apiRef.resources[pivotResource];
|
|
585
|
-
const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || pivotResource;
|
|
586
|
-
const localKey = manyToManyDef.foreignKey;
|
|
587
|
-
const foreignKey = manyToManyDef.otherKey;
|
|
588
|
-
|
|
589
|
-
for (const identifier of params.relationshipData) {
|
|
590
|
-
await knex(pivotTable)
|
|
591
|
-
.where(localKey, context.id)
|
|
592
|
-
.where(foreignKey, identifier.id)
|
|
593
|
-
.delete()
|
|
594
|
-
.transacting(context.transaction);
|
|
595
|
-
}
|
|
596
|
-
} else {
|
|
597
|
-
// Null out foreign keys for hasMany
|
|
598
|
-
const targetType = relDef.hasMany;
|
|
599
|
-
for (const identifier of params.relationshipData) {
|
|
600
|
-
await apiRef.resources[targetType].patch({
|
|
601
|
-
id: identifier.id,
|
|
602
|
-
inputRecord: {
|
|
603
|
-
data: {
|
|
604
|
-
type: targetType,
|
|
605
|
-
id: identifier.id,
|
|
606
|
-
attributes: { [relDef.hasMany.foreignKey]: null }
|
|
607
|
-
}
|
|
608
|
-
},
|
|
609
|
-
transaction: context.transaction,
|
|
610
|
-
simplified: false
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
await runHooks('finish');
|
|
616
|
-
await runHooks('finishDeleteRelationship');
|
|
617
|
-
|
|
618
|
-
if (context.shouldCommit) {
|
|
619
|
-
await context.transaction.commit();
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
return; // 204 No Content
|
|
623
|
-
|
|
624
|
-
} catch (error) {
|
|
625
|
-
await handleWriteMethodError(error, context, 'DELETE_RELATIONSHIP', scopeName, log);
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
// Listen for scope additions to register relationship routes
|
|
630
|
-
addHook('scope:added', 'registerRelationshipRoutes', {}, async ({ context }) => {
|
|
631
|
-
const { scopeName } = context;
|
|
632
|
-
const basePath = api.scopes[scopeName].vars.resourceUrlPrefix || '';
|
|
633
|
-
const scopePath = `${basePath}/${scopeName}`;
|
|
634
|
-
|
|
635
|
-
// Helper to create route handlers
|
|
636
|
-
const createRouteHandler = (methodName) => {
|
|
637
|
-
return async ({ params, body, queryString }) => {
|
|
638
|
-
const scope = api.scopes[scopeName];
|
|
639
|
-
|
|
640
|
-
const methodParams = {
|
|
641
|
-
id: params.id,
|
|
642
|
-
relationshipName: params.relationshipName,
|
|
643
|
-
isTransport: true
|
|
644
|
-
};
|
|
645
|
-
|
|
646
|
-
// Add query params for getRelated
|
|
647
|
-
if (methodName === 'getRelated' && queryString) {
|
|
648
|
-
methodParams.queryParams = parseJsonApiQuery(queryString);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Add body data for write operations
|
|
652
|
-
if (body && body.data !== undefined) {
|
|
653
|
-
methodParams.relationshipData = body.data;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
return await scope[methodName](methodParams);
|
|
657
|
-
};
|
|
658
|
-
};
|
|
659
|
-
|
|
660
|
-
// Register relationship routes
|
|
661
|
-
|
|
662
|
-
// GET /api/{scope}/{id}/relationships/{relationshipName}
|
|
663
|
-
await api.addRoute({
|
|
664
|
-
method: 'GET',
|
|
665
|
-
path: `${scopePath}/:id/relationships/:relationshipName`,
|
|
666
|
-
handler: createRouteHandler('getRelationship')
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
// GET /api/{scope}/{id}/{relationshipName}
|
|
670
|
-
await api.addRoute({
|
|
671
|
-
method: 'GET',
|
|
672
|
-
path: `${scopePath}/:id/:relationshipName`,
|
|
673
|
-
handler: createRouteHandler('getRelated')
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// POST /api/{scope}/{id}/relationships/{relationshipName}
|
|
677
|
-
await api.addRoute({
|
|
678
|
-
method: 'POST',
|
|
679
|
-
path: `${scopePath}/:id/relationships/:relationshipName`,
|
|
680
|
-
handler: createRouteHandler('postRelationship')
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
// PATCH /api/{scope}/{id}/relationships/{relationshipName}
|
|
684
|
-
await api.addRoute({
|
|
685
|
-
method: 'PATCH',
|
|
686
|
-
path: `${scopePath}/:id/relationships/:relationshipName`,
|
|
687
|
-
handler: createRouteHandler('patchRelationship')
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
// DELETE /api/{scope}/{id}/relationships/{relationshipName}
|
|
691
|
-
await api.addRoute({
|
|
692
|
-
method: 'DELETE',
|
|
693
|
-
path: `${scopePath}/:id/relationships/:relationshipName`,
|
|
694
|
-
handler: createRouteHandler('deleteRelationship')
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
log.trace(`Registered relationship routes for scope: ${scopeName}`);
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
};
|