diff --git a/backend/src/api-tests/crossSearch/export.test.ts b/backend/src/api-tests/crossSearch/export.test.ts index c5a60d4c..1ef773b5 100644 --- a/backend/src/api-tests/crossSearch/export.test.ts +++ b/backend/src/api-tests/crossSearch/export.test.ts @@ -16,6 +16,12 @@ describe('Getting cross-search export', () => { expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' }) }) + it('with invalid POST column filters type does not work', async () => { + const response = await send(`crosssearch/export`, 'POST', { columnFilters: '"not an array"', sorting: [] }) + expect(response.status).toEqual(403) + expect(response.body).toEqual({ error: 'ColumnFilters is not an array.' }) + }) + it('with invalid column filters content does not work', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/export/[{"id": "not_a_column", "value": "Dmanisi"}]/[]`, diff --git a/backend/src/api-tests/crossSearch/get.test.ts b/backend/src/api-tests/crossSearch/get.test.ts index 098c0dfa..0003438c 100644 --- a/backend/src/api-tests/crossSearch/get.test.ts +++ b/backend/src/api-tests/crossSearch/get.test.ts @@ -62,6 +62,19 @@ describe('Getting cross-search data', () => { expect(response2.body.length).toEqual(10) }) + it('Requesting get all with POST body has correct data', async () => { + const { body, status } = await send(`crosssearch/all`, 'POST', { + limit: 20, + offset: 0, + columnFilters: [{ id: 'country', value: 'Spain' }], + sorting: [{ id: 'loc_name', desc: false }], + }) + + expect(status).toEqual(200) + expect(body).toHaveLength(10) + expect(body.every(row => row.country === 'Spain')).toBe(true) + }) + it('Requesting get all with column filters has correct data', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/all/20/0/[{"id": "lid_now_loc", "value": "21050"}]/[]`, @@ -132,6 +145,18 @@ describe('Getting cross-search data', () => { expect(lastCountries).toEqual(['Japan', 'Japan', 'Japan', 'Japan', 'Japan']) }) + it('Requesting localities with POST body has correct data', async () => { + const legacyResponse = await send(`crosssearch/localities/[{"id": "country", "value": "Spain"}]/[]`, 'GET') + const { body, status } = await send(`crosssearch/localities`, 'POST', { + columnFilters: [{ id: 'country', value: 'Spain' }], + sorting: [], + }) + + expect(status).toEqual(200) + expect(body.length).toBeGreaterThan(0) + expect(body).toEqual(legacyResponse.body) + }) + it('Requesting get all with column filtering does not allow invalid arrays or column names', async () => { const { body: responseBody1, status: responseStatus1 } = await send( `crosssearch/all/20/0/[{"id": "invalid_column", "value": "21050"}]/[]`, diff --git a/backend/src/routes/crossSearch.ts b/backend/src/routes/crossSearch.ts index 2fd771f2..c5a2917c 100644 --- a/backend/src/routes/crossSearch.ts +++ b/backend/src/routes/crossSearch.ts @@ -1,8 +1,9 @@ -import { Router } from 'express' +import { Request, Response, Router } from 'express' import { getCrossSearchRawSql, getCrossSearchLocalitiesRawSql, parseAndValidateCrossSearchRouteParameters, + CrossSearchRequestParameters, } from '../services/crossSearch' import { fixBigInt } from '../utils/common' import { format, FormatterRow, FormatterRowTransformFunction } from 'fast-csv' @@ -32,82 +33,101 @@ const transformFunction = (row: CrossSearch & { full_count?: number }) => { return transformedRow } -router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +const getValidatedValues = (parameters: CrossSearchRequestParameters) => { + const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters(parameters) + const validatedValues = values + if (validationErrors.length > 0) { + return { validationErrors } + } + return { validatedValues } +} + +const handleCrossSearchValidationError = (error: unknown, res: Response) => { + if (error instanceof Error) return res.status(403).send({ error: error.message }) + return res.status(403).send('Unknown error') +} + +const sendCrossSearchRows = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - limit: req.params.limit, - offset: req.params.offset, - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } try { const result = await getCrossSearchRawSql( req.user, - validatedValues.validatedLimit, - validatedValues.validatedOffset, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedLimit, + parsedValues.validatedValues.validatedOffset, + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting ) return res.status(200).send(fixBigInt(result)) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } -}) +} -router.get(`/localities/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +const sendCrossSearchLocalities = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } try { const result = await getCrossSearchLocalitiesRawSql( req.user, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting ) return res.status(200).send(fixBigInt(result)) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } +} + +router.post(`/all`, async (req, res) => { + return sendCrossSearchRows(req, res, req.body as CrossSearchRequestParameters) }) -router.get(`/export/:columnfilters/:sorting`, async (req, res) => { - let validatedValues +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.post(`/localities`, 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, + }) +}) + +const streamCrossSearchExport = async (req: Request, res: Response, parameters: CrossSearchRequestParameters) => { + let parsedValues try { - const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({ - columnFilters: req.params.columnfilters, - sorting: req.params.sorting, - }) - validatedValues = values - if (validationErrors.length > 0) { - return res.status(403).send(validationErrors) - } + parsedValues = getValidatedValues(parameters) } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) + } + if ('validationErrors' in parsedValues) { + return res.status(403).send(parsedValues.validationErrors) } let dataArray: Partial @@ -116,12 +136,11 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => { req.user, undefined, undefined, - validatedValues.validatedColumnFilters, - validatedValues.validatedSorting + parsedValues.validatedValues.validatedColumnFilters, + parsedValues.validatedValues.validatedSorting )) as Partial } catch (error) { - if (error instanceof Error) return res.status(403).send({ error: error.message }) - return res.status(403).send('Unknown error') + return handleCrossSearchValidationError(error, res) } res.on('finish', () => { @@ -163,6 +182,17 @@ router.get(`/export/:columnfilters/:sorting`, async (req, res) => { } return stream.end() +} + +router.post(`/export`, 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, + }) }) export default router diff --git a/backend/src/services/crossSearch.ts b/backend/src/services/crossSearch.ts index 00955d76..2dbdb680 100644 --- a/backend/src/services/crossSearch.ts +++ b/backend/src/services/crossSearch.ts @@ -13,6 +13,13 @@ import { generateCrossSearchLocalitiesSql, generateCrossSearchSql } from './quer import { ValidationObject } from '../../../frontend/src/shared/validators/validator' import { validateCrossSearchRouteParams } from '../../../frontend/src/shared/validators/crossSearch' +export type CrossSearchRequestParameters = { + limit?: string | number + offset?: string | number + columnFilters: unknown + sorting: unknown +} + const getAllowedLocalities = async (user: User) => { const usersProjects = await nowDb.now_proj_people.findMany({ where: { initials: user.initials }, @@ -195,22 +202,27 @@ export const getCrossSearchLocalitiesRawSql = async ( return result } -export const parseAndValidateCrossSearchRouteParameters = (parameters: CrossSearchRouteParameters) => { +const parseOptionalNumber = (value: string | number | undefined, name: string) => { + if (value === undefined) return undefined + const parsedValue = typeof value === 'number' ? value : parseInt(value) + if (isNaN(parsedValue)) throw new Error(`${name} is not a number.`) + return parsedValue +} + +const parseArrayParameter = (value: unknown, name: string) => { + const parsedValue: unknown = typeof value === 'string' ? (JSON.parse(value) as unknown) : value + if (!Array.isArray(parsedValue)) throw new Error(`${name} is not an array.`) + return parsedValue as unknown[] +} + +export const parseAndValidateCrossSearchRouteParameters = ( + parameters: CrossSearchRouteParameters | CrossSearchRequestParameters +) => { const { limit, offset, columnFilters, sorting } = parameters - let parsedLimit - let parsedOffset - if (limit) { - parsedLimit = parseInt(limit) - if (isNaN(parsedLimit)) throw new Error('Limit is not a number.') - } - if (offset) { - parsedOffset = parseInt(offset) - if (isNaN(parsedOffset)) throw new Error('Offset is not a number.') - } - const parsedColumnFilters = JSON.parse(columnFilters) as unknown - if (!Array.isArray(parsedColumnFilters)) throw new Error('ColumnFilters is not an array.') - const parsedSorting = JSON.parse(sorting) as unknown - if (!Array.isArray(parsedSorting)) throw new Error('Sorting is not an array.') + const parsedLimit = parseOptionalNumber(limit, 'Limit') + const parsedOffset = parseOptionalNumber(offset, 'Offset') + const parsedColumnFilters = parseArrayParameter(columnFilters, 'ColumnFilters') + const parsedSorting = parseArrayParameter(sorting, 'Sorting') const parsedParameters = { limit: parsedLimit, diff --git a/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx b/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx index dd014cad..b2d10e43 100644 --- a/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx +++ b/frontend/src/components/CrossSearch/CrossSearchExportMenuItem.tsx @@ -19,14 +19,20 @@ export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () => const fetchCSVFile = async () => { setLoading(true) - const URIColumnFilters = encodeURIComponent(JSON.stringify(sqlColumnFilters)) - const URIOrderBy = encodeURIComponent(JSON.stringify(sqlOrderBy)) try { await downloadExportFileWithProgress({ - url: `${BACKEND_URL}/crosssearch/export/${URIColumnFilters}/${URIOrderBy}`, + url: `${BACKEND_URL}/crosssearch/export`, filename, - fetchOptions, + fetchOptions: { + ...fetchOptions, + method: 'POST', + headers: { + ...fetchOptions.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ columnFilters: sqlColumnFilters, sorting: sqlOrderBy }), + }, notify, setNotificationMessage, startMessage: 'Generating cross-search CSV export...', diff --git a/frontend/src/redux/crossSearchReducer.ts b/frontend/src/redux/crossSearchReducer.ts index e6e6edfd..21cfba55 100755 --- a/frontend/src/redux/crossSearchReducer.ts +++ b/frontend/src/redux/crossSearchReducer.ts @@ -9,7 +9,9 @@ const crossSearchApi = api.injectEndpoints({ { limit: number; offset: number; columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState } >({ query: ({ limit, offset, columnFilters, sorting }) => ({ - url: `/crosssearch/all/${limit}/${offset}/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`, + url: `/crosssearch/all`, + method: 'POST', + body: { limit, offset, columnFilters, sorting }, }), providesTags: result => (result ? [{ type: 'localities' }] : []), }), @@ -18,7 +20,9 @@ const crossSearchApi = api.injectEndpoints({ { columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState } >({ query: ({ columnFilters, sorting }) => ({ - url: `/crosssearch/localities/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`, + url: `/crosssearch/localities`, + method: 'POST', + body: { columnFilters, sorting }, }), providesTags: result => (result ? [{ type: 'localities' }] : []), }),