json-rest-api 1.0.21 → 1.0.23
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/CLAUDE.md +1 -2
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
- package/docs/API.md +51 -14
- package/docs/COMPARISON.md +2 -2
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
- package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
- package/docs/GUIDE/index.md +18 -9
- package/docs/QUICKSTART.md +5 -1
- package/docs/index.md +1 -1
- package/index.js +3 -2
- package/package.json +4 -4
- package/plugins/core/connectors/express-plugin.js +97 -184
- package/plugins/core/connectors/fastify-plugin.js +206 -0
- package/plugins/core/connectors/lib/connector-core.js +195 -0
- package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
- package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
- package/plugins/core/lib/dbIntrospection.js +1078 -0
- package/plugins/core/lib/dbTablesOperations.js +1104 -160
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
- package/plugins/core/lib/querying/knex-process-includes.js +3 -4
- package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
- package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
- package/plugins/core/lib/querying/query-builder-utils.js +19 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
- package/plugins/core/lib/querying/sort-helpers.js +33 -0
- package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
- package/plugins/core/lib/querying/url-helpers.js +8 -2
- package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
- package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
- package/plugins/core/lib/storage/storage-adapter.js +79 -2
- package/plugins/core/lib/storage/storage-mapping.js +204 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
- package/plugins/core/rest-api-autofilter-plugin.js +414 -0
- package/plugins/core/rest-api-knex-plugin.js +249 -211
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +121 -83
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get.js +23 -14
- package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
- package/plugins/core/rest-api-plugin-methods/post.js +13 -8
- package/plugins/core/rest-api-plugin-methods/put.js +45 -21
- package/plugins/core/rest-api-plugin-methods/query.js +15 -53
- package/plugins/core/rest-api-plugin.js +2 -0
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/plugins/core/rest-api-query-projections-plugin.js +130 -0
- package/plugins/core/socketio-plugin.js +1 -3
- package/tests/autofilter.test.js +463 -0
- package/tests/custom-id-simplified-post.test.js +75 -0
- package/tests/custom-idproperty-relationships.test.js +33 -0
- package/tests/db-introspection.test.js +518 -0
- package/tests/db-table-operations.test.js +781 -0
- package/tests/fastify-plugin.test.js +141 -0
- package/tests/field-getters.test.js +1 -2
- package/tests/field-setters.test.js +0 -4
- package/tests/fixtures/api-configs.js +188 -219
- package/tests/helpers/fake-fastify.js +99 -0
- package/tests/http-connectors-parity.test.js +235 -0
- package/tests/projected-fields.test.js +319 -0
- package/tests/relationships.test.js +3 -3
- package/tests/searchschema-knex-behavior.test.js +187 -0
- package/tests/searchschema-merge.test.js +21 -69
- package/tests/storage-mapping.test.js +210 -0
- package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
- package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
- package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
- package/plugins/core/rest-api-access.js +0 -459
- package/plugins/core/rest-api-multihome-plugin.js +0 -314
- package/tests/multihome.test.js +0 -553
- package/tests/multihome.test.js.bak +0 -550
- package/tests/ownership-optout.test.js +0 -76
- package/tests/rest-api-access.test.js +0 -206
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { addType, addValidator, createSchema } from 'json-rest-schema'
|
|
2
|
+
import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
|
|
3
|
+
|
|
4
|
+
function isPlainObject (value) {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function buildJsonApiIdTransportSchema () {
|
|
9
|
+
return {
|
|
10
|
+
anyOf: [
|
|
11
|
+
{ type: 'string', minLength: 1 },
|
|
12
|
+
{ type: 'number' }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildResourceIdentifierTransportSchema (allowedTypes = null) {
|
|
18
|
+
return {
|
|
19
|
+
type: 'object',
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
required: ['type', 'id'],
|
|
22
|
+
properties: {
|
|
23
|
+
type: Array.isArray(allowedTypes) && allowedTypes.length > 0
|
|
24
|
+
? { enum: allowedTypes }
|
|
25
|
+
: { type: 'string', minLength: 1 },
|
|
26
|
+
id: buildJsonApiIdTransportSchema()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildRelationshipDataTransportSchema (allowedTypes = null) {
|
|
32
|
+
const identifierSchema = buildResourceIdentifierTransportSchema(allowedTypes)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
anyOf: [
|
|
36
|
+
identifierSchema,
|
|
37
|
+
{
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: identifierSchema
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildIncludeDepthPattern (maxDepth) {
|
|
46
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
|
|
47
|
+
return '^[^.]+(?:\\.[^.]+)*$'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (maxDepth === 1) {
|
|
51
|
+
return '^[^.]+$'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `^[^.]+(?:\\.[^.]+){0,${maxDepth - 1}}$`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildFieldMapJsonSchemaFragment () {
|
|
58
|
+
return {
|
|
59
|
+
additionalProperties: {
|
|
60
|
+
type: 'string'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildKnownPageSchema () {
|
|
66
|
+
return createSchema({
|
|
67
|
+
number: { type: 'number' },
|
|
68
|
+
size: { type: 'number' },
|
|
69
|
+
limit: { type: 'number' },
|
|
70
|
+
offset: { type: 'number' },
|
|
71
|
+
after: { type: 'string' },
|
|
72
|
+
before: { type: 'string' },
|
|
73
|
+
cursor: { type: 'string' }
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pickFirstError (errors = {}) {
|
|
78
|
+
return Object.values(errors)[0] || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildLooseObjectTransportSchema (propertiesSchema = null) {
|
|
82
|
+
const baseSchema = {
|
|
83
|
+
type: 'object',
|
|
84
|
+
additionalProperties: true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!propertiesSchema || !isPlainObject(propertiesSchema.properties)) {
|
|
88
|
+
return baseSchema
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...baseSchema,
|
|
93
|
+
properties: propertiesSchema.properties
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateJsonApiIdValue (value, context) {
|
|
98
|
+
if (typeof value === 'string') {
|
|
99
|
+
if (value.length === 0) {
|
|
100
|
+
context.throwTypeError()
|
|
101
|
+
}
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof value === 'number') {
|
|
106
|
+
if (!Number.isFinite(value)) {
|
|
107
|
+
context.throwTypeError()
|
|
108
|
+
}
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
context.throwTypeError()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateResourceIdentifierValue (value, context, allowedTypes = null) {
|
|
116
|
+
if (!isPlainObject(value)) {
|
|
117
|
+
context.throwTypeError()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value.type !== 'string' || value.type.length === 0) {
|
|
121
|
+
context.throwParamError(
|
|
122
|
+
'INVALID_RESOURCE_TYPE',
|
|
123
|
+
'Relationship resource identifiers must have a non-empty type.'
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(allowedTypes) && allowedTypes.length > 0 && !allowedTypes.includes(value.type)) {
|
|
128
|
+
context.throwParamError(
|
|
129
|
+
'INVALID_RESOURCE_TYPE',
|
|
130
|
+
`Relationship type must be one of: ${allowedTypes.join(', ')}.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!Object.hasOwn(value, 'id')) {
|
|
135
|
+
context.throwParamError(
|
|
136
|
+
'MISSING_RESOURCE_ID',
|
|
137
|
+
'Relationship resource identifiers must have an id.'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
validateJsonApiIdValue(value.id, context)
|
|
142
|
+
return value
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let requestContractSupportInstalled = false
|
|
146
|
+
|
|
147
|
+
function installRequestContractSupport () {
|
|
148
|
+
if (requestContractSupportInstalled) return
|
|
149
|
+
requestContractSupportInstalled = true
|
|
150
|
+
|
|
151
|
+
const jsonApiIdType = (context) => validateJsonApiIdValue(context.value, context)
|
|
152
|
+
jsonApiIdType.toJsonSchema = () => buildJsonApiIdTransportSchema()
|
|
153
|
+
addType('jsonApiId', jsonApiIdType)
|
|
154
|
+
|
|
155
|
+
const jsonApiRelationshipDataType = (context) => {
|
|
156
|
+
const allowedTypes = Array.isArray(context.definition.allowedTypes)
|
|
157
|
+
? context.definition.allowedTypes
|
|
158
|
+
: null
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(context.value)) {
|
|
161
|
+
context.value.forEach((entry) => validateResourceIdentifierValue(entry, context, allowedTypes))
|
|
162
|
+
return context.value
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return validateResourceIdentifierValue(context.value, context, allowedTypes)
|
|
166
|
+
}
|
|
167
|
+
jsonApiRelationshipDataType.toJsonSchema = ({ definition }) => buildRelationshipDataTransportSchema(
|
|
168
|
+
Array.isArray(definition.allowedTypes) ? definition.allowedTypes : null
|
|
169
|
+
)
|
|
170
|
+
addType('jsonApiRelationshipData', jsonApiRelationshipDataType)
|
|
171
|
+
|
|
172
|
+
const jsonApiAttributesType = (context) => {
|
|
173
|
+
if (!isPlainObject(context.value)) {
|
|
174
|
+
context.throwTypeError()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return context.value
|
|
178
|
+
}
|
|
179
|
+
jsonApiAttributesType.toJsonSchema = ({ definition }) => (
|
|
180
|
+
definition?.transportSchema || buildLooseObjectTransportSchema()
|
|
181
|
+
)
|
|
182
|
+
addType('jsonApiAttributes', jsonApiAttributesType)
|
|
183
|
+
|
|
184
|
+
const atLeastOneOfValidator = (context) => {
|
|
185
|
+
if (!Array.isArray(context.parameterValue) || context.parameterValue.length === 0) {
|
|
186
|
+
throw new Error(`Validator atLeastOneOf on '${context.fieldName}' requires a non-empty array.`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!context.parameterValue.some((fieldName) => Object.hasOwn(context.value || {}, fieldName))) {
|
|
190
|
+
context.throwParamError(
|
|
191
|
+
'AT_LEAST_ONE_REQUIRED',
|
|
192
|
+
`Must include at least one of: ${context.parameterValue.join(', ')}.`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
atLeastOneOfValidator.toJsonSchema = ({ parameterValue }) => ({
|
|
197
|
+
anyOf: parameterValue.map((fieldName) => ({ required: [fieldName] }))
|
|
198
|
+
})
|
|
199
|
+
addValidator('atLeastOneOf', atLeastOneOfValidator)
|
|
200
|
+
|
|
201
|
+
const includePathsValidator = (context) => {
|
|
202
|
+
const maxDepth = context.parameterValue
|
|
203
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) return
|
|
204
|
+
|
|
205
|
+
for (const includePath of context.value || []) {
|
|
206
|
+
const depth = String(includePath).split('.').length
|
|
207
|
+
if (depth > maxDepth) {
|
|
208
|
+
context.throwParamError(
|
|
209
|
+
'MAX_INCLUDE_DEPTH',
|
|
210
|
+
`Include path '${includePath}' exceeds maximum depth of ${maxDepth}.`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
includePathsValidator.toJsonSchema = ({ parameterValue }) => ({
|
|
216
|
+
items: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
pattern: buildIncludeDepthPattern(parameterValue)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
addValidator('includePaths', includePathsValidator)
|
|
222
|
+
|
|
223
|
+
const sortableEntriesValidator = (context) => {
|
|
224
|
+
const allowedFields = Array.isArray(context.parameterValue)
|
|
225
|
+
? context.parameterValue
|
|
226
|
+
: []
|
|
227
|
+
|
|
228
|
+
if (allowedFields.length === 0) return
|
|
229
|
+
|
|
230
|
+
for (const sortEntry of context.value || []) {
|
|
231
|
+
const normalizedField = String(sortEntry).startsWith('-')
|
|
232
|
+
? String(sortEntry).slice(1)
|
|
233
|
+
: String(sortEntry)
|
|
234
|
+
|
|
235
|
+
if (!allowedFields.includes(normalizedField)) {
|
|
236
|
+
context.throwParamError(
|
|
237
|
+
'UNSORTABLE_FIELD',
|
|
238
|
+
`Field '${normalizedField}' is not sortable. Sortable fields are: ${allowedFields.join(', ')}.`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
sortableEntriesValidator.toJsonSchema = ({ parameterValue }) => {
|
|
244
|
+
const allowedFields = Array.isArray(parameterValue) ? parameterValue : []
|
|
245
|
+
if (allowedFields.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
items: { type: 'string' }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const enumValues = allowedFields.flatMap((fieldName) => [fieldName, `-${fieldName}`])
|
|
252
|
+
return {
|
|
253
|
+
items: {
|
|
254
|
+
type: 'string',
|
|
255
|
+
enum: enumValues
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
addValidator('sortableEntries', sortableEntriesValidator)
|
|
260
|
+
|
|
261
|
+
const stringMapValuesValidator = (context) => {
|
|
262
|
+
for (const [key, value] of Object.entries(context.value || {})) {
|
|
263
|
+
if (typeof value !== 'string') {
|
|
264
|
+
context.throwParamError(
|
|
265
|
+
'INVALID_MAP_VALUE',
|
|
266
|
+
`Value for key '${key}' must be a string.`
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
stringMapValuesValidator.toJsonSchema = () => buildFieldMapJsonSchemaFragment()
|
|
272
|
+
addValidator('stringMapValues', stringMapValuesValidator)
|
|
273
|
+
|
|
274
|
+
const pageParamsValidator = (context) => {
|
|
275
|
+
const pageSchema = buildKnownPageSchema()
|
|
276
|
+
const knownPageParams = {}
|
|
277
|
+
|
|
278
|
+
for (const fieldName of Object.keys(pageSchema.structure)) {
|
|
279
|
+
if (Object.hasOwn(context.value || {}, fieldName)) {
|
|
280
|
+
knownPageParams[fieldName] = context.value[fieldName]
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const { validatedObject, errors } = pageSchema.patch(knownPageParams)
|
|
285
|
+
if (Object.keys(errors).length > 0) {
|
|
286
|
+
const firstError = pickFirstError(errors)
|
|
287
|
+
context.throwParamError(firstError?.code || 'INVALID_PAGE', firstError?.message || 'Invalid page parameters.')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
...context.value,
|
|
292
|
+
...validatedObject
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
pageParamsValidator.toJsonSchema = () => {
|
|
296
|
+
const pageSchema = buildKnownPageSchema().toJsonSchema({
|
|
297
|
+
mode: 'patch',
|
|
298
|
+
additionalProperties: false
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
properties: pageSchema.properties,
|
|
303
|
+
additionalProperties: true
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
addValidator('pageParams', pageParamsValidator)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildWritableAttributesStructure (schemaInfo = {}) {
|
|
310
|
+
const schemaStructure = schemaInfo.schemaStructure || {}
|
|
311
|
+
const schemaRelationships = schemaInfo.schemaRelationships || {}
|
|
312
|
+
const idProperty = schemaInfo.idProperty || 'id'
|
|
313
|
+
const attributesStructure = {}
|
|
314
|
+
const excludedFieldNames = new Set()
|
|
315
|
+
|
|
316
|
+
for (const relDef of Object.values(schemaRelationships)) {
|
|
317
|
+
if (!relDef?.belongsToPolymorphic) continue
|
|
318
|
+
|
|
319
|
+
const { typeField, idField } = relDef.belongsToPolymorphic
|
|
320
|
+
if (typeField) excludedFieldNames.add(typeField)
|
|
321
|
+
if (idField) excludedFieldNames.add(idField)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
|
|
325
|
+
if (!fieldDef) continue
|
|
326
|
+
if (fieldName === idProperty) continue
|
|
327
|
+
if (excludedFieldNames.has(fieldName)) continue
|
|
328
|
+
if (fieldDef.computed === true) continue
|
|
329
|
+
if (fieldDef.type === undefined) continue
|
|
330
|
+
if (fieldDef.belongsTo && fieldDef.as) continue
|
|
331
|
+
attributesStructure[fieldName] = { ...fieldDef }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return attributesStructure
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildWritableAttributesTransportSchema (schemaInfo = {}) {
|
|
338
|
+
const writableAttributesSchema = createSchema(buildWritableAttributesStructure(schemaInfo))
|
|
339
|
+
const jsonSchema = writableAttributesSchema.toJsonSchema({
|
|
340
|
+
mode: 'patch',
|
|
341
|
+
additionalProperties: true
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
return buildLooseObjectTransportSchema(jsonSchema)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function resolveRelationshipAllowedTypes (relName, relDef) {
|
|
348
|
+
if (relDef?.belongsToPolymorphic?.types?.length) {
|
|
349
|
+
return relDef.belongsToPolymorphic.types
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (relDef?.target) {
|
|
353
|
+
return [relDef.target]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (relDef?.belongsTo) {
|
|
357
|
+
return [relDef.belongsTo]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (relDef?.type === 'manyToMany' || relDef?.type === 'hasMany' || relDef?.type === 'hasOne') {
|
|
361
|
+
return [relDef.target || relName]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function buildRelationshipStructure (schemaInfo = {}) {
|
|
368
|
+
const schemaStructure = schemaInfo.schemaStructure || {}
|
|
369
|
+
const schemaRelationships = schemaInfo.schemaRelationships || {}
|
|
370
|
+
const relationshipStructure = {}
|
|
371
|
+
|
|
372
|
+
for (const [, fieldDef] of Object.entries(schemaStructure)) {
|
|
373
|
+
if (!fieldDef?.belongsTo || !fieldDef?.as) continue
|
|
374
|
+
|
|
375
|
+
relationshipStructure[fieldDef.as] = {
|
|
376
|
+
type: 'object',
|
|
377
|
+
schema: createSchema({
|
|
378
|
+
data: {
|
|
379
|
+
type: 'jsonApiRelationshipData',
|
|
380
|
+
required: true,
|
|
381
|
+
nullable: true,
|
|
382
|
+
allowedTypes: [fieldDef.belongsTo]
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const [relName, relDef] of Object.entries(schemaRelationships)) {
|
|
389
|
+
relationshipStructure[relName] = {
|
|
390
|
+
type: 'object',
|
|
391
|
+
schema: createSchema({
|
|
392
|
+
data: {
|
|
393
|
+
type: 'jsonApiRelationshipData',
|
|
394
|
+
required: true,
|
|
395
|
+
nullable: true,
|
|
396
|
+
allowedTypes: resolveRelationshipAllowedTypes(relName, relDef)
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return relationshipStructure
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildWriteDocumentContract (scopeName, schemaInfo, mode) {
|
|
406
|
+
const relationshipStructure = buildRelationshipStructure(schemaInfo)
|
|
407
|
+
|
|
408
|
+
const dataFieldDefinition = {
|
|
409
|
+
type: 'object',
|
|
410
|
+
required: true,
|
|
411
|
+
schema: createSchema({
|
|
412
|
+
type: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
required: true,
|
|
415
|
+
enum: [scopeName]
|
|
416
|
+
},
|
|
417
|
+
...(mode === 'post'
|
|
418
|
+
? {
|
|
419
|
+
id: {
|
|
420
|
+
type: 'jsonApiId',
|
|
421
|
+
nullable: true
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
: {
|
|
425
|
+
id: {
|
|
426
|
+
type: 'jsonApiId',
|
|
427
|
+
required: true
|
|
428
|
+
}
|
|
429
|
+
}),
|
|
430
|
+
attributes: {
|
|
431
|
+
type: 'jsonApiAttributes',
|
|
432
|
+
transportSchema: buildWritableAttributesTransportSchema(schemaInfo)
|
|
433
|
+
},
|
|
434
|
+
relationships: {
|
|
435
|
+
type: 'object',
|
|
436
|
+
schema: createSchema(relationshipStructure)
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (mode === 'patch') {
|
|
442
|
+
dataFieldDefinition.atLeastOneOf = ['attributes', 'relationships']
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const schema = createSchema({
|
|
446
|
+
data: dataFieldDefinition
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
schema,
|
|
451
|
+
mode: mode === 'post' ? 'create' : mode === 'put' ? 'replace' : 'patch'
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function buildGetRequestContract ({ includeDepthLimit }) {
|
|
456
|
+
const queryParamsSchema = createSchema({
|
|
457
|
+
include: {
|
|
458
|
+
type: 'array',
|
|
459
|
+
items: { type: 'string' },
|
|
460
|
+
includePaths: includeDepthLimit
|
|
461
|
+
},
|
|
462
|
+
fields: {
|
|
463
|
+
type: 'object',
|
|
464
|
+
additionalProperties: true,
|
|
465
|
+
stringMapValues: true
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
schema: createSchema({
|
|
471
|
+
id: {
|
|
472
|
+
type: 'jsonApiId',
|
|
473
|
+
required: true
|
|
474
|
+
},
|
|
475
|
+
queryParams: {
|
|
476
|
+
type: 'object',
|
|
477
|
+
schema: queryParamsSchema
|
|
478
|
+
}
|
|
479
|
+
}),
|
|
480
|
+
mode: 'replace'
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function buildQueryRequestContract ({ includeDepthLimit, sortableFields, searchSchemaInstance }) {
|
|
485
|
+
const queryParamsSchema = createSchema({
|
|
486
|
+
include: {
|
|
487
|
+
type: 'array',
|
|
488
|
+
items: { type: 'string' },
|
|
489
|
+
includePaths: includeDepthLimit
|
|
490
|
+
},
|
|
491
|
+
fields: {
|
|
492
|
+
type: 'object',
|
|
493
|
+
additionalProperties: true,
|
|
494
|
+
stringMapValues: true
|
|
495
|
+
},
|
|
496
|
+
filters: {
|
|
497
|
+
type: 'object',
|
|
498
|
+
schema: searchSchemaInstance || createSchema({})
|
|
499
|
+
},
|
|
500
|
+
sort: {
|
|
501
|
+
type: 'array',
|
|
502
|
+
items: { type: 'string' },
|
|
503
|
+
sortableEntries: sortableFields
|
|
504
|
+
},
|
|
505
|
+
page: {
|
|
506
|
+
type: 'object',
|
|
507
|
+
additionalProperties: true,
|
|
508
|
+
pageParams: true
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
schema: createSchema({
|
|
514
|
+
queryParams: {
|
|
515
|
+
type: 'object',
|
|
516
|
+
schema: queryParamsSchema
|
|
517
|
+
}
|
|
518
|
+
}),
|
|
519
|
+
mode: 'patch'
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function buildRequestContracts ({ scopeName, schemaInfo, includeDepthLimit, sortableFields }) {
|
|
524
|
+
return {
|
|
525
|
+
post: buildWriteDocumentContract(scopeName, schemaInfo, 'post'),
|
|
526
|
+
put: buildWriteDocumentContract(scopeName, schemaInfo, 'put'),
|
|
527
|
+
patch: buildWriteDocumentContract(scopeName, schemaInfo, 'patch'),
|
|
528
|
+
get: buildGetRequestContract({ includeDepthLimit }),
|
|
529
|
+
query: buildQueryRequestContract({
|
|
530
|
+
includeDepthLimit,
|
|
531
|
+
sortableFields,
|
|
532
|
+
searchSchemaInstance: schemaInfo.searchSchemaInstance
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function buildContractCacheKey ({ includeDepthLimit, sortableFields }) {
|
|
538
|
+
return JSON.stringify({
|
|
539
|
+
includeDepthLimit,
|
|
540
|
+
sortableFields: Array.isArray(sortableFields) ? [...sortableFields].sort() : []
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function getRequestContracts ({ scopeName, schemaInfo, includeDepthLimit = 3, sortableFields = [] }) {
|
|
545
|
+
installRequestContractSupport()
|
|
546
|
+
|
|
547
|
+
const cacheKey = buildContractCacheKey({ includeDepthLimit, sortableFields })
|
|
548
|
+
if (schemaInfo.requestContracts && schemaInfo.requestContractsCacheKey === cacheKey) {
|
|
549
|
+
return schemaInfo.requestContracts
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const requestContracts = buildRequestContracts({
|
|
553
|
+
scopeName,
|
|
554
|
+
schemaInfo,
|
|
555
|
+
includeDepthLimit,
|
|
556
|
+
sortableFields
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
schemaInfo.requestContracts = requestContracts
|
|
560
|
+
schemaInfo.requestContractsCacheKey = cacheKey
|
|
561
|
+
|
|
562
|
+
return requestContracts
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function validateRequestContractOrThrow (contract, payload, message = 'Request validation failed') {
|
|
566
|
+
const mode = contract?.mode || 'replace'
|
|
567
|
+
const schema = contract?.schema
|
|
568
|
+
|
|
569
|
+
if (!schema || typeof schema[mode] !== 'function') {
|
|
570
|
+
throw new Error('Invalid request contract.')
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const { validatedObject, errors } = schema[mode](payload)
|
|
574
|
+
if (Object.keys(errors).length === 0) {
|
|
575
|
+
return validatedObject
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const violations = Object.values(errors).map((error) => ({
|
|
579
|
+
field: error.field,
|
|
580
|
+
rule: error.code || 'invalid_value',
|
|
581
|
+
message: error.message
|
|
582
|
+
}))
|
|
583
|
+
|
|
584
|
+
const firstViolationMessage = violations[0]?.message
|
|
585
|
+
|
|
586
|
+
throw new RestApiValidationError(firstViolationMessage || message, {
|
|
587
|
+
fields: violations.map((entry) => entry.field),
|
|
588
|
+
violations
|
|
589
|
+
})
|
|
590
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const translateCanonicalAttributesForStorage = (attributes = {}, descriptor = {}) => {
|
|
2
|
+
const row = {}
|
|
3
|
+
|
|
4
|
+
for (const [fieldName, value] of Object.entries(attributes)) {
|
|
5
|
+
const slot = descriptor.fields?.[fieldName]
|
|
6
|
+
if (!slot) continue
|
|
7
|
+
|
|
8
|
+
if (slot.slotType === 'belongsTo') {
|
|
9
|
+
row[slot.slot] = value == null ? null : String(value)
|
|
10
|
+
} else {
|
|
11
|
+
row[slot.slot] = value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (slot.slotType === 'belongsTo') {
|
|
15
|
+
const alias = slot.alias || descriptor.fields?.[fieldName]?.alias
|
|
16
|
+
const belongsToInfo = alias ? descriptor.belongsTo?.[alias] : null
|
|
17
|
+
if (belongsToInfo) {
|
|
18
|
+
row[belongsToInfo.typeColumn] = value == null ? null : belongsToInfo.target
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return row
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const translateCanonicalRecordFromStorage = (row = {}, descriptor = {}, options = {}) => {
|
|
27
|
+
const attributes = {}
|
|
28
|
+
const consumedColumns = new Set(['id'])
|
|
29
|
+
const allowedExtraFields = new Set(options.allowedExtraFields || [])
|
|
30
|
+
|
|
31
|
+
for (const [slot, logical] of Object.entries(descriptor.reverseAttributes || {})) {
|
|
32
|
+
if (slot in row) {
|
|
33
|
+
attributes[logical] = row[slot]
|
|
34
|
+
consumedColumns.add(slot)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const relationships = {}
|
|
39
|
+
|
|
40
|
+
for (const [alias, info] of Object.entries(descriptor.belongsTo || {})) {
|
|
41
|
+
const idValue = row[info.idColumn]
|
|
42
|
+
consumedColumns.add(info.idColumn)
|
|
43
|
+
if (info.typeColumn) {
|
|
44
|
+
consumedColumns.add(info.typeColumn)
|
|
45
|
+
}
|
|
46
|
+
const relationshipData = idValue == null
|
|
47
|
+
? null
|
|
48
|
+
: { type: info.target, id: String(idValue) }
|
|
49
|
+
|
|
50
|
+
relationships[alias] = { data: relationshipData }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [alias, info] of Object.entries(descriptor.polymorphicBelongsTo || {})) {
|
|
54
|
+
const typeValue = info.typeColumn ? row[info.typeColumn] : null
|
|
55
|
+
const idValue = info.idColumn ? row[info.idColumn] : null
|
|
56
|
+
if (info.typeColumn) {
|
|
57
|
+
consumedColumns.add(info.typeColumn)
|
|
58
|
+
}
|
|
59
|
+
if (info.idColumn) {
|
|
60
|
+
consumedColumns.add(info.idColumn)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let relationshipData = null
|
|
64
|
+
if (typeValue != null && idValue != null) {
|
|
65
|
+
relationshipData = {
|
|
66
|
+
type: String(typeValue),
|
|
67
|
+
id: String(idValue),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
relationships[alias] = { data: relationshipData }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const canonical = descriptor.canonical || {}
|
|
75
|
+
const internalColumns = new Set([
|
|
76
|
+
canonical.tenantColumn,
|
|
77
|
+
canonical.resourceColumn,
|
|
78
|
+
'created_at',
|
|
79
|
+
'updated_at',
|
|
80
|
+
'deleted_at'
|
|
81
|
+
].filter(Boolean))
|
|
82
|
+
|
|
83
|
+
for (const [columnName, value] of Object.entries(row)) {
|
|
84
|
+
if (consumedColumns.has(columnName) || internalColumns.has(columnName)) {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (!allowedExtraFields.has(columnName)) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
attributes[columnName] = value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { attributes, relationships }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const getCanonicalFieldValue = (row, descriptor = {}, fieldName) => {
|
|
97
|
+
if (!row || !fieldName) return undefined
|
|
98
|
+
|
|
99
|
+
if (fieldName === 'id') {
|
|
100
|
+
return row.id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fieldInfo = descriptor.fields?.[fieldName]
|
|
104
|
+
if (fieldInfo?.slot && Object.hasOwn(row, fieldInfo.slot)) {
|
|
105
|
+
return row[fieldInfo.slot]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (Object.hasOwn(row, fieldName)) {
|
|
109
|
+
return row[fieldName]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return undefined
|
|
113
|
+
}
|