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.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +56 -6
- package/tests/test-development-guide.md +5 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RestApiValidationError
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
resolve()
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
file.on('error',
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
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
|
-
|
|
162
|
-
if (fieldConfig.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
})
|
|
255
|
+
}
|
|
187
256
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
`
|
|
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:
|
|
278
|
+
message: error.message
|
|
213
279
|
}]
|
|
214
280
|
}
|
|
215
281
|
)
|
|
216
282
|
}
|
|
217
283
|
}
|
|
218
284
|
|
|
219
|
-
//
|
|
220
|
-
if (!
|
|
221
|
-
|
|
285
|
+
// Replace inputRecord with processed data
|
|
286
|
+
if (!params.inputRecord) {
|
|
287
|
+
params.inputRecord = { data: { attributes: {} } }
|
|
222
288
|
}
|
|
223
|
-
|
|
224
|
-
|
|
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
|
}
|