[App Search] Add new encodePathParams helper (fixes unencoded document IDs) (#88648)

* Add encodePathParams helper to EnterpriseSearchRequestHandler

This helper accomplishes two things:

- Fixes 404s from the Enterprise Search server for user-generated IDs with special characters (e.g. ? char)

- Allows us to simplify some of our createRequest calls - no longer will we have to grab request.params to create paths, this helper will do so for us

* Update AS document route to use new helper

- This was the primary view/API I was testing to fix this bug

* Update remaining AS routes to use new helper

- shorter, saves us a few lines
+ remove unnecessary payload: params that doesn't actually validate params

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Constance 2021-01-20 11:07:32 -08:00 committed by GitHub
parent 8b1a228c29
commit f4f6cb687c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 100 additions and 108 deletions

View file

@ -116,16 +116,40 @@ describe('EnterpriseSearchRequestHandler', () => {
); );
}); });
it('correctly encodes paths and query string parameters', async () => { it('correctly encodes query string parameters', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({ const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/some example', path: '/api/example',
}); });
await makeAPICall(requestHandler, { query: { 'page[current]': 1 } }); await makeAPICall(requestHandler, { query: { 'page[current]': 1 } });
EnterpriseSearchAPI.shouldHaveBeenCalledWith( EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/api/some%20example?page%5Bcurrent%5D=1' 'http://localhost:3002/api/example?page%5Bcurrent%5D=1'
); );
}); });
describe('encodePathParams', () => {
it('correctly replaces :pathVariables with request.params', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/examples/:example/some/:id',
});
await makeAPICall(requestHandler, { params: { example: 'hello', id: 'world' } });
EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/api/examples/hello/some/world'
);
});
it('correctly encodes path params as URI components', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/examples/:example',
});
await makeAPICall(requestHandler, { params: { example: 'hello#@/$%^/&[]{}/";world' } });
EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/api/examples/hello%23%40%2F%24%25%5E%2F%26%5B%5D%7B%7D%2F%22%3Bworld'
);
});
});
}); });
describe('response passing', () => { describe('response passing', () => {

View file

@ -65,11 +65,12 @@ export class EnterpriseSearchRequestHandler {
) => { ) => {
try { try {
// Set up API URL // Set up API URL
const encodedPath = this.encodePathParams(path, request.params as Record<string, string>);
const queryParams = { ...(request.query as object), ...params }; const queryParams = { ...(request.query as object), ...params };
const queryString = !this.isEmptyObj(queryParams) const queryString = !this.isEmptyObj(queryParams)
? `?${querystring.stringify(queryParams)}` ? `?${querystring.stringify(queryParams)}`
: ''; : '';
const url = encodeURI(this.enterpriseSearchUrl + path) + queryString; const url = encodeURI(this.enterpriseSearchUrl) + encodedPath + queryString;
// Set up API options // Set up API options
const { method } = request.route; const { method } = request.route;
@ -126,6 +127,36 @@ export class EnterpriseSearchRequestHandler {
}; };
} }
/**
* This path helper is similar to React Router's generatePath, but much simpler &
* does not use regexes. It enables us to pass a static '/foo/:bar/baz' string to
* createRequest({ path }) and have :bar be automatically replaced by the value of
* request.params.bar.
* It also (very importantly) wraps all URL request params with encodeURIComponent(),
* which is an extra layer of encoding required by the Enterprise Search server in
* order to correctly & safely parse user-generated IDs with special characters in
* their names - just encodeURI alone won't work.
*/
encodePathParams(path: string, params: Record<string, string>) {
const hasParams = path.includes(':');
if (!hasParams) {
return path;
} else {
return path
.split('/')
.map((pathPart) => {
const isParam = pathPart.startsWith(':');
if (!isParam) {
return pathPart;
} else {
const pathParam = pathPart.replace(':', '');
return encodeURIComponent(params[pathParam]);
}
})
.join('/');
}
}
/** /**
* Attempt to grab a usable error body from Enterprise Search - this isn't * Attempt to grab a usable error body from Enterprise Search - this isn't
* always possible because some of our internal endpoints send back blank * always possible because some of our internal endpoints send back blank

View file

@ -27,12 +27,8 @@ describe('analytics routes', () => {
}); });
it('creates a request handler', () => { it('creates a request handler', () => {
mockRouter.callRoute({
params: { engineName: 'some-engine' },
});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/analytics/queries', path: '/as/engines/:engineName/analytics/queries',
}); });
}); });
@ -84,12 +80,8 @@ describe('analytics routes', () => {
}); });
it('creates a request handler', () => { it('creates a request handler', () => {
mockRouter.callRoute({
params: { engineName: 'some-engine', query: 'some-query' },
});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/analytics/query/some-query', path: '/as/engines/:engineName/analytics/query/:query',
}); });
}); });

View file

@ -32,13 +32,9 @@ export function registerAnalyticsRoutes({
query: schema.object(queriesSchema), query: schema.object(queriesSchema),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
const { engineName } = request.params; path: '/as/engines/:engineName/analytics/queries',
})
return enterpriseSearchRequestHandler.createRequest({
path: `/as/engines/${engineName}/analytics/queries`,
})(context, request, response);
}
); );
router.get( router.get(
@ -52,12 +48,8 @@ export function registerAnalyticsRoutes({
query: schema.object(querySchema), query: schema.object(querySchema),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
const { engineName, query } = request.params; path: '/as/engines/:engineName/analytics/query/:query',
})
return enterpriseSearchRequestHandler.createRequest({
path: `/as/engines/${engineName}/analytics/query/${query}`,
})(context, request, response);
}
); );
} }

View file

@ -200,16 +200,8 @@ describe('credentials routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
const mockRequest = {
params: {
name: 'abc123',
},
};
mockRouter.callRoute(mockRequest);
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/credentials/abc123', path: '/as/credentials/:name',
}); });
}); });
@ -311,7 +303,6 @@ describe('credentials routes', () => {
mockRouter = new MockRouter({ mockRouter = new MockRouter({
method: 'delete', method: 'delete',
path: '/api/app_search/credentials/{name}', path: '/api/app_search/credentials/{name}',
payload: 'params',
}); });
registerCredentialsRoutes({ registerCredentialsRoutes({
@ -321,16 +312,8 @@ describe('credentials routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
const mockRequest = {
params: {
name: 'abc123',
},
};
mockRouter.callRoute(mockRequest);
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/credentials/abc123', path: '/as/credentials/:name',
}); });
}); });
}); });

View file

@ -81,11 +81,9 @@ export function registerCredentialsRoutes({
body: tokenSchema, body: tokenSchema,
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: '/as/credentials/:name',
path: `/as/credentials/${request.params.name}`, })
})(context, request, response);
}
); );
router.delete( router.delete(
{ {
@ -96,10 +94,8 @@ export function registerCredentialsRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: '/as/credentials/:name',
path: `/as/credentials/${request.params.name}`, })
})(context, request, response);
}
); );
} }

View file

@ -27,13 +27,8 @@ describe('documents routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({
params: { engineName: 'some-engine' },
body: { documents: [{ foo: 'bar' }] },
});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/documents/new', path: '/as/engines/:engineName/documents/new',
}); });
}); });
@ -79,10 +74,8 @@ describe('document routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({ params: { engineName: 'some-engine', documentId: '1' } });
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/documents/1', path: '/as/engines/:engineName/documents/:documentId',
}); });
}); });
}); });
@ -104,10 +97,8 @@ describe('document routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({ params: { engineName: 'some-engine', documentId: '1' } });
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/documents/1', path: '/as/engines/:engineName/documents/:documentId',
}); });
}); });
}); });

View file

@ -24,11 +24,9 @@ export function registerDocumentsRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: `/as/engines/:engineName/documents/new`,
path: `/as/engines/${request.params.engineName}/documents/new`, })
})(context, request, response);
}
); );
} }
@ -46,11 +44,9 @@ export function registerDocumentRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: `/as/engines/:engineName/documents/:documentId`,
path: `/as/engines/${request.params.engineName}/documents/${request.params.documentId}`, })
})(context, request, response);
}
); );
router.delete( router.delete(
{ {
@ -62,10 +58,8 @@ export function registerDocumentRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: `/as/engines/:engineName/documents/:documentId`,
path: `/as/engines/${request.params.engineName}/documents/${request.params.documentId}`, })
})(context, request, response);
}
); );
} }

View file

@ -59,6 +59,7 @@ describe('engine routes', () => {
describe('hasValidData', () => { describe('hasValidData', () => {
it('should correctly validate that the response has data', () => { it('should correctly validate that the response has data', () => {
mockRequestHandler.createRequest.mockClear();
const response = { const response = {
meta: { meta: {
page: { page: {
@ -73,6 +74,7 @@ describe('engine routes', () => {
}); });
it('should correctly validate that a response does not have data', () => { it('should correctly validate that a response does not have data', () => {
mockRequestHandler.createRequest.mockClear();
const response = {}; const response = {};
mockRouter.callRoute(mockRequest); mockRouter.callRoute(mockRequest);
@ -125,10 +127,8 @@ describe('engine routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({ params: { name: 'some-engine' } });
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/details', path: '/as/engines/:name/details',
}); });
}); });
}); });
@ -150,10 +150,8 @@ describe('engine routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({ params: { name: 'some-engine' } });
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/engines/some-engine/overview_metrics', path: '/as/engines/:name/overview_metrics',
}); });
}); });
}); });

View file

@ -54,11 +54,9 @@ export function registerEnginesRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: `/as/engines/:name/details`,
path: `/as/engines/${request.params.name}/details`, })
})(context, request, response);
}
); );
router.get( router.get(
{ {
@ -69,10 +67,8 @@ export function registerEnginesRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: `/as/engines/:name/overview_metrics`,
path: `/as/engines/${request.params.name}/overview_metrics`, })
})(context, request, response);
}
); );
} }

View file

@ -26,8 +26,6 @@ describe('log settings routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/log_settings', path: '/as/log_settings',
}); });
@ -52,7 +50,6 @@ describe('log settings routes', () => {
}); });
it('creates a request to enterprise search', () => { it('creates a request to enterprise search', () => {
mockRouter.callRoute({});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/as/log_settings', path: '/as/log_settings',
}); });

View file

@ -40,10 +40,8 @@ export function registerSettingsRoutes({
}), }),
}, },
}, },
async (context, request, response) => { enterpriseSearchRequestHandler.createRequest({
return enterpriseSearchRequestHandler.createRequest({ path: '/as/log_settings',
path: '/as/log_settings', })
})(context, request, response);
}
); );
} }