json-rest-api 1.0.24 → 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 (82) 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 +603 -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/schema-utils.js +1 -0
  25. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
  26. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
  27. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
  29. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  30. package/plugins/core/lib/querying/url-helpers.js +9 -21
  31. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  32. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  33. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  34. package/plugins/core/lib/querying-writing/knex-field-helpers.js +6 -1
  35. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  36. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  37. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  38. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
  39. package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
  40. package/plugins/core/lib/storage/storage-adapter.js +74 -13
  41. package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
  42. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  43. package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
  44. package/plugins/core/rest-api-knex-plugin.js +70 -60
  45. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
  46. package/plugins/core/rest-api-plugin-methods/common.js +85 -63
  47. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
  48. package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
  49. package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
  50. package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
  51. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
  52. package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
  53. package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
  54. package/plugins/core/rest-api-plugin-methods/post.js +4 -7
  55. package/plugins/core/rest-api-plugin-methods/put.js +4 -7
  56. package/plugins/core/rest-api-plugin.js +6 -36
  57. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  58. package/plugins/storage/local-storage.js +44 -19
  59. package/plugins/storage/s3-storage.js +21 -55
  60. package/tests/README.md +1 -1
  61. package/tests/anyapi-cursor-pagination.test.js +7 -1
  62. package/tests/anyapi-custom-idproperty.test.js +163 -0
  63. package/tests/fastify-plugin.test.js +28 -0
  64. package/tests/field-setters.test.js +10 -10
  65. package/tests/file-handling.test.js +118 -0
  66. package/tests/fixtures/api-configs.js +362 -4
  67. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  68. package/tests/http-connectors-parity.test.js +165 -5
  69. package/tests/local-storage.test.js +96 -0
  70. package/tests/pagination-cursor-multifield.test.js +121 -0
  71. package/tests/pagination-enhanced.test.js +66 -15
  72. package/tests/post-commit-hooks.test.js +120 -0
  73. package/tests/public-exports.test.js +15 -0
  74. package/tests/put-create-parity.test.js +216 -0
  75. package/tests/relationship-endpoints.test.js +259 -0
  76. package/tests/return-record-settings.test.js +184 -0
  77. package/tests/row-policy.test.js +433 -0
  78. package/tests/s3-storage.test.js +38 -0
  79. package/tests/searchschema-merge.test.js +17 -0
  80. package/tests/socketio.test.js +62 -0
  81. package/tests/storage-mapping.test.js +1 -0
  82. 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,133 @@ 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
+
201
336
  /**
202
337
  * Creates a small API focused on resource ID normalization behavior.
203
338
  */
@@ -721,7 +856,6 @@ export async function createPaginationApi (knex, options = {}) {
721
856
  * Creates an API with WebSocket/Socket.IO support for testing
722
857
  */
723
858
  export async function createWebSocketApi (knex, pluginOptions = {}) {
724
- const { SocketIOPlugin } = await import('../../plugins/core/socketio-plugin.js')
725
859
  const { jwtVerify } = await import('jose')
726
860
 
727
861
  const api = await createBasicApi(knex, {
@@ -1558,6 +1692,148 @@ export async function createAutoFilterApi (knex, pluginOptions = {}) {
1558
1692
  return api
1559
1693
  }
1560
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
+
1561
1837
  /**
1562
1838
  * Creates an API with positioning support for testing
1563
1839
  */
@@ -1847,7 +2123,7 @@ export async function createCursorPaginationApi (knex) {
1847
2123
  put: 'full',
1848
2124
  patch: 'minimal'
1849
2125
  },
1850
- sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'created_at', 'code', 'status', 'type'],
2126
+ sortableFields: ['id', 'name', 'category', 'brand', 'price', 'sku', 'createdAt', 'code', 'status', 'type'],
1851
2127
  queryDefaultLimit: 20,
1852
2128
  queryMaxLimit: 100,
1853
2129
  returnBasePath: 'https://api.example.com'
@@ -1867,11 +2143,12 @@ export async function createCursorPaginationApi (knex) {
1867
2143
  price: { type: 'number', required: true },
1868
2144
  sku: { type: 'string', required: true, max: 50, unique: true },
1869
2145
  status: { type: 'string', max: 50, default: 'active' },
1870
- created_at: { type: 'dateTime', default: 'now()' }
2146
+ createdAt: { type: 'dateTime', default: 'now()' }
1871
2147
  },
1872
2148
  tableName: 'cursor_products'
1873
2149
  })
1874
2150
  await api.resources.products.createKnexTable()
2151
+ mapTable('cursor_products', 'products')
1875
2152
 
1876
2153
  // Create items table with custom ID property
1877
2154
  await api.addResource('items', {
@@ -1886,6 +2163,7 @@ export async function createCursorPaginationApi (knex) {
1886
2163
  tableName: 'cursor_items'
1887
2164
  })
1888
2165
  await api.resources.items.createKnexTable()
2166
+ mapTable('cursor_items', 'items')
1889
2167
 
1890
2168
  return api
1891
2169
  }
@@ -2048,3 +2326,83 @@ export async function createSearchSchemaMergeApi (knex, pluginOptions = {}) {
2048
2326
 
2049
2327
  return api
2050
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
+ })