json-rest-api 1.0.23 → 1.0.24

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 (60) hide show
  1. package/README.md +22 -1
  2. package/docs/API.md +145 -24
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +83 -4
  4. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  5. package/lib/enhanced-logger.js +1 -1
  6. package/package.json +6 -2
  7. package/plugins/core/bulk-operations-plugin.js +26 -34
  8. package/plugins/core/connectors/express-plugin.js +0 -6
  9. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  10. package/plugins/core/lib/anyapi/schema-utils.js +43 -4
  11. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
  12. package/plugins/core/lib/dbIntrospection.js +1 -1
  13. package/plugins/core/lib/dbTablesOperations.js +10 -3
  14. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  15. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +0 -1
  16. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  17. package/plugins/core/lib/querying/knex-relationship-includes.js +11 -18
  18. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  19. package/plugins/core/lib/querying-writing/knex-field-helpers.js +1 -2
  20. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  21. package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
  22. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  23. package/plugins/core/lib/storage/canonical-storage-mapping.js +22 -2
  24. package/plugins/core/lib/storage/storage-adapter.js +3 -2
  25. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  26. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
  27. package/plugins/core/rest-api-anyapi-knex-plugin.js +194 -102
  28. package/plugins/core/rest-api-knex-plugin.js +23 -16
  29. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
  30. package/plugins/core/rest-api-plugin-methods/common.js +27 -9
  31. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
  32. package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
  33. package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
  34. package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
  35. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  36. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
  37. package/plugins/core/rest-api-plugin-methods/patch.js +31 -3
  38. package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
  39. package/plugins/core/rest-api-plugin-methods/post.js +8 -0
  40. package/plugins/core/rest-api-plugin-methods/put.js +31 -3
  41. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  42. package/plugins/core/rest-api-plugin.js +4 -0
  43. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  44. package/plugins/core/socketio-plugin.js +0 -1
  45. package/plugins/storage/local-storage.js +1 -1
  46. package/plugins/storage/s3-storage.js +5 -2
  47. package/quickTest.js +1 -0
  48. package/scripts/run-docs.js +80 -84
  49. package/test-debug.js +1 -1
  50. package/tests/README.md +7 -0
  51. package/tests/anyapi-basic.test.js +3 -0
  52. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  53. package/tests/db-introspection.test.js +4 -1
  54. package/tests/db-table-operations.test.js +25 -20
  55. package/tests/fastify-plugin.test.js +21 -6
  56. package/tests/fixtures/api-configs.js +133 -0
  57. package/tests/id-normalization.test.js +444 -0
  58. package/tests/logical-id-write-validation.test.js +151 -0
  59. package/tests/pagination-cursor-multifield.test.js +11 -11
  60. package/tests/storage-mapping.test.js +55 -6
@@ -0,0 +1,131 @@
1
+ import {
2
+ RestApiResourceError,
3
+ RestApiValidationError
4
+ } from '../../../../lib/rest-api-errors.js'
5
+
6
+ export function defaultNormalizeResourceId (value) {
7
+ if (value === null || value === undefined) {
8
+ return null
9
+ }
10
+
11
+ if (typeof value === 'string') {
12
+ const normalized = value.trim()
13
+ return normalized || null
14
+ }
15
+
16
+ if (typeof value === 'number') {
17
+ return Number.isFinite(value) ? String(value) : null
18
+ }
19
+
20
+ if (typeof value === 'bigint') {
21
+ return String(value)
22
+ }
23
+
24
+ const normalized = String(value).trim()
25
+ return normalized || null
26
+ }
27
+
28
+ export function resolveResourceIdNormalizer ({ scopeOptions = null, vars = null } = {}) {
29
+ if (typeof scopeOptions?.normalizeId === 'function') {
30
+ return scopeOptions.normalizeId
31
+ }
32
+
33
+ if (typeof vars?.normalizeId === 'function') {
34
+ return vars.normalizeId
35
+ }
36
+
37
+ return defaultNormalizeResourceId
38
+ }
39
+
40
+ export function normalizeResourceId (value, options = {}) {
41
+ const normalizer = resolveResourceIdNormalizer(options)
42
+ return defaultNormalizeResourceId(normalizer(value))
43
+ }
44
+
45
+ export function requireExistingResourceId (value, {
46
+ scopeOptions = null,
47
+ vars = null,
48
+ scopeName = ''
49
+ } = {}) {
50
+ const normalizedId = normalizeResourceId(value, { scopeOptions, vars })
51
+ if (normalizedId) {
52
+ return normalizedId
53
+ }
54
+
55
+ throw new RestApiResourceError(
56
+ 'Resource not found',
57
+ {
58
+ subtype: 'not_found',
59
+ resourceType: scopeName,
60
+ resourceId: value == null ? null : String(value)
61
+ }
62
+ )
63
+ }
64
+
65
+ export function requireDocumentResourceId (value, {
66
+ scopeOptions = null,
67
+ vars = null
68
+ } = {}) {
69
+ const normalizedId = normalizeResourceId(value, { scopeOptions, vars })
70
+ if (normalizedId) {
71
+ return normalizedId
72
+ }
73
+
74
+ throw new RestApiValidationError(
75
+ 'Resource document id is invalid',
76
+ {
77
+ fields: ['data.id'],
78
+ violations: [{
79
+ field: 'data.id',
80
+ rule: 'invalid_resource_id',
81
+ message: 'Resource document id must normalize to a non-empty value.'
82
+ }]
83
+ }
84
+ )
85
+ }
86
+
87
+ function resolveResourceNormalizationOptions (resourceType, { api } = {}) {
88
+ const scope = api?.resources?.[resourceType]
89
+ return {
90
+ scopeOptions: scope?.scopeOptions || scope?._scopeOptions || null,
91
+ vars: scope?.vars || null,
92
+ scopeName: resourceType
93
+ }
94
+ }
95
+
96
+ export function requireReferencedResourceId (resourceType, value, { api } = {}) {
97
+ return requireExistingResourceId(
98
+ value,
99
+ resolveResourceNormalizationOptions(resourceType, { api })
100
+ )
101
+ }
102
+
103
+ function normalizeRelationshipIdentifier (identifier, { api } = {}) {
104
+ if (!identifier || typeof identifier !== 'object' || Array.isArray(identifier)) {
105
+ return identifier
106
+ }
107
+
108
+ const type = typeof identifier.type === 'string' ? identifier.type : ''
109
+ if (!type || !Object.hasOwn(identifier, 'id')) {
110
+ return {
111
+ ...identifier
112
+ }
113
+ }
114
+
115
+ return {
116
+ ...identifier,
117
+ id: requireReferencedResourceId(type, identifier.id, { api })
118
+ }
119
+ }
120
+
121
+ export function normalizeRelationshipIdentifiers (relationshipData, { api } = {}) {
122
+ if (Array.isArray(relationshipData)) {
123
+ return relationshipData.map((identifier) => normalizeRelationshipIdentifier(identifier, { api }))
124
+ }
125
+
126
+ if (relationshipData == null) {
127
+ return relationshipData
128
+ }
129
+
130
+ return normalizeRelationshipIdentifier(relationshipData, { api })
131
+ }
@@ -9,8 +9,6 @@
9
9
  * - Detecting circular dependencies in field definitions
10
10
  */
11
11
 
12
- import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
13
-
14
12
  /**
15
13
  * Marks all fields in a searchSchema as indexed for database optimization
16
14
  *
@@ -280,22 +280,15 @@ export const transformSimplifiedToJsonApi = (scope, deps) => {
280
280
  relationshipsData[relName] = {
281
281
  data: value.map(relId => ({ type: targetType, id: String(relId) }))
282
282
  }
283
- }
284
- // Handle hasOne relationships
285
- else if (relConfig.type === 'hasOne' && value !== undefined) {
283
+ } else if (relConfig.type === 'hasOne' && value !== undefined) {
286
284
  relationshipsData[relName] = {
287
285
  data: value ? { type: relConfig.target, id: String(value) } : null
288
286
  }
289
- }
290
- // Handle single belongsTo relationships if they were also defined in 'relationships'
291
- // (though typically they are defined in schema.belongsTo)
292
- else if (relConfig.belongsTo && value !== undefined) {
287
+ } else if (relConfig.belongsTo && value !== undefined) {
293
288
  relationshipsData[relName] = {
294
289
  data: value ? { type: relConfig.belongsTo, id: String(value) } : null
295
290
  }
296
- }
297
- // Handle polymorphic belongsTo relationships with minimal object format
298
- else if (relConfig.belongsToPolymorphic && value !== undefined) {
291
+ } else if (relConfig.belongsToPolymorphic && value !== undefined) {
299
292
  if (value === null) {
300
293
  relationshipsData[relName] = { data: null }
301
294
  } else if (typeof value === 'object' && value !== null && value.id && value._type) {
@@ -780,7 +773,7 @@ export const transformSingleJsonApiToSimplified = (scope, deps) => {
780
773
  const schemaEntry = Object.entries(schema).find(([_, def]) => def.as === relName)
781
774
 
782
775
  if (schemaEntry) {
783
- const [fieldName, fieldDef] = schemaEntry
776
+ const [, fieldDef] = schemaEntry
784
777
  if (fieldDef.belongsTo && relData.data) {
785
778
  // Create minimal relationship object (no more foreign key field)
786
779
  simplified[relName] = { id: relData.data.id }
@@ -23,9 +23,29 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
23
23
  return row
24
24
  }
25
25
 
26
+ export const getCanonicalResourceIdColumn = (descriptor = {}) => (
27
+ descriptor?.canonical?.logicalIdColumn || 'logical_id'
28
+ )
29
+
30
+ export const getCanonicalResourceId = (row = {}, descriptor = {}) => {
31
+ const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
32
+ const logicalId = row?.[logicalIdColumn]
33
+
34
+ if (logicalId !== undefined && logicalId !== null) {
35
+ return String(logicalId)
36
+ }
37
+
38
+ if (row?.id !== undefined && row?.id !== null) {
39
+ return String(row.id)
40
+ }
41
+
42
+ return null
43
+ }
44
+
26
45
  export const translateCanonicalRecordFromStorage = (row = {}, descriptor = {}, options = {}) => {
27
46
  const attributes = {}
28
- const consumedColumns = new Set(['id'])
47
+ const logicalIdColumn = getCanonicalResourceIdColumn(descriptor)
48
+ const consumedColumns = new Set(['id', logicalIdColumn])
29
49
  const allowedExtraFields = new Set(options.allowedExtraFields || [])
30
50
 
31
51
  for (const [slot, logical] of Object.entries(descriptor.reverseAttributes || {})) {
@@ -97,7 +117,7 @@ export const getCanonicalFieldValue = (row, descriptor = {}, fieldName) => {
97
117
  if (!row || !fieldName) return undefined
98
118
 
99
119
  if (fieldName === 'id') {
100
- return row.id
120
+ return getCanonicalResourceId(row, descriptor)
101
121
  }
102
122
 
103
123
  const fieldInfo = descriptor.fields?.[fieldName]
@@ -6,6 +6,7 @@ import {
6
6
  } from './storage-mapping.js'
7
7
  import {
8
8
  getCanonicalFieldValue,
9
+ getCanonicalResourceIdColumn,
9
10
  translateCanonicalAttributesForStorage,
10
11
  } from './canonical-storage-mapping.js'
11
12
 
@@ -146,7 +147,7 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
146
147
 
147
148
  const translateColumn = (field) => {
148
149
  if (!field) return field
149
- if (field === 'id') return 'id'
150
+ if (field === 'id') return getCanonicalResourceIdColumn(descriptor)
150
151
 
151
152
  const canonicalEntry = canonicalFieldMap[field]
152
153
  if (typeof canonicalEntry === 'string') {
@@ -214,7 +215,7 @@ const createCanonicalAdapter = ({ knex, schemaInfo }) => {
214
215
  return baseAdapter({
215
216
  knex,
216
217
  tableName: canonical.tableName,
217
- idColumn: 'id',
218
+ idColumn: getCanonicalResourceIdColumn(descriptor),
218
219
  translateColumn,
219
220
  translateFilterValue,
220
221
  applyResourceScope,
@@ -1,5 +1,8 @@
1
1
  const EMPTY_STORAGE_INFO = Object.freeze({
2
2
  idColumn: 'id',
3
+ storage: Object.freeze({
4
+ naming: 'snake_case'
5
+ }),
3
6
  fields: Object.freeze({}),
4
7
  columns: Object.freeze({})
5
8
  })
@@ -10,14 +13,75 @@ const isPlainObject = (value) => (
10
13
  !Array.isArray(value)
11
14
  )
12
15
 
13
- export const normalizeFieldStorage = (fieldName, definition = {}, { idColumn = 'id' } = {}) => {
14
- const storage = definition.storage
16
+ const SNAKE_CASE_STORAGE_NAMING = 'snake_case'
17
+ const EXACT_STORAGE_NAMING = 'exact'
15
18
 
16
- if (storage !== undefined && !isPlainObject(storage)) {
19
+ const STORAGE_NAMING_ALIASES = Object.freeze({
20
+ snake: SNAKE_CASE_STORAGE_NAMING,
21
+ snake_case: SNAKE_CASE_STORAGE_NAMING,
22
+ snakeCase: SNAKE_CASE_STORAGE_NAMING,
23
+ exact: EXACT_STORAGE_NAMING,
24
+ field: EXACT_STORAGE_NAMING,
25
+ verbatim: EXACT_STORAGE_NAMING
26
+ })
27
+
28
+ const toSnakeCase = (value) => String(value)
29
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
30
+ .replace(/([A-Z]+)([A-Z][a-z0-9])/g, '$1_$2')
31
+ .toLowerCase()
32
+
33
+ export const normalizeStorageConfig = (storage = undefined) => {
34
+ if (storage === undefined || storage === null) {
35
+ return {
36
+ naming: SNAKE_CASE_STORAGE_NAMING
37
+ }
38
+ }
39
+
40
+ if (!isPlainObject(storage)) {
41
+ throw new Error('Resource storage metadata must be an object.')
42
+ }
43
+
44
+ const rawNaming = storage.naming
45
+ const naming = rawNaming === undefined
46
+ ? SNAKE_CASE_STORAGE_NAMING
47
+ : STORAGE_NAMING_ALIASES[rawNaming]
48
+
49
+ if (!naming) {
50
+ throw new Error(
51
+ `Invalid resource storage.naming value '${String(rawNaming)}'. Expected 'snake_case' or 'exact'.`
52
+ )
53
+ }
54
+
55
+ return {
56
+ ...storage,
57
+ naming
58
+ }
59
+ }
60
+
61
+ const resolveDefaultStorageColumn = (fieldName, { idColumn = 'id', storageConfig = undefined } = {}) => {
62
+ if (fieldName === 'id') {
63
+ return idColumn
64
+ }
65
+
66
+ const normalizedStorage = normalizeStorageConfig(storageConfig)
67
+ if (normalizedStorage.naming === EXACT_STORAGE_NAMING) {
68
+ return fieldName
69
+ }
70
+
71
+ return toSnakeCase(fieldName)
72
+ }
73
+
74
+ export const normalizeFieldStorage = (fieldName, definition = {}, { idColumn = 'id', storageConfig = undefined } = {}) => {
75
+ const fieldStorage = definition.storage
76
+
77
+ if (fieldStorage !== undefined && !isPlainObject(fieldStorage)) {
17
78
  throw new Error(`Field '${fieldName}' has invalid storage metadata. Expected an object.`)
18
79
  }
19
80
 
20
- const column = storage?.column ?? fieldName
81
+ const column = fieldStorage?.column ?? resolveDefaultStorageColumn(fieldName, {
82
+ idColumn,
83
+ storageConfig
84
+ })
21
85
  if (typeof column !== 'string' || column.trim() === '') {
22
86
  throw new Error(`Field '${fieldName}' has an invalid storage.column value.`)
23
87
  }
@@ -28,25 +92,29 @@ export const normalizeFieldStorage = (fieldName, definition = {}, { idColumn = '
28
92
  )
29
93
  }
30
94
 
31
- if (storage?.serialize !== undefined && typeof storage.serialize !== 'function') {
95
+ if (fieldStorage?.serialize !== undefined && typeof fieldStorage.serialize !== 'function') {
32
96
  throw new Error(`Field '${fieldName}' has an invalid storage.serialize value. Expected a function.`)
33
97
  }
34
98
 
35
99
  return {
36
100
  column,
37
- serialize: storage?.serialize || null,
101
+ serialize: fieldStorage?.serialize || null,
38
102
  persisted: definition.virtual !== true && definition.computed !== true
39
103
  }
40
104
  }
41
105
 
42
- export const buildStorageInfo = ({ schemaStructure = {}, idProperty = 'id' } = {}) => {
106
+ export const buildStorageInfo = ({ schemaStructure = {}, idProperty = 'id', storage = undefined } = {}) => {
43
107
  const idColumn = idProperty || 'id'
108
+ const storageConfig = normalizeStorageConfig(storage)
44
109
  const fields = {}
45
110
  const columns = {}
46
111
  const usedColumns = new Map([[idColumn, 'id']])
47
112
 
48
113
  for (const [fieldName, definition] of Object.entries(schemaStructure)) {
49
- const normalized = normalizeFieldStorage(fieldName, definition, { idColumn })
114
+ const normalized = normalizeFieldStorage(fieldName, definition, {
115
+ idColumn,
116
+ storageConfig
117
+ })
50
118
  const isIdSurrogateField = fieldName === idColumn && definition.type === 'id'
51
119
 
52
120
  if (fieldName !== 'id' && normalized.column === idColumn && !isIdSurrogateField) {
@@ -75,6 +143,7 @@ export const buildStorageInfo = ({ schemaStructure = {}, idProperty = 'id' } = {
75
143
 
76
144
  return {
77
145
  idColumn,
146
+ storage: storageConfig,
78
147
  fields,
79
148
  columns
80
149
  }
@@ -91,7 +160,8 @@ export const getStorageInfo = (schemaInfo = {}) => {
91
160
 
92
161
  return buildStorageInfo({
93
162
  schemaStructure: schemaInfo.schemaStructure,
94
- idProperty: schemaInfo.idProperty || 'id'
163
+ idProperty: schemaInfo.idProperty || 'id',
164
+ storage: schemaInfo.storage
95
165
  })
96
166
  }
97
167
 
@@ -165,13 +235,13 @@ export const translateAttributesForStorage = (attributes, schemaInfo = {}, optio
165
235
  : fieldStorage?.column || fieldName
166
236
  const serializedValue = fieldStorage?.serialize
167
237
  ? fieldStorage.serialize(value, {
168
- fieldName,
169
- columnName,
170
- definition: fieldStorage.definition,
171
- schemaInfo,
172
- context,
173
- operation
174
- })
238
+ fieldName,
239
+ columnName,
240
+ definition: fieldStorage.definition,
241
+ schemaInfo,
242
+ context,
243
+ operation
244
+ })
175
245
  : value
176
246
 
177
247
  translated[columnName] = serializedValue
@@ -1,5 +1,5 @@
1
1
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
2
- import { transformSimplifiedToJsonApi } from '../querying-writing/simplified-helpers.js'
2
+ import { normalizeRelationshipIdentifiers } from '../querying-writing/resource-id-normalization.js'
3
3
 
4
4
  /**
5
5
  * Updates many-to-many relationships intelligently by synchronizing pivot table records
@@ -103,7 +103,8 @@ import { transformSimplifiedToJsonApi } from '../querying-writing/simplified-hel
103
103
  export const updateManyToManyRelationship = async (scope, deps) => {
104
104
  // Extract values from deps
105
105
  const { api, context } = deps
106
- const { resourceId, relDef, relData, transaction: trx } = context
106
+ const { resourceId, relDef, transaction: trx } = context
107
+ const relData = normalizeRelationshipIdentifiers(context.relData || [], { api })
107
108
 
108
109
  // Get the knex instance from the pivot scope
109
110
  const pivotScope = api.resources[relDef.through]
@@ -257,6 +258,7 @@ export const updateManyToManyRelationship = async (scope, deps) => {
257
258
  * 6. Returns (no data returned, throws on error)
258
259
  */
259
260
  export const createPivotRecords = async (api, resourceId, relDef, relData, trx) => {
261
+ relData = normalizeRelationshipIdentifiers(relData, { api })
260
262
  if (relData.length === 0) return // Early exit if nothing to create
261
263
 
262
264
  // Get pivot table info