json-rest-api 1.0.24 → 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 +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- 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_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/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- 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 +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- 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/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 +6 -1
- 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/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +362 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- 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 +1 -0
- package/tests/test-development-guide.md +5 -5
|
@@ -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
|
}
|
|
@@ -9,6 +9,21 @@ const clone = (value) => {
|
|
|
9
9
|
|
|
10
10
|
const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
|
|
11
11
|
|
|
12
|
+
const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
|
|
13
|
+
if (explicitIdProperty) return explicitIdProperty
|
|
14
|
+
if (schema.id) return 'id'
|
|
15
|
+
|
|
16
|
+
const idFields = Object.entries(schema)
|
|
17
|
+
.filter(([, fieldDef]) => (
|
|
18
|
+
fieldDef?.type === 'id' &&
|
|
19
|
+
!fieldDef.belongsTo &&
|
|
20
|
+
!fieldDef.belongsToPolymorphic
|
|
21
|
+
))
|
|
22
|
+
.map(([fieldName]) => fieldName)
|
|
23
|
+
|
|
24
|
+
return idFields.length === 1 ? idFields[0] : 'id'
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
|
|
13
28
|
if (fieldDef.as) return fieldDef.as
|
|
14
29
|
if (fieldName.endsWith('_id')) {
|
|
@@ -305,6 +320,7 @@ export class AnyapiRegistry {
|
|
|
305
320
|
|
|
306
321
|
async #register (definition, externalTrx) {
|
|
307
322
|
const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition
|
|
323
|
+
const idProperty = resolveIdProperty(schema, definition.idProperty)
|
|
308
324
|
const trx = externalTrx || await this.knex.transaction()
|
|
309
325
|
const managed = !externalTrx
|
|
310
326
|
|
|
@@ -520,7 +536,7 @@ export class AnyapiRegistry {
|
|
|
520
536
|
await trx('any_relationship_configs').insert(relationshipInserts)
|
|
521
537
|
}
|
|
522
538
|
|
|
523
|
-
const descriptor = await this.#loadDescriptor(tenant, resource, trx)
|
|
539
|
+
const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
|
|
524
540
|
|
|
525
541
|
if (managed) {
|
|
526
542
|
await trx.commit()
|
|
@@ -535,7 +551,7 @@ export class AnyapiRegistry {
|
|
|
535
551
|
}
|
|
536
552
|
}
|
|
537
553
|
|
|
538
|
-
async #loadDescriptor (tenant, resource, trx) {
|
|
554
|
+
async #loadDescriptor (tenant, resource, trx, options = {}) {
|
|
539
555
|
const query = (trx || this.knex)('any_resource_configs')
|
|
540
556
|
.where({ tenant_id: tenant, resource })
|
|
541
557
|
.first()
|
|
@@ -568,6 +584,7 @@ export class AnyapiRegistry {
|
|
|
568
584
|
slotState,
|
|
569
585
|
fieldRows,
|
|
570
586
|
relationshipRows,
|
|
587
|
+
idProperty: options.idProperty,
|
|
571
588
|
})
|
|
572
589
|
|
|
573
590
|
const canonicalFieldMap = {}
|
|
@@ -589,7 +606,7 @@ export class AnyapiRegistry {
|
|
|
589
606
|
return descriptor
|
|
590
607
|
}
|
|
591
608
|
|
|
592
|
-
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows }) {
|
|
609
|
+
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
|
|
593
610
|
const fields = {}
|
|
594
611
|
const reverseAttributes = {}
|
|
595
612
|
const belongsTo = {}
|
|
@@ -671,6 +688,7 @@ export class AnyapiRegistry {
|
|
|
671
688
|
return {
|
|
672
689
|
tenant,
|
|
673
690
|
resource,
|
|
691
|
+
idProperty: resolveIdProperty(schema, idProperty),
|
|
674
692
|
schema,
|
|
675
693
|
relationships,
|
|
676
694
|
canonical: DEFAULT_CANONICAL_CONFIG,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RestApiValidationError } from '../../../../../lib/rest-api-errors.js'
|
|
2
|
+
import { normalizeDateValue } from '../../querying-writing/database-value-normalizers.js'
|
|
2
3
|
|
|
3
4
|
export const normalizeId = (value) => (value === null || value === undefined ? null : String(value))
|
|
4
5
|
|
|
@@ -21,7 +22,8 @@ export const findSchemaFieldByAlias = (descriptor, alias) => {
|
|
|
21
22
|
|
|
22
23
|
export const resolveFieldInfo = (descriptor, field) => {
|
|
23
24
|
if (!descriptor) return null
|
|
24
|
-
|
|
25
|
+
const idProperty = descriptor.idProperty || 'id'
|
|
26
|
+
if (field === 'id' || field === idProperty) {
|
|
25
27
|
return {
|
|
26
28
|
column: descriptor?.canonical?.logicalIdColumn || 'logical_id',
|
|
27
29
|
definition: { type: 'id' }
|
|
@@ -90,6 +92,10 @@ export const coerceValueForDefinition = (value, definition, { isRelationship } =
|
|
|
90
92
|
return String(value)
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
if (['date', 'dateTime', 'time'].includes(type)) {
|
|
96
|
+
return normalizeDateValue(value, type)
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
return value
|
|
94
100
|
}
|
|
95
101
|
|
|
@@ -350,37 +350,42 @@ export const toJsonApiRecord = (scope, record, scopeName) => {
|
|
|
350
350
|
export const buildJsonApiResponse = async (scope, records, included = [], isSingle = false, scopeName, context) => {
|
|
351
351
|
const {
|
|
352
352
|
vars: {
|
|
353
|
-
schemaInfo
|
|
353
|
+
schemaInfo
|
|
354
354
|
}
|
|
355
355
|
} = scope
|
|
356
356
|
|
|
357
|
-
const
|
|
357
|
+
const {
|
|
358
|
+
schemaInstance,
|
|
359
|
+
schemaRelationships: relationships
|
|
360
|
+
} = schemaInfo
|
|
358
361
|
|
|
359
362
|
const schemaStructure = getSchemaStructure(schemaInstance)
|
|
360
363
|
|
|
361
364
|
const processedRecords = records.map(record => {
|
|
362
365
|
const { [RELATIONSHIPS_KEY]: _relationships, ...cleanRecord } = record
|
|
366
|
+
const logicalRecord = translateRecordFromStorage(cleanRecord, schemaInfo)
|
|
363
367
|
const jsonApiRecord = toJsonApiRecord(scope, cleanRecord, scopeName)
|
|
368
|
+
const resourceId = jsonApiRecord.id
|
|
364
369
|
|
|
365
370
|
if (_relationships) {
|
|
366
371
|
jsonApiRecord.relationships = _relationships
|
|
367
372
|
}
|
|
368
373
|
|
|
369
374
|
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
370
|
-
if (fieldDef.belongsTo && fieldDef.as && fieldName in
|
|
375
|
+
if (fieldDef.belongsTo && fieldDef.as && fieldName in logicalRecord) {
|
|
371
376
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
372
377
|
if (!jsonApiRecord.relationships[fieldDef.as]) {
|
|
373
|
-
if (
|
|
378
|
+
if (logicalRecord[fieldName] !== null && logicalRecord[fieldName] !== undefined) {
|
|
374
379
|
const relationshipObject = {
|
|
375
380
|
data: {
|
|
376
381
|
type: fieldDef.belongsTo,
|
|
377
|
-
id: String(
|
|
382
|
+
id: String(logicalRecord[fieldName])
|
|
378
383
|
}
|
|
379
384
|
}
|
|
380
385
|
|
|
381
386
|
relationshipObject.links = {
|
|
382
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
383
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
387
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
388
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -390,8 +395,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
390
395
|
}
|
|
391
396
|
|
|
392
397
|
relationshipObject.links = {
|
|
393
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
394
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
398
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, true),
|
|
399
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, fieldDef.as, false)
|
|
395
400
|
}
|
|
396
401
|
|
|
397
402
|
jsonApiRecord.relationships[fieldDef.as] = relationshipObject
|
|
@@ -402,8 +407,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
402
407
|
|
|
403
408
|
Object.entries(relationships || {}).forEach(([relName, relDef]) => {
|
|
404
409
|
if (relDef.belongsToPolymorphic) {
|
|
405
|
-
const typeValue =
|
|
406
|
-
const idValue =
|
|
410
|
+
const typeValue = logicalRecord[relDef.belongsToPolymorphic.typeField]
|
|
411
|
+
const idValue = logicalRecord[relDef.belongsToPolymorphic.idField]
|
|
407
412
|
|
|
408
413
|
if (typeValue && idValue) {
|
|
409
414
|
jsonApiRecord.relationships = jsonApiRecord.relationships || {}
|
|
@@ -415,8 +420,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
415
420
|
}
|
|
416
421
|
|
|
417
422
|
relationshipObject.links = {
|
|
418
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
419
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
423
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
424
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
420
425
|
}
|
|
421
426
|
|
|
422
427
|
jsonApiRecord.relationships[relName] = relationshipObject
|
|
@@ -427,8 +432,8 @@ export const buildJsonApiResponse = async (scope, records, included = [], isSing
|
|
|
427
432
|
}
|
|
428
433
|
|
|
429
434
|
relationshipObject.links = {
|
|
430
|
-
self: buildRelationshipUrl(context, scope, scopeName,
|
|
431
|
-
related: buildRelationshipUrl(context, scope, scopeName,
|
|
435
|
+
self: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, true),
|
|
436
|
+
related: buildRelationshipUrl(context, scope, scopeName, resourceId, relName, false)
|
|
432
437
|
}
|
|
433
438
|
|
|
434
439
|
jsonApiRecord.relationships[relName] = relationshipObject
|