mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge pull request #1163 from nowcommunity/codex/express-validator-crosssearch-733
Add express-validator list middleware
This commit is contained in:
Generated
+26
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user