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.
- package/README.md +22 -1
- package/docs/API.md +145 -24
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +83 -4
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +26 -34
- package/plugins/core/connectors/express-plugin.js +0 -6
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/lib/anyapi/schema-utils.js +43 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +4 -1
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +0 -1
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +11 -18
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +1 -2
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +0 -2
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +22 -2
- package/plugins/core/lib/storage/storage-adapter.js +3 -2
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -2
- package/plugins/core/rest-api-anyapi-knex-plugin.js +194 -102
- package/plugins/core/rest-api-knex-plugin.js +23 -16
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +1 -0
- package/plugins/core/rest-api-plugin-methods/common.js +27 -9
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +11 -2
- package/plugins/core/rest-api-plugin-methods/delete.js +6 -1
- package/plugins/core/rest-api-plugin-methods/get-related.js +7 -2
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +7 -2
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +10 -3
- package/plugins/core/rest-api-plugin-methods/patch.js +31 -3
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +11 -2
- package/plugins/core/rest-api-plugin-methods/post.js +8 -0
- package/plugins/core/rest-api-plugin-methods/put.js +31 -3
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +4 -0
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +1 -1
- package/plugins/storage/s3-storage.js +5 -2
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +7 -0
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +21 -6
- package/tests/fixtures/api-configs.js +133 -0
- package/tests/id-normalization.test.js +444 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +11 -11
- 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
|
|
12
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
950
|
-
|
|
951
|
-
|
|
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 =
|
|
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(
|
|
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 [
|
|
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:
|
|
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
|
-
|
|
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 '${
|
|
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.
|
|
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
|
-
|
|
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 '${
|
|
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.
|
|
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
|
|
@@ -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 [
|
|
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(/[
|
|
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({
|