From bdecd5917fa4ff25625ad64d052f26dab75ba029 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
This simple format will be made more complex in the future, but should serve the basic needs as it is.
- RERUM will test for property matches. By default, this is limited to 10 records in the response to guard against unreasonable queries. To allow for more records in the response one can add the URL parameter Note that your application may experience strange behavior with large limits, such as ?limit=1000. It is recommended to use a limit of 100 or less. If you expect there are more than 100 matching records, use a paged query to make consecutive requests until all records all gathered. A API (1.1.0)
Custom Query
limit to the query requests. If you expect the query request will have a very large response with many objects, your application should use a paged query by also using the skip URL parameter. You will see an example of this below.
- limit to the query requests. If you expect the query request will have a very large response with many objects, your application should use a paged query by also using the skip URL parameter. You will see an example of this below.
+ limit above the maximum of 500 is not an error. It is reduced to 500, and the response says so — see Pagination parameters. Read the applied value from the Pagination-Limit response header rather than assuming you received everything you asked for.
const many_results = await pagedQuery(100, 0, {"type": "Thing"})
- function pagedQuery(lim, it = 0, queryObj, allResults = []) {
- return fetch(`https://devstore.rerum.io/v1/api/query?limit=${lim}&skip=${it}`, {
+ async function pagedQuery(lim, it = 0, queryObj, allResults = []) {
+ const response = await fetch(`https://devstore.rerum.io/v1/api/query?limit=${lim}&skip=${it}`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify(queryObj)
})
- .then(response => response.json())
- .then(results => {
- if (results.length) {
- allResults = allResults.concat(results)
- return pagedQuery(lim, it + results.length, queryObj, allResults)
- }
- return allResults
- })
- .catch(err => {
- console.warn("Could not process a result in paged query")
- throw err
- })
+ // Also how a walk past the skip maximum ends. The message says so.
+ if (!response.ok) throw new Error(await response.text())
+ const results = await response.json()
+ allResults = allResults.concat(results)
+
+ // Advance by the page size the server applied, which may be smaller than the one you asked for.
+ const appliedLimit = Number(response.headers.get("Pagination-Limit")) || lim
+
+ // A short page is the last page. Do not wait for an empty one.
+ if (results.length < appliedLimit) return allResults
+ return pagedQuery(lim, it + appliedLimit, queryObj, allResults)
}
+
+ limit and skip are URL parameters shared by Custom Query, Text Search, and Phrase Search. Both must be whole numbers written in plain decimal digits. Anything else is a 400 rather than a guess, because guessing is how a client asking for ?limit=1e3 used to receive a single record and report a completed walk.
+
| Parameter | +Default | +Maximum | +Rules | +
|---|---|---|---|
limit |
+ 100 | +500 | +A whole number of 1 or greater. Above the maximum it is reduced to the maximum, and Pagination-Limit reports what was applied. |
+
skip |
+ 0 | +100000 | +A whole number of 0 or greater. Above the maximum it is a 400 naming the maximum, because clamping it would return the page at the maximum over and over. |
+
+ Rejected with a 400: ?limit=abc, ?limit=10abc, ?limit=1e3, ?limit=0x10, ?limit=250.7, ?limit=0, ?limit=-5, ?limit=, and the same forms of skip. Supplying either parameter more than once, as in ?limit=100&limit=200, is also a 400 — there is no correct reading of it.
+
One form cannot be caught. ?limit[a]=5 never reaches the server as a limit at all, so it is indistinguishable from leaving the parameter out and you will silently get the default.
+ The two maximums are not treated alike, because being over them does not mean the same thing. An over-maximum limit is a page size RERUM can honour in part, so it is reduced. An over-maximum skip has no honest reading at all — every page past it would repeat the page at the maximum — so it is refused. If you are reaching that depth, narrow the query rather than paging further.
+
+ Every paged response reports what was applied and what the maximums are, so a client can configure itself from any single response and tell a truncated page from a genuine final one. These headers are readable cross-origin. +
++
+ Pagination-Limit: 500
+ Pagination-Skip: 0
+ Pagination-Limit-Max: 500
+ Pagination-Skip-Max: 100000
+
+
+
- Rejected with a 400: ?limit=abc, ?limit=10abc, ?limit=1e3, ?limit=0x10, ?limit=250.7, ?limit=0, ?limit=-5, ?limit=, and the same forms of skip. Supplying either parameter more than once, as in ?limit=100&limit=200, is also a 400 — there is no correct reading of it.
-
One form cannot be caught. ?limit[a]=5 never reaches the server as a limit at all, so it is indistinguishable from leaving the parameter out and you will silently get the default.
- The two maximums are not treated alike, because being over them does not mean the same thing. An over-maximum limit is a page size RERUM can honour in part, so it is reduced. An over-maximum skip has no honest reading at all — every page past it would repeat the page at the maximum — so it is refused. If you are reaching that depth, narrow the query rather than paging further.
-
Every paged response reports what was applied and what the maximums are, so a client can configure itself from any single response and tell a truncated page from a genuine final one. These headers are readable cross-origin.
- Pagination-Limit-Max and Pagination-Skip-Max are reported on rejections too, including the 400 for a skip past the maximum. That is what lets a paged walk recognise the depth boundary and stop cleanly, instead of treating every 400 as the end of the results. Pagination-Limit and Pagination-Skip appear only where a page was actually served.
+ Pagination-Limit-Max and Pagination-Skip-Max are reported on rejections too, including the 400 for a skip past the maximum. That is what lets a paged walk recognise the depth boundary and stop cleanly.
From a469f8930d756ae8d747581f4117b067da6fb5d8 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 11 Sep 2026 12:15:45 -0500
Subject: [PATCH 09/15] less tests
---
__tests__/utils.test.js | 25 +++++-----------------
routes/__tests__/query.test.js | 38 +++++++++------------------------
routes/__tests__/search.test.js | 14 +-----------
3 files changed, 16 insertions(+), 61 deletions(-)
diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js
index 8cb4506d..6decbc09 100644
--- a/__tests__/utils.test.js
+++ b/__tests__/utils.test.js
@@ -353,7 +353,7 @@ describe('controllers/utils.js getPagination', () => {
})
it('rejects a limit that is not a whole number greater than 0', () => {
- for (const limit of ['abc', '10abc', '1e3', '0x10', '250.7', '-5', '', '0']) {
+ for (const limit of ['abc', '10abc', '1e3', '0x10', '250.7', '-5', '']) {
assertRejects({ limit }, /'limit' URL parameter must be a whole number greater than 0/)
}
})
@@ -383,12 +383,6 @@ describe('controllers/utils.js getPagination', () => {
)
})
- it('clamps a limit above the maximum instead of rejecting it', () => {
- const { limit } = getPagination({ limit: String(Number.MAX_SAFE_INTEGER) })
- const { 'Pagination-Limit-Max': max } = capturedHeadersFor({})
- assert.strictEqual(limit, Number(max))
- })
-
it('clamps a limit too large for a Number rather than calling it not a whole number', () => {
// Past about 309 digits Number.parseInt returns Infinity. Rejected there, a digit-only value
// would get a 400 saying it is not a whole number, and a limit the contract promises to clamp
@@ -403,13 +397,8 @@ describe('controllers/utils.js getPagination', () => {
it('rejects a skip above the maximum rather than serving the same page forever', () => {
// Clamping it would hand back the page at the maximum on every request past it. A client
- // advancing skip and stopping on an empty page would never terminate.
- const max = Number(capturedHeadersFor({})['Pagination-Skip-Max'])
- assertRejects({ skip: String(max + 1) }, /beyond the maximum/)
- assertRejects({ skip: String(max + 50000) }, /beyond the maximum/)
- })
-
- it('names the configured maximum in the rejection, so a client can act on it', () => {
+ // advancing skip and stopping on an empty page would never terminate. The rejection names the
+ // configured maximum, so a client can act on it, and the maximum itself is still readable.
const original = process.env.MAX_QUERY_SKIP
try {
process.env.MAX_QUERY_SKIP = '2500'
@@ -421,11 +410,6 @@ describe('controllers/utils.js getPagination', () => {
}
})
- it('accepts a skip exactly at the maximum', () => {
- const max = Number(capturedHeadersFor({})['Pagination-Skip-Max'])
- assert.strictEqual(getPagination({ skip: String(max) }).skip, max)
- })
-
it('reports the applied values and the maximums on every paged response', () => {
const headers = capturedHeadersFor({ limit: '25', skip: '10' })
assert.strictEqual(headers['Pagination-Limit'], '25')
@@ -476,7 +460,8 @@ describe('controllers/utils.js getPagination', () => {
it('reports the ceilings on a rejection, so a client can recover from the 400 it just got', () => {
// The 400 for an over-deep skip is the response a client most needs the ceiling from, and it
// is what lets a paged walk tell that boundary apart from every other 400 it could receive.
- for (const query of [{ limit: 'abc' }, { skip: 'abc' }, { limit: ['100', '200'] }, { skip: '999999999' }]) {
+ // One query per throw site: the value it cannot read, and the skip it can read but will not serve.
+ for (const query of [{ limit: 'abc' }, { skip: '999999999' }]) {
const headers = capturedHeadersFor(query)
assert.ok(Number(headers['Pagination-Limit-Max']) > 0, `${JSON.stringify(query)} should still report the limit ceiling`)
assert.ok(Number(headers['Pagination-Skip-Max']) > 0, `${JSON.stringify(query)} should still report the skip ceiling`)
diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js
index 03b7c048..b62b6a1c 100644
--- a/routes/__tests__/query.test.js
+++ b/routes/__tests__/query.test.js
@@ -142,16 +142,20 @@ describe('pagination parameters on /query', () => {
.send({ test: "item" })
}
+ // The rejected forms themselves are covered against getPagination in __tests__/utils.test.js.
+ // What is left to prove here is the wiring: the controller reads req.query, and the 400 it
+ // throws reaches the client as a 400 rather than as an unhandled error.
it("rejects a limit or skip it cannot read exactly", async () => {
- for (const queryString of ["?limit=abc", "?limit=1e3", "?limit=0", "?limit=-5", "?limit=250.7", "?limit=", "?skip=abc", "?skip=2.9", "?skip=-5"]) {
+ for (const queryString of ["?limit=abc", "?skip=2.9"]) {
const response = await post(queryString)
assert.strictEqual(response.statusCode, 400, `${queryString} should be a 400`)
}
})
+ // Asserted over HTTP rather than only against getPagination, because this is what proves
+ // Express really does hand a repeated parameter over as the Array that getPagination rejects.
it("rejects a repeated limit rather than taking one of the two values", async () => {
assert.strictEqual((await post("?limit=100&limit=200")).statusCode, 400)
- assert.strictEqual((await post("?limit=200&limit=100")).statusCode, 400)
})
it("reports the applied limit and skip, and the maximums, on a paged response", async () => {
@@ -163,13 +167,7 @@ describe('pagination parameters on /query', () => {
assert.ok(Number(response.headers['pagination-skip-max']) > 0)
})
- it("reports the clamp when the limit asked for is above the maximum", async () => {
- const response = await post("?limit=999999")
- assert.strictEqual(response.statusCode, 200)
- assert.strictEqual(response.headers['pagination-limit'], response.headers['pagination-limit-max'])
- })
-
- it("applies the reported limit and skip to the database cursor", async () => {
+ it("applies the reported limit and skip to the database cursor, over a deterministic order", async () => {
const applied = {}
db.find.mockReturnValueOnce(recordingCursor([mockDoc], applied))
const response = await request(pagedTester)
@@ -179,6 +177,8 @@ describe('pagination parameters on /query', () => {
assert.strictEqual(applied.limit, 7)
assert.strictEqual(applied.skip, 3)
+ // A skip offset means nothing without a stable order to count into.
+ assert.deepStrictEqual(applied.sort, { _id: 1 })
assert.strictEqual(response.headers['pagination-limit'], '7')
assert.strictEqual(response.headers['pagination-skip'], '3')
})
@@ -192,31 +192,13 @@ describe('pagination parameters on /query', () => {
const response = await post(`?skip=${skipMax + 1}`)
assert.strictEqual(response.statusCode, 400)
assert.match(response.text, new RegExp(`beyond the maximum of ${skipMax}`))
- })
-
- it("reports the ceilings on the skip rejection, so a walk can tell that boundary from any other 400", async () => {
- const paged = await post("?limit=2&skip=0")
- const skipMax = Number(paged.headers['pagination-skip-max'])
-
- const response = await post(`?skip=${skipMax + 1}`)
- assert.strictEqual(response.statusCode, 400)
+ // The ceilings still come back, so a paged walk can tell this boundary from any other 400.
assert.strictEqual(response.headers['pagination-skip-max'], String(skipMax))
assert.ok(Number(response.headers['pagination-limit-max']) > 0)
assert.strictEqual(response.headers['pagination-limit'], undefined, 'no page was served')
assert.strictEqual(response.headers['pagination-skip'], undefined, 'no page was served')
})
- it("pages over a deterministic order, because a skip offset means nothing without one", async () => {
- const applied = {}
- db.find.mockReturnValueOnce(recordingCursor([mockDoc], applied))
- await request(pagedTester)
- .post("/query?limit=7&skip=3")
- .set("Content-Type", "application/json")
- .send({ test: "item" })
-
- assert.deepStrictEqual(applied.sort, { _id: 1 })
- })
-
it("reports no page on the empty body 400, because none was served", async () => {
const response = await request(pagedTester)
.post("/query?limit=25&skip=5")
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index f939e966..b853189d 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -141,18 +141,6 @@ describe('search controllers', () => {
assert.deepStrictEqual(idsOf(response), ['presi2-best', 'presi3-weak', 'presi3-weaker'])
})
- it("searchAsPhrase ranks across both indexes too", async () => {
- mockBranchResults([scoredDoc('presi3-weak', 2.45)], [scoredDoc('presi2-best', 6.95)])
-
- const response = await request(routeTester)
- .post('/search/phrase')
- .set('Content-Type', 'text/plain')
- .send('exact phrase')
-
- assert.strictEqual(response.statusCode, 200)
- assert.deepStrictEqual(idsOf(response), ['presi2-best', 'presi3-weak'])
- })
-
// The point of the ranking, for this endpoint: 'limit' and 'skip' slice the merged order, so a
// merge that does not rank hands back a window of the wrong records rather than a wrong order.
it("pages the score order, so skip walks best-first across both indexes", async () => {
@@ -185,7 +173,7 @@ describe('search pagination parameters', () => {
}
it("searchAsWords rejects a limit or skip it cannot read exactly", async () => {
- for (const queryString of ["?limit=abc", "?limit=1e3", "?limit=0", "?skip=-5", "?limit=100&limit=200"]) {
+ for (const queryString of ["?limit=abc", "?skip=-5"]) {
const response = await searchFor('/search', queryString)
assert.strictEqual(response.statusCode, 400, `${queryString} should be a 400`)
}
From 8fc2ca942302ede0d17e7b8754d799170cc88721 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Mon, 14 Sep 2026 10:55:02 -0500
Subject: [PATCH 10/15] touch ups from review
---
controllers/gog.js | 4 ++--
controllers/utils.js | 3 +--
routes/__tests__/query.test.js | 2 --
3 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/controllers/gog.js b/controllers/gog.js
index 4d5117cb..8232d9ea 100644
--- a/controllers/gog.js
+++ b/controllers/gog.js
@@ -31,7 +31,6 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
if (!agent) return
const agentID = agent.split("/").pop()
const manID = req.body["ManuscriptWitness"]
- const { limit, skip } = getPagination(req.query, null, 50)
let err = { message: `` }
// This request can only be made my Gallery of Glosses production apps.
if (agentID !== GOG_PROD_AGENT) {
@@ -50,6 +49,7 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
+ const { limit, skip } = getPagination(req.query, null, 50)
try {
let matches = []
const partOfConditions = [
@@ -160,7 +160,6 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
if (!agent) return
const agentID = agent.split("/").pop()
const manID = req.body["ManuscriptWitness"]
- const { limit, skip } = getPagination(req.query, null, 50)
let err = { message: `` }
// This request can only be made my Gallery of Glosses production apps.
if (agentID !== GOG_PROD_AGENT) {
@@ -179,6 +178,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
+ const { limit, skip } = getPagination(req.query, null, 50)
try {
let matches = []
const partOfConditions = [
diff --git a/controllers/utils.js b/controllers/utils.js
index f5316fcc..e71fe9be 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -86,8 +86,7 @@ function readWholeNumberParam(raw, name, fallback, min) {
*
* @param query The Express 'req.query' object.
* @param res The Express response, so the applied values can be reported. Optional. Anything
- * without a 'set' method reports nothing rather than throwing, so a caller using the older
- * two-argument shape still gets its values back instead of a 500.
+ * without a 'set' method reports nothing.
* @param defaultLimit The limit to apply when the client does not ask for one.
* @throws A 400 express error when either parameter is not a whole number in range, or when 'skip'
* is beyond the configured maximum.
diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js
index b62b6a1c..2dae1102 100644
--- a/routes/__tests__/query.test.js
+++ b/routes/__tests__/query.test.js
@@ -191,7 +191,6 @@ describe('pagination parameters on /query', () => {
const response = await post(`?skip=${skipMax + 1}`)
assert.strictEqual(response.statusCode, 400)
- assert.match(response.text, new RegExp(`beyond the maximum of ${skipMax}`))
// The ceilings still come back, so a paged walk can tell this boundary from any other 400.
assert.strictEqual(response.headers['pagination-skip-max'], String(skipMax))
assert.ok(Number(response.headers['pagination-limit-max']) > 0)
@@ -206,7 +205,6 @@ describe('pagination parameters on /query', () => {
.send({})
assert.strictEqual(response.statusCode, 400)
- assert.match(response.text, /Detected empty JSON object/)
for (const header of ['pagination-limit', 'pagination-skip', 'pagination-limit-max', 'pagination-skip-max']) {
assert.strictEqual(response.headers[header], undefined, `${header} should not be set`)
}
From 7d36e1df070b7acf397f14792c730aa8ba14f2bf Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Mon, 14 Sep 2026 11:01:51 -0500
Subject: [PATCH 11/15] API doc touch up
---
public/API.html | 13 ++-----------
1 file changed, 2 insertions(+), 11 deletions(-)
diff --git a/public/API.html b/public/API.html
index 9db1f369..b06993af 100644
--- a/public/API.html
+++ b/public/API.html
@@ -561,9 +561,6 @@ Custom Query
},
body: JSON.stringify(queryObj)
})
- // Only the skip ceiling ends a walk early. Every other 400 is a real error, and
- // returning the pages gathered so far would report a walk that never finished.
- // Stopping at the ceiling still truncates, so say so rather than returning quietly.
const skipMax = Number(response.headers.get("Pagination-Skip-Max"))
if (response.status === 400 && skipMax && it > skipMax) {
console.warn(`Stopped at the skip ceiling of ${skipMax}. These results are incomplete. Narrow the query.`)
@@ -571,14 +568,8 @@ Custom Query
}
if (!response.ok) throw new Error(await response.text())
const results = await response.json()
- // Append in place. Walking to the skip ceiling at this page size is a thousand pages,
- // and concat() would copy every record gathered so far on each one of them.
allResults.push(...results)
-
- // Advance by the page size the server applied, which may be smaller than the one you asked for.
const appliedLimit = Number(response.headers.get("Pagination-Limit")) || lim
-
- // A short page is the last page. Do not wait for an empty one.
if (results.length < appliedLimit) return allResults
return pagedQuery(lim, it + appliedLimit, queryObj, allResults)
}
@@ -630,11 +621,11 @@ Result ordering
Custom Query pages in ascending _id order, on both POST and HEAD. A skip offset only means something over a fixed order, so consecutive pages tile that order without repeating or missing a record.
- This is not oldest-to-newest. RERUM _id values are a mix of BSON types across the repository's history, and BSON sorts each type into its own block, so the order is stable and complete but carries no chronological meaning. Sort client-side on __rerum.createdAt if you need records in the order they were made.
+
The search endpoints page in descending relevance score, which you can read back from __rerum.score on each result. That order is the same across both the IIIF 3.0 and IIIF 2.1 indexes, so the best match is the first record of the first page whichever shape it was written in.
- Descending score is an ordering, not a tiling guarantee. Custom Query is the endpoint to walk when you need every matching record exactly once.
+ Descending score is an ordering, not a tiling guarantee.
Text Search
From cc6beff8e4d276a355402a7dc1499a0b719b9ef9 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Mon, 14 Sep 2026 11:19:43 -0500
Subject: [PATCH 12/15] documentation cleanup
---
__tests__/utils.test.js | 18 ------------------
public/API.html | 2 +-
routes/__tests__/query.test.js | 10 ----------
routes/__tests__/search.test.js | 7 -------
4 files changed, 1 insertion(+), 36 deletions(-)
diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js
index 6decbc09..830d7774 100644
--- a/__tests__/utils.test.js
+++ b/__tests__/utils.test.js
@@ -384,9 +384,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('clamps a limit too large for a Number rather than calling it not a whole number', () => {
- // Past about 309 digits Number.parseInt returns Infinity. Rejected there, a digit-only value
- // would get a 400 saying it is not a whole number, and a limit the contract promises to clamp
- // would be refused for being large - the one thing an over-maximum limit is never supposed to be.
const { limit } = getPagination({ limit: '9'.repeat(400) })
assert.strictEqual(limit, Number(capturedHeadersFor({})['Pagination-Limit-Max']))
})
@@ -396,9 +393,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('rejects a skip above the maximum rather than serving the same page forever', () => {
- // Clamping it would hand back the page at the maximum on every request past it. A client
- // advancing skip and stopping on an empty page would never terminate. The rejection names the
- // configured maximum, so a client can act on it, and the maximum itself is still readable.
const original = process.env.MAX_QUERY_SKIP
try {
process.env.MAX_QUERY_SKIP = '2500'
@@ -424,8 +418,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('reads the caps from the environment at call time', () => {
- // Captured at module load these would be unreadable, which is how the RERUM_MAX_QUERY_* /
- // MAX_QUERY_* name mismatch went unnoticed.
const original = process.env.MAX_QUERY_LIMIT
try {
process.env.MAX_QUERY_LIMIT = '25'
@@ -439,8 +431,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('falls back to the code default when a configured cap is unusable', () => {
- // '1e3' and '100_000' are what a hand-typed six figure cap looks like. Parsed rather than
- // validated they become 1, which would serve one record per page across the deployment.
const original = process.env.MAX_QUERY_LIMIT
try {
for (const configured of ['not-a-number', '1e3', '100_000', '500abc', '250.7', '0', '-5', ' 500']) {
@@ -458,9 +448,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('reports the ceilings on a rejection, so a client can recover from the 400 it just got', () => {
- // The 400 for an over-deep skip is the response a client most needs the ceiling from, and it
- // is what lets a paged walk tell that boundary apart from every other 400 it could receive.
- // One query per throw site: the value it cannot read, and the skip it can read but will not serve.
for (const query of [{ limit: 'abc' }, { skip: '999999999' }]) {
const headers = capturedHeadersFor(query)
assert.ok(Number(headers['Pagination-Limit-Max']) > 0, `${JSON.stringify(query)} should still report the limit ceiling`)
@@ -482,9 +469,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('reports nothing rather than throwing when the second argument cannot set headers', () => {
- // The pre-existing shape was getPagination(query, defaultLimit). A caller still using it would
- // otherwise get a TypeError out of the header reporting, which surfaces as a 500 on an endpoint
- // that meant to answer 200.
for (const notAResponse of [100, 'res', true, {}, { set: 'not a function' }]) {
const result = getPagination({ limit: '25', skip: '5' }, notAResponse)
assert.strictEqual(result.limit, 25, `${JSON.stringify(notAResponse)} should not change the limit`)
@@ -493,8 +477,6 @@ describe('controllers/utils.js getPagination', () => {
})
it('echoes the raw skip in the ceiling rejection, not the value it parsed to', () => {
- // A digit string long enough to lose precision parses to a different number than the client
- // sent, and a message naming a value nobody asked for cannot be matched back to its request.
const raw = '99999999999999999999'
assert.throws(
() => getPagination({ skip: raw }),
diff --git a/public/API.html b/public/API.html
index b06993af..a598ebdc 100644
--- a/public/API.html
+++ b/public/API.html
@@ -545,7 +545,7 @@ Custom Query
]
- All responses are a JSON Array that is not ordered, even if a single or zero records are matched.
+ All responses are a JSON Array, even if a single or zero records are matched. Results are in ascending _id order — see Pagination parameters.
diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js
index 2dae1102..f4bad932 100644
--- a/routes/__tests__/query.test.js
+++ b/routes/__tests__/query.test.js
@@ -106,11 +106,6 @@ describe('HEAD /query', () => {
})
describe('pagination parameters on /query', () => {
- // rest.messenger renders the 400s that getPagination throws. The routeTester above deliberately
- // mounts no error handler, so these get their own app rather than changing how it behaves.
- // Mounted the way routes/query.js mounts it, verifyJsonContentType included, so the order the
- // real app answers in is what is under test: a Content-Type it cannot accept is a 415 before any
- // pagination parameter is read.
const pagedTester = express()
pagedTester.use(express.json({ type: ["application/json", "application/ld+json"] }))
pagedTester.head("/query", controller.queryHeadRequest)
@@ -142,9 +137,6 @@ describe('pagination parameters on /query', () => {
.send({ test: "item" })
}
- // The rejected forms themselves are covered against getPagination in __tests__/utils.test.js.
- // What is left to prove here is the wiring: the controller reads req.query, and the 400 it
- // throws reaches the client as a 400 rather than as an unhandled error.
it("rejects a limit or skip it cannot read exactly", async () => {
for (const queryString of ["?limit=abc", "?skip=2.9"]) {
const response = await post(queryString)
@@ -152,8 +144,6 @@ describe('pagination parameters on /query', () => {
}
})
- // Asserted over HTTP rather than only against getPagination, because this is what proves
- // Express really does hand a repeated parameter over as the Array that getPagination rejects.
it("rejects a repeated limit rather than taking one of the two values", async () => {
assert.strictEqual((await post("?limit=100&limit=200")).statusCode, 400)
})
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index b853189d..d32f8bf8 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -123,9 +123,6 @@ describe('search controllers', () => {
assert.strictEqual(response.body.length, 1, 'duplicate _id across indexes should be deduped')
})
- // The branch pipelines write relevance to '__rerum.score'. A comparator reading a top-level
- // 'score' finds nothing on any document, so the merge keeps its construction order and every
- // IIIF 2.1 match is ranked behind every IIIF 3.0 match however well it scores.
it("searchAsWords ranks across both indexes by score, not by which index answered", async () => {
mockBranchResults(
[scoredDoc('presi3-weak', 1.69), scoredDoc('presi3-weaker', 1.24)],
@@ -141,8 +138,6 @@ describe('search controllers', () => {
assert.deepStrictEqual(idsOf(response), ['presi2-best', 'presi3-weak', 'presi3-weaker'])
})
- // The point of the ranking, for this endpoint: 'limit' and 'skip' slice the merged order, so a
- // merge that does not rank hands back a window of the wrong records rather than a wrong order.
it("pages the score order, so skip walks best-first across both indexes", async () => {
const branches = () => mockBranchResults(
[scoredDoc('p3-c', 3), scoredDoc('p3-d', 2)],
@@ -162,8 +157,6 @@ describe('search controllers', () => {
})
describe('search pagination parameters', () => {
- // getPagination is shared with /query, so this proves the search endpoints are covered by the
- // same contract rather than re-testing every rejected form here.
const searchFor = (path, queryString) => {
mockAggregateResults([])
return request(routeTester)
From d8c0e76a59c5cc1ef13deb9984670ff5c6db1eab Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Mon, 14 Sep 2026 11:25:58 -0500
Subject: [PATCH 13/15] Get the dev deploy!
---
controllers/crud.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index df8e0fe7..ca7d95b9 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -82,7 +82,6 @@ const query = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- // Below the guard above, so a request that is never paged does not report a page in its headers.
const { limit, skip } = getPagination(req.query, res, 100)
try {
let matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()
From d47b790749ae4691e5fe7b70b14308b1217cc547 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 16 Sep 2026 12:31:59 -0500
Subject: [PATCH 14/15] getPagination options object and shared decimal check
Response to PR review https://github.com/CenterForDigitalHumanities/rerum_server_nodejs/pull/310#issuecomment-5701204082
---
__tests__/utils.test.js | 11 ++++++-----
controllers/crud.js | 2 +-
controllers/gog.js | 4 ++--
controllers/history.js | 2 +-
controllers/search.js | 10 +++++-----
controllers/utils.js | 27 +++++++++++++++++++++------
6 files changed, 36 insertions(+), 20 deletions(-)
diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js
index 830d7774..fc4ef216 100644
--- a/__tests__/utils.test.js
+++ b/__tests__/utils.test.js
@@ -341,9 +341,10 @@ describe('controllers/utils.js getPagination', () => {
}
it('returns the default limit and skip 0 for an empty query', () => {
- const result = getPagination({}, null, 100)
+ const result = getPagination({})
assert.strictEqual(result.limit, 100)
assert.strictEqual(result.skip, 0)
+ assert.strictEqual(getPagination({}, { defaultLimit: 50 }).limit, 50)
})
it('parses decimal integer string values from the query', () => {
@@ -468,9 +469,9 @@ describe('controllers/utils.js getPagination', () => {
assertRejects({ limit: 250.7 }, /whole number greater than 0/)
})
- it('reports nothing rather than throwing when the second argument cannot set headers', () => {
- for (const notAResponse of [100, 'res', true, {}, { set: 'not a function' }]) {
- const result = getPagination({ limit: '25', skip: '5' }, notAResponse)
+ it('reports nothing rather than throwing when res cannot set headers', () => {
+ for (const notAResponse of [null, 100, 'res', true, {}, { set: 'not a function' }]) {
+ const result = getPagination({ limit: '25', skip: '5' }, { res: notAResponse })
assert.strictEqual(result.limit, 25, `${JSON.stringify(notAResponse)} should not change the limit`)
assert.strictEqual(result.skip, 5, `${JSON.stringify(notAResponse)} should not change the skip`)
}
@@ -498,7 +499,7 @@ describe('controllers/utils.js getPagination', () => {
function capturedHeadersFor(query) {
const captured = {}
try {
- getPagination(query, { set: (headers) => Object.assign(captured, headers) })
+ getPagination(query, { res: { set: (headers) => Object.assign(captured, headers) } })
} catch (err) {
if (err.statusCode !== 400) throw err
}
diff --git a/controllers/crud.js b/controllers/crud.js
index ca7d95b9..88cd4c6d 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -82,7 +82,7 @@ const query = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
try {
let matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()
matches = matches.map(o => idNegotiation(o))
diff --git a/controllers/gog.js b/controllers/gog.js
index 8232d9ea..63513fea 100644
--- a/controllers/gog.js
+++ b/controllers/gog.js
@@ -49,7 +49,7 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, null, 50)
+ const { limit, skip } = getPagination(req.query, { defaultLimit: 50 })
try {
let matches = []
const partOfConditions = [
@@ -178,7 +178,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, null, 50)
+ const { limit, skip } = getPagination(req.query, { defaultLimit: 50 })
try {
let matches = []
const partOfConditions = [
diff --git a/controllers/history.js b/controllers/history.js
index 331158a8..2bfeed0d 100644
--- a/controllers/history.js
+++ b/controllers/history.js
@@ -86,7 +86,7 @@ const history = async function (req, res, next) {
const queryHeadRequest = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let props = req.body
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
try {
// Sorted the same way POST /query is, so the two verbs page over one order.
const matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()
diff --git a/controllers/search.js b/controllers/search.js
index c5163fe9..16ee1009 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -269,7 +269,7 @@ const searchAsWords = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: searchOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
@@ -355,7 +355,7 @@ const searchAsPhrase = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
@@ -433,7 +433,7 @@ const searchFuzzily = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: fuzzyOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
@@ -527,7 +527,7 @@ const searchWildly = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "wildcard", options: wildcardOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
@@ -620,7 +620,7 @@ const searchAlikes = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const { limit, skip } = getPagination(req.query, res, 100)
+ const { limit, skip } = getPagination(req.query, { res })
// Build moreLikeThis queries for both IIIF 3.0 and IIIF 2.1 indexes
const searchQuery_presi3 = [
{
diff --git a/controllers/utils.js b/controllers/utils.js
index e71fe9be..e346aa52 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -18,6 +18,19 @@ const DEFAULT_MAX_QUERY_SKIP = 100000
*/
const PARAM_ECHO_MAX = 40
+/**
+ * Check whether a value is a string of plain decimal digits, the only form a pagination value may take.
+ * Anything else, such as '1e3', '0x10', '250.7', '-5', ' 500', or the empty string, is not.
+ * Both the configured caps and the 'limit' and 'skip' URL parameters are held to this, so the policy
+ * lives here.
+ *
+ * @param value The raw value to check.
+ * @return A boolean
+ */
+function isDecimalWholeNumber(value) {
+ return typeof value === "string" && /^\d+$/.test(value)
+}
+
/**
* Resolve a configured pagination cap from the environment.
* An unset key falls back quietly. A value that is present but unusable falls back loudly, because
@@ -30,7 +43,7 @@ const PARAM_ECHO_MAX = 40
function resolveQueryCap(key, fallback) {
const raw = process.env[key]
if (raw === undefined || raw === "") return fallback
- const configured = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : NaN
+ const configured = isDecimalWholeNumber(raw) ? Number.parseInt(raw, 10) : NaN
if (!Number.isInteger(configured) || configured <= 0) {
console.warn(`\x1b[33m[pagination] ${key}='${String(raw).slice(0, PARAM_ECHO_MAX)}' is not a whole number greater than 0. Falling back to ${fallback}.\x1b[0m`)
return fallback
@@ -59,7 +72,7 @@ function readWholeNumberParam(raw, name, fallback, min) {
})
}
const parsed = typeof raw === "number" ? raw
- : typeof raw === "string" && /^\d+$/.test(raw) ? Number.parseInt(raw, 10)
+ : isDecimalWholeNumber(raw) ? Number.parseInt(raw, 10)
: NaN
if (parsed === Infinity) return Number.MAX_SAFE_INTEGER
if (!Number.isInteger(parsed) || parsed < min) {
@@ -85,14 +98,16 @@ function readWholeNumberParam(raw, name, fallback, min) {
* a truncated page from a genuine final one and can configure itself from any single response.
*
* @param query The Express 'req.query' object.
- * @param res The Express response, so the applied values can be reported. Optional. Anything
- * without a 'set' method reports nothing.
- * @param defaultLimit The limit to apply when the client does not ask for one.
+ * @param options Optional settings for this endpoint.
+ * @param options.res The Express response, so the applied values can be reported. Optional.
+ * Anything without a 'set' method reports nothing.
+ * @param options.defaultLimit The limit to apply when the client does not ask for one. Defaults to 100.
* @throws A 400 express error when either parameter is not a whole number in range, or when 'skip'
* is beyond the configured maximum.
* @return An object carrying the applied 'limit' and 'skip'.
*/
-function getPagination(query = {}, res = null, defaultLimit = 100) {
+function getPagination(query = {}, options = {}) {
+ const { res = null, defaultLimit = 100 } = options ?? {}
const limitMax = resolveQueryCap("MAX_QUERY_LIMIT", DEFAULT_MAX_QUERY_LIMIT)
const skipMax = resolveQueryCap("MAX_QUERY_SKIP", DEFAULT_MAX_QUERY_SKIP)
// 'res' is only ever used to report headers, so anything that cannot report is treated as an
From 10b08fcb6ea980784740818443effc52ada6d4b7 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Wed, 16 Sep 2026 12:36:25 -0500
Subject: [PATCH 15/15] Share the #301 vector table across pagination tests
Response to PR review https://github.com/CenterForDigitalHumanities/rerum_server_nodejs/pull/310#issuecomment-5701204082
---
__tests__/utils.test.js | 13 ++++++++++---
controllers/utils.js | 6 +++---
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js
index fc4ef216..5a8421d6 100644
--- a/__tests__/utils.test.js
+++ b/__tests__/utils.test.js
@@ -327,6 +327,13 @@ describe('controllers/utils.js idNegotiation edge cases', () => {
})
describe('controllers/utils.js getPagination', () => {
+ /**
+ * Values that are not plain decimal digits, from the vector list in #301 plus whitespace and digit
+ * separators. 'limit', 'skip', and the configured caps all share this one policy, so they share
+ * this one table.
+ */
+ const NOT_DECIMAL_WHOLE_NUMBERS = ['abc', '10abc', '1e3', '0x10', '250.7', '2.9', '-5', ' 500', '100_000', '']
+
/** Assert that a query is rejected as a 400 rather than guessed at. */
const assertRejects = (query, matcher) => {
assert.throws(
@@ -354,13 +361,13 @@ describe('controllers/utils.js getPagination', () => {
})
it('rejects a limit that is not a whole number greater than 0', () => {
- for (const limit of ['abc', '10abc', '1e3', '0x10', '250.7', '-5', '']) {
+ for (const limit of NOT_DECIMAL_WHOLE_NUMBERS) {
assertRejects({ limit }, /'limit' URL parameter must be a whole number greater than 0/)
}
})
it('rejects a skip that is not a whole number of 0 or greater', () => {
- for (const skip of ['abc', '10abc', '1e3', '0x10', '2.9', '-5', '']) {
+ for (const skip of NOT_DECIMAL_WHOLE_NUMBERS) {
assertRejects({ skip }, /'skip' URL parameter must be a whole number 0 or greater/)
}
})
@@ -434,7 +441,7 @@ describe('controllers/utils.js getPagination', () => {
it('falls back to the code default when a configured cap is unusable', () => {
const original = process.env.MAX_QUERY_LIMIT
try {
- for (const configured of ['not-a-number', '1e3', '100_000', '500abc', '250.7', '0', '-5', ' 500']) {
+ for (const configured of [...NOT_DECIMAL_WHOLE_NUMBERS, '0']) {
process.env.MAX_QUERY_LIMIT = configured
assert.strictEqual(
capturedHeadersFor({})['Pagination-Limit-Max'],
diff --git a/controllers/utils.js b/controllers/utils.js
index e346aa52..e0fc4f26 100644
--- a/controllers/utils.js
+++ b/controllers/utils.js
@@ -19,10 +19,10 @@ const DEFAULT_MAX_QUERY_SKIP = 100000
const PARAM_ECHO_MAX = 40
/**
- * Check whether a value is a string of plain decimal digits, the only form a pagination value may take.
+ * Check whether a value is a string of plain decimal digits.
* Anything else, such as '1e3', '0x10', '250.7', '-5', ' 500', or the empty string, is not.
- * Both the configured caps and the 'limit' and 'skip' URL parameters are held to this, so the policy
- * lives here.
+ * The configured caps and the 'limit' and 'skip' URL parameters arrive as strings and are held to
+ * this, so the policy lives here. A caller passing an already-integral Number skips this check.
*
* @param value The raw value to check.
* @return A boolean