json-rest-api 1.0.14 → 1.0.16
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/connectors_ideas/fastify.js +66 -68
- package/connectors_ideas/koa-plugin.js +50 -51
- package/eslint.config.js +11 -2
- package/examples/url-override-example.js +63 -63
- package/index.js +13 -13
- package/lib/enhanced-logger.js +51 -51
- package/lib/error-formatter.js +61 -61
- package/lib/rest-api-errors.js +28 -28
- package/package.json +1 -1
- package/plugins/core/bulk-operations-plugin.js +142 -145
- package/plugins/core/connectors/express-plugin.js +210 -217
- package/plugins/core/connectors/lib/busboy-detector.js +84 -84
- package/plugins/core/connectors/lib/formidable-detector.js +50 -50
- package/plugins/core/connectors/lib/request-helpers.js +74 -74
- package/plugins/core/file-handling-plugin.js +125 -125
- package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
- package/plugins/core/lib/anyapi/schema-utils.js +101 -101
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
- package/plugins/core/lib/dbTablesOperations.js +134 -135
- package/plugins/core/lib/fractional-positioning.js +74 -76
- package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
- package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
- package/plugins/core/lib/querying/knex-process-includes.js +50 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
- package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
- package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
- package/plugins/core/lib/querying/knex-window-queries.js +89 -89
- package/plugins/core/lib/querying/url-helpers.js +35 -35
- package/plugins/core/lib/querying-writing/compile-schemas.js +99 -99
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
- package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
- package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
- package/plugins/core/lib/querying-writing/field-utils.js +44 -42
- package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
- package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
- package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
- package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
- package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
- package/plugins/core/lib/storage/storage-adapter.js +68 -68
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
- package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
- package/plugins/core/lib/writing/relationship-processor.js +58 -58
- package/plugins/core/rest-api-access.js +11 -15
- package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
- package/plugins/core/rest-api-cors-plugin.js +170 -171
- package/plugins/core/rest-api-knex-plugin.js +482 -493
- package/plugins/core/rest-api-multihome-plugin.js +247 -251
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
- package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
- package/plugins/core/rest-api-plugin-methods/common.js +295 -297
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
- package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
- package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
- package/plugins/core/rest-api-plugin-methods/get.js +86 -86
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
- package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
- package/plugins/core/rest-api-plugin-methods/post.js +61 -62
- package/plugins/core/rest-api-plugin-methods/put.js +86 -87
- package/plugins/core/rest-api-plugin-methods/query.js +88 -88
- package/plugins/core/rest-api-plugin-methods/release.js +5 -6
- package/plugins/core/rest-api-plugin.js +76 -78
- package/plugins/core/rest-api-positioning-plugin.js +251 -252
- package/plugins/core/socketio-plugin.js +240 -236
- package/plugins/storage/local-storage.js +103 -103
- package/plugins/storage/s3-storage.js +41 -42
- package/quickTest.js +37 -37
- package/scripts/debug-anyapi-review-filter.mjs +18 -18
- package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
- package/scripts/inspect-anyapi-descriptor.mjs +10 -10
- package/scripts/run-docs.js +112 -113
- package/test-debug.js +28 -28
- package/tests/TEST_TEMPLATE.test.js +51 -51
- package/tests/anyapi-basic.test.js +42 -42
- package/tests/anyapi-cursor-pagination.test.js +48 -48
- package/tests/bulk-operations.test.js +208 -208
- package/tests/computed-fields.test.js +204 -204
- package/tests/cors.test.js +412 -413
- package/tests/curl.test.js +275 -276
- package/tests/custom-idproperty-relationships.test.js +266 -266
- package/tests/field-getters.test.js +132 -132
- package/tests/field-setters.test.js +110 -110
- package/tests/fixtures/api-configs.js +699 -699
- package/tests/helpers/storage-mode.js +32 -32
- package/tests/helpers/test-utils.js +76 -76
- package/tests/include-depth-validation.test.js +76 -76
- package/tests/includes.test.js +145 -145
- package/tests/multihome.test.js +175 -175
- package/tests/nested-includes.test.js +255 -255
- package/tests/pagination-cursor-multifield.test.js +164 -164
- package/tests/pagination-enhanced.test.js +225 -225
- package/tests/polymorphic-relationships.test.js +96 -96
- package/tests/polymorphic-simple.test.js +44 -44
- package/tests/positioning-advanced.test.js +226 -226
- package/tests/positioning-extended.test.js +399 -399
- package/tests/positioning-multifilter.test.js +204 -204
- package/tests/positioning.test.js +266 -266
- package/tests/queries.test.js +184 -184
- package/tests/query-limits.test.js +154 -155
- package/tests/relationship-endpoints.test.js +127 -127
- package/tests/relationships.test.js +178 -179
- package/tests/rest-api-access.test.js +86 -86
- package/tests/rest-api.test.js +103 -103
- package/tests/searchschema-merge.test.js +93 -93
- package/tests/socketio.test.js +326 -326
- package/tests/virtual-fields.test.js +164 -164
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Calculates pagination metadata from query results
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* @param {number} total - Total number of records in the dataset
|
|
5
5
|
* @param {number} page - Current page number (1-based)
|
|
6
6
|
* @param {number} pageSize - Number of records per page
|
|
7
7
|
* @returns {Object} Pagination metadata including page count and hasMore flag
|
|
8
8
|
* @throws {Error} If pageSize <= 0 or page < 1
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
10
|
* @example
|
|
11
11
|
* // Input: 100 total records, viewing page 2 of 20 records each
|
|
12
12
|
* const meta = calculatePaginationMeta(100, 2, 20);
|
|
13
13
|
* // Output:
|
|
14
14
|
* // {
|
|
15
15
|
* // page: 2, // current page
|
|
16
|
-
* // pageSize: 20, // records per page
|
|
16
|
+
* // pageSize: 20, // records per page
|
|
17
17
|
* // pageCount: 5, // total pages (100/20)
|
|
18
18
|
* // total: 100, // total records
|
|
19
19
|
* // hasMore: true // page 2 < 5, so more pages exist
|
|
20
20
|
* // }
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
22
|
* @example
|
|
23
23
|
* // Input: Last page scenario
|
|
24
24
|
* const meta = calculatePaginationMeta(45, 5, 10);
|
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
* // total: 45,
|
|
31
31
|
* // hasMore: false // on last page
|
|
32
32
|
* // }
|
|
33
|
-
*
|
|
33
|
+
*
|
|
34
34
|
* @description
|
|
35
35
|
* Used by:
|
|
36
36
|
* - rest-api-knex-plugin's dataQuery method after counting total records
|
|
37
37
|
* - generatePaginationLinks to build navigation links
|
|
38
38
|
* - Applied when offset-based pagination is enabled
|
|
39
|
-
*
|
|
39
|
+
*
|
|
40
40
|
* Purpose:
|
|
41
41
|
* - Provides consistent pagination metadata across all collection responses
|
|
42
42
|
* - Calculates derived values like pageCount and hasMore flag
|
|
43
43
|
* - Validates pagination parameters to prevent invalid states
|
|
44
|
-
*
|
|
44
|
+
*
|
|
45
45
|
* Data flow:
|
|
46
46
|
* 1. Query method counts total records when pagination.counts is enabled
|
|
47
47
|
* 2. calculatePaginationMeta processes the count with current page/size
|
|
@@ -50,34 +50,34 @@
|
|
|
50
50
|
*/
|
|
51
51
|
export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
52
52
|
if (pageSize <= 0) {
|
|
53
|
-
throw new Error('Page size must be greater than 0')
|
|
53
|
+
throw new Error('Page size must be greater than 0')
|
|
54
54
|
}
|
|
55
55
|
if (page < 1) {
|
|
56
|
-
throw new Error('Page number must be greater than 0')
|
|
56
|
+
throw new Error('Page number must be greater than 0')
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
const pageCount = Math.ceil(total / pageSize)
|
|
60
|
-
const currentPage = page || 1
|
|
61
|
-
const hasMore = currentPage < pageCount
|
|
62
|
-
|
|
58
|
+
|
|
59
|
+
const pageCount = Math.ceil(total / pageSize)
|
|
60
|
+
const currentPage = page || 1
|
|
61
|
+
const hasMore = currentPage < pageCount
|
|
62
|
+
|
|
63
63
|
return {
|
|
64
64
|
page: currentPage,
|
|
65
65
|
pageSize,
|
|
66
66
|
pageCount,
|
|
67
67
|
total,
|
|
68
68
|
hasMore
|
|
69
|
-
}
|
|
70
|
-
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Generates JSON:API compliant pagination links for offset-based pagination
|
|
74
|
-
*
|
|
74
|
+
*
|
|
75
75
|
* @param {string} urlPrefix - Base URL prefix for the API
|
|
76
76
|
* @param {string} scopeName - Resource type name
|
|
77
77
|
* @param {Object} queryParams - Current query parameters
|
|
78
78
|
* @param {Object} paginationMeta - Pagination metadata from calculatePaginationMeta
|
|
79
79
|
* @returns {Object|null} Links object with self, first, last, prev, next
|
|
80
|
-
*
|
|
80
|
+
*
|
|
81
81
|
* @example
|
|
82
82
|
* // Input data:
|
|
83
83
|
* const urlPrefix = '/api/v1';
|
|
@@ -94,9 +94,9 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
94
94
|
* total: 100,
|
|
95
95
|
* hasMore: true
|
|
96
96
|
* };
|
|
97
|
-
*
|
|
97
|
+
*
|
|
98
98
|
* const links = generatePaginationLinks(urlPrefix, scopeName, queryParams, paginationMeta);
|
|
99
|
-
*
|
|
99
|
+
*
|
|
100
100
|
* // Output - complete URLs with all parameters preserved:
|
|
101
101
|
* // {
|
|
102
102
|
* // self: '/api/articles?filter[status]=published&sort=-created_at&page[number]=2&page[size]=20',
|
|
@@ -105,12 +105,12 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
105
105
|
* // prev: '/api/articles?filter[status]=published&sort=-created_at&page[number]=1&page[size]=20',
|
|
106
106
|
* // next: '/api/articles?filter[status]=published&sort=-created_at&page[number]=3&page[size]=20'
|
|
107
107
|
* // }
|
|
108
|
-
*
|
|
108
|
+
*
|
|
109
109
|
* @example
|
|
110
110
|
* // Input: First page (no prev link)
|
|
111
111
|
* const paginationMeta = { page: 1, pageSize: 10, pageCount: 3 };
|
|
112
112
|
* const links = generatePaginationLinks('/api', 'users', {}, paginationMeta);
|
|
113
|
-
*
|
|
113
|
+
*
|
|
114
114
|
* // Output - notice no 'prev' property:
|
|
115
115
|
* // {
|
|
116
116
|
* // self: '/api/users?page[number]=1&page[size]=10',
|
|
@@ -118,18 +118,18 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
118
118
|
* // last: '/api/users?page[number]=3&page[size]=10',
|
|
119
119
|
* // next: '/api/users?page[number]=2&page[size]=10'
|
|
120
120
|
* // }
|
|
121
|
-
*
|
|
121
|
+
*
|
|
122
122
|
* @description
|
|
123
123
|
* Used by:
|
|
124
124
|
* - rest-api-knex-plugin's dataQuery adds these links to response.links
|
|
125
125
|
* - Called when offset-based pagination is used (not cursor-based)
|
|
126
|
-
*
|
|
126
|
+
*
|
|
127
127
|
* Purpose:
|
|
128
128
|
* - JSON:API spec requires pagination links for easy navigation
|
|
129
129
|
* - Preserves all other query parameters (filters, sorts, includes)
|
|
130
130
|
* - Handles complex nested parameters like filter[author][name]=John
|
|
131
131
|
* - Only includes prev/next when applicable
|
|
132
|
-
*
|
|
132
|
+
*
|
|
133
133
|
* Data flow:
|
|
134
134
|
* 1. After records are fetched and pagination calculated
|
|
135
135
|
* 2. Builds complete URLs preserving all query parameters
|
|
@@ -139,81 +139,81 @@ export const calculatePaginationMeta = (total, page, pageSize) => {
|
|
|
139
139
|
export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, paginationMeta) => {
|
|
140
140
|
// Allow empty urlPrefix to generate relative links
|
|
141
141
|
// if (!urlPrefix) return null;
|
|
142
|
-
|
|
143
|
-
const { page, pageCount, pageSize } = paginationMeta
|
|
144
|
-
const links = {}
|
|
145
|
-
|
|
142
|
+
|
|
143
|
+
const { page, pageCount, pageSize } = paginationMeta
|
|
144
|
+
const links = {}
|
|
145
|
+
|
|
146
146
|
const otherParams = Object.entries(queryParams)
|
|
147
147
|
.filter(([key]) => key !== 'page')
|
|
148
148
|
.flatMap(([key, value]) => {
|
|
149
149
|
if (Array.isArray(value)) {
|
|
150
150
|
return value.length === 0
|
|
151
151
|
? []
|
|
152
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
152
|
+
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
153
153
|
}
|
|
154
154
|
if (typeof value === 'object' && value !== null) {
|
|
155
|
-
const parts = []
|
|
155
|
+
const parts = []
|
|
156
156
|
const processObject = (obj, prefix) => {
|
|
157
157
|
Object.entries(obj).forEach(([k, v]) => {
|
|
158
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]
|
|
158
|
+
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
159
159
|
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
160
|
-
processObject(v, newKey)
|
|
160
|
+
processObject(v, newKey)
|
|
161
161
|
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
162
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
162
|
+
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
163
163
|
}
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
processObject(value, key)
|
|
167
|
-
return parts
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
processObject(value, key)
|
|
167
|
+
return parts
|
|
168
168
|
}
|
|
169
169
|
if (value === undefined || value === null) {
|
|
170
|
-
return []
|
|
170
|
+
return []
|
|
171
171
|
}
|
|
172
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
172
|
+
return [`${key}=${encodeURIComponent(value)}`]
|
|
173
173
|
})
|
|
174
174
|
.filter(Boolean)
|
|
175
|
-
.join('&')
|
|
176
|
-
|
|
177
|
-
const baseUrl = `${urlPrefix}/${scopeName}
|
|
178
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
179
|
-
|
|
180
|
-
links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}
|
|
181
|
-
|
|
175
|
+
.join('&')
|
|
176
|
+
|
|
177
|
+
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
178
|
+
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
179
|
+
|
|
180
|
+
links.self = `${baseUrl}${queryPrefix}page[number]=${page}&page[size]=${pageSize}`
|
|
181
|
+
|
|
182
182
|
if (pageCount !== undefined) {
|
|
183
|
-
links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}
|
|
184
|
-
links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}
|
|
185
|
-
|
|
183
|
+
links.first = `${baseUrl}${queryPrefix}page[number]=1&page[size]=${pageSize}`
|
|
184
|
+
links.last = `${baseUrl}${queryPrefix}page[number]=${pageCount}&page[size]=${pageSize}`
|
|
185
|
+
|
|
186
186
|
if (page > 1) {
|
|
187
|
-
links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}
|
|
187
|
+
links.prev = `${baseUrl}${queryPrefix}page[number]=${page - 1}&page[size]=${pageSize}`
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
|
|
190
190
|
if (page < pageCount) {
|
|
191
|
-
links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}
|
|
191
|
+
links.next = `${baseUrl}${queryPrefix}page[number]=${page + 1}&page[size]=${pageSize}`
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
-
|
|
195
|
-
return links
|
|
196
|
-
}
|
|
194
|
+
|
|
195
|
+
return links
|
|
196
|
+
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
199
|
* Creates an opaque cursor string from record data for cursor-based pagination
|
|
200
|
-
*
|
|
200
|
+
*
|
|
201
201
|
* @param {Object} record - Database record to create cursor from
|
|
202
202
|
* @param {Array<string>} sortFields - Fields to include in cursor (default: ['id'])
|
|
203
203
|
* @returns {string} URL-safe cursor string
|
|
204
|
-
*
|
|
204
|
+
*
|
|
205
205
|
* @example
|
|
206
206
|
* // Input: Simple ID-based cursor
|
|
207
|
-
* const record = {
|
|
208
|
-
* id: 123,
|
|
209
|
-
* title: 'My Article',
|
|
210
|
-
* created_at: '2024-01-01T10:00:00Z'
|
|
207
|
+
* const record = {
|
|
208
|
+
* id: 123,
|
|
209
|
+
* title: 'My Article',
|
|
210
|
+
* created_at: '2024-01-01T10:00:00Z'
|
|
211
211
|
* };
|
|
212
212
|
* const cursor = createCursor(record, ['id']);
|
|
213
|
-
*
|
|
213
|
+
*
|
|
214
214
|
* // Output: "id:123"
|
|
215
215
|
* // This cursor marks position at record with id=123
|
|
216
|
-
*
|
|
216
|
+
*
|
|
217
217
|
* @example
|
|
218
218
|
* // Input: Multi-field cursor for complex sorting
|
|
219
219
|
* const record = {
|
|
@@ -222,12 +222,12 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
222
222
|
* title: 'Article: Part 2'
|
|
223
223
|
* };
|
|
224
224
|
* const cursor = createCursor(record, ['created_at', 'id']);
|
|
225
|
-
*
|
|
225
|
+
*
|
|
226
226
|
* // Output: "created_at:2024-01-15T08%3A30%3A00.000Z,id:5"
|
|
227
227
|
* // URL-encoded to handle the colons in timestamp
|
|
228
228
|
* // Used for queries like: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', 5)
|
|
229
|
-
*
|
|
230
|
-
* @example
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
231
|
* // Input: Handling special characters
|
|
232
232
|
* const record = {
|
|
233
233
|
* category: 'Tech & Science',
|
|
@@ -235,21 +235,21 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
235
235
|
* id: 42
|
|
236
236
|
* };
|
|
237
237
|
* const cursor = createCursor(record, ['category', 'title', 'id']);
|
|
238
|
-
*
|
|
238
|
+
*
|
|
239
239
|
* // Output: "category:Tech%20%26%20Science,title:AI%3A%20The%20Future%3F,id:42"
|
|
240
240
|
* // Spaces encoded as %20, & as %26, : as %3A, ? as %3F
|
|
241
|
-
*
|
|
241
|
+
*
|
|
242
242
|
* @description
|
|
243
243
|
* Used by:
|
|
244
244
|
* - generateCursorPaginationLinks to create next/prev cursors
|
|
245
245
|
* - buildCursorMeta to provide cursor in response metadata
|
|
246
|
-
*
|
|
246
|
+
*
|
|
247
247
|
* Purpose:
|
|
248
248
|
* - Cursor-based pagination is more stable than offset for changing data
|
|
249
249
|
* - Encodes multiple sort fields to maintain stable ordering
|
|
250
250
|
* - URL-encodes values to handle special characters safely
|
|
251
251
|
* - Simple format that's easy to parse back
|
|
252
|
-
*
|
|
252
|
+
*
|
|
253
253
|
* Data flow:
|
|
254
254
|
* 1. After fetching records, takes the last record
|
|
255
255
|
* 2. Extracts values for all sort fields
|
|
@@ -258,44 +258,44 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
|
|
|
258
258
|
* 5. Enables efficient "WHERE (field1, field2) > (val1, val2)" queries
|
|
259
259
|
*/
|
|
260
260
|
export const createCursor = (record, sortFields = ['id']) => {
|
|
261
|
-
const parts = []
|
|
261
|
+
const parts = []
|
|
262
262
|
sortFields.forEach(field => {
|
|
263
263
|
if (record[field] !== undefined) {
|
|
264
|
-
const value = record[field]
|
|
265
|
-
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
266
|
-
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
264
|
+
const value = record[field]
|
|
265
|
+
const stringValue = value instanceof Date ? value.toISOString() : String(value)
|
|
266
|
+
parts.push(`${field}:${encodeURIComponent(stringValue)}`)
|
|
267
267
|
}
|
|
268
|
-
})
|
|
269
|
-
return parts.join(',')
|
|
270
|
-
}
|
|
268
|
+
})
|
|
269
|
+
return parts.join(',')
|
|
270
|
+
}
|
|
271
271
|
|
|
272
272
|
/**
|
|
273
273
|
* Parses a cursor string back into field/value pairs
|
|
274
|
-
*
|
|
274
|
+
*
|
|
275
275
|
* @param {string} cursor - Cursor string to parse
|
|
276
276
|
* @returns {Object} Object with field names as keys and decoded values
|
|
277
277
|
* @throws {Error} If cursor format is invalid
|
|
278
|
-
*
|
|
278
|
+
*
|
|
279
279
|
* @example
|
|
280
280
|
* // Input: Simple cursor
|
|
281
281
|
* const cursor = "id:123";
|
|
282
282
|
* const data = parseCursor(cursor);
|
|
283
|
-
*
|
|
283
|
+
*
|
|
284
284
|
* // Output: { id: "123" }
|
|
285
285
|
* // Ready to use in SQL: WHERE id > '123'
|
|
286
|
-
*
|
|
286
|
+
*
|
|
287
287
|
* @example
|
|
288
288
|
* // Input: Multi-field cursor with URL-encoded values
|
|
289
289
|
* const cursor = "created_at:2024-01-15T08%3A30%3A00.000Z,id:5";
|
|
290
290
|
* const data = parseCursor(cursor);
|
|
291
|
-
*
|
|
292
|
-
* // Output:
|
|
291
|
+
*
|
|
292
|
+
* // Output:
|
|
293
293
|
* // {
|
|
294
294
|
* // created_at: "2024-01-15T08:30:00.000Z", // Decoded
|
|
295
295
|
* // id: "5"
|
|
296
296
|
* // }
|
|
297
297
|
* // Used for: WHERE (created_at, id) > ('2024-01-15T08:30:00.000Z', '5')
|
|
298
|
-
*
|
|
298
|
+
*
|
|
299
299
|
* @example
|
|
300
300
|
* // Input: Invalid cursor (throws error)
|
|
301
301
|
* try {
|
|
@@ -304,17 +304,17 @@ export const createCursor = (record, sortFields = ['id']) => {
|
|
|
304
304
|
* console.log(e.message);
|
|
305
305
|
* // "Invalid cursor format: Invalid cursor format: missing colon separator"
|
|
306
306
|
* }
|
|
307
|
-
*
|
|
307
|
+
*
|
|
308
308
|
* @description
|
|
309
309
|
* Used by:
|
|
310
310
|
* - rest-api-knex-plugin's dataQuery when processing page[after] parameter
|
|
311
311
|
* - Used to build WHERE clause for cursor-based queries
|
|
312
|
-
*
|
|
312
|
+
*
|
|
313
313
|
* Purpose:
|
|
314
314
|
* - Decodes cursor back to usable values for SQL queries
|
|
315
315
|
* - Handles URL-encoded special characters
|
|
316
316
|
* - Validates cursor format to prevent injection attacks
|
|
317
|
-
*
|
|
317
|
+
*
|
|
318
318
|
* Data flow:
|
|
319
319
|
* 1. Client sends page[after]=cursor parameter
|
|
320
320
|
* 2. parseCursor extracts field values from cursor
|
|
@@ -323,38 +323,38 @@ export const createCursor = (record, sortFields = ['id']) => {
|
|
|
323
323
|
*/
|
|
324
324
|
export const parseCursor = (cursor) => {
|
|
325
325
|
try {
|
|
326
|
-
const data = {}
|
|
326
|
+
const data = {}
|
|
327
327
|
if (!cursor || cursor.trim() === '') {
|
|
328
|
-
throw new Error('Empty cursor')
|
|
328
|
+
throw new Error('Empty cursor')
|
|
329
329
|
}
|
|
330
|
-
|
|
331
|
-
const pairs = cursor.split(',')
|
|
332
|
-
|
|
330
|
+
|
|
331
|
+
const pairs = cursor.split(',')
|
|
332
|
+
|
|
333
333
|
for (const pair of pairs) {
|
|
334
|
-
const colonIndex = pair.indexOf(':')
|
|
334
|
+
const colonIndex = pair.indexOf(':')
|
|
335
335
|
if (colonIndex === -1) {
|
|
336
|
-
throw new Error('Invalid cursor format: missing colon separator')
|
|
336
|
+
throw new Error('Invalid cursor format: missing colon separator')
|
|
337
337
|
}
|
|
338
|
-
|
|
339
|
-
const field = pair.substring(0, colonIndex)
|
|
340
|
-
const encodedValue = pair.substring(colonIndex + 1)
|
|
341
|
-
|
|
338
|
+
|
|
339
|
+
const field = pair.substring(0, colonIndex)
|
|
340
|
+
const encodedValue = pair.substring(colonIndex + 1)
|
|
341
|
+
|
|
342
342
|
if (!field) {
|
|
343
|
-
throw new Error('Invalid cursor format: empty field name')
|
|
343
|
+
throw new Error('Invalid cursor format: empty field name')
|
|
344
344
|
}
|
|
345
|
-
|
|
346
|
-
data[field] = decodeURIComponent(encodedValue)
|
|
345
|
+
|
|
346
|
+
data[field] = decodeURIComponent(encodedValue)
|
|
347
347
|
}
|
|
348
|
-
|
|
349
|
-
return data
|
|
348
|
+
|
|
349
|
+
return data
|
|
350
350
|
} catch (e) {
|
|
351
|
-
throw new Error(`Invalid cursor format: ${e.message}`)
|
|
351
|
+
throw new Error(`Invalid cursor format: ${e.message}`)
|
|
352
352
|
}
|
|
353
|
-
}
|
|
353
|
+
}
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
356
|
* Generates pagination links for cursor-based pagination
|
|
357
|
-
*
|
|
357
|
+
*
|
|
358
358
|
* @param {string} urlPrefix - Base URL prefix
|
|
359
359
|
* @param {string} scopeName - Resource type name
|
|
360
360
|
* @param {Object} queryParams - Current query parameters
|
|
@@ -363,12 +363,12 @@ export const parseCursor = (cursor) => {
|
|
|
363
363
|
* @param {boolean} hasMore - Whether more records exist
|
|
364
364
|
* @param {Array<string>} sortFields - Fields used for cursor
|
|
365
365
|
* @returns {Object|null} Links object with self, first, next
|
|
366
|
-
*
|
|
366
|
+
*
|
|
367
367
|
* @example
|
|
368
368
|
* // Input: First page of results
|
|
369
369
|
* const urlPrefix = '/api/v1';
|
|
370
370
|
* const scopeName = 'articles';
|
|
371
|
-
* const queryParams = {
|
|
371
|
+
* const queryParams = {
|
|
372
372
|
* filter: { status: 'published' },
|
|
373
373
|
* page: { size: 20 }
|
|
374
374
|
* };
|
|
@@ -378,11 +378,11 @@ export const parseCursor = (cursor) => {
|
|
|
378
378
|
* { id: 3, created_at: '2024-01-03', title: 'Third' }
|
|
379
379
|
* ];
|
|
380
380
|
* const hasMore = true; // More records exist
|
|
381
|
-
*
|
|
381
|
+
*
|
|
382
382
|
* const links = generateCursorPaginationLinks(
|
|
383
383
|
* urlPrefix, scopeName, queryParams, records, 20, hasMore, ['created_at', 'id']
|
|
384
384
|
* );
|
|
385
|
-
*
|
|
385
|
+
*
|
|
386
386
|
* // Output:
|
|
387
387
|
* // {
|
|
388
388
|
* // self: '/api/v1/articles?filter[status]=published&page[size]=20',
|
|
@@ -390,7 +390,7 @@ export const parseCursor = (cursor) => {
|
|
|
390
390
|
* // next: '/api/v1/articles?filter[status]=published&page[size]=20&page[after]=created_at:2024-01-03,id:3'
|
|
391
391
|
* // }
|
|
392
392
|
* // The 'next' cursor points after the last record (id:3)
|
|
393
|
-
*
|
|
393
|
+
*
|
|
394
394
|
* @example
|
|
395
395
|
* // Input: Last page (no more records)
|
|
396
396
|
* const records = [
|
|
@@ -398,28 +398,28 @@ export const parseCursor = (cursor) => {
|
|
|
398
398
|
* { id: 99, created_at: '2024-03-02' }
|
|
399
399
|
* ];
|
|
400
400
|
* const hasMore = false; // No more records
|
|
401
|
-
*
|
|
401
|
+
*
|
|
402
402
|
* const links = generateCursorPaginationLinks(
|
|
403
403
|
* '/api', 'articles', {}, records, 20, hasMore, ['id']
|
|
404
404
|
* );
|
|
405
|
-
*
|
|
405
|
+
*
|
|
406
406
|
* // Output - no 'next' link:
|
|
407
407
|
* // {
|
|
408
408
|
* // self: '/api/articles?page[size]=20',
|
|
409
409
|
* // first: '/api/articles?page[size]=20'
|
|
410
410
|
* // }
|
|
411
|
-
*
|
|
411
|
+
*
|
|
412
412
|
* @description
|
|
413
413
|
* Used by:
|
|
414
414
|
* - rest-api-knex-plugin when cursor pagination is enabled
|
|
415
415
|
* - Called after fetching records with cursor-based query
|
|
416
|
-
*
|
|
416
|
+
*
|
|
417
417
|
* Purpose:
|
|
418
418
|
* - Cursor pagination is more efficient for large datasets
|
|
419
419
|
* - Stable pagination when data is being added/removed
|
|
420
420
|
* - Only includes 'next' link when more data exists
|
|
421
421
|
* - Preserves all other query parameters
|
|
422
|
-
*
|
|
422
|
+
*
|
|
423
423
|
* Data flow:
|
|
424
424
|
* 1. After cursor-based query fetches records
|
|
425
425
|
* 2. If hasMore is true, creates cursor from last record
|
|
@@ -429,72 +429,72 @@ export const parseCursor = (cursor) => {
|
|
|
429
429
|
*/
|
|
430
430
|
export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams, records, pageSize, hasMore, sortFields = ['id']) => {
|
|
431
431
|
// Allow empty urlPrefix to generate relative links
|
|
432
|
-
if (!records.length) return null
|
|
433
|
-
|
|
434
|
-
const links = {}
|
|
435
|
-
const baseUrl = `${urlPrefix}/${scopeName}
|
|
436
|
-
|
|
432
|
+
if (!records.length) return null
|
|
433
|
+
|
|
434
|
+
const links = {}
|
|
435
|
+
const baseUrl = `${urlPrefix}/${scopeName}`
|
|
436
|
+
|
|
437
437
|
const otherParams = Object.entries(queryParams)
|
|
438
438
|
.filter(([key]) => key !== 'page')
|
|
439
439
|
.flatMap(([key, value]) => {
|
|
440
440
|
if (Array.isArray(value)) {
|
|
441
441
|
return value.length === 0
|
|
442
442
|
? []
|
|
443
|
-
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
443
|
+
: value.map((v) => `${key}=${encodeURIComponent(v)}`)
|
|
444
444
|
}
|
|
445
445
|
if (typeof value === 'object' && value !== null) {
|
|
446
|
-
const parts = []
|
|
446
|
+
const parts = []
|
|
447
447
|
const processObject = (obj, prefix) => {
|
|
448
448
|
Object.entries(obj).forEach(([k, v]) => {
|
|
449
|
-
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]
|
|
449
|
+
const newKey = prefix ? `${prefix}[${k}]` : `${key}[${k}]`
|
|
450
450
|
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
451
|
-
processObject(v, newKey)
|
|
451
|
+
processObject(v, newKey)
|
|
452
452
|
} else if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
|
|
453
|
-
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
453
|
+
parts.push(`${newKey}=${encodeURIComponent(v)}`)
|
|
454
454
|
}
|
|
455
|
-
})
|
|
456
|
-
}
|
|
457
|
-
processObject(value, key)
|
|
458
|
-
return parts
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
processObject(value, key)
|
|
458
|
+
return parts
|
|
459
459
|
}
|
|
460
460
|
if (value === undefined || value === null) {
|
|
461
|
-
return []
|
|
461
|
+
return []
|
|
462
462
|
}
|
|
463
|
-
return [`${key}=${encodeURIComponent(value)}`]
|
|
463
|
+
return [`${key}=${encodeURIComponent(value)}`]
|
|
464
464
|
})
|
|
465
465
|
.filter(Boolean)
|
|
466
|
-
.join('&')
|
|
467
|
-
|
|
468
|
-
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
469
|
-
|
|
466
|
+
.join('&')
|
|
467
|
+
|
|
468
|
+
const queryPrefix = otherParams ? `?${otherParams}&` : '?'
|
|
469
|
+
|
|
470
470
|
if (queryParams.page?.after) {
|
|
471
|
-
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}
|
|
471
|
+
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${queryParams.page.after}`
|
|
472
472
|
} else if (queryParams.page?.before) {
|
|
473
|
-
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}
|
|
473
|
+
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[before]=${queryParams.page.before}`
|
|
474
474
|
} else {
|
|
475
|
-
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}
|
|
475
|
+
links.self = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
|
|
476
476
|
}
|
|
477
|
-
|
|
478
|
-
links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}
|
|
479
|
-
|
|
477
|
+
|
|
478
|
+
links.first = `${baseUrl}${queryPrefix}page[size]=${pageSize}`
|
|
479
|
+
|
|
480
480
|
if (hasMore && records.length > 0) {
|
|
481
|
-
const lastRecord = records[records.length - 1]
|
|
482
|
-
const nextCursor = createCursor(lastRecord, sortFields)
|
|
483
|
-
links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}
|
|
481
|
+
const lastRecord = records[records.length - 1]
|
|
482
|
+
const nextCursor = createCursor(lastRecord, sortFields)
|
|
483
|
+
links.next = `${baseUrl}${queryPrefix}page[size]=${pageSize}&page[after]=${nextCursor}`
|
|
484
484
|
}
|
|
485
|
-
|
|
486
|
-
return links
|
|
487
|
-
}
|
|
485
|
+
|
|
486
|
+
return links
|
|
487
|
+
}
|
|
488
488
|
|
|
489
489
|
/**
|
|
490
490
|
* Builds cursor pagination metadata for the response
|
|
491
|
-
*
|
|
491
|
+
*
|
|
492
492
|
* @param {Array<Object>} records - Current page records
|
|
493
493
|
* @param {number} pageSize - Records per page
|
|
494
494
|
* @param {boolean} hasMore - Whether more records exist
|
|
495
495
|
* @param {Array<string>} sortFields - Fields used for cursor
|
|
496
496
|
* @returns {Object} Metadata with pageSize, hasMore, and optional cursor
|
|
497
|
-
*
|
|
497
|
+
*
|
|
498
498
|
* @example
|
|
499
499
|
* // Input: Page with more records available
|
|
500
500
|
* const records = [
|
|
@@ -503,7 +503,7 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
|
|
|
503
503
|
* { id: 12, created_at: '2024-01-12', title: 'Article 12' }
|
|
504
504
|
* ];
|
|
505
505
|
* const meta = buildCursorMeta(records, 20, true, ['created_at', 'id']);
|
|
506
|
-
*
|
|
506
|
+
*
|
|
507
507
|
* // Output:
|
|
508
508
|
* // {
|
|
509
509
|
* // pageSize: 20,
|
|
@@ -512,31 +512,31 @@ export const generateCursorPaginationLinks = (urlPrefix, scopeName, queryParams,
|
|
|
512
512
|
* // next: 'created_at:2024-01-12,id:12' // Cursor from last record
|
|
513
513
|
* // }
|
|
514
514
|
* // }
|
|
515
|
-
*
|
|
515
|
+
*
|
|
516
516
|
* @example
|
|
517
517
|
* // Input: Last page (no more records)
|
|
518
518
|
* const records = [
|
|
519
519
|
* { id: 98, name: 'Last Item' }
|
|
520
520
|
* ];
|
|
521
521
|
* const meta = buildCursorMeta(records, 20, false, ['id']);
|
|
522
|
-
*
|
|
522
|
+
*
|
|
523
523
|
* // Output:
|
|
524
524
|
* // {
|
|
525
525
|
* // pageSize: 20,
|
|
526
526
|
* // hasMore: false
|
|
527
527
|
* // // No cursor property since hasMore is false
|
|
528
528
|
* // }
|
|
529
|
-
*
|
|
529
|
+
*
|
|
530
530
|
* @description
|
|
531
531
|
* Used by:
|
|
532
532
|
* - rest-api-knex-plugin adds this to response.meta for cursor pagination
|
|
533
533
|
* - Provides cursor that client can use directly if needed
|
|
534
|
-
*
|
|
534
|
+
*
|
|
535
535
|
* Purpose:
|
|
536
536
|
* - Gives clients direct access to cursor for custom pagination
|
|
537
537
|
* - Indicates whether more pages exist without counting
|
|
538
538
|
* - Simpler than offset pagination metadata (no total count)
|
|
539
|
-
*
|
|
539
|
+
*
|
|
540
540
|
* Data flow:
|
|
541
541
|
* 1. Called after fetching records with cursor query
|
|
542
542
|
* 2. Creates cursor from last record if more exist
|
|
@@ -547,14 +547,14 @@ export const buildCursorMeta = (records, pageSize, hasMore, sortFields = ['id'])
|
|
|
547
547
|
const meta = {
|
|
548
548
|
pageSize,
|
|
549
549
|
hasMore
|
|
550
|
-
}
|
|
551
|
-
|
|
550
|
+
}
|
|
551
|
+
|
|
552
552
|
if (hasMore && records.length > 0) {
|
|
553
|
-
const lastRecord = records[records.length - 1]
|
|
553
|
+
const lastRecord = records[records.length - 1]
|
|
554
554
|
meta.cursor = {
|
|
555
555
|
next: createCursor(lastRecord, sortFields)
|
|
556
|
-
}
|
|
556
|
+
}
|
|
557
557
|
}
|
|
558
|
-
|
|
559
|
-
return meta
|
|
560
|
-
}
|
|
558
|
+
|
|
559
|
+
return meta
|
|
560
|
+
}
|