json-rest-api 1.0.22 → 1.0.24

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.
Files changed (290) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/README.md +22 -1
  4. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  5. package/docs/API.md +196 -38
  6. package/docs/COMPARISON.md +2 -2
  7. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +111 -5
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  9. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  10. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  11. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  12. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  13. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  14. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  15. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  16. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +370 -0
  17. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  18. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  19. package/docs/GUIDE/index.md +18 -9
  20. package/docs/QUICKSTART.md +5 -1
  21. package/docs/index.md +1 -1
  22. package/index.js +3 -2
  23. package/lib/enhanced-logger.js +1 -1
  24. package/package.json +6 -2
  25. package/plugins/core/bulk-operations-plugin.js +26 -34
  26. package/plugins/core/connectors/express-plugin.js +97 -190
  27. package/plugins/core/connectors/fastify-plugin.js +206 -0
  28. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  29. package/plugins/core/connectors/lib/connector-core.js +195 -0
  30. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  31. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  32. package/plugins/core/lib/anyapi/schema-utils.js +43 -4
  33. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
  34. package/plugins/core/lib/dbIntrospection.js +1078 -0
  35. package/plugins/core/lib/dbTablesOperations.js +1111 -160
  36. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  37. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -3
  38. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  39. package/plugins/core/lib/querying/knex-query-helpers.js +21 -76
  40. package/plugins/core/lib/querying/knex-relationship-includes.js +468 -215
  41. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  42. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  43. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  44. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  45. package/plugins/core/lib/querying/url-helpers.js +8 -2
  46. package/plugins/core/lib/querying-writing/compile-schemas.js +20 -5
  47. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -14
  48. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  49. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  50. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  51. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  52. package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
  53. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  54. package/plugins/core/lib/storage/canonical-storage-mapping.js +133 -0
  55. package/plugins/core/lib/storage/storage-adapter.js +82 -4
  56. package/plugins/core/lib/storage/storage-mapping.js +274 -0
  57. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
  58. package/plugins/core/rest-api-anyapi-knex-plugin.js +531 -312
  59. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  60. package/plugins/core/rest-api-knex-plugin.js +271 -226
  61. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  62. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  63. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
  64. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  65. package/plugins/core/rest-api-plugin-methods/common.js +148 -92
  66. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
  67. package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
  68. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  69. package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
  70. package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
  71. package/plugins/core/rest-api-plugin-methods/get.js +29 -14
  72. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
  73. package/plugins/core/rest-api-plugin-methods/patch.js +56 -12
  74. package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
  75. package/plugins/core/rest-api-plugin-methods/post.js +21 -8
  76. package/plugins/core/rest-api-plugin-methods/put.js +73 -21
  77. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  78. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  79. package/plugins/core/rest-api-plugin.js +6 -0
  80. package/plugins/core/rest-api-positioning-plugin.js +2 -5
  81. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  82. package/plugins/core/socketio-plugin.js +1 -4
  83. package/plugins/storage/local-storage.js +1 -1
  84. package/plugins/storage/s3-storage.js +5 -2
  85. package/quickTest.js +1 -0
  86. package/scripts/run-docs.js +80 -84
  87. package/test-debug.js +1 -1
  88. package/tests/README.md +7 -0
  89. package/tests/anyapi-basic.test.js +3 -0
  90. package/tests/autofilter.test.js +463 -0
  91. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  92. package/tests/custom-id-simplified-post.test.js +75 -0
  93. package/tests/custom-idproperty-relationships.test.js +33 -0
  94. package/tests/db-introspection.test.js +521 -0
  95. package/tests/db-table-operations.test.js +786 -0
  96. package/tests/fastify-plugin.test.js +156 -0
  97. package/tests/field-getters.test.js +1 -2
  98. package/tests/field-setters.test.js +0 -4
  99. package/tests/fixtures/api-configs.js +278 -176
  100. package/tests/helpers/fake-fastify.js +99 -0
  101. package/tests/http-connectors-parity.test.js +235 -0
  102. package/tests/id-normalization.test.js +444 -0
  103. package/tests/logical-id-write-validation.test.js +151 -0
  104. package/tests/pagination-cursor-multifield.test.js +11 -11
  105. package/tests/projected-fields.test.js +319 -0
  106. package/tests/relationships.test.js +3 -3
  107. package/tests/searchschema-knex-behavior.test.js +187 -0
  108. package/tests/searchschema-merge.test.js +21 -69
  109. package/tests/storage-mapping.test.js +259 -0
  110. package/audit/00-charter.md +0 -103
  111. package/audit/01-scope-map.md +0 -84
  112. package/audit/02-coverage-matrix.csv +0 -174
  113. package/audit/03-invariants.md +0 -123
  114. package/audit/04-standards-rubric.md +0 -56
  115. package/audit/05-competitor-rubric.md +0 -77
  116. package/audit/06-findings.md +0 -160
  117. package/audit/07-risk-register.csv +0 -10
  118. package/audit/08-remediation-roadmap.md +0 -177
  119. package/audit/09-signoff-check.md +0 -23
  120. package/audit/10-final-synthesis.md +0 -29
  121. package/audit/evidence/code/all-files-json-rest-api.txt +0 -168
  122. package/audit/evidence/code/all-files-json-rest-schema.txt +0 -12
  123. package/audit/evidence/code/boundary-leak-checks.txt +0 -72
  124. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__bulk-operations-plugin.js.txt +0 -480
  125. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__express-plugin.js.txt +0 -532
  126. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__busboy-detector.js.txt +0 -175
  127. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__formidable-detector.js.txt +0 -138
  128. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__connectors__lib__request-helpers.js.txt +0 -222
  129. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__file-handling-plugin.js.txt +0 -342
  130. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__dbTablesOperations.js.txt +0 -304
  131. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__fractional-positioning.js.txt +0 -188
  132. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__compile-schemas.js.txt +0 -336
  133. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__connectors-query-parser.js.txt +0 -165
  134. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-capabilities.js.txt +0 -265
  135. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__database-value-normalizers.js.txt +0 -369
  136. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__default-data-helpers.js.txt +0 -72
  137. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__field-utils.js.txt +0 -139
  138. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-constants.js.txt +0 -45
  139. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-field-helpers.js.txt +0 -396
  140. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__knex-json-api-transformers.js.txt +0 -162
  141. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__payload-validators.js.txt +0 -1314
  142. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__schema-helpers.js.txt +0 -488
  143. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__scope-validations.js.txt +0 -484
  144. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying-writing__simplified-helpers.js.txt +0 -857
  145. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-cross-table-search.js.txt +0 -565
  146. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-json-api-transformers-querying.js.txt +0 -485
  147. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-pagination-helpers.js.txt +0 -560
  148. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-process-includes.js.txt +0 -167
  149. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers-base.js.txt +0 -109
  150. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-query-helpers.js.txt +0 -1064
  151. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-relationship-includes.js.txt +0 -1983
  152. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__knex-window-queries.js.txt +0 -328
  153. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__querying__url-helpers.js.txt +0 -119
  154. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__storage__storage-adapter.js.txt +0 -160
  155. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__many-to-many-manipulations.js.txt +0 -300
  156. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__lib__writing__relationship-processor.js.txt +0 -245
  157. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-access.js.txt +0 -459
  158. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-cors-plugin.js.txt +0 -237
  159. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-knex-plugin.js.txt +0 -1135
  160. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-multihome-plugin.js.txt +0 -314
  161. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__compile-resource-schemas.js.txt +0 -7
  162. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-relationship-routes.js.txt +0 -71
  163. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__register-scope-routes.js.txt +0 -102
  164. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__turn-scope-init-into-vars.js.txt +0 -49
  165. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-hooks__validate-include-configurations.js.txt +0 -39
  166. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__add-route.js.txt +0 -23
  167. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__check-permissions.js.txt +0 -13
  168. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__common.js.txt +0 -639
  169. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete-relationship.js.txt +0 -117
  170. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__delete.js.txt +0 -104
  171. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__enrich-attributes.js.txt +0 -199
  172. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-related.js.txt +0 -353
  173. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get-relationship.js.txt +0 -61
  174. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__get.js.txt +0 -184
  175. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch-relationship.js.txt +0 -55
  176. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__patch.js.txt +0 -191
  177. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post-relationship.js.txt +0 -110
  178. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__post.js.txt +0 -168
  179. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__put.js.txt +0 -253
  180. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__query.js.txt +0 -207
  181. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin-methods__release.js.txt +0 -12
  182. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-plugin.js.txt +0 -165
  183. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__rest-api-positioning-plugin.js.txt +0 -668
  184. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__core__socketio-plugin.js.txt +0 -621
  185. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__local-storage.js.txt +0 -218
  186. package/audit/evidence/code/critical-pass1/json-rest-api__plugins__storage__s3-storage.js.txt +0 -124
  187. package/audit/evidence/code/critical-pass1/json-rest-schema__src__CorePlugin.js.txt +0 -269
  188. package/audit/evidence/code/critical-pass1/json-rest-schema__src__Schema.js.txt +0 -242
  189. package/audit/evidence/code/critical-pass1/json-rest-schema__src__index.js.txt +0 -77
  190. package/audit/evidence/code/critical-pass1-risk-keywords.txt +0 -238
  191. package/audit/evidence/code/critical-pass1-summary.csv +0 -67
  192. package/audit/evidence/code/critical-pass2-summary.csv +0 -67
  193. package/audit/evidence/code/early-silent-zones-no-test-token.txt +0 -57
  194. package/audit/evidence/code/error-model-signals.txt +0 -98
  195. package/audit/evidence/code/git-files-json-rest-api.txt +0 -171
  196. package/audit/evidence/code/git-files-json-rest-schema.txt +0 -27
  197. package/audit/evidence/code/hook-flow-signals.txt +0 -282
  198. package/audit/evidence/code/idproperty-signals.txt +0 -157
  199. package/audit/evidence/code/import-cycles-non-anyapi.txt +0 -3
  200. package/audit/evidence/code/import-hotspots-non-anyapi.txt +0 -96
  201. package/audit/evidence/code/imports-json-rest-api.txt +0 -354
  202. package/audit/evidence/code/imports-json-rest-schema.txt +0 -15
  203. package/audit/evidence/code/in-scope-files-json-rest-api.txt +0 -160
  204. package/audit/evidence/code/in-scope-files-json-rest-schema.txt +0 -13
  205. package/audit/evidence/code/non-anyapi-core-files.txt +0 -101
  206. package/audit/evidence/code/permission-signals.txt +0 -193
  207. package/audit/evidence/code/query-include-signals.txt +0 -687
  208. package/audit/evidence/code/transaction-signals.txt +0 -28
  209. package/audit/evidence/code/validation-signals.txt +0 -650
  210. package/audit/evidence/code/write-path-signals.txt +0 -61
  211. package/audit/evidence/docs/docs-claims-signals.txt +0 -1075
  212. package/audit/evidence/docs/schema-docs-claims-signals.txt +0 -179
  213. package/audit/evidence/external/corpus-freeze.md +0 -21
  214. package/audit/evidence/external/npm-competitor-metadata.txt +0 -70
  215. package/audit/evidence/external/npm-download-signals.txt +0 -23
  216. package/audit/evidence/external/npm-jsonapi-server.txt +0 -8
  217. package/audit/evidence/external/npm-modern-jsonapi-metadata.txt +0 -38
  218. package/audit/evidence/external/readme-fortune-signals.txt +0 -26
  219. package/audit/evidence/external/readme-fortune.md +0 -165
  220. package/audit/evidence/external/readme-json-api-serializer-signals.txt +0 -80
  221. package/audit/evidence/external/readme-json-api-serializer.md +0 -690
  222. package/audit/evidence/external/readme-jsonapi-serde-server-signals.txt +0 -0
  223. package/audit/evidence/external/readme-jsonapi-serde-server.md +0 -22
  224. package/audit/evidence/external/readme-jsonapi-serializer-signals.txt +0 -63
  225. package/audit/evidence/external/readme-jsonapi-serializer.md +0 -315
  226. package/audit/evidence/external/readme-jsonapi-server-signals.txt +0 -19
  227. package/audit/evidence/external/readme-jsonapi-server.md +0 -104
  228. package/audit/evidence/external/readme-nestjs-json-api-signals.txt +0 -4
  229. package/audit/evidence/external/readme-nestjs-json-api.md +0 -94
  230. package/audit/evidence/external/readme-yaser-json-api-nestjs-signals.txt +0 -47
  231. package/audit/evidence/external/readme-yaser-json-api-nestjs.md +0 -447
  232. package/audit/evidence/external/standard-jsonapi-snippets.txt +0 -84
  233. package/audit/evidence/external/standard-rfc-snippets.txt +0 -168
  234. package/audit/evidence/external/standards-sources.txt +0 -6
  235. package/audit/evidence/runtime/baseline-commits.txt +0 -2
  236. package/audit/evidence/runtime/db-adapter-assumptions.txt +0 -3
  237. package/audit/evidence/runtime/environment.txt +0 -4
  238. package/audit/evidence/runtime/invariant-runtime-mapping.md +0 -38
  239. package/audit/evidence/runtime/probe-patch-relationship-error.exitcode +0 -1
  240. package/audit/evidence/runtime/probe-patch-relationship-error.log +0 -76
  241. package/audit/evidence/runtime/probe-patch-relationship-error.mjs +0 -67
  242. package/audit/evidence/runtime/probe-patch-relationship-referenceerror.exitcode +0 -1
  243. package/audit/evidence/runtime/probe-patch-relationship-referenceerror.log +0 -62
  244. package/audit/evidence/runtime/probe-patch-relationship-referenceerror.mjs +0 -47
  245. package/audit/evidence/runtime/test-exitcodes.txt +0 -4
  246. package/audit/evidence/runtime/test-summary.md +0 -107
  247. package/audit/evidence/runtime/trace-anchors.txt +0 -61
  248. package/audit/evidence/tests/access-multitenancy-test-signals.txt +0 -125
  249. package/audit/evidence/tests/failing-tests-summary.txt +0 -25
  250. package/audit/evidence/tests/npm-test-json-rest-api.exitcode +0 -1
  251. package/audit/evidence/tests/npm-test-json-rest-api.log +0 -5885
  252. package/audit/evidence/tests/npm-test-json-rest-schema.exitcode +0 -1
  253. package/audit/evidence/tests/npm-test-json-rest-schema.log +0 -356
  254. package/audit/evidence/tests/query-include-test-signals.txt +0 -948
  255. package/audit/evidence/tests/relationship-test-signals.txt +0 -223
  256. package/audit/evidence/tests/skipped-tests.txt +0 -10
  257. package/audit/evidence/tests/test-suite-list-non-anyapi.txt +0 -30
  258. package/audit/evidence/tests/test-suite-list.txt +0 -32
  259. package/audit/subsystems/docs-drift.md +0 -49
  260. package/audit/subsystems/pass2-cross-review.md +0 -131
  261. package/audit/subsystems/plugins-access.md +0 -51
  262. package/audit/subsystems/plugins-bulk.md +0 -50
  263. package/audit/subsystems/plugins-cors.md +0 -48
  264. package/audit/subsystems/plugins-file.md +0 -53
  265. package/audit/subsystems/plugins-multihome.md +0 -50
  266. package/audit/subsystems/plugins-positioning.md +0 -54
  267. package/audit/subsystems/plugins-socketio.md +0 -50
  268. package/audit/subsystems/query-engine.md +0 -61
  269. package/audit/subsystems/relationships.md +0 -55
  270. package/audit/subsystems/rest-orchestration.md +0 -66
  271. package/audit/subsystems/schema-core.md +0 -64
  272. package/audit/subsystems/storage-knex.md +0 -57
  273. package/audit/subsystems/tests.md +0 -48
  274. package/audit/subsystems/transport-express.md +0 -55
  275. package/audit/traces/trace-read-get.md +0 -22
  276. package/audit/traces/trace-read-query.md +0 -25
  277. package/audit/traces/trace-relationship-endpoints.md +0 -31
  278. package/audit/traces/trace-write-delete.md +0 -17
  279. package/audit/traces/trace-write-patch.md +0 -26
  280. package/audit/traces/trace-write-post.md +0 -22
  281. package/audit/traces/trace-write-put.md +0 -23
  282. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  283. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  284. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  285. package/plugins/core/rest-api-access.js +0 -459
  286. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  287. package/tests/multihome.test.js +0 -553
  288. package/tests/multihome.test.js.bak +0 -550
  289. package/tests/ownership-optout.test.js +0 -76
  290. 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 }