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.
Files changed (88) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  4. package/docs/API.md +51 -14
  5. package/docs/COMPARISON.md +2 -2
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
  7. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  8. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  9. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  10. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  11. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  12. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  13. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  14. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  15. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
  16. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  17. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  18. package/docs/GUIDE/index.md +18 -9
  19. package/docs/QUICKSTART.md +5 -1
  20. package/docs/index.md +1 -1
  21. package/index.js +3 -2
  22. package/package.json +4 -4
  23. package/plugins/core/connectors/express-plugin.js +97 -184
  24. package/plugins/core/connectors/fastify-plugin.js +206 -0
  25. package/plugins/core/connectors/lib/connector-core.js +195 -0
  26. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  27. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  28. package/plugins/core/lib/dbIntrospection.js +1078 -0
  29. package/plugins/core/lib/dbTablesOperations.js +1104 -160
  30. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
  31. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  32. package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
  33. package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
  34. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  35. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  36. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  37. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  38. package/plugins/core/lib/querying/url-helpers.js +8 -2
  39. package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
  40. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
  41. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  42. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  43. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  44. package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
  45. package/plugins/core/lib/storage/storage-adapter.js +79 -2
  46. package/plugins/core/lib/storage/storage-mapping.js +204 -0
  47. package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
  48. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  49. package/plugins/core/rest-api-knex-plugin.js +249 -211
  50. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  51. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  52. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  53. package/plugins/core/rest-api-plugin-methods/common.js +121 -83
  54. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  55. package/plugins/core/rest-api-plugin-methods/get.js +23 -14
  56. package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
  57. package/plugins/core/rest-api-plugin-methods/post.js +13 -8
  58. package/plugins/core/rest-api-plugin-methods/put.js +45 -21
  59. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  60. package/plugins/core/rest-api-plugin.js +2 -0
  61. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  62. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  63. package/plugins/core/socketio-plugin.js +1 -3
  64. package/tests/autofilter.test.js +463 -0
  65. package/tests/custom-id-simplified-post.test.js +75 -0
  66. package/tests/custom-idproperty-relationships.test.js +33 -0
  67. package/tests/db-introspection.test.js +518 -0
  68. package/tests/db-table-operations.test.js +781 -0
  69. package/tests/fastify-plugin.test.js +141 -0
  70. package/tests/field-getters.test.js +1 -2
  71. package/tests/field-setters.test.js +0 -4
  72. package/tests/fixtures/api-configs.js +188 -219
  73. package/tests/helpers/fake-fastify.js +99 -0
  74. package/tests/http-connectors-parity.test.js +235 -0
  75. package/tests/projected-fields.test.js +319 -0
  76. package/tests/relationships.test.js +3 -3
  77. package/tests/searchschema-knex-behavior.test.js +187 -0
  78. package/tests/searchschema-merge.test.js +21 -69
  79. package/tests/storage-mapping.test.js +210 -0
  80. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  81. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  82. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  83. package/plugins/core/rest-api-access.js +0 -459
  84. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  85. package/tests/multihome.test.js +0 -553
  86. package/tests/multihome.test.js.bak +0 -550
  87. package/tests/ownership-optout.test.js +0 -76
  88. package/tests/rest-api-access.test.js +0 -206
@@ -60,42 +60,48 @@ export default async function registerScopeRoutes ({ context, api, vars, log })
60
60
  await api.addRoute({
61
61
  method: 'GET',
62
62
  path: scopePath,
63
- handler: createRouteHandler(scopeName, 'query')
63
+ handler: createRouteHandler(scopeName, 'query'),
64
+ routeMeta: { kind: 'resource', scopeName, operation: 'query' }
64
65
  })
65
66
 
66
67
  // GET /api/{scope}/{id} - Get single resource
67
68
  await api.addRoute({
68
69
  method: 'GET',
69
70
  path: `${scopePath}/:id`,
70
- handler: createRouteHandler(scopeName, 'get')
71
+ handler: createRouteHandler(scopeName, 'get'),
72
+ routeMeta: { kind: 'resource', scopeName, operation: 'get' }
71
73
  })
72
74
 
73
75
  // POST /api/{scope} - Create resource
74
76
  await api.addRoute({
75
77
  method: 'POST',
76
78
  path: scopePath,
77
- handler: createRouteHandler(scopeName, 'post')
79
+ handler: createRouteHandler(scopeName, 'post'),
80
+ routeMeta: { kind: 'resource', scopeName, operation: 'post' }
78
81
  })
79
82
 
80
83
  // PUT /api/{scope}/{id} - Replace resource
81
84
  await api.addRoute({
82
85
  method: 'PUT',
83
86
  path: `${scopePath}/:id`,
84
- handler: createRouteHandler(scopeName, 'put')
87
+ handler: createRouteHandler(scopeName, 'put'),
88
+ routeMeta: { kind: 'resource', scopeName, operation: 'put' }
85
89
  })
86
90
 
87
91
  // PATCH /api/{scope}/{id} - Update resource
88
92
  await api.addRoute({
89
93
  method: 'PATCH',
90
94
  path: `${scopePath}/:id`,
91
- handler: createRouteHandler(scopeName, 'patch')
95
+ handler: createRouteHandler(scopeName, 'patch'),
96
+ routeMeta: { kind: 'resource', scopeName, operation: 'patch' }
92
97
  })
93
98
 
94
99
  // DELETE /api/{scope}/{id} - Delete resource
95
100
  await api.addRoute({
96
101
  method: 'DELETE',
97
102
  path: `${scopePath}/:id`,
98
- handler: createRouteHandler(scopeName, 'delete')
103
+ handler: createRouteHandler(scopeName, 'delete'),
104
+ routeMeta: { kind: 'resource', scopeName, operation: 'delete' }
99
105
  })
100
106
 
101
107
  log.info(`Routes registered for scope '${scopeName}'`)
@@ -0,0 +1,41 @@
1
+ export default async function applyQueryFiltersMethod ({ context, params = {}, runHooks }) {
2
+ const { query, filters, storageAdapter, ...knexQuery } = params
3
+
4
+ if (!query) {
5
+ return query
6
+ }
7
+
8
+ const previousKnexQuery = context.knexQuery
9
+ const previousStorageAdapter = context.storageAdapter
10
+
11
+ context.knexQuery = {
12
+ query,
13
+ filters,
14
+ storageAdapter,
15
+ adapter: storageAdapter,
16
+ ...knexQuery
17
+ }
18
+
19
+ if (storageAdapter) {
20
+ context.storageAdapter = storageAdapter
21
+ }
22
+
23
+ try {
24
+ await runHooks('knexQueryFiltering')
25
+ return {
26
+ query: context.knexQuery?.query || query
27
+ }
28
+ } finally {
29
+ if (previousKnexQuery === undefined) {
30
+ delete context.knexQuery
31
+ } else {
32
+ context.knexQuery = previousKnexQuery
33
+ }
34
+
35
+ if (previousStorageAdapter === undefined) {
36
+ delete context.storageAdapter
37
+ } else {
38
+ context.storageAdapter = previousStorageAdapter
39
+ }
40
+ }
41
+ }
@@ -1,9 +1,10 @@
1
1
  import {
2
- RestApiValidationError,
3
- RestApiPayloadError
2
+ RestApiResourceError,
3
+ RestApiValidationError
4
4
  } from '../../../lib/rest-api-errors.js'
5
5
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
6
6
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
7
+ import { unwrapQueryBuilderState } from '../lib/querying/query-builder-utils.js'
7
8
 
8
9
  /**
9
10
  * Gets an enhanced logger instance with full error details and stack traces
@@ -140,56 +141,6 @@ export async function setupCommonRequest ({ params, context, vars, scopes, scope
140
141
  { inputRecord: context.inputRecord },
141
142
  { context: { scopeName, schemaStructure, schemaRelationships } }
142
143
  )
143
- } else {
144
- // Strict mode: validate no belongsTo fields in attributes
145
- if (context.inputRecord?.data?.attributes) {
146
- for (const [key, fieldDef] of Object.entries(schemaStructure)) {
147
- if (fieldDef.belongsTo && key in context.inputRecord.data.attributes) {
148
- throw new RestApiValidationError(
149
- `Field '${key}' is a foreign key and must be set via relationships, not attributes`,
150
- { fields: [`data.attributes.${key}`] }
151
- )
152
- }
153
- }
154
- }
155
- }
156
-
157
- if (context.inputRecord.data.type !== scopeName) {
158
- throw new RestApiValidationError(
159
- `Resource type mismatch. Expected '${scopeName}' but got '${context.inputRecord.data.type}'`,
160
- {
161
- fields: ['data.type'],
162
- violations: [{
163
- field: 'data.type',
164
- rule: 'resource_type_match',
165
- message: `Resource type must be '${scopeName}'`
166
- }]
167
- }
168
- )
169
- }
170
-
171
- // Remove included validation since JSON:API doesn't support it
172
- if (context.inputRecord.included) {
173
- throw new RestApiPayloadError(
174
- context.method + ' requests cannot include an "included" array. JSON:API does not support creating multiple resources in a single request.',
175
- { path: 'included', expected: 'undefined', received: 'array' }
176
- )
177
- }
178
-
179
- // If both URL path ID and request body ID are provided, they must match
180
- // Convert both to strings for comparison since databases may return numeric IDs
181
- if (context.id && context.inputRecord.data.id && String(context.id) !== String(context.inputRecord.data.id)) {
182
- throw new RestApiValidationError(
183
- `ID mismatch. URL path ID '${context.id}' does not match request body ID '${context.inputRecord.data.id}'`,
184
- {
185
- fields: ['data.id'],
186
- violations: [{
187
- field: 'data.id',
188
- rule: 'id_consistency',
189
- message: 'Request body ID must match URL path ID when both are provided'
190
- }]
191
- }
192
- )
193
144
  }
194
145
 
195
146
  // Return key schema-related objects for direct use in the main methods
@@ -261,6 +212,61 @@ export const getMethodHookSuffix = (method) => {
261
212
  return method.charAt(0).toUpperCase() + method.slice(1)
262
213
  }
263
214
 
215
+ export const validateCompleteReplacePayload = ({
216
+ context,
217
+ belongsToUpdates
218
+ }) => {
219
+ const schemaInfo = context.schemaInfo || {}
220
+ const schemaStructure = schemaInfo.schemaStructure || {}
221
+ const idProperty = schemaInfo.idProperty || 'id'
222
+ const originalInputAttributes = context.originalInputAttributes || context.inputRecord?.data?.attributes || {}
223
+ const missingFields = []
224
+
225
+ for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
226
+ if (!fieldDef) continue
227
+ if (fieldName === idProperty) continue
228
+ if (fieldDef.computed === true || fieldDef.virtual === true) continue
229
+ if (fieldDef.type === undefined) continue
230
+
231
+ if (fieldDef.belongsTo && fieldDef.as) {
232
+ if (Object.hasOwn(belongsToUpdates, fieldName)) continue
233
+
234
+ const existingRelationshipData = context.minimalRecord?.relationships?.[fieldDef.as]?.data
235
+ if (existingRelationshipData?.id !== undefined && existingRelationshipData?.id !== null) {
236
+ missingFields.push({
237
+ field: `data.relationships.${fieldDef.as}.data.id`,
238
+ message: `PUT requests must explicitly include relationship '${fieldDef.as}' when it already has a value`
239
+ })
240
+ }
241
+ continue
242
+ }
243
+
244
+ if (Object.hasOwn(originalInputAttributes, fieldName)) continue
245
+
246
+ const existingValue = context.minimalRecord?.attributes?.[fieldName]
247
+ if (existingValue !== undefined && existingValue !== null) {
248
+ missingFields.push({
249
+ field: `data.attributes.${fieldName}`,
250
+ message: `PUT requests must explicitly include attribute '${fieldName}' when it already has a value`
251
+ })
252
+ }
253
+ }
254
+
255
+ if (missingFields.length > 0) {
256
+ throw new RestApiValidationError(
257
+ 'PUT request omits persisted fields that already have stored values',
258
+ {
259
+ fields: missingFields.map(({ field }) => field),
260
+ violations: missingFields.map(({ field, message }) => ({
261
+ field,
262
+ rule: 'complete_replacement',
263
+ message
264
+ }))
265
+ }
266
+ )
267
+ }
268
+ }
269
+
264
270
  /**
265
271
  * Validates resource attributes before write operations
266
272
  *
@@ -285,9 +291,8 @@ export const validateResourceAttributesBeforeWrite = async ({
285
291
  await runHooks('beforeSchemaValidate')
286
292
  await runHooks(`beforeSchemaValidate${methodSpecificHookSuffix}`)
287
293
 
288
- // Store original input attributes before validation adds defaults (primarily for POST)
289
- // Only if it's not already set and the current method is POST
290
- if (!context.originalInputAttributes && context.method === 'post') {
294
+ // Store original input attributes before validation adds defaults or casts.
295
+ if (!context.originalInputAttributes) {
291
296
  context.originalInputAttributes = { ...(context.inputRecord.data.attributes || {}) }
292
297
  }
293
298
 
@@ -361,9 +366,13 @@ export const validateResourceAttributesBeforeWrite = async ({
361
366
  return acc
362
367
  }, {})
363
368
 
364
- const validationOptions = isPartialValidation ? { onlyObjectValues: true } : {}
369
+ const validationMethod = isPartialValidation
370
+ ? 'patch'
371
+ : context.method === 'put'
372
+ ? 'replace'
373
+ : 'create'
365
374
 
366
- const { validatedObject, errors } = await schema.validate(attributesForValidation, validationOptions)
375
+ const { validatedObject, errors } = await schema[validationMethod](attributesForValidation)
367
376
 
368
377
  if (Object.keys(errors).length > 0) {
369
378
  // --- START OF MODIFICATION ---
@@ -416,11 +425,10 @@ export const validateResourceAttributesBeforeWrite = async ({
416
425
  * @param {object} context - The context object containing authentication info
417
426
  * @param {object} inputRecord - The input record containing relationships to validate
418
427
  * @param {object} helpers - Data helpers including dataGetMinimal
419
- * @param {function} runHooks - Function to run hooks
420
428
  * @param {object} api - API instance to access resources
421
429
  * @throws {Error} If user doesn't have access to any related resource
422
430
  */
423
- export const validateRelationshipAccess = async (context, inputRecord, helpers, runHooks, api) => {
431
+ export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
424
432
  if (!inputRecord?.data?.relationships) return
425
433
 
426
434
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
@@ -442,19 +450,47 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
442
450
  id: item.id,
443
451
  schemaInfo: relatedScope.vars.schemaInfo,
444
452
  scopeName: item.type,
453
+ queryParams: {},
445
454
  method: 'get', // We're checking read permission
446
455
  isUpdate: false
447
456
  }
448
457
 
458
+ const targetScope = api.resources?.[item.type]
459
+ const applyQueryFilters = targetScope?.applyQueryFilters
460
+ ? async ({ query, filters, tableName, db, scopeName, storageAdapter, isAnyApi }) => {
461
+ const queryState = await targetScope.applyQueryFilters({
462
+ query,
463
+ filters,
464
+ schemaInfo: getContext.schemaInfo,
465
+ scopeName,
466
+ tableName,
467
+ db,
468
+ isAnyApi,
469
+ storageAdapter
470
+ }, getContext)
471
+
472
+ return {
473
+ query: unwrapQueryBuilderState(queryState, query)
474
+ }
475
+ }
476
+ : undefined
477
+
449
478
  // Get the minimal record
450
479
  const record = await helpers.dataGetMinimal({
451
480
  scopeName: item.type,
452
481
  context: getContext,
453
- runHooks
482
+ applyQueryFilters
454
483
  })
455
484
 
456
485
  if (!record) {
457
- throw new Error(`Cannot create relationship to non-existent ${item.type} with id ${item.id}`)
486
+ throw new RestApiResourceError(
487
+ `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
488
+ {
489
+ subtype: 'not_found',
490
+ resourceType: item.type,
491
+ resourceId: item.id
492
+ }
493
+ )
458
494
  }
459
495
 
460
496
  // Check permissions using the related scope's checkPermissions
@@ -493,29 +529,31 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
493
529
  const transformedAttributes = { ...attributes }
494
530
 
495
531
  for (const fieldName of sortedSetterFields) {
496
- // Only process fields that exist in the attributes
497
- if (fieldName in transformedAttributes) {
498
- const setterInfo = fieldSetters[fieldName]
499
- try {
500
- const setterContext = {
501
- attributes: transformedAttributes, // Current state with previous setters applied
502
- fieldName,
503
- originalValue: attributes[fieldName],
504
- originalAttributes: attributes,
505
- scopeName: context.scopeName,
506
- method: context.method,
507
- api,
508
- helpers,
509
- auth: context.auth
510
- }
511
- transformedAttributes[fieldName] = await setterInfo.setter(
512
- transformedAttributes[fieldName],
513
- setterContext
514
- )
515
- } catch (error) {
516
- console.warn(`Setter for field '${fieldName}' failed:`, error)
517
- // Keep original value if setter fails
532
+ const setterInfo = fieldSetters[fieldName]
533
+ const fieldPresent = fieldName in transformedAttributes
534
+ const hasDependencies = Array.isArray(setterInfo.runSetterAfter) && setterInfo.runSetterAfter.length > 0
535
+
536
+ if (!fieldPresent && !hasDependencies) continue
537
+
538
+ try {
539
+ const setterContext = {
540
+ attributes: transformedAttributes, // Current state with previous setters applied
541
+ fieldName,
542
+ originalValue: attributes[fieldName],
543
+ originalAttributes: attributes,
544
+ scopeName: context.scopeName,
545
+ method: context.method,
546
+ api,
547
+ helpers,
548
+ auth: context.auth
518
549
  }
550
+ transformedAttributes[fieldName] = await setterInfo.setter(
551
+ transformedAttributes[fieldName],
552
+ setterContext
553
+ )
554
+ } catch (error) {
555
+ console.warn(`Setter for field '${fieldName}' failed:`, error)
556
+ // Keep original value if setter fails
519
557
  }
520
558
  }
521
559
 
@@ -33,6 +33,7 @@ export default async function enrichAttributesMethod ({ context, params, runHook
33
33
  const computedFields = scopes[scopeName]?.vars?.schemaInfo?.computed || {}
34
34
  const fieldGetters = scopes[scopeName]?.vars?.schemaInfo?.fieldGetters || {}
35
35
  const sortedGetterFields = scopes[scopeName]?.vars?.schemaInfo?.sortedGetterFields || []
36
+ const queryFields = scopes[scopeName]?.vars?.queryFields || {}
36
37
 
37
38
  // Make a copy of attributes for transformation
38
39
  const transformedAttributes = { ...attributes }
@@ -72,7 +73,11 @@ export default async function enrichAttributesMethod ({ context, params, runHook
72
73
  // Filter hidden fields from attributes based on visibility rules
73
74
  // This removes hidden:true fields and normallyHidden:true fields (unless requested)
74
75
  const requestedFields = parentContext?.queryParams?.fields?.[scopeName]
75
- const filteredAttributes = filterHiddenFields(transformedAttributes, { structure: schemaStructure }, requestedFields)
76
+ const filteredAttributes = filterHiddenFields(
77
+ transformedAttributes,
78
+ { structure: { ...schemaStructure, ...queryFields } },
79
+ requestedFields
80
+ )
76
81
 
77
82
  // Handle virtual fields - they need to be added from parent context if available
78
83
  // Virtual fields come from input and need to be preserved in responses
@@ -1,8 +1,8 @@
1
1
  import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
- import { validateGetPayload } from '../lib/querying-writing/payload-validators.js'
3
2
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
4
3
  import { getRequestedComputedFields } from '../lib/querying-writing/knex-field-helpers.js'
5
4
  import { transformJsonApiToSimplified } from '../lib/querying-writing/simplified-helpers.js'
5
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
6
  import { cascadeConfig } from './common.js'
7
7
 
8
8
  /**
@@ -41,11 +41,11 @@ export default async function getMethod ({
41
41
 
42
42
  // Assign common context properties
43
43
  context.schemaInfo = scopes[scopeName].vars.schemaInfo
44
- context.queryParams = params.queryParams || {}
45
-
46
- // These only make sense as parameter per query
47
- context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {})
48
- context.queryParams.include = cascadeConfig('include', [context.queryParams], [])
44
+ const rawQueryParams = params.queryParams || {}
45
+ context.queryParams = {
46
+ fields: cascadeConfig('fields', [rawQueryParams], {}),
47
+ include: cascadeConfig('include', [rawQueryParams], [])
48
+ }
49
49
 
50
50
  context.transaction = params.transaction
51
51
  context.db = context.transaction || api.knex.instance
@@ -56,14 +56,23 @@ export default async function getMethod ({
56
56
  const schemaStructure = context.schemaInfo.schemaInstance.structure
57
57
  const schemaRelationships = context.schemaInfo.schemaRelationships
58
58
 
59
- context.id = params.id
60
-
61
- // Validate GET request to ensure required parameters are present and properly formatted.
62
- // This checks that 'id' parameter exists and is not empty (you can't GET without an ID),
63
- // validates 'include' contains valid relationship names (not arbitrary fields), and ensures
64
- // 'fields' for sparse fieldsets follow the format fields[type]=comma,separated,list.
65
- // Example: validates id: '123' exists, include: ['author', 'tags'] are real relationships.
66
- validateGetPayload({ id: context.id, queryParams: context.queryParams }, vars.includeDepthLimit)
59
+ const requestContracts = getRequestContracts({
60
+ scopeName,
61
+ schemaInfo: context.schemaInfo,
62
+ includeDepthLimit: vars.includeDepthLimit,
63
+ sortableFields: vars.sortableFields
64
+ })
65
+ const validatedRequest = validateRequestContractOrThrow(
66
+ requestContracts.get,
67
+ {
68
+ id: params.id,
69
+ queryParams: context.queryParams
70
+ },
71
+ 'GET request parameters are invalid'
72
+ )
73
+
74
+ context.id = validatedRequest.id
75
+ context.queryParams = validatedRequest.queryParams || {}
67
76
 
68
77
  // Fetch minimal record for authorization checks
69
78
  const minimalRecord = await helpers.dataGetMinimal({
@@ -1,8 +1,8 @@
1
- import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
- import { validatePatchPayload } from '../lib/querying-writing/payload-validators.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
3
2
  import { processRelationships } from '../lib/writing/relationship-processor.js'
4
3
  import { updateManyToManyRelationship } from '../lib/writing/many-to-many-manipulations.js'
5
4
  import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js'
5
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
6
  import {
7
7
  setupCommonRequest,
8
8
  validateResourceAttributesBeforeWrite,
@@ -49,23 +49,39 @@ export default async function patchMethod ({
49
49
  api,
50
50
  helpers
51
51
  })
52
- context.id = context.inputRecord.data.id
53
-
54
52
  // Run early hooks for pre-processing (e.g., file handling)
55
53
  await runHooks('beforeProcessing')
56
54
  await runHooks('beforeProcessingPatch')
57
55
 
58
- // Validate PATCH payload to ensure the partial update actually contains changes.
59
- // PATCH requests must include either attributes to update or relationships to modify -
60
- // an empty PATCH is invalid. This prevents accidental no-op requests and ensures clients
61
- // are explicit about what they want to change. Unlike PUT, PATCH preserves all fields
62
- // not mentioned in the request.
63
- // Example: data must have either attributes: {title: 'New'} or relationships: {author: {...}}
64
- validatePatchPayload(context.inputRecord, scopes)
56
+ const requestContracts = getRequestContracts({
57
+ scopeName,
58
+ schemaInfo: context.schemaInfo,
59
+ includeDepthLimit: vars.includeDepthLimit,
60
+ sortableFields: vars.sortableFields
61
+ })
62
+ context.inputRecord = validateRequestContractOrThrow(
63
+ requestContracts.patch,
64
+ context.inputRecord,
65
+ 'PATCH request body is invalid'
66
+ )
67
+ if (params.id && String(params.id) !== String(context.inputRecord.data.id)) {
68
+ throw new RestApiValidationError(
69
+ `ID mismatch. URL path ID '${params.id}' does not match request body ID '${context.inputRecord.data.id}'`,
70
+ {
71
+ fields: ['data.id'],
72
+ violations: [{
73
+ field: 'data.id',
74
+ rule: 'id_consistency',
75
+ message: 'Request body ID must match URL path ID when both are provided'
76
+ }]
77
+ }
78
+ )
79
+ }
80
+ context.id = params.id || context.inputRecord.data.id
65
81
 
66
82
  // Validate that user has read access to all related resources
67
83
  // This ensures users can only create relationships to resources they can access
68
- await validateRelationshipAccess(context, context.inputRecord, helpers, runHooks, api)
84
+ await validateRelationshipAccess(context, context.inputRecord, helpers, api)
69
85
 
70
86
  // Extract foreign keys from JSON:API relationships and prepare many-to-many operations
71
87
  // Example: relationships.author -> author_id: '123' for storage
@@ -1,5 +1,5 @@
1
- import { validatePostPayload } from '../lib/querying-writing/payload-validators.js'
2
1
  import { processRelationships } from '../lib/writing/relationship-processor.js'
2
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
3
3
  import { createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
4
  import {
5
5
  setupCommonRequest,
@@ -50,16 +50,21 @@ export default async function postMethod ({
50
50
  await runHooks('beforeProcessing')
51
51
  await runHooks('beforeProcessingPost')
52
52
 
53
- // Validate POST payload to ensure it follows JSON:API format and references valid resources.
54
- // This checks the payload has required 'data' object with 'type' and 'attributes', validates
55
- // that data.type matches a real resource type (preventing creation of non-existent resources),
56
- // and ensures any relationships reference valid resource types with proper ID format.
57
- // Example: data.type: 'articles' must be a registered scope, relationships.author must reference 'users'.
58
- validatePostPayload(context.inputRecord, scopes)
53
+ const requestContracts = getRequestContracts({
54
+ scopeName,
55
+ schemaInfo: context.schemaInfo,
56
+ includeDepthLimit: vars.includeDepthLimit,
57
+ sortableFields: vars.sortableFields
58
+ })
59
+ context.inputRecord = validateRequestContractOrThrow(
60
+ requestContracts.post,
61
+ context.inputRecord,
62
+ 'POST request body is invalid'
63
+ )
59
64
 
60
65
  // Validate that user has read access to all related resources
61
66
  // This ensures users can only create relationships to resources they can access
62
- await validateRelationshipAccess(context, context.inputRecord, helpers, runHooks, api)
67
+ await validateRelationshipAccess(context, context.inputRecord, helpers, api)
63
68
 
64
69
  // Extract foreign keys from JSON:API relationships and prepare many-to-many operations
65
70
  // Example: relationships.author -> author_id: '123' for storage
@@ -1,10 +1,11 @@
1
- import { RestApiResourceError } from '../../../lib/rest-api-errors.js'
2
- import { validatePutPayload } from '../lib/querying-writing/payload-validators.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../../lib/rest-api-errors.js'
3
2
  import { processRelationships } from '../lib/writing/relationship-processor.js'
4
3
  import { updateManyToManyRelationship, createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
5
4
  import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js'
5
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
6
  import {
7
7
  setupCommonRequest,
8
+ validateCompleteReplacePayload,
8
9
  validateResourceAttributesBeforeWrite,
9
10
  validateRelationshipAccess,
10
11
  applyFieldSetters,
@@ -17,7 +18,9 @@ import {
17
18
  * PUT
18
19
  * Updates an existing top-level resource by completely replacing it.
19
20
  * This method supports updating both attributes and relationships (1:1 and n:n).
20
- * PUT is a complete replacement - any relationships not provided will be removed/nulled.
21
+ * Existing persisted values cannot be silently dropped: if a stored attribute or belongsTo
22
+ * relationship already has a value, PUT must include it explicitly. Relationship collections
23
+ * still follow replacement semantics when a relationships object is provided.
21
24
  * This method does NOT support creating new related resources via an `included` array.
22
25
  */
23
26
  export default async function putMethod ({
@@ -48,23 +51,39 @@ export default async function putMethod ({
48
51
  api,
49
52
  helpers
50
53
  })
51
- context.id = context.inputRecord.data.id
52
-
53
54
  // Run early hooks for pre-processing (e.g., file handling)
54
55
  await runHooks('beforeProcessing')
55
56
  await runHooks('beforeProcessingPut')
56
57
 
57
- // Validate PUT payload to ensure it's a complete resource replacement operation.
58
- // PUT requires the full resource representation including ID (unlike POST which generates ID).
59
- // It validates that data.id matches the URL parameter, prevents 'included' array (which is
60
- // read-only), and ensures the payload represents a complete replacement. Any fields not
61
- // provided will be removed or reset to defaults - this is the key difference from PATCH.
62
- // Example: PUT to /articles/123 must have data.id: '123' and all required fields.
63
- validatePutPayload(context.inputRecord, scopes)
58
+ const requestContracts = getRequestContracts({
59
+ scopeName,
60
+ schemaInfo: context.schemaInfo,
61
+ includeDepthLimit: vars.includeDepthLimit,
62
+ sortableFields: vars.sortableFields
63
+ })
64
+ context.inputRecord = validateRequestContractOrThrow(
65
+ requestContracts.put,
66
+ context.inputRecord,
67
+ 'PUT request body is invalid'
68
+ )
69
+ if (params.id && String(params.id) !== String(context.inputRecord.data.id)) {
70
+ throw new RestApiValidationError(
71
+ `ID mismatch. URL path ID '${params.id}' does not match request body ID '${context.inputRecord.data.id}'`,
72
+ {
73
+ fields: ['data.id'],
74
+ violations: [{
75
+ field: 'data.id',
76
+ rule: 'id_consistency',
77
+ message: 'Request body ID must match URL path ID when both are provided'
78
+ }]
79
+ }
80
+ )
81
+ }
82
+ context.id = params.id || context.inputRecord.data.id
64
83
 
65
84
  // Validate that user has read access to all related resources
66
85
  // This ensures users can only create relationships to resources they can access
67
- await validateRelationshipAccess(context, context.inputRecord, helpers, runHooks, api)
86
+ await validateRelationshipAccess(context, context.inputRecord, helpers, api)
68
87
 
69
88
  // Extract foreign keys from JSON:API relationships and prepare many-to-many operations
70
89
  // Example: relationships.author -> author_id: '123' for storage
@@ -74,13 +93,6 @@ export default async function putMethod ({
74
93
  { context }
75
94
  )
76
95
 
77
- await validateResourceAttributesBeforeWrite({
78
- context,
79
- schema,
80
- belongsToUpdates,
81
- runHooks
82
- })
83
-
84
96
  // Check existence first
85
97
  context.exists = await helpers.dataExists({
86
98
  scopeName,
@@ -108,8 +120,20 @@ export default async function putMethod ({
108
120
  context.minimalRecord = minimalRecord
109
121
  }
110
122
 
123
+ validateCompleteReplacePayload({
124
+ context,
125
+ belongsToUpdates
126
+ })
127
+
128
+ await validateResourceAttributesBeforeWrite({
129
+ context,
130
+ schema,
131
+ belongsToUpdates,
132
+ runHooks
133
+ })
134
+
111
135
  // For PUT, we also need to handle relationships that are NOT provided
112
- // (they should be set to null/empty as PUT is a complete replacement)
136
+ // (they should be set to null/empty when a relationships object is provided)
113
137
  const allRelationships = {}
114
138
 
115
139
  // Collect all defined relationships for this resource