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,295 @@
|
|
|
1
|
+
import { RestApiResourceError } from "../../../lib/rest-api-errors.js";
|
|
2
|
+
import { findRelationshipDefinition } from "./common.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET RELATED
|
|
6
|
+
* Retrieves the actual related resources (full data)
|
|
7
|
+
* GET /api/articles/1/comments
|
|
8
|
+
*
|
|
9
|
+
* @param {string} id - The ID of the parent resource
|
|
10
|
+
* @param {string} relationshipName - The name of the relationship
|
|
11
|
+
* @param {object} queryParams - Standard query parameters
|
|
12
|
+
* @returns {Promise<object>} Related resources with full data
|
|
13
|
+
*/
|
|
14
|
+
export default async function getRelatedMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
|
|
15
|
+
context.method = 'getRelated';
|
|
16
|
+
context.id = params.id;
|
|
17
|
+
context.relationshipName = params.relationshipName;
|
|
18
|
+
context.queryParams = params.queryParams || {};
|
|
19
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
20
|
+
context.transaction = params.transaction;
|
|
21
|
+
context.db = context.transaction || api.knex.instance
|
|
22
|
+
|
|
23
|
+
// Validate the relationship exists
|
|
24
|
+
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
25
|
+
|
|
26
|
+
if (!relDef) {
|
|
27
|
+
throw new RestApiResourceError(
|
|
28
|
+
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
29
|
+
{ subtype: 'relationship_not_found' }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Determine target type - relationships are stored as strings
|
|
34
|
+
const targetType = relDef.belongsTo || relDef.hasMany ||
|
|
35
|
+
relDef.manyToMany || relDef.hasOne;
|
|
36
|
+
|
|
37
|
+
if (!targetType || !scopes[targetType]) {
|
|
38
|
+
throw new RestApiResourceError(
|
|
39
|
+
`Related resource type '${targetType}' not found`,
|
|
40
|
+
{ subtype: 'related_type_not_found' }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check permissions
|
|
45
|
+
await runHooks('checkPermissions');
|
|
46
|
+
await runHooks('checkPermissionsGetRelated');
|
|
47
|
+
|
|
48
|
+
// Verify parent exists
|
|
49
|
+
const exists = await helpers.dataExists({
|
|
50
|
+
scopeName,
|
|
51
|
+
context: { db: context.db, id: context.id, schemaInfo: context.schemaInfo }
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!exists) {
|
|
55
|
+
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Handle to-one relationships (belongsTo and hasOne)
|
|
59
|
+
// For example: GET /api/books/1/country or GET /api/books/1/publisher
|
|
60
|
+
if (relDef.belongsTo || relDef.hasOne) {
|
|
61
|
+
// OPTIMIZATION: Detect if we actually need to make two API calls
|
|
62
|
+
//
|
|
63
|
+
// The naive approach always makes 2 calls:
|
|
64
|
+
// 1. Get parent with relationship included (fetches FULL related record)
|
|
65
|
+
// 2. Extract just the ID and fetch the same record again with queryParams
|
|
66
|
+
//
|
|
67
|
+
// This optimization checks if there are queryParams that would affect
|
|
68
|
+
// the related resource. If not, we can use the data from the first call.
|
|
69
|
+
const hasRelevantQueryParams = context.queryParams && (
|
|
70
|
+
// Check for includes on the related resource (e.g., ?include=some.nested.relation)
|
|
71
|
+
context.queryParams.include?.length > 0 ||
|
|
72
|
+
// Check for field selection on the related resource (e.g., ?fields[countries]=name,code)
|
|
73
|
+
context.queryParams.fields?.[targetType] ||
|
|
74
|
+
// Note: Filters and sorting don't make sense for a single to-one relationship
|
|
75
|
+
// so we don't check for them
|
|
76
|
+
false
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (hasRelevantQueryParams) {
|
|
80
|
+
// CASE 1: Has queryParams that affect the related resource
|
|
81
|
+
// We need to make two calls to properly apply the queryParams
|
|
82
|
+
|
|
83
|
+
// First call: Get parent with minimal data (just need the related ID)
|
|
84
|
+
const parent = await scope.get({
|
|
85
|
+
id: context.id,
|
|
86
|
+
queryParams: {
|
|
87
|
+
include: [context.relationshipName],
|
|
88
|
+
fields: { [scopeName]: vars.idProperty || 'id' } // Only fetch parent ID to minimize data
|
|
89
|
+
},
|
|
90
|
+
transaction: context.transaction,
|
|
91
|
+
simplified: false,
|
|
92
|
+
isTransport: params.isTransport
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id;
|
|
96
|
+
if (!relatedId) {
|
|
97
|
+
return {
|
|
98
|
+
links: { self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
99
|
+
data: null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Second call: Get the related resource with all queryParams applied
|
|
104
|
+
const related = await api.resources[targetType].get({
|
|
105
|
+
id: relatedId,
|
|
106
|
+
queryParams: context.queryParams,
|
|
107
|
+
transaction: context.transaction,
|
|
108
|
+
simplified: false,
|
|
109
|
+
isTransport: params.isTransport
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
links: { self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
114
|
+
data: related.data,
|
|
115
|
+
included: related.included
|
|
116
|
+
};
|
|
117
|
+
} else {
|
|
118
|
+
// CASE 2: No queryParams that affect the related resource
|
|
119
|
+
// We can get everything in one call and extract from included
|
|
120
|
+
|
|
121
|
+
// Single call: Get parent with full related resource included
|
|
122
|
+
const parent = await scope.get({
|
|
123
|
+
id: context.id,
|
|
124
|
+
queryParams: {
|
|
125
|
+
include: [context.relationshipName],
|
|
126
|
+
fields: context.queryParams.fields // Respect any field selections for the parent
|
|
127
|
+
},
|
|
128
|
+
transaction: context.transaction,
|
|
129
|
+
simplified: false,
|
|
130
|
+
isTransport: params.isTransport
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Extract the related resource from the parent's relationships
|
|
134
|
+
const relatedId = parent.data.relationships?.[context.relationshipName]?.data?.id;
|
|
135
|
+
if (!relatedId) {
|
|
136
|
+
return {
|
|
137
|
+
links: { self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
138
|
+
data: null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Find the full related resource in the included array
|
|
143
|
+
// The include system already fetched it for us!
|
|
144
|
+
const relatedResource = parent.included?.find(
|
|
145
|
+
r => r.type === targetType && r.id === relatedId
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
links: { self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}` },
|
|
150
|
+
data: relatedResource || null
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle simple hasMany (one-to-many)
|
|
156
|
+
if (relDef.hasMany && !relDef.through) {
|
|
157
|
+
// Check if this is a polymorphic relationship using 'via'
|
|
158
|
+
if (relDef.via) {
|
|
159
|
+
// Polymorphic hasMany relationship
|
|
160
|
+
// Example: publishers hasMany reviews via reviewable
|
|
161
|
+
const targetRelationships = scopes[targetType].vars.schemaInfo.schemaRelationships;
|
|
162
|
+
const viaRel = targetRelationships?.[relDef.via];
|
|
163
|
+
|
|
164
|
+
if (!viaRel?.belongsToPolymorphic) {
|
|
165
|
+
throw new RestApiResourceError(
|
|
166
|
+
`Via relationship '${relDef.via}' not found or not polymorphic in '${targetType}'`,
|
|
167
|
+
{ subtype: 'invalid_via_relationship' }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { typeField, idField } = viaRel.belongsToPolymorphic;
|
|
172
|
+
|
|
173
|
+
// Add polymorphic filters
|
|
174
|
+
const filters = {
|
|
175
|
+
...context.queryParams.filters,
|
|
176
|
+
[typeField]: scopeName,
|
|
177
|
+
[idField]: context.id
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = await api.resources[targetType].query({
|
|
181
|
+
queryParams: { ...context.queryParams, filters },
|
|
182
|
+
transaction: context.transaction,
|
|
183
|
+
simplified: false,
|
|
184
|
+
isTransport: params.isTransport
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!result.links) {
|
|
188
|
+
result.links = {};
|
|
189
|
+
}
|
|
190
|
+
result.links.self = `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}`;
|
|
191
|
+
return result;
|
|
192
|
+
} else {
|
|
193
|
+
// Regular hasMany with foreignKey
|
|
194
|
+
const filters = {
|
|
195
|
+
...context.queryParams.filters,
|
|
196
|
+
[relDef.foreignKey]: context.id
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const result = await api.resources[targetType].query({
|
|
200
|
+
queryParams: { ...context.queryParams, filters },
|
|
201
|
+
transaction: context.transaction,
|
|
202
|
+
simplified: false,
|
|
203
|
+
isTransport: params.isTransport
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (!result.links) {
|
|
207
|
+
result.links = {};
|
|
208
|
+
}
|
|
209
|
+
result.links.self = `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}`;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle many-to-many relationships (hasMany with through)
|
|
215
|
+
// For example: GET /api/authors/1/books (where authors and books are linked via book_authors)
|
|
216
|
+
if (relDef.manyToMany || (relDef.hasMany && relDef.through)) {
|
|
217
|
+
// APPROACH: Query the pivot table directly
|
|
218
|
+
//
|
|
219
|
+
// Instead of:
|
|
220
|
+
// 1. Getting parent with relationship included to find related IDs
|
|
221
|
+
// 2. Trying to filter by id:[array] which doesn't work
|
|
222
|
+
//
|
|
223
|
+
// We query the pivot table (e.g., book_authors) filtered by the parent ID
|
|
224
|
+
// and include the target resources. This is simpler and uses existing
|
|
225
|
+
// API functionality without needing special array filter support.
|
|
226
|
+
|
|
227
|
+
const pivotResource = relDef.through;
|
|
228
|
+
const foreignKey = relDef.foreignKey;
|
|
229
|
+
const otherKey = relDef.otherKey;
|
|
230
|
+
|
|
231
|
+
// These should already be validated by scope-validations.js
|
|
232
|
+
if (!foreignKey || !otherKey) {
|
|
233
|
+
throw new Error(`Missing foreignKey or otherKey in many-to-many relationship`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// We need to find the relationship name on the pivot table that points to the target
|
|
237
|
+
// The pivot table should have a belongsTo relationship to the target resource
|
|
238
|
+
const pivotScope = scopes[pivotResource];
|
|
239
|
+
if (!pivotScope) {
|
|
240
|
+
throw new RestApiResourceError(
|
|
241
|
+
`Pivot table resource '${pivotResource}' not found`,
|
|
242
|
+
{ subtype: 'pivot_table_not_found' }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Find the relationship name on the pivot table that points to the target
|
|
247
|
+
let targetRelationshipName = null;
|
|
248
|
+
const pivotSchema = pivotScope.vars.schemaInfo.schemaStructure;
|
|
249
|
+
for (const [fieldName, fieldDef] of Object.entries(pivotSchema)) {
|
|
250
|
+
if (fieldDef.belongsTo === targetType && fieldDef.as) {
|
|
251
|
+
targetRelationshipName = fieldDef.as;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!targetRelationshipName) {
|
|
257
|
+
throw new RestApiResourceError(
|
|
258
|
+
`Cannot find relationship to '${targetType}' in pivot table '${pivotResource}'`,
|
|
259
|
+
{ subtype: 'pivot_relationship_not_found' }
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Build filters for the pivot table
|
|
264
|
+
const pivotFilters = {
|
|
265
|
+
[foreignKey]: context.id
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Query the pivot table with the target included
|
|
269
|
+
const pivotResult = await api.resources[pivotResource].query({
|
|
270
|
+
queryParams: {
|
|
271
|
+
filters: pivotFilters,
|
|
272
|
+
include: [targetRelationshipName],
|
|
273
|
+
// Pass through other query params that should apply to the included resources
|
|
274
|
+
fields: context.queryParams.fields,
|
|
275
|
+
sort: context.queryParams.sort,
|
|
276
|
+
page: context.queryParams.page
|
|
277
|
+
},
|
|
278
|
+
transaction: context.transaction,
|
|
279
|
+
simplified: false,
|
|
280
|
+
isTransport: params.isTransport
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Extract the included target resources
|
|
284
|
+
const includedResources = pivotResult.included?.filter(r => r.type === targetType) || [];
|
|
285
|
+
|
|
286
|
+
// Build the response
|
|
287
|
+
return {
|
|
288
|
+
links: {
|
|
289
|
+
self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}`
|
|
290
|
+
},
|
|
291
|
+
data: includedResources,
|
|
292
|
+
meta: pivotResult.meta
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
|
|
2
|
+
import { RestApiResourceError } from "../../../lib/rest-api-errors.js";
|
|
3
|
+
import { findRelationshipDefinition } from "./common.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET RELATIONSHIP
|
|
7
|
+
* Retrieves relationship linkage data (just resource identifiers)
|
|
8
|
+
* GET /api/articles/1/relationships/author
|
|
9
|
+
*
|
|
10
|
+
* @param {string} id - The ID of the resource
|
|
11
|
+
* @param {string} relationshipName - The name of the relationship
|
|
12
|
+
* @returns {Promise<object>} Relationship linkage with links
|
|
13
|
+
*/
|
|
14
|
+
export default async function getRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
|
|
15
|
+
context.method = 'getRelationship';
|
|
16
|
+
context.id = params.id;
|
|
17
|
+
context.relationshipName = params.relationshipName;
|
|
18
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
19
|
+
|
|
20
|
+
// Validate the relationship exists
|
|
21
|
+
const relDef = findRelationshipDefinition(context.schemaInfo, context.relationshipName);
|
|
22
|
+
|
|
23
|
+
if (!relDef) {
|
|
24
|
+
throw new RestApiResourceError(
|
|
25
|
+
`Relationship '${context.relationshipName}' not found on resource '${scopeName}'`,
|
|
26
|
+
{ subtype: 'relationship_not_found' }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check permissions
|
|
31
|
+
await runHooks('checkPermissions');
|
|
32
|
+
await runHooks('checkPermissionsGetRelationship');
|
|
33
|
+
|
|
34
|
+
// Reuse existing get method with minimal fields
|
|
35
|
+
const fullRecord = await scope.get({
|
|
36
|
+
id: context.id,
|
|
37
|
+
queryParams: {
|
|
38
|
+
include: [context.relationshipName],
|
|
39
|
+
fields: { [scopeName]: vars.idProperty || 'id' }
|
|
40
|
+
},
|
|
41
|
+
transaction: context.transaction,
|
|
42
|
+
simplified: false,
|
|
43
|
+
isTransport: params.isTransport
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!fullRecord || !fullRecord.data) {
|
|
47
|
+
throw new RestApiResourceError('Resource not found', { subtype: 'not_found' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Extract just the relationship data
|
|
51
|
+
const relationshipData = fullRecord.data.relationships?.[context.relationshipName];
|
|
52
|
+
|
|
53
|
+
// Build response with links
|
|
54
|
+
return {
|
|
55
|
+
links: {
|
|
56
|
+
self: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/relationships/${context.relationshipName}`,
|
|
57
|
+
related: `${vars.publicBaseUrl || vars.transport?.mountPath || ''}/${scopeName}/${context.id}/${context.relationshipName}`
|
|
58
|
+
},
|
|
59
|
+
data: relationshipData?.data || (relDef.hasMany || relDef.manyToMany ? [] : null)
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { RestApiResourceError } from '../../../lib/rest-api-errors.js';
|
|
2
|
+
import { validateGetPayload } 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
|
+
* GET
|
|
10
|
+
* Retrieves a single resource by its type and ID.
|
|
11
|
+
* @param {(string|number)} id - The unique ID of the resource to fetch.
|
|
12
|
+
* @param {object} [queryParams={}] - Optional. An object to customize the query.
|
|
13
|
+
* @param {string} [queryParams.include] - A comma-separated string of relationship paths to sideload (e.g., "authors,publisher").
|
|
14
|
+
* @param {object} [queryParams.fields] - An object to request only specific fields (sparse fieldsets). Keys are resource types, values are comma-separated field names.
|
|
15
|
+
* @returns {Promise<object>} A Promise that resolves to the JSON:API response document.
|
|
16
|
+
*/
|
|
17
|
+
export default async function getMethod({
|
|
18
|
+
params,
|
|
19
|
+
context,
|
|
20
|
+
vars,
|
|
21
|
+
helpers,
|
|
22
|
+
scope,
|
|
23
|
+
scopes,
|
|
24
|
+
runHooks,
|
|
25
|
+
apiOptions,
|
|
26
|
+
pluginOptions,
|
|
27
|
+
scopeOptions,
|
|
28
|
+
scopeName,
|
|
29
|
+
api
|
|
30
|
+
}) {
|
|
31
|
+
context.method = 'get';
|
|
32
|
+
|
|
33
|
+
// Determine which simplified setting to use based on transport
|
|
34
|
+
const isTransport = params.isTransport === true;
|
|
35
|
+
|
|
36
|
+
// Use vars which automatically cascade from scope to global
|
|
37
|
+
const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
38
|
+
|
|
39
|
+
// Get simplified setting - from params only (per-call override) or use default
|
|
40
|
+
context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified;
|
|
41
|
+
|
|
42
|
+
// Assign common context properties
|
|
43
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
44
|
+
context.queryParams = params.queryParams || {};
|
|
45
|
+
|
|
46
|
+
// These only make sense as parameter per query
|
|
47
|
+
context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {});
|
|
48
|
+
context.queryParams.include = cascadeConfig('include', [context.queryParams], []);
|
|
49
|
+
|
|
50
|
+
context.transaction = params.transaction;
|
|
51
|
+
context.db = context.transaction || api.knex.instance;
|
|
52
|
+
|
|
53
|
+
context.scopeName = scopeName;
|
|
54
|
+
|
|
55
|
+
// These are just shortcuts used in this function and will be returned
|
|
56
|
+
const schemaStructure = context.schemaInfo.schema.structure;
|
|
57
|
+
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
58
|
+
|
|
59
|
+
context.id = params.id;
|
|
60
|
+
|
|
61
|
+
// Validate GET request to ensure required parameters are present and properly formatted.
|
|
62
|
+
// This checks that 'id' parameter exists and is not empty (you can't GET without an ID),
|
|
63
|
+
// validates 'include' contains valid relationship names (not arbitrary fields), and ensures
|
|
64
|
+
// 'fields' for sparse fieldsets follow the format fields[type]=comma,separated,list.
|
|
65
|
+
// Example: validates id: '123' exists, include: ['author', 'tags'] are real relationships.
|
|
66
|
+
validateGetPayload({ id: context.id, queryParams: context.queryParams }, vars.includeDepthLimit);
|
|
67
|
+
|
|
68
|
+
// Fetch minimal record for authorization checks
|
|
69
|
+
const minimalRecord = await helpers.dataGetMinimal({
|
|
70
|
+
scopeName,
|
|
71
|
+
context,
|
|
72
|
+
runHooks
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!minimalRecord) {
|
|
76
|
+
throw new RestApiResourceError(
|
|
77
|
+
`Resource not found`,
|
|
78
|
+
{
|
|
79
|
+
subtype: 'not_found',
|
|
80
|
+
resourceType: scopeName,
|
|
81
|
+
resourceId: context.id
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
context.minimalRecord = minimalRecord;
|
|
87
|
+
|
|
88
|
+
// Centralised checkPermissions function
|
|
89
|
+
await scope.checkPermissions({
|
|
90
|
+
method: 'get',
|
|
91
|
+
auth: context.auth,
|
|
92
|
+
id: context.id,
|
|
93
|
+
minimalRecord: context.minimalRecord,
|
|
94
|
+
transaction: context.transaction
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await runHooks('beforeData');
|
|
98
|
+
await runHooks('beforeDataGet');
|
|
99
|
+
|
|
100
|
+
context.record = await helpers.dataGet({
|
|
101
|
+
scopeName,
|
|
102
|
+
context,
|
|
103
|
+
runHooks
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Check if record was found - storage layer returns null/undefined for non-existent records.
|
|
107
|
+
// This generates a proper 404 error with JSON:API error format instead of returning empty data.
|
|
108
|
+
if (!context.record || !context.record.data) {
|
|
109
|
+
throw new RestApiResourceError(
|
|
110
|
+
`Resource not found`,
|
|
111
|
+
{
|
|
112
|
+
subtype: 'not_found',
|
|
113
|
+
resourceType: scopeName,
|
|
114
|
+
resourceId: context.id
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Normalize database values (e.g., convert 1/0 to true/false for booleans)
|
|
120
|
+
context.record = normalizeRecordAttributes(context.record, scopes);
|
|
121
|
+
|
|
122
|
+
await runHooks('checkDataPermissions');
|
|
123
|
+
await runHooks('checkDataPermissionsGet');
|
|
124
|
+
|
|
125
|
+
// Make a backup
|
|
126
|
+
context.originalRecord = structuredClone(context.record);
|
|
127
|
+
|
|
128
|
+
// This will enhance record, which is the WHOLE JSON:API record
|
|
129
|
+
await runHooks('enrichRecord');
|
|
130
|
+
|
|
131
|
+
// Get computed field information for main resource
|
|
132
|
+
const computedFields = scope.vars.schemaInfo?.computed || {};
|
|
133
|
+
const requestedFields = context.queryParams.fields?.[scopeName];
|
|
134
|
+
const requestedComputedFields = getRequestedComputedFields(scopeName, requestedFields, computedFields);
|
|
135
|
+
|
|
136
|
+
// Enrich attributes for the main resource
|
|
137
|
+
// Pass computedDependencies from context (set by dataGet in Knex plugin)
|
|
138
|
+
// This tells enrichAttributes which fields to remove after computation
|
|
139
|
+
context.record.data.attributes = await scope.enrichAttributes({
|
|
140
|
+
attributes: context.record.data.attributes,
|
|
141
|
+
parentContext: context,
|
|
142
|
+
requestedComputedFields: requestedComputedFields,
|
|
143
|
+
isMainResource: true,
|
|
144
|
+
computedDependencies: context.computedDependencies // Fields to remove if not requested
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
for (const entry of (context.record.included || [])) {
|
|
148
|
+
const entryScope = scopes[entry.type];
|
|
149
|
+
const entryComputed = entryScope.vars.schemaInfo?.computed || {};
|
|
150
|
+
const entryRequestedFields = context.queryParams.fields?.[entry.type];
|
|
151
|
+
const entryRequestedComputed = getRequestedComputedFields(
|
|
152
|
+
entry.type,
|
|
153
|
+
entryRequestedFields,
|
|
154
|
+
entryComputed
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
entry.attributes = await entryScope.enrichAttributes({
|
|
158
|
+
attributes: entry.attributes,
|
|
159
|
+
parentContext: context,
|
|
160
|
+
requestedComputedFields: entryRequestedComputed,
|
|
161
|
+
isMainResource: false,
|
|
162
|
+
computedDependencies: entry.__$jsonrestapi_computed_deps$__
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await runHooks('enrichRecordWithRelationships');
|
|
167
|
+
|
|
168
|
+
// The called hooks should NOT change context.record
|
|
169
|
+
await runHooks('finish');
|
|
170
|
+
await runHooks('finishGet');
|
|
171
|
+
|
|
172
|
+
// Get schema info for transformation
|
|
173
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
174
|
+
|
|
175
|
+
// Transform output if in simplified mode
|
|
176
|
+
if (context.simplified) {
|
|
177
|
+
// Convert JSON:API response back to simplified format
|
|
178
|
+
// Example: {data: {type: 'posts', id: '1', attributes: {title: 'My Post'}, relationships: {author: {data: {type: 'users', id: '123'}}}}}
|
|
179
|
+
// becomes: {id: '1', title: 'My Post', author_id: '123'} - flattens structure and restores foreign keys
|
|
180
|
+
return transformJsonApiToSimplified(
|
|
181
|
+
{ record: context.record },
|
|
182
|
+
{ context: { schemaStructure, schemaRelationships, scopes } }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return context.record;
|
|
187
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATCH RELATIONSHIP
|
|
3
|
+
* Completely replaces a relationship
|
|
4
|
+
* PATCH /api/articles/1/relationships/author
|
|
5
|
+
*
|
|
6
|
+
* @param {string} id - The ID of the resource
|
|
7
|
+
* @param {string} relationshipName - The name of the relationship
|
|
8
|
+
* @param {object|array} relationshipData - New relationship data
|
|
9
|
+
* @returns {Promise<void>} 204 No Content
|
|
10
|
+
*/
|
|
11
|
+
export default async function patchRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
|
|
12
|
+
context.method = 'patchRelationship';
|
|
13
|
+
context.id = params.id;
|
|
14
|
+
context.relationshipName = params.relationshipName;
|
|
15
|
+
|
|
16
|
+
// Transaction handling
|
|
17
|
+
context.transaction = params.transaction ||
|
|
18
|
+
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
19
|
+
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
20
|
+
context.db = context.transaction || api.knex.instance
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// Check permissions
|
|
24
|
+
await runHooks('checkPermissions');
|
|
25
|
+
await runHooks('checkPermissionsPatchRelationship');
|
|
26
|
+
|
|
27
|
+
// Reuse existing patch with relationship data
|
|
28
|
+
await scope.patch({
|
|
29
|
+
id: context.id,
|
|
30
|
+
inputRecord: {
|
|
31
|
+
data: {
|
|
32
|
+
type: scopeName,
|
|
33
|
+
id: context.id,
|
|
34
|
+
relationships: {
|
|
35
|
+
[params.relationshipName]: { data: params.relationshipData }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
transaction: context.transaction,
|
|
40
|
+
simplified: false,
|
|
41
|
+
isTransport: params.isTransport
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await runHooks('finish');
|
|
45
|
+
await runHooks('finishPatchRelationship');
|
|
46
|
+
|
|
47
|
+
if (context.shouldCommit) {
|
|
48
|
+
await context.transaction.commit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return; // 204 No Content
|
|
52
|
+
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await handleWriteMethodError(error, context, 'PATCH_RELATIONSHIP', scopeName, log);
|
|
55
|
+
}
|
|
56
|
+
}
|