json-rest-api 1.0.28 → 2.0.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 (289) 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 +746 -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 +634 -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 +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -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 +36 -0
  44. package/index.js +1 -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 +64 -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 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  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 +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. 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) {