json-rest-api 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +99 -33
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default async function validateIncludeConfigurations({ context, scopes, log }) {
|
|
2
|
+
const { scopeName } = context;
|
|
3
|
+
const scope = scopes[scopeName];
|
|
4
|
+
const relationships = scope.vars.schemaInfo?.schemaRelationships;
|
|
5
|
+
|
|
6
|
+
if (!relationships) return;
|
|
7
|
+
|
|
8
|
+
// Check each relationship for include configuration
|
|
9
|
+
for (const [relName, relDef] of Object.entries(relationships)) {
|
|
10
|
+
if (relDef.include?.strategy === 'window') {
|
|
11
|
+
// This relationship requires window functions
|
|
12
|
+
// We'll validate this at query time since the database might not be connected yet
|
|
13
|
+
log.debug(`Relationship ${scopeName}.${relName} configured for window function includes`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Validate include configuration
|
|
17
|
+
if (relDef.include?.limit) {
|
|
18
|
+
if (typeof relDef.include.limit !== 'number') {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid include limit for ${scopeName}.${relName}: limit must be a number`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
// Check against queryMaxLimit if available
|
|
24
|
+
const maxLimit = scope.vars?.queryMaxLimit;
|
|
25
|
+
if (maxLimit && relDef.include.limit > maxLimit) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Invalid include limit for ${scopeName}.${relName}: ` +
|
|
28
|
+
`limit (${relDef.include.limit}) exceeds queryMaxLimit (${maxLimit})`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (relDef.include?.orderBy && !Array.isArray(relDef.include.orderBy)) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Invalid include orderBy for ${scopeName}.${relName}: orderBy must be an array`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ValidationError } from 'hooked-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* addRoute
|
|
5
|
+
* Registers a new route with the transport layer
|
|
6
|
+
*/
|
|
7
|
+
export default async ({ params, context, runHooks }) => {
|
|
8
|
+
const { method, path, handler } = params;
|
|
9
|
+
|
|
10
|
+
// Validate route configuration
|
|
11
|
+
if (!method || !path || !handler) {
|
|
12
|
+
throw new ValidationError('Route requires method, path, and handler');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// debugger
|
|
16
|
+
// Create context for enrichAttributes hooks
|
|
17
|
+
Object.assign(context, params)
|
|
18
|
+
|
|
19
|
+
// Run the addRoute hook to notify transport plugins
|
|
20
|
+
await runHooks('addRoute');
|
|
21
|
+
|
|
22
|
+
return { registered: true, method, path };
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkPermissions
|
|
3
|
+
* Check if ther are permissions to access a resource.
|
|
4
|
+
*
|
|
5
|
+
*/
|
|
6
|
+
export default async function checkPermissionsMethod({ context, params, runHooks, scopeName, scopes, helpers }) {
|
|
7
|
+
|
|
8
|
+
Object.assign(context, {
|
|
9
|
+
method: params.method,
|
|
10
|
+
isUpdate: params.isUpdate,
|
|
11
|
+
id: params.id,
|
|
12
|
+
auth: params.auth,
|
|
13
|
+
transaction: params.transaction,
|
|
14
|
+
minimalRecord: params.minimalRecord
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
await runHooks('checkPermissions');
|
|
18
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RestApiValidationError,
|
|
3
|
+
RestApiPayloadError
|
|
4
|
+
} from '../../../lib/rest-api-errors.js';
|
|
5
|
+
import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js';
|
|
6
|
+
import { createEnhancedLogger } from '../../../lib/enhanced-logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gets an enhanced logger instance with full error details and stack traces
|
|
10
|
+
* @param {Object} log - The base logger instance
|
|
11
|
+
* @returns {Object} Enhanced logger instance
|
|
12
|
+
*/
|
|
13
|
+
export const getEnhancedLogger = (log) => {
|
|
14
|
+
return createEnhancedLogger(log, {
|
|
15
|
+
logFullErrors: true,
|
|
16
|
+
includeStack: true
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cascades configuration values through multiple sources with fallback to default
|
|
22
|
+
* @param {string} settingName - The setting name to look for
|
|
23
|
+
* @param {Array} sources - Array of objects to search through
|
|
24
|
+
* @param {*} defaultValue - Default value if not found
|
|
25
|
+
* @returns {*} The first defined value found or the default
|
|
26
|
+
*/
|
|
27
|
+
export const cascadeConfig = (settingName, sources, defaultValue) =>
|
|
28
|
+
sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes return record settings to valid values
|
|
32
|
+
* @param {string|boolean} value - The value to normalize
|
|
33
|
+
* @returns {string} Normalized value: 'no', 'minimal', or 'full'
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeReturnValue(value) {
|
|
36
|
+
if (['no', 'minimal', 'full'].includes(value)) return value;
|
|
37
|
+
return 'no'; // default
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sets up common request context for REST API methods
|
|
42
|
+
* Handles simplified mode, transaction setup, and initial validation
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} params - The method parameters
|
|
45
|
+
* @param {Object} context - The request context
|
|
46
|
+
* @param {Object} vars - Plugin variables
|
|
47
|
+
* @param {Object} scopes - All available scopes
|
|
48
|
+
* @param {Object} scopeOptions - Scope-specific options
|
|
49
|
+
* @param {string} scopeName - The name of the current scope
|
|
50
|
+
* @param {Object} api - The API instance
|
|
51
|
+
* @param {Object} helpers - Helper functions
|
|
52
|
+
* @returns {Object} An object containing schema-related shortcuts
|
|
53
|
+
*/
|
|
54
|
+
export async function setupCommonRequest({ params, context, vars, scopes, scopeOptions, scopeName, api, helpers }) {
|
|
55
|
+
// Determine which simplified setting to use based on transport
|
|
56
|
+
const isTransport = params.isTransport === true;
|
|
57
|
+
|
|
58
|
+
// Use vars which automatically cascade from scope to global
|
|
59
|
+
const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi;
|
|
60
|
+
|
|
61
|
+
// Get simplified setting - from params only (per-call override) or use default
|
|
62
|
+
context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified;
|
|
63
|
+
|
|
64
|
+
// Special case: if no inputRecord provided, force simplified mode
|
|
65
|
+
if (!params.inputRecord && context.simplified === false) {
|
|
66
|
+
context.simplified = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Params is totally bypassed in simplified mode
|
|
70
|
+
if (context.simplified) {
|
|
71
|
+
if (params.inputRecord) {
|
|
72
|
+
context.inputRecord = params.inputRecord;
|
|
73
|
+
context.params = params;
|
|
74
|
+
} else {
|
|
75
|
+
context.inputRecord = params;
|
|
76
|
+
// Preserve returnFullRecord if specified in params
|
|
77
|
+
context.params = params.returnFullRecord ? { returnFullRecord: params.returnFullRecord } : {};
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
context.inputRecord = params.inputRecord;
|
|
81
|
+
context.params = params;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Assign common context properties
|
|
85
|
+
context.schemaInfo = scopes[scopeName].vars.schemaInfo;
|
|
86
|
+
|
|
87
|
+
// Use vars which automatically cascade from scope to global
|
|
88
|
+
const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi;
|
|
89
|
+
|
|
90
|
+
// Get return record setting - from params only (per-call override) or use default
|
|
91
|
+
const returnFullRecordRaw = context.params.returnFullRecord !== undefined
|
|
92
|
+
? context.params.returnFullRecord
|
|
93
|
+
: defaultReturnFullRecord;
|
|
94
|
+
|
|
95
|
+
// Normalize return record setting to always be an object with method keys
|
|
96
|
+
if (typeof returnFullRecordRaw === 'object' && returnFullRecordRaw !== null) {
|
|
97
|
+
// It's already an object, normalize the values
|
|
98
|
+
context.returnRecordSetting = {
|
|
99
|
+
post: normalizeReturnValue(returnFullRecordRaw.post),
|
|
100
|
+
put: normalizeReturnValue(returnFullRecordRaw.put),
|
|
101
|
+
patch: normalizeReturnValue(returnFullRecordRaw.patch)
|
|
102
|
+
};
|
|
103
|
+
} else {
|
|
104
|
+
// It's a single value (string or boolean), apply to all methods
|
|
105
|
+
const normalized = normalizeReturnValue(returnFullRecordRaw);
|
|
106
|
+
context.returnRecordSetting = {
|
|
107
|
+
post: normalized,
|
|
108
|
+
put: normalized,
|
|
109
|
+
patch: normalized
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Helper function to normalize return values (same as above)
|
|
114
|
+
function normalizeReturnValue(value) {
|
|
115
|
+
if (['no', 'minimal', 'full'].includes(value)) return value;
|
|
116
|
+
return 'no'; // default
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// These only make sense as parameter per query, not in vars etc.
|
|
120
|
+
context.queryParams = params.queryParams || {};
|
|
121
|
+
context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {});
|
|
122
|
+
context.queryParams.include = cascadeConfig('include', [context.queryParams], []);
|
|
123
|
+
|
|
124
|
+
context.scopeName = scopeName;
|
|
125
|
+
|
|
126
|
+
// Transaction handling
|
|
127
|
+
context.transaction = params.transaction ||
|
|
128
|
+
(helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null);
|
|
129
|
+
context.shouldCommit = !params.transaction && !!context.transaction;
|
|
130
|
+
context.db = context.transaction || api.knex.instance;
|
|
131
|
+
|
|
132
|
+
// These are just shortcuts used in this function and will be returned
|
|
133
|
+
const schema = context.schemaInfo.schema;
|
|
134
|
+
const schemaStructure = context.schemaInfo.schema.structure;
|
|
135
|
+
const schemaRelationships = context.schemaInfo.schemaRelationships;
|
|
136
|
+
|
|
137
|
+
// Transform input if in simplified mode
|
|
138
|
+
if (context.simplified) {
|
|
139
|
+
context.inputRecord = transformSimplifiedToJsonApi(
|
|
140
|
+
{ inputRecord: context.inputRecord },
|
|
141
|
+
{ context: { scopeName, schemaStructure, schemaRelationships } }
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
// Strict mode: validate no belongsTo fields in attributes
|
|
145
|
+
if (context.inputRecord?.data?.attributes) {
|
|
146
|
+
for (const [key, fieldDef] of Object.entries(schemaStructure)) {
|
|
147
|
+
if (fieldDef.belongsTo && key in context.inputRecord.data.attributes) {
|
|
148
|
+
throw new RestApiValidationError(
|
|
149
|
+
`Field '${key}' is a foreign key and must be set via relationships, not attributes`,
|
|
150
|
+
{ fields: [`data.attributes.${key}`] }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (context.inputRecord.data.type !== scopeName) {
|
|
158
|
+
throw new RestApiValidationError(
|
|
159
|
+
`Resource type mismatch. Expected '${scopeName}' but got '${context.inputRecord.data.type}'`,
|
|
160
|
+
{
|
|
161
|
+
fields: ['data.type'],
|
|
162
|
+
violations: [{
|
|
163
|
+
field: 'data.type',
|
|
164
|
+
rule: 'resource_type_match',
|
|
165
|
+
message: `Resource type must be '${scopeName}'`
|
|
166
|
+
}]
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Remove included validation since JSON:API doesn't support it
|
|
172
|
+
if (context.inputRecord.included) {
|
|
173
|
+
throw new RestApiPayloadError(
|
|
174
|
+
context.method + ' requests cannot include an "included" array. JSON:API does not support creating multiple resources in a single request.',
|
|
175
|
+
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If both URL path ID and request body ID are provided, they must match
|
|
180
|
+
// Convert both to strings for comparison since databases may return numeric IDs
|
|
181
|
+
if (context.id && context.inputRecord.data.id && String(context.id) !== String(context.inputRecord.data.id)) {
|
|
182
|
+
throw new RestApiValidationError(
|
|
183
|
+
`ID mismatch. URL path ID '${context.id}' does not match request body ID '${context.inputRecord.data.id}'`,
|
|
184
|
+
{
|
|
185
|
+
fields: ['data.id'],
|
|
186
|
+
violations: [{
|
|
187
|
+
field: 'data.id',
|
|
188
|
+
rule: 'id_consistency',
|
|
189
|
+
message: `Request body ID must match URL path ID when both are provided`
|
|
190
|
+
}]
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Return key schema-related objects for direct use in the main methods
|
|
196
|
+
return { schema, schemaStructure, schemaRelationships };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Handles error cleanup and logging for write methods (POST, PUT, PATCH)
|
|
201
|
+
*
|
|
202
|
+
* @param {Error} error - The error that was caught
|
|
203
|
+
* @param {Object} context - The request context
|
|
204
|
+
* @param {string} method - The HTTP method name (POST, PUT, PATCH)
|
|
205
|
+
* @param {string} scopeName - The name of the resource scope
|
|
206
|
+
* @param {Object} log - The logger instance
|
|
207
|
+
* @param {Function} runHooks - Function to run hooks
|
|
208
|
+
* @throws {Error} Re-throws the original error after cleanup
|
|
209
|
+
*/
|
|
210
|
+
export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
|
|
211
|
+
// Rollback transaction if we created it
|
|
212
|
+
if (context.shouldCommit) {
|
|
213
|
+
await context.transaction.rollback();
|
|
214
|
+
await runHooks('afterRollback');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create enhanced logger for error logging
|
|
218
|
+
const enhancedLog = getEnhancedLogger(log);
|
|
219
|
+
|
|
220
|
+
// Log the full error details
|
|
221
|
+
enhancedLog.logError(`Error in ${method} method`, error, {
|
|
222
|
+
scopeName,
|
|
223
|
+
method: method.toLowerCase(),
|
|
224
|
+
inputRecord: context.inputRecord
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Validates that a pivot resource exists for many-to-many relationships
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} scopes - All available scopes/resources
|
|
234
|
+
* @param {Object} relDef - The relationship definition
|
|
235
|
+
* @param {string} relName - The relationship name
|
|
236
|
+
* @throws {RestApiValidationError} If the pivot resource doesn't exist
|
|
237
|
+
*/
|
|
238
|
+
export const validatePivotResource = (scopes, relDef, relName) => {
|
|
239
|
+
if (!scopes[relDef.through]) {
|
|
240
|
+
throw new RestApiValidationError(
|
|
241
|
+
`Pivot resource '${relDef.through}' not found for relationship '${relName}'`,
|
|
242
|
+
{
|
|
243
|
+
fields: [`relationships.${relName}`],
|
|
244
|
+
violations: [{
|
|
245
|
+
field: `relationships.${relName}`,
|
|
246
|
+
rule: 'missing_pivot_resource',
|
|
247
|
+
message: `Pivot resource '${relDef.through}' must be defined`
|
|
248
|
+
}]
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Gets the appropriate hook suffix based on HTTP method
|
|
256
|
+
*
|
|
257
|
+
* @param {string} method - The HTTP method (e.g., 'post', 'get')
|
|
258
|
+
* @returns {string} The capitalized method name for hook naming
|
|
259
|
+
*/
|
|
260
|
+
export const getMethodHookSuffix = (method) => {
|
|
261
|
+
return method.charAt(0).toUpperCase() + method.slice(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validates resource attributes before write operations
|
|
266
|
+
*
|
|
267
|
+
* @param {Object} params - Validation parameters
|
|
268
|
+
* @param {Object} params.context - The request context
|
|
269
|
+
* @param {Object} params.schema - The resource schema
|
|
270
|
+
* @param {Object} params.belongsToUpdates - BelongsTo relationship updates
|
|
271
|
+
* @param {Function} params.runHooks - Function to run hooks
|
|
272
|
+
* @param {boolean} params.isPartialValidation - Whether this is partial validation (for PATCH)
|
|
273
|
+
* @throws {RestApiValidationError} If validation fails
|
|
274
|
+
*/
|
|
275
|
+
export const validateResourceAttributesBeforeWrite = async ({
|
|
276
|
+
context,
|
|
277
|
+
schema,
|
|
278
|
+
belongsToUpdates,
|
|
279
|
+
runHooks,
|
|
280
|
+
isPartialValidation = false
|
|
281
|
+
}) => {
|
|
282
|
+
// Dynamically get the method suffix
|
|
283
|
+
const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
|
|
284
|
+
|
|
285
|
+
await runHooks('beforeSchemaValidate');
|
|
286
|
+
await runHooks(`beforeSchemaValidate${methodSpecificHookSuffix}`);
|
|
287
|
+
|
|
288
|
+
// Store original input attributes before validation adds defaults (primarily for POST)
|
|
289
|
+
// Only if it's not already set and the current method is POST
|
|
290
|
+
if (!context.originalInputAttributes && context.method === 'post') {
|
|
291
|
+
context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Merge belongsTo updates with attributes for validation
|
|
295
|
+
const attributesToValidate = {
|
|
296
|
+
...context.inputRecord.data.attributes,
|
|
297
|
+
...belongsToUpdates
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Extract computed fields before validation (they should be rejected)
|
|
301
|
+
const computedFields = {};
|
|
302
|
+
const schemaStructure = context.schemaInfo.schemaStructure || {};
|
|
303
|
+
Object.entries(attributesToValidate).forEach(([key, value]) => {
|
|
304
|
+
const fieldDef = schemaStructure[key];
|
|
305
|
+
// Computed fields should be stripped from input
|
|
306
|
+
if (fieldDef && fieldDef.computed === true && value !== undefined) {
|
|
307
|
+
computedFields[key] = value;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Warn if computed fields were sent in the input
|
|
312
|
+
if (Object.keys(computedFields).length > 0) {
|
|
313
|
+
const fieldNames = Object.keys(computedFields).join(', ');
|
|
314
|
+
console.warn(`Computed fields [${fieldNames}] were sent in input for resource '${context.scopeName}' but will be ignored as they are output-only`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Filter out ONLY computed fields from validation
|
|
318
|
+
// Virtual fields MUST go through validation
|
|
319
|
+
const attributesForValidation = Object.entries(attributesToValidate)
|
|
320
|
+
.filter(([key, _]) => {
|
|
321
|
+
const fieldDef = schemaStructure[key];
|
|
322
|
+
return !fieldDef || fieldDef.computed !== true;
|
|
323
|
+
})
|
|
324
|
+
.reduce((acc, [key, value]) => {
|
|
325
|
+
acc[key] = value;
|
|
326
|
+
return acc;
|
|
327
|
+
}, {});
|
|
328
|
+
|
|
329
|
+
const validationOptions = isPartialValidation ? { onlyObjectValues: true } : {};
|
|
330
|
+
|
|
331
|
+
const { validatedObject, errors } = await schema.validate(attributesForValidation, validationOptions);
|
|
332
|
+
|
|
333
|
+
if (Object.keys(errors).length > 0) {
|
|
334
|
+
// --- START OF MODIFICATION ---
|
|
335
|
+
const schemaStructure = context.schemaInfo.schema.structure; // Get the schema structure for lookup
|
|
336
|
+
|
|
337
|
+
const violations = Object.entries(errors).map(([field, error]) => {
|
|
338
|
+
let fieldPath = `data.attributes.${field}`; // Default path for attributes
|
|
339
|
+
|
|
340
|
+
// Check if this field is a foreign key that has an 'as' alias
|
|
341
|
+
const fieldDef = schemaStructure[field];
|
|
342
|
+
if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
|
|
343
|
+
// If it's a belongsTo field with an alias, rewrite the path to the relationship alias
|
|
344
|
+
fieldPath = `data.relationships.${fieldDef.as}.data.id`;
|
|
345
|
+
}
|
|
346
|
+
// For many-to-many relationships, the original `transformSimplifiedToJsonApi`
|
|
347
|
+
// already puts them under `relationships.relName.data`, so `field` here
|
|
348
|
+
// would already be the relationship name, not a foreign key.
|
|
349
|
+
// However, if a validation error somehow slips through for a pivot table field
|
|
350
|
+
// that doesn't have an 'as' alias but is a foreign key, you might need
|
|
351
|
+
// more sophisticated mapping. For now, this covers belongsTo.
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
field: fieldPath,
|
|
355
|
+
rule: error.code || 'invalid_value',
|
|
356
|
+
message: error.message
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
// --- END OF MODIFICATION ---
|
|
360
|
+
|
|
361
|
+
throw new RestApiValidationError(
|
|
362
|
+
'Schema validation failed for resource attributes',
|
|
363
|
+
{
|
|
364
|
+
fields: violations.map(v => v.field), // Use the potentially rewritten fields
|
|
365
|
+
violations
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Update attributes with validated values
|
|
371
|
+
// Virtual fields have now been validated and cast properly
|
|
372
|
+
context.inputRecord.data.attributes = validatedObject;
|
|
373
|
+
|
|
374
|
+
await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`);
|
|
375
|
+
await runHooks('afterSchemaValidate');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Validates that the user has access to all resources referenced in relationships
|
|
380
|
+
*
|
|
381
|
+
* @param {object} context - The context object containing authentication info
|
|
382
|
+
* @param {object} inputRecord - The input record containing relationships to validate
|
|
383
|
+
* @param {object} helpers - Data helpers including dataGetMinimal
|
|
384
|
+
* @param {function} runHooks - Function to run hooks
|
|
385
|
+
* @param {object} api - API instance to access resources
|
|
386
|
+
* @throws {Error} If user doesn't have access to any related resource
|
|
387
|
+
*/
|
|
388
|
+
export const validateRelationshipAccess = async (context, inputRecord, helpers, runHooks, api) => {
|
|
389
|
+
if (!inputRecord?.data?.relationships) return;
|
|
390
|
+
|
|
391
|
+
for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
|
|
392
|
+
if (!relData?.data) continue;
|
|
393
|
+
|
|
394
|
+
// Handle both single and array relationships
|
|
395
|
+
const relatedItems = Array.isArray(relData.data) ? relData.data : [relData.data];
|
|
396
|
+
|
|
397
|
+
for (const item of relatedItems) {
|
|
398
|
+
// Get the scope for the related resource
|
|
399
|
+
const relatedScope = api.resources[item.type];
|
|
400
|
+
if (!relatedScope) {
|
|
401
|
+
throw new Error(`Unknown resource type: ${item.type}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Create context for dataGetMinimal
|
|
405
|
+
const getContext = {
|
|
406
|
+
...context,
|
|
407
|
+
id: item.id,
|
|
408
|
+
schemaInfo: relatedScope.vars.schemaInfo,
|
|
409
|
+
scopeName: item.type,
|
|
410
|
+
method: 'get', // We're checking read permission
|
|
411
|
+
isUpdate: false
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
// Get the minimal record
|
|
416
|
+
const record = await helpers.dataGetMinimal({
|
|
417
|
+
scopeName: item.type,
|
|
418
|
+
context: getContext,
|
|
419
|
+
runHooks
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (!record) {
|
|
423
|
+
throw new Error(`Cannot create relationship to non-existent ${item.type} with id ${item.id}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Check permissions using the related scope's checkPermissions
|
|
427
|
+
await relatedScope.checkPermissions({
|
|
428
|
+
method: 'get',
|
|
429
|
+
auth: context.auth,
|
|
430
|
+
id: item.id,
|
|
431
|
+
minimalRecord: record,
|
|
432
|
+
transaction: context.transaction
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Applies field setters to transform attribute values before storage
|
|
440
|
+
* Setters are applied in dependency order to handle interdependent fields
|
|
441
|
+
*
|
|
442
|
+
* @param {object} attributes - The validated attributes to transform
|
|
443
|
+
* @param {object} schemaInfo - Schema information including fieldSetters
|
|
444
|
+
* @param {object} context - The request context
|
|
445
|
+
* @param {object} api - The API instance
|
|
446
|
+
* @param {object} helpers - Helper functions
|
|
447
|
+
* @returns {object} Transformed attributes ready for storage
|
|
448
|
+
*/
|
|
449
|
+
export const applyFieldSetters = async (attributes, schemaInfo, context, api, helpers) => {
|
|
450
|
+
const fieldSetters = schemaInfo.fieldSetters || {};
|
|
451
|
+
const sortedSetterFields = schemaInfo.sortedSetterFields || [];
|
|
452
|
+
|
|
453
|
+
// No setters to apply
|
|
454
|
+
if (sortedSetterFields.length === 0) {
|
|
455
|
+
return attributes;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let transformedAttributes = { ...attributes };
|
|
459
|
+
|
|
460
|
+
for (const fieldName of sortedSetterFields) {
|
|
461
|
+
// Only process fields that exist in the attributes
|
|
462
|
+
if (fieldName in transformedAttributes) {
|
|
463
|
+
const setterInfo = fieldSetters[fieldName];
|
|
464
|
+
try {
|
|
465
|
+
const setterContext = {
|
|
466
|
+
attributes: transformedAttributes, // Current state with previous setters applied
|
|
467
|
+
fieldName,
|
|
468
|
+
originalValue: attributes[fieldName],
|
|
469
|
+
originalAttributes: attributes,
|
|
470
|
+
scopeName: context.scopeName,
|
|
471
|
+
method: context.method,
|
|
472
|
+
api,
|
|
473
|
+
helpers,
|
|
474
|
+
auth: context.auth
|
|
475
|
+
};
|
|
476
|
+
transformedAttributes[fieldName] = await setterInfo.setter(
|
|
477
|
+
transformedAttributes[fieldName],
|
|
478
|
+
setterContext
|
|
479
|
+
);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
console.warn(`Setter for field '${fieldName}' failed:`, error);
|
|
482
|
+
// Keep original value if setter fails
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return transformedAttributes;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function handleRecordReturnAfterWrite({
|
|
491
|
+
context,
|
|
492
|
+
scopeName,
|
|
493
|
+
api,
|
|
494
|
+
scopes,
|
|
495
|
+
schemaStructure,
|
|
496
|
+
schemaRelationships,
|
|
497
|
+
scopeOptions,
|
|
498
|
+
vars,
|
|
499
|
+
runHooks,
|
|
500
|
+
helpers,
|
|
501
|
+
log
|
|
502
|
+
}) {
|
|
503
|
+
// Create enhanced logger for warnings
|
|
504
|
+
const enhancedLog = getEnhancedLogger(log);
|
|
505
|
+
const methodSpecificHookSuffix = getMethodHookSuffix(context.method);
|
|
506
|
+
|
|
507
|
+
// Step 1: Set up record state for hooks
|
|
508
|
+
// Handle originalMinimalRecord and minimalRecord based on method type
|
|
509
|
+
if (context.method === 'DELETE') {
|
|
510
|
+
// For DELETE, keep the deleted record reference
|
|
511
|
+
if (context.minimalRecord) {
|
|
512
|
+
context.originalMinimalRecord = context.minimalRecord;
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
// For POST, PUT, PATCH - save the original state if it exists
|
|
516
|
+
if (context.minimalRecord) {
|
|
517
|
+
context.originalMinimalRecord = context.minimalRecord;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Fetch the current state of the record after the write operation
|
|
521
|
+
try {
|
|
522
|
+
const currentRecord = await helpers.dataGetMinimal({
|
|
523
|
+
scopeName,
|
|
524
|
+
context,
|
|
525
|
+
runHooks
|
|
526
|
+
});
|
|
527
|
+
context.minimalRecord = currentRecord;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Step 2: Determine what to return based on configuration
|
|
534
|
+
const returnMode = context.returnRecordSetting[context.method];
|
|
535
|
+
|
|
536
|
+
// Case 1: Return nothing (204 No Content)
|
|
537
|
+
if (returnMode === 'no') {
|
|
538
|
+
context.responseRecord = undefined;
|
|
539
|
+
await runHooks('finish');
|
|
540
|
+
await runHooks(`finish${methodSpecificHookSuffix}`);
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Case 2: Return minimal record (just type and id)
|
|
545
|
+
if (returnMode === 'minimal') {
|
|
546
|
+
if (context.simplified) {
|
|
547
|
+
context.responseRecord = {
|
|
548
|
+
id: String(context.id),
|
|
549
|
+
type: scopeName
|
|
550
|
+
};
|
|
551
|
+
} else {
|
|
552
|
+
context.responseRecord = {
|
|
553
|
+
data: {
|
|
554
|
+
type: scopeName,
|
|
555
|
+
id: String(context.id)
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
await runHooks('finish');
|
|
560
|
+
await runHooks(`finish${methodSpecificHookSuffix}`);
|
|
561
|
+
return context.responseRecord;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Case 3: Return full record
|
|
565
|
+
if (returnMode === 'full') {
|
|
566
|
+
// Fetch the complete record using the GET method
|
|
567
|
+
const fullRecord = await api.resources[scopeName].get({
|
|
568
|
+
id: context.id,
|
|
569
|
+
queryParams: context.queryParams,
|
|
570
|
+
transaction: context.transaction,
|
|
571
|
+
simplified: context.simplified
|
|
572
|
+
}, {...context });
|
|
573
|
+
|
|
574
|
+
context.responseRecord = fullRecord || undefined;
|
|
575
|
+
|
|
576
|
+
// Run finish hooks
|
|
577
|
+
await runHooks('finish');
|
|
578
|
+
await runHooks(`finish${methodSpecificHookSuffix}`);
|
|
579
|
+
|
|
580
|
+
// No transformation needed - GET already returns the correct format
|
|
581
|
+
// based on context.simplified
|
|
582
|
+
return context.responseRecord;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// This should never be reached, but just in case
|
|
586
|
+
throw new Error(`Invalid returnMode: ${returnMode}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
|
|
590
|
+
// First check schemaRelationships (for relationships defined in relationships object)
|
|
591
|
+
const relDef = schemaInfo.schemaRelationships?.[relationshipName];
|
|
592
|
+
if (relDef) {
|
|
593
|
+
return relDef;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Then check schema fields for belongsTo relationships with matching 'as' property
|
|
597
|
+
for (const [fieldName, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
|
|
598
|
+
if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
|
|
599
|
+
return fieldDef;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return null;
|
|
604
|
+
};
|
|
605
|
+
|