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.
- package/README.md +2 -1
- package/docs/API.md +12 -9
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +5 -2
- 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_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/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +5 -4
- package/plugins/core/connectors/express-plugin.js +6 -1
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- 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 +1 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +7 -1
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -15
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-relationship-includes.js +29 -28
- 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/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 +6 -1
- 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/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -41
- package/plugins/core/lib/storage/canonical-storage-mapping.js +2 -1
- package/plugins/core/lib/storage/storage-adapter.js +74 -13
- package/plugins/core/lib/writing/many-to-many-manipulations.js +4 -3
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +210 -97
- package/plugins/core/rest-api-knex-plugin.js +70 -60
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +8 -2
- package/plugins/core/rest-api-plugin-methods/common.js +85 -63
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +23 -13
- package/plugins/core/rest-api-plugin-methods/delete.js +2 -6
- package/plugins/core/rest-api-plugin-methods/get-related.js +15 -13
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +1 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +17 -6
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -6
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +24 -14
- package/plugins/core/rest-api-plugin-methods/post.js +4 -7
- package/plugins/core/rest-api-plugin-methods/put.js +4 -7
- package/plugins/core/rest-api-plugin.js +6 -36
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/storage/local-storage.js +44 -19
- package/plugins/storage/s3-storage.js +21 -55
- package/tests/README.md +1 -1
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/fastify-plugin.test.js +28 -0
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +362 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/local-storage.test.js +96 -0
- package/tests/pagination-cursor-multifield.test.js +121 -0
- 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 +1 -0
- 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
|
|
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,
|
|
61
|
+
await api.use(ExpressPlugin, transportOptions)
|
|
54
62
|
await api.use(FastifyPlugin, {
|
|
55
63
|
app: fastifyApp,
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
})
|