json-rest-api 1.0.23 → 1.0.25

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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -1,5 +1,13 @@
1
1
  import { Api } from 'hooked-api'
2
- import { RestApiPlugin, RestApiKnexPlugin, RestApiAnyapiKnexPlugin, QueryProjectionsPlugin } from '../../index.js'
2
+ import {
3
+ FileHandlingPlugin,
4
+ QueryProjectionsPlugin,
5
+ RestApiAnyapiKnexPlugin,
6
+ RestApiKnexPlugin,
7
+ RestApiPlugin,
8
+ RowPolicyPlugin,
9
+ SocketIOPlugin
10
+ } from '../../index.js'
3
11
  import { ExpressPlugin } from '../../plugins/core/connectors/express-plugin.js'
4
12
  import express from 'express'
5
13
  import { createServer } from 'http'
@@ -198,6 +206,266 @@ export async function createBasicApi (knex, pluginOptions = {}) {
198
206
  }
199
207
  }
200
208
 
209
+ export async function createReturnRecordApi (knex, pluginOptions = {}) {
210
+ const apiName = pluginOptions.apiName || 'return-record-test-api'
211
+ const tablePrefix = pluginOptions.tablePrefix || 'return'
212
+ if (storageMode.isAnyApi()) {
213
+ storageMode.clearRegistry()
214
+ }
215
+
216
+ const api = new Api({
217
+ name: apiName,
218
+ log: { level: process.env.LOG_LEVEL || 'info' }
219
+ })
220
+
221
+ const previousTenant = storageMode.currentTenant
222
+ const tenantId = storageMode.isAnyApi()
223
+ ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
224
+ : storageMode.defaultTenant
225
+ if (storageMode.isAnyApi()) {
226
+ storageMode.setCurrentTenant(tenantId)
227
+ }
228
+
229
+ await api.use(RestApiPlugin, {
230
+ simplifiedApi: false,
231
+ simplifiedTransport: false,
232
+ returnRecordApi: {
233
+ post: true,
234
+ put: false,
235
+ patch: false
236
+ },
237
+ returnRecordTransport: {
238
+ post: false,
239
+ put: true,
240
+ patch: 'minimal'
241
+ },
242
+ sortableFields: ['id', 'name'],
243
+ ...pluginOptions['rest-api']
244
+ })
245
+ await useStoragePlugin(api, knex, { tenantId })
246
+ await resetAnyApiTables(knex)
247
+
248
+ try {
249
+ await api.addResource('global_items', {
250
+ schema: {
251
+ id: { type: 'id' },
252
+ name: { type: 'string', required: true, max: 100 }
253
+ },
254
+ tableName: `${tablePrefix}_global_items`
255
+ })
256
+ await api.resources.global_items.createKnexTable()
257
+ mapTable(`${tablePrefix}_global_items`, 'global_items')
258
+
259
+ await api.addResource('scope_items', {
260
+ schema: {
261
+ id: { type: 'id' },
262
+ name: { type: 'string', required: true, max: 100 }
263
+ },
264
+ tableName: `${tablePrefix}_scope_items`,
265
+ returnRecordApi: {
266
+ post: false,
267
+ put: true,
268
+ patch: 'minimal'
269
+ },
270
+ returnRecordTransport: true
271
+ })
272
+ await api.resources.scope_items.createKnexTable()
273
+ mapTable(`${tablePrefix}_scope_items`, 'scope_items')
274
+ } finally {
275
+ if (storageMode.isAnyApi()) {
276
+ storageMode.setCurrentTenant(previousTenant)
277
+ }
278
+ }
279
+
280
+ return api
281
+ }
282
+
283
+ /**
284
+ * Creates a small API that uses logical camelCase belongsTo fields with default snake_case storage mapping.
285
+ * This reproduces the full JSON:API linkage path where storage column names differ from logical field names.
286
+ */
287
+ export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
288
+ const apiName = pluginOptions.apiName || 'camelcase-belongsto-test-api'
289
+ const tablePrefix = pluginOptions.tablePrefix || 'camel_fk'
290
+
291
+ const api = new Api({
292
+ name: apiName,
293
+ log: { level: process.env.LOG_LEVEL || 'info' }
294
+ })
295
+
296
+ await api.use(RestApiPlugin, {
297
+ simplifiedApi: false,
298
+ simplifiedTransport: false,
299
+ returnRecordApi: {
300
+ post: true,
301
+ put: false,
302
+ patch: false
303
+ },
304
+ returnRecordTransport: {
305
+ post: 'full',
306
+ put: 'no',
307
+ patch: 'full'
308
+ },
309
+ ...(pluginOptions['rest-api'] || {})
310
+ })
311
+
312
+ await api.use(RestApiKnexPlugin, { knex })
313
+
314
+ await api.addResource('countries', {
315
+ schema: {
316
+ id: { type: 'id' },
317
+ name: { type: 'string', required: true, max: 100 }
318
+ },
319
+ tableName: `${tablePrefix}_countries`
320
+ })
321
+ await api.resources.countries.createKnexTable()
322
+
323
+ await api.addResource('publishers', {
324
+ schema: {
325
+ id: { type: 'id' },
326
+ name: { type: 'string', required: true, max: 200 },
327
+ countryId: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
328
+ },
329
+ tableName: `${tablePrefix}_publishers`
330
+ })
331
+ await api.resources.publishers.createKnexTable()
332
+
333
+ return api
334
+ }
335
+
336
+ /**
337
+ * Creates a small API focused on resource ID normalization behavior.
338
+ */
339
+ export async function createIdNormalizationApi (knex, pluginOptions = {}) {
340
+ const apiName = pluginOptions.apiName || 'id-normalization-test-api'
341
+ const tablePrefix = pluginOptions.tablePrefix || 'id_norm'
342
+ if (storageMode.isAnyApi()) {
343
+ storageMode.clearRegistry()
344
+ }
345
+
346
+ const api = new Api({
347
+ name: apiName,
348
+ log: { level: process.env.LOG_LEVEL || 'info' }
349
+ })
350
+
351
+ const previousTenant = storageMode.currentTenant
352
+ const tenantId = storageMode.isAnyApi()
353
+ ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
354
+ : storageMode.defaultTenant
355
+ if (storageMode.isAnyApi()) {
356
+ storageMode.setCurrentTenant(tenantId)
357
+ }
358
+
359
+ await api.use(RestApiPlugin, {
360
+ simplifiedApi: false,
361
+ simplifiedTransport: false,
362
+ returnRecordApi: {
363
+ post: true,
364
+ put: false,
365
+ patch: false
366
+ },
367
+ returnRecordTransport: {
368
+ post: 'full',
369
+ put: 'no',
370
+ patch: 'minimal'
371
+ },
372
+ ...(pluginOptions['rest-api'] || {})
373
+ })
374
+
375
+ try {
376
+ await withTenantContext(tenantId, async () => {
377
+ await useStoragePlugin(api, knex, { tenantId })
378
+ await resetAnyApiTables(knex)
379
+
380
+ await api.addResource('countries', {
381
+ schema: {
382
+ id: { type: 'string', required: true },
383
+ name: { type: 'string', required: true, max: 100 }
384
+ },
385
+ tableName: `${tablePrefix}_countries`,
386
+ ...(pluginOptions.countryResourceOptions || {})
387
+ })
388
+ await knex.schema.dropTableIfExists(`${tablePrefix}_countries`)
389
+ await knex.schema.createTable(`${tablePrefix}_countries`, (table) => {
390
+ table.string('id').primary()
391
+ table.string('name').notNullable()
392
+ })
393
+ mapTable(`${tablePrefix}_countries`, 'countries')
394
+
395
+ await api.addResource('publishers', {
396
+ schema: {
397
+ id: { type: 'string', required: true },
398
+ name: { type: 'string', required: true, max: 100 },
399
+ country_id: { type: 'string', nullable: true, belongsTo: 'countries', as: 'country' }
400
+ },
401
+ relationships: {
402
+ tags: { type: 'manyToMany', through: 'publisher_tags', foreignKey: 'publisher_id', otherKey: 'tag_id' }
403
+ },
404
+ tableName: `${tablePrefix}_publishers`,
405
+ ...(pluginOptions.publisherResourceOptions || {})
406
+ })
407
+ await knex.schema.dropTableIfExists(`${tablePrefix}_publishers`)
408
+ await knex.schema.createTable(`${tablePrefix}_publishers`, (table) => {
409
+ table.string('id').primary()
410
+ table.string('name').notNullable()
411
+ table.string('country_id').nullable()
412
+ })
413
+ mapTable(`${tablePrefix}_publishers`, 'publishers')
414
+
415
+ await api.addResource('tags', {
416
+ schema: {
417
+ id: { type: 'string', required: true },
418
+ name: { type: 'string', required: true, max: 100 }
419
+ },
420
+ tableName: `${tablePrefix}_tags`,
421
+ ...(pluginOptions.tagResourceOptions || {})
422
+ })
423
+ await knex.schema.dropTableIfExists(`${tablePrefix}_tags`)
424
+ await knex.schema.createTable(`${tablePrefix}_tags`, (table) => {
425
+ table.string('id').primary()
426
+ table.string('name').notNullable()
427
+ })
428
+ mapTable(`${tablePrefix}_tags`, 'tags')
429
+
430
+ await api.addResource('publisher_tags', {
431
+ schema: {
432
+ id: { type: 'id' },
433
+ publisher_id: { type: 'string', required: true, belongsTo: 'publishers', as: 'publisher' },
434
+ tag_id: { type: 'string', required: true, belongsTo: 'tags', as: 'tag' }
435
+ },
436
+ tableName: `${tablePrefix}_publisher_tags`
437
+ })
438
+ await knex.schema.dropTableIfExists(`${tablePrefix}_publisher_tags`)
439
+ await knex.schema.createTable(`${tablePrefix}_publisher_tags`, (table) => {
440
+ table.increments('id').primary()
441
+ table.string('publisher_id').notNullable()
442
+ table.string('tag_id').notNullable()
443
+ })
444
+ mapTable(`${tablePrefix}_publisher_tags`, 'publisher_tags')
445
+ if (storageMode.isAnyApi()) {
446
+ const descriptorTenant = api.anyapi?.tenantId || tenantId
447
+ const publishersDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'publishers')
448
+ const tagsDescriptor = await api.anyapi.registry.getDescriptor(descriptorTenant, 'tags')
449
+ const relationshipKey = publishersDescriptor?.manyToMany?.tags?.relationship
450
+ const inverseRelationshipKey = tagsDescriptor?.manyToMany?.publishers?.relationship
451
+ storageMode.registerLink(
452
+ `${tablePrefix}_publisher_tags`,
453
+ 'publishers',
454
+ 'tags',
455
+ relationshipKey,
456
+ inverseRelationshipKey
457
+ )
458
+ }
459
+ })
460
+
461
+ return api
462
+ } finally {
463
+ if (storageMode.isAnyApi()) {
464
+ storageMode.setCurrentTenant(previousTenant)
465
+ }
466
+ }
467
+ }
468
+
201
469
  /**
202
470
  * Creates a basic API with bulk operations enabled
203
471
  */
@@ -588,7 +856,6 @@ export async function createPaginationApi (knex, options = {}) {
588
856
  * Creates an API with WebSocket/Socket.IO support for testing
589
857
  */
590
858
  export async function createWebSocketApi (knex, pluginOptions = {}) {
591
- const { SocketIOPlugin } = await import('../../plugins/core/socketio-plugin.js')
592
859
  const { jwtVerify } = await import('jose')
593
860
 
594
861
  const api = await createBasicApi(knex, {
@@ -1425,6 +1692,148 @@ export async function createAutoFilterApi (knex, pluginOptions = {}) {
1425
1692
  return api
1426
1693
  }
1427
1694
 
1695
+ export async function createRowPolicyApi (knex, pluginOptions = {}) {
1696
+ const api = new Api({
1697
+ name: 'row-policy-test-api',
1698
+ })
1699
+
1700
+ const tenantId = storageMode.isAnyApi() ? 'row_policy_tenant' : storageMode.defaultTenant
1701
+ const previousTenant = storageMode.currentTenant
1702
+ if (storageMode.isAnyApi()) {
1703
+ storageMode.setCurrentTenant(tenantId)
1704
+ }
1705
+
1706
+ await api.use(RestApiPlugin, {
1707
+ simplifiedApi: false,
1708
+ simplifiedTransport: false,
1709
+ returnRecordApi: {
1710
+ post: true,
1711
+ put: false,
1712
+ patch: false
1713
+ },
1714
+ sortableFields: ['id', 'name', 'title', 'access_group', 'project_id']
1715
+ })
1716
+
1717
+ await useStoragePlugin(api, knex, { tenantId })
1718
+ await resetAnyApiTables(knex)
1719
+
1720
+ const rowPolicyOptions = pluginOptions.rowPolicy || {}
1721
+ await api.use(RowPolicyPlugin, {
1722
+ ...rowPolicyOptions,
1723
+ policies: {
1724
+ groupVisibility: ({ query, context, column, value, scopeName, queryPurpose }) => {
1725
+ pluginOptions.onPolicy?.({ scopeName, queryPurpose })
1726
+
1727
+ if (context.visibility?.all === true) {
1728
+ return true
1729
+ }
1730
+
1731
+ const groups = context.visibility?.groups
1732
+ if (!Array.isArray(groups) || groups.length === 0) {
1733
+ return false
1734
+ }
1735
+
1736
+ query.whereIn(
1737
+ column('access_group'),
1738
+ groups.map((group) => value('access_group', group))
1739
+ )
1740
+ return true
1741
+ },
1742
+ ...(rowPolicyOptions.policies || {})
1743
+ }
1744
+ })
1745
+
1746
+ await api.addResource('policy_projects', {
1747
+ schema: {
1748
+ id: { type: 'id' },
1749
+ name: { type: 'string', required: true, max: 200 },
1750
+ access_group: { type: 'string', required: true }
1751
+ },
1752
+ relationships: {
1753
+ tasks: { type: 'hasMany', target: 'policy_tasks', foreignKey: 'project_id' },
1754
+ shared_tasks: {
1755
+ type: 'manyToMany',
1756
+ target: 'policy_tasks',
1757
+ through: 'policy_project_tasks',
1758
+ foreignKey: 'project_id',
1759
+ otherKey: 'task_id'
1760
+ }
1761
+ },
1762
+ rowPolicy: 'groupVisibility',
1763
+ tableName: 'row_policy_projects'
1764
+ })
1765
+ await api.resources.policy_projects.createKnexTable()
1766
+ mapTable('row_policy_projects', 'policy_projects')
1767
+
1768
+ await api.addResource('policy_tasks', {
1769
+ schema: {
1770
+ id: { type: 'id' },
1771
+ title: { type: 'string', required: true, max: 200, search: true },
1772
+ access_group: { type: 'string', required: true },
1773
+ project_id: {
1774
+ type: 'number',
1775
+ nullable: true,
1776
+ belongsTo: 'policy_projects',
1777
+ as: 'project',
1778
+ search: true
1779
+ }
1780
+ },
1781
+ relationships: {
1782
+ shared_projects: {
1783
+ type: 'manyToMany',
1784
+ target: 'policy_projects',
1785
+ through: 'policy_project_tasks',
1786
+ foreignKey: 'task_id',
1787
+ otherKey: 'project_id'
1788
+ }
1789
+ },
1790
+ rowPolicy: 'groupVisibility',
1791
+ tableName: 'row_policy_tasks'
1792
+ })
1793
+ await api.resources.policy_tasks.createKnexTable()
1794
+ mapTable('row_policy_tasks', 'policy_tasks')
1795
+
1796
+ await api.addResource('policy_project_tasks', {
1797
+ schema: {
1798
+ id: { type: 'id' },
1799
+ project_id: { type: 'number', required: true, belongsTo: 'policy_projects', as: 'project' },
1800
+ task_id: { type: 'number', required: true, belongsTo: 'policy_tasks', as: 'task' }
1801
+ },
1802
+ tableName: 'row_policy_project_tasks'
1803
+ })
1804
+ await api.resources.policy_project_tasks.createKnexTable()
1805
+ mapTable('row_policy_project_tasks', 'policy_project_tasks')
1806
+
1807
+ if (storageMode.isAnyApi()) {
1808
+ const projectsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_projects')
1809
+ const tasksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_tasks')
1810
+ storageMode.registerLink(
1811
+ 'row_policy_project_tasks',
1812
+ 'policy_projects',
1813
+ 'shared_tasks',
1814
+ projectsDescriptor?.manyToMany?.shared_tasks?.relationship,
1815
+ tasksDescriptor?.manyToMany?.shared_projects?.relationship
1816
+ )
1817
+ }
1818
+
1819
+ await api.addResource('policy_broken', {
1820
+ schema: {
1821
+ id: { type: 'id' },
1822
+ name: { type: 'string', required: true }
1823
+ },
1824
+ rowPolicy: () => undefined,
1825
+ tableName: 'row_policy_broken'
1826
+ })
1827
+ await api.resources.policy_broken.createKnexTable()
1828
+ mapTable('row_policy_broken', 'policy_broken')
1829
+
1830
+ if (storageMode.isAnyApi()) {
1831
+ storageMode.setCurrentTenant(previousTenant)
1832
+ }
1833
+
1834
+ return api
1835
+ }
1836
+
1428
1837
  /**
1429
1838
  * Creates an API with positioning support for testing
1430
1839
  */
@@ -1714,7 +2123,7 @@ export async function createCursorPaginationApi (knex) {
1714
2123
  put: 'full',
1715
2124
  patch: 'minimal'
1716
2125
  },
1717
- sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'created_at', 'code', 'status', 'type'],
2126
+ sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'createdAt', 'code', 'status', 'type'],
1718
2127
  queryDefaultLimit: 20,
1719
2128
  queryMaxLimit: 100,
1720
2129
  returnBasePath: 'https://api.example.com'
@@ -1734,11 +2143,12 @@ export async function createCursorPaginationApi (knex) {
1734
2143
  price: { type: 'number', required: true },
1735
2144
  sku: { type: 'string', required: true, max: 50, unique: true },
1736
2145
  status: { type: 'string', max: 50, default: 'active' },
1737
- created_at: { type: 'dateTime', default: 'now()' }
2146
+ createdAt: { type: 'dateTime', default: 'now()' }
1738
2147
  },
1739
2148
  tableName: 'cursor_products'
1740
2149
  })
1741
2150
  await api.resources.products.createKnexTable()
2151
+ mapTable('cursor_products', 'products')
1742
2152
 
1743
2153
  // Create items table with custom ID property
1744
2154
  await api.addResource('items', {
@@ -1753,6 +2163,7 @@ export async function createCursorPaginationApi (knex) {
1753
2163
  tableName: 'cursor_items'
1754
2164
  })
1755
2165
  await api.resources.items.createKnexTable()
2166
+ mapTable('cursor_items', 'items')
1756
2167
 
1757
2168
  return api
1758
2169
  }
@@ -1915,3 +2326,83 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
1915
2326
 
1916
2327
  return api
1917
2328
  }
2329
+
2330
+ export async function createFileUploadApi (knex, pluginOptions = {}) {
2331
+ const apiName = pluginOptions.apiName || 'file-upload-test-api'
2332
+ const tableName = pluginOptions.tableName || 'file_documents'
2333
+ const tablePrefix = pluginOptions.tablePrefix || 'file'
2334
+ const storage = pluginOptions.storage
2335
+ const detectorState = pluginOptions.detectorState || { payload: null }
2336
+
2337
+ if (!storage) {
2338
+ throw new Error('createFileUploadApi requires a storage adapter')
2339
+ }
2340
+
2341
+ if (storageMode.isAnyApi()) {
2342
+ storageMode.clearRegistry()
2343
+ }
2344
+
2345
+ const api = new Api({
2346
+ name: apiName,
2347
+ log: { level: process.env.LOG_LEVEL || 'info' }
2348
+ })
2349
+
2350
+ const previousTenant = storageMode.currentTenant
2351
+ const tenantId = storageMode.isAnyApi()
2352
+ ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
2353
+ : storageMode.defaultTenant
2354
+
2355
+ if (storageMode.isAnyApi()) {
2356
+ storageMode.setCurrentTenant(tenantId)
2357
+ }
2358
+
2359
+ try {
2360
+ await api.use(RestApiPlugin, {
2361
+ simplifiedApi: false,
2362
+ simplifiedTransport: false,
2363
+ returnRecordApi: {
2364
+ post: true,
2365
+ put: false,
2366
+ patch: false
2367
+ },
2368
+ returnRecordTransport: {
2369
+ post: 'full',
2370
+ put: 'no',
2371
+ patch: 'minimal'
2372
+ },
2373
+ sortableFields: ['id', 'title'],
2374
+ ...pluginOptions['rest-api']
2375
+ })
2376
+ await useStoragePlugin(api, knex, { tenantId })
2377
+ await resetAnyApiTables(knex)
2378
+ await api.use(FileHandlingPlugin)
2379
+
2380
+ api.rest.registerFileDetector({
2381
+ name: 'test-file-detector',
2382
+ detect: () => Boolean(detectorState.payload),
2383
+ parse: async () => detectorState.payload
2384
+ })
2385
+
2386
+ await api.addResource('documents', {
2387
+ schema: {
2388
+ id: { type: 'id' },
2389
+ title: { type: 'string', required: true },
2390
+ attachment: {
2391
+ type: 'file',
2392
+ storage,
2393
+ accepts: ['image/png'],
2394
+ maxSize: '1mb'
2395
+ }
2396
+ },
2397
+ tableName
2398
+ })
2399
+ await api.resources.documents.createKnexTable()
2400
+ mapTable(tableName, 'documents')
2401
+
2402
+ return api
2403
+ } finally {
2404
+ if (storageMode.isAnyApi()) {
2405
+ storageMode.setCurrentTenant(previousTenant)
2406
+ }
2407
+ }
2408
+ }
@@ -0,0 +1,150 @@
1
+ import { after, before, beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import knexLib from 'knex'
4
+ import { createCamelCaseBelongsToApi } from './fixtures/api-configs.js'
5
+ import {
6
+ createJsonApiDocument,
7
+ createRelationship,
8
+ resourceIdentifier,
9
+ assertResourceRelationship
10
+ } from './helpers/test-utils.js'
11
+
12
+ const knex = knexLib({
13
+ client: 'better-sqlite3',
14
+ connection: {
15
+ filename: ':memory:'
16
+ },
17
+ useNullAsDefault: true
18
+ })
19
+
20
+ let api
21
+
22
+ describe('Full JSON:API belongsTo linkage', () => {
23
+ before(async () => {
24
+ api = await createCamelCaseBelongsToApi(knex)
25
+ })
26
+
27
+ after(async () => {
28
+ await knex.destroy()
29
+ })
30
+
31
+ beforeEach(async () => {
32
+ await knex('camel_fk_publishers').delete()
33
+ await knex('camel_fk_countries').delete()
34
+ })
35
+
36
+ const createCountry = async () => {
37
+ return api.resources.countries.post({
38
+ inputRecord: createJsonApiDocument('countries', {
39
+ name: 'United States'
40
+ }),
41
+ simplified: false
42
+ })
43
+ }
44
+
45
+ const createPublisher = async (countryId = null) => {
46
+ return api.resources.publishers.post({
47
+ inputRecord: createJsonApiDocument(
48
+ 'publishers',
49
+ { name: 'Mapped Publisher' },
50
+ {
51
+ country: createRelationship(
52
+ countryId == null ? null : resourceIdentifier('countries', countryId)
53
+ )
54
+ }
55
+ ),
56
+ simplified: false
57
+ })
58
+ }
59
+
60
+ it('emits belongsTo linkage on primary resources without include for single and collection responses', async () => {
61
+ const country = await createCountry()
62
+ const publisher = await createPublisher(country.data.id)
63
+
64
+ assertResourceRelationship(
65
+ publisher.data,
66
+ 'country',
67
+ resourceIdentifier('countries', country.data.id)
68
+ )
69
+ assert.equal(Object.hasOwn(publisher.data.attributes, 'countryId'), false)
70
+
71
+ const single = await api.resources.publishers.get({
72
+ id: publisher.data.id,
73
+ simplified: false
74
+ })
75
+
76
+ assertResourceRelationship(
77
+ single.data,
78
+ 'country',
79
+ resourceIdentifier('countries', country.data.id)
80
+ )
81
+ assert.equal(Object.hasOwn(single.data.attributes, 'countryId'), false)
82
+
83
+ const collection = await api.resources.publishers.query({
84
+ simplified: false
85
+ })
86
+
87
+ assert.equal(collection.data.length, 1)
88
+ assertResourceRelationship(
89
+ collection.data[0],
90
+ 'country',
91
+ resourceIdentifier('countries', country.data.id)
92
+ )
93
+ assert.equal(Object.hasOwn(collection.data[0].attributes, 'countryId'), false)
94
+ })
95
+
96
+ it('keeps primary belongsTo linkage when include is requested and adds included resources', async () => {
97
+ const country = await createCountry()
98
+ const publisher = await createPublisher(country.data.id)
99
+
100
+ const result = await api.resources.publishers.get({
101
+ id: publisher.data.id,
102
+ queryParams: {
103
+ include: ['country']
104
+ },
105
+ simplified: false,
106
+ returnFullRecord: 'full'
107
+ })
108
+
109
+ assertResourceRelationship(
110
+ result.data,
111
+ 'country',
112
+ resourceIdentifier('countries', country.data.id)
113
+ )
114
+ assert.equal(Object.hasOwn(result.data.attributes, 'countryId'), false)
115
+ assert.equal(result.included?.length, 1)
116
+ assert.equal(result.included?.[0]?.type, 'countries')
117
+ assert.equal(result.included?.[0]?.id, country.data.id)
118
+ })
119
+
120
+ it('emits explicit null belongsTo linkage without exposing the foreign key attribute', async () => {
121
+ const country = await createCountry()
122
+ const publisher = await createPublisher(country.data.id)
123
+
124
+ const patched = await api.resources.publishers.patch({
125
+ id: publisher.data.id,
126
+ inputRecord: {
127
+ data: {
128
+ type: 'publishers',
129
+ id: publisher.data.id,
130
+ relationships: {
131
+ country: { data: null }
132
+ }
133
+ }
134
+ },
135
+ simplified: false,
136
+ returnFullRecord: 'full'
137
+ })
138
+
139
+ assert.equal(patched.data.relationships?.country?.data, null)
140
+ assert.equal(Object.hasOwn(patched.data.attributes, 'countryId'), false)
141
+
142
+ const single = await api.resources.publishers.get({
143
+ id: publisher.data.id,
144
+ simplified: false
145
+ })
146
+
147
+ assert.equal(single.data.relationships?.country?.data, null)
148
+ assert.equal(Object.hasOwn(single.data.attributes, 'countryId'), false)
149
+ })
150
+ })