json-rest-api 1.0.29 → 2.1.0

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