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
@@ -8,9 +8,8 @@ import {
8
8
  generateKnexMigrationDiff
9
9
  } from './lib/dbTablesOperations.js'
10
10
  import { introspectKnexTableSnapshot } from './lib/dbIntrospection.js'
11
- import { applyFieldSelectionToQuery, buildFieldSelection, isNonDatabaseField } from './lib/querying-writing/knex-field-helpers.js'
12
- import { getForeignKeyFields } from './lib/querying-writing/field-utils.js'
13
- import { toJsonApiRecord, buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js'
11
+ import { applyFieldSelectionToQuery, buildFieldSelection } from './lib/querying-writing/knex-field-helpers.js'
12
+ import { buildJsonApiResponse } from './lib/querying/knex-json-api-transformers-querying.js'
14
13
  import { processBelongsToRelationships } from './lib/writing/knex-json-api-transformers-writing.js'
15
14
  import { toJsonApiRecordWithBelongsTo } from './lib/querying-writing/knex-json-api-transformers.js'
16
15
  import { processIncludes } from './lib/querying/knex-process-includes.js'
@@ -46,9 +45,8 @@ export const RestApiKnexPlugin = {
46
45
 
47
46
  async install ({ helpers, vars, pluginOptions, api, log, scopes, addHook, addScopeMethod }) {
48
47
  // Try to import knex dynamically
49
- let knexLib
50
48
  try {
51
- knexLib = await import('knex')
49
+ await import('knex')
52
50
  } catch (e) {
53
51
  requirePackage('knex', 'rest-api-knex',
54
52
  'Knex.js is required for database operations. This is a peer dependency that allows you to control the version.')
@@ -100,6 +98,7 @@ export const RestApiKnexPlugin = {
100
98
 
101
99
  return {
102
100
  structure: filteredSchema,
101
+ storage: vars.schemaInfo?.storage,
103
102
  indexes: vars.schemaInfo?.indexes || [],
104
103
  foreignKeys: vars.schemaInfo?.foreignKeys || [],
105
104
  checkConstraints: vars.schemaInfo?.checkConstraints || []
@@ -682,9 +681,7 @@ export const RestApiKnexPlugin = {
682
681
  query
683
682
  .limit(pageSize)
684
683
  .offset((pageNumber - 1) * pageSize)
685
- }
686
- // Cursor-based pagination
687
- else if (queryParams.page.after || queryParams.page.before) {
684
+ } else if (queryParams.page.after || queryParams.page.before) {
688
685
  // Fetch one extra record to determine if there are more
689
686
  query.limit(pageSize + 1)
690
687
 
@@ -745,9 +742,7 @@ export const RestApiKnexPlugin = {
745
742
  }
746
743
  )
747
744
  }
748
- }
749
- // Default pagination if only size is specified - treat as cursor-based for "load more"
750
- else {
745
+ } else {
751
746
  // Fetch one extra to detect hasMore
752
747
  query.limit(pageSize + 1)
753
748
  }
@@ -918,7 +913,6 @@ export const RestApiKnexPlugin = {
918
913
  if (storageAdapter) {
919
914
  context.storageAdapter = storageAdapter
920
915
  }
921
- const scope = api.resources[scopeName]
922
916
  const tableName = storageAdapter?.getTableName?.() || context.schemaInfo.tableName
923
917
  const idProperty = storageAdapter?.getIdColumn?.() || context.schemaInfo.idProperty
924
918
  const db = context.db || api.knex.instance
@@ -943,12 +937,26 @@ export const RestApiKnexPlugin = {
943
937
  })
944
938
  : attributes
945
939
 
940
+ const explicitId = dbAttributes[idProperty] ?? inputRecord.data.id
941
+
946
942
  // Insert and get the new ID
947
943
  const result = await db(tableName).insert(dbAttributes).returning(idProperty)
948
944
 
949
- // Extract the ID value (SQLite returns array of objects)
950
- const id = result[0]?.[idProperty] || result[0]
951
- return id
945
+ // Extract the ID value. Some dialects ignore .returning() for inserts and can
946
+ // yield 0 even when the caller explicitly supplied the primary key.
947
+ const returnedId = Array.isArray(result)
948
+ ? (result[0]?.[idProperty] ?? result[0])
949
+ : (result?.[idProperty] ?? result)
950
+
951
+ if (
952
+ explicitId !== undefined &&
953
+ explicitId !== null &&
954
+ (returnedId === undefined || returnedId === null || returnedId === 0 || returnedId === '0')
955
+ ) {
956
+ return explicitId
957
+ }
958
+
959
+ return returnedId
952
960
  }
953
961
 
954
962
  /**
@@ -1135,7 +1143,6 @@ export const RestApiKnexPlugin = {
1135
1143
  if (storageAdapter) {
1136
1144
  context.storageAdapter = storageAdapter
1137
1145
  }
1138
- const scope = api.resources[scopeName]
1139
1146
  const id = context.id
1140
1147
 
1141
1148
  const tableName = storageAdapter?.getTableName?.() || context.schemaInfo.tableName
@@ -24,6 +24,7 @@ export default async function turnScopeInitIntoVars ({ context, scopes, vars: ap
24
24
 
25
25
  // Set idProperty as scope var
26
26
  if (typeof scopeOptions.idProperty !== 'undefined') vars.idProperty = scopeOptions.idProperty
27
+ if (typeof scopeOptions.normalizeId === 'function') vars.normalizeId = scopeOptions.normalizeId
27
28
 
28
29
  // Add validation for query limits
29
30
  if (vars.queryDefaultLimit && vars.queryMaxLimit) {
@@ -3,6 +3,7 @@ import {
3
3
  RestApiValidationError
4
4
  } from '../../../lib/rest-api-errors.js'
5
5
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
6
+ import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
6
7
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
7
8
  import { unwrapQueryBuilderState } from '../lib/querying/query-builder-utils.js'
8
9
 
@@ -296,16 +297,20 @@ export const validateResourceAttributesBeforeWrite = async ({
296
297
  context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) }
297
298
  }
298
299
 
300
+ const schemaStructure = context.schemaInfo.schemaStructure || {}
301
+ const hasLogicalIdField = Object.hasOwn(schemaStructure, 'id')
302
+ const resourceId = context.inputRecord?.data?.id
303
+
299
304
  // Merge belongsTo updates with attributes for validation
300
305
  const attributesToValidate = {
301
306
  ...context.inputRecord.data.attributes,
302
- ...belongsToUpdates
307
+ ...belongsToUpdates,
308
+ ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
303
309
  }
304
310
 
305
311
  // Extract computed fields and foreign key fields before validation
306
312
  const computedFields = {}
307
313
  const foreignKeyFields = {}
308
- const schemaStructure = context.schemaInfo.schemaStructure || {}
309
314
  Object.entries(attributesToValidate).forEach(([key, value]) => {
310
315
  const fieldDef = schemaStructure[key]
311
316
  // Computed fields should be stripped from input
@@ -375,12 +380,13 @@ export const validateResourceAttributesBeforeWrite = async ({
375
380
  const { validatedObject, errors } = await schema[validationMethod](attributesForValidation)
376
381
 
377
382
  if (Object.keys(errors).length > 0) {
378
- // --- START OF MODIFICATION ---
379
- const schemaStructure = context.schemaInfo.schemaInstance.structure // Get the schema structure for lookup
380
-
381
383
  const violations = Object.entries(errors).map(([field, error]) => {
382
384
  let fieldPath = `data.attributes.${field}` // Default path for attributes
383
385
 
386
+ if (field === 'id' && hasLogicalIdField) {
387
+ fieldPath = 'data.id'
388
+ }
389
+
384
390
  // Check if this field is a foreign key that has an 'as' alias
385
391
  const fieldDef = schemaStructure[field]
386
392
  if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
@@ -400,7 +406,6 @@ export const validateResourceAttributesBeforeWrite = async ({
400
406
  message: error.message
401
407
  }
402
408
  })
403
- // --- END OF MODIFICATION ---
404
409
 
405
410
  throw new RestApiValidationError(
406
411
  'Schema validation failed for resource attributes',
@@ -411,9 +416,14 @@ export const validateResourceAttributesBeforeWrite = async ({
411
416
  )
412
417
  }
413
418
 
419
+ if (Object.hasOwn(validatedObject, 'id')) {
420
+ context.inputRecord.data.id = validatedObject.id
421
+ }
422
+ const { id: _validatedId, ...validatedAttributes } = validatedObject
423
+
414
424
  // Update attributes with validated values
415
425
  // Virtual fields have now been validated and cast properly
416
- context.inputRecord.data.attributes = validatedObject
426
+ context.inputRecord.data.attributes = validatedAttributes
417
427
 
418
428
  await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`)
419
429
  await runHooks('afterSchemaValidate')
@@ -434,8 +444,16 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
434
444
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
435
445
  if (!relData?.data) continue
436
446
 
447
+ const normalizedRelationshipData = normalizeRelationshipIdentifiers(relData.data, { api })
448
+ inputRecord.data.relationships[relName] = {
449
+ ...relData,
450
+ data: normalizedRelationshipData
451
+ }
452
+
437
453
  // Handle both single and array relationships
438
- const relatedItems = Array.isArray(relData.data) ? relData.data : [relData.data]
454
+ const relatedItems = Array.isArray(normalizedRelationshipData)
455
+ ? normalizedRelationshipData
456
+ : [normalizedRelationshipData]
439
457
 
440
458
  for (const item of relatedItems) {
441
459
  // Get the scope for the related resource
@@ -667,7 +685,7 @@ export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
667
685
  }
668
686
 
669
687
  // Then check schema fields for belongsTo relationships with matching 'as' property
670
- for (const [fieldName, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
688
+ for (const [, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
671
689
  if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
672
690
  return fieldDef
673
691
  }
@@ -1,5 +1,9 @@
1
1
  import { RestApiResourceError, RestApiValidationError, RestApiPayloadError } from '../../../lib/rest-api-errors.js'
2
2
  import { findRelationshipDefinition, handleWriteMethodError } from './common.js'
3
+ import {
4
+ normalizeRelationshipIdentifiers,
5
+ requireExistingResourceId
6
+ } from '../lib/querying-writing/resource-id-normalization.js'
3
7
 
4
8
  /**
5
9
  * DELETE RELATIONSHIP
@@ -11,9 +15,13 @@ import { findRelationshipDefinition, handleWriteMethodError } from './common.js'
11
15
  * @param {array} relationshipData - Array of resource identifiers to remove
12
16
  * @returns {Promise<void>} 204 No Content
13
17
  */
14
- export default async function deleteRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api, log }) {
18
+ export default async function deleteRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeOptions, scopeName, api, log }) {
15
19
  context.method = 'deleteRelationship'
16
- context.id = params.id
20
+ context.id = requireExistingResourceId(params.id, {
21
+ scopeOptions,
22
+ vars,
23
+ scopeName
24
+ })
17
25
  context.relationshipName = params.relationshipName
18
26
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
19
27
 
@@ -43,6 +51,7 @@ export default async function deleteRelationshipMethod ({ params, context, vars,
43
51
  if (!Array.isArray(params.relationshipData)) {
44
52
  throw new RestApiPayloadError('DELETE from relationship requires array of resource identifiers')
45
53
  }
54
+ params.relationshipData = normalizeRelationshipIdentifiers(params.relationshipData, { api })
46
55
 
47
56
  // Check permissions
48
57
  await runHooks('checkPermissions')
@@ -1,5 +1,6 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
2
  import { handleWriteMethodError } from './common.js'
3
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
3
4
 
4
5
  /**
5
6
  * DELETE
@@ -26,7 +27,11 @@ export default async function deleteMethod ({
26
27
  context.method = 'delete'
27
28
 
28
29
  // Set the ID in context
29
- context.id = params.id
30
+ context.id = requireExistingResourceId(params.id, {
31
+ scopeOptions,
32
+ vars,
33
+ scopeName
34
+ })
30
35
 
31
36
  // Set scopeName in context (needed for broadcasting)
32
37
  context.scopeName = scopeName
@@ -1,6 +1,7 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
2
  import { findRelationshipDefinition } from './common.js'
3
3
  import { buildRelationshipUrl } from '../lib/querying/url-helpers.js'
4
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
4
5
 
5
6
  /**
6
7
  * GET RELATED
@@ -12,9 +13,13 @@ import { buildRelationshipUrl } from '../lib/querying/url-helpers.js'
12
13
  * @param {object} queryParams - Standard query parameters
13
14
  * @returns {Promise<object>} Related resources with full data
14
15
  */
15
- export default async function getRelatedMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
16
+ export default async function getRelatedMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeOptions, scopeName, api }) {
16
17
  context.method = 'getRelated'
17
- context.id = params.id
18
+ context.id = requireExistingResourceId(params.id, {
19
+ scopeOptions,
20
+ vars,
21
+ scopeName
22
+ })
18
23
  context.relationshipName = params.relationshipName
19
24
  context.queryParams = params.queryParams || {}
20
25
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
@@ -1,6 +1,7 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
2
  import { findRelationshipDefinition } from './common.js'
3
3
  import { buildRelationshipUrl } from '../lib/querying/url-helpers.js'
4
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
4
5
 
5
6
  /**
6
7
  * GET RELATIONSHIP
@@ -11,9 +12,13 @@ import { buildRelationshipUrl } from '../lib/querying/url-helpers.js'
11
12
  * @param {string} relationshipName - The name of the relationship
12
13
  * @returns {Promise<object>} Relationship linkage with links
13
14
  */
14
- export default async function getRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
15
+ export default async function getRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeOptions, scopeName, api }) {
15
16
  context.method = 'getRelationship'
16
- context.id = params.id
17
+ context.id = requireExistingResourceId(params.id, {
18
+ scopeOptions,
19
+ vars,
20
+ scopeName
21
+ })
17
22
  context.relationshipName = params.relationshipName
18
23
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
19
24
 
@@ -3,6 +3,7 @@ import { normalizeRecordAttributes } from '../lib/querying-writing/database-valu
3
3
  import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
4
4
  import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
6
7
  import { cascadeConfig } from './common.js'
7
8
 
8
9
  /**
@@ -62,10 +63,15 @@ export default async function getMethod ({
62
63
  includeDepthLimit: vars.includeDepthLimit,
63
64
  sortableFields: vars.sortableFields
64
65
  })
66
+ const normalizedId = requireExistingResourceId(params.id, {
67
+ scopeOptions,
68
+ vars,
69
+ scopeName
70
+ })
65
71
  const validatedRequest = validateRequestContractOrThrow(
66
72
  requestContracts.get,
67
73
  {
68
- id: params.id,
74
+ id: normalizedId,
69
75
  queryParams: context.queryParams
70
76
  },
71
77
  'GET request parameters are invalid'
@@ -1,3 +1,6 @@
1
+ import { handleWriteMethodError } from './common.js'
2
+ import { requireExistingResourceId } from '../lib/querying-writing/resource-id-normalization.js'
3
+
1
4
  /**
2
5
  * PATCH RELATIONSHIP
3
6
  * Completely replaces a relationship
@@ -8,9 +11,13 @@
8
11
  * @param {object|array} relationshipData - New relationship data
9
12
  * @returns {Promise<void>} 204 No Content
10
13
  */
11
- export default async function patchRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api }) {
14
+ export default async function patchRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeOptions, scopeName, api, log }) {
12
15
  context.method = 'patchRelationship'
13
- context.id = params.id
16
+ context.id = requireExistingResourceId(params.id, {
17
+ scopeOptions,
18
+ vars,
19
+ scopeName
20
+ })
14
21
  context.relationshipName = params.relationshipName
15
22
 
16
23
  // Transaction handling
@@ -50,6 +57,6 @@ export default async function patchRelationshipMethod ({ params, context, vars,
50
57
 
51
58
  // 204 No Content
52
59
  } catch (error) {
53
- await handleWriteMethodError(error, context, 'PATCH_RELATIONSHIP', scopeName, log)
60
+ await handleWriteMethodError(error, context, 'PATCH_RELATIONSHIP', scopeName, log, runHooks)
54
61
  }
55
62
  }
@@ -3,6 +3,10 @@ import { processRelationships } from '../lib/writing/relationship-processor.js'
3
3
  import { updateManyToManyRelationship } from '../lib/writing/many-to-many-manipulations.js'
4
4
  import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
+ import {
7
+ requireDocumentResourceId,
8
+ requireExistingResourceId
9
+ } from '../lib/querying-writing/resource-id-normalization.js'
6
10
  import {
7
11
  setupCommonRequest,
8
12
  validateResourceAttributesBeforeWrite,
@@ -59,14 +63,37 @@ export default async function patchMethod ({
59
63
  includeDepthLimit: vars.includeDepthLimit,
60
64
  sortableFields: vars.sortableFields
61
65
  })
66
+ const normalizedPathId = params.id === undefined
67
+ ? null
68
+ : requireExistingResourceId(params.id, {
69
+ scopeOptions,
70
+ vars,
71
+ scopeName
72
+ })
73
+
74
+ if (normalizedPathId && !context.inputRecord?.data?.id) {
75
+ context.inputRecord = {
76
+ ...context.inputRecord,
77
+ data: {
78
+ ...(context.inputRecord?.data || {}),
79
+ id: normalizedPathId
80
+ }
81
+ }
82
+ }
83
+
62
84
  context.inputRecord = validateRequestContractOrThrow(
63
85
  requestContracts.patch,
64
86
  context.inputRecord,
65
87
  'PATCH request body is invalid'
66
88
  )
67
- if (params.id && String(params.id) !== String(context.inputRecord.data.id)) {
89
+ const normalizedBodyId = requireDocumentResourceId(context.inputRecord.data.id, {
90
+ scopeOptions,
91
+ vars
92
+ })
93
+
94
+ if (normalizedPathId && normalizedPathId !== normalizedBodyId) {
68
95
  throw new RestApiValidationError(
69
- `ID mismatch. URL path ID '${params.id}' does not match request body ID '${context.inputRecord.data.id}'`,
96
+ `ID mismatch. URL path ID '${normalizedPathId}' does not match request body ID '${normalizedBodyId}'`,
70
97
  {
71
98
  fields: ['data.id'],
72
99
  violations: [{
@@ -77,7 +104,8 @@ export default async function patchMethod ({
77
104
  }
78
105
  )
79
106
  }
80
- context.id = params.id || context.inputRecord.data.id
107
+ context.inputRecord.data.id = normalizedBodyId
108
+ context.id = normalizedPathId || normalizedBodyId
81
109
 
82
110
  // Validate that user has read access to all related resources
83
111
  // This ensures users can only create relationships to resources they can access
@@ -1,6 +1,10 @@
1
1
  import { RestApiResourceError, RestApiValidationError, RestApiPayloadError } from '../../../lib/rest-api-errors.js'
2
2
  import { findRelationshipDefinition, handleWriteMethodError } from './common.js'
3
3
  import { createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
+ import {
5
+ normalizeRelationshipIdentifiers,
6
+ requireExistingResourceId
7
+ } from '../lib/querying-writing/resource-id-normalization.js'
4
8
 
5
9
  /**
6
10
  * POST RELATIONSHIP
@@ -12,9 +16,13 @@ import { createPivotRecords } from '../lib/writing/many-to-many-manipulations.js
12
16
  * @param {array} relationshipData - Array of resource identifiers to add
13
17
  * @returns {Promise<void>} 204 No Content
14
18
  */
15
- export default async function postRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeName, api, log }) {
19
+ export default async function postRelationshipMethod ({ params, context, vars, helpers, scope, scopes, runHooks, scopeOptions, scopeName, api, log }) {
16
20
  context.method = 'postRelationship'
17
- context.id = params.id
21
+ context.id = requireExistingResourceId(params.id, {
22
+ scopeOptions,
23
+ vars,
24
+ scopeName
25
+ })
18
26
  context.relationshipName = params.relationshipName
19
27
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
20
28
 
@@ -45,6 +53,7 @@ export default async function postRelationshipMethod ({ params, context, vars, h
45
53
  if (!Array.isArray(params.relationshipData)) {
46
54
  throw new RestApiPayloadError('POST to relationship requires array of resource identifiers')
47
55
  }
56
+ params.relationshipData = normalizeRelationshipIdentifiers(params.relationshipData, { api })
48
57
 
49
58
  // Check permissions
50
59
  await runHooks('checkPermissions')
@@ -1,6 +1,7 @@
1
1
  import { processRelationships } from '../lib/writing/relationship-processor.js'
2
2
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
3
3
  import { createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
+ import { requireDocumentResourceId } from '../lib/querying-writing/resource-id-normalization.js'
4
5
  import {
5
6
  setupCommonRequest,
6
7
  validateResourceAttributesBeforeWrite,
@@ -62,6 +63,13 @@ export default async function postMethod ({
62
63
  'POST request body is invalid'
63
64
  )
64
65
 
66
+ if (context.inputRecord?.data?.id !== undefined) {
67
+ context.inputRecord.data.id = requireDocumentResourceId(context.inputRecord.data.id, {
68
+ scopeOptions,
69
+ vars
70
+ })
71
+ }
72
+
65
73
  // Validate that user has read access to all related resources
66
74
  // This ensures users can only create relationships to resources they can access
67
75
  await validateRelationshipAccess(context, context.inputRecord, helpers, api)
@@ -3,6 +3,10 @@ import { processRelationships } from '../lib/writing/relationship-processor.js'
3
3
  import { updateManyToManyRelationship, createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
4
  import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
+ import {
7
+ requireDocumentResourceId,
8
+ requireExistingResourceId
9
+ } from '../lib/querying-writing/resource-id-normalization.js'
6
10
  import {
7
11
  setupCommonRequest,
8
12
  validateCompleteReplacePayload,
@@ -61,14 +65,37 @@ export default async function putMethod ({
61
65
  includeDepthLimit: vars.includeDepthLimit,
62
66
  sortableFields: vars.sortableFields
63
67
  })
68
+ const normalizedPathId = params.id === undefined
69
+ ? null
70
+ : requireExistingResourceId(params.id, {
71
+ scopeOptions,
72
+ vars,
73
+ scopeName
74
+ })
75
+
76
+ if (normalizedPathId && !context.inputRecord?.data?.id) {
77
+ context.inputRecord = {
78
+ ...context.inputRecord,
79
+ data: {
80
+ ...(context.inputRecord?.data || {}),
81
+ id: normalizedPathId
82
+ }
83
+ }
84
+ }
85
+
64
86
  context.inputRecord = validateRequestContractOrThrow(
65
87
  requestContracts.put,
66
88
  context.inputRecord,
67
89
  'PUT request body is invalid'
68
90
  )
69
- if (params.id && String(params.id) !== String(context.inputRecord.data.id)) {
91
+ const normalizedBodyId = requireDocumentResourceId(context.inputRecord.data.id, {
92
+ scopeOptions,
93
+ vars
94
+ })
95
+
96
+ if (normalizedPathId && normalizedPathId !== normalizedBodyId) {
70
97
  throw new RestApiValidationError(
71
- `ID mismatch. URL path ID '${params.id}' does not match request body ID '${context.inputRecord.data.id}'`,
98
+ `ID mismatch. URL path ID '${normalizedPathId}' does not match request body ID '${normalizedBodyId}'`,
72
99
  {
73
100
  fields: ['data.id'],
74
101
  violations: [{
@@ -79,7 +106,8 @@ export default async function putMethod ({
79
106
  }
80
107
  )
81
108
  }
82
- context.id = params.id || context.inputRecord.data.id
109
+ context.inputRecord.data.id = normalizedBodyId
110
+ context.id = normalizedPathId || normalizedBodyId
83
111
 
84
112
  // Validate that user has read access to all related resources
85
113
  // This ensures users can only create relationships to resources they can access
@@ -1,5 +1,3 @@
1
- import { ValidationError } from 'hooked-api'
2
-
3
1
  /**
4
2
  * release
5
3
  * Runs the hook that tells plugins to release resources
@@ -27,6 +27,7 @@ import postRelationshipMethod from './rest-api-plugin-methods/post-relationship.
27
27
  import getRelationshipMethod from './rest-api-plugin-methods/get-relationship.js'
28
28
  import patchRelationshipMethod from './rest-api-plugin-methods/patch-relationship.js'
29
29
  import deleteRelationshipMethod from './rest-api-plugin-methods/delete-relationship.js'
30
+ import { defaultNormalizeResourceId } from './lib/querying-writing/resource-id-normalization.js'
30
31
 
31
32
  export const RestApiPlugin = {
32
33
  name: 'rest-api',
@@ -66,6 +67,9 @@ export const RestApiPlugin = {
66
67
  : true // Default true for better DX in programmatic API
67
68
 
68
69
  vars.idProperty = restApiOptions.idProperty || 'id'
70
+ vars.normalizeId = typeof restApiOptions.normalizeId === 'function'
71
+ ? restApiOptions.normalizeId
72
+ : defaultNormalizeResourceId
69
73
 
70
74
  // Return full record configuration for API and Transport
71
75
  // Support values: 'no', 'minimal', 'full'
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  calculatePosition,
3
3
  getInitialPosition,
4
- isValidPosition,
5
- assignInitialPositions,
6
4
  } from './lib/fractional-positioning.js'
7
5
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
8
6
 
@@ -167,7 +165,7 @@ export const PositioningPlugin = {
167
165
  fieldExists = true
168
166
  } else {
169
167
  // Check if it's a relationship name (as property)
170
- for (const [fieldName, fieldDef] of Object.entries(schema)) {
168
+ for (const [, fieldDef] of Object.entries(schema)) {
171
169
  if (fieldDef.as === filterField) {
172
170
  fieldExists = true
173
171
  break
@@ -419,7 +417,6 @@ export const PositioningPlugin = {
419
417
  log.debug('*** Position filter conditions', { scopeName, filterConditions })
420
418
 
421
419
  // Query existing items in the same position group
422
- const knex = api.knex.instance
423
420
  const tableName = adapter.getTableName()
424
421
  const idProperty = schemaInfo.idProperty
425
422
  const idColumn = adapter.getIdColumn()
@@ -393,7 +393,6 @@ export const SocketIOPlugin = {
393
393
  let io
394
394
 
395
395
  api.startSocketServer = async (server, startOptions = {}) => {
396
- const transportConfig = { ...config.transport, ...startOptions.transport }
397
396
  const authConfig = { ...config.auth, ...(startOptions.auth || {}) }
398
397
  const redisConfig = startOptions.redis ?? pluginOptions.redis ?? null
399
398
 
@@ -92,7 +92,7 @@ export class LocalStorage {
92
92
  return filename
93
93
  // Remove path traversal attempts
94
94
  .replace(/\.\./g, '')
95
- .replace(/[\/\\]/g, '')
95
+ .replace(/[/\\]/g, '')
96
96
  // Remove control characters and special chars
97
97
  .replace(/[^\w\s.-]/g, '_')
98
98
  // Remove leading/trailing dots and spaces
@@ -100,13 +100,16 @@ export class S3Storage {
100
100
  * @returns {Promise<void>}
101
101
  */
102
102
  async delete (url) {
103
- // Extract key from URL
104
103
  const urlObj = new URL(url)
105
- const key = urlObj.pathname.substring(1) // Remove leading /
106
104
 
107
105
  if (this.mockMode) {
108
106
  // Mock mode - just simulate deletion
109
107
  await new Promise(resolve => setTimeout(resolve, 50))
108
+ return
109
+ }
110
+
111
+ if (urlObj.pathname.length === 0) {
112
+ throw new Error('Invalid S3 object URL')
110
113
  }
111
114
 
112
115
  // Real S3 delete would go here
package/quickTest.js CHANGED
@@ -70,6 +70,7 @@ const harperResult = await api.resources.publishers.post({
70
70
  name: 'HarperCollins'
71
71
  }
72
72
  })
73
+ console.log('Created second publisher:', inspect(harperResult))
73
74
 
74
75
  // Method 3: Full JSON:API mode (standards compliant)
75
76
  const oxfordResult = await api.resources.publishers.post({