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
@@ -25,13 +25,21 @@ const knex = knexLib({
25
25
  useNullAsDefault: true
26
26
  })
27
27
 
28
- async function createConnectorParityApi () {
28
+ async function createConnectorParityApi ({
29
+ name = 'connector-parity-test-api',
30
+ tableName = 'connector_parity_countries',
31
+ publicBaseUrl = ''
32
+ } = {}) {
29
33
  const expressApp = express()
30
34
  const fastifyApp = new FakeFastifyApp()
31
35
  const api = new Api({
32
- name: 'connector-parity-test-api',
36
+ name,
33
37
  log: { level: process.env.LOG_LEVEL || 'silent' }
34
38
  })
39
+ const transportOptions = {
40
+ mountPath: '/api',
41
+ ...(publicBaseUrl ? { publicBaseUrl } : {})
42
+ }
35
43
 
36
44
  await api.use(RestApiPlugin, {
37
45
  simplifiedApi: false,
@@ -50,10 +58,10 @@ async function createConnectorParityApi () {
50
58
  })
51
59
 
52
60
  await api.use(RestApiKnexPlugin, { knex })
53
- await api.use(ExpressPlugin, { mountPath: '/api' })
61
+ await api.use(ExpressPlugin, transportOptions)
54
62
  await api.use(FastifyPlugin, {
55
63
  app: fastifyApp,
56
- mountPath: '/api'
64
+ ...transportOptions
57
65
  })
58
66
 
59
67
  await api.customize({
@@ -61,6 +69,10 @@ async function createConnectorParityApi () {
61
69
  'transport:request': {
62
70
  functionName: 'connector-parity-rejector',
63
71
  handler: async ({ context }) => {
72
+ if (context.transport?.request?.headers?.['x-use-public-url-override'] === 'yes') {
73
+ context.urlPrefixOverride = 'https://trusted.example/api'
74
+ }
75
+
64
76
  if (context.transport?.request?.headers?.['x-block-request'] === 'yes') {
65
77
  context.reject(401, 'Blocked by connector parity hook', {
66
78
  title: 'Unauthorized'
@@ -83,7 +95,7 @@ async function createConnectorParityApi () {
83
95
  name: { type: 'string', required: true, max: 100 },
84
96
  code: { type: 'string', max: 2 }
85
97
  },
86
- tableName: 'connector_parity_countries'
98
+ tableName
87
99
  })
88
100
 
89
101
  await api.resources.countries.createKnexTable()
@@ -101,6 +113,12 @@ function assertLocationSuffix (location, resourceId) {
101
113
  )
102
114
  }
103
115
 
116
+ function assertResourceLinksUsePrefix (body, expectedPrefix) {
117
+ const resourceId = body.data.id
118
+ assert.equal(body.data.links.self, `${expectedPrefix}/countries/${resourceId}`)
119
+ assert.equal(body.links.self, `${expectedPrefix}/countries/${resourceId}`)
120
+ }
121
+
104
122
  describe('HTTP Connector Parity', () => {
105
123
  let api
106
124
  let expressApp
@@ -159,6 +177,148 @@ describe('HTTP Connector Parity', () => {
159
177
  assertLocationSuffix(fastifyReply.headers.Location, fastifyReply.payload.data.id)
160
178
  })
161
179
 
180
+ it('does not build response URLs from untrusted proxy headers', async () => {
181
+ const expressDoc = createJsonApiDocument('countries', {
182
+ name: 'Express Hostile Header Country',
183
+ code: 'EH'
184
+ })
185
+ const fastifyDoc = createJsonApiDocument('countries', {
186
+ name: 'Fastify Hostile Header Country',
187
+ code: 'FH'
188
+ })
189
+
190
+ const expressResponse = await request(expressApp)
191
+ .post('/api/countries')
192
+ .set('Content-Type', 'application/vnd.api+json')
193
+ .set('Accept', 'application/vnd.api+json')
194
+ .set('Host', 'attacker.example')
195
+ .set('X-Forwarded-Host', 'proxy-attacker.example')
196
+ .set('X-Forwarded-Proto', 'https')
197
+ .send(expressDoc)
198
+
199
+ const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
200
+ method: 'POST',
201
+ routeUrl: '/api/countries',
202
+ requestUrl: '/api/countries',
203
+ headers: {
204
+ 'content-type': 'application/vnd.api+json',
205
+ accept: 'application/vnd.api+json',
206
+ host: 'attacker.example',
207
+ 'x-forwarded-host': 'proxy-attacker.example',
208
+ 'x-forwarded-proto': 'https'
209
+ },
210
+ body: fastifyDoc
211
+ })
212
+
213
+ assert.equal(expressResponse.status, 201)
214
+ assert.equal(fastifyReply.statusCode, 201)
215
+ assert.equal(
216
+ expressResponse.headers.location,
217
+ `/api/countries/${expressResponse.body.data.id}`
218
+ )
219
+ assert.equal(
220
+ fastifyReply.headers.Location,
221
+ `/api/countries/${fastifyReply.payload.data.id}`
222
+ )
223
+ assertResourceLinksUsePrefix(expressResponse.body, '/api')
224
+ assertResourceLinksUsePrefix(fastifyReply.payload, '/api')
225
+ })
226
+
227
+ it('uses explicit publicBaseUrl when a connector is configured with one', async () => {
228
+ const {
229
+ expressApp: publicExpressApp,
230
+ fastifyApp: publicFastifyApp
231
+ } = await createConnectorParityApi({
232
+ name: 'connector-parity-public-url-test-api',
233
+ tableName: 'connector_parity_public_countries',
234
+ publicBaseUrl: 'https://public.example/api/'
235
+ })
236
+
237
+ const expressDoc = createJsonApiDocument('countries', {
238
+ name: 'Express Public Country',
239
+ code: 'EP'
240
+ })
241
+ const fastifyDoc = createJsonApiDocument('countries', {
242
+ name: 'Fastify Public Country',
243
+ code: 'FP'
244
+ })
245
+
246
+ const expressResponse = await request(publicExpressApp)
247
+ .post('/api/countries')
248
+ .set('Content-Type', 'application/vnd.api+json')
249
+ .set('Accept', 'application/vnd.api+json')
250
+ .send(expressDoc)
251
+
252
+ const { reply: fastifyReply } = await invokeFastifyRoute(publicFastifyApp, {
253
+ method: 'POST',
254
+ routeUrl: '/api/countries',
255
+ requestUrl: '/api/countries',
256
+ headers: {
257
+ 'content-type': 'application/vnd.api+json',
258
+ accept: 'application/vnd.api+json'
259
+ },
260
+ body: fastifyDoc
261
+ })
262
+
263
+ assert.equal(expressResponse.status, 201)
264
+ assert.equal(fastifyReply.statusCode, 201)
265
+ assert.equal(
266
+ expressResponse.headers.location,
267
+ `https://public.example/api/countries/${expressResponse.body.data.id}`
268
+ )
269
+ assert.equal(
270
+ fastifyReply.headers.Location,
271
+ `https://public.example/api/countries/${fastifyReply.payload.data.id}`
272
+ )
273
+ assertResourceLinksUsePrefix(expressResponse.body, 'https://public.example/api')
274
+ assertResourceLinksUsePrefix(fastifyReply.payload, 'https://public.example/api')
275
+
276
+ await cleanTables(knex, ['connector_parity_public_countries'])
277
+ })
278
+
279
+ it('honors urlPrefixOverride set during transport request hooks', async () => {
280
+ const expressDoc = createJsonApiDocument('countries', {
281
+ name: 'Express Override Country',
282
+ code: 'EO'
283
+ })
284
+ const fastifyDoc = createJsonApiDocument('countries', {
285
+ name: 'Fastify Override Country',
286
+ code: 'FO'
287
+ })
288
+
289
+ const expressResponse = await request(expressApp)
290
+ .post('/api/countries')
291
+ .set('Content-Type', 'application/vnd.api+json')
292
+ .set('Accept', 'application/vnd.api+json')
293
+ .set('x-use-public-url-override', 'yes')
294
+ .send(expressDoc)
295
+
296
+ const { reply: fastifyReply } = await invokeFastifyRoute(fastifyApp, {
297
+ method: 'POST',
298
+ routeUrl: '/api/countries',
299
+ requestUrl: '/api/countries',
300
+ headers: {
301
+ 'content-type': 'application/vnd.api+json',
302
+ accept: 'application/vnd.api+json',
303
+ 'x-use-public-url-override': 'yes'
304
+ },
305
+ body: fastifyDoc
306
+ })
307
+
308
+ assert.equal(expressResponse.status, 201)
309
+ assert.equal(fastifyReply.statusCode, 201)
310
+ assert.equal(
311
+ expressResponse.headers.location,
312
+ `https://trusted.example/api/countries/${expressResponse.body.data.id}`
313
+ )
314
+ assert.equal(
315
+ fastifyReply.headers.Location,
316
+ `https://trusted.example/api/countries/${fastifyReply.payload.data.id}`
317
+ )
318
+ assertResourceLinksUsePrefix(expressResponse.body, 'https://trusted.example/api')
319
+ assertResourceLinksUsePrefix(fastifyReply.payload, 'https://trusted.example/api')
320
+ })
321
+
162
322
  it('rejects unsupported write content types using each connector transport policy', async () => {
163
323
  const expressResponse = await request(expressApp)
164
324
  .post('/api/countries')
@@ -0,0 +1,96 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { LocalStorage } from '../plugins/storage/local-storage.js'
7
+
8
+ let tempRoot
9
+
10
+ async function createTempRoot () {
11
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'json-rest-api-local-storage-'))
12
+ return tempRoot
13
+ }
14
+
15
+ function createFile (filename = 'photo.png') {
16
+ return {
17
+ filename,
18
+ mimetype: 'image/png',
19
+ data: Buffer.from('x')
20
+ }
21
+ }
22
+
23
+ describe('LocalStorage path containment', () => {
24
+ beforeEach(async () => {
25
+ await createTempRoot()
26
+ })
27
+
28
+ afterEach(async () => {
29
+ await fs.rm(tempRoot, { recursive: true, force: true })
30
+ })
31
+
32
+ it('rejects custom generated paths that escape to sibling directories', async () => {
33
+ const directory = path.join(tempRoot, 'uploads')
34
+ const evilDirectory = path.join(tempRoot, 'uploads_evil')
35
+ await fs.mkdir(evilDirectory, { recursive: true })
36
+
37
+ const storage = new LocalStorage({
38
+ directory,
39
+ fileBaseUrl: '/uploads',
40
+ nameStrategy: 'custom',
41
+ nameGenerator: async () => '../uploads_evil/pwn'
42
+ })
43
+
44
+ await assert.rejects(
45
+ storage.upload(createFile()),
46
+ /Custom nameGenerator must return a basename, not a path/
47
+ )
48
+
49
+ await assert.rejects(
50
+ fs.access(path.join(evilDirectory, 'pwn.png')),
51
+ { code: 'ENOENT' }
52
+ )
53
+ })
54
+
55
+ it('rejects empty and drive-prefixed custom generated names', async () => {
56
+ const directory = path.join(tempRoot, 'uploads')
57
+
58
+ const emptyNameStorage = new LocalStorage({
59
+ directory,
60
+ nameStrategy: 'custom',
61
+ nameGenerator: async () => ' '
62
+ })
63
+ await assert.rejects(
64
+ emptyNameStorage.upload(createFile()),
65
+ /Custom nameGenerator must return a non-empty basename/
66
+ )
67
+
68
+ const driveNameStorage = new LocalStorage({
69
+ directory,
70
+ nameStrategy: 'custom',
71
+ nameGenerator: async () => 'C:evil'
72
+ })
73
+ await assert.rejects(
74
+ driveNameStorage.upload(createFile()),
75
+ /Custom nameGenerator must return a basename, not a path/
76
+ )
77
+ })
78
+
79
+ it('keeps safe custom basenames inside the configured directory', async () => {
80
+ const directory = path.join(tempRoot, 'uploads')
81
+ const storage = new LocalStorage({
82
+ directory,
83
+ fileBaseUrl: '/uploads',
84
+ nameStrategy: 'custom',
85
+ nameGenerator: async () => 'avatar'
86
+ })
87
+
88
+ const firstUrl = await storage.upload(createFile())
89
+ const secondUrl = await storage.upload(createFile())
90
+
91
+ assert.equal(firstUrl, '/uploads/avatar.png')
92
+ assert.equal(secondUrl, '/uploads/avatar_1.png')
93
+ assert.equal(await fs.readFile(path.join(directory, 'avatar.png'), 'utf8'), 'x')
94
+ assert.equal(await fs.readFile(path.join(directory, 'avatar_1.png'), 'utf8'), 'x')
95
+ })
96
+ })
@@ -7,6 +7,7 @@ import {
7
7
  createJsonApiDocument
8
8
  } from './helpers/test-utils.js'
9
9
  import { createCursorPaginationApi } from './fixtures/api-configs.js'
10
+ import { storageMode } from './helpers/storage-mode.js'
10
11
 
11
12
  // Create Knex instance for tests
12
13
  const knex = knexLib({
@@ -19,6 +20,19 @@ const knex = knexLib({
19
20
 
20
21
  // API instance that persists across tests
21
22
  let api
23
+ const anyapiIt = storageMode.isAnyApi() ? it : it.skip
24
+
25
+ async function countAnyRecords (resource) {
26
+ const result = await knex('any_records')
27
+ .where({
28
+ tenant_id: api.anyapi.tenantId,
29
+ resource
30
+ })
31
+ .count('* as count')
32
+ .first()
33
+
34
+ return Number(result?.count ?? 0)
35
+ }
22
36
 
23
37
  describe('Multi-field Cursor Pagination', () => {
24
38
  before(async () => {
@@ -31,6 +45,43 @@ describe('Multi-field Cursor Pagination', () => {
31
45
  await knex.destroy()
32
46
  })
33
47
 
48
+ anyapiIt('cleans cursor fixture records from AnyAPI canonical storage', async () => {
49
+ await cleanTables(knex, ['cursor_products', 'cursor_items'])
50
+
51
+ await api.resources.products.post({
52
+ inputRecord: createJsonApiDocument('products', {
53
+ name: 'Cleanup Product',
54
+ category: 'Cleanup',
55
+ brand: 'Fixture',
56
+ price: 10,
57
+ sku: 'CLEAN-001'
58
+ }),
59
+ simplified: false
60
+ })
61
+
62
+ await api.resources.items.post({
63
+ inputRecord: createJsonApiDocument('items', {
64
+ code: 'CLEAN',
65
+ name: 'Cleanup Item',
66
+ category: 'Cleanup',
67
+ type: 'fixture'
68
+ }),
69
+ simplified: false
70
+ })
71
+
72
+ assert.equal(await countAnyRecords('products'), 1)
73
+ assert.equal(await countAnyRecords('items'), 1)
74
+
75
+ await cleanTables(knex, ['cursor_products'])
76
+
77
+ assert.equal(await countAnyRecords('products'), 0)
78
+ assert.equal(await countAnyRecords('items'), 1)
79
+
80
+ await cleanTables(knex, ['cursor_items'])
81
+
82
+ assert.equal(await countAnyRecords('items'), 0)
83
+ })
84
+
34
85
  describe('Basic Multi-field Cursor Pagination', () => {
35
86
  beforeEach(async () => {
36
87
  await cleanTables(knex, ['cursor_products'])
@@ -222,6 +273,76 @@ describe('Multi-field Cursor Pagination', () => {
222
273
  'Prices should be non-decreasing across pages')
223
274
  })
224
275
 
276
+ it('should advance when sorting by logical camelCase fields backed by snake_case storage columns', async () => {
277
+ await cleanTables(knex, ['cursor_products'])
278
+
279
+ const products = [
280
+ {
281
+ name: 'Newest Product',
282
+ category: 'Catalog',
283
+ brand: 'Acme',
284
+ price: 10,
285
+ sku: 'CAMEL-001',
286
+ createdAt: '2026-05-03T10:00:00.000Z'
287
+ },
288
+ {
289
+ name: 'Recent Product',
290
+ category: 'Catalog',
291
+ brand: 'Acme',
292
+ price: 20,
293
+ sku: 'CAMEL-002',
294
+ createdAt: '2026-05-03T09:00:00.000Z'
295
+ },
296
+ {
297
+ name: 'Older Product',
298
+ category: 'Catalog',
299
+ brand: 'Acme',
300
+ price: 30,
301
+ sku: 'CAMEL-003',
302
+ createdAt: '2026-05-03T08:00:00.000Z'
303
+ }
304
+ ]
305
+
306
+ for (const product of products) {
307
+ await api.resources.products.post({
308
+ inputRecord: createJsonApiDocument('products', product),
309
+ simplified: false
310
+ })
311
+ }
312
+
313
+ const firstPage = await api.resources.products.query({
314
+ queryParams: {
315
+ page: { size: 2 },
316
+ sort: ['-createdAt']
317
+ },
318
+ simplified: false
319
+ })
320
+
321
+ assert.equal(firstPage.data.length, 2)
322
+ assert.equal(firstPage.meta.pagination.cursor?.next.includes('createdAt:'), true,
323
+ 'Cursor should include the logical createdAt field')
324
+
325
+ const secondPage = await api.resources.products.query({
326
+ queryParams: {
327
+ page: {
328
+ size: 2,
329
+ after: firstPage.meta.pagination.cursor.next
330
+ },
331
+ sort: ['-createdAt']
332
+ },
333
+ simplified: false
334
+ })
335
+
336
+ assert.deepEqual(
337
+ firstPage.data.map((record) => record.attributes.name),
338
+ ['Newest Product', 'Recent Product']
339
+ )
340
+ assert.deepEqual(
341
+ secondPage.data.map((record) => record.attributes.name),
342
+ ['Older Product']
343
+ )
344
+ })
345
+
225
346
  it('should generate correct cursor values for multi-field sorting', async () => {
226
347
  const result = await api.resources.products.query({
227
348
  queryParams: {
@@ -9,6 +9,7 @@ import {
9
9
  resourceIdentifier
10
10
  } from './helpers/test-utils.js'
11
11
  import { createPaginationApi } from './fixtures/api-configs.js'
12
+ import { parseJsonApiQuery } from '../plugins/core/lib/querying-writing/connectors-query-parser.js'
12
13
 
13
14
  // Create Knex instance for tests
14
15
  const knex = knexLib({
@@ -22,6 +23,11 @@ const knex = knexLib({
22
23
  // API instance that persists across tests
23
24
  let api
24
25
 
26
+ const parseLinkQuery = (link) => {
27
+ const url = new URL(link, 'https://example.test')
28
+ return parseJsonApiQuery(url.search.slice(1))
29
+ }
30
+
25
31
  describe('Enhanced Pagination Features', () => {
26
32
  before(async () => {
27
33
  // Initialize API once with pagination features
@@ -34,6 +40,8 @@ describe('Enhanced Pagination Features', () => {
34
40
  })
35
41
 
36
42
  describe('Self Links', () => {
43
+ let countryId
44
+
37
45
  beforeEach(async () => {
38
46
  await cleanTables(knex, [
39
47
  'pagination_countries', 'pagination_publishers', 'pagination_books'
@@ -45,6 +53,7 @@ describe('Enhanced Pagination Features', () => {
45
53
  inputRecord: countryDoc,
46
54
  simplified: false
47
55
  })
56
+ countryId = countryResult.data.id
48
57
 
49
58
  // Create some books
50
59
  for (let i = 1; i <= 3; i++) {
@@ -131,6 +140,38 @@ describe('Enhanced Pagination Features', () => {
131
140
  assert(result.links, 'Response should have top-level links')
132
141
  assert(result.links.self.startsWith('/books'), 'Top-level self link should be relative')
133
142
  })
143
+
144
+ it('should serialize collection self-link query parameters in JSON:API form', async () => {
145
+ const result = await api.resources.books.query({
146
+ queryParams: {
147
+ filters: {
148
+ country: countryId
149
+ },
150
+ fields: {
151
+ books: 'title,country_id',
152
+ countries: 'name,code'
153
+ },
154
+ include: ['country'],
155
+ sort: ['title']
156
+ },
157
+ simplified: false
158
+ })
159
+
160
+ validateJsonApiStructure(result, true)
161
+
162
+ const selfUrl = new URL(result.links.self, 'https://example.test')
163
+ const selfQuery = parseLinkQuery(result.links.self)
164
+
165
+ assert.equal(selfUrl.searchParams.get('filter[country]'), countryId)
166
+ assert.equal(selfUrl.searchParams.has('filters[country]'), false)
167
+ assert.deepEqual(selfQuery.filters, { country: countryId })
168
+ assert.deepEqual(selfQuery.include, ['country'])
169
+ assert.deepEqual(selfQuery.sort, ['title'])
170
+ assert.deepEqual(selfQuery.fields, {
171
+ books: 'title,country_id',
172
+ countries: 'name,code'
173
+ })
174
+ })
134
175
  })
135
176
 
136
177
  describe('Pagination Metadata', () => {
@@ -207,13 +248,11 @@ describe('Enhanced Pagination Features', () => {
207
248
  assert(result.links.prev, 'Should have prev link')
208
249
  assert(result.links.next, 'Should have next link')
209
250
 
210
- // Verify link structure
211
- assert(result.links.self.includes('page[number]=2'))
212
- assert(result.links.self.includes('page[size]=3'))
213
- assert(result.links.first.includes('page[number]=1'))
214
- assert(result.links.last.includes('page[number]=4'))
215
- assert(result.links.prev.includes('page[number]=1'))
216
- assert(result.links.next.includes('page[number]=3'))
251
+ assert.deepEqual(parseLinkQuery(result.links.self).page, { number: 2, size: 3 })
252
+ assert.deepEqual(parseLinkQuery(result.links.first).page, { number: 1, size: 3 })
253
+ assert.deepEqual(parseLinkQuery(result.links.last).page, { number: 4, size: 3 })
254
+ assert.deepEqual(parseLinkQuery(result.links.prev).page, { number: 1, size: 3 })
255
+ assert.deepEqual(parseLinkQuery(result.links.next).page, { number: 3, size: 3 })
217
256
  })
218
257
 
219
258
  it('should not include prev link on first page', async () => {
@@ -253,9 +292,9 @@ describe('Enhanced Pagination Features', () => {
253
292
 
254
293
  assert(result.links, 'Should have links')
255
294
  assert(result.links.next, 'Should have next link since page 2 of 4')
256
- assert(result.links.next.includes('sort=-title'))
257
- assert(result.links.next.includes('page[number]=3'))
258
- assert(result.links.next.includes('page[size]=3'))
295
+ const nextQuery = parseLinkQuery(result.links.next)
296
+ assert.deepEqual(nextQuery.sort, ['-title'])
297
+ assert.deepEqual(nextQuery.page, { number: 3, size: 3 })
259
298
  })
260
299
  })
261
300
 
@@ -335,7 +374,7 @@ describe('Enhanced Pagination Features', () => {
335
374
  assert(result.links.self)
336
375
  assert(result.links.first)
337
376
  assert(result.links.next)
338
- assert(result.links.next.includes('page[after]='), 'Next link should include cursor parameter')
377
+ assert(parseLinkQuery(result.links.next).page.after, 'Next link should include cursor parameter')
339
378
  })
340
379
 
341
380
  it('should handle last page with cursor pagination', async () => {
@@ -476,6 +515,10 @@ describe('Enhanced Pagination Features', () => {
476
515
  filters: {
477
516
  country: parseInt(usCountryId)
478
517
  },
518
+ fields: {
519
+ books: 'title,country_id',
520
+ countries: 'name,code'
521
+ },
479
522
  include: ['country'],
480
523
  sort: ['title']
481
524
  },
@@ -503,10 +546,18 @@ describe('Enhanced Pagination Features', () => {
503
546
  assert(book.links.self)
504
547
  })
505
548
 
506
- // Check pagination links include all parameters
507
- assert(result.links.next.includes(`filters[country]=${usCountryId}`))
508
- assert(result.links.next.includes('include=country'))
509
- assert(result.links.next.includes('sort=title'))
549
+ const nextUrl = new URL(result.links.next, 'https://example.test')
550
+ const nextQuery = parseJsonApiQuery(nextUrl.search.slice(1))
551
+ assert.equal(nextUrl.searchParams.get('filter[country]'), usCountryId)
552
+ assert.equal(nextUrl.searchParams.has('filters[country]'), false)
553
+ assert.deepEqual(nextQuery.filters, { country: usCountryId })
554
+ assert.deepEqual(nextQuery.include, ['country'])
555
+ assert.deepEqual(nextQuery.sort, ['title'])
556
+ assert.deepEqual(nextQuery.fields, {
557
+ books: 'title,country_id',
558
+ countries: 'name,code'
559
+ })
560
+ assert.deepEqual(nextQuery.page, { number: 2, size: 5 })
510
561
  })
511
562
  })
512
563
  })