Merge pull request #1163 from nowcommunity/codex/express-validator-crosssearch-733

Add express-validator list middleware
This commit is contained in:
Kari Lintulaakso
2026-05-21 16:17:56 +03:00
committed by GitHub
9 changed files with 268 additions and 30 deletions
+26
View File
@@ -24,6 +24,7 @@
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.3.2",
"fast-csv": "^5.0.2",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
@@ -6410,6 +6411,18 @@
"express": "^4.11 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
"integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
"dependencies": {
"lodash": "^4.18.1",
"validator": "~13.15.23"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/fast-csv": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-5.0.2.tgz",
@@ -9581,6 +9594,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -11785,6 +11803,14 @@
"node": ">=10.12.0"
}
},
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+1
View File
@@ -53,6 +53,7 @@
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.3.2",
"fast-csv": "^5.0.2",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
@@ -10,7 +10,7 @@ describe('Tab list query validation and pagination', () => {
beforeEach(async () => {
await resetDatabase()
await login()
})
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
@@ -40,6 +40,28 @@ describe('Tab list query validation and pagination', () => {
)
})
it('rejects invalid pagination for museum localities endpoint', async () => {
const { body, status } = await send<{ message: string; errors: string[] }>(
'museum/RGM?pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A999%7D',
'GET'
)
expect(status).toBe(400)
expect(body.message).toBe('Invalid query parameters')
expect(body.errors).toContain('pagination.pageSize must be an integer between 1 and 500.')
})
it('rejects invalid sorting for time bound time-units endpoint', async () => {
const { body, status } = await send<{ message: string; errors: string[] }>(
'time-bound/time-units/1?sorting=%5B%7B%22id%22%3A%22invalid%22%2C%22desc%22%3Afalse%7D%5D',
'GET'
)
expect(status).toBe(400)
expect(body.message).toBe('Invalid query parameters')
expect(body.errors[0]).toContain('sorting.id must be one of')
})
it('applies pagination and sorting to reference species endpoint', async () => {
const { body, status } = await send<Record<string, unknown>[]>(
'reference/species/10029?sorting=%5B%7B%22id%22%3A%22species_name%22%2C%22desc%22%3Atrue%7D%5D&pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A1%7D',
@@ -0,0 +1,116 @@
import { RequestHandler } from 'express'
import { query, validationResult, ValidationChain } from 'express-validator'
type TabListQueryValidationOptions = {
allowedSortingColumns: string[]
allowServerColumnFilters?: boolean
}
const getSingleQueryValue = (value: unknown): string | undefined => {
if (typeof value === 'string') return value
if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
return undefined
}
const parseJsonQueryValue = (value: unknown, errorMessage: string) => {
const singleValue = getSingleQueryValue(value)
if (!singleValue) return undefined
try {
return JSON.parse(singleValue) as unknown
} catch {
throw new Error(errorMessage)
}
}
const validateWholeNumber = (fieldName: 'limit' | 'offset', value: unknown) => {
const singleValue = getSingleQueryValue(value)
if (!singleValue) return true
if (!/^\d+$/.test(singleValue)) throw new Error(`${fieldName} must be a non-negative integer.`)
const parsedValue = parseInt(singleValue, 10)
if (fieldName === 'limit' && (parsedValue < 1 || parsedValue > 500)) {
throw new Error('limit must be between 1 and 500.')
}
return true
}
const validateSorting = (value: unknown, allowedSortingColumns: string[]) => {
const parsedValue = parseJsonQueryValue(value, 'sorting must be valid JSON.')
if (parsedValue === undefined) return true
if (!Array.isArray(parsedValue)) throw new Error('sorting must be a JSON array.')
for (const entry of parsedValue) {
if (typeof entry !== 'object' || entry === null) {
throw new Error('sorting entries must be objects.')
}
const candidate = entry as { id?: unknown; desc?: unknown }
if (typeof candidate.id !== 'string' || !allowedSortingColumns.includes(candidate.id)) {
throw new Error(`sorting.id must be one of: ${allowedSortingColumns.join(', ')}.`)
}
if (candidate.desc !== undefined && typeof candidate.desc !== 'boolean') {
throw new Error('sorting.desc must be boolean when provided.')
}
}
return true
}
const validateColumnFilters = (value: unknown, allowServerColumnFilters: boolean) => {
const parsedValue = parseJsonQueryValue(value, 'columnfilters must be valid JSON.')
if (parsedValue === undefined) return true
if (!Array.isArray(parsedValue)) throw new Error('columnfilters must be a JSON array.')
if (!allowServerColumnFilters && parsedValue.length > 0) {
throw new Error('Server-side columnfilters are not supported for this endpoint. Use client-side filtering.')
}
return true
}
const validatePagination = (value: unknown) => {
const parsedValue = parseJsonQueryValue(value, 'pagination must be valid JSON.')
if (parsedValue === undefined) return true
if (typeof parsedValue !== 'object' || parsedValue === null) throw new Error('pagination must be a JSON object.')
const candidate = parsedValue as { pageIndex?: unknown; pageSize?: unknown }
if (!Number.isInteger(candidate.pageIndex) || (candidate.pageIndex as number) < 0) {
throw new Error('pagination.pageIndex must be a non-negative integer.')
}
if (
!Number.isInteger(candidate.pageSize) ||
(candidate.pageSize as number) < 1 ||
(candidate.pageSize as number) > 500
) {
throw new Error('pagination.pageSize must be an integer between 1 and 500.')
}
return true
}
const handleTabListValidationErrors: RequestHandler = (req, res, next) => {
const errors = validationResult(req)
if (errors.isEmpty()) return next()
return res.status(400).send({
message: 'Invalid query parameters',
errors: errors.array().map(error => String(error.msg)),
})
}
export const validateTabListQuery = ({
allowedSortingColumns,
allowServerColumnFilters = false,
}: TabListQueryValidationOptions): Array<ValidationChain | RequestHandler> => [
query('sorting').custom(value => validateSorting(value, allowedSortingColumns)),
query('columnfilters').custom(value => validateColumnFilters(value, allowServerColumnFilters)),
query('columnFilters').custom(value => validateColumnFilters(value, allowServerColumnFilters)),
query('limit').custom(value => validateWholeNumber('limit', value)),
query('offset').custom(value => validateWholeNumber('offset', value)),
query('pagination').custom(validatePagination),
handleTabListValidationErrors,
]
+73 -24
View File
@@ -1,4 +1,5 @@
import { Request, Response, Router } from 'express'
import { Request, RequestHandler, Response, Router } from 'express'
import { body, param, validationResult, ValidationChain } from 'express-validator'
import {
getCrossSearchRawSql,
getCrossSearchLocalitiesRawSql,
@@ -15,6 +16,39 @@ import { once } from 'events'
const router = Router()
const parseArrayRouteParameter = (value: string, message: string) => {
const parsedValue = JSON.parse(value) as unknown
if (!Array.isArray(parsedValue)) throw new Error(message)
return true
}
const handleRequestValidationErrors: RequestHandler = (req, res, next) => {
const errors = validationResult(req)
if (errors.isEmpty()) return next()
const firstError = errors.array()[0]
return res.status(403).send({ error: String(firstError.msg) })
}
const crossSearchBodyValidators: ValidationChain[] = [
body('limit').optional().isInt().withMessage('Limit is not a number.'),
body('offset').optional().isInt().withMessage('Offset is not a number.'),
body('columnFilters').isArray().withMessage('ColumnFilters is not an array.'),
body('sorting').isArray().withMessage('Sorting is not an array.'),
]
const crossSearchRowsRouteValidators: ValidationChain[] = [
param('limit').isInt().withMessage('Limit is not a number.'),
param('offset').isInt().withMessage('Offset is not a number.'),
param('columnfilters').custom(value => parseArrayRouteParameter(value as string, 'ColumnFilters is not an array.')),
param('sorting').custom(value => parseArrayRouteParameter(value as string, 'Sorting is not an array.')),
]
const crossSearchLocalitiesRouteValidators: ValidationChain[] = [
param('columnfilters').custom(value => parseArrayRouteParameter(value as string, 'ColumnFilters is not an array.')),
param('sorting').custom(value => parseArrayRouteParameter(value as string, 'Sorting is not an array.')),
]
const transformFunction = (row: CrossSearch & { full_count?: number }) => {
const transformedRow: { [key: string]: string | number | boolean | null } = {}
const keys = Object.keys(row) as Array<keyof (CrossSearch & { full_count?: number })>
@@ -95,29 +129,39 @@ const sendCrossSearchLocalities = async (req: Request, res: Response, parameters
}
}
router.post(`/all`, async (req, res) => {
router.post(`/all`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => {
return sendCrossSearchRows(req, res, req.body as CrossSearchRequestParameters)
})
router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => {
return sendCrossSearchRows(req, res, {
limit: req.params.limit,
offset: req.params.offset,
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})
router.get(
`/all/:limit/:offset/:columnfilters/:sorting`,
...crossSearchRowsRouteValidators,
handleRequestValidationErrors,
async (req, res) => {
return sendCrossSearchRows(req, res, {
limit: req.params.limit,
offset: req.params.offset,
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
}
)
router.post(`/localities`, async (req, res) => {
router.post(`/localities`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => {
return sendCrossSearchLocalities(req, res, req.body as CrossSearchRequestParameters)
})
router.get(`/localities/:columnfilters/:sorting`, async (req, res) => {
return sendCrossSearchLocalities(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})
router.get(
`/localities/:columnfilters/:sorting`,
...crossSearchLocalitiesRouteValidators,
handleRequestValidationErrors,
async (req, res) => {
return sendCrossSearchLocalities(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
}
)
const streamCrossSearchExport = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => {
let parsedValues
@@ -184,15 +228,20 @@ const streamCrossSearchExport = async (req: Request, res: Response, parameters:
return stream.end()
}
router.post(`/export`, async (req, res) => {
router.post(`/export`, ...crossSearchBodyValidators, handleRequestValidationErrors, async (req, res) => {
return streamCrossSearchExport(req, res, req.body as CrossSearchRequestParameters)
})
router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
return streamCrossSearchExport(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
})
router.get(
`/export/:columnfilters/:sorting`,
...crossSearchLocalitiesRouteValidators,
handleRequestValidationErrors,
async (req, res) => {
return streamCrossSearchExport(req, res, {
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
}
)
export default router
+6 -1
View File
@@ -5,15 +5,20 @@ import { requireOneOf } from '../middlewares/authorizer'
import { Role, EditDataType, EditMetaData, Museum } from '../../../frontend/src/shared/types'
import { DuplicateMuseumCodeError, writeMuseum } from '../services/write/museum'
import { parseTabListQuery } from '../services/tabularQuery'
import { validateTabListQuery } from '../middlewares/tabListQueryValidator'
const router = Router()
const museumLocalitiesTabQueryValidation = validateTabListQuery({
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
})
router.get('/all', async (_req, res) => {
const museums = await getAllMuseums()
return res.status(200).send(museums)
})
router.get('/:id', async (req, res) => {
router.get('/:id', ...museumLocalitiesTabQueryValidation, async (req, res) => {
const id = req.params.id
const parsedQuery = parseTabListQuery({
query: req.query,
+11 -2
View File
@@ -16,6 +16,7 @@ import { Role, EditMetaData, ReferenceDetailsType, EditDataType } from '../../..
import { deleteReference, writeReference } from '../services/write/reference'
import { fixBigInt } from '../utils/common'
import { parseTabListQuery } from '../services/tabularQuery'
import { validateTabListQuery } from '../middlewares/tabListQueryValidator'
const router = Router()
@@ -61,7 +62,15 @@ router.get('/:id', async (req, res) => {
return res.status(200).send(reference)
})
router.get('/localities/:id', async (req, res) => {
const referenceLocalitiesTabQueryValidation = validateTabListQuery({
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
})
const referenceSpeciesTabQueryValidation = validateTabListQuery({
allowedSortingColumns: ['order_name', 'family_name', 'genus_name', 'species_name', 'species_id'],
})
router.get('/localities/:id', ...referenceLocalitiesTabQueryValidation, async (req, res) => {
const id = req.params.id
const parsedQuery = parseTabListQuery({
query: req.query,
@@ -77,7 +86,7 @@ router.get('/localities/:id', async (req, res) => {
return res.status(200).send(fixBigInt(localities))
})
router.get('/species/:id', async (req, res) => {
router.get('/species/:id', ...referenceSpeciesTabQueryValidation, async (req, res) => {
const id = req.params.id
const parsedQuery = parseTabListQuery({
query: req.query,
+6 -1
View File
@@ -10,9 +10,14 @@ import {
import { deleteTimeBound, writeTimeBound } from '../services/write/timeBound'
import { fixBigInt } from '../utils/common'
import { parseTabListQuery } from '../services/tabularQuery'
import { validateTabListQuery } from '../middlewares/tabListQueryValidator'
const router = Router()
const timeBoundTimeUnitsTabQueryValidation = validateTabListQuery({
allowedSortingColumns: ['tu_name', 'tu_display_name', 'rank', 'sequence', 'tu_comment', 'up_bnd', 'low_bnd'],
})
router.get('/all', async (_req, res) => {
const time_bounds = await getAllTimeBounds()
return res.status(200).send(time_bounds)
@@ -25,7 +30,7 @@ router.get('/:id', async (req, res) => {
return res.status(200).send(timeBound)
})
router.get('/time-units/:id', async (req, res) => {
router.get('/time-units/:id', ...timeBoundTimeUnitsTabQueryValidation, async (req, res) => {
const id = parseInt(req.params.id)
const parsedQuery = parseTabListQuery({
query: req.query,
+6 -1
View File
@@ -26,9 +26,14 @@ import {
import { fixBigInt } from '../utils/common'
import { writeTimeBound } from '../services/write/timeBound'
import { parseTabListQuery } from '../services/tabularQuery'
import { validateTabListQuery } from '../middlewares/tabListQueryValidator'
const router = Router()
const timeUnitLocalitiesTabQueryValidation = validateTabListQuery({
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
})
router.get('/all', async (_req, res) => {
try {
const time_units = await getAllTimeUnits()
@@ -51,7 +56,7 @@ router.get('/:id', async (req, res) => {
}
})
router.get('/localities/:id', async (req, res) => {
router.get('/localities/:id', ...timeUnitLocalitiesTabQueryValidation, async (req, res) => {
const id = req.params.id
const parsedQuery = parseTabListQuery({
query: req.query,