json-rest-api 1.0.29 → 2.1.0

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 (294) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +744 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +733 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +103 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +37 -0
  44. package/index.js +3 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +70 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,11 +1,26 @@
1
1
  import { getUrlPrefix } from '../../lib/querying/url-helpers.js'
2
+ import { lockRelationshipParent } from '../../lib/writing/relationship-processor.js'
3
+ import { parseJsonApiQuery } from '../../lib/querying-writing/connectors-query-parser.js'
4
+ import { serializableTransaction } from '../../../../lib/knex-transaction.js'
5
+ import { cloneRequestHeaders } from './request-helpers.js'
6
+ import { RestApiPreconditionFailedError, RestApiValidationError, RestApiWriteError } from '../../../../lib/rest-api-errors.js'
7
+ import { createStrongEntityTag, matchesIfMatch, parseIfMatch } from './http-validators.js'
8
+ import { getOperationDiagnosticContext, getWriteOutcome, isRestApiError, wrapWriteError } from '../../../../lib/error-context.js'
9
+ import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
2
10
  import {
3
11
  determineResponseStatus,
4
- mapRestApiErrorToHttp
12
+ getHttpDiagnosticError,
13
+ isWriteMethod,
14
+ mapRestApiErrorToHttp,
15
+ mergeResponseHeaders
5
16
  } from './transport-http-helpers.js'
6
17
 
18
+ const responseLifecycleStarted = Symbol('responseLifecycleStarted')
19
+
7
20
  export function extractQueryString (url = '') {
8
- return String(url || '').split('?')[1] || ''
21
+ const value = String(url || '')
22
+ const index = value.indexOf('?')
23
+ return index === -1 ? '' : value.slice(index + 1)
9
24
  }
10
25
 
11
26
  export function buildTransportRequestData ({
@@ -74,6 +89,17 @@ export async function runTransportRequestLifecycle ({ context, runHooks }) {
74
89
  }
75
90
  }
76
91
 
92
+ export function addWriteOutcomeToHttpErrors (body, context) {
93
+ if (!isWriteMethod(context.transport?.request?.method) || !Array.isArray(body?.errors)) return body
94
+ return {
95
+ ...body,
96
+ errors: body.errors.map(error => ({
97
+ ...error,
98
+ meta: { ...error.meta, transactionOutcome: error.meta?.transactionOutcome || getWriteOutcome(context) }
99
+ }))
100
+ }
101
+ }
102
+
77
103
  export function buildTransportRejectionBody (context) {
78
104
  return {
79
105
  errors: [{
@@ -99,7 +125,11 @@ export async function applyTransportResponseLifecycle ({
99
125
  transportData.response.body = body
100
126
  context.transport = transportData
101
127
 
102
- await runHooks('transport:response', context)
128
+ // A failing response hook is reported as an error, without rerunning hooks.
129
+ if (!context[responseLifecycleStarted]) {
130
+ context[responseLifecycleStarted] = true
131
+ await runHooks('transport:response', context)
132
+ }
103
133
 
104
134
  return transportData.response.headers || {}
105
135
  }
@@ -117,7 +147,7 @@ export function getConnectorLocationHeader ({
117
147
  publicBaseUrl = ''
118
148
  }) {
119
149
  if (String(method || '').toUpperCase() !== 'POST') return null
120
- if (!context?.id || !routeMeta?.scopeName || !helpers?.getLocation) return null
150
+ if (context?.id === undefined || context.id === null || !routeMeta?.scopeName || !helpers?.getLocation) return null
121
151
 
122
152
  const location = helpers.getLocation({
123
153
  scopeName: routeMeta.scopeName,
@@ -131,6 +161,8 @@ export function getConnectorLocationHeader ({
131
161
  }
132
162
 
133
163
  export async function executeConnectorRoute ({
164
+ api,
165
+ httpValidators = false,
134
166
  method,
135
167
  handler,
136
168
  queryString,
@@ -145,13 +177,72 @@ export async function executeConnectorRoute ({
145
177
  publicBaseUrl = '',
146
178
  runHooks
147
179
  }) {
148
- const result = await handler({
180
+ const requestMethod = String(method).toUpperCase()
181
+ const ignoreConditions = ['OPTIONS', 'CONNECT', 'TRACE'].includes(requestMethod)
182
+ const condition = httpValidators && !ignoreConditions ? parseIfMatch(headers?.['if-match']) : undefined
183
+ const isGet = ['GET', 'HEAD'].includes(requestMethod)
184
+ const conditionalWrite = condition !== undefined && !isGet && routeMeta?.kind === 'resource' && ['put', 'patch', 'delete'].includes(routeMeta.operation)
185
+ if (condition !== undefined && !isGet && !conditionalWrite) {
186
+ throw new RestApiValidationError('If-Match is not supported for this route', { fields: ['headers.if-match'] })
187
+ }
188
+ const request = {
149
189
  queryString,
150
190
  headers,
151
191
  params,
152
192
  body,
153
193
  context
154
- })
194
+ }
195
+ let result
196
+ if (conditionalWrite) {
197
+ result = await api.transaction(async transaction => {
198
+ const readTransport = buildTransportData({ ...transportData.request, headers: cloneRequestHeaders(transportData.request.headers), method: 'GET', body: undefined })
199
+ let readContext
200
+ if (condition.wildcard) {
201
+ readContext = { ...context }
202
+ } else {
203
+ readContext = {
204
+ ...context,
205
+ request: { ...context.request, headers: cloneRequestHeaders(context.request?.headers), method: 'GET' },
206
+ transport: readTransport,
207
+ precondition: true
208
+ }
209
+ }
210
+ let preconditionPassed = false
211
+ const precondition = async () => {
212
+ preconditionPassed = false
213
+ let representation
214
+ try {
215
+ representation = await api.resources[routeMeta.scopeName].get({
216
+ id: params.id,
217
+ format: 'jsonapi',
218
+ transaction,
219
+ ...(condition.wildcard ? {} : { queryParams: parseJsonApiQuery(queryString) })
220
+ }, readContext)
221
+ await lockRelationshipParent({ context: readContext, helpers, scopeName: routeMeta.scopeName })
222
+ } catch (error) {
223
+ // Conditional PUT requires an existing visible representation.
224
+ if (routeMeta.operation === 'put' && isRestApiError(error) && error.subtype === 'not_found') {
225
+ throw new RestApiPreconditionFailedError({ resourceType: routeMeta.scopeName, resourceId: params.id })
226
+ }
227
+ throw error
228
+ }
229
+ if (!condition.wildcard) {
230
+ await applyTransportResponseLifecycle({ context: readContext, transportData: readTransport, status: 200, body: representation, runHooks })
231
+ const bytes = JSON.stringify(getConnectorResponseBody(readTransport.response.body))
232
+ const tag = bytes === undefined ? undefined : parseIfMatch(createStrongEntityTag(bytes)).tags[0]
233
+ if (!matchesIfMatch(condition, { exists: bytes !== undefined, tag })) {
234
+ throw new RestApiPreconditionFailedError({ resourceType: routeMeta.scopeName, resourceId: params.id })
235
+ }
236
+ }
237
+ preconditionPassed = true
238
+ }
239
+ const result = await handler({ ...request, transaction, precondition })
240
+ if (!preconditionPassed) throw new RestApiValidationError('Resource handler did not evaluate its HTTP precondition', { fields: ['headers.if-match'] })
241
+ return result
242
+ }, { [serializableTransaction]: !condition.wildcard })
243
+ } else {
244
+ result = await handler(request)
245
+ }
155
246
 
156
247
  const status = determineResponseStatus(method, result)
157
248
  const location = getConnectorLocationHeader({
@@ -170,32 +261,65 @@ export async function executeConnectorRoute ({
170
261
  runHooks
171
262
  })
172
263
 
264
+ let responseBody
265
+ if (status !== 204) {
266
+ const response = context && transportData ? transportData.response.body : result
267
+ responseBody = getConnectorResponseBody(response)
268
+ }
269
+ const serialized = httpValidators && isGet && status === 200 && responseBody !== undefined
270
+ const responseBytes = serialized ? JSON.stringify(responseBody) : undefined
271
+ const validatorHeaders = serialized ? { etag: createStrongEntityTag(responseBytes) } : undefined
272
+ if (condition !== undefined && isGet && status >= 200 && status < 300) {
273
+ const tag = validatorHeaders ? parseIfMatch(validatorHeaders.etag).tags[0] : undefined
274
+ if (!matchesIfMatch(condition, { exists: status === 200, tag })) {
275
+ throw new RestApiPreconditionFailedError({ resourceType: routeMeta?.scopeName, resourceId: params?.id })
276
+ }
277
+ }
173
278
  return {
174
279
  status,
175
- body: status === 204 ? undefined : getConnectorResponseBody(result),
176
- headers: {
177
- ...(result?.headers || {}),
178
- ...transportHeaders
179
- },
280
+ body: serialized ? responseBytes : responseBody,
281
+ serialized,
282
+ headers: mergeResponseHeaders(result?.headers, transportHeaders, validatorHeaders),
180
283
  location,
181
284
  result
182
285
  }
183
286
  }
184
287
 
288
+ export async function logHttpRequestError ({ error, context, log, scopes, routeMeta, api, method, path, message }) {
289
+ try {
290
+ const schemaInfo = scopes[routeMeta?.scopeName]?.vars?.schemaInfo || context.schemaInfo
291
+ const logger = createEnhancedLogger(log, { schemaInfo, redactFields: ['body', 'headers'] })
292
+ await logger.logError(message, getHttpDiagnosticError(error), {
293
+ ...getOperationDiagnosticContext(context, {
294
+ phase: 'httpError',
295
+ method,
296
+ scopeName: routeMeta?.scopeName || context.scopeName,
297
+ backend: api.knex?.instance?.client?.config?.client
298
+ }),
299
+ path
300
+ })
301
+ } catch (error) {
302
+ (context.cleanupErrors ||= []).push({ phase: 'logging', during: 'httpError', error })
303
+ }
304
+ }
305
+
185
306
  export async function handleConnectorError ({
186
307
  error,
187
308
  context,
188
309
  transportData,
189
310
  runHooks
190
311
  }) {
312
+ const write = isWriteMethod(transportData?.request?.method)
313
+ if (write && !(isRestApiError(error) && error instanceof RestApiWriteError)) error = wrapWriteError(error, context)
191
314
  const { status, body } = mapRestApiErrorToHttp(error)
192
- const headers = await applyTransportResponseLifecycle({
193
- context,
194
- transportData,
195
- status,
196
- body,
197
- runHooks
198
- })
199
-
200
- return { status, body, headers }
315
+ try {
316
+ const headers = await applyTransportResponseLifecycle({ context, transportData, status, body, runHooks })
317
+ return { status, body: context && transportData ? transportData.response.body : body, headers }
318
+ } catch (responseError) {
319
+ const failure = new AggregateError([error, responseError], 'Request and response hook failed', { cause: error })
320
+ const outcome = mapRestApiErrorToHttp(write ? wrapWriteError(failure, context) : failure)
321
+ // The first attempt marked the lifecycle started, so this only updates metadata.
322
+ const headers = await applyTransportResponseLifecycle({ context, transportData, ...outcome, runHooks })
323
+ return { ...outcome, headers }
324
+ }
201
325
  }
@@ -1,138 +1,81 @@
1
- /**
2
- * Formidable File Detector for HTTP Multipart Uploads
3
- *
4
- * This detector handles multipart/form-data uploads using the formidable library.
5
- * Unlike busboy, formidable saves files to disk temporarily, which is better
6
- * for large files.
7
- *
8
- * Features:
9
- * - Disk-based storage (better for large files)
10
- * - Automatic temp file cleanup
11
- * - Progress tracking support
12
- * - Built-in file type detection
13
- *
14
- * Usage:
15
- * ```javascript
16
- * import { createFormidableDetector } from 'jsonrestapi/plugins/core/lib/formidable-detector.js';
17
- *
18
- * api.use(ExpressPlugin, {
19
- * fileParser: 'formidable',
20
- * fileParserOptions: {
21
- * uploadDir: './uploads/temp',
22
- * keepExtensions: true,
23
- * maxFileSize: 200 * 1024 * 1024 // 200MB
24
- * }
25
- * });
26
- * ```
27
- */
28
-
29
- import { requirePackage } from 'hooked-api'
30
- import { promises as fs } from 'fs'
31
- import path from 'path'
1
+ import { throwMissingPackage } from '../../../../lib/missing-package.js'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
6
+ import { isMultipartContentType } from './transport-http-helpers.js'
7
+ import { addMultipartField, multipartLimits } from './multipart-helpers.js'
32
8
 
33
9
  let formidable
34
- try {
35
- formidable = (await import('formidable')).default
36
- } catch (e) {
37
- requirePackage('formidable', 'express/http-connector',
38
- 'Formidable is required for multipart/form-data file uploads with disk storage. This is a peer dependency.')
10
+ try { formidable = (await import('formidable')).default } catch {
11
+ throwMissingPackage('formidable', 'express-connector', 'Formidable is required for multipart uploads. Install the optional formidable peer.')
39
12
  }
40
13
 
41
- /**
42
- * Creates a formidable-based file detector
43
- *
44
- * @param {Object} options - Formidable configuration options
45
- * @param {string} options.uploadDir - Directory for temporary files
46
- * @param {boolean} options.keepExtensions - Keep file extensions
47
- * @param {number} options.maxFileSize - Max file size in bytes
48
- * @param {boolean} options.multiples - Allow multiple files per field
49
- * @returns {Object} Detector object with detect() and parse() methods
50
- */
14
+ /** Buffer completed uploads, then remove this request's temporary directory. */
51
15
  export function createFormidableDetector (options = {}) {
52
- // Set default upload directory
53
- const uploadDir = options.uploadDir || path.join(process.cwd(), 'uploads', 'temp')
54
-
16
+ const uploadDir = path.resolve(options.uploadDir || tmpdir())
55
17
  return {
56
18
  name: 'formidable-multipart',
57
-
58
- /**
59
- * Check if this detector can handle the request
60
- * @param {Object} params - Request parameters
61
- * @returns {boolean} True if this is a multipart request
62
- */
63
- detect: (params) => {
64
- const req = params._httpReq || params._expressReq
65
- if (!req || !req.headers) return false
66
-
67
- const contentType = req.headers['content-type'] || ''
68
- return contentType.includes('multipart/form-data')
69
- },
70
-
71
- /**
72
- * Parse multipart data from the request
73
- * @param {Object} params - Request parameters
74
- * @returns {Promise<{fields: Object, files: Object}>} Parsed data
75
- */
19
+ detect: params => isMultipartContentType((params._httpReq || params._expressReq)?.headers?.['content-type']),
76
20
  parse: async (params) => {
77
21
  const req = params._httpReq || params._expressReq
78
-
79
- // Ensure upload directory exists
80
- await fs.mkdir(uploadDir, { recursive: true })
81
-
82
- // Create form parser
83
- const form = formidable({
84
- uploadDir,
85
- keepExtensions: true,
86
- ...options
87
- })
88
-
89
- // Parse the request
90
- const [fields, fileUploads] = await form.parse(req)
91
-
92
- // Normalize formidable's file format to our standard format
93
- const files = {}
94
-
95
- for (const [fieldname, uploadedFiles] of Object.entries(fileUploads)) {
96
- // Formidable returns arrays for multiple files
97
- const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles]
98
-
99
- // For now, just take the first file (TODO: handle multiple files per field)
100
- const file = fileArray[0]
101
-
102
- if (file) {
103
- // Read file data into memory (for small files)
104
- // For large files, you might want to keep them on disk
105
- const data = await fs.readFile(file.filepath)
106
-
107
- files[fieldname] = {
22
+ if (!req?.on || req.aborted) throw new RestApiPayloadError('Multipart request is unavailable or aborted')
23
+ let directory, failure, rejectParsing, result
24
+ const onAborted = () => rejectParsing?.(new RestApiPayloadError('Multipart request aborted'))
25
+ req.once('aborted', onAborted)
26
+ try {
27
+ await fs.mkdir(uploadDir, { recursive: true })
28
+ directory = await fs.mkdtemp(path.join(uploadDir, 'json-rest-upload-'))
29
+ if (req.aborted) throw new RestApiPayloadError('Multipart request aborted')
30
+ const form = formidable({
31
+ maxFileSize: multipartLimits.fileSize,
32
+ maxTotalFileSize: multipartLimits.fileSize * multipartLimits.files,
33
+ maxFiles: multipartLimits.files,
34
+ maxFields: multipartLimits.fields,
35
+ maxFieldsSize: multipartLimits.fieldSize * multipartLimits.fields,
36
+ allowEmptyFiles: true,
37
+ minFileSize: 0,
38
+ ...options,
39
+ uploadDir: directory
40
+ })
41
+ const entries = []
42
+ const uploads = []
43
+ form.on('field', (name, value) => entries.push([name, value]))
44
+ form.on('file', (name, file) => uploads.push([name, file]))
45
+ await new Promise((resolve, reject) => {
46
+ rejectParsing = reject
47
+ // Callback mode avoids an unobserved internal promise on early abort.
48
+ form.parse(req, error => error ? reject(error) : resolve()).catch(reject)
49
+ })
50
+ const fields = new Map()
51
+ const files = new Map()
52
+ for (const [name, value] of entries) addMultipartField(fields, name, value)
53
+ for (const [name, file] of uploads) {
54
+ if (files.has(name)) throw new RestApiPayloadError(`Multiple files for field '${name}' are not supported`, { path: name })
55
+ files.set(name, {
108
56
  filename: file.originalFilename || 'unknown',
109
57
  mimetype: file.mimetype || 'application/octet-stream',
110
58
  size: file.size,
111
- data,
112
- filepath: file.filepath, // Keep for reference
113
-
114
- // Cleanup function to remove temp file
115
- cleanup: async () => {
116
- try {
117
- await fs.unlink(file.filepath)
118
- } catch (error) {
119
- // File might already be deleted, ignore
120
- if (error.code !== 'ENOENT') {
121
- throw error
122
- }
123
- }
124
- }
59
+ data: await fs.readFile(file.filepath)
60
+ })
61
+ }
62
+ result = { fields: Object.fromEntries(fields), files: Object.fromEntries(files) }
63
+ } catch (error) {
64
+ failure = [400, 413].includes(error.httpCode)
65
+ ? new RestApiPayloadError(error.message, { statusCode: error.httpCode, cause: error })
66
+ : error
67
+ } finally {
68
+ req.removeListener('aborted', onAborted)
69
+ if (failure) req.resume()
70
+ if (directory) {
71
+ try { await fs.rm(directory, { recursive: true, force: true }) } catch (error) {
72
+ if (failure) failure.cleanupError = error
73
+ else failure = error
125
74
  }
126
75
  }
127
76
  }
128
-
129
- // Normalize fields (formidable returns arrays for repeated fields)
130
- const normalizedFields = {}
131
- for (const [key, value] of Object.entries(fields)) {
132
- normalizedFields[key] = Array.isArray(value) && value.length === 1 ? value[0] : value
133
- }
134
-
135
- return { fields: normalizedFields, files }
77
+ if (failure) throw failure
78
+ return result
136
79
  }
137
80
  }
138
81
  }
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ /** @typedef {{ readonly weak: boolean, readonly value: string }} EntityTag */
3
+ /** @typedef {{ readonly wildcard: true, readonly tags: readonly [] } | { readonly wildcard: false, readonly tags: readonly EntityTag[] }} IfMatchCondition */
4
+ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
5
+ import { createHash } from 'node:crypto'
6
+
7
+ const MAX_HEADER_LENGTH = 8192
8
+ const MAX_LIST_ITEMS = 128
9
+ const invalidIfMatch = () => new RestApiValidationError('Invalid or oversized If-Match header', { fields: ['headers.if-match'] })
10
+
11
+ // Hash serialized bytes only; callers must send these same bytes without reserialization.
12
+ /**
13
+ * @param {string | Buffer} body
14
+ * @param {{ contentType?: string, contentEncoding?: string }} [options]
15
+ * @returns {string}
16
+ */
17
+ export function createStrongEntityTag (body, { contentType = 'application/vnd.api+json', contentEncoding = 'identity' } = {}) {
18
+ if (typeof body !== 'string' && !Buffer.isBuffer(body)) throw new TypeError('Entity tags require a serialized string or Buffer')
19
+ if (typeof contentType !== 'string' || typeof contentEncoding !== 'string') throw new TypeError('Entity tag representation metadata must be strings')
20
+ const digest = createHash('sha256').update(JSON.stringify([contentType, contentEncoding])).update('\0').update(body).digest('base64url')
21
+ return `"jra1-${digest}"`
22
+ }
23
+
24
+ // Entity tags are opaque octets, not JSON strings: backslashes do not escape quotes.
25
+ /** @param {unknown} input @returns {IfMatchCondition | undefined} */
26
+ export function parseIfMatch (input) {
27
+ if (input === undefined) return undefined
28
+ /** @type {readonly unknown[]} */
29
+ const lines = Array.isArray(input) ? input : [input]
30
+ if (lines.length > MAX_LIST_ITEMS) throw invalidIfMatch()
31
+ let length = Math.max(0, lines.length - 1)
32
+ for (const line of lines) {
33
+ if (typeof line !== 'string' || (length += line.length) > MAX_HEADER_LENGTH) throw invalidIfMatch()
34
+ }
35
+ const text = lines.join(',')
36
+ if (text.replace(/^[ \t]+|[ \t]+$/g, '') === '*') return { wildcard: true, tags: [] }
37
+ const tags = []
38
+ let position = 0
39
+ let separators = 0
40
+ const whitespace = () => { while (text[position] === ' ' || text[position] === '\t') position++ }
41
+ while (position < text.length) {
42
+ whitespace()
43
+ if (position === text.length) break
44
+ if (text[position] === ',') {
45
+ if (++separators > MAX_LIST_ITEMS) throw invalidIfMatch()
46
+ position++
47
+ continue
48
+ }
49
+ const weak = text.startsWith('W/', position)
50
+ if (weak) position += 2
51
+ if (text[position++] !== '"') throw invalidIfMatch()
52
+ const start = position
53
+ while (position < text.length && text[position] !== '"') {
54
+ const code = text.charCodeAt(position++)
55
+ if (!(code === 0x21 || (code >= 0x23 && code <= 0x7e) || (code >= 0x80 && code <= 0xff))) throw invalidIfMatch()
56
+ }
57
+ if (position === text.length) throw invalidIfMatch()
58
+ tags.push({ weak, value: text.slice(start, position++) })
59
+ if (tags.length > MAX_LIST_ITEMS) throw invalidIfMatch()
60
+ whitespace()
61
+ if (position < text.length && text[position] !== ',') throw invalidIfMatch()
62
+ }
63
+ return { wildcard: false, tags }
64
+ }
65
+
66
+ /**
67
+ * @param {IfMatchCondition | undefined} condition
68
+ * @param {{ exists: boolean, tag?: EntityTag }} representation
69
+ * @returns {boolean}
70
+ */
71
+ export function matchesIfMatch (condition, { exists, tag }) {
72
+ if (condition === undefined) return true
73
+ if (!exists) return false
74
+ if (condition.wildcard) return true
75
+ if (!tag || tag.weak) return false
76
+ return condition.tags.some(candidate => !candidate.weak && candidate.value === tag.value)
77
+ }
@@ -0,0 +1,18 @@
1
+ import { RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
2
+
3
+ export const multipartLimits = Object.freeze({
4
+ fileSize: 10 * 1024 * 1024,
5
+ files: 10,
6
+ fields: 100,
7
+ fieldSize: 64 * 1024,
8
+ parts: 110
9
+ })
10
+
11
+ export function addMultipartField (fields, name, value) {
12
+ if (typeof name !== 'string' || !name) throw new RestApiPayloadError('Multipart fields must have a name')
13
+ const array = name.match(/^(.+)\[\d*\]$/)
14
+ const key = array ? array[1] : name
15
+ if (fields.has(key)) {
16
+ fields.set(key, [...[].concat(fields.get(key)), value])
17
+ } else fields.set(key, array ? [value] : value)
18
+ }
@@ -3,9 +3,18 @@
3
3
  * Used by both HTTP and Express plugins
4
4
  */
5
5
 
6
+ /**
7
+ * Copy HTTP header fields, including repeated-value arrays, for a separate view.
8
+ * @param {import('node:http').IncomingHttpHeaders} [headers]
9
+ * @returns {import('node:http').IncomingHttpHeaders}
10
+ */
11
+ export function cloneRequestHeaders (headers = {}) {
12
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? [...value] : value]))
13
+ }
14
+
6
15
  /**
7
16
  * Extract the real client IP address, handling proxies
8
- * @param {Object} req - The request object (Node.js http.IncomingMessage or Express Request)
17
+ * @param {Object} req - The request object
9
18
  * @returns {string} The client IP address
10
19
  */
11
20
  export function getClientIP (req) {