json-rest-api 1.0.24 → 1.0.26

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 (84) hide show
  1. package/README.md +2 -1
  2. package/docs/API.md +12 -9
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
  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_Row_Policies.md +605 -0
  10. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  11. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  12. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  13. package/docs/GUIDE/index.md +4 -1
  14. package/fixing_plan.md +112 -0
  15. package/index.js +2 -0
  16. package/package.json +1 -1
  17. package/plugins/core/bulk-operations-plugin.js +5 -4
  18. package/plugins/core/connectors/express-plugin.js +6 -1
  19. package/plugins/core/connectors/fastify-plugin.js +5 -1
  20. package/plugins/core/connectors/lib/connector-core.js +17 -11
  21. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  22. package/plugins/core/file-handling-plugin.js +126 -96
  23. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  24. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +36 -26
  25. package/plugins/core/lib/anyapi/schema-utils.js +1 -0
  26. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  27. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  28. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  29. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  30. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  31. package/plugins/core/lib/querying/storage-adapter-utils.js +8 -8
  32. package/plugins/core/lib/querying/url-helpers.js +9 -21
  33. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  34. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  35. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  36. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  37. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  38. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  39. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  40. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  41. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  42. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  43. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  44. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  45. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  46. package/plugins/core/rest-api-knex-plugin.js +70 -60
  47. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  48. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  49. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  50. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  51. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  52. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  53. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  54. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  55. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  56. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  57. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  58. package/plugins/core/rest-api-plugin.js +6 -36
  59. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  60. package/plugins/storage/local-storage.js +44 -19
  61. package/plugins/storage/s3-storage.js +21 -55
  62. package/tests/README.md +1 -1
  63. package/tests/anyapi-cursor-pagination.test.js +7 -1
  64. package/tests/anyapi-custom-idproperty.test.js +163 -0
  65. package/tests/fastify-plugin.test.js +28 -0
  66. package/tests/field-setters.test.js +10 -10
  67. package/tests/file-handling.test.js +118 -0
  68. package/tests/fixtures/api-configs.js +383 -4
  69. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  70. package/tests/http-connectors-parity.test.js +165 -5
  71. package/tests/local-storage.test.js +96 -0
  72. package/tests/pagination-cursor-multifield.test.js +121 -0
  73. package/tests/pagination-enhanced.test.js +66 -15
  74. package/tests/post-commit-hooks.test.js +120 -0
  75. package/tests/public-exports.test.js +15 -0
  76. package/tests/put-create-parity.test.js +216 -0
  77. package/tests/relationship-endpoints.test.js +259 -0
  78. package/tests/return-record-settings.test.js +184 -0
  79. package/tests/row-policy.test.js +489 -0
  80. package/tests/s3-storage.test.js +38 -0
  81. package/tests/searchschema-merge.test.js +17 -0
  82. package/tests/socketio.test.js +62 -0
  83. package/tests/storage-mapping.test.js +1 -0
  84. package/tests/test-development-guide.md +5 -5
@@ -1,5 +1,14 @@
1
1
  import { Api } from 'hooked-api'
2
- import { RestApiPlugin, RestApiKnexPlugin, RestApiAnyapiKnexPlugin, QueryProjectionsPlugin } from '../../index.js'
2
+ import {
3
+ AutoFilterPlugin,
4
+ FileHandlingPlugin,
5
+ QueryProjectionsPlugin,
6
+ RestApiAnyapiKnexPlugin,
7
+ RestApiKnexPlugin,
8
+ RestApiPlugin,
9
+ RowPolicyPlugin,
10
+ SocketIOPlugin
11
+ } from '../../index.js'
3
12
  import { ExpressPlugin } from '../../plugins/core/connectors/express-plugin.js'
4
13
  import express from 'express'
5
14
  import { createServer } from 'http'
@@ -198,6 +207,133 @@ export async function createBasicApi (knex, pluginOptions = {}) {
198
207
  }
199
208
  }
200
209
 
210
+ export async function createReturnRecordApi (knex, pluginOptions = {}) {
211
+ const apiName = pluginOptions.apiName || 'return-record-test-api'
212
+ const tablePrefix = pluginOptions.tablePrefix || 'return'
213
+ if (storageMode.isAnyApi()) {
214
+ storageMode.clearRegistry()
215
+ }
216
+
217
+ const api = new Api({
218
+ name: apiName,
219
+ log: { level: process.env.LOG_LEVEL || 'info' }
220
+ })
221
+
222
+ const previousTenant = storageMode.currentTenant
223
+ const tenantId = storageMode.isAnyApi()
224
+ ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
225
+ : storageMode.defaultTenant
226
+ if (storageMode.isAnyApi()) {
227
+ storageMode.setCurrentTenant(tenantId)
228
+ }
229
+
230
+ await api.use(RestApiPlugin, {
231
+ simplifiedApi: false,
232
+ simplifiedTransport: false,
233
+ returnRecordApi: {
234
+ post: true,
235
+ put: false,
236
+ patch: false
237
+ },
238
+ returnRecordTransport: {
239
+ post: false,
240
+ put: true,
241
+ patch: 'minimal'
242
+ },
243
+ sortableFields: ['id', 'name'],
244
+ ...pluginOptions['rest-api']
245
+ })
246
+ await useStoragePlugin(api, knex, { tenantId })
247
+ await resetAnyApiTables(knex)
248
+
249
+ try {
250
+ await api.addResource('global_items', {
251
+ schema: {
252
+ id: { type: 'id' },
253
+ name: { type: 'string', required: true, max: 100 }
254
+ },
255
+ tableName: `${tablePrefix}_global_items`
256
+ })
257
+ await api.resources.global_items.createKnexTable()
258
+ mapTable(`${tablePrefix}_global_items`, 'global_items')
259
+
260
+ await api.addResource('scope_items', {
261
+ schema: {
262
+ id: { type: 'id' },
263
+ name: { type: 'string', required: true, max: 100 }
264
+ },
265
+ tableName: `${tablePrefix}_scope_items`,
266
+ returnRecordApi: {
267
+ post: false,
268
+ put: true,
269
+ patch: 'minimal'
270
+ },
271
+ returnRecordTransport: true
272
+ })
273
+ await api.resources.scope_items.createKnexTable()
274
+ mapTable(`${tablePrefix}_scope_items`, 'scope_items')
275
+ } finally {
276
+ if (storageMode.isAnyApi()) {
277
+ storageMode.setCurrentTenant(previousTenant)
278
+ }
279
+ }
280
+
281
+ return api
282
+ }
283
+
284
+ /**
285
+ * Creates a small API that uses logical camelCase belongsTo fields with default snake_case storage mapping.
286
+ * This reproduces the full JSON:API linkage path where storage column names differ from logical field names.
287
+ */
288
+ export async function createCamelCaseBelongsToApi (knex, pluginOptions = {}) {
289
+ const apiName = pluginOptions.apiName || 'camelcase-belongsto-test-api'
290
+ const tablePrefix = pluginOptions.tablePrefix || 'camel_fk'
291
+
292
+ const api = new Api({
293
+ name: apiName,
294
+ log: { level: process.env.LOG_LEVEL || 'info' }
295
+ })
296
+
297
+ await api.use(RestApiPlugin, {
298
+ simplifiedApi: false,
299
+ simplifiedTransport: false,
300
+ returnRecordApi: {
301
+ post: true,
302
+ put: false,
303
+ patch: false
304
+ },
305
+ returnRecordTransport: {
306
+ post: 'full',
307
+ put: 'no',
308
+ patch: 'full'
309
+ },
310
+ ...(pluginOptions['rest-api'] || {})
311
+ })
312
+
313
+ await api.use(RestApiKnexPlugin, { knex })
314
+
315
+ await api.addResource('countries', {
316
+ schema: {
317
+ id: { type: 'id' },
318
+ name: { type: 'string', required: true, max: 100 }
319
+ },
320
+ tableName: `${tablePrefix}_countries`
321
+ })
322
+ await api.resources.countries.createKnexTable()
323
+
324
+ await api.addResource('publishers', {
325
+ schema: {
326
+ id: { type: 'id' },
327
+ name: { type: 'string', required: true, max: 200 },
328
+ countryId: { type: 'number', nullable: true, belongsTo: 'countries', as: 'country' }
329
+ },
330
+ tableName: `${tablePrefix}_publishers`
331
+ })
332
+ await api.resources.publishers.createKnexTable()
333
+
334
+ return api
335
+ }
336
+
201
337
  /**
202
338
  * Creates a small API focused on resource ID normalization behavior.
203
339
  */
@@ -721,7 +857,6 @@ export async function createPaginationApi (knex, options = {}) {
721
857
  * Creates an API with WebSocket/Socket.IO support for testing
722
858
  */
723
859
  export async function createWebSocketApi (knex, pluginOptions = {}) {
724
- const { SocketIOPlugin } = await import('../../plugins/core/socketio-plugin.js')
725
860
  const { jwtVerify } = await import('jose')
726
861
 
727
862
  const api = await createBasicApi(knex, {
@@ -1558,6 +1693,168 @@ export async function createAutoFilterApi (knex, pluginOptions = {}) {
1558
1693
  return api
1559
1694
  }
1560
1695
 
1696
+ export async function createRowPolicyApi (knex, pluginOptions = {}) {
1697
+ const api = new Api({
1698
+ name: 'row-policy-test-api',
1699
+ })
1700
+
1701
+ const tenantId = storageMode.isAnyApi() ? 'row_policy_tenant' : storageMode.defaultTenant
1702
+ const previousTenant = storageMode.currentTenant
1703
+ if (storageMode.isAnyApi()) {
1704
+ storageMode.setCurrentTenant(tenantId)
1705
+ }
1706
+
1707
+ await api.use(RestApiPlugin, {
1708
+ simplifiedApi: false,
1709
+ simplifiedTransport: false,
1710
+ returnRecordApi: {
1711
+ post: true,
1712
+ put: false,
1713
+ patch: false
1714
+ },
1715
+ sortableFields: ['id', 'name', 'title', 'access_group', 'project_id']
1716
+ })
1717
+
1718
+ await useStoragePlugin(api, knex, { tenantId })
1719
+ await resetAnyApiTables(knex)
1720
+
1721
+ const rowPolicyOptions = pluginOptions.rowPolicy || {}
1722
+ await api.use(RowPolicyPlugin, {
1723
+ ...rowPolicyOptions,
1724
+ policies: {
1725
+ groupVisibility: ({ query, context, column, value, scopeName, queryPurpose }) => {
1726
+ pluginOptions.onPolicy?.({ scopeName, queryPurpose })
1727
+
1728
+ if (context.visibility?.all === true) {
1729
+ return true
1730
+ }
1731
+
1732
+ const groups = context.visibility?.groups
1733
+ if (!Array.isArray(groups) || groups.length === 0) {
1734
+ return false
1735
+ }
1736
+
1737
+ query.where(function visibilityGrants () {
1738
+ this.whereRaw('1 = 0')
1739
+ this.orWhere(function groupVisibilityGrant () {
1740
+ this.whereIn(
1741
+ column('access_group'),
1742
+ groups.map((group) => value('access_group', group))
1743
+ )
1744
+ })
1745
+ })
1746
+ return true
1747
+ },
1748
+ ...(rowPolicyOptions.policies || {})
1749
+ }
1750
+ })
1751
+
1752
+ await api.use(AutoFilterPlugin, {
1753
+ resolvers: {
1754
+ workspace: ({ context }) => context.scopeValues?.workspaceId
1755
+ },
1756
+ presets: {
1757
+ workspace: {
1758
+ filters: [{ field: 'workspace_id', resolver: 'workspace' }]
1759
+ }
1760
+ }
1761
+ })
1762
+
1763
+ await api.addResource('policy_projects', {
1764
+ schema: {
1765
+ id: { type: 'id' },
1766
+ name: { type: 'string', required: true, max: 200 },
1767
+ access_group: { type: 'string', required: true },
1768
+ workspace_id: { type: 'string', required: true }
1769
+ },
1770
+ relationships: {
1771
+ tasks: { type: 'hasMany', target: 'policy_tasks', foreignKey: 'project_id' },
1772
+ shared_tasks: {
1773
+ type: 'manyToMany',
1774
+ target: 'policy_tasks',
1775
+ through: 'policy_project_tasks',
1776
+ foreignKey: 'project_id',
1777
+ otherKey: 'task_id'
1778
+ }
1779
+ },
1780
+ autofilter: 'workspace',
1781
+ rowPolicy: 'groupVisibility',
1782
+ tableName: 'row_policy_projects'
1783
+ })
1784
+ await api.resources.policy_projects.createKnexTable()
1785
+ mapTable('row_policy_projects', 'policy_projects')
1786
+
1787
+ await api.addResource('policy_tasks', {
1788
+ schema: {
1789
+ id: { type: 'id' },
1790
+ title: { type: 'string', required: true, max: 200, search: true },
1791
+ access_group: { type: 'string', required: true },
1792
+ workspace_id: { type: 'string', required: true },
1793
+ project_id: {
1794
+ type: 'number',
1795
+ nullable: true,
1796
+ belongsTo: 'policy_projects',
1797
+ as: 'project',
1798
+ search: true
1799
+ }
1800
+ },
1801
+ relationships: {
1802
+ shared_projects: {
1803
+ type: 'manyToMany',
1804
+ target: 'policy_projects',
1805
+ through: 'policy_project_tasks',
1806
+ foreignKey: 'task_id',
1807
+ otherKey: 'project_id'
1808
+ }
1809
+ },
1810
+ autofilter: 'workspace',
1811
+ rowPolicy: 'groupVisibility',
1812
+ tableName: 'row_policy_tasks'
1813
+ })
1814
+ await api.resources.policy_tasks.createKnexTable()
1815
+ mapTable('row_policy_tasks', 'policy_tasks')
1816
+
1817
+ await api.addResource('policy_project_tasks', {
1818
+ schema: {
1819
+ id: { type: 'id' },
1820
+ project_id: { type: 'number', required: true, belongsTo: 'policy_projects', as: 'project' },
1821
+ task_id: { type: 'number', required: true, belongsTo: 'policy_tasks', as: 'task' }
1822
+ },
1823
+ tableName: 'row_policy_project_tasks'
1824
+ })
1825
+ await api.resources.policy_project_tasks.createKnexTable()
1826
+ mapTable('row_policy_project_tasks', 'policy_project_tasks')
1827
+
1828
+ if (storageMode.isAnyApi()) {
1829
+ const projectsDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_projects')
1830
+ const tasksDescriptor = await api.anyapi.registry.getDescriptor(tenantId, 'policy_tasks')
1831
+ storageMode.registerLink(
1832
+ 'row_policy_project_tasks',
1833
+ 'policy_projects',
1834
+ 'shared_tasks',
1835
+ projectsDescriptor?.manyToMany?.shared_tasks?.relationship,
1836
+ tasksDescriptor?.manyToMany?.shared_projects?.relationship
1837
+ )
1838
+ }
1839
+
1840
+ await api.addResource('policy_broken', {
1841
+ schema: {
1842
+ id: { type: 'id' },
1843
+ name: { type: 'string', required: true }
1844
+ },
1845
+ rowPolicy: () => undefined,
1846
+ tableName: 'row_policy_broken'
1847
+ })
1848
+ await api.resources.policy_broken.createKnexTable()
1849
+ mapTable('row_policy_broken', 'policy_broken')
1850
+
1851
+ if (storageMode.isAnyApi()) {
1852
+ storageMode.setCurrentTenant(previousTenant)
1853
+ }
1854
+
1855
+ return api
1856
+ }
1857
+
1561
1858
  /**
1562
1859
  * Creates an API with positioning support for testing
1563
1860
  */
@@ -1847,7 +2144,7 @@ export async function createCursorPaginationApi (knex) {
1847
2144
  put: 'full',
1848
2145
  patch: 'minimal'
1849
2146
  },
1850
- sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'created_at', 'code', 'status', 'type'],
2147
+ sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'createdAt', 'code', 'status', 'type'],
1851
2148
  queryDefaultLimit: 20,
1852
2149
  queryMaxLimit: 100,
1853
2150
  returnBasePath: 'https://api.example.com'
@@ -1867,11 +2164,12 @@ export async function createCursorPaginationApi (knex) {
1867
2164
  price: { type: 'number', required: true },
1868
2165
  sku: { type: 'string', required: true, max: 50, unique: true },
1869
2166
  status: { type: 'string', max: 50, default: 'active' },
1870
- created_at: { type: 'dateTime', default: 'now()' }
2167
+ createdAt: { type: 'dateTime', default: 'now()' }
1871
2168
  },
1872
2169
  tableName: 'cursor_products'
1873
2170
  })
1874
2171
  await api.resources.products.createKnexTable()
2172
+ mapTable('cursor_products', 'products')
1875
2173
 
1876
2174
  // Create items table with custom ID property
1877
2175
  await api.addResource('items', {
@@ -1886,6 +2184,7 @@ export async function createCursorPaginationApi (knex) {
1886
2184
  tableName: 'cursor_items'
1887
2185
  })
1888
2186
  await api.resources.items.createKnexTable()
2187
+ mapTable('cursor_items', 'items')
1889
2188
 
1890
2189
  return api
1891
2190
  }
@@ -2048,3 +2347,83 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
2048
2347
 
2049
2348
  return api
2050
2349
  }
2350
+
2351
+ export async function createFileUploadApi (knex, pluginOptions = {}) {
2352
+ const apiName = pluginOptions.apiName || 'file-upload-test-api'
2353
+ const tableName = pluginOptions.tableName || 'file_documents'
2354
+ const tablePrefix = pluginOptions.tablePrefix || 'file'
2355
+ const storage = pluginOptions.storage
2356
+ const detectorState = pluginOptions.detectorState || { payload: null }
2357
+
2358
+ if (!storage) {
2359
+ throw new Error('createFileUploadApi requires a storage adapter')
2360
+ }
2361
+
2362
+ if (storageMode.isAnyApi()) {
2363
+ storageMode.clearRegistry()
2364
+ }
2365
+
2366
+ const api = new Api({
2367
+ name: apiName,
2368
+ log: { level: process.env.LOG_LEVEL || 'info' }
2369
+ })
2370
+
2371
+ const previousTenant = storageMode.currentTenant
2372
+ const tenantId = storageMode.isAnyApi()
2373
+ ? (pluginOptions.tenantId || `${tablePrefix}_tenant`)
2374
+ : storageMode.defaultTenant
2375
+
2376
+ if (storageMode.isAnyApi()) {
2377
+ storageMode.setCurrentTenant(tenantId)
2378
+ }
2379
+
2380
+ try {
2381
+ await api.use(RestApiPlugin, {
2382
+ simplifiedApi: false,
2383
+ simplifiedTransport: false,
2384
+ returnRecordApi: {
2385
+ post: true,
2386
+ put: false,
2387
+ patch: false
2388
+ },
2389
+ returnRecordTransport: {
2390
+ post: 'full',
2391
+ put: 'no',
2392
+ patch: 'minimal'
2393
+ },
2394
+ sortableFields: ['id', 'title'],
2395
+ ...pluginOptions['rest-api']
2396
+ })
2397
+ await useStoragePlugin(api, knex, { tenantId })
2398
+ await resetAnyApiTables(knex)
2399
+ await api.use(FileHandlingPlugin)
2400
+
2401
+ api.rest.registerFileDetector({
2402
+ name: 'test-file-detector',
2403
+ detect: () => Boolean(detectorState.payload),
2404
+ parse: async () => detectorState.payload
2405
+ })
2406
+
2407
+ await api.addResource('documents', {
2408
+ schema: {
2409
+ id: { type: 'id' },
2410
+ title: { type: 'string', required: true },
2411
+ attachment: {
2412
+ type: 'file',
2413
+ storage,
2414
+ accepts: ['image/png'],
2415
+ maxSize: '1mb'
2416
+ }
2417
+ },
2418
+ tableName
2419
+ })
2420
+ await api.resources.documents.createKnexTable()
2421
+ mapTable(tableName, 'documents')
2422
+
2423
+ return api
2424
+ } finally {
2425
+ if (storageMode.isAnyApi()) {
2426
+ storageMode.setCurrentTenant(previousTenant)
2427
+ }
2428
+ }
2429
+ }
@@ -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
+ })