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,7 +1,31 @@
1
1
  import {
2
+ RestApiWriteError,
2
3
  REST_API_FIELDSET_ERROR_CODE,
4
+ REST_API_INCLUDE_ERROR_CODE,
3
5
  REST_API_TEMPORAL_DATA_ERROR_CODE
4
6
  } from '../../../../lib/rest-api-errors.js'
7
+ import { isRestApiError } from '../../../../lib/error-context.js'
8
+ import { parse as parseContentType } from 'content-type'
9
+ import vary from 'vary'
10
+
11
+ export function mergeResponseHeaders (...sources) {
12
+ const headers = new Map()
13
+ for (const source of sources) {
14
+ for (const [name, value] of Object.entries(source || {})) {
15
+ if (value === undefined || value === null) continue
16
+ const key = name.toLowerCase()
17
+ headers.set(key, key === 'vary' ? vary.append(headers.get(key) || '', value) : value)
18
+ }
19
+ }
20
+ return Object.fromEntries(headers)
21
+ }
22
+
23
+ export function getHttpDiagnosticError (error) {
24
+ // Parser messages and stacks can quote raw input before a resource is known.
25
+ // Keep the original error for hooks/responses, but log only this safe preview.
26
+ if (error?.type !== 'entity.parse.failed') return error
27
+ return { name: 'SyntaxError', message: 'Invalid JSON request body', type: 'entity.parse.failed', status: 400 }
28
+ }
5
29
 
6
30
  const JSON_API_WRITE_CONTENT_TYPES = [
7
31
  'application/vnd.api+json',
@@ -12,6 +36,10 @@ export function isWriteMethod (method) {
12
36
  return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || '').toUpperCase())
13
37
  }
14
38
 
39
+ export function isMultipartContentType (contentType) {
40
+ try { return parseContentType(contentType).type === 'multipart/form-data' } catch { return false }
41
+ }
42
+
15
43
  export function getAllowedWriteContentTypes ({ allowMultipart = false } = {}) {
16
44
  return allowMultipart
17
45
  ? [...JSON_API_WRITE_CONTENT_TYPES, 'multipart/form-data']
@@ -20,9 +48,40 @@ export function getAllowedWriteContentTypes ({ allowMultipart = false } = {}) {
20
48
 
21
49
  export function isAllowedWriteContentType (contentType, { allowMultipart = false } = {}) {
22
50
  if (!contentType) return true
51
+ try {
52
+ const media = parseContentType(contentType)
53
+ return getAllowedWriteContentTypes({ allowMultipart }).includes(media.type) &&
54
+ (media.type !== JSON_API_WRITE_CONTENT_TYPES[0] || Object.keys(media.parameters).every(key => key === 'profile'))
55
+ } catch { return false }
56
+ }
57
+
58
+ export function acceptsJsonApi (accept) {
59
+ if (accept === undefined) return true
60
+ // Split only between media ranges; quoted profile URIs can contain commas.
61
+ const ranges = (String(accept).match(/(?:[^,"]|"(?:\\.|[^"\\])*")+/g) || []).flatMap(value => {
62
+ try { return [parseContentType(value.trim())] } catch { return [] }
63
+ })
64
+ const vendorType = JSON_API_WRITE_CONTENT_TYPES[0]
65
+ const validParameters = media => Object.keys(media.parameters).every(key => ['profile', 'q'].includes(key))
66
+ const vendorRanges = ranges.filter(media => media.type === vendorType)
67
+ if (vendorRanges.length && !vendorRanges.some(validParameters)) return false
68
+ const matches = ranges.flatMap(media => {
69
+ const specificity = [vendorType, 'application/*', '*/*'].indexOf(media.type)
70
+ if (specificity < 0 || !validParameters(media)) return []
71
+ const q = media.parameters.q ?? '1'
72
+ const quality = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(q) ? Number(q) : 0
73
+ return [{ specificity, quality }]
74
+ })
75
+ matches.sort((a, b) => a.specificity - b.specificity || b.quality - a.quality)
76
+ return (matches[0]?.quality || 0) > 0
77
+ }
78
+
79
+ export function getNotAcceptableErrorBody () {
80
+ return { errors: [{ status: '406', title: 'Not Acceptable', detail: 'Accept must allow application/vnd.api+json without unsupported parameters or extensions' }] }
81
+ }
23
82
 
24
- return getAllowedWriteContentTypes({ allowMultipart })
25
- .some((allowedType) => contentType.includes(allowedType))
83
+ export function getNotFoundErrorBody (method, url) {
84
+ return { errors: [{ status: '404', title: 'Not Found', detail: `The requested endpoint ${method} ${url} does not exist` }] }
26
85
  }
27
86
 
28
87
  export function getUnsupportedMediaTypeErrorBody ({ allowMultipart = false } = {}) {
@@ -53,6 +112,9 @@ export function determineResponseStatus (method, result) {
53
112
  }
54
113
 
55
114
  export function mapRestApiErrorToHttp (error) {
115
+ // Read hooks can reject with null or undefined.
116
+ error ??= {}
117
+ const statusHint = error.statusCode ?? error.status
56
118
  let status = 500
57
119
  let errors = [{
58
120
  status: '500',
@@ -76,6 +138,15 @@ export function mapRestApiErrorToHttp (error) {
76
138
  title: 'Invalid Sparse Fieldset',
77
139
  detail: error.message
78
140
  }]
141
+ } else if (error.code === REST_API_INCLUDE_ERROR_CODE) {
142
+ status = 400
143
+ errors = [{
144
+ status: '400',
145
+ code: error.code,
146
+ title: 'Invalid Include Path',
147
+ detail: error.message,
148
+ source: { parameter: 'include' }
149
+ }]
79
150
  } else if (error.code === REST_API_TEMPORAL_DATA_ERROR_CODE) {
80
151
  status = 500
81
152
  errors = [{
@@ -101,6 +172,12 @@ export function mapRestApiErrorToHttp (error) {
101
172
  ...(error.details ? { source: error.details } : {})
102
173
  }]
103
174
  }
175
+ } else if (error.code === 'REST_API_PRECONDITION_FAILED') {
176
+ status = 412
177
+ errors = [{ status: '412', code: error.code, title: 'Precondition Failed', detail: error.message }]
178
+ } else if (error.code === 'REST_API_VERSION_CONFLICT') {
179
+ status = 409
180
+ errors = [{ status: '409', code: error.code, title: 'Version Conflict', detail: error.message }]
104
181
  } else if (error.code === 'REST_API_RESOURCE') {
105
182
  switch (error.subtype) {
106
183
  case 'not_found':
@@ -120,15 +197,15 @@ export function mapRestApiErrorToHttp (error) {
120
197
  errors = [{ status: '400', title: 'Bad Request', detail: error.message }]
121
198
  }
122
199
  } else if (error.code === 'REST_API_PAYLOAD') {
123
- status = 400
200
+ status = error.statusCode === 413 ? 413 : 400
124
201
  errors = [{
125
- status: '400',
126
- title: 'Bad Request',
202
+ status: String(status),
203
+ title: status === 413 ? 'Content Too Large' : 'Bad Request',
127
204
  detail: error.message,
128
- ...(error.path ? { source: { pointer: error.path } } : {})
205
+ ...(error.parameter ? { source: { parameter: error.parameter } } : error.path ? { source: { pointer: error.path } } : {})
129
206
  }]
130
- } else if (typeof error.statusCode === 'number' && error.statusCode >= 400 && error.statusCode < 500) {
131
- status = error.statusCode
207
+ } else if (typeof statusHint === 'number' && statusHint >= 400 && statusHint < 500) {
208
+ status = statusHint
132
209
  errors = [{
133
210
  status: String(status),
134
211
  title: status === 415 ? 'Unsupported Media Type' : 'Bad Request',
@@ -136,6 +213,10 @@ export function mapRestApiErrorToHttp (error) {
136
213
  }]
137
214
  }
138
215
 
216
+ if (isRestApiError(error) && error instanceof RestApiWriteError) {
217
+ errors = errors.map(item => ({ ...item, meta: { ...item.meta, transactionOutcome: error.transactionOutcome } }))
218
+ }
219
+
139
220
  return {
140
221
  status,
141
222
  body: { errors }
@@ -1,6 +1,6 @@
1
1
  import { getRequestContracts } from '../../lib/querying-writing/request-contracts.js'
2
2
 
3
- export function buildTransportRouteSchema ({ routeMeta, api }) {
3
+ export function getTransportRouteContract ({ routeMeta, api }) {
4
4
  if (!routeMeta) {
5
5
  return null
6
6
  }
@@ -38,7 +38,10 @@ export function buildTransportRouteSchema ({ routeMeta, api }) {
38
38
  return null
39
39
  }
40
40
 
41
- return {
42
- body: contract.schema.toJsonSchema({ mode: contract.mode, additionalProperties: false })
43
- }
41
+ return contract
42
+ }
43
+
44
+ export function buildTransportRouteSchema (options) {
45
+ const contract = getTransportRouteContract(options)
46
+ return contract ? { body: contract.schema.toJsonSchema({ mode: contract.mode, additionalProperties: false }) } : null
44
47
  }
@@ -1,49 +1,17 @@
1
- /**
2
- * File Handling Plugin for JSON REST API
3
- *
4
- * This plugin provides automatic file upload handling based on schema definitions.
5
- * It works with any protocol connector (HTTP, Express, WebSocket, etc.) by using
6
- * a detector registry pattern.
7
- *
8
- * Features:
9
- * - Schema-driven: Detects file fields from type: 'file' in schemas
10
- * - Protocol-agnostic: Works with any connector that registers a detector
11
- * - Storage pluggable: Different fields can use different storage backends
12
- * - Zero configuration: Just define file fields in your schema
13
- *
14
- * Usage:
15
- * ```javascript
16
- * // 1. Define schema with file fields
17
- * const imageSchema = {
18
- * title: { type: 'string' },
19
- * uploadedImage: {
20
- * type: 'file',
21
- * storage: S3Storage,
22
- * accepts: ['image/jpeg', 'image/png']
23
- * }
24
- * };
25
- *
26
- * // 2. Use plugins (order matters - file-handling depends on rest-api)
27
- * api.use(RestApiPlugin);
28
- * api.use(FileHandlingPlugin);
29
- * api.use(ExpressPlugin); // Or any other connector
30
- *
31
- * // 3. Files are automatically handled!
32
- * ```
33
- */
1
+ /** File detection, uploads and cleanup tied to resource writes. */
34
2
 
35
3
  import { RestApiValidationError } from '../../lib/rest-api-errors.js'
36
- import { isRestApiError } from '../../lib/error-context.js'
4
+ import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../lib/error-context.js'
5
+ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
37
6
 
38
7
  export const FileHandlingPlugin = {
39
8
  name: 'file-handling',
40
9
  dependencies: ['rest-api'],
41
10
 
42
- install ({ addHook, helpers, scopes, log, vars, on, api }) {
43
- // Track which scopes have file fields
44
- const fileScopes = new Map() // scopeName -> fileField[]
11
+ install ({ addHook, scopes, log, api }) {
12
+ const boundedLog = createEnhancedLogger(log)
13
+ const fileFieldsBySchema = new WeakMap()
45
14
 
46
- // Registry of file detectors from various protocols
47
15
  const detectorRegistry = []
48
16
 
49
17
  /**
@@ -60,27 +28,31 @@ export const FileHandlingPlugin = {
60
28
  }
61
29
 
62
30
  detectorRegistry.push(detector)
63
- log.debug(`Registered file detector: ${detector.name}`)
31
+ boundedLog.debug(`Registered file detector: ${detector.name}`)
64
32
  }
65
33
 
66
- // Store detectors array for inspection
67
34
  api.rest.fileDetectors = detectorRegistry
68
35
 
69
- const trackUploadedFile = (context, upload) => {
70
- if (!context.fileHandlingUploads) {
71
- context.fileHandlingUploads = []
36
+ const recordCleanupFailure = async (context, phase, field, error) => {
37
+ const errors = context.cleanupErrors ||= []
38
+ errors.push({ phase, field, error })
39
+ try {
40
+ await createEnhancedLogger(log, { schemaInfo: context.schemaInfo }).warn('File cleanup failed', {
41
+ ...getOperationDiagnosticContext(context, { phase }), field, error
42
+ })
43
+ } catch (error) {
44
+ errors.push({ phase: 'logging', during: phase, field, error })
72
45
  }
73
- context.fileHandlingUploads.push(upload)
74
46
  }
75
47
 
76
- const cleanupParsedFiles = async (files = {}) => {
77
- for (const file of Object.values(files)) {
48
+ const cleanupParsedFiles = async (files = {}, context) => {
49
+ for (const [field, file] of Object.entries(files)) {
78
50
  if (!file?.cleanup) continue
79
51
 
80
52
  try {
81
53
  await file.cleanup()
82
54
  } catch (error) {
83
- log.warn('Failed to cleanup temp file:', error)
55
+ await recordCleanupFailure(context, 'temporaryFileCleanup', field, error)
84
56
  }
85
57
  }
86
58
  }
@@ -89,130 +61,89 @@ export const FileHandlingPlugin = {
89
61
  const uploads = context.fileHandlingUploads
90
62
  if (!uploads || uploads.length === 0) return
91
63
 
92
- context.fileHandlingUploads = []
64
+ const remaining = []
93
65
  for (const upload of uploads) {
94
- if (!upload?.url || typeof upload.storage?.delete !== 'function') {
66
+ if (upload.transaction !== context.transaction || !upload.url || typeof upload.storage?.delete !== 'function') {
67
+ remaining.push(upload)
95
68
  continue
96
69
  }
97
70
 
98
71
  try {
99
72
  await upload.storage.delete(upload.url)
100
73
  } catch (error) {
101
- log.warn(`Failed to cleanup uploaded file '${upload.url}' after rollback:`, error)
74
+ remaining.push(upload)
75
+ await recordCleanupFailure(context, 'uploadedFileCleanup', upload.field, error)
102
76
  }
103
77
  }
78
+ context.fileHandlingUploads = remaining
104
79
  }
105
80
 
106
- /**
107
- * Analyze a scope's schema to find file fields
108
- */
109
- const analyzeScopeSchema = (scopeName, scopeOptions) => {
110
- const schema = scopeOptions?.schema
111
- if (!schema) return
81
+ const getFileFields = scopeName => {
82
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
83
+ if (!schemaInfo) return []
84
+ const cached = fileFieldsBySchema.get(schemaInfo)
85
+ if (cached) return cached
112
86
 
113
87
  const fileFields = []
114
-
115
- // Look for fields with type: 'file'
116
- for (const [fieldName, fieldConfig] of Object.entries(schema)) {
117
- if (fieldConfig && fieldConfig.type === 'file') {
88
+ for (const [fieldName, fieldConfig] of Object.entries(schemaInfo.schemaStructure)) {
89
+ if (fieldConfig?.type === 'file') {
118
90
  fileFields.push({
119
91
  field: fieldName,
120
92
  storage: fieldConfig.storage,
121
93
  accepts: fieldConfig.accepts || ['*'],
122
- maxSize: fieldConfig.maxSize,
123
- required: fieldConfig.required || false
94
+ maxSize: fieldConfig.maxSize
124
95
  })
125
96
  }
126
97
  }
127
-
128
- if (fileFields.length > 0) {
129
- fileScopes.set(scopeName, fileFields)
130
- log.info(`Scope '${scopeName}' has ${fileFields.length} file field(s): ${fileFields.map(f => f.field).join(', ')}`)
131
- }
132
- }
133
-
134
- // Analyze existing scopes
135
- for (const [scopeName, scope] of Object.entries(scopes)) {
136
- if (scope._scopeOptions) {
137
- analyzeScopeSchema(scopeName, scope._scopeOptions)
138
- }
98
+ fileFieldsBySchema.set(schemaInfo, fileFields)
99
+ return fileFields
139
100
  }
140
101
 
141
- // Listen for new scopes being added
142
- addHook('scope:added', 'analyzeFileFields', {}, ({ context }) => {
143
- const { scopeName, scopeOptions } = context
144
- analyzeScopeSchema(scopeName, scopeOptions)
145
- })
146
-
147
- /**
148
- * Process files for a scope if it has file fields
149
- */
150
102
  const processFiles = async (scopeName, params, context) => {
151
- const fileFields = fileScopes.get(scopeName)
152
- if (!fileFields || fileFields.length === 0) {
153
- return // This scope doesn't have file fields
154
- }
103
+ const fileFields = getFileFields(scopeName)
104
+ if (fileFields.length === 0) return
155
105
 
156
- // Try each detector to see if we have files
157
106
  let parsed = null
158
107
  let detectorUsed = null
159
108
 
160
109
  for (const detector of detectorRegistry) {
110
+ let matched
161
111
  try {
162
- if (await detector.detect(params, context)) {
163
- log.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
164
- parsed = await detector.parse(params, context)
165
- detectorUsed = detector.name
166
- break
167
- }
112
+ matched = await detector.detect(params, context)
168
113
  } catch (error) {
169
- if (isRestApiError(error)) throw error
170
- log.warn(`Detector '${detector.name}' failed:`, error)
114
+ throw wrapUnexpectedError(error, {
115
+ message: `File detector '${detector.name}' failed`,
116
+ context: { scopeName, detector: detector.name, phase: 'fileDetection' }
117
+ })
171
118
  }
172
- }
173
-
174
- if (!parsed) {
175
- // No files detected - check if any were required
176
- for (const fieldConfig of fileFields) {
177
- if (fieldConfig.required && !params.inputRecord?.data?.attributes?.[fieldConfig.field]) {
178
- throw new RestApiValidationError(
179
- `Required file field '${fieldConfig.field}' is missing`,
180
- {
181
- fields: [fieldConfig.field],
182
- violations: [{
183
- field: fieldConfig.field,
184
- message: 'This field is required'
185
- }]
186
- }
187
- )
188
- }
119
+ if (!matched) continue
120
+ boundedLog.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
121
+ try {
122
+ parsed = await detector.parse(params, context)
123
+ } catch (error) {
124
+ throw wrapUnexpectedError(error, {
125
+ message: `File parser '${detector.name}' failed`,
126
+ context: { scopeName, detector: detector.name, phase: 'fileParsing' }
127
+ })
189
128
  }
190
- return
129
+ detectorUsed = detector.name
130
+ break
191
131
  }
192
132
 
193
- log.debug(`Processing files with detector '${detectorUsed}'`)
133
+ if (!parsed) return
134
+
194
135
  const { fields = {}, files = {} } = parsed
195
136
 
196
137
  try {
197
- // Process each file field defined in schema
138
+ boundedLog.debug(`Processing files with detector '${detectorUsed}'`)
139
+ const fileNames = new Set(fileFields.map(config => config.field))
140
+ for (const name of Object.keys(files)) {
141
+ if (!fileNames.has(name)) throw new RestApiValidationError(`Unknown file field '${name}'`, { fields: [name] })
142
+ }
198
143
  for (const fieldConfig of fileFields) {
199
144
  const file = files[fieldConfig.field]
200
145
 
201
- if (!file) {
202
- if (fieldConfig.required) {
203
- throw new RestApiValidationError(
204
- `Required file field '${fieldConfig.field}' is missing`,
205
- {
206
- fields: [fieldConfig.field],
207
- violations: [{
208
- field: fieldConfig.field,
209
- message: 'This field is required'
210
- }]
211
- }
212
- )
213
- }
214
- continue
215
- }
146
+ if (!file) continue
216
147
 
217
148
  // Validate mime type
218
149
  if (fieldConfig.accepts[0] !== '*') {
@@ -240,7 +171,7 @@ export const FileHandlingPlugin = {
240
171
  }
241
172
 
242
173
  // Validate file size
243
- if (fieldConfig.maxSize) {
174
+ if (fieldConfig.maxSize !== undefined) {
244
175
  const maxBytes = parseSize(fieldConfig.maxSize)
245
176
  if (file.size > maxBytes) {
246
177
  throw new RestApiValidationError(
@@ -256,75 +187,53 @@ export const FileHandlingPlugin = {
256
187
  }
257
188
  }
258
189
 
259
- // Upload to storage
260
190
  if (!fieldConfig.storage) {
261
191
  throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
262
192
  }
263
193
 
264
194
  try {
265
195
  const storedUrl = await fieldConfig.storage.upload(file)
266
- fields[fieldConfig.field] = storedUrl
267
- trackUploadedFile(context, {
196
+ Object.defineProperty(fields, fieldConfig.field, { value: storedUrl, enumerable: true, writable: true, configurable: true })
197
+ const uploads = context.fileHandlingUploads ||= []
198
+ uploads.push({
268
199
  field: fieldConfig.field,
269
200
  storage: fieldConfig.storage,
270
- url: storedUrl
201
+ url: storedUrl,
202
+ transaction: context.transaction
271
203
  })
272
- log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
204
+ boundedLog.debug(`Uploaded file for field '${fieldConfig.field}'`)
273
205
  } catch (error) {
274
- if (isRestApiError(error)) throw error
275
- throw new RestApiValidationError(
276
- `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
277
- {
278
- fields: [fieldConfig.field],
279
- violations: [{
280
- field: fieldConfig.field,
281
- message: error.message
282
- }]
283
- }
284
- )
206
+ throw wrapUnexpectedError(error, {
207
+ message: `Failed to upload file for field '${fieldConfig.field}'`,
208
+ context: { scopeName, field: fieldConfig.field, phase: 'fileUpload' }
209
+ })
285
210
  }
286
211
  }
287
212
 
288
- // Replace inputRecord with processed data
289
- if (!params.inputRecord) {
290
- params.inputRecord = { data: { attributes: {} } }
291
- }
292
- if (!params.inputRecord.data) {
293
- params.inputRecord.data = { attributes: {} }
294
- }
295
- if (!params.inputRecord.data.attributes) {
296
- params.inputRecord.data.attributes = {}
297
- }
298
-
299
- // Merge fields into attributes
300
- Object.assign(params.inputRecord.data.attributes, fields)
213
+ // Work on the canonical document, including calls using the plain format.
214
+ context.inputRecord.data ||= { attributes: {} }
215
+ context.inputRecord.data.attributes = { ...context.inputRecord.data.attributes, ...fields }
301
216
  } finally {
302
- await cleanupParsedFiles(files)
217
+ await cleanupParsedFiles(files, context)
303
218
  }
304
219
  }
305
220
 
306
- /**
307
- * Hook into REST API methods to process files
308
- */
309
221
  addHook('beforeProcessing', 'processFiles', {}, async ({ context }) => {
310
- const method = context.method
311
- const scopeName = context.scopeName
312
- const params = context.params
313
-
314
- // Only process for mutation methods
315
- if (!['post', 'put', 'patch'].includes(method)) {
316
- return
317
- }
318
-
319
- // Process files if this scope has file fields
320
- await processFiles(scopeName, params, context)
222
+ if (!['post', 'put', 'patch'].includes(context.method)) return
223
+ await processFiles(context.scopeName, context.params, context)
321
224
  })
322
225
 
323
226
  addHook('afterRollback', 'cleanupUploadedFiles', {}, async ({ context }) => {
324
227
  await cleanupUploadedFiles(context)
325
228
  })
326
229
 
327
- log.info('File handling plugin initialized successfully')
230
+ addHook('afterCommit', 'releaseUploadedFiles', {}, ({ context }) => {
231
+ if (context.fileHandlingUploads) {
232
+ context.fileHandlingUploads = context.fileHandlingUploads.filter(upload => upload.transaction !== context.transaction)
233
+ }
234
+ })
235
+
236
+ boundedLog.info('File handling plugin initialized successfully')
328
237
  }
329
238
  }
330
239
 
@@ -334,6 +243,7 @@ export const FileHandlingPlugin = {
334
243
  * @returns {number} Size in bytes
335
244
  */
336
245
  function parseSize (size) {
246
+ if (typeof size === 'number' && Number.isFinite(size) && size >= 0) return size
337
247
  const units = {
338
248
  b: 1,
339
249
  kb: 1024,