diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 8ec29a8f..6decbc09 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -327,30 +327,201 @@ describe('controllers/utils.js idNegotiation edge cases', () => { }) describe('controllers/utils.js getPagination', () => { + /** Assert that a query is rejected as a 400 rather than guessed at. */ + const assertRejects = (query, matcher) => { + assert.throws( + () => getPagination(query), + (err) => { + assert.strictEqual(err.statusCode, 400) + assert.match(err.statusMessage, matcher) + return true + }, + `${JSON.stringify(query)} should be rejected` + ) + } + it('returns the default limit and skip 0 for an empty query', () => { - const result = getPagination({}, 100) + const result = getPagination({}, null, 100) assert.strictEqual(result.limit, 100) assert.strictEqual(result.skip, 0) }) - it('parses numeric string values from the query', () => { + it('parses decimal integer string values from the query', () => { const result = getPagination({ limit: '50', skip: '10' }) assert.strictEqual(result.limit, 50) assert.strictEqual(result.skip, 10) }) - it('falls back to defaults on non-numeric or non-positive input', () => { - const result = getPagination({ limit: 'bogus', skip: 'nope' }, 100) - assert.strictEqual(result.limit, 100) - assert.strictEqual(result.skip, 0) + it('rejects a limit that is not a whole number greater than 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/) + } }) - it('clamps an unreasonably large limit below the max', () => { - const huge = Number.MAX_SAFE_INTEGER - const result = getPagination({ limit: String(huge) }) - assert.ok(result.limit > 0) - assert.ok(result.limit < huge, `limit should be clamped below ${huge}`) + 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', '']) { + assertRejects({ skip }, /'skip' URL parameter must be a whole number 0 or greater/) + } + }) + + it('accepts skip 0, which limit does not', () => { + assert.strictEqual(getPagination({ skip: '0' }).skip, 0) + assertRejects({ limit: '0' }, /whole number greater than 0/) }) + + it('rejects a repeated parameter rather than taking a guess at which one was meant', () => { + // Express hands a repeated URL parameter over as an Array. '?limit=100&limit=200' has no + // single correct reading. + assertRejects({ limit: ['100', '200'] }, /'limit' URL parameter was provided more than once/) + assertRejects({ skip: ['1', '2'] }, /'skip' URL parameter was provided more than once/) + }) + + it('truncates a long rejected value rather than reflecting all of it back', () => { + assert.throws( + () => getPagination({ limit: 'x'.repeat(500) }), + (err) => err.statusMessage.length < 200 + ) + }) + + 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'])) + }) + + it('sends a skip too large for a Number to the ceiling message, not the malformed one', () => { + assertRejects({ skip: '9'.repeat(400) }, /beyond the maximum/) + }) + + 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' + assertRejects({ skip: '2501' }, /2501 is beyond the maximum of 2500/) + assert.strictEqual(getPagination({ skip: '2500' }).skip, 2500) + } finally { + if (original === undefined) delete process.env.MAX_QUERY_SKIP + else process.env.MAX_QUERY_SKIP = original + } + }) + + 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') + assert.strictEqual(headers['Pagination-Skip'], '10') + assert.ok(Number(headers['Pagination-Limit-Max']) > 0) + assert.ok(Number(headers['Pagination-Skip-Max']) > 0) + }) + + it('reports the clamped limit, not the one that was asked for', () => { + const headers = capturedHeadersFor({ limit: '999999' }) + assert.strictEqual(headers['Pagination-Limit'], headers['Pagination-Limit-Max']) + }) + + 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' + const { limit } = getPagination({ limit: '100' }) + assert.strictEqual(limit, 25) + assert.strictEqual(capturedHeadersFor({})['Pagination-Limit-Max'], '25') + } finally { + if (original === undefined) delete process.env.MAX_QUERY_LIMIT + else process.env.MAX_QUERY_LIMIT = original + } + }) + + 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']) { + process.env.MAX_QUERY_LIMIT = configured + assert.strictEqual( + capturedHeadersFor({})['Pagination-Limit-Max'], + '500', + `MAX_QUERY_LIMIT='${configured}' should fall back to the code default` + ) + } + } finally { + if (original === undefined) delete process.env.MAX_QUERY_LIMIT + else process.env.MAX_QUERY_LIMIT = original + } + }) + + 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`) + assert.ok(Number(headers['Pagination-Skip-Max']) > 0, `${JSON.stringify(query)} should still report the skip ceiling`) + } + }) + + it('reports no applied page on a rejection, because none was served', () => { + const headers = capturedHeadersFor({ skip: '999999999' }) + assert.strictEqual(headers['Pagination-Limit'], undefined) + assert.strictEqual(headers['Pagination-Skip'], undefined) + }) + + it('accepts an already-integral number, which req.query never holds but a caller might pass', () => { + const result = getPagination({ limit: 50, skip: 10 }) + assert.strictEqual(result.limit, 50) + assert.strictEqual(result.skip, 10) + assertRejects({ limit: 250.7 }, /whole number greater than 0/) + }) + + 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`) + assert.strictEqual(result.skip, 5, `${JSON.stringify(notAResponse)} should not change the skip`) + } + }) + + 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 }), + (err) => { + assert.strictEqual(err.statusCode, 400) + assert.match(err.statusMessage, new RegExp(`of ${raw} is beyond the maximum`)) + return true + } + ) + }) + + /** + * Run getPagination against a minimal response double and hand back the headers it set. + * + * The ceilings and the applied values arrive as two separate set() calls, so they are merged + * rather than overwritten. A rejected query still reports the ceilings, so a 400 is caught here + * instead of propagating. Anything else still throws, so a real fault is not swallowed. + */ + function capturedHeadersFor(query) { + const captured = {} + try { + getPagination(query, { set: (headers) => Object.assign(captured, headers) }) + } catch (err) { + if (err.statusCode !== 400) throw err + } + return captured + } }) describe('controllers/utils.js findLeafAnnotationsFor', () => { diff --git a/controllers/crud.js b/controllers/crud.js index 7d1f90dc..df8e0fe7 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -74,7 +74,6 @@ const create = async function (req, res, next) { const query = async function (req, res, next) { res.set("Content-Type", "application/json; charset=utf-8") let props = req.body - const { limit, skip } = getPagination(req.query, 100) if (!props || Object.keys(props).length === 0) { //Hey now, don't ask for everything...this can happen by accident. Don't allow it. let err = { @@ -83,8 +82,10 @@ 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).limit(limit).skip(skip).toArray() + let matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray() matches = matches.map(o => idNegotiation(o)) res.set(utils.configureLDHeadersFor(matches)) res.json(matches) diff --git a/controllers/gog.js b/controllers/gog.js index 778658a7..4d5117cb 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -23,8 +23,6 @@ const GOG_AGENTS = [GOG_PROD_AGENT, GOG_DEV_AGENT] * The Bearer Token in the header must be from TinyMatt. * The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"} * - * TODO? Some sort of limit and skip for large responses? - * * @return The set of {'@id':'123', '@type':'WitnessFragment'} objects that match this criteria, as an Array * */ const _gog_fragments_from_manuscript = async function (req, res, next) { @@ -33,7 +31,7 @@ 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, 50) + 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) { @@ -154,8 +152,6 @@ const _gog_fragments_from_manuscript = async function (req, res, next) { * The Bearer Token in the header must be from TinyMatt. * The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"} * - * TODO? Some sort of limit and skip for large responses? - * * @return The set of {'@id':'123', '@type':'Gloss'} objects that match this criteria, as an Array * */ const _gog_glosses_from_manuscript = async function (req, res, next) { @@ -164,7 +160,7 @@ 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, 50) + 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) { diff --git a/controllers/history.js b/controllers/history.js index b7b55a57..331158a8 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -86,9 +86,10 @@ 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, 100) + const { limit, skip } = getPagination(req.query, res, 100) try { - const matches = await db.find(props).limit(limit).skip(skip).toArray() + // 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() if (matches.length) { const negotiated = matches.map(o => idNegotiation(o)) const size = Buffer.byteLength(JSON.stringify(negotiated)) diff --git a/controllers/search.js b/controllers/search.js index 816a1aa6..c5163fe9 100644 --- a/controllers/search.js +++ b/controllers/search.js @@ -23,7 +23,7 @@ import { idNegotiation, getPagination } from './utils.js' * 1. Combines both result arrays * 2. Removes duplicates based on MongoDB _id (keeps first occurrence) * 3. Sorts by search score in descending order (highest relevance first) - * + * * The function handles different _id formats: * - ObjectId objects with $oid property * - String-based _id values @@ -40,9 +40,7 @@ function mergeSearchResults(results1, results2) { merged.push(result) } } - - // Sort by score descending - return merged.sort((a, b) => (b.score || 0) - (a.score || 0)) + return merged.sort((a, b) => (b.__rerum?.score ?? 0) - (a.__rerum?.score ?? 0)) } /** @@ -271,7 +269,7 @@ const searchAsWords = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const { limit, skip } = getPagination(req.query, 100) + const { limit, skip } = getPagination(req.query, res, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: searchOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -357,7 +355,7 @@ const searchAsPhrase = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const { limit, skip } = getPagination(req.query, 100) + const { limit, skip } = getPagination(req.query, res, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -435,7 +433,7 @@ const searchFuzzily = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const { limit, skip } = getPagination(req.query, 100) + const { limit, skip } = getPagination(req.query, res, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: fuzzyOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -529,7 +527,7 @@ const searchWildly = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const { limit, skip } = getPagination(req.query, 100) + const { limit, skip } = getPagination(req.query, res, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "wildcard", options: wildcardOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -622,7 +620,7 @@ const searchAlikes = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const { limit, skip } = getPagination(req.query, 100) + const { limit, skip } = getPagination(req.query, res, 100) // 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 e7be35db..f5316fcc 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -9,21 +9,114 @@ import utils from '../utils.js' const ObjectID = newID -const MAX_QUERY_LIMIT = Number.parseInt(process.env.RERUM_MAX_QUERY_LIMIT ?? 500, 10) -const MAX_QUERY_SKIP = Number.parseInt(process.env.RERUM_MAX_QUERY_SKIP ?? 100000, 10) +const DEFAULT_MAX_QUERY_LIMIT = 500 +const DEFAULT_MAX_QUERY_SKIP = 100000 -function clampNonNegativeInt(value, fallback, max) { - const parsed = Number.parseInt(value, 10) - if (!Number.isFinite(parsed) || parsed <= 0) return fallback - return parsed > max ? max : parsed +/** + * The longest raw parameter value echoed back in a 400 message. A query string can be arbitrarily + * long and there is no reason to reflect all of it into the response. + */ +const PARAM_ECHO_MAX = 40 + +/** + * 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 + * a cap that is silently wrong is very hard to notice. 0 is not a usable cap for either maximum. + * + * @param key The 'process.env' key holding the cap. + * @param fallback The cap to use when the key is unset or unusable. + * @return A usable cap greater than 0. + */ +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 + 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 + } + return configured +} + +/** + * Read one pagination URL parameter as a whole number, rejecting anything it cannot read exactly. + * A parameter supplied more than once arrives from Express as an Array. There is no single correct + * reading of '?limit=100&limit=200', so it is a client mistake rather than something to guess at. + * + * @param raw The raw value from 'req.query', or undefined when the parameter was omitted. + * @param name The parameter name, used in the error message. + * @param fallback The value to use when the parameter was omitted. + * @param min The smallest acceptable value. + * @throws A 400 express error when the value is not a whole number of at least 'min'. + * @return The parsed value, not yet clamped to any maximum. + */ +function readWholeNumberParam(raw, name, fallback, min) { + if (raw === undefined) return fallback + if (Array.isArray(raw)) { + throw utils.createExpressError({ + message: `The '${name}' URL parameter was provided more than once. Provide it exactly once.`, + status: 400 + }) + } + const parsed = typeof raw === "number" ? raw + : typeof raw === "string" && /^\d+$/.test(raw) ? Number.parseInt(raw, 10) + : NaN + if (parsed === Infinity) return Number.MAX_SAFE_INTEGER + if (!Number.isInteger(parsed) || parsed < min) { + const bound = min > 0 ? `greater than 0` : `0 or greater` + throw utils.createExpressError({ + message: `The '${name}' URL parameter must be a whole number ${bound}. Received '${String(raw).slice(0, PARAM_ECHO_MAX)}'.`, + status: 400 + }) + } + return parsed } -function getPagination(query = {}, defaultLimit = 100) { - const limitMax = Number.isFinite(MAX_QUERY_LIMIT) && MAX_QUERY_LIMIT > 0 ? MAX_QUERY_LIMIT : 500 - const skipMax = Number.isFinite(MAX_QUERY_SKIP) && MAX_QUERY_SKIP >= 0 ? MAX_QUERY_SKIP : 100000 +/** + * Resolve the 'limit' and 'skip' URL parameters for a paged endpoint, and report what was applied. + * An unreadable value is rejected with a 400 instead of being guessed at. + * + * The two maximums are not treated alike, because being over them does not mean the same thing. + * A 'limit' above its maximum is clamped, which is conventional for a page size and leaves the + * response readable. A 'skip' above its maximum is rejected, because clamping it would serve the + * page at the maximum over and over. + * + * The applied values and both maximums are reported in the response headers, so a client can tell + * 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 rather than throwing, so a caller using the older + * two-argument shape still gets its values back instead of a 500. + * @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. + * @return An object carrying the applied 'limit' and 'skip'. + */ +function getPagination(query = {}, res = null, defaultLimit = 100) { + 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 + // omitted response rather than an error. + const report = typeof res?.set === "function" ? (headers) => res.set(headers) : () => undefined + report({ + "Pagination-Limit-Max": String(limitMax), + "Pagination-Skip-Max": String(skipMax) + }) const safeDefaultLimit = defaultLimit > 0 ? defaultLimit : 100 - const limit = clampNonNegativeInt(query.limit, safeDefaultLimit, limitMax) - const skip = clampNonNegativeInt(query.skip, 0, skipMax) + const limit = Math.min(readWholeNumberParam(query.limit, "limit", safeDefaultLimit, 1), limitMax) + const skip = readWholeNumberParam(query.skip, "skip", 0, 0) + if (skip > skipMax) { + throw utils.createExpressError({ + message: `The 'skip' URL parameter of ${String(query.skip).slice(0, PARAM_ECHO_MAX)} is beyond the maximum of ${skipMax}. Reading deeper than that is not supported, because every page past it would repeat the one at the maximum. Narrow the query so the records you want fall within the first ${skipMax} results.`, + status: 400 + }) + } + // The applied values are only knowable once both parameters have survived validation. + report({ + "Pagination-Limit": String(limit), + "Pagination-Skip": String(skip) + }) return { limit, skip } } diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js index 886842ba..f51e2607 100644 --- a/database/__mocks__/index.js +++ b/database/__mocks__/index.js @@ -49,6 +49,7 @@ function createMockFunction(implementation = () => undefined) { */ export function createCursor(docs = []) { const cursor = { + sort: createMockFunction(function () { return this }), limit: createMockFunction(function () { return this }), skip: createMockFunction(function () { return this }), batchSize: createMockFunction(function () { return this }), diff --git a/public/API.html b/public/API.html index 54804b09..9db1f369 100644 --- a/public/API.html +++ b/public/API.html @@ -60,6 +60,7 @@
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 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.
-
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.
+ RERUM will test for property matches. By default, this is limited to 100 records in the response to guard against unreasonable queries. To allow for more records in the response one can add the URL parameterlimit 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.
+ A 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)
- }
+ // 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.`)
return allResults
- })
- .catch(err => {
- console.warn("Could not process a result in paged query")
- throw err
- })
+ }
+ 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)
}
+
+ 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.
+
| 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. |
+
+ 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.
+
+
+ Pagination-Limit: 500
+ Pagination-Skip: 0
+ Pagination-Limit-Max: 500
+ Pagination-Skip-Max: 100000
+
+
+
+ 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.
+