json-rest-api 1.0.22 → 1.0.23
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.md +1 -2
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
- package/docs/API.md +51 -14
- package/docs/COMPARISON.md +2 -2
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
- package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
- package/docs/GUIDE/index.md +18 -9
- package/docs/QUICKSTART.md +5 -1
- package/docs/index.md +1 -1
- package/index.js +3 -2
- package/package.json +2 -2
- package/plugins/core/connectors/express-plugin.js +97 -184
- package/plugins/core/connectors/fastify-plugin.js +206 -0
- package/plugins/core/connectors/lib/connector-core.js +195 -0
- package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
- package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
- package/plugins/core/lib/dbIntrospection.js +1078 -0
- package/plugins/core/lib/dbTablesOperations.js +1104 -160
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
- package/plugins/core/lib/querying/knex-process-includes.js +3 -4
- package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
- package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
- package/plugins/core/lib/querying/query-builder-utils.js +19 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
- package/plugins/core/lib/querying/sort-helpers.js +33 -0
- package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
- package/plugins/core/lib/querying/url-helpers.js +8 -2
- package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
- package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
- package/plugins/core/lib/storage/storage-adapter.js +79 -2
- package/plugins/core/lib/storage/storage-mapping.js +204 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
- package/plugins/core/rest-api-autofilter-plugin.js +414 -0
- package/plugins/core/rest-api-knex-plugin.js +249 -211
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +121 -83
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get.js +23 -14
- package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
- package/plugins/core/rest-api-plugin-methods/post.js +13 -8
- package/plugins/core/rest-api-plugin-methods/put.js +45 -21
- package/plugins/core/rest-api-plugin-methods/query.js +15 -53
- package/plugins/core/rest-api-plugin.js +2 -0
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/plugins/core/rest-api-query-projections-plugin.js +130 -0
- package/plugins/core/socketio-plugin.js +1 -3
- package/tests/autofilter.test.js +463 -0
- package/tests/custom-id-simplified-post.test.js +75 -0
- package/tests/custom-idproperty-relationships.test.js +33 -0
- package/tests/db-introspection.test.js +518 -0
- package/tests/db-table-operations.test.js +781 -0
- package/tests/fastify-plugin.test.js +141 -0
- package/tests/field-getters.test.js +1 -2
- package/tests/field-setters.test.js +0 -4
- package/tests/fixtures/api-configs.js +188 -219
- package/tests/helpers/fake-fastify.js +99 -0
- package/tests/http-connectors-parity.test.js +235 -0
- package/tests/projected-fields.test.js +319 -0
- package/tests/relationships.test.js +3 -3
- package/tests/searchschema-knex-behavior.test.js +187 -0
- package/tests/searchschema-merge.test.js +21 -69
- package/tests/storage-mapping.test.js +210 -0
- package/audit/00-charter.md +0 -103
- package/audit/01-scope-map.md +0 -84
- package/audit/02-coverage-matrix.csv +0 -174
- package/audit/03-invariants.md +0 -123
- package/audit/04-standards-rubric.md +0 -56
- package/audit/05-competitor-rubric.md +0 -77
- package/audit/06-findings.md +0 -160
- package/audit/07-risk-register.csv +0 -10
- package/audit/08-remediation-roadmap.md +0 -177
- package/audit/09-signoff-check.md +0 -23
- package/audit/10-final-synthesis.md +0 -29
- package/audit/evidence/code/all-files-json-rest-api.txt +0 -168
- package/audit/evidence/code/all-files-json-rest-schema.txt +0 -12
- package/audit/evidence/code/boundary-leak-checks.txt +0 -72
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__bulk-operations-plugin.js.txt +0 -480
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__express-plugin.js.txt +0 -532
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__busboy-detector.js.txt +0 -175
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__formidable-detector.js.txt +0 -138
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__request-helpers.js.txt +0 -222
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__file-handling-plugin.js.txt +0 -342
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__dbTablesOperations.js.txt +0 -304
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__fractional-positioning.js.txt +0 -188
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__compile-schemas.js.txt +0 -336
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__connectors-query-parser.js.txt +0 -165
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-capabilities.js.txt +0 -265
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-value-normalizers.js.txt +0 -369
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__default-data-helpers.js.txt +0 -72
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__field-utils.js.txt +0 -139
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-constants.js.txt +0 -45
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-field-helpers.js.txt +0 -396
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-json-api-transformers.js.txt +0 -162
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__payload-validators.js.txt +0 -1314
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__schema-helpers.js.txt +0 -488
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__scope-validations.js.txt +0 -484
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__simplified-helpers.js.txt +0 -857
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-cross-table-search.js.txt +0 -565
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-json-api-transformers-querying.js.txt +0 -485
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-pagination-helpers.js.txt +0 -560
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-process-includes.js.txt +0 -167
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers-base.js.txt +0 -109
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers.js.txt +0 -1064
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-relationship-includes.js.txt +0 -1983
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-window-queries.js.txt +0 -328
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__url-helpers.js.txt +0 -119
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__storage__storage-adapter.js.txt +0 -160
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__many-to-many-manipulations.js.txt +0 -300
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__relationship-processor.js.txt +0 -245
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-access.js.txt +0 -459
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-cors-plugin.js.txt +0 -237
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-knex-plugin.js.txt +0 -1135
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-multihome-plugin.js.txt +0 -314
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__compile-resource-schemas.js.txt +0 -7
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-relationship-routes.js.txt +0 -71
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-scope-routes.js.txt +0 -102
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__turn-scope-init-into-vars.js.txt +0 -49
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__validate-include-configurations.js.txt +0 -39
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__add-route.js.txt +0 -23
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__check-permissions.js.txt +0 -13
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__common.js.txt +0 -639
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete-relationship.js.txt +0 -117
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete.js.txt +0 -104
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__enrich-attributes.js.txt +0 -199
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-related.js.txt +0 -353
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-relationship.js.txt +0 -61
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get.js.txt +0 -184
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch-relationship.js.txt +0 -55
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch.js.txt +0 -191
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post-relationship.js.txt +0 -110
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post.js.txt +0 -168
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__put.js.txt +0 -253
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__query.js.txt +0 -207
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__release.js.txt +0 -12
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin.js.txt +0 -165
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-positioning-plugin.js.txt +0 -668
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__socketio-plugin.js.txt +0 -621
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__local-storage.js.txt +0 -218
- package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__s3-storage.js.txt +0 -124
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__CorePlugin.js.txt +0 -269
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__Schema.js.txt +0 -242
- package/audit/evidence/code/critical-pass1/json-rest-schema__src__index.js.txt +0 -77
- package/audit/evidence/code/critical-pass1-risk-keywords.txt +0 -238
- package/audit/evidence/code/critical-pass1-summary.csv +0 -67
- package/audit/evidence/code/critical-pass2-summary.csv +0 -67
- package/audit/evidence/code/early-silent-zones-no-test-token.txt +0 -57
- package/audit/evidence/code/error-model-signals.txt +0 -98
- package/audit/evidence/code/git-files-json-rest-api.txt +0 -171
- package/audit/evidence/code/git-files-json-rest-schema.txt +0 -27
- package/audit/evidence/code/hook-flow-signals.txt +0 -282
- package/audit/evidence/code/idproperty-signals.txt +0 -157
- package/audit/evidence/code/import-cycles-non-anyapi.txt +0 -3
- package/audit/evidence/code/import-hotspots-non-anyapi.txt +0 -96
- package/audit/evidence/code/imports-json-rest-api.txt +0 -354
- package/audit/evidence/code/imports-json-rest-schema.txt +0 -15
- package/audit/evidence/code/in-scope-files-json-rest-api.txt +0 -160
- package/audit/evidence/code/in-scope-files-json-rest-schema.txt +0 -13
- package/audit/evidence/code/non-anyapi-core-files.txt +0 -101
- package/audit/evidence/code/permission-signals.txt +0 -193
- package/audit/evidence/code/query-include-signals.txt +0 -687
- package/audit/evidence/code/transaction-signals.txt +0 -28
- package/audit/evidence/code/validation-signals.txt +0 -650
- package/audit/evidence/code/write-path-signals.txt +0 -61
- package/audit/evidence/docs/docs-claims-signals.txt +0 -1075
- package/audit/evidence/docs/schema-docs-claims-signals.txt +0 -179
- package/audit/evidence/external/corpus-freeze.md +0 -21
- package/audit/evidence/external/npm-competitor-metadata.txt +0 -70
- package/audit/evidence/external/npm-download-signals.txt +0 -23
- package/audit/evidence/external/npm-jsonapi-server.txt +0 -8
- package/audit/evidence/external/npm-modern-jsonapi-metadata.txt +0 -38
- package/audit/evidence/external/readme-fortune-signals.txt +0 -26
- package/audit/evidence/external/readme-fortune.md +0 -165
- package/audit/evidence/external/readme-json-api-serializer-signals.txt +0 -80
- package/audit/evidence/external/readme-json-api-serializer.md +0 -690
- package/audit/evidence/external/readme-jsonapi-serde-server-signals.txt +0 -0
- package/audit/evidence/external/readme-jsonapi-serde-server.md +0 -22
- package/audit/evidence/external/readme-jsonapi-serializer-signals.txt +0 -63
- package/audit/evidence/external/readme-jsonapi-serializer.md +0 -315
- package/audit/evidence/external/readme-jsonapi-server-signals.txt +0 -19
- package/audit/evidence/external/readme-jsonapi-server.md +0 -104
- package/audit/evidence/external/readme-nestjs-json-api-signals.txt +0 -4
- package/audit/evidence/external/readme-nestjs-json-api.md +0 -94
- package/audit/evidence/external/readme-yaser-json-api-nestjs-signals.txt +0 -47
- package/audit/evidence/external/readme-yaser-json-api-nestjs.md +0 -447
- package/audit/evidence/external/standard-jsonapi-snippets.txt +0 -84
- package/audit/evidence/external/standard-rfc-snippets.txt +0 -168
- package/audit/evidence/external/standards-sources.txt +0 -6
- package/audit/evidence/runtime/baseline-commits.txt +0 -2
- package/audit/evidence/runtime/db-adapter-assumptions.txt +0 -3
- package/audit/evidence/runtime/environment.txt +0 -4
- package/audit/evidence/runtime/invariant-runtime-mapping.md +0 -38
- package/audit/evidence/runtime/probe-patch-relationship-error.exitcode +0 -1
- package/audit/evidence/runtime/probe-patch-relationship-error.log +0 -76
- package/audit/evidence/runtime/probe-patch-relationship-error.mjs +0 -67
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.exitcode +0 -1
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.log +0 -62
- package/audit/evidence/runtime/probe-patch-relationship-referenceerror.mjs +0 -47
- package/audit/evidence/runtime/test-exitcodes.txt +0 -4
- package/audit/evidence/runtime/test-summary.md +0 -107
- package/audit/evidence/runtime/trace-anchors.txt +0 -61
- package/audit/evidence/tests/access-multitenancy-test-signals.txt +0 -125
- package/audit/evidence/tests/failing-tests-summary.txt +0 -25
- package/audit/evidence/tests/npm-test-json-rest-api.exitcode +0 -1
- package/audit/evidence/tests/npm-test-json-rest-api.log +0 -5885
- package/audit/evidence/tests/npm-test-json-rest-schema.exitcode +0 -1
- package/audit/evidence/tests/npm-test-json-rest-schema.log +0 -356
- package/audit/evidence/tests/query-include-test-signals.txt +0 -948
- package/audit/evidence/tests/relationship-test-signals.txt +0 -223
- package/audit/evidence/tests/skipped-tests.txt +0 -10
- package/audit/evidence/tests/test-suite-list-non-anyapi.txt +0 -30
- package/audit/evidence/tests/test-suite-list.txt +0 -32
- package/audit/subsystems/docs-drift.md +0 -49
- package/audit/subsystems/pass2-cross-review.md +0 -131
- package/audit/subsystems/plugins-access.md +0 -51
- package/audit/subsystems/plugins-bulk.md +0 -50
- package/audit/subsystems/plugins-cors.md +0 -48
- package/audit/subsystems/plugins-file.md +0 -53
- package/audit/subsystems/plugins-multihome.md +0 -50
- package/audit/subsystems/plugins-positioning.md +0 -54
- package/audit/subsystems/plugins-socketio.md +0 -50
- package/audit/subsystems/query-engine.md +0 -61
- package/audit/subsystems/relationships.md +0 -55
- package/audit/subsystems/rest-orchestration.md +0 -66
- package/audit/subsystems/schema-core.md +0 -64
- package/audit/subsystems/storage-knex.md +0 -57
- package/audit/subsystems/tests.md +0 -48
- package/audit/subsystems/transport-express.md +0 -55
- package/audit/traces/trace-read-get.md +0 -22
- package/audit/traces/trace-read-query.md +0 -25
- package/audit/traces/trace-relationship-endpoints.md +0 -31
- package/audit/traces/trace-write-delete.md +0 -17
- package/audit/traces/trace-write-patch.md +0 -26
- package/audit/traces/trace-write-post.md +0 -22
- package/audit/traces/trace-write-put.md +0 -23
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
- package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
- package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
- package/plugins/core/rest-api-access.js +0 -459
- package/plugins/core/rest-api-multihome-plugin.js +0 -314
- package/tests/multihome.test.js +0 -553
- package/tests/multihome.test.js.bak +0 -550
- package/tests/ownership-optout.test.js +0 -76
- package/tests/rest-api-access.test.js +0 -206
|
@@ -1,1314 +0,0 @@
|
|
|
1
|
-
import { RestApiValidationError, RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates that include paths don't exceed maximum depth
|
|
5
|
-
*
|
|
6
|
-
* @param {string[]} includes - Array of include paths to validate
|
|
7
|
-
* @param {number} maxDepth - Maximum allowed depth
|
|
8
|
-
* @returns {void}
|
|
9
|
-
* @throws {RestApiValidationError} If depth is exceeded
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* // Input: Valid include paths
|
|
13
|
-
* validateIncludeDepth(['author', 'comments.author'], 3);
|
|
14
|
-
* // Output: Validation succeeds (no error thrown)
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* // Input: Path exceeding depth limit
|
|
18
|
-
* validateIncludeDepth(['author.company.employees.manager'], 3);
|
|
19
|
-
* // Throws: RestApiValidationError
|
|
20
|
-
* // "Include path 'author.company.employees.manager' exceeds maximum depth of 3"
|
|
21
|
-
*
|
|
22
|
-
* @private
|
|
23
|
-
*/
|
|
24
|
-
function validateIncludeDepth (includes, maxDepth) {
|
|
25
|
-
if (!includes || !Array.isArray(includes)) {
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
for (const includePath of includes) {
|
|
30
|
-
if (typeof includePath !== 'string') {
|
|
31
|
-
continue // Let other validators handle type errors
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const depth = includePath.split('.').length
|
|
35
|
-
if (depth > maxDepth) {
|
|
36
|
-
throw new RestApiValidationError(
|
|
37
|
-
`Include path '${includePath}' exceeds maximum depth of ${maxDepth}`,
|
|
38
|
-
{
|
|
39
|
-
fields: ['include'],
|
|
40
|
-
violations: [{
|
|
41
|
-
field: 'include',
|
|
42
|
-
rule: 'max_depth',
|
|
43
|
-
message: `Path '${includePath}' has depth ${depth}, maximum allowed is ${maxDepth}`
|
|
44
|
-
}]
|
|
45
|
-
}
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Validates a JSON:API resource identifier object
|
|
53
|
-
*
|
|
54
|
-
* @param {Object} identifier - Resource identifier to validate
|
|
55
|
-
* @param {string} context - Context for error messages
|
|
56
|
-
* @param {Object} scopes - Scopes proxy to verify resource types
|
|
57
|
-
* @returns {boolean} True if valid
|
|
58
|
-
* @throws {RestApiPayloadError|RestApiValidationError} If validation fails
|
|
59
|
-
*
|
|
60
|
-
* @example
|
|
61
|
-
* // Input: Valid identifier
|
|
62
|
-
* validateResourceIdentifier(
|
|
63
|
-
* { type: 'articles', id: '123' },
|
|
64
|
-
* "Relationship 'author'",
|
|
65
|
-
* scopes
|
|
66
|
-
* );
|
|
67
|
-
* // Output: true
|
|
68
|
-
*
|
|
69
|
-
* @example
|
|
70
|
-
* // Input: Missing type field
|
|
71
|
-
* validateResourceIdentifier(
|
|
72
|
-
* { id: '123' },
|
|
73
|
-
* "Relationship 'comments'",
|
|
74
|
-
* scopes
|
|
75
|
-
* );
|
|
76
|
-
* // Throws: RestApiPayloadError
|
|
77
|
-
* // "Relationship 'comments': Resource identifier must have a non-empty 'type' string"
|
|
78
|
-
*
|
|
79
|
-
* @example
|
|
80
|
-
* // Input: Unknown resource type
|
|
81
|
-
* validateResourceIdentifier(
|
|
82
|
-
* { type: 'unicorns', id: '456' },
|
|
83
|
-
* "Included resource",
|
|
84
|
-
* scopes // scopes['unicorns'] doesn't exist
|
|
85
|
-
* );
|
|
86
|
-
* // Throws: RestApiValidationError
|
|
87
|
-
* // "Included resource: Unknown resource type 'unicorns'. No scope with this name exists."
|
|
88
|
-
*
|
|
89
|
-
* @description
|
|
90
|
-
* Used by:
|
|
91
|
-
* - validateRelationship to check relationship data
|
|
92
|
-
* - validatePostPayload to validate included resources
|
|
93
|
-
* - Internal validation of resource references
|
|
94
|
-
*
|
|
95
|
-
* Purpose:
|
|
96
|
-
* - Ensures JSON:API compliance for resource identifiers
|
|
97
|
-
* - Validates resource types exist in the system
|
|
98
|
-
* - Provides clear error context for debugging
|
|
99
|
-
*
|
|
100
|
-
* @private
|
|
101
|
-
*/
|
|
102
|
-
function validateResourceIdentifier (identifier, context, scopes = null) {
|
|
103
|
-
if (!identifier || typeof identifier !== 'object') {
|
|
104
|
-
throw new RestApiPayloadError(
|
|
105
|
-
`${context}: Resource identifier must be an object`,
|
|
106
|
-
{ path: context, expected: 'object', received: typeof identifier }
|
|
107
|
-
)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (typeof identifier.type !== 'string' || !identifier.type) {
|
|
111
|
-
throw new RestApiPayloadError(
|
|
112
|
-
`${context}: Resource identifier must have a non-empty 'type' string`,
|
|
113
|
-
{ path: `${context}.type`, expected: 'non-empty string', received: identifier.type }
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Check if type is valid by checking if scope exists
|
|
118
|
-
if (scopes && !scopes[identifier.type]) {
|
|
119
|
-
throw new RestApiValidationError(
|
|
120
|
-
`${context}: Unknown resource type '${identifier.type}'. No scope with this name exists.`,
|
|
121
|
-
{
|
|
122
|
-
fields: [`${context}.type`],
|
|
123
|
-
violations: [{ field: `${context}.type`, rule: 'valid_resource_type', message: `Resource type '${identifier.type}' does not exist` }]
|
|
124
|
-
}
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (!('id' in identifier)) {
|
|
129
|
-
throw new RestApiPayloadError(
|
|
130
|
-
`${context}: Resource identifier must have an 'id' property`,
|
|
131
|
-
{ path: `${context}.id`, expected: 'property exists', received: 'missing' }
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (identifier.id !== null && typeof identifier.id !== 'string' && typeof identifier.id !== 'number') {
|
|
136
|
-
throw new RestApiPayloadError(
|
|
137
|
-
`${context}: Resource identifier 'id' must be a string, number, or null`,
|
|
138
|
-
{ path: `${context}.id`, expected: 'string, number, or null', received: typeof identifier.id }
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Validates a relationship object in JSON:API format
|
|
145
|
-
*
|
|
146
|
-
* @param {Object} relationship - Relationship object to validate
|
|
147
|
-
* @param {string} relationshipName - Name for error context
|
|
148
|
-
* @param {Object} scopes - Scopes proxy to verify resource types
|
|
149
|
-
* @returns {void}
|
|
150
|
-
* @throws {RestApiPayloadError} If validation fails
|
|
151
|
-
*
|
|
152
|
-
* @example
|
|
153
|
-
* // Input: To-one relationship
|
|
154
|
-
* validateRelationship(
|
|
155
|
-
* { data: { type: 'users', id: '42' } },
|
|
156
|
-
* 'author',
|
|
157
|
-
* scopes
|
|
158
|
-
* );
|
|
159
|
-
* // Output: Validation succeeds (no error thrown)
|
|
160
|
-
*
|
|
161
|
-
* @example
|
|
162
|
-
* // Input: To-many relationship
|
|
163
|
-
* validateRelationship(
|
|
164
|
-
* {
|
|
165
|
-
* data: [
|
|
166
|
-
* { type: 'tags', id: '1' },
|
|
167
|
-
* { type: 'tags', id: '2' }
|
|
168
|
-
* ]
|
|
169
|
-
* },
|
|
170
|
-
* 'tags',
|
|
171
|
-
* scopes
|
|
172
|
-
* );
|
|
173
|
-
* // Output: Validation succeeds (no error thrown)
|
|
174
|
-
*
|
|
175
|
-
* @example
|
|
176
|
-
* // Input: Null relationship (remove association)
|
|
177
|
-
* validateRelationship(
|
|
178
|
-
* { data: null },
|
|
179
|
-
* 'featuredImage',
|
|
180
|
-
* scopes
|
|
181
|
-
* );
|
|
182
|
-
* // Output: Validation succeeds (null is valid for clearing)
|
|
183
|
-
*
|
|
184
|
-
* @example
|
|
185
|
-
* // Input: Missing data property
|
|
186
|
-
* validateRelationship(
|
|
187
|
-
* { type: 'users', id: '1' }, // Wrong structure
|
|
188
|
-
* 'author',
|
|
189
|
-
* scopes
|
|
190
|
-
* );
|
|
191
|
-
* // Throws: RestApiPayloadError
|
|
192
|
-
* // "Relationship 'author' must have a 'data' property"
|
|
193
|
-
*
|
|
194
|
-
* @description
|
|
195
|
-
* Used by:
|
|
196
|
-
* - validatePostPayload for new resource relationships
|
|
197
|
-
* - validatePutPayload for relationship replacement
|
|
198
|
-
* - validatePatchPayload for relationship updates
|
|
199
|
-
*
|
|
200
|
-
* Purpose:
|
|
201
|
-
* - Ensures JSON:API relationship structure
|
|
202
|
-
* - Validates both to-one and to-many relationships
|
|
203
|
-
* - Handles null/empty cases for clearing relationships
|
|
204
|
-
* - Validates each resource identifier in arrays
|
|
205
|
-
*
|
|
206
|
-
* @private
|
|
207
|
-
*/
|
|
208
|
-
function validateRelationship (relationship, relationshipName, scopes = null) {
|
|
209
|
-
if (!relationship || typeof relationship !== 'object') {
|
|
210
|
-
throw new RestApiPayloadError(
|
|
211
|
-
`Relationship '${relationshipName}' must be an object`,
|
|
212
|
-
{ path: `relationships.${relationshipName}`, expected: 'object', received: typeof relationship }
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (!('data' in relationship)) {
|
|
217
|
-
throw new RestApiPayloadError(
|
|
218
|
-
`Relationship '${relationshipName}' must have a 'data' property`,
|
|
219
|
-
{ path: `relationships.${relationshipName}.data`, expected: 'property exists', received: 'missing' }
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const { data } = relationship
|
|
224
|
-
|
|
225
|
-
// data can be null (empty to-one relationship)
|
|
226
|
-
if (data === null) {
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// data can be a single resource identifier (to-one)
|
|
231
|
-
if (!Array.isArray(data)) {
|
|
232
|
-
validateResourceIdentifier(data, `Relationship '${relationshipName}'`, scopes)
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// data can be an array of resource identifiers (to-many)
|
|
237
|
-
if (data.length === 0) {
|
|
238
|
-
return // Empty to-many relationship is valid
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
data.forEach((identifier, index) => {
|
|
242
|
-
validateResourceIdentifier(identifier, `Relationship '${relationshipName}[${index}]'`, scopes)
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Validates query parameters for GET requests (single resource retrieval)
|
|
248
|
-
*
|
|
249
|
-
* @param {Object} params - Parameters containing id and queryParams
|
|
250
|
-
* @param {string|number} params.id - Resource ID to fetch
|
|
251
|
-
* @param {Object} [params.queryParams] - Optional query parameters
|
|
252
|
-
* @param {string[]} [params.queryParams.include] - Related resources to include
|
|
253
|
-
* @param {Object} [params.queryParams.fields] - Sparse fieldsets by resource type
|
|
254
|
-
* @param {number} maxIncludeDepth - Maximum allowed include depth
|
|
255
|
-
* @returns {boolean} True if valid
|
|
256
|
-
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* // Input: Basic GET request
|
|
260
|
-
* validateGetPayload({
|
|
261
|
-
* id: '123'
|
|
262
|
-
* });
|
|
263
|
-
* // Output: true
|
|
264
|
-
*
|
|
265
|
-
* @example
|
|
266
|
-
* // Input: GET with nested includes
|
|
267
|
-
* validateGetPayload({
|
|
268
|
-
* id: '123',
|
|
269
|
-
* queryParams: {
|
|
270
|
-
* include: ['author', 'comments.author']
|
|
271
|
-
* }
|
|
272
|
-
* });
|
|
273
|
-
* // Output: true (include depths are 1 and 2)
|
|
274
|
-
*
|
|
275
|
-
* @example
|
|
276
|
-
* // Input: GET with sparse fieldsets
|
|
277
|
-
* validateGetPayload({
|
|
278
|
-
* id: '123',
|
|
279
|
-
* queryParams: {
|
|
280
|
-
* include: ['author'],
|
|
281
|
-
* fields: {
|
|
282
|
-
* articles: 'title,body', // Only these article fields
|
|
283
|
-
* users: 'name,email' // Only these user fields
|
|
284
|
-
* }
|
|
285
|
-
* }
|
|
286
|
-
* });
|
|
287
|
-
* // Output: true
|
|
288
|
-
*
|
|
289
|
-
* @example
|
|
290
|
-
* // Input: Missing ID parameter
|
|
291
|
-
* validateGetPayload({
|
|
292
|
-
* queryParams: { include: ['author'] }
|
|
293
|
-
* });
|
|
294
|
-
* // Throws: RestApiValidationError
|
|
295
|
-
* // "GET request must include an id parameter"
|
|
296
|
-
*
|
|
297
|
-
* @example
|
|
298
|
-
* // Input: Invalid field specification
|
|
299
|
-
* validateGetPayload({
|
|
300
|
-
* id: '123',
|
|
301
|
-
* queryParams: {
|
|
302
|
-
* fields: {
|
|
303
|
-
* articles: ['title', 'body'] // Should be string, not array
|
|
304
|
-
* }
|
|
305
|
-
* }
|
|
306
|
-
* });
|
|
307
|
-
* // Throws: RestApiPayloadError
|
|
308
|
-
* // "queryParams.fields['articles'] must be a comma-separated string"
|
|
309
|
-
*
|
|
310
|
-
* @description
|
|
311
|
-
* Used by:
|
|
312
|
-
* - rest-api-plugin.get() method before data fetching
|
|
313
|
-
* - Validates parameters before passing to storage layer
|
|
314
|
-
*
|
|
315
|
-
* Purpose:
|
|
316
|
-
* - Ensures required ID parameter is present and valid
|
|
317
|
-
* - Validates JSON:API query parameter structure
|
|
318
|
-
* - Prevents invalid queries from reaching database
|
|
319
|
-
* - Enforces include depth limits for performance
|
|
320
|
-
* - Provides consistent error messages across storage backends
|
|
321
|
-
*
|
|
322
|
-
* Data flow:
|
|
323
|
-
* 1. Checks params object structure
|
|
324
|
-
* 2. Validates required ID parameter
|
|
325
|
-
* 3. If queryParams exist, validates each type
|
|
326
|
-
* 4. Validates include paths don't exceed depth limit
|
|
327
|
-
* 5. Returns true or throws descriptive error
|
|
328
|
-
*/
|
|
329
|
-
export function validateGetPayload (params, maxIncludeDepth = 3) {
|
|
330
|
-
if (!params || typeof params !== 'object') {
|
|
331
|
-
throw new RestApiPayloadError(
|
|
332
|
-
'GET parameters must be an object',
|
|
333
|
-
{ path: 'params', expected: 'object', received: typeof params }
|
|
334
|
-
)
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Validate ID
|
|
338
|
-
if (!('id' in params)) {
|
|
339
|
-
throw new RestApiValidationError(
|
|
340
|
-
'GET request must include an id parameter',
|
|
341
|
-
{ fields: ['id'], violations: [{ field: 'id', rule: 'required', message: 'ID parameter is required' }] }
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (params.id === null || params.id === undefined || params.id === '') {
|
|
346
|
-
throw new RestApiValidationError(
|
|
347
|
-
'GET request id cannot be null, undefined, or empty',
|
|
348
|
-
{ fields: ['id'], violations: [{ field: 'id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
349
|
-
)
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Validate queryParams if present
|
|
353
|
-
if (params.queryParams) {
|
|
354
|
-
if (typeof params.queryParams !== 'object') {
|
|
355
|
-
throw new RestApiPayloadError(
|
|
356
|
-
'queryParams must be an object',
|
|
357
|
-
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
358
|
-
)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const { include, fields } = params.queryParams
|
|
362
|
-
|
|
363
|
-
// Validate include
|
|
364
|
-
if (include !== undefined) {
|
|
365
|
-
if (!Array.isArray(include)) {
|
|
366
|
-
throw new RestApiPayloadError(
|
|
367
|
-
'queryParams.include must be an array of strings',
|
|
368
|
-
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
369
|
-
)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
include.forEach((path, index) => {
|
|
373
|
-
if (typeof path !== 'string') {
|
|
374
|
-
throw new RestApiPayloadError(
|
|
375
|
-
`queryParams.include[${index}] must be a string`,
|
|
376
|
-
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
377
|
-
)
|
|
378
|
-
}
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
// Validate include depth
|
|
382
|
-
validateIncludeDepth(include, maxIncludeDepth)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Validate fields
|
|
386
|
-
if (fields !== undefined) {
|
|
387
|
-
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
388
|
-
throw new RestApiPayloadError(
|
|
389
|
-
'queryParams.fields must be an object',
|
|
390
|
-
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
395
|
-
if (typeof fieldList !== 'string') {
|
|
396
|
-
throw new RestApiPayloadError(
|
|
397
|
-
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
398
|
-
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
399
|
-
)
|
|
400
|
-
}
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return true
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Validates query parameters for collection requests (query/list operations)
|
|
410
|
-
*
|
|
411
|
-
* @param {Object} params - Parameters object
|
|
412
|
-
* @param {Object} [params.queryParams] - Query parameters for the collection
|
|
413
|
-
* @param {string[]} [params.queryParams.include] - Related resources to include
|
|
414
|
-
* @param {Object} [params.queryParams.fields] - Sparse fieldsets by resource type
|
|
415
|
-
* @param {Object} [params.queryParams.filters] - Filter conditions
|
|
416
|
-
* @param {string[]} [params.queryParams.sort] - Sort fields (prefix with - for DESC)
|
|
417
|
-
* @param {Object} [params.queryParams.page] - Pagination parameters
|
|
418
|
-
* @param {string[]} sortableFields - Fields allowed for sorting
|
|
419
|
-
* @param {number} maxIncludeDepth - Maximum allowed include depth
|
|
420
|
-
* @returns {boolean} True if valid
|
|
421
|
-
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
422
|
-
*
|
|
423
|
-
* @example
|
|
424
|
-
* // Input: Empty query (fetch all)
|
|
425
|
-
* validateQueryPayload({}, ['title', 'createdAt']);
|
|
426
|
-
* // Output: true
|
|
427
|
-
*
|
|
428
|
-
* @example
|
|
429
|
-
* // Input: Query with filters and sorting
|
|
430
|
-
* validateQueryPayload({
|
|
431
|
-
* queryParams: {
|
|
432
|
-
* filters: {
|
|
433
|
-
* status: 'published',
|
|
434
|
-
* 'author.name': 'John Doe' // Dot notation for relationships
|
|
435
|
-
* },
|
|
436
|
-
* sort: ['-publishedAt', 'title'] // DESC publishedAt, ASC title
|
|
437
|
-
* }
|
|
438
|
-
* }, ['publishedAt', 'title', 'createdAt']);
|
|
439
|
-
* // Output: true
|
|
440
|
-
*
|
|
441
|
-
* @example
|
|
442
|
-
* // Input: Different pagination styles
|
|
443
|
-
* // Page-based:
|
|
444
|
-
* validateQueryPayload({
|
|
445
|
-
* queryParams: {
|
|
446
|
-
* page: { number: 2, size: 20 } // Page 2, 20 items per page
|
|
447
|
-
* }
|
|
448
|
-
* }, []);
|
|
449
|
-
* // Output: true
|
|
450
|
-
*
|
|
451
|
-
* // Offset-based:
|
|
452
|
-
* validateQueryPayload({
|
|
453
|
-
* queryParams: {
|
|
454
|
-
* page: { offset: 40, limit: 20 } // Skip 40, take 20
|
|
455
|
-
* }
|
|
456
|
-
* }, []);
|
|
457
|
-
* // Output: true
|
|
458
|
-
*
|
|
459
|
-
* @example
|
|
460
|
-
* // Input: Complex query with all features
|
|
461
|
-
* validateQueryPayload({
|
|
462
|
-
* queryParams: {
|
|
463
|
-
* include: ['author', 'tags', 'comments.author'],
|
|
464
|
-
* fields: {
|
|
465
|
-
* articles: 'title,summary,publishedAt',
|
|
466
|
-
* users: 'name,avatar',
|
|
467
|
-
* tags: 'name'
|
|
468
|
-
* },
|
|
469
|
-
* filters: {
|
|
470
|
-
* status: 'published',
|
|
471
|
-
* 'tags.name': 'javascript'
|
|
472
|
-
* },
|
|
473
|
-
* sort: ['-publishedAt'],
|
|
474
|
-
* page: { number: 1, size: 10 }
|
|
475
|
-
* }
|
|
476
|
-
* }, ['publishedAt', 'title', 'updatedAt']);
|
|
477
|
-
* // Output: true (all parameters valid)
|
|
478
|
-
*
|
|
479
|
-
* @example
|
|
480
|
-
* // Input: Invalid sort field
|
|
481
|
-
* validateQueryPayload({
|
|
482
|
-
* queryParams: {
|
|
483
|
-
* sort: ['password'] // Not in sortableFields
|
|
484
|
-
* }
|
|
485
|
-
* }, ['title', 'createdAt']);
|
|
486
|
-
* // Throws: RestApiValidationError
|
|
487
|
-
* // "Field 'password' is not sortable. Sortable fields are: title, createdAt"
|
|
488
|
-
*
|
|
489
|
-
* @description
|
|
490
|
-
* Used by:
|
|
491
|
-
* - rest-api-plugin.query() method before data fetching
|
|
492
|
-
* - Validates collection request parameters
|
|
493
|
-
*
|
|
494
|
-
* Purpose:
|
|
495
|
-
* - Validates all JSON:API query parameters for collections
|
|
496
|
-
* - Prevents SQL injection via filter key validation
|
|
497
|
-
* - Enforces sortable field restrictions for performance
|
|
498
|
-
* - Ensures consistent pagination across storage backends
|
|
499
|
-
* - Validates include depth limits
|
|
500
|
-
*
|
|
501
|
-
* Data flow:
|
|
502
|
-
* 1. Validates params object structure
|
|
503
|
-
* 2. For each query parameter type, validates format
|
|
504
|
-
* 3. Checks sort fields against allowed list
|
|
505
|
-
* 4. Validates pagination parameters are numbers/strings
|
|
506
|
-
* 5. Returns true or throws descriptive error
|
|
507
|
-
*/
|
|
508
|
-
export function validateQueryPayload (params, sortableFields = [], maxIncludeDepth = 3) {
|
|
509
|
-
if (!params || typeof params !== 'object') {
|
|
510
|
-
throw new RestApiPayloadError(
|
|
511
|
-
'Query parameters must be an object',
|
|
512
|
-
{ path: 'params', expected: 'object', received: typeof params }
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// queryParams is optional but if present must be an object
|
|
517
|
-
if (params.queryParams) {
|
|
518
|
-
if (typeof params.queryParams !== 'object') {
|
|
519
|
-
throw new RestApiPayloadError(
|
|
520
|
-
'queryParams must be an object',
|
|
521
|
-
{ path: 'queryParams', expected: 'object', received: typeof params.queryParams }
|
|
522
|
-
)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const { include, fields, filters, sort, page } = params.queryParams
|
|
526
|
-
|
|
527
|
-
// Validate include
|
|
528
|
-
if (include !== undefined) {
|
|
529
|
-
if (!Array.isArray(include)) {
|
|
530
|
-
throw new RestApiPayloadError(
|
|
531
|
-
'queryParams.include must be an array of strings',
|
|
532
|
-
{ path: 'queryParams.include', expected: 'array', received: typeof include }
|
|
533
|
-
)
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
include.forEach((path, index) => {
|
|
537
|
-
if (typeof path !== 'string') {
|
|
538
|
-
throw new RestApiPayloadError(
|
|
539
|
-
`queryParams.include[${index}] must be a string`,
|
|
540
|
-
{ path: `queryParams.include[${index}]`, expected: 'string', received: typeof path }
|
|
541
|
-
)
|
|
542
|
-
}
|
|
543
|
-
})
|
|
544
|
-
|
|
545
|
-
// Validate include depth
|
|
546
|
-
validateIncludeDepth(include, maxIncludeDepth)
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Validate fields
|
|
550
|
-
if (fields !== undefined) {
|
|
551
|
-
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
552
|
-
throw new RestApiPayloadError(
|
|
553
|
-
'queryParams.fields must be an object',
|
|
554
|
-
{ path: 'queryParams.fields', expected: 'object', received: Array.isArray(fields) ? 'array' : typeof fields }
|
|
555
|
-
)
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
Object.entries(fields).forEach(([resourceType, fieldList]) => {
|
|
559
|
-
if (typeof fieldList !== 'string') {
|
|
560
|
-
throw new RestApiPayloadError(
|
|
561
|
-
`queryParams.fields['${resourceType}'] must be a comma-separated string`,
|
|
562
|
-
{ path: `queryParams.fields['${resourceType}']`, expected: 'string', received: typeof fieldList }
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
})
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Validate filters
|
|
569
|
-
if (filters !== undefined) {
|
|
570
|
-
if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) {
|
|
571
|
-
throw new RestApiPayloadError(
|
|
572
|
-
'queryParams.filters must be an object',
|
|
573
|
-
{ path: 'queryParams.filters', expected: 'object', received: Array.isArray(filters) ? 'array' : typeof filters }
|
|
574
|
-
)
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Validate sort
|
|
579
|
-
if (sort !== undefined) {
|
|
580
|
-
if (!Array.isArray(sort)) {
|
|
581
|
-
throw new RestApiPayloadError(
|
|
582
|
-
'queryParams.sort must be an array of strings',
|
|
583
|
-
{ path: 'queryParams.sort', expected: 'array', received: typeof sort }
|
|
584
|
-
)
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
sort.forEach((field, index) => {
|
|
588
|
-
if (typeof field !== 'string') {
|
|
589
|
-
throw new RestApiPayloadError(
|
|
590
|
-
`queryParams.sort[${index}] must be a string`,
|
|
591
|
-
{ path: `queryParams.sort[${index}]`, expected: 'string', received: typeof field }
|
|
592
|
-
)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// Check if field is sortable (remove leading - for descending sort)
|
|
596
|
-
const fieldName = field.startsWith('-') ? field.substring(1) : field
|
|
597
|
-
if (sortableFields.length > 0 && !sortableFields.includes(fieldName)) {
|
|
598
|
-
throw new RestApiValidationError(
|
|
599
|
-
`Field '${fieldName}' is not sortable. Sortable fields are: ${sortableFields.join(', ')}`,
|
|
600
|
-
{ fields: ['sort'], violations: [{ field: 'sort', rule: 'sortable_field', message: `Field '${fieldName}' is not in the list of sortable fields` }] }
|
|
601
|
-
)
|
|
602
|
-
}
|
|
603
|
-
})
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Validate page
|
|
607
|
-
if (page !== undefined) {
|
|
608
|
-
if (typeof page !== 'object' || page === null || Array.isArray(page)) {
|
|
609
|
-
throw new RestApiPayloadError(
|
|
610
|
-
'queryParams.page must be an object',
|
|
611
|
-
{ path: 'queryParams.page', expected: 'object', received: Array.isArray(page) ? 'array' : typeof page }
|
|
612
|
-
)
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Common pagination parameters
|
|
616
|
-
if ('number' in page && typeof page.number !== 'number' && typeof page.number !== 'string') {
|
|
617
|
-
throw new RestApiPayloadError(
|
|
618
|
-
'queryParams.page.number must be a number or string',
|
|
619
|
-
{ path: 'queryParams.page.number', expected: 'number or string', received: typeof page.number }
|
|
620
|
-
)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if ('size' in page && typeof page.size !== 'number' && typeof page.size !== 'string') {
|
|
624
|
-
throw new RestApiPayloadError(
|
|
625
|
-
'queryParams.page.size must be a number or string',
|
|
626
|
-
{ path: 'queryParams.page.size', expected: 'number or string', received: typeof page.size }
|
|
627
|
-
)
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if ('limit' in page && typeof page.limit !== 'number' && typeof page.limit !== 'string') {
|
|
631
|
-
throw new RestApiPayloadError(
|
|
632
|
-
'queryParams.page.limit must be a number or string',
|
|
633
|
-
{ path: 'queryParams.page.limit', expected: 'number or string', received: typeof page.limit }
|
|
634
|
-
)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
if ('offset' in page && typeof page.offset !== 'number' && typeof page.offset !== 'string') {
|
|
638
|
-
throw new RestApiPayloadError(
|
|
639
|
-
'queryParams.page.offset must be a number or string',
|
|
640
|
-
{ path: 'queryParams.page.offset', expected: 'number or string', received: typeof page.offset }
|
|
641
|
-
)
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
return true
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* Validates a JSON:API document for POST requests (resource creation)
|
|
651
|
-
*
|
|
652
|
-
* @param {Object} inputRecord - JSON:API document to validate
|
|
653
|
-
* @param {Object} inputRecord.data - Primary resource to create
|
|
654
|
-
* @param {string} inputRecord.data.type - Resource type (must match a scope)
|
|
655
|
-
* @param {string|number} [inputRecord.data.id] - Optional client-generated ID
|
|
656
|
-
* @param {Object} [inputRecord.data.attributes] - Resource attributes
|
|
657
|
-
* @param {Object} [inputRecord.data.relationships] - Resource relationships
|
|
658
|
-
* @param {Object[]} [inputRecord.included] - Related resources for compound documents
|
|
659
|
-
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
660
|
-
* @returns {boolean} True if valid
|
|
661
|
-
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
662
|
-
*
|
|
663
|
-
* @example
|
|
664
|
-
* // Input: Simple resource creation
|
|
665
|
-
* validatePostPayload({
|
|
666
|
-
* data: {
|
|
667
|
-
* type: 'articles',
|
|
668
|
-
* attributes: {
|
|
669
|
-
* title: 'Hello World',
|
|
670
|
-
* body: 'Welcome to my blog...'
|
|
671
|
-
* }
|
|
672
|
-
* }
|
|
673
|
-
* }, scopes);
|
|
674
|
-
* // Output: true
|
|
675
|
-
*
|
|
676
|
-
* @example
|
|
677
|
-
* // Input: Create with relationships
|
|
678
|
-
* validatePostPayload({
|
|
679
|
-
* data: {
|
|
680
|
-
* type: 'articles',
|
|
681
|
-
* attributes: {
|
|
682
|
-
* title: 'REST API Design'
|
|
683
|
-
* },
|
|
684
|
-
* relationships: {
|
|
685
|
-
* author: {
|
|
686
|
-
* data: { type: 'users', id: '42' } // To-one
|
|
687
|
-
* },
|
|
688
|
-
* tags: {
|
|
689
|
-
* data: [ // To-many
|
|
690
|
-
* { type: 'tags', id: '1' },
|
|
691
|
-
* { type: 'tags', id: '2' }
|
|
692
|
-
* ]
|
|
693
|
-
* }
|
|
694
|
-
* }
|
|
695
|
-
* }
|
|
696
|
-
* }, scopes);
|
|
697
|
-
* // Output: true (validates each relationship)
|
|
698
|
-
*
|
|
699
|
-
* @example
|
|
700
|
-
* // Input: Client-generated ID
|
|
701
|
-
* validatePostPayload({
|
|
702
|
-
* data: {
|
|
703
|
-
* type: 'articles',
|
|
704
|
-
* id: 'article-2023-11-15', // Client provides ID
|
|
705
|
-
* attributes: {
|
|
706
|
-
* title: 'Hello World'
|
|
707
|
-
* }
|
|
708
|
-
* }
|
|
709
|
-
* }, scopes);
|
|
710
|
-
* // Output: true (ID is optional for POST)
|
|
711
|
-
*
|
|
712
|
-
* @example
|
|
713
|
-
* // Input: Compound document with included
|
|
714
|
-
* validatePostPayload({
|
|
715
|
-
* data: {
|
|
716
|
-
* type: 'articles',
|
|
717
|
-
* attributes: { title: 'My Article' },
|
|
718
|
-
* relationships: {
|
|
719
|
-
* author: {
|
|
720
|
-
* data: { type: 'users', id: '42' }
|
|
721
|
-
* }
|
|
722
|
-
* }
|
|
723
|
-
* },
|
|
724
|
-
* included: [ // Related resource data
|
|
725
|
-
* {
|
|
726
|
-
* type: 'users',
|
|
727
|
-
* id: '42', // Must have ID
|
|
728
|
-
* attributes: {
|
|
729
|
-
* name: 'John Doe',
|
|
730
|
-
* email: 'john@example.com'
|
|
731
|
-
* }
|
|
732
|
-
* }
|
|
733
|
-
* ]
|
|
734
|
-
* }, scopes);
|
|
735
|
-
* // Output: true (included resources validated)
|
|
736
|
-
*
|
|
737
|
-
* @example
|
|
738
|
-
* // Input: Missing required type
|
|
739
|
-
* validatePostPayload({
|
|
740
|
-
* data: {
|
|
741
|
-
* attributes: { title: 'Missing Type' }
|
|
742
|
-
* }
|
|
743
|
-
* }, scopes);
|
|
744
|
-
* // Throws: RestApiPayloadError
|
|
745
|
-
* // 'POST request "data" must have a non-empty "type" string'
|
|
746
|
-
*
|
|
747
|
-
* @description
|
|
748
|
-
* Used by:
|
|
749
|
-
* - rest-api-plugin.post() method before creating resources
|
|
750
|
-
* - Validates complete document structure
|
|
751
|
-
*
|
|
752
|
-
* Purpose:
|
|
753
|
-
* - Ensures JSON:API compliance for resource creation
|
|
754
|
-
* - Validates resource types exist in system
|
|
755
|
-
* - Checks relationship references are valid
|
|
756
|
-
* - Validates included resources have required fields
|
|
757
|
-
* - Enables compound document creation patterns
|
|
758
|
-
*
|
|
759
|
-
* Data flow:
|
|
760
|
-
* 1. Validates document has required 'data' property
|
|
761
|
-
* 2. Checks primary resource type exists in scopes
|
|
762
|
-
* 3. Validates attributes object if present
|
|
763
|
-
* 4. Validates each relationship if present
|
|
764
|
-
* 5. Validates included resources array if present
|
|
765
|
-
* 6. Returns true or throws descriptive error
|
|
766
|
-
*/
|
|
767
|
-
export function validatePostPayload (inputRecord, scopes = null) {
|
|
768
|
-
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
769
|
-
throw new RestApiPayloadError(
|
|
770
|
-
'POST request body must be a JSON:API document object',
|
|
771
|
-
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
772
|
-
)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Validate required 'data' property
|
|
776
|
-
if (!('data' in inputRecord)) {
|
|
777
|
-
throw new RestApiPayloadError(
|
|
778
|
-
'POST request body must have a "data" property',
|
|
779
|
-
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
780
|
-
)
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const { data, included } = inputRecord
|
|
784
|
-
|
|
785
|
-
// Validate primary data
|
|
786
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
787
|
-
throw new RestApiPayloadError(
|
|
788
|
-
'POST request "data" must be a single resource object',
|
|
789
|
-
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
790
|
-
)
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (typeof data.type !== 'string' || !data.type) {
|
|
794
|
-
throw new RestApiPayloadError(
|
|
795
|
-
'POST request "data" must have a non-empty "type" string',
|
|
796
|
-
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
797
|
-
)
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Check if primary resource type is valid
|
|
801
|
-
if (scopes && !scopes[data.type]) {
|
|
802
|
-
throw new RestApiValidationError(
|
|
803
|
-
`POST request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
804
|
-
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
805
|
-
)
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// For POST, id is optional (server may generate it)
|
|
809
|
-
if ('id' in data && data.id !== null && typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
810
|
-
throw new RestApiPayloadError(
|
|
811
|
-
'POST request "data.id" if present must be a string, number, or null',
|
|
812
|
-
{ path: 'data.id', expected: 'string, number, or null', received: typeof data.id }
|
|
813
|
-
)
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Validate attributes if present
|
|
817
|
-
if ('attributes' in data) {
|
|
818
|
-
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
819
|
-
throw new RestApiPayloadError(
|
|
820
|
-
'POST request "data.attributes" must be an object',
|
|
821
|
-
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
822
|
-
)
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Validate relationships if present
|
|
827
|
-
if ('relationships' in data) {
|
|
828
|
-
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
829
|
-
throw new RestApiPayloadError(
|
|
830
|
-
'POST request "data.relationships" must be an object',
|
|
831
|
-
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
832
|
-
)
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
836
|
-
validateRelationship(relationship, relName, scopes)
|
|
837
|
-
})
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// Validate included resources if present
|
|
841
|
-
if (included !== undefined) {
|
|
842
|
-
if (!Array.isArray(included)) {
|
|
843
|
-
throw new RestApiPayloadError(
|
|
844
|
-
'POST request "included" must be an array',
|
|
845
|
-
{ path: 'included', expected: 'array', received: typeof included }
|
|
846
|
-
)
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
included.forEach((resource, index) => {
|
|
850
|
-
if (!resource || typeof resource !== 'object') {
|
|
851
|
-
throw new RestApiPayloadError(
|
|
852
|
-
`POST request "included[${index}]" must be a resource object`,
|
|
853
|
-
{ path: `included[${index}]`, expected: 'object', received: typeof resource }
|
|
854
|
-
)
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
if (typeof resource.type !== 'string' || !resource.type) {
|
|
858
|
-
throw new RestApiPayloadError(
|
|
859
|
-
`POST request "included[${index}]" must have a non-empty "type" string`,
|
|
860
|
-
{ path: `included[${index}].type`, expected: 'non-empty string', received: resource.type || 'empty' }
|
|
861
|
-
)
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Check if included resource type is valid
|
|
865
|
-
if (scopes && !scopes[resource.type]) {
|
|
866
|
-
throw new RestApiValidationError(
|
|
867
|
-
`POST request "included[${index}].type" '${resource.type}' is not a valid resource type. No scope with this name exists.`,
|
|
868
|
-
{ fields: [`included[${index}].type`], violations: [{ field: `included[${index}].type`, rule: 'valid_resource_type', message: `Resource type '${resource.type}' does not exist` }] }
|
|
869
|
-
)
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (!('id' in resource) || resource.id === null || resource.id === undefined) {
|
|
873
|
-
throw new RestApiPayloadError(
|
|
874
|
-
`POST request "included[${index}]" must have a non-null "id"`,
|
|
875
|
-
{ path: `included[${index}].id`, expected: 'non-null value', received: 'null or undefined' }
|
|
876
|
-
)
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
if (typeof resource.id !== 'string' && typeof resource.id !== 'number') {
|
|
880
|
-
throw new RestApiPayloadError(
|
|
881
|
-
`POST request "included[${index}].id" must be a string or number`,
|
|
882
|
-
{ path: `included[${index}].id`, expected: 'string or number', received: typeof resource.id }
|
|
883
|
-
)
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
if ('attributes' in resource) {
|
|
887
|
-
if (typeof resource.attributes !== 'object' || resource.attributes === null || Array.isArray(resource.attributes)) {
|
|
888
|
-
throw new RestApiPayloadError(
|
|
889
|
-
`POST request "included[${index}].attributes" must be an object`,
|
|
890
|
-
{ path: `included[${index}].attributes`, expected: 'object', received: Array.isArray(resource.attributes) ? 'array' : typeof resource.attributes }
|
|
891
|
-
)
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
})
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
return true
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* Validates a JSON:API document for PUT requests (full resource replacement)
|
|
902
|
-
*
|
|
903
|
-
* @param {Object} inputRecord - JSON:API document to validate
|
|
904
|
-
* @param {Object} inputRecord.data - Complete resource representation
|
|
905
|
-
* @param {string} inputRecord.data.type - Resource type (must match existing)
|
|
906
|
-
* @param {string|number} inputRecord.data.id - Resource ID (required)
|
|
907
|
-
* @param {Object} [inputRecord.data.attributes] - Complete attributes
|
|
908
|
-
* @param {Object} [inputRecord.data.relationships] - Complete relationships
|
|
909
|
-
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
910
|
-
* @returns {boolean} True if valid
|
|
911
|
-
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
912
|
-
*
|
|
913
|
-
* @example
|
|
914
|
-
* // Input: Complete resource replacement
|
|
915
|
-
* validatePutPayload({
|
|
916
|
-
* data: {
|
|
917
|
-
* type: 'articles',
|
|
918
|
-
* id: '123', // Required for PUT
|
|
919
|
-
* attributes: {
|
|
920
|
-
* title: 'Updated Title',
|
|
921
|
-
* body: 'Completely new body text',
|
|
922
|
-
* status: 'published'
|
|
923
|
-
* }
|
|
924
|
-
* }
|
|
925
|
-
* }, scopes);
|
|
926
|
-
* // Output: true
|
|
927
|
-
*
|
|
928
|
-
* @example
|
|
929
|
-
* // Input: Replace with new relationships
|
|
930
|
-
* validatePutPayload({
|
|
931
|
-
* data: {
|
|
932
|
-
* type: 'articles',
|
|
933
|
-
* id: '123',
|
|
934
|
-
* attributes: {
|
|
935
|
-
* title: 'REST API Best Practices',
|
|
936
|
-
* body: 'Here are some tips...'
|
|
937
|
-
* },
|
|
938
|
-
* relationships: {
|
|
939
|
-
* author: {
|
|
940
|
-
* data: { type: 'users', id: '99' } // New author
|
|
941
|
-
* },
|
|
942
|
-
* tags: {
|
|
943
|
-
* data: [ // Replace all tags
|
|
944
|
-
* { type: 'tags', id: '5' },
|
|
945
|
-
* { type: 'tags', id: '6' },
|
|
946
|
-
* { type: 'tags', id: '7' }
|
|
947
|
-
* ]
|
|
948
|
-
* },
|
|
949
|
-
* featuredImage: {
|
|
950
|
-
* data: null // Remove relationship
|
|
951
|
-
* }
|
|
952
|
-
* }
|
|
953
|
-
* }
|
|
954
|
-
* }, scopes);
|
|
955
|
-
* // Output: true (complete replacement)
|
|
956
|
-
*
|
|
957
|
-
* @example
|
|
958
|
-
* // Input: Missing required ID
|
|
959
|
-
* validatePutPayload({
|
|
960
|
-
* data: {
|
|
961
|
-
* type: 'articles',
|
|
962
|
-
* attributes: { title: 'No ID' }
|
|
963
|
-
* }
|
|
964
|
-
* }, scopes);
|
|
965
|
-
* // Throws: RestApiPayloadError
|
|
966
|
-
* // 'PUT request "data" must have an "id" property'
|
|
967
|
-
*
|
|
968
|
-
* @example
|
|
969
|
-
* // Input: Includes not allowed
|
|
970
|
-
* validatePutPayload({
|
|
971
|
-
* data: {
|
|
972
|
-
* type: 'articles',
|
|
973
|
-
* id: '123',
|
|
974
|
-
* attributes: { title: 'Updated' }
|
|
975
|
-
* },
|
|
976
|
-
* included: [ // Not allowed!
|
|
977
|
-
* { type: 'users', id: '1' }
|
|
978
|
-
* ]
|
|
979
|
-
* }, scopes);
|
|
980
|
-
* // Throws: RestApiPayloadError
|
|
981
|
-
* // 'PUT requests cannot include an "included" array for creating new resources'
|
|
982
|
-
*
|
|
983
|
-
* @description
|
|
984
|
-
* Used by:
|
|
985
|
-
* - rest-api-plugin.put() method for full replacement
|
|
986
|
-
* - Enforces PUT semantics vs PATCH
|
|
987
|
-
*
|
|
988
|
-
* Purpose:
|
|
989
|
-
* - Ensures complete resource replacement semantics
|
|
990
|
-
* - Requires ID to match URL parameter
|
|
991
|
-
* - Prevents included resources (no side effects)
|
|
992
|
-
* - Maintains idempotency of PUT operations
|
|
993
|
-
* - Differentiates from PATCH partial updates
|
|
994
|
-
*
|
|
995
|
-
* Data flow:
|
|
996
|
-
* 1. Validates document structure with data property
|
|
997
|
-
* 2. Ensures no included array present
|
|
998
|
-
* 3. Validates resource type exists in scopes
|
|
999
|
-
* 4. Requires ID property (unlike POST)
|
|
1000
|
-
* 5. Validates attributes and relationships if present
|
|
1001
|
-
* 6. Returns true or throws descriptive error
|
|
1002
|
-
*/
|
|
1003
|
-
export function validatePutPayload (inputRecord, scopes = null) {
|
|
1004
|
-
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
1005
|
-
throw new RestApiPayloadError(
|
|
1006
|
-
'PUT request body must be a JSON:API document object',
|
|
1007
|
-
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
1008
|
-
)
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Validate required 'data' property
|
|
1012
|
-
if (!('data' in inputRecord)) {
|
|
1013
|
-
throw new RestApiPayloadError(
|
|
1014
|
-
'PUT request body must have a "data" property',
|
|
1015
|
-
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
1016
|
-
)
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const { data, included } = inputRecord
|
|
1020
|
-
|
|
1021
|
-
// PUT cannot have included array
|
|
1022
|
-
if (included !== undefined) {
|
|
1023
|
-
throw new RestApiPayloadError(
|
|
1024
|
-
'PUT requests cannot include an "included" array for creating new resources',
|
|
1025
|
-
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
1026
|
-
)
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Validate primary data
|
|
1030
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1031
|
-
throw new RestApiPayloadError(
|
|
1032
|
-
'PUT request "data" must be a single resource object',
|
|
1033
|
-
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
1034
|
-
)
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
if (typeof data.type !== 'string' || !data.type) {
|
|
1038
|
-
throw new RestApiPayloadError(
|
|
1039
|
-
'PUT request "data" must have a non-empty "type" string',
|
|
1040
|
-
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
1041
|
-
)
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// Check if resource type is valid
|
|
1045
|
-
if (scopes && !scopes[data.type]) {
|
|
1046
|
-
throw new RestApiValidationError(
|
|
1047
|
-
`PUT request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
1048
|
-
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
1049
|
-
)
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
// For PUT, id is required
|
|
1053
|
-
if (!('id' in data)) {
|
|
1054
|
-
throw new RestApiPayloadError(
|
|
1055
|
-
'PUT request "data" must have an "id" property',
|
|
1056
|
-
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
1057
|
-
)
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
if (data.id === null || data.id === undefined || data.id === '') {
|
|
1061
|
-
throw new RestApiValidationError(
|
|
1062
|
-
'PUT request "data.id" cannot be null, undefined, or empty',
|
|
1063
|
-
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
1064
|
-
)
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
1068
|
-
throw new RestApiPayloadError(
|
|
1069
|
-
'PUT request "data.id" must be a string or number',
|
|
1070
|
-
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
1071
|
-
)
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// Validate attributes if present
|
|
1075
|
-
if ('attributes' in data) {
|
|
1076
|
-
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
1077
|
-
throw new RestApiPayloadError(
|
|
1078
|
-
'PUT request "data.attributes" must be an object',
|
|
1079
|
-
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
1080
|
-
)
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Validate relationships if present
|
|
1085
|
-
if ('relationships' in data) {
|
|
1086
|
-
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
1087
|
-
throw new RestApiPayloadError(
|
|
1088
|
-
'PUT request "data.relationships" must be an object',
|
|
1089
|
-
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
1090
|
-
)
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
1094
|
-
validateRelationship(relationship, relName, scopes)
|
|
1095
|
-
})
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
return true
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/**
|
|
1102
|
-
* Validates a JSON:API document for PATCH requests (partial resource updates)
|
|
1103
|
-
*
|
|
1104
|
-
* @param {Object} inputRecord - JSON:API document to validate
|
|
1105
|
-
* @param {Object} inputRecord.data - Partial resource representation
|
|
1106
|
-
* @param {string} inputRecord.data.type - Resource type (must match existing)
|
|
1107
|
-
* @param {string|number} inputRecord.data.id - Resource ID (required)
|
|
1108
|
-
* @param {Object} [inputRecord.data.attributes] - Attributes to update
|
|
1109
|
-
* @param {Object} [inputRecord.data.relationships] - Relationships to update
|
|
1110
|
-
* @param {Object} scopes - Scopes proxy to verify resource types exist
|
|
1111
|
-
* @returns {boolean} True if valid
|
|
1112
|
-
* @throws {RestApiValidationError|RestApiPayloadError} If validation fails
|
|
1113
|
-
*
|
|
1114
|
-
* @example
|
|
1115
|
-
* // Input: Update single field
|
|
1116
|
-
* validatePatchPayload({
|
|
1117
|
-
* data: {
|
|
1118
|
-
* type: 'articles',
|
|
1119
|
-
* id: '123',
|
|
1120
|
-
* attributes: {
|
|
1121
|
-
* title: 'New Title Only' // Only title changes
|
|
1122
|
-
* // body, status, etc. remain unchanged
|
|
1123
|
-
* }
|
|
1124
|
-
* }
|
|
1125
|
-
* }, scopes);
|
|
1126
|
-
* // Output: true
|
|
1127
|
-
*
|
|
1128
|
-
* @example
|
|
1129
|
-
* // Input: Update multiple fields
|
|
1130
|
-
* validatePatchPayload({
|
|
1131
|
-
* data: {
|
|
1132
|
-
* type: 'articles',
|
|
1133
|
-
* id: '123',
|
|
1134
|
-
* attributes: {
|
|
1135
|
-
* status: 'published',
|
|
1136
|
-
* publishedAt: '2023-11-15T10:00:00Z'
|
|
1137
|
-
* // Other fields untouched
|
|
1138
|
-
* }
|
|
1139
|
-
* }
|
|
1140
|
-
* }, scopes);
|
|
1141
|
-
* // Output: true (partial update)
|
|
1142
|
-
*
|
|
1143
|
-
* @example
|
|
1144
|
-
* // Input: Update relationships only
|
|
1145
|
-
* validatePatchPayload({
|
|
1146
|
-
* data: {
|
|
1147
|
-
* type: 'articles',
|
|
1148
|
-
* id: '123',
|
|
1149
|
-
* relationships: {
|
|
1150
|
-
* author: {
|
|
1151
|
-
* data: { type: 'users', id: '456' } // Change author
|
|
1152
|
-
* }
|
|
1153
|
-
* // Other relationships unchanged
|
|
1154
|
-
* }
|
|
1155
|
-
* }
|
|
1156
|
-
* }, scopes);
|
|
1157
|
-
* // Output: true
|
|
1158
|
-
*
|
|
1159
|
-
* @example
|
|
1160
|
-
* // Input: Clear relationships
|
|
1161
|
-
* validatePatchPayload({
|
|
1162
|
-
* data: {
|
|
1163
|
-
* type: 'articles',
|
|
1164
|
-
* id: '123',
|
|
1165
|
-
* relationships: {
|
|
1166
|
-
* featuredImage: {
|
|
1167
|
-
* data: null // Remove to-one
|
|
1168
|
-
* },
|
|
1169
|
-
* tags: {
|
|
1170
|
-
* data: [] // Clear to-many
|
|
1171
|
-
* }
|
|
1172
|
-
* }
|
|
1173
|
-
* }
|
|
1174
|
-
* }, scopes);
|
|
1175
|
-
* // Output: true (clearing is valid)
|
|
1176
|
-
*
|
|
1177
|
-
* @example
|
|
1178
|
-
* // Input: No changes specified
|
|
1179
|
-
* validatePatchPayload({
|
|
1180
|
-
* data: {
|
|
1181
|
-
* type: 'articles',
|
|
1182
|
-
* id: '123'
|
|
1183
|
-
* // Missing both attributes and relationships!
|
|
1184
|
-
* }
|
|
1185
|
-
* }, scopes);
|
|
1186
|
-
* // Throws: RestApiValidationError
|
|
1187
|
-
* // 'PATCH request "data" must have at least one of "attributes" or "relationships"'
|
|
1188
|
-
*
|
|
1189
|
-
* @description
|
|
1190
|
-
* Used by:
|
|
1191
|
-
* - rest-api-plugin.patch() method for partial updates
|
|
1192
|
-
* - Enforces PATCH semantics vs PUT
|
|
1193
|
-
*
|
|
1194
|
-
* Purpose:
|
|
1195
|
-
* - Enables efficient partial updates (send only changes)
|
|
1196
|
-
* - Requires at least one change (attributes or relationships)
|
|
1197
|
-
* - Prevents included resources (no side effects)
|
|
1198
|
-
* - Reduces bandwidth for large resources
|
|
1199
|
-
* - Prevents race conditions in concurrent updates
|
|
1200
|
-
*
|
|
1201
|
-
* Data flow:
|
|
1202
|
-
* 1. Validates document structure with data property
|
|
1203
|
-
* 2. Ensures no included array present
|
|
1204
|
-
* 3. Validates resource type exists in scopes
|
|
1205
|
-
* 4. Requires ID property for targeting
|
|
1206
|
-
* 5. Requires at least attributes or relationships
|
|
1207
|
-
* 6. Validates each if present
|
|
1208
|
-
* 7. Returns true or throws descriptive error
|
|
1209
|
-
*/
|
|
1210
|
-
export function validatePatchPayload (inputRecord, scopes = null) {
|
|
1211
|
-
if (!inputRecord || typeof inputRecord !== 'object') {
|
|
1212
|
-
throw new RestApiPayloadError(
|
|
1213
|
-
'PATCH request body must be a JSON:API document object',
|
|
1214
|
-
{ path: 'body', expected: 'object', received: typeof inputRecord }
|
|
1215
|
-
)
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Validate required 'data' property
|
|
1219
|
-
if (!('data' in inputRecord)) {
|
|
1220
|
-
throw new RestApiPayloadError(
|
|
1221
|
-
'PATCH request body must have a "data" property',
|
|
1222
|
-
{ path: 'data', expected: 'property exists', received: 'missing' }
|
|
1223
|
-
)
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const { data, included } = inputRecord
|
|
1227
|
-
|
|
1228
|
-
// PATCH cannot have included array
|
|
1229
|
-
if (included !== undefined) {
|
|
1230
|
-
throw new RestApiPayloadError(
|
|
1231
|
-
'PATCH requests cannot include an "included" array for creating new resources',
|
|
1232
|
-
{ path: 'included', expected: 'undefined', received: 'array' }
|
|
1233
|
-
)
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// Validate primary data
|
|
1237
|
-
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
1238
|
-
throw new RestApiPayloadError(
|
|
1239
|
-
'PATCH request "data" must be a single resource object',
|
|
1240
|
-
{ path: 'data', expected: 'object', received: Array.isArray(data) ? 'array' : typeof data }
|
|
1241
|
-
)
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
if (typeof data.type !== 'string' || !data.type) {
|
|
1245
|
-
throw new RestApiPayloadError(
|
|
1246
|
-
'PATCH request "data" must have a non-empty "type" string',
|
|
1247
|
-
{ path: 'data.type', expected: 'non-empty string', received: data.type || 'empty' }
|
|
1248
|
-
)
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Check if resource type is valid
|
|
1252
|
-
if (scopes && !scopes[data.type]) {
|
|
1253
|
-
throw new RestApiValidationError(
|
|
1254
|
-
`PATCH request "data.type" '${data.type}' is not a valid resource type. No scope with this name exists.`,
|
|
1255
|
-
{ fields: ['data.type'], violations: [{ field: 'data.type', rule: 'valid_resource_type', message: `Resource type '${data.type}' does not exist` }] }
|
|
1256
|
-
)
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// For PATCH, id is required
|
|
1260
|
-
if (!('id' in data)) {
|
|
1261
|
-
throw new RestApiPayloadError(
|
|
1262
|
-
'PATCH request "data" must have an "id" property',
|
|
1263
|
-
{ path: 'data.id', expected: 'property exists', received: 'missing' }
|
|
1264
|
-
)
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
if (data.id === null || data.id === undefined || data.id === '') {
|
|
1268
|
-
throw new RestApiValidationError(
|
|
1269
|
-
'PATCH request "data.id" cannot be null, undefined, or empty',
|
|
1270
|
-
{ fields: ['data.id'], violations: [{ field: 'data.id', rule: 'not_empty', message: 'ID cannot be null, undefined, or empty' }] }
|
|
1271
|
-
)
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
if (typeof data.id !== 'string' && typeof data.id !== 'number') {
|
|
1275
|
-
throw new RestApiPayloadError(
|
|
1276
|
-
'PATCH request "data.id" must be a string or number',
|
|
1277
|
-
{ path: 'data.id', expected: 'string or number', received: typeof data.id }
|
|
1278
|
-
)
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
// For PATCH, at least one of attributes or relationships should be present
|
|
1282
|
-
if (!('attributes' in data) && !('relationships' in data)) {
|
|
1283
|
-
throw new RestApiValidationError(
|
|
1284
|
-
'PATCH request "data" must have at least one of "attributes" or "relationships"',
|
|
1285
|
-
{ fields: ['data'], violations: [{ field: 'data', rule: 'partial_update', message: 'Must include at least one of attributes or relationships' }] }
|
|
1286
|
-
)
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Validate attributes if present
|
|
1290
|
-
if ('attributes' in data && data.attributes !== undefined) {
|
|
1291
|
-
if (typeof data.attributes !== 'object' || data.attributes === null || Array.isArray(data.attributes)) {
|
|
1292
|
-
throw new RestApiPayloadError(
|
|
1293
|
-
'PATCH request "data.attributes" must be an object',
|
|
1294
|
-
{ path: 'data.attributes', expected: 'object', received: Array.isArray(data.attributes) ? 'array' : typeof data.attributes }
|
|
1295
|
-
)
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// Validate relationships if present
|
|
1300
|
-
if ('relationships' in data && data.relationships !== undefined) {
|
|
1301
|
-
if (typeof data.relationships !== 'object' || data.relationships === null || Array.isArray(data.relationships)) {
|
|
1302
|
-
throw new RestApiPayloadError(
|
|
1303
|
-
'PATCH request "data.relationships" must be an object',
|
|
1304
|
-
{ path: 'data.relationships', expected: 'object', received: Array.isArray(data.relationships) ? 'array' : typeof data.relationships }
|
|
1305
|
-
)
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
Object.entries(data.relationships).forEach(([relName, relationship]) => {
|
|
1309
|
-
validateRelationship(relationship, relName, scopes)
|
|
1310
|
-
})
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
return true
|
|
1314
|
-
}
|