json-rest-api 1.0.23 → 1.0.25

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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -1,4 +1,4 @@
1
- import { RestApiValidationError, RestApiResourceError } from '../../lib/rest-api-errors.js'
1
+ import { RestApiValidationError } from '../../lib/rest-api-errors.js'
2
2
 
3
3
  export const BulkOperationsPlugin = {
4
4
  name: 'bulk-operations',
@@ -193,11 +193,12 @@ export const BulkOperationsPlugin = {
193
193
  transaction
194
194
  }, recordContext)
195
195
 
196
- // If patch doesn't return full record, fetch it
197
- let resultData = result.data
198
- if (!resultData && result.id) {
196
+ // If patch doesn't return a full record, fetch the updated record.
197
+ const resultId = result?.data?.id || result?.id || operation.id
198
+ let resultData = result?.data
199
+ if (!resultData && resultId) {
199
200
  const fetchedRecord = await scope.get({
200
- id: result.id,
201
+ id: resultId,
201
202
  transaction
202
203
  }, recordContext)
203
204
  resultData = fetchedRecord.data
@@ -353,41 +354,33 @@ export const BulkOperationsPlugin = {
353
354
  // Create route handlers
354
355
  const createBulkRouteHandler = (method) => {
355
356
  return async ({ context, body, query }) => {
356
- try {
357
- // Parse query params for atomic mode override
358
- const atomic = query?.atomic !== undefined
359
- ? query.atomic === 'true'
360
- : defaultAtomic
361
-
362
- let params
363
- if (method === 'bulkPost') {
364
- // For bulk create, expect array in data field (JSON:API style)
365
- params = {
366
- inputRecords: body?.data || body,
367
- atomic
368
- }
369
- } else if (method === 'bulkPatch') {
370
- // For bulk update, expect operations array
371
- params = {
372
- operations: body?.operations || body,
373
- atomic
374
- }
375
- } else if (method === 'bulkDelete') {
376
- // For bulk delete, expect IDs array
377
- params = {
378
- ids: body?.data || body?.ids || body,
379
- atomic
380
- }
357
+ // Parse query params for atomic mode override
358
+ const atomic = query?.atomic !== undefined
359
+ ? query.atomic === 'true'
360
+ : defaultAtomic
361
+
362
+ let params
363
+ if (method === 'bulkPost') {
364
+ // For bulk create, expect array in data field (JSON:API style)
365
+ params = {
366
+ inputRecords: body?.data || body,
367
+ atomic
368
+ }
369
+ } else if (method === 'bulkPatch') {
370
+ // For bulk update, expect operations array
371
+ params = {
372
+ operations: body?.operations || body,
373
+ atomic
374
+ }
375
+ } else if (method === 'bulkDelete') {
376
+ // For bulk delete, expect IDs array
377
+ params = {
378
+ ids: body?.data || body?.ids || body,
379
+ atomic
381
380
  }
382
-
383
- // Call the scope method
384
- const result = await api.scopes[scopeName][method](params)
385
-
386
- return result
387
- } catch (error) {
388
- // Let transport plugin handle error mapping
389
- throw error
390
381
  }
382
+
383
+ return await api.scopes[scopeName][method](params)
391
384
  }
392
385
  }
393
386
 
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import { requirePackage } from 'hooked-api'
18
- import { parseJsonApiQuery } from '../lib/querying-writing/connectors-query-parser.js'
19
18
  import { createContext } from './lib/request-helpers.js'
20
19
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
21
20
  import {
@@ -67,6 +66,7 @@ export const ExpressPlugin = {
67
66
  // Get mountPath from options (this is now a transport concern)
68
67
  const mountPath = expressOptions.mountPath || ''
69
68
  const basePath = mountPath // Keep basePath for internal use
69
+ const publicBaseUrl = expressOptions.publicBaseUrl || ''
70
70
  const strictContentType = expressOptions.strictContentType !== false
71
71
  const requestSizeLimit = expressOptions.requestSizeLimit || '1mb'
72
72
 
@@ -74,7 +74,8 @@ export const ExpressPlugin = {
74
74
  vars.transport = {
75
75
  type: 'express',
76
76
  matchAll: '*', // Express wildcard pattern for matching all routes
77
- mountPath // Transport-specific mount path
77
+ mountPath, // Transport-specific mount path
78
+ publicBaseUrl
78
79
  }
79
80
 
80
81
  // Register file detector if enabled
@@ -143,6 +144,7 @@ export const ExpressPlugin = {
143
144
  reply: res,
144
145
  source: 'express',
145
146
  mountPath: basePath,
147
+ publicBaseUrl,
146
148
  requestData,
147
149
  createContext,
148
150
  urlPrefixOverride: req.urlPrefixOverride
@@ -241,6 +243,7 @@ export const ExpressPlugin = {
241
243
  reply: res,
242
244
  source: 'express',
243
245
  mountPath: basePath,
246
+ publicBaseUrl,
244
247
  requestData,
245
248
  createContext,
246
249
  urlPrefixOverride: req.urlPrefixOverride
@@ -263,6 +266,7 @@ export const ExpressPlugin = {
263
266
  routeMeta,
264
267
  helpers,
265
268
  mountPath: basePath,
269
+ publicBaseUrl,
266
270
  runHooks
267
271
  })
268
272
  res.set(outcome.headers)
@@ -337,11 +341,6 @@ export const ExpressPlugin = {
337
341
  router[method.toLowerCase()](path, ...beforeMiddleware, expressHandler)
338
342
 
339
343
  // Debug logging for route registration
340
-
341
- // Check if the route was actually added
342
- if (router.stack) {
343
- const lastRoute = router.stack[router.stack.length - 1]
344
- }
345
344
  }
346
345
  } catch (routeError) {
347
346
  log.error('[EXPRESS DEBUG] Error creating route:', {
@@ -70,12 +70,14 @@ export const FastifyPlugin = {
70
70
  api.http.fastify = { app }
71
71
 
72
72
  const mountPath = fastifyOptions.mountPath || ''
73
+ const publicBaseUrl = fastifyOptions.publicBaseUrl || ''
73
74
  const strictContentType = fastifyOptions.strictContentType !== false
74
75
 
75
76
  vars.transport = {
76
77
  type: 'fastify',
77
78
  matchAll: '*',
78
- mountPath
79
+ mountPath,
80
+ publicBaseUrl
79
81
  }
80
82
 
81
83
  registerVendorJsonParser(app)
@@ -140,6 +142,7 @@ export const FastifyPlugin = {
140
142
  reply,
141
143
  source: 'fastify',
142
144
  mountPath,
145
+ publicBaseUrl,
143
146
  requestData,
144
147
  createContext
145
148
  })
@@ -174,6 +177,7 @@ export const FastifyPlugin = {
174
177
  routeMeta,
175
178
  helpers,
176
179
  mountPath,
180
+ publicBaseUrl,
177
181
  runHooks
178
182
  })
179
183
 
@@ -101,14 +101,14 @@ export function createBusboyDetector (options = {}) {
101
101
  const chunks = []
102
102
  let size = 0
103
103
 
104
- const filePromise = new Promise((fileResolve, fileReject) => {
104
+ const filePromise = new Promise((resolve, reject) => {
105
105
  file.on('data', (chunk) => {
106
106
  chunks.push(chunk)
107
107
  size += chunk.length
108
108
  })
109
109
 
110
110
  file.on('limit', () => {
111
- fileReject(new Error(`File size limit exceeded for field '${fieldname}'`))
111
+ reject(new Error(`File size limit exceeded for field '${fieldname}'`))
112
112
  })
113
113
 
114
114
  file.on('end', () => {
@@ -119,10 +119,10 @@ export function createBusboyDetector (options = {}) {
119
119
  size,
120
120
  data: Buffer.concat(chunks)
121
121
  }
122
- fileResolve()
122
+ resolve()
123
123
  })
124
124
 
125
- file.on('error', fileReject)
125
+ file.on('error', reject)
126
126
  })
127
127
 
128
128
  filePromises.push(filePromise)
@@ -43,6 +43,7 @@ export function createConnectorContext ({
43
43
  reply,
44
44
  source,
45
45
  mountPath = '',
46
+ publicBaseUrl = '',
46
47
  requestData,
47
48
  createContext,
48
49
  urlPrefixOverride
@@ -55,8 +56,7 @@ export function createConnectorContext ({
55
56
 
56
57
  context.urlPrefix = getUrlPrefix(
57
58
  context,
58
- { vars: { transport: { mountPath } } },
59
- request
59
+ { vars: { transport: { mountPath, publicBaseUrl } } }
60
60
  )
61
61
 
62
62
  const transportData = buildTransportData(requestData)
@@ -113,7 +113,8 @@ export function getConnectorLocationHeader ({
113
113
  context,
114
114
  routeMeta,
115
115
  helpers,
116
- mountPath = ''
116
+ mountPath = '',
117
+ publicBaseUrl = ''
117
118
  }) {
118
119
  if (String(method || '').toUpperCase() !== 'POST') return null
119
120
  if (!context?.id || !routeMeta?.scopeName || !helpers?.getLocation) return null
@@ -123,7 +124,9 @@ export function getConnectorLocationHeader ({
123
124
  id: context.id
124
125
  })
125
126
 
126
- const baseUrl = context.urlPrefix || mountPath
127
+ const baseUrl = getUrlPrefix(context, {
128
+ vars: { transport: { mountPath, publicBaseUrl } }
129
+ })
127
130
  return `${baseUrl}${location}`
128
131
  }
129
132
 
@@ -139,6 +142,7 @@ export async function executeConnectorRoute ({
139
142
  routeMeta,
140
143
  helpers,
141
144
  mountPath = '',
145
+ publicBaseUrl = '',
142
146
  runHooks
143
147
  }) {
144
148
  const result = await handler({
@@ -150,6 +154,14 @@ export async function executeConnectorRoute ({
150
154
  })
151
155
 
152
156
  const status = determineResponseStatus(method, result)
157
+ const location = getConnectorLocationHeader({
158
+ method,
159
+ context,
160
+ routeMeta,
161
+ helpers,
162
+ mountPath,
163
+ publicBaseUrl
164
+ })
153
165
  const transportHeaders = await applyTransportResponseLifecycle({
154
166
  context,
155
167
  transportData,
@@ -165,13 +177,7 @@ export async function executeConnectorRoute ({
165
177
  ...(result?.headers || {}),
166
178
  ...transportHeaders
167
179
  },
168
- location: getConnectorLocationHeader({
169
- method,
170
- context,
171
- routeMeta,
172
- helpers,
173
- mountPath
174
- }),
180
+ location,
175
181
  result
176
182
  }
177
183
  }
@@ -1,70 +1,11 @@
1
1
  import { getRequestContracts } from '../../lib/querying-writing/request-contracts.js'
2
2
 
3
- const STRING_OR_NUMBER_SCHEMA = {
4
- anyOf: [
5
- { type: 'string' },
6
- { type: 'number' }
7
- ]
8
- }
9
-
10
- function buildResourceIdentifierSchema () {
11
- return {
12
- type: 'object',
13
- additionalProperties: false,
14
- required: ['type', 'id'],
15
- properties: {
16
- type: { type: 'string', minLength: 1 },
17
- id: STRING_OR_NUMBER_SCHEMA
18
- }
19
- }
20
- }
21
-
22
- function buildRelationshipValueSchema (operation) {
23
- if (operation === 'patchRelationship') {
24
- return {
25
- anyOf: [
26
- { type: 'null' },
27
- buildResourceIdentifierSchema(),
28
- {
29
- type: 'array',
30
- items: buildResourceIdentifierSchema()
31
- }
32
- ]
33
- }
34
- }
35
-
36
- return {
37
- type: 'array',
38
- items: buildResourceIdentifierSchema()
39
- }
40
- }
41
-
42
- function buildRelationshipRouteBodySchema (operation) {
43
- if (!['postRelationship', 'patchRelationship', 'deleteRelationship'].includes(operation)) {
44
- return null
45
- }
46
-
47
- return {
48
- type: 'object',
49
- additionalProperties: false,
50
- required: ['data'],
51
- properties: {
52
- data: buildRelationshipValueSchema(operation)
53
- }
54
- }
55
- }
56
-
57
3
  export function buildTransportRouteSchema ({ routeMeta, api }) {
58
4
  if (!routeMeta) {
59
5
  return null
60
6
  }
61
7
 
62
- if (routeMeta.kind === 'relationship') {
63
- const relationshipBodySchema = buildRelationshipRouteBodySchema(routeMeta.operation)
64
- return relationshipBodySchema ? { body: relationshipBodySchema } : null
65
- }
66
-
67
- if (routeMeta.kind !== 'resource' || !['post', 'put', 'patch'].includes(routeMeta.operation)) {
8
+ if (!['resource', 'relationship'].includes(routeMeta.kind)) {
68
9
  return null
69
10
  }
70
11
 
@@ -86,6 +27,17 @@ export function buildTransportRouteSchema ({ routeMeta, api }) {
86
27
  return null
87
28
  }
88
29
 
30
+ if (routeMeta.kind === 'resource' && !['post', 'put', 'patch'].includes(routeMeta.operation)) {
31
+ return null
32
+ }
33
+
34
+ if (
35
+ routeMeta.kind === 'relationship' &&
36
+ !['postRelationship', 'patchRelationship', 'deleteRelationship'].includes(routeMeta.operation)
37
+ ) {
38
+ return null
39
+ }
40
+
89
41
  return {
90
42
  body: contract.schema.toJsonSchema({ mode: contract.mode, additionalProperties: false })
91
43
  }
@@ -65,6 +65,43 @@ export const FileHandlingPlugin = {
65
65
  // Store detectors array for inspection
66
66
  api.rest.fileDetectors = detectorRegistry
67
67
 
68
+ const trackUploadedFile = (context, upload) => {
69
+ if (!context.fileHandlingUploads) {
70
+ context.fileHandlingUploads = []
71
+ }
72
+ context.fileHandlingUploads.push(upload)
73
+ }
74
+
75
+ const cleanupParsedFiles = async (files = {}) => {
76
+ for (const file of Object.values(files)) {
77
+ if (!file?.cleanup) continue
78
+
79
+ try {
80
+ await file.cleanup()
81
+ } catch (error) {
82
+ log.warn('Failed to cleanup temp file:', error)
83
+ }
84
+ }
85
+ }
86
+
87
+ const cleanupUploadedFiles = async (context) => {
88
+ const uploads = context.fileHandlingUploads
89
+ if (!uploads || uploads.length === 0) return
90
+
91
+ context.fileHandlingUploads = []
92
+ for (const upload of uploads) {
93
+ if (!upload?.url || typeof upload.storage?.delete !== 'function') {
94
+ continue
95
+ }
96
+
97
+ try {
98
+ await upload.storage.delete(upload.url)
99
+ } catch (error) {
100
+ log.warn(`Failed to cleanup uploaded file '${upload.url}' after rollback:`, error)
101
+ }
102
+ }
103
+ }
104
+
68
105
  /**
69
106
  * Analyze a scope's schema to find file fields
70
107
  */
@@ -152,125 +189,114 @@ export const FileHandlingPlugin = {
152
189
  }
153
190
 
154
191
  log.debug(`Processing files with detector '${detectorUsed}'`)
155
- const { fields, files } = parsed
192
+ const { fields = {}, files = {} } = parsed
156
193
 
157
- // Process each file field defined in schema
158
- for (const fieldConfig of fileFields) {
159
- const file = files[fieldConfig.field]
194
+ try {
195
+ // Process each file field defined in schema
196
+ for (const fieldConfig of fileFields) {
197
+ const file = files[fieldConfig.field]
198
+
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
+ }
160
214
 
161
- if (!file) {
162
- if (fieldConfig.required) {
163
- throw new RestApiValidationError(
164
- `Required file field '${fieldConfig.field}' is missing`,
165
- {
166
- fields: [fieldConfig.field],
167
- violations: [{
168
- field: fieldConfig.field,
169
- message: 'This field is required'
170
- }]
215
+ // Validate mime type
216
+ if (fieldConfig.accepts[0] !== '*') {
217
+ const acceptable = fieldConfig.accepts.some(pattern => {
218
+ if (pattern.endsWith('/*')) {
219
+ // e.g., 'image/*'
220
+ const prefix = pattern.slice(0, -2)
221
+ return file.mimetype.startsWith(prefix + '/')
171
222
  }
172
- )
223
+ return file.mimetype === pattern
224
+ })
225
+
226
+ if (!acceptable) {
227
+ throw new RestApiValidationError(
228
+ `Invalid file type for field '${fieldConfig.field}'`,
229
+ {
230
+ fields: [fieldConfig.field],
231
+ violations: [{
232
+ field: fieldConfig.field,
233
+ message: `Expected ${fieldConfig.accepts.join(' or ')}, got ${file.mimetype}`
234
+ }]
235
+ }
236
+ )
237
+ }
173
238
  }
174
- continue
175
- }
176
239
 
177
- // Validate mime type
178
- if (fieldConfig.accepts[0] !== '*') {
179
- const acceptable = fieldConfig.accepts.some(pattern => {
180
- if (pattern.endsWith('/*')) {
181
- // e.g., 'image/*'
182
- const prefix = pattern.slice(0, -2)
183
- return file.mimetype.startsWith(prefix + '/')
240
+ // Validate file size
241
+ if (fieldConfig.maxSize) {
242
+ const maxBytes = parseSize(fieldConfig.maxSize)
243
+ if (file.size > maxBytes) {
244
+ throw new RestApiValidationError(
245
+ `File too large for field '${fieldConfig.field}'`,
246
+ {
247
+ fields: [fieldConfig.field],
248
+ violations: [{
249
+ field: fieldConfig.field,
250
+ message: `Maximum size is ${fieldConfig.maxSize}, got ${formatSize(file.size)}`
251
+ }]
252
+ }
253
+ )
184
254
  }
185
- return file.mimetype === pattern
186
- })
255
+ }
187
256
 
188
- if (!acceptable) {
189
- throw new RestApiValidationError(
190
- `Invalid file type for field '${fieldConfig.field}'`,
191
- {
192
- fields: [fieldConfig.field],
193
- violations: [{
194
- field: fieldConfig.field,
195
- message: `Expected ${fieldConfig.accepts.join(' or ')}, got ${file.mimetype}`
196
- }]
197
- }
198
- )
257
+ // Upload to storage
258
+ if (!fieldConfig.storage) {
259
+ throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
199
260
  }
200
- }
201
261
 
202
- // Validate file size
203
- if (fieldConfig.maxSize) {
204
- const maxBytes = parseSize(fieldConfig.maxSize)
205
- if (file.size > maxBytes) {
262
+ try {
263
+ const storedUrl = await fieldConfig.storage.upload(file)
264
+ fields[fieldConfig.field] = storedUrl
265
+ trackUploadedFile(context, {
266
+ field: fieldConfig.field,
267
+ storage: fieldConfig.storage,
268
+ url: storedUrl
269
+ })
270
+ log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
271
+ } catch (error) {
206
272
  throw new RestApiValidationError(
207
- `File too large for field '${fieldConfig.field}'`,
273
+ `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
208
274
  {
209
275
  fields: [fieldConfig.field],
210
276
  violations: [{
211
277
  field: fieldConfig.field,
212
- message: `Maximum size is ${fieldConfig.maxSize}, got ${formatSize(file.size)}`
278
+ message: error.message
213
279
  }]
214
280
  }
215
281
  )
216
282
  }
217
283
  }
218
284
 
219
- // Upload to storage
220
- if (!fieldConfig.storage) {
221
- throw new Error(`No storage configured for file field '${fieldConfig.field}'`)
285
+ // Replace inputRecord with processed data
286
+ if (!params.inputRecord) {
287
+ params.inputRecord = { data: { attributes: {} } }
222
288
  }
223
-
224
- try {
225
- const storedUrl = await fieldConfig.storage.upload(file)
226
- fields[fieldConfig.field] = storedUrl
227
- log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`)
228
- } catch (error) {
229
- // Cleanup if file has cleanup function
230
- if (file.cleanup) {
231
- try {
232
- await file.cleanup()
233
- } catch (cleanupError) {
234
- log.warn('Failed to cleanup file after upload error:', cleanupError)
235
- }
236
- }
237
-
238
- throw new RestApiValidationError(
239
- `Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
240
- {
241
- fields: [fieldConfig.field],
242
- violations: [{
243
- field: fieldConfig.field,
244
- message: error.message
245
- }]
246
- }
247
- )
289
+ if (!params.inputRecord.data) {
290
+ params.inputRecord.data = { attributes: {} }
248
291
  }
249
- }
250
-
251
- // Replace inputRecord with processed data
252
- if (!params.inputRecord) {
253
- params.inputRecord = { data: { attributes: {} } }
254
- }
255
- if (!params.inputRecord.data) {
256
- params.inputRecord.data = { attributes: {} }
257
- }
258
- if (!params.inputRecord.data.attributes) {
259
- params.inputRecord.data.attributes = {}
260
- }
261
-
262
- // Merge fields into attributes
263
- Object.assign(params.inputRecord.data.attributes, fields)
264
-
265
- // Cleanup any remaining temp files
266
- for (const file of Object.values(files)) {
267
- if (file.cleanup) {
268
- try {
269
- await file.cleanup()
270
- } catch (error) {
271
- log.warn('Failed to cleanup temp file:', error)
272
- }
292
+ if (!params.inputRecord.data.attributes) {
293
+ params.inputRecord.data.attributes = {}
273
294
  }
295
+
296
+ // Merge fields into attributes
297
+ Object.assign(params.inputRecord.data.attributes, fields)
298
+ } finally {
299
+ await cleanupParsedFiles(files)
274
300
  }
275
301
  }
276
302
 
@@ -291,6 +317,10 @@ export const FileHandlingPlugin = {
291
317
  await processFiles(scopeName, params, context)
292
318
  })
293
319
 
320
+ addHook('afterRollback', 'cleanupUploadedFiles', {}, async ({ context }) => {
321
+ await cleanupUploadedFiles(context)
322
+ })
323
+
294
324
  log.info('File handling plugin initialized successfully')
295
325
  }
296
326
  }