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