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
@@ -1,6 +1,5 @@
1
1
  import { Api } from 'hooked-api'
2
- import { RestApiPlugin, RestApiKnexPlugin, RestApiAnyapiKnexPlugin } from '../../index.js'
3
- import { AccessPlugin } from '../../plugins/core/rest-api-access.js'
2
+ import { RestApiPlugin, RestApiKnexPlugin, RestApiAnyapiKnexPlugin, QueryProjectionsPlugin } from '../../index.js'
4
3
  import { ExpressPlugin } from '../../plugins/core/connectors/express-plugin.js'
5
4
  import express from 'express'
6
5
  import { createServer } from 'http'
@@ -126,7 +125,8 @@ export async function createBasicApi (knex, pluginOptions = {}) {
126
125
  country_id: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
127
126
  },
128
127
  relationships: {
129
- books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' }
128
+ books: { type: 'hasMany', target: 'books', foreignKey: 'publisher_id' },
129
+ authors: { type: 'hasMany', target: 'authors', foreignKey: 'publisher_id' }
130
130
  },
131
131
  tableName: `${tablePrefix}_publishers`
132
132
  })
@@ -137,7 +137,8 @@ export async function createBasicApi (knex, pluginOptions = {}) {
137
137
  await api.addResource('authors', {
138
138
  schema: {
139
139
  id: { type: 'id' },
140
- name: { type: 'string', required: true, max: 200 }
140
+ name: { type: 'string', required: true, max: 200 },
141
+ publisher_id: { type: 'number', nullable: true, belongsTo: 'publishers', as: 'publisher' }
141
142
  },
142
143
  relationships: {
143
144
  books: { type: 'manyToMany', through: 'book_authors', foreignKey: 'author_id', otherKey: 'book_id' }
@@ -197,113 +198,6 @@ export async function createBasicApi (knex, pluginOptions = {}) {
197
198
  }
198
199
  }
199
200
 
200
- export async function createAccessControlApi (knex, pluginOptions = {}) {
201
- const apiName = pluginOptions.apiName || 'access-test-api'
202
- const tablePrefix = pluginOptions.tablePrefix || 'access'
203
- if (storageMode.isAnyApi()) {
204
- storageMode.clearRegistry()
205
- }
206
-
207
- const api = new Api({
208
- name: apiName,
209
- log: { level: process.env.LOG_LEVEL || 'info' }
210
- })
211
-
212
- const previousTenant = storageMode.currentTenant
213
- const tenantId = storageMode.isAnyApi()
214
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
215
- : storageMode.defaultTenant
216
- if (storageMode.isAnyApi()) {
217
- storageMode.setCurrentTenant(tenantId)
218
- }
219
-
220
- await api.use(RestApiPlugin, {
221
- simplifiedApi: false,
222
- simplifiedTransport: false,
223
- returnRecordApi: {
224
- post: true,
225
- put: false,
226
- patch: false
227
- },
228
- returnRecordTransport: {
229
- post: 'full',
230
- put: 'no',
231
- patch: 'minimal'
232
- },
233
- sortableFields: ['id', 'name', 'user_id']
234
- })
235
- await useStoragePlugin(api, knex, { tenantId })
236
- await resetAnyApiTables(knex)
237
-
238
- try {
239
- await api.use(AccessPlugin, pluginOptions.access || {})
240
-
241
- await api.addResource('users', {
242
- schema: {
243
- id: { type: 'id' },
244
- email: { type: 'string', required: true },
245
- display_name: { type: 'string', nullable: true }
246
- },
247
- tableName: `${tablePrefix}_users`
248
- })
249
- await api.resources.users.createKnexTable()
250
- mapTable(`${tablePrefix}_users`, 'users')
251
-
252
- await api.addResource('projects', {
253
- schema: {
254
- id: { type: 'id' },
255
- name: { type: 'string', required: true },
256
- status: { type: 'string', nullable: true },
257
- user_id: { type: 'number', belongsTo: 'users', as: 'user', nullable: true }
258
- },
259
- relationships: {
260
- user: { type: 'belongsTo', target: 'users', foreignKey: 'user_id' }
261
- },
262
- auth: {
263
- query: ['authenticated'],
264
- get: ['owns'],
265
- post: ['authenticated'],
266
- patch: ['owns'],
267
- delete: ['owns']
268
- },
269
- ownership: true,
270
- tableName: `${tablePrefix}_projects`
271
- })
272
- await api.resources.projects.createKnexTable()
273
- mapTable(`${tablePrefix}_projects`, 'projects')
274
-
275
- await api.addResource('notes', {
276
- schema: {
277
- id: { type: 'id' },
278
- project_id: { type: 'number', belongsTo: 'projects', as: 'project', nullable: true },
279
- body: { type: 'string', required: true },
280
- user_id: { type: 'number', belongsTo: 'users', as: 'user', nullable: true }
281
- },
282
- relationships: {
283
- project: { type: 'belongsTo', target: 'projects', foreignKey: 'project_id' },
284
- user: { type: 'belongsTo', target: 'users', foreignKey: 'user_id' }
285
- },
286
- auth: {
287
- query: ['authenticated'],
288
- get: ['owns'],
289
- post: ['authenticated'],
290
- patch: ['owns'],
291
- delete: ['owns']
292
- },
293
- ownership: true,
294
- tableName: `${tablePrefix}_notes`
295
- })
296
- await api.resources.notes.createKnexTable()
297
- mapTable(`${tablePrefix}_notes`, 'notes')
298
-
299
- return api
300
- } finally {
301
- if (storageMode.isAnyApi()) {
302
- storageMode.setCurrentTenant(previousTenant)
303
- }
304
- }
305
- }
306
-
307
201
  /**
308
202
  * Creates a basic API with bulk operations enabled
309
203
  */
@@ -1071,6 +965,92 @@ export async function createFieldGettersApi (knex, pluginOptions = {}) {
1071
965
  return api
1072
966
  }
1073
967
 
968
+ export async function createProjectedFieldsApi (knex, pluginOptions = {}) {
969
+ const api = new Api({
970
+ name: 'projected-fields-test-api',
971
+ log: { level: process.env.LOG_LEVEL || 'info' }
972
+ })
973
+
974
+ const tenantId = storageMode.isAnyApi() ? 'projected_fields_tenant' : storageMode.defaultTenant
975
+ const previousTenant = storageMode.currentTenant
976
+ if (storageMode.isAnyApi()) {
977
+ storageMode.setCurrentTenant(tenantId)
978
+ }
979
+
980
+ await api.use(RestApiPlugin, {
981
+ simplifiedApi: false,
982
+ simplifiedTransport: false,
983
+ returnRecordApi: {
984
+ post: true,
985
+ put: false,
986
+ patch: false
987
+ },
988
+ returnRecordTransport: {
989
+ post: 'full',
990
+ put: 'no',
991
+ patch: 'minimal'
992
+ },
993
+ sortableFields: ['id', 'first_name', 'last_name'],
994
+ ...pluginOptions['rest-api']
995
+ })
996
+ await api.use(QueryProjectionsPlugin)
997
+
998
+ await useStoragePlugin(api, knex, { tenantId })
999
+ await resetAnyApiTables(knex)
1000
+
1001
+ await api.addResource('authors', {
1002
+ schema: {
1003
+ id: { type: 'id' },
1004
+ first_name: { type: 'string', required: true, max: 100 },
1005
+ last_name: { type: 'string', required: true, max: 100 }
1006
+ },
1007
+ queryFields: {
1008
+ full_name: {
1009
+ type: 'string',
1010
+ sortable: true,
1011
+ select: ({ knex, column }) => knex.raw(
1012
+ "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1013
+ [column('first_name'), column('last_name')]
1014
+ )
1015
+ },
1016
+ sort_name: {
1017
+ type: 'string',
1018
+ sortable: true,
1019
+ normallyHidden: true,
1020
+ select: ({ knex, column }) => knex.raw(
1021
+ "trim(coalesce(??, '') || ' ' || coalesce(??, ''))",
1022
+ [column('first_name'), column('last_name')]
1023
+ )
1024
+ }
1025
+ },
1026
+ relationships: {
1027
+ books: { type: 'hasMany', target: 'books', foreignKey: 'author_id' }
1028
+ },
1029
+ sortableFields: ['id', 'first_name', 'last_name'],
1030
+ defaultSort: ['full_name'],
1031
+ tableName: 'projected_authors'
1032
+ })
1033
+ await api.resources.authors.createKnexTable()
1034
+ mapTable('projected_authors', 'authors')
1035
+
1036
+ await api.addResource('books', {
1037
+ schema: {
1038
+ id: { type: 'id' },
1039
+ title: { type: 'string', required: true, max: 200 },
1040
+ author_id: { type: 'id', required: true, belongsTo: 'authors', as: 'author' }
1041
+ },
1042
+ tableName: 'projected_books'
1043
+ })
1044
+ await api.resources.books.createKnexTable()
1045
+ mapTable('projected_books', 'books')
1046
+
1047
+ if (storageMode.isAnyApi()) {
1048
+ storageMode.setCurrentTenant(previousTenant)
1049
+ }
1050
+
1051
+ return api
1052
+ }
1053
+
1074
1054
  /**
1075
1055
  * Creates an API with field setters for testing
1076
1056
  */
@@ -1288,14 +1268,14 @@ export async function createFieldSettersApi (knex, pluginOptions = {}) {
1288
1268
  return api
1289
1269
  }
1290
1270
 
1291
- export async function createMultiHomeApi (knex, pluginOptions = {}) {
1292
- const { MultiHomePlugin } = await import('../../plugins/core/rest-api-multihome-plugin.js')
1271
+ export async function createAutoFilterApi (knex, pluginOptions = {}) {
1272
+ const { AutoFilterPlugin } = await import('../../plugins/core/rest-api-autofilter-plugin.js')
1293
1273
 
1294
1274
  const api = new Api({
1295
- name: 'multihome-test-api',
1275
+ name: 'autofilter-test-api',
1296
1276
  })
1297
1277
 
1298
- const tenantId = storageMode.isAnyApi() ? 'multihome_tenant' : storageMode.defaultTenant
1278
+ const tenantId = storageMode.isAnyApi() ? 'autofilter_tenant' : storageMode.defaultTenant
1299
1279
  const previousTenant = storageMode.currentTenant
1300
1280
  if (storageMode.isAnyApi()) {
1301
1281
  storageMode.setCurrentTenant(tenantId)
@@ -1314,7 +1294,7 @@ export async function createMultiHomeApi (knex, pluginOptions = {}) {
1314
1294
  put: 'no',
1315
1295
  patch: 'minimal'
1316
1296
  },
1317
- sortableFields: ['id', 'title', 'name', 'tenant_id']
1297
+ sortableFields: ['id', 'title', 'name', 'workspace_id', 'user_id']
1318
1298
  })
1319
1299
 
1320
1300
  await useStoragePlugin(api, knex, { tenantId })
@@ -1325,34 +1305,77 @@ export async function createMultiHomeApi (knex, pluginOptions = {}) {
1325
1305
  await api.use(ExpressPlugin, pluginOptions.express || {})
1326
1306
  }
1327
1307
 
1328
- // Add MultiHome plugin with configuration
1329
- await api.use(MultiHomePlugin, {
1330
- field: pluginOptions.field || 'tenant_id',
1331
- excludeResources: pluginOptions.excludeResources || ['system_settings'],
1332
- requireAuth: pluginOptions.requireAuth !== undefined ? pluginOptions.requireAuth : true,
1333
- allowMissing: pluginOptions.allowMissing || false,
1334
- extractor: pluginOptions.extractor || ((request) => {
1335
- // Default to header extraction for tests
1336
- return request.headers?.['x-tenant-id'] || null
1337
- })
1308
+ const autofilterOptions = pluginOptions.autofilter || {}
1309
+ const workspaceField = pluginOptions.workspaceField || 'workspace_id'
1310
+ const userField = pluginOptions.userField || 'user_id'
1311
+
1312
+ await api.use(AutoFilterPlugin, {
1313
+ ...autofilterOptions,
1314
+ resolvers: {
1315
+ workspace: ({ context }) => context.scopeValues?.workspaceId,
1316
+ user: ({ context }) => context.scopeValues?.userId,
1317
+ currentProject: ({ context }) => context.scopeValues?.projectId ?? null,
1318
+ ...(autofilterOptions.resolvers || {})
1319
+ },
1320
+ presets: {
1321
+ public: { filters: [] },
1322
+ workspace: {
1323
+ filters: [{ field: workspaceField, resolver: 'workspace' }]
1324
+ },
1325
+ user: {
1326
+ filters: [{ field: userField, resolver: 'user' }]
1327
+ },
1328
+ workspace_user: {
1329
+ filters: [
1330
+ { field: workspaceField, resolver: 'workspace' },
1331
+ { field: userField, resolver: 'user' }
1332
+ ]
1333
+ },
1334
+ ...(autofilterOptions.presets || {})
1335
+ }
1338
1336
  })
1339
1337
 
1340
- // Tenant-specific resources
1338
+ await api.addResource('workspace_reports', {
1339
+ schema: {
1340
+ id: { type: 'id' },
1341
+ title: { type: 'string', required: true, max: 200 },
1342
+ workspace_id: { type: 'string', required: true }
1343
+ },
1344
+ autofilter: 'workspace',
1345
+ tableName: 'autofilter_workspace_reports'
1346
+ })
1347
+ await api.resources.workspace_reports.createKnexTable()
1348
+ mapTable('autofilter_workspace_reports', 'workspace_reports')
1349
+
1350
+ await api.addResource('user_notes', {
1351
+ schema: {
1352
+ id: { type: 'id' },
1353
+ body: { type: 'string', required: true, max: 500 },
1354
+ user_id: { type: 'number', required: true }
1355
+ },
1356
+ autofilter: 'user',
1357
+ tableName: 'autofilter_user_notes'
1358
+ })
1359
+ await api.resources.user_notes.createKnexTable()
1360
+ mapTable('autofilter_user_notes', 'user_notes')
1361
+
1341
1362
  await api.addResource('projects', {
1342
1363
  schema: {
1343
1364
  id: { type: 'id' },
1344
1365
  name: { type: 'string', required: true, max: 200 },
1345
1366
  description: { type: 'string', max: 1000 },
1346
1367
  status: { type: 'string', defaultTo: 'active' },
1347
- tenant_id: { type: 'string', required: true }
1368
+ workspace_id: { type: 'string', required: true },
1369
+ user_id: { type: 'number', required: true }
1348
1370
  },
1349
1371
  relationships: {
1350
1372
  tasks: { type: 'hasMany', target: 'tasks', foreignKey: 'project_id' }
1351
1373
  },
1352
- tableName: 'multihome_projects'
1374
+ autofilter: 'workspace_user',
1375
+ tableName: 'autofilter_projects'
1353
1376
  })
1354
1377
  await api.resources.projects.createKnexTable()
1355
- mapTable('multihome_projects', 'projects')
1378
+ mapTable('autofilter_projects', 'projects')
1356
1379
 
1357
1380
  await api.addResource('tasks', {
1358
1381
  schema: {
@@ -1360,37 +1383,40 @@ export async function createMultiHomeApi (knex, pluginOptions = {}) {
1360
1383
  title: { type: 'string', required: true, max: 200 },
1361
1384
  completed: { type: 'boolean', defaultTo: false },
1362
1385
  project_id: { type: 'number', belongsTo: 'projects', as: 'project' },
1363
- tenant_id: { type: 'string', required: true }
1386
+ workspace_id: { type: 'string', required: true },
1387
+ user_id: { type: 'number', required: true }
1364
1388
  },
1365
- tableName: 'multihome_tasks'
1389
+ autofilter: 'workspace_user',
1390
+ tableName: 'autofilter_tasks'
1366
1391
  })
1367
1392
  await api.resources.tasks.createKnexTable()
1368
- mapTable('multihome_tasks', 'tasks')
1393
+ mapTable('autofilter_tasks', 'tasks')
1369
1394
 
1370
- await api.addResource('users', {
1395
+ await api.addResource('optional_tasks', {
1371
1396
  schema: {
1372
1397
  id: { type: 'id' },
1373
- email: { type: 'string', required: true, unique: true },
1374
- name: { type: 'string', required: true },
1375
- role: { type: 'string', defaultTo: 'member', search: true },
1376
- tenant_id: { type: 'string', required: true }
1398
+ title: { type: 'string', required: true, max: 200 },
1399
+ project_id: { type: 'number', belongsTo: 'projects', as: 'project', nullable: true }
1400
+ },
1401
+ autofilter: {
1402
+ filters: [{ field: 'project_id', resolver: 'currentProject' }]
1377
1403
  },
1378
- tableName: 'multihome_users'
1404
+ tableName: 'autofilter_optional_tasks'
1379
1405
  })
1380
- await api.resources.users.createKnexTable()
1381
- mapTable('multihome_users', 'users')
1406
+ await api.resources.optional_tasks.createKnexTable()
1407
+ mapTable('autofilter_optional_tasks', 'optional_tasks')
1382
1408
 
1383
- // Global resource (excluded from multihome)
1384
1409
  await api.addResource('system_settings', {
1385
1410
  schema: {
1386
1411
  id: { type: 'id' },
1387
1412
  key: { type: 'string', required: true, unique: true },
1388
1413
  value: { type: 'string', required: true }
1389
1414
  },
1390
- tableName: 'multihome_system_settings'
1415
+ autofilter: 'public',
1416
+ tableName: 'autofilter_system_settings'
1391
1417
  })
1392
1418
  await api.resources.system_settings.createKnexTable()
1393
- mapTable('multihome_system_settings', 'system_settings')
1419
+ mapTable('autofilter_system_settings', 'system_settings')
1394
1420
 
1395
1421
  if (storageMode.isAnyApi()) {
1396
1422
  storageMode.setCurrentTenant(previousTenant)
@@ -1786,7 +1812,9 @@ export async function createVirtualFieldsApi (knex, pluginOptions = {}) {
1786
1812
  }
1787
1813
 
1788
1814
  /**
1789
- * Creates an API configuration for testing searchSchema merge behavior
1815
+ * Creates an API configuration for testing searchSchema merge behavior.
1816
+ * Shared suites assert merge/validation behavior across storage backends;
1817
+ * backend-specific execution cases live in storage-specific tests.
1790
1818
  */
1791
1819
  export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
1792
1820
  const api = new Api({
@@ -1825,12 +1853,27 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
1825
1853
  type: 'array',
1826
1854
  filterOperator: 'between'
1827
1855
  },
1856
+ // Arbitrary public filter name that searches across multiple fields.
1857
+ // The key `term` is part of the API contract even though there is no `term` column.
1858
+ term: {
1859
+ type: 'string',
1860
+ oneOf: ['name', 'description'],
1861
+ filterOperator: 'like'
1862
+ },
1828
1863
  // This is a virtual field
1829
1864
  category_name: {
1830
1865
  type: 'string',
1831
1866
  actualField: 'category.name',
1832
1867
  filterOperator: 'like'
1833
1868
  },
1869
+ // Arbitrary public filter name with fully custom server-side meaning.
1870
+ // The Knex query layer decides what `availability` means.
1871
+ availability: {
1872
+ type: 'boolean',
1873
+ applyFilter: (query, value) => {
1874
+ query.where('status', value ? 'active' : 'inactive')
1875
+ }
1876
+ },
1834
1877
  // This is an explicit field not marked with search:true
1835
1878
  // 'in' operator requires array type for validation
1836
1879
  status: {
@@ -1872,77 +1915,3 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
1872
1915
 
1873
1916
  return api
1874
1917
  }
1875
-
1876
-
1877
- /**
1878
- * Creates an API with resources that OPT OUT of ownership
1879
- * - public_items_ownerfield: uses ownerField: false
1880
- * - public_items_ownership: uses ownership: false
1881
- * Both tables intentionally DO NOT have a user_id column.
1882
- */
1883
- export async function createOwnershipOptOutApi (knex, pluginOptions = {}) {
1884
- const apiName = pluginOptions.apiName || 'ownership-optout-api'
1885
- const tablePrefix = pluginOptions.tablePrefix || 'ownopt'
1886
- if (storageMode.isAnyApi()) {
1887
- storageMode.clearRegistry()
1888
- }
1889
-
1890
- const api = new Api({
1891
- name: apiName,
1892
- log: { level: process.env.LOG_LEVEL || 'info' }
1893
- })
1894
-
1895
- const previousTenant = storageMode.currentTenant
1896
- const tenantId = storageMode.isAnyApi()
1897
- ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
1898
- : storageMode.defaultTenant
1899
- if (storageMode.isAnyApi()) {
1900
- storageMode.setCurrentTenant(tenantId)
1901
- }
1902
-
1903
- await api.use(RestApiPlugin, {
1904
- simplifiedApi: false,
1905
- simplifiedTransport: false,
1906
- returnRecordApi: { post: true, put: false, patch: false },
1907
- returnRecordTransport: { post: 'full', put: 'no', patch: 'minimal' },
1908
- sortableFields: ['id', 'name']
1909
- })
1910
- await useStoragePlugin(api, knex, { tenantId })
1911
- await resetAnyApiTables(knex)
1912
-
1913
- try {
1914
- // Access plugin (defaults: ownership enabled, filterByOwner true)
1915
- await api.use(AccessPlugin, pluginOptions.access || {})
1916
-
1917
- // Resource A: disable via ownerField:false
1918
- await api.addResource('public_items_ownerfield', {
1919
- schema: {
1920
- id: { type: 'id' },
1921
- name: { type: 'string', required: true }
1922
- },
1923
- // Important: no user_id column in schema; ownerField disables ownership logic
1924
- ownerField: false,
1925
- tableName: `${tablePrefix}_public_items_of`
1926
- })
1927
- await api.resources.public_items_ownerfield.createKnexTable()
1928
- mapTable(`${tablePrefix}_public_items_of`, 'public_items_ownerfield')
1929
-
1930
- // Resource B: disable via ownership:false
1931
- await api.addResource('public_items_ownership', {
1932
- schema: {
1933
- id: { type: 'id' },
1934
- name: { type: 'string', required: true }
1935
- },
1936
- ownership: false,
1937
- tableName: `${tablePrefix}_public_items_own`
1938
- })
1939
- await api.resources.public_items_ownership.createKnexTable()
1940
- mapTable(`${tablePrefix}_public_items_own`, 'public_items_ownership')
1941
-
1942
- return api
1943
- } finally {
1944
- if (storageMode.isAnyApi()) {
1945
- storageMode.setCurrentTenant(previousTenant)
1946
- }
1947
- }
1948
- }
@@ -0,0 +1,99 @@
1
+ export class FakeFastifyApp {
2
+ constructor () {
3
+ this.routes = []
4
+ this.parsers = []
5
+ }
6
+
7
+ route (definition) {
8
+ this.routes.push(definition)
9
+ }
10
+
11
+ addContentTypeParser (contentType, options, parser) {
12
+ this.parsers.push({ contentType, options, parser })
13
+ }
14
+
15
+ hasContentTypeParser (contentType) {
16
+ return this.parsers.some((entry) => entry.contentType === contentType)
17
+ }
18
+ }
19
+
20
+ export class FakeReply {
21
+ constructor () {
22
+ this.statusCode = null
23
+ this.contentType = null
24
+ this.headers = {}
25
+ this.payload = undefined
26
+ }
27
+
28
+ code (statusCode) {
29
+ this.statusCode = statusCode
30
+ return this
31
+ }
32
+
33
+ type (contentType) {
34
+ this.contentType = contentType
35
+ return this
36
+ }
37
+
38
+ header (name, value) {
39
+ this.headers[name] = value
40
+ return this
41
+ }
42
+
43
+ send (payload) {
44
+ this.payload = payload
45
+ return this
46
+ }
47
+ }
48
+
49
+ export function findFastifyRoute (app, method, url) {
50
+ return app.routes.find((route) => route.method === method && route.url === url)
51
+ }
52
+
53
+ export async function invokeFastifyRoute (
54
+ app,
55
+ {
56
+ method,
57
+ routeUrl,
58
+ requestUrl = routeUrl,
59
+ headers = {},
60
+ body,
61
+ params = {},
62
+ query = {}
63
+ }
64
+ ) {
65
+ const route = findFastifyRoute(app, method, routeUrl)
66
+ if (!route) {
67
+ throw new Error(`Fastify route not found: ${method} ${routeUrl}`)
68
+ }
69
+
70
+ const reply = new FakeReply()
71
+ const request = {
72
+ method,
73
+ url: requestUrl,
74
+ raw: { url: requestUrl },
75
+ headers,
76
+ body,
77
+ params,
78
+ query
79
+ }
80
+
81
+ if (typeof route.preValidation === 'function') {
82
+ await route.preValidation(request, reply)
83
+ if (reply.statusCode !== null || reply.payload !== undefined) {
84
+ return { route, request, reply }
85
+ }
86
+ }
87
+
88
+ try {
89
+ await route.handler(request, reply)
90
+ } catch (error) {
91
+ if (typeof route.errorHandler === 'function') {
92
+ await route.errorHandler(error, request, reply)
93
+ } else {
94
+ throw error
95
+ }
96
+ }
97
+
98
+ return { route, request, reply }
99
+ }