json-rest-api 1.0.29 → 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 (292) 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 +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} +74 -30
  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 -177
  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 +48 -317
  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 +134 -37
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +13 -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 +179 -194
  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 +100 -290
  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 +93 -38
  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 +128 -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 -2485
  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 -1077
  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 -395
  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 -148
  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 +36 -68
  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 +37 -81
  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 -1247
  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 -148
  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 -155
  251. package/tests/fixtures/api-configs.js +0 -2566
  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-boundaries.test.js +0 -154
  264. package/tests/pagination-contract.test.js +0 -122
  265. package/tests/pagination-cursor-multifield.test.js +0 -641
  266. package/tests/pagination-enhanced.test.js +0 -563
  267. package/tests/polymorphic-relationships.test.js +0 -347
  268. package/tests/polymorphic-simple.test.js +0 -148
  269. package/tests/positioning-advanced.test.js +0 -692
  270. package/tests/positioning-extended.test.js +0 -915
  271. package/tests/positioning-multifilter.test.js +0 -699
  272. package/tests/positioning.test.js +0 -560
  273. package/tests/post-commit-hooks.test.js +0 -120
  274. package/tests/projected-fields.test.js +0 -319
  275. package/tests/public-exports.test.js +0 -15
  276. package/tests/put-create-parity.test.js +0 -216
  277. package/tests/queries.test.js +0 -521
  278. package/tests/query-limits.test.js +0 -584
  279. package/tests/relationship-endpoints.test.js +0 -620
  280. package/tests/relationship-include-storage-adapter.test.js +0 -56
  281. package/tests/relationships.test.js +0 -681
  282. package/tests/rest-api.test.js +0 -337
  283. package/tests/return-record-settings.test.js +0 -184
  284. package/tests/row-policy.test.js +0 -489
  285. package/tests/s3-storage.test.js +0 -38
  286. package/tests/searchschema-knex-behavior.test.js +0 -187
  287. package/tests/searchschema-merge.test.js +0 -151
  288. package/tests/socketio.test.js +0 -860
  289. package/tests/storage-mapping.test.js +0 -362
  290. package/tests/temporal-boundaries.test.js +0 -208
  291. package/tests/test-development-guide.md +0 -370
  292. package/tests/virtual-fields.test.js +0 -404
@@ -1,9 +1,13 @@
1
1
  import { createContext } from './lib/request-helpers.js'
2
- import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
3
- import { buildTransportRouteSchema } from './lib/transport-route-schemas.js'
2
+ import { buildTransportRouteSchema, getTransportRouteContract } from './lib/transport-route-schemas.js'
3
+ import { validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
4
4
  import {
5
5
  isWriteMethod,
6
6
  isAllowedWriteContentType,
7
+ mergeResponseHeaders,
8
+ acceptsJsonApi,
9
+ getNotAcceptableErrorBody,
10
+ getNotFoundErrorBody,
7
11
  getUnsupportedMediaTypeErrorBody
8
12
  } from './lib/transport-http-helpers.js'
9
13
  import {
@@ -11,59 +15,55 @@ import {
11
15
  buildTransportRequestData,
12
16
  createConnectorContext,
13
17
  runTransportRequestLifecycle,
18
+ applyTransportResponseLifecycle,
19
+ addWriteOutcomeToHttpErrors,
14
20
  buildTransportRejectionBody,
15
21
  executeConnectorRoute,
22
+ logHttpRequestError,
16
23
  handleConnectorError
17
24
  } from './lib/connector-core.js'
18
25
 
19
26
  function applyHeaders (reply, headers = {}) {
20
- for (const [headerName, headerValue] of Object.entries(headers)) {
27
+ const merged = mergeResponseHeaders({ vary: reply.getHeader('vary') }, headers, { vary: 'Accept' })
28
+ for (const [headerName, headerValue] of Object.entries(merged)) {
21
29
  reply.header(headerName, headerValue)
22
30
  }
23
31
  }
24
32
 
25
- function registerVendorJsonParser (app) {
26
- if (typeof app?.addContentTypeParser !== 'function') return
27
- if (typeof app?.hasContentTypeParser === 'function' && app.hasContentTypeParser('application/vnd.api+json')) {
28
- return
29
- }
30
-
31
- app.addContentTypeParser(
32
- 'application/vnd.api+json',
33
- { parseAs: 'string' },
34
- (request, body, done) => {
35
- if (body === '' || body === undefined || body === null) {
36
- done(null, {})
37
- return
38
- }
33
+ async function setJsonApiResponseType (request, reply, payload) {
34
+ // Fastify adds a charset during serialization; normalize after that step.
35
+ reply.header('Content-Type', 'application/vnd.api+json')
36
+ return payload
37
+ }
39
38
 
40
- try {
41
- done(null, JSON.parse(body))
42
- } catch (error) {
43
- error.statusCode = 400
44
- done(error)
45
- }
39
+ function registerJsonParsers (app) {
40
+ const parse = (request, body, done) => {
41
+ try { done(null, body === '' ? {} : JSON.parse(body)) } catch (error) {
42
+ error.statusCode = 400
43
+ error.type = 'entity.parse.failed'
44
+ done(error)
46
45
  }
47
- )
46
+ }
47
+ for (const contentType of ['application/json', 'application/vnd.api+json']) {
48
+ if (app.hasContentTypeParser(contentType)) app.removeContentTypeParser(contentType)
49
+ app.addContentTypeParser(contentType, { parseAs: 'string' }, parse)
50
+ }
48
51
  }
49
52
 
50
53
  export const FastifyPlugin = {
51
54
  name: 'fastify',
52
55
  dependencies: ['rest-api'],
53
56
 
54
- async install ({ vars, helpers, pluginOptions, log, api, runHooks, addHook }) {
57
+ async install ({ vars, helpers, pluginOptions, log, api, scopes, runHooks, addHook }) {
55
58
  const fastifyOptions = pluginOptions || {}
59
+ const httpValidators = fastifyOptions.httpValidators ?? false
60
+ if (typeof httpValidators !== 'boolean') throw new TypeError('httpValidators must be a boolean')
56
61
  const app = fastifyOptions.app
57
62
 
58
63
  if (!app || typeof app.route !== 'function') {
59
64
  throw new Error('FastifyPlugin requires a Fastify instance in pluginOptions.app.')
60
65
  }
61
66
 
62
- const enhancedLog = createEnhancedLogger(log, {
63
- logFullErrors: true,
64
- includeStack: true
65
- })
66
-
67
67
  if (!api.http) {
68
68
  api.http = {}
69
69
  }
@@ -80,19 +80,50 @@ export const FastifyPlugin = {
80
80
  publicBaseUrl
81
81
  }
82
82
 
83
- registerVendorJsonParser(app)
83
+ const ensureContext = (request, reply) => {
84
+ if (!request.jsonRestContext) {
85
+ const requestData = buildTransportRequestData({
86
+ method: request.method,
87
+ url: request.url,
88
+ path: request.url,
89
+ headers: request.headers,
90
+ body: request.body,
91
+ params: request.params,
92
+ query: request.query
93
+ })
94
+ const { context } = createConnectorContext({
95
+ request, reply, source: 'fastify', mountPath, publicBaseUrl, requestData, createContext
96
+ })
97
+ request.jsonRestContext = context
98
+ }
99
+ return request.jsonRestContext
100
+ }
101
+ const sendResponse = async (request, reply, status, body) => {
102
+ const context = ensureContext(request, reply)
103
+ body = addWriteOutcomeToHttpErrors(body, context)
104
+ const headers = await applyTransportResponseLifecycle({ context, transportData: context.transport, status, body, runHooks })
105
+ applyHeaders(reply, headers)
106
+ return reply.code(status).type('application/vnd.api+json').send(body)
107
+ }
84
108
 
85
109
  const buildFastifyHandler = ({ method, path, handler, routeMeta }) => {
86
110
  const routeSchema = buildTransportRouteSchema({ routeMeta, api })
87
111
 
88
112
  const fastifyErrorHandler = async (error, request, reply) => {
89
- enhancedLog.logError('Fastify request error', error, {
113
+ const context = ensureContext(request, reply)
114
+ await logHttpRequestError({
115
+ error,
116
+ context,
117
+ log,
118
+ scopes,
119
+ routeMeta,
120
+ api,
90
121
  method: request.method,
91
- path: request.url
122
+ path,
123
+ message: 'Fastify request error'
92
124
  })
93
125
 
94
- const context = request.jsonRestContext || null
95
- const transportData = context?.transport || null
126
+ const transportData = context.transport
96
127
  const { status, body, headers } = await handleConnectorError({
97
128
  error,
98
129
  context,
@@ -107,65 +138,54 @@ export const FastifyPlugin = {
107
138
  }
108
139
 
109
140
  const preValidation = async (request, reply) => {
110
- if (!strictContentType || !isWriteMethod(method)) {
111
- return
141
+ const context = ensureContext(request, reply)
142
+ const { rejected, handled } = await runTransportRequestLifecycle({ context, runHooks })
143
+ if (rejected) {
144
+ return sendResponse(request, reply, context.rejection.status || 500, buildTransportRejectionBody(context))
112
145
  }
146
+ if (handled) return
113
147
 
114
- const contentType = request.headers?.['content-type'] || ''
115
- if (isAllowedWriteContentType(contentType)) {
116
- return
148
+ applyHeaders(reply)
149
+ if (!acceptsJsonApi(request.headers?.accept)) {
150
+ return sendResponse(request, reply, 406, getNotAcceptableErrorBody())
117
151
  }
152
+ }
118
153
 
119
- reply.code(415)
120
- reply.type('application/vnd.api+json')
121
- return reply.send(getUnsupportedMediaTypeErrorBody())
154
+ const preParsing = async (request, reply, payload) => {
155
+ if (strictContentType && isWriteMethod(request.method) && !isAllowedWriteContentType(request.headers?.['content-type'])) {
156
+ await sendResponse(request, reply, 415, getUnsupportedMediaTypeErrorBody())
157
+ return
158
+ }
159
+ return payload
122
160
  }
123
161
 
124
162
  return {
125
163
  method,
126
164
  url: path,
127
- ...(routeSchema ? { schema: routeSchema } : {}),
165
+ ...(routeSchema
166
+ ? {
167
+ schema: routeSchema,
168
+ // Use the existing request contract without Ajv's field removal or coercion.
169
+ validatorCompiler: () => (payload) => {
170
+ try {
171
+ const contract = getTransportRouteContract({ routeMeta, api })
172
+ return { value: validateRequestContractOrThrow(contract, payload) }
173
+ } catch (error) { return { error } }
174
+ }
175
+ }
176
+ : {}),
128
177
  errorHandler: fastifyErrorHandler,
178
+ onSend: setJsonApiResponseType,
179
+ preParsing,
129
180
  preValidation,
130
181
  handler: async (request, reply) => {
131
- const requestData = buildTransportRequestData({
132
- method: request.method,
133
- url: request.url,
134
- path: request.url,
135
- headers: request.headers,
136
- body: request.body,
137
- params: request.params,
138
- query: request.query
139
- })
140
- const { context, transportData } = createConnectorContext({
141
- request,
142
- reply,
143
- source: 'fastify',
144
- mountPath,
145
- publicBaseUrl,
146
- requestData,
147
- createContext
148
- })
149
- request.jsonRestContext = context
150
-
151
- const { rejected, handled } = await runTransportRequestLifecycle({
152
- context,
153
- runHooks
154
- })
155
-
156
- if (rejected) {
157
- const rejectionBody = buildTransportRejectionBody(context)
158
- applyHeaders(reply, transportData.response.headers)
159
- reply.code(context.rejection.status || 500)
160
- reply.type('application/vnd.api+json')
161
- return reply.send(rejectionBody)
162
- }
163
-
164
- if (handled) {
165
- return
166
- }
182
+ const context = request.jsonRestContext
183
+ if (context.handled) return
184
+ const transportData = context.transport
167
185
 
168
186
  const outcome = await executeConnectorRoute({
187
+ api,
188
+ httpValidators,
169
189
  method,
170
190
  handler,
171
191
  queryString: extractQueryString(request?.raw?.url || request?.url || ''),
@@ -199,9 +219,32 @@ export const FastifyPlugin = {
199
219
  }
200
220
  }
201
221
 
222
+ const pendingRoutes = []
223
+ let registerRoute = route => pendingRoutes.push(route)
224
+ app.register(async (instance) => {
225
+ registerJsonParsers(instance)
226
+ registerRoute = route => instance.route(route)
227
+ for (const route of pendingRoutes.splice(0)) registerRoute(route)
228
+
229
+ if (fastifyOptions.handle404 !== false) {
230
+ instance.register(async (fallback) => {
231
+ const route = buildFastifyHandler({
232
+ method: 'GET',
233
+ handler: async ({ context }) => ({
234
+ statusCode: 404,
235
+ body: getNotFoundErrorBody(context.transport.request.method, context.transport.request.path.split('?')[0])
236
+ })
237
+ })
238
+ fallback.setErrorHandler(route.errorHandler)
239
+ fallback.addHook('onSend', setJsonApiResponseType)
240
+ fallback.addHook('preParsing', route.preParsing)
241
+ fallback.setNotFoundHandler({ preValidation: route.preValidation }, route.handler)
242
+ }, { prefix: mountPath })
243
+ }
244
+ })
245
+
202
246
  addHook('addRoute', 'fastifyRouteCreator', {}, async ({ context }) => {
203
- const route = buildFastifyHandler(context)
204
- app.route(route)
247
+ registerRoute(buildFastifyHandler(context))
205
248
  log.trace(`Fastify route created: ${context.method} ${context.path}`)
206
249
  })
207
250
 
@@ -1,174 +1,92 @@
1
- /**
2
- * Busboy File Detector for HTTP Multipart Uploads
3
- *
4
- * This detector handles multipart/form-data uploads using the busboy library.
5
- * It's suitable for both HTTP and Express connectors.
6
- *
7
- * Features:
8
- * - Streaming parser (memory efficient)
9
- * - Configurable file size limits
10
- * - Automatic field parsing
11
- * - No temporary files by default (keeps in memory)
12
- *
13
- * Usage:
14
- * ```javascript
15
- * import { createBusboyDetector } from 'jsonrestapi/plugins/core/lib/busboy-detector.js';
16
- *
17
- * api.use(HttpPlugin, {
18
- * fileParser: 'busboy',
19
- * fileParserOptions: {
20
- * limits: { fileSize: 10 * 1024 * 1024 } // 10MB
21
- * }
22
- * });
23
- * ```
24
- */
25
-
26
- import { requirePackage } from 'hooked-api'
27
- import { Readable } from 'stream'
28
-
29
- let Busboy
30
- try {
31
- Busboy = (await import('busboy')).default
32
- } catch (e) {
33
- requirePackage('busboy', 'express/http-connector',
34
- 'Busboy is required for multipart/form-data file uploads. This is a peer dependency.')
1
+ import { throwMissingPackage } from '../../../../lib/missing-package.js'
2
+ import { RestApiPayloadError } from '../../../../lib/rest-api-errors.js'
3
+ import { isMultipartContentType } from './transport-http-helpers.js'
4
+ import { addMultipartField, multipartLimits } from './multipart-helpers.js'
5
+
6
+ let busboyFactory
7
+ try { busboyFactory = (await import('busboy')).default } catch {
8
+ throwMissingPackage('busboy', 'express-connector', 'Busboy is required for multipart uploads. Install the optional busboy peer.')
35
9
  }
36
10
 
37
- /**
38
- * Creates a busboy-based file detector
39
- *
40
- * @param {Object} options - Busboy configuration options
41
- * @param {Object} options.limits - Size limits
42
- * @param {number} options.limits.fileSize - Max file size in bytes
43
- * @param {number} options.limits.files - Max number of files
44
- * @param {number} options.limits.fields - Max number of fields
45
- * @returns {Object} Detector object with detect() and parse() methods
46
- */
11
+ /** Parse a streamed multipart request into buffered files and text fields. */
47
12
  export function createBusboyDetector (options = {}) {
13
+ const limits = { ...multipartLimits, ...options.limits }
48
14
  return {
49
15
  name: 'busboy-multipart',
50
-
51
- /**
52
- * Check if this detector can handle the request
53
- * @param {Object} params - Request parameters
54
- * @returns {boolean} True if this is a multipart request
55
- */
56
- detect: (params) => {
57
- const req = params._httpReq || params._expressReq
58
- if (!req || !req.headers) return false
59
-
60
- const contentType = req.headers['content-type'] || ''
61
- return contentType.includes('multipart/form-data')
62
- },
63
-
64
- /**
65
- * Parse multipart data from the request
66
- * @param {Object} params - Request parameters
67
- * @returns {Promise<{fields: Object, files: Object}>} Parsed data
68
- */
16
+ detect: params => isMultipartContentType((params._httpReq || params._expressReq)?.headers?.['content-type']),
69
17
  parse: async (params) => {
70
18
  const req = params._httpReq || params._expressReq
71
-
19
+ if (!req?.pipe || req.aborted) throw new RestApiPayloadError('Multipart request is unavailable or aborted')
72
20
  return new Promise((resolve, reject) => {
73
- const busboy = new Busboy({
74
- headers: req.headers,
75
- ...options
21
+ let parser
22
+ const fields = new Map()
23
+ const files = new Map()
24
+ const fileNames = new Set()
25
+ let failure
26
+ const cleanup = () => {
27
+ req.removeListener('aborted', onAborted)
28
+ req.removeListener('error', onError)
29
+ }
30
+ const fail = error => {
31
+ if (failure) return
32
+ failure = error
33
+ fields.clear()
34
+ files.clear()
35
+ req.unpipe(parser)
36
+ req.resume()
37
+ cleanup()
38
+ // A limit event can run inside Busboy's current write; destroy afterwards.
39
+ queueMicrotask(() => parser?.destroy())
40
+ reject(error)
41
+ }
42
+ const onError = cause => fail(new RestApiPayloadError('Malformed multipart request', { cause }))
43
+ const onAborted = () => fail(new RestApiPayloadError('Multipart request aborted'))
44
+ const limitError = field => new RestApiPayloadError(`Multipart limit exceeded: ${field}`, { path: field, statusCode: 413 })
45
+ try {
46
+ parser = busboyFactory({
47
+ defParamCharset: 'utf8',
48
+ ...options,
49
+ headers: req.headers,
50
+ // Busboy reports truncation when a limit is reached, including exact size.
51
+ limits: { ...limits, fileSize: limits.fileSize + 1, fieldSize: limits.fieldSize + 1, parts: limits.parts + 1 }
52
+ })
53
+ } catch (cause) { reject(new RestApiPayloadError('Invalid multipart headers or boundary', { cause })); return }
54
+ req.once('aborted', onAborted)
55
+ req.once('error', onError)
56
+ parser.on('field', (name, value, info) => {
57
+ if (failure) return
58
+ if (info.valueTruncated || info.nameTruncated || Buffer.byteLength(value) > limits.fieldSize) return fail(limitError(name))
59
+ try { addMultipartField(fields, name, value) } catch (error) { fail(error) }
76
60
  })
77
-
78
- const fields = {}
79
- const files = {}
80
- const filePromises = []
81
-
82
- // Handle fields
83
- busboy.on('field', (fieldname, val) => {
84
- // Handle array notation (field[] or field[0])
85
- const arrayMatch = fieldname.match(/^(.+)\[\d*\]$/)
86
- if (arrayMatch) {
87
- const baseName = arrayMatch[1]
88
- if (!fields[baseName]) {
89
- fields[baseName] = []
90
- }
91
- if (Array.isArray(fields[baseName])) {
92
- fields[baseName].push(val)
93
- }
94
- } else {
95
- fields[fieldname] = val
61
+ parser.on('file', (name, stream, { filename, encoding, mimeType }) => {
62
+ stream.on('error', onError)
63
+ if (failure) { stream.resume(); return }
64
+ if (fileNames.has(name)) {
65
+ stream.resume()
66
+ fail(new RestApiPayloadError(`Multiple files for field '${name}' are not supported`, { path: name }))
67
+ return
96
68
  }
97
- })
98
-
99
- // Handle files
100
- busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
69
+ fileNames.add(name)
101
70
  const chunks = []
102
71
  let size = 0
103
-
104
- const filePromise = new Promise((resolve, reject) => {
105
- file.on('data', (chunk) => {
106
- chunks.push(chunk)
107
- size += chunk.length
108
- })
109
-
110
- file.on('limit', () => {
111
- reject(new Error(`File size limit exceeded for field '${fieldname}'`))
112
- })
113
-
114
- file.on('end', () => {
115
- files[fieldname] = {
116
- filename,
117
- mimetype,
118
- encoding,
119
- size,
120
- data: Buffer.concat(chunks)
121
- }
122
- resolve()
123
- })
124
-
125
- file.on('error', reject)
72
+ stream.on('data', chunk => {
73
+ if (failure) return
74
+ size += chunk.length
75
+ if (size > limits.fileSize) { fail(limitError(name)); return }
76
+ chunks.push(chunk)
77
+ })
78
+ stream.on('limit', () => fail(limitError(name)))
79
+ stream.on('end', () => {
80
+ if (!failure) files.set(name, { filename, encoding, mimetype: mimeType, size, data: Buffer.concat(chunks) })
126
81
  })
127
-
128
- filePromises.push(filePromise)
129
- })
130
-
131
- // Handle completion
132
- busboy.on('finish', async () => {
133
- try {
134
- // Wait for all files to be fully read
135
- await Promise.all(filePromises)
136
- resolve({ fields, files })
137
- } catch (error) {
138
- reject(error)
139
- }
140
- })
141
-
142
- // Handle errors
143
- busboy.on('error', (error) => {
144
- reject(error)
145
- })
146
-
147
- // Handle limit errors
148
- busboy.on('partsLimit', () => {
149
- reject(new Error('Parts limit exceeded'))
150
- })
151
-
152
- busboy.on('filesLimit', () => {
153
- reject(new Error('Files limit exceeded'))
154
82
  })
155
-
156
- busboy.on('fieldsLimit', () => {
157
- reject(new Error('Fields limit exceeded'))
83
+ for (const event of ['partsLimit', 'filesLimit', 'fieldsLimit']) parser.on(event, () => fail(limitError(event)))
84
+ parser.on('error', onError)
85
+ parser.on('close', () => {
86
+ cleanup()
87
+ if (!failure) resolve({ fields: Object.fromEntries(fields), files: Object.fromEntries(files) })
158
88
  })
159
-
160
- // Pipe request to busboy
161
- if (req.pipe) {
162
- req.pipe(busboy)
163
- } else if (req.on) {
164
- // For already buffered requests
165
- const stream = new Readable()
166
- stream.push(req.body || req)
167
- stream.push(null)
168
- stream.pipe(busboy)
169
- } else {
170
- reject(new Error('Request object is not a stream'))
171
- }
89
+ req.pipe(parser)
172
90
  })
173
91
  }
174
92
  }