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,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,48 +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'
4
+ import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../lib/error-context.js'
5
+ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
36
6
 
37
7
  export const FileHandlingPlugin = {
38
8
  name: 'file-handling',
39
9
  dependencies: ['rest-api'],
40
10
 
41
- install ({ addHook, helpers, scopes, log, vars, on, api }) {
42
- // Track which scopes have file fields
43
- const fileScopes = new Map() // scopeName -> fileField[]
11
+ install ({ addHook, scopes, log, api }) {
12
+ const boundedLog = createEnhancedLogger(log)
13
+ const fileFieldsBySchema = new WeakMap()
44
14
 
45
- // Registry of file detectors from various protocols
46
15
  const detectorRegistry = []
47
16
 
48
17
  /**
@@ -59,27 +28,31 @@ export const FileHandlingPlugin = {
59
28
  }
60
29
 
61
30
  detectorRegistry.push(detector)
62
- log.debug(`Registered file detector: ${detector.name}`)
31
+ boundedLog.debug(`Registered file detector: ${detector.name}`)
63
32
  }
64
33
 
65
- // Store detectors array for inspection
66
34
  api.rest.fileDetectors = detectorRegistry
67
35
 
68
- const trackUploadedFile = (context, upload) => {
69
- if (!context.fileHandlingUploads) {
70
- 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 })
71
45
  }
72
- context.fileHandlingUploads.push(upload)
73
46
  }
74
47
 
75
- const cleanupParsedFiles = async (files = {}) => {
76
- for (const file of Object.values(files)) {
48
+ const cleanupParsedFiles = async (files = {}, context) => {
49
+ for (const [field, file] of Object.entries(files)) {
77
50
  if (!file?.cleanup) continue
78
51
 
79
52
  try {
80
53
  await file.cleanup()
81
54
  } catch (error) {
82
- log.warn('Failed to cleanup temp file:', error)
55
+ await recordCleanupFailure(context, 'temporaryFileCleanup', field, error)
83
56
  }
84
57
  }
85
58
  }
@@ -88,129 +61,89 @@ export const FileHandlingPlugin = {
88
61
  const uploads = context.fileHandlingUploads
89
62
  if (!uploads || uploads.length === 0) return
90
63
 
91
- context.fileHandlingUploads = []
64
+ const remaining = []
92
65
  for (const upload of uploads) {
93
- 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)
94
68
  continue
95
69
  }
96
70
 
97
71
  try {
98
72
  await upload.storage.delete(upload.url)
99
73
  } catch (error) {
100
- log.warn(`Failed to cleanup uploaded file '${upload.url}' after rollback:`, error)
74
+ remaining.push(upload)
75
+ await recordCleanupFailure(context, 'uploadedFileCleanup', upload.field, error)
101
76
  }
102
77
  }
78
+ context.fileHandlingUploads = remaining
103
79
  }
104
80
 
105
- /**
106
- * Analyze a scope's schema to find file fields
107
- */
108
- const analyzeScopeSchema = (scopeName, scopeOptions) => {
109
- const schema = scopeOptions?.schema
110
- 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
111
86
 
112
87
  const fileFields = []
113
-
114
- // Look for fields with type: 'file'
115
- for (const [fieldName, fieldConfig] of Object.entries(schema)) {
116
- if (fieldConfig && fieldConfig.type === 'file') {
88
+ for (const [fieldName, fieldConfig] of Object.entries(schemaInfo.schemaStructure)) {
89
+ if (fieldConfig?.type === 'file') {
117
90
  fileFields.push({
118
91
  field: fieldName,
119
92
  storage: fieldConfig.storage,
120
93
  accepts: fieldConfig.accepts || ['*'],
121
- maxSize: fieldConfig.maxSize,
122
- required: fieldConfig.required || false
94
+ maxSize: fieldConfig.maxSize
123
95
  })
124
96
  }
125
97
  }
126
-
127
- if (fileFields.length > 0) {
128
- fileScopes.set(scopeName, fileFields)
129
- log.info(`Scope '${scopeName}' has ${fileFields.length} file field(s): ${fileFields.map(f => f.field).join(', ')}`)
130
- }
131
- }
132
-
133
- // Analyze existing scopes
134
- for (const [scopeName, scope] of Object.entries(scopes)) {
135
- if (scope._scopeOptions) {
136
- analyzeScopeSchema(scopeName, scope._scopeOptions)
137
- }
98
+ fileFieldsBySchema.set(schemaInfo, fileFields)
99
+ return fileFields
138
100
  }
139
101
 
140
- // Listen for new scopes being added
141
- addHook('scope:added', 'analyzeFileFields', {}, ({ context }) => {
142
- const { scopeName, scopeOptions } = context
143
- analyzeScopeSchema(scopeName, scopeOptions)
144
- })
145
-
146
- /**
147
- * Process files for a scope if it has file fields
148
- */
149
102
  const processFiles = async (scopeName, params, context) => {
150
- const fileFields = fileScopes.get(scopeName)
151
- if (!fileFields || fileFields.length === 0) {
152
- return // This scope doesn't have file fields
153
- }
103
+ const fileFields = getFileFields(scopeName)
104
+ if (fileFields.length === 0) return
154
105
 
155
- // Try each detector to see if we have files
156
106
  let parsed = null
157
107
  let detectorUsed = null
158
108
 
159
109
  for (const detector of detectorRegistry) {
110
+ let matched
160
111
  try {
161
- if (await detector.detect(params, context)) {
162
- log.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`)
163
- parsed = await detector.parse(params, context)
164
- detectorUsed = detector.name
165
- break
166
- }
112
+ matched = await detector.detect(params, context)
167
113
  } catch (error) {
168
- 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
+ })
169
118
  }
170
- }
171
-
172
- if (!parsed) {
173
- // No files detected - check if any were required
174
- for (const fieldConfig of fileFields) {
175
- if (fieldConfig.required && !params.inputRecord?.data?.attributes?.[fieldConfig.field]) {
176
- throw new RestApiValidationError(
177
- `Required file field '${fieldConfig.field}' is missing`,
178
- {
179
- fields: [fieldConfig.field],
180
- violations: [{
181
- field: fieldConfig.field,
182
- message: 'This field is required'
183
- }]
184
- }
185
- )
186
- }
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
+ })
187
128
  }
188
- return
129
+ detectorUsed = detector.name
130
+ break
189
131
  }
190
132
 
191
- log.debug(`Processing files with detector '${detectorUsed}'`)
133
+ if (!parsed) return
134
+
192
135
  const { fields = {}, files = {} } = parsed
193
136
 
194
137
  try {
195
- // 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
+ }
196
143
  for (const fieldConfig of fileFields) {
197
144
  const file = files[fieldConfig.field]
198
145
 
199
- if (!file) {
200
- if (fieldConfig.required) {
201
- throw new RestApiValidationError(
202
- `Required file field '${fieldConfig.field}' is missing`,
203
- {
204
- fields: [fieldConfig.field],
205
- violations: [{
206
- field: fieldConfig.field,
207
- message: 'This field is required'
208
- }]
209
- }
210
- )
211
- }
212
- continue
213
- }
146
+ if (!file) continue
214
147
 
215
148
  // Validate mime type
216
149
  if (fieldConfig.accepts[0] !== '*') {
@@ -238,7 +171,7 @@ export const FileHandlingPlugin = {
238
171
  }
239
172
 
240
173
  // Validate file size
241
- if (fieldConfig.maxSize) {
174
+ if (fieldConfig.maxSize !== undefined) {
242
175
  const maxBytes = parseSize(fieldConfig.maxSize)
243
176
  if (file.size > maxBytes) {
244
177
  throw new RestApiValidationError(
@@ -254,74 +187,53 @@ export const FileHandlingPlugin = {
254
187
  }
255
188
  }
256
189
 
257
- // Upload to storage
258
190
  if (!fieldConfig.storage) {
259
191
  throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
260
192
  }
261
193
 
262
194
  try {
263
195
  const storedUrl = await fieldConfig.storage.upload(file)
264
- fields[fieldConfig.field] = storedUrl
265
- trackUploadedFile(context, {
196
+ Object.defineProperty(fields, fieldConfig.field, { value: storedUrl, enumerable: true, writable: true, configurable: true })
197
+ const uploads = context.fileHandlingUploads ||= []
198
+ uploads.push({
266
199
  field: fieldConfig.field,
267
200
  storage: fieldConfig.storage,
268
- url: storedUrl
201
+ url: storedUrl,
202
+ transaction: context.transaction
269
203
  })
270
- log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
204
+ boundedLog.debug(`Uploaded file for field '${fieldConfig.field}'`)
271
205
  } catch (error) {
272
- throw new RestApiValidationError(
273
- `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
274
- {
275
- fields: [fieldConfig.field],
276
- violations: [{
277
- field: fieldConfig.field,
278
- message: error.message
279
- }]
280
- }
281
- )
206
+ throw wrapUnexpectedError(error, {
207
+ message: `Failed to upload file for field '${fieldConfig.field}'`,
208
+ context: { scopeName, field: fieldConfig.field, phase: 'fileUpload' }
209
+ })
282
210
  }
283
211
  }
284
212
 
285
- // Replace inputRecord with processed data
286
- if (!params.inputRecord) {
287
- params.inputRecord = { data: { attributes: {} } }
288
- }
289
- if (!params.inputRecord.data) {
290
- params.inputRecord.data = { attributes: {} }
291
- }
292
- if (!params.inputRecord.data.attributes) {
293
- params.inputRecord.data.attributes = {}
294
- }
295
-
296
- // Merge fields into attributes
297
- 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 }
298
216
  } finally {
299
- await cleanupParsedFiles(files)
217
+ await cleanupParsedFiles(files, context)
300
218
  }
301
219
  }
302
220
 
303
- /**
304
- * Hook into REST API methods to process files
305
- */
306
221
  addHook('beforeProcessing', 'processFiles', {}, async ({ context }) => {
307
- const method = context.method
308
- const scopeName = context.scopeName
309
- const params = context.params
310
-
311
- // Only process for mutation methods
312
- if (!['post', 'put', 'patch'].includes(method)) {
313
- return
314
- }
315
-
316
- // Process files if this scope has file fields
317
- await processFiles(scopeName, params, context)
222
+ if (!['post', 'put', 'patch'].includes(context.method)) return
223
+ await processFiles(context.scopeName, context.params, context)
318
224
  })
319
225
 
320
226
  addHook('afterRollback', 'cleanupUploadedFiles', {}, async ({ context }) => {
321
227
  await cleanupUploadedFiles(context)
322
228
  })
323
229
 
324
- 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')
325
237
  }
326
238
  }
327
239
 
@@ -331,6 +243,7 @@ export const FileHandlingPlugin = {
331
243
  * @returns {number} Size in bytes
332
244
  */
333
245
  function parseSize (size) {
246
+ if (typeof size === 'number' && Number.isFinite(size) && size >= 0) return size
334
247
  const units = {
335
248
  b: 1,
336
249
  kb: 1024,