Skip to content

Commit

Permalink
[Enterprise Search] Added an App Search route for listing Credentials (
Browse files Browse the repository at this point in the history
…elastic#75487)

In addition to a route for listing Credentials, this also adds a
utility function which helps create API routes which simply proxy
the App Search API.

The reasoning for this is as follows;
1. Creating new routes takes less effort and cognitive load if we
can simply just create proxy routes that use the APIs as is.
2. It keeps the App Search API as the source of truth. All logic is
implemented in the underlying API.
3. It makes unit testing routes much simpler. We do not need to verify
any connectivity to the underlying App Search API, because that is
already tested as part of the utility.
  • Loading branch information
JasonStoltz committed Aug 27, 2020
1 parent 7c44674 commit 8dad004
Show file tree
Hide file tree
Showing 15 changed files with 355 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { loggingSystemMock } from 'src/core/server/mocks';
import { ConfigType } from '../../';
import { ConfigType } from '../';

export const mockLogger = loggingSystemMock.createLogger().get();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { mockLogger } from '../../routes/__mocks__';
import { mockLogger } from '../../__mocks__';

import { registerTelemetryUsageCollector } from './telemetry';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { mockLogger } from '../../routes/__mocks__';
import { mockLogger } from '../../__mocks__';

jest.mock('../../../../../../src/core/server', () => ({
SavedObjectsErrorHelpers: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { mockLogger } from '../../routes/__mocks__';
import { mockLogger } from '../../__mocks__';

import { registerTelemetryUsageCollector } from './telemetry';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { mockConfig, mockLogger } from '../__mocks__';

import { createEnterpriseSearchRequestHandler } from './enterprise_search_request_handler';

jest.mock('node-fetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fetchMock = require('node-fetch') as jest.Mock;
const { Response } = jest.requireActual('node-fetch');

const responseMock = {
ok: jest.fn(),
customError: jest.fn(),
};
const KibanaAuthHeader = 'Basic 123';

describe('createEnterpriseSearchRequestHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
fetchMock.mockReset();
});

it('makes an API call and returns the response', async () => {
const responseBody = {
results: [{ name: 'engine1' }],
meta: { page: { total_results: 1 } },
};

EnterpriseSearchAPI.mockReturn(responseBody);

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
});

await makeAPICall(requestHandler, {
query: {
type: 'indexed',
pageIndex: 1,
},
});

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1'
);

expect(responseMock.ok).toHaveBeenCalledWith({
body: responseBody,
});
});

describe('when an API request fails', () => {
it('should return 502 with a message', async () => {
EnterpriseSearchAPI.mockReturnError();

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
});

await makeAPICall(requestHandler);

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection'
);

expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting or fetching data from Enterprise Search',
statusCode: 502,
});
});
});

describe('when `hasValidData` fails', () => {
it('should return 502 with a message', async () => {
const responseBody = {
foo: 'bar',
};

EnterpriseSearchAPI.mockReturn(responseBody);

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
hasValidData: (body?: any) =>
Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number',
});

await makeAPICall(requestHandler);

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection'
);

expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting or fetching data from Enterprise Search',
statusCode: 502,
});
});
});
});

const makeAPICall = (handler: Function, params = {}) => {
const request = { headers: { authorization: KibanaAuthHeader }, ...params };
return handler(null, request, responseMock);
};

const EnterpriseSearchAPI = {
shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) {
expect(fetchMock).toHaveBeenCalledWith(expectedUrl, {
headers: { Authorization: KibanaAuthHeader },
...expectedParams,
});
},
mockReturn(response: object) {
fetchMock.mockImplementation(() => {
return Promise.resolve(new Response(JSON.stringify(response)));
});
},
mockReturnError() {
fetchMock.mockImplementation(() => {
return Promise.reject('Failed');
});
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import fetch from 'node-fetch';
import querystring from 'querystring';
import {
RequestHandlerContext,
KibanaRequest,
KibanaResponseFactory,
Logger,
} from 'src/core/server';
import { ConfigType } from '../index';

interface IEnterpriseSearchRequestParams<ResponseBody> {
config: ConfigType;
log: Logger;
path: string;
hasValidData?: (body?: ResponseBody) => boolean;
}

/**
* This helper function creates a single standard DRY way of handling
* Enterprise Search API requests.
*
* This handler assumes that it will essentially just proxy the
* Enterprise Search API request, so the request body and request
* parameters are simply passed through.
*/
export function createEnterpriseSearchRequestHandler<ResponseBody>({
config,
log,
path,
hasValidData = () => true,
}: IEnterpriseSearchRequestParams<ResponseBody>) {
return async (
_context: RequestHandlerContext,
request: KibanaRequest<unknown, Readonly<{}>, unknown>,
response: KibanaResponseFactory
) => {
try {
const enterpriseSearchUrl = config.host as string;
const params = request.query ? `?${querystring.stringify(request.query)}` : '';
const url = `${encodeURI(enterpriseSearchUrl)}${path}${params}`;

const apiResponse = await fetch(url, {
headers: { Authorization: request.headers.authorization as string },
});

const body = await apiResponse.json();

if (hasValidData(body)) {
return response.ok({ body });
} else {
throw new Error(`Invalid data received: ${JSON.stringify(body)}`);
}
} catch (e) {
log.error(`Cannot connect to Enterprise Search: ${e.toString()}`);
if (e instanceof Error) log.debug(e.stack as string);

return response.customError({
statusCode: 502,
body: 'Error connecting or fetching data from Enterprise Search',
});
}
};
}
2 changes: 2 additions & 0 deletions x-pack/plugins/enterprise_search/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { registerTelemetryRoute } from './routes/enterprise_search/telemetry';
import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry';
import { registerEnginesRoute } from './routes/app_search/engines';
import { registerCredentialsRoutes } from './routes/app_search/credentials';

import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry';
import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry';
Expand Down Expand Up @@ -108,6 +109,7 @@ export class EnterpriseSearchPlugin implements Plugin {

registerConfigDataRoute(dependencies);
registerEnginesRoute(dependencies);
registerCredentialsRoutes(dependencies);
registerWSOverviewRoute(dependencies);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { MockRouter, mockConfig, mockLogger } from '../../__mocks__';

import { registerCredentialsRoutes } from './credentials';

jest.mock('../../lib/enterprise_search_request_handler', () => ({
createEnterpriseSearchRequestHandler: jest.fn(),
}));
import { createEnterpriseSearchRequestHandler } from '../../lib/enterprise_search_request_handler';

describe('credentials routes', () => {
describe('GET /api/app_search/credentials', () => {
let mockRouter: MockRouter;

beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({ method: 'get', payload: 'query' });

registerCredentialsRoutes({
router: mockRouter.router,
log: mockLogger,
config: mockConfig,
});
});

it('creates a handler with createEnterpriseSearchRequestHandler', () => {
expect(createEnterpriseSearchRequestHandler).toHaveBeenCalledWith({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
hasValidData: expect.any(Function),
});
});

describe('hasValidData', () => {
it('should correctly validate that a response has data', () => {
const response = {
meta: {
page: {
current: 1,
total_pages: 1,
total_results: 1,
size: 25,
},
},
results: [
{
id: 'loco_moco_account_id:5f3575de2b76ff13405f3155|name:asdfasdf',
key: 'search-fe49u2z8d5gvf9s4ekda2ad4',
name: 'asdfasdf',
type: 'search',
access_all_engines: true,
},
],
};

const {
hasValidData,
} = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0];

expect(hasValidData(response)).toBe(true);
});

it('should correctly validate that a response does not have data', () => {
const response = {
foo: 'bar',
};

const hasValidData = (createEnterpriseSearchRequestHandler as jest.Mock).mock.calls[0][0]
.hasValidData;

expect(hasValidData(response)).toBe(false);
});
});

describe('validates', () => {
it('correctly', () => {
const request = { query: { 'page[current]': 1 } };
mockRouter.shouldValidate(request);
});

it('missing page[current]', () => {
const request = { query: {} };
mockRouter.shouldThrow(request);
});
});
});
});
Loading

0 comments on commit 8dad004

Please sign in to comment.