search-backend: validate query string
Ensures that known query params accepted by the search endpoint have the correct type after parsing Signed-off-by: MT Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
---
|
||||
|
||||
Validate query string in search endpoint
|
||||
@@ -21,13 +21,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.10.4-next.0",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.2.0",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@backstage/plugin-search-backend-node": "^0.4.4",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
"yn": "^4.0.0",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.12.0-next.0",
|
||||
|
||||
@@ -53,6 +53,39 @@ describe('createRouter', () => {
|
||||
expect(response.body).toMatchObject({ results: [] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'term=foo',
|
||||
'term=foo&extra=param',
|
||||
'types[0]=first-type',
|
||||
'types[0]=first-type&types[1]=second-type',
|
||||
'filters[prop]=value',
|
||||
'pageCursor=foo',
|
||||
])('accepts valid query string "%s"', async queryString => {
|
||||
const response = await request(app).get(`/query?${queryString}`);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toMatchObject({
|
||||
results: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'term[0]=foo',
|
||||
'term[prop]=value',
|
||||
'types=foo',
|
||||
'types[length]=10000&types[0]=first-type',
|
||||
'filters=stringValue',
|
||||
'pageCursor[0]=1',
|
||||
])('rejects invalid query string "%s"', async queryString => {
|
||||
const response = await request(app).get(`/query?${queryString}`);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body).toMatchObject({
|
||||
error: { message: /invalid query string/i },
|
||||
});
|
||||
});
|
||||
|
||||
describe('search result filtering', () => {
|
||||
beforeAll(async () => {
|
||||
const logger = getVoidLogger();
|
||||
|
||||
@@ -17,9 +17,28 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { SearchQuery, SearchResultSet } from '@backstage/search-common';
|
||||
import { z } from 'zod';
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import { SearchEngine } from '@backstage/plugin-search-backend-node';
|
||||
|
||||
const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
|
||||
const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(jsonValueSchema),
|
||||
jsonObjectSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
return z.record(jsonValueSchema);
|
||||
});
|
||||
|
||||
export type RouterOptions = {
|
||||
engine: SearchEngine;
|
||||
logger: Logger;
|
||||
@@ -32,6 +51,13 @@ export async function createRouter(
|
||||
): Promise<express.Router> {
|
||||
const { engine, logger } = options;
|
||||
|
||||
const requestSchema = z.object({
|
||||
term: z.string().default(''),
|
||||
filters: jsonObjectSchema.optional(),
|
||||
types: z.array(z.string()).optional(),
|
||||
pageCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({
|
||||
...resultSet,
|
||||
results: results.filter(result => {
|
||||
@@ -50,21 +76,26 @@ export async function createRouter(
|
||||
const router = Router();
|
||||
router.get(
|
||||
'/query',
|
||||
async (
|
||||
req: express.Request<any, unknown, unknown, SearchQuery>,
|
||||
res: express.Response<SearchResultSet>,
|
||||
) => {
|
||||
const { term, filters = {}, types, pageCursor } = req.query;
|
||||
async (req: express.Request, res: express.Response<SearchResultSet>) => {
|
||||
const parseResult = requestSchema.safeParse(req.query);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new InputError(`Invalid query string: ${parseResult.error}`);
|
||||
}
|
||||
|
||||
const query = parseResult.data;
|
||||
|
||||
logger.info(
|
||||
`Search request received: term="${term}", filters=${JSON.stringify(
|
||||
filters,
|
||||
)}, types=${types ? types.join(',') : ''}, pageCursor=${
|
||||
pageCursor ?? ''
|
||||
}`,
|
||||
`Search request received: term="${
|
||||
query.term
|
||||
}", filters=${JSON.stringify(query.filters)}, types=${
|
||||
query.types ? query.types.join(',') : ''
|
||||
}, pageCursor=${query.pageCursor ?? ''}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const resultSet = await engine?.query(req.query);
|
||||
const resultSet = await engine?.query(query);
|
||||
|
||||
res.send(filterResultSet(resultSet));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
@@ -74,5 +105,7 @@ export async function createRouter(
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user