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.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +56 -6
- package/tests/test-development-guide.md +5 -5
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, before, after, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import knexLib from 'knex'
|
|
4
|
+
import { Api } from 'hooked-api'
|
|
5
|
+
import { RestApiPlugin } from '../plugins/core/rest-api-plugin.js'
|
|
6
|
+
import { RestApiKnexPlugin } from '../plugins/core/rest-api-knex-plugin.js'
|
|
7
|
+
import {
|
|
8
|
+
cleanTables,
|
|
9
|
+
validateJsonApiStructure,
|
|
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('Logical resource ids in write validation', () => {
|
|
23
|
+
before(async () => {
|
|
24
|
+
api = new Api({
|
|
25
|
+
name: 'logical-id-write-validation-test'
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await api.use(RestApiPlugin, {
|
|
29
|
+
simplifiedApi: false,
|
|
30
|
+
simplifiedTransport: false,
|
|
31
|
+
returnRecordApi: {
|
|
32
|
+
post: 'full',
|
|
33
|
+
put: 'full',
|
|
34
|
+
patch: 'full'
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
await api.use(RestApiKnexPlugin, { knex })
|
|
38
|
+
|
|
39
|
+
await api.addResource('profiles', {
|
|
40
|
+
tableName: 'logical_id_profiles',
|
|
41
|
+
idProperty: 'user_id',
|
|
42
|
+
schema: {
|
|
43
|
+
id: { type: 'id', required: true, storage: { column: 'user_id' } },
|
|
44
|
+
name: { type: 'string', required: true, max: 64 }
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
await api.resources.profiles.createKnexTable()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
after(async () => {
|
|
51
|
+
await knex.destroy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
await cleanTables(knex, ['logical_id_profiles'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('accepts explicit resource ids in non-simplified POST without exposing id as an attribute', async () => {
|
|
59
|
+
const created = await api.resources.profiles.post({
|
|
60
|
+
inputRecord: {
|
|
61
|
+
data: {
|
|
62
|
+
type: 'profiles',
|
|
63
|
+
id: '101',
|
|
64
|
+
attributes: {
|
|
65
|
+
name: 'Alice'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
validateJsonApiStructure(created)
|
|
72
|
+
assert.equal(created.data.id, '101')
|
|
73
|
+
assert.equal(created.data.attributes.name, 'Alice')
|
|
74
|
+
assert.ok(!('id' in created.data.attributes))
|
|
75
|
+
|
|
76
|
+
const dbRow = await knex('logical_id_profiles').where('user_id', 101).first()
|
|
77
|
+
assert.ok(dbRow)
|
|
78
|
+
assert.equal(String(dbRow.user_id), '101')
|
|
79
|
+
assert.equal(dbRow.name, 'Alice')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('reports missing logical ids against data.id during POST validation', async () => {
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
() => api.resources.profiles.post({
|
|
85
|
+
inputRecord: {
|
|
86
|
+
data: {
|
|
87
|
+
type: 'profiles',
|
|
88
|
+
attributes: {
|
|
89
|
+
name: 'Missing Id'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
(error) => {
|
|
95
|
+
assert.equal(error.code, 'REST_API_VALIDATION')
|
|
96
|
+
assert.deepEqual(error.details?.fields, ['data.id'])
|
|
97
|
+
assert.equal(error.details?.violations?.[0]?.field, 'data.id')
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('accepts explicit resource ids in simplified POST and still keeps id out of attributes', async () => {
|
|
104
|
+
const created = await api.resources.profiles.post({
|
|
105
|
+
id: '202',
|
|
106
|
+
name: 'Bob'
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
assert.equal(created.id, '202')
|
|
110
|
+
assert.equal(created.name, 'Bob')
|
|
111
|
+
|
|
112
|
+
const fetched = await api.resources.profiles.get({
|
|
113
|
+
id: '202',
|
|
114
|
+
simplified: false
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
validateJsonApiStructure(fetched)
|
|
118
|
+
assert.equal(fetched.data.id, '202')
|
|
119
|
+
assert.equal(fetched.data.attributes.name, 'Bob')
|
|
120
|
+
assert.ok(!('id' in fetched.data.attributes))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('accepts logical id validation through PUT full replacement', async () => {
|
|
124
|
+
await knex('logical_id_profiles').insert({
|
|
125
|
+
user_id: 303,
|
|
126
|
+
name: 'Before'
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const updated = await api.resources.profiles.put({
|
|
130
|
+
id: '303',
|
|
131
|
+
inputRecord: {
|
|
132
|
+
data: {
|
|
133
|
+
type: 'profiles',
|
|
134
|
+
id: '303',
|
|
135
|
+
attributes: {
|
|
136
|
+
name: 'After'
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
validateJsonApiStructure(updated)
|
|
143
|
+
assert.equal(updated.data.id, '303')
|
|
144
|
+
assert.equal(updated.data.attributes.name, 'After')
|
|
145
|
+
assert.ok(!('id' in updated.data.attributes))
|
|
146
|
+
|
|
147
|
+
const dbRow = await knex('logical_id_profiles').where('user_id', 303).first()
|
|
148
|
+
assert.ok(dbRow)
|
|
149
|
+
assert.equal(dbRow.name, 'After')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -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'])
|
|
@@ -55,7 +106,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
55
106
|
}
|
|
56
107
|
})
|
|
57
108
|
|
|
58
|
-
it
|
|
109
|
+
it('should handle cursor pagination with multi-field sorting without skipping records', async () => {
|
|
59
110
|
// Sort by category first (which has duplicates), then by name
|
|
60
111
|
const pageSize = 3
|
|
61
112
|
const allProducts = []
|
|
@@ -102,7 +153,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
102
153
|
assert.equal(ids.length, uniqueIds.length, 'Should have no duplicate products')
|
|
103
154
|
})
|
|
104
155
|
|
|
105
|
-
it
|
|
156
|
+
it('should handle DESC sorting with duplicates correctly', async () => {
|
|
106
157
|
// Sort by category DESC, then name ASC
|
|
107
158
|
const firstPage = await api.resources.products.query({
|
|
108
159
|
queryParams: {
|
|
@@ -189,7 +240,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
189
240
|
'Green Gardens vegetables should be in alphabetical order by name')
|
|
190
241
|
})
|
|
191
242
|
|
|
192
|
-
it
|
|
243
|
+
it('should handle cursor pagination with numeric and text fields', async () => {
|
|
193
244
|
// Sort by price (numeric), then category (text), then name (text)
|
|
194
245
|
const firstPage = await api.resources.products.query({
|
|
195
246
|
queryParams: {
|
|
@@ -222,7 +273,77 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
222
273
|
'Prices should be non-decreasing across pages')
|
|
223
274
|
})
|
|
224
275
|
|
|
225
|
-
it
|
|
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
|
+
|
|
346
|
+
it('should generate correct cursor values for multi-field sorting', async () => {
|
|
226
347
|
const result = await api.resources.products.query({
|
|
227
348
|
queryParams: {
|
|
228
349
|
page: { size: 3 },
|
|
@@ -244,7 +365,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
244
365
|
assert(cursor.includes('name:'), 'Cursor should include name field')
|
|
245
366
|
})
|
|
246
367
|
|
|
247
|
-
it
|
|
368
|
+
it('should handle page[before] with multi-field sorting', async () => {
|
|
248
369
|
// First, get to the middle of the dataset
|
|
249
370
|
const firstPage = await api.resources.products.query({
|
|
250
371
|
queryParams: {
|
|
@@ -272,7 +393,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
272
393
|
size: 4,
|
|
273
394
|
before: secondPage.meta.pagination.cursor?.next ||
|
|
274
395
|
// If no next cursor, create one from the last record
|
|
275
|
-
`category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)}`
|
|
396
|
+
`category:${encodeURIComponent(secondPage.data[0].attributes.category)},name:${encodeURIComponent(secondPage.data[0].attributes.name)},id:${encodeURIComponent(secondPage.data[0].id)}`
|
|
276
397
|
},
|
|
277
398
|
sort: ['category', 'name']
|
|
278
399
|
},
|
|
@@ -292,7 +413,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
292
413
|
await cleanTables(knex, ['cursor_products', 'cursor_items'])
|
|
293
414
|
})
|
|
294
415
|
|
|
295
|
-
it
|
|
416
|
+
it('should handle all records having the same value in first sort field', async () => {
|
|
296
417
|
// Create products all in the same category
|
|
297
418
|
const products = [
|
|
298
419
|
{ name: 'Alpha', category: 'Electronics', brand: 'TechCo', price: 100, sku: 'TC-001' },
|
|
@@ -348,7 +469,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
348
469
|
assert(gadgetIncIndex < firstTechCoIndex, 'GadgetInc products should come before TechCo')
|
|
349
470
|
})
|
|
350
471
|
|
|
351
|
-
it
|
|
472
|
+
it('should handle empty results with multi-field cursor', async () => {
|
|
352
473
|
// Create one product
|
|
353
474
|
await api.resources.products.post({
|
|
354
475
|
inputRecord: createJsonApiDocument('products', {
|
|
@@ -375,7 +496,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
375
496
|
assert(!firstPage.meta.pagination.cursor?.next, 'Should not have next cursor on last page')
|
|
376
497
|
})
|
|
377
498
|
|
|
378
|
-
it
|
|
499
|
+
it('should handle special characters in multi-field cursors', async () => {
|
|
379
500
|
// Create products with special characters
|
|
380
501
|
const products = [
|
|
381
502
|
{ name: 'Product: Special', category: 'Cat:1', brand: 'Brand,A', price: 10, sku: 'SP-001' },
|
|
@@ -418,7 +539,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
418
539
|
'Should get different record on second page')
|
|
419
540
|
})
|
|
420
541
|
|
|
421
|
-
it
|
|
542
|
+
it('should maintain stable sorting with custom id property', async () => {
|
|
422
543
|
// Create items using the resource with custom ID property
|
|
423
544
|
const items = [
|
|
424
545
|
{ code: 'A001', name: 'Apple', category: 'Fruit', type: 'Fresh' },
|
|
@@ -464,7 +585,7 @@ describe('Multi-field Cursor Pagination', () => {
|
|
|
464
585
|
}
|
|
465
586
|
})
|
|
466
587
|
|
|
467
|
-
it
|
|
588
|
+
it('should handle the exact bug scenario - multiple fruits with same category', async () => {
|
|
468
589
|
// This is the exact scenario from the bug report
|
|
469
590
|
await cleanTables(knex, ['cursor_products'])
|
|
470
591
|
|
|
@@ -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
|
-
|
|
211
|
-
assert(result.links.
|
|
212
|
-
assert(result.links.
|
|
213
|
-
assert(result.links.
|
|
214
|
-
assert(result.links.
|
|
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
|
-
|
|
257
|
-
assert(
|
|
258
|
-
assert(
|
|
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.
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
assert(
|
|
509
|
-
assert(
|
|
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
|
})
|