Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Address larger tech debt/TODOs #8

Merged
merged 5 commits into from
May 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions x-pack/plugins/enterprise_search/common/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* 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.
*/

export const ENGINES_PAGE_SIZE = 10;
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('EngineOverview', () => {

it('hasNoAccount', async () => {
const wrapper = await mountWithApiMock({
get: () => ({ message: 'no-as-account' }),
get: () => Promise.reject({ body: { message: 'no-as-account' } }),
});
expect(wrapper.find(NoUserState)).toHaveLength(1);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,34 +47,20 @@ export const EngineOverview: ReactFC<> = () => {
query: { type, pageIndex },
});
};
const hasValidData = response => {
return (
response &&
Array.isArray(response.results) &&
response.meta &&
response.meta.page &&
typeof response.meta.page.total_results === 'number'
); // TODO: Move to optional chaining once Prettier has been updated to support it
};
const hasNoAccountError = response => {
return response && response.message === 'no-as-account';
};
const setEnginesData = async (params, callbacks) => {
try {
const response = await getEnginesData(params);
if (!hasValidData(response)) {
if (hasNoAccountError(response)) {
return setHasNoAccount(true);
}
throw new Error('App Search engines response is missing valid data');
}

callbacks.setResults(response.results);
callbacks.setResultsTotal(response.meta.page.total_results);

setIsLoading(false);
} catch (error) {
// TODO - should we be logging errors to telemetry or elsewhere for debugging?
setHasErrorConnecting(true);
if (error?.body?.message === 'no-as-account') {
setHasNoAccount(true);
} else {
setHasErrorConnecting(true);
}
}
};

Expand All @@ -83,16 +69,14 @@ export const EngineOverview: ReactFC<> = () => {
const callbacks = { setResults: setEngines, setResultsTotal: setEnginesTotal };

setEnginesData(params, callbacks);
}, [enginesPage]); // eslint-disable-line
// TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies
}, [enginesPage]); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
const params = { type: 'meta', pageIndex: metaEnginesPage };
const callbacks = { setResults: setMetaEngines, setResultsTotal: setMetaEnginesTotal };

setEnginesData(params, callbacks);
}, [metaEnginesPage]); // eslint-disable-line
// TODO: https://reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies
}, [metaEnginesPage]); // eslint-disable-line react-hooks/exhaustive-deps

if (hasErrorConnecting) return <ErrorState />;
if (hasNoAccount) return <NoUserState />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { EuiBasicTable, EuiLink } from '@elastic/eui';
import { sendTelemetry } from '../../../shared/telemetry';
import { KibanaContext, IKibanaContext } from '../../../index';

import { ENGINES_PAGE_SIZE } from '../../../../../common/constants';

interface IEngineTableProps {
data: Array<{
name: string;
Expand Down Expand Up @@ -103,7 +105,7 @@ export const EngineTable: ReactFC<IEngineTableProps> = ({
columns={columns}
pagination={{
pageIndex,
pageSize: 10, // TODO: pull this out to a constant?
pageSize: ENGINES_PAGE_SIZE,
totalItemCount: totalEngines,
hidePerPageOptions: true,
}}
Expand Down
5 changes: 4 additions & 1 deletion x-pack/plugins/enterprise_search/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PluginInitializerContext,
CoreSetup,
CoreStart,
Logger,
SavedObjectsServiceStart,
} from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
Expand All @@ -30,10 +31,12 @@ export interface ServerConfigType {

export class EnterpriseSearchPlugin implements Plugin {
private config: Observable<ServerConfigType>;
private logger: Logger;
private savedObjects?: SavedObjectsServiceStart;

constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.create<ServerConfigType>();
this.logger = initializerContext.logger.get();
}

public async setup(
Expand All @@ -42,7 +45,7 @@ export class EnterpriseSearchPlugin implements Plugin {
) {
const router = http.createRouter();
const config = await this.config.pipe(first()).toPromise();
const dependencies = { router, config };
const dependencies = { router, config, log: this.logger };

registerEnginesRoute(dependencies);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { RequestHandlerContext } from 'kibana/server';
import { mockRouter, RouterMock } from 'src/core/server/http/router/router.mock';
import { loggingServiceMock } from 'src/core/server/mocks';
import { httpServerMock } from 'src/core/server/http/http_server.mocks';
import { RouteValidatorConfig } from 'src/core/server/http/router/validator';

Expand All @@ -21,13 +22,15 @@ describe('engine routes', () => {
describe('GET /api/app_search/engines', () => {
const AUTH_HEADER = 'Basic 123';
let router: RouterMock;
const mockResponseFactory = httpServerMock.createResponseFactory();
const mockResponse = httpServerMock.createResponseFactory();
const mockLogger = loggingServiceMock.create().get();

beforeEach(() => {
jest.resetAllMocks();
router = mockRouter.create();
registerEnginesRoute({
router,
log: mockLogger,
config: {
host: 'http://localhost:3002',
},
Expand All @@ -38,17 +41,18 @@ describe('engine routes', () => {
beforeEach(() => {
AppSearchAPI.shouldBeCalledWith(
`http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`,
{
headers: { Authorization: AUTH_HEADER },
}
).andReturn({ name: 'engine1' });
{ headers: { Authorization: AUTH_HEADER } }
).andReturn({
results: [{ name: 'engine1' }],
meta: { page: { total_results: 1 } },
});
});

it('should return 200 with a list of engines from the App Search API', async () => {
await callThisRoute();

expectResponseToBe200With({
body: { name: 'engine1' },
expect(mockResponse.ok).toHaveBeenCalledWith({
body: { results: [{ name: 'engine1' }], meta: { page: { total_results: 1 } } },
headers: { 'content-type': 'application/json' },
});
});
Expand All @@ -58,19 +62,57 @@ describe('engine routes', () => {
beforeEach(() => {
AppSearchAPI.shouldBeCalledWith(
`http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`,
{
headers: { Authorization: AUTH_HEADER },
}
{ headers: { Authorization: AUTH_HEADER } }
).andReturnRedirect();
});

it('should return 200 with a message', async () => {
it('should return 403 with a message', async () => {
await callThisRoute();

expectResponseToBe200With({
body: { message: 'no-as-account' },
headers: { 'content-type': 'application/json' },
expect(mockResponse.forbidden).toHaveBeenCalledWith({
body: 'no-as-account',
});
expect(mockLogger.info).toHaveBeenCalledWith('No corresponding App Search account found');
});
});

describe('when the App Search URL is invalid', () => {
beforeEach(() => {
AppSearchAPI.shouldBeCalledWith(
`http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`,
{ headers: { Authorization: AUTH_HEADER } }
).andReturnError();
});

it('should return 404 with a message', async () => {
await callThisRoute();

expect(mockResponse.notFound).toHaveBeenCalledWith({
body: 'cannot-connect',
});
expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to App Search: Failed');
expect(mockLogger.debug).not.toHaveBeenCalled();
});
});

describe('when the App Search API returns invalid data', () => {
beforeEach(() => {
AppSearchAPI.shouldBeCalledWith(
`http://localhost:3002/as/engines/collection?type=indexed&page[current]=1&page[size]=10`,
{ headers: { Authorization: AUTH_HEADER } }
).andReturnInvalidData();
});

it('should return 404 with a message', async () => {
await callThisRoute();

expect(mockResponse.notFound).toHaveBeenCalledWith({
body: 'cannot-connect',
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Cannot connect to App Search: Error: Invalid data received from App Search: {"foo":"bar"}'
);
expect(mockLogger.debug).toHaveBeenCalled();
});
});

Expand All @@ -88,7 +130,7 @@ describe('engine routes', () => {
}

describe('when query is valid', () => {
const request = { query: { type: 'indexed', pageIndex: 1 } };
const request = { query: { type: 'meta', pageIndex: 5 } };
itShouldValidate(request);
});

Expand All @@ -97,8 +139,8 @@ describe('engine routes', () => {
itShouldThrow(request);
});

describe('type is wrong type', () => {
const request = { query: { type: 1, pageIndex: 1 } };
describe('type is wrong string', () => {
const request = { query: { type: 'invalid', pageIndex: 1 } };
itShouldThrow(request);
});

Expand Down Expand Up @@ -136,14 +178,26 @@ describe('engine routes', () => {
return Promise.resolve(new Response(JSON.stringify(response)));
});
},
andReturnInvalidData(response: object) {
fetchMock.mockImplementation((url: string, params: object) => {
expect(url).toEqual(expectedUrl);
expect(params).toEqual(expectedParams);

return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' })));
});
},
andReturnError(response: object) {
fetchMock.mockImplementation((url: string, params: object) => {
expect(url).toEqual(expectedUrl);
expect(params).toEqual(expectedParams);

return Promise.reject('Failed');
});
},
};
},
};

const expectResponseToBe200With = (response: object) => {
expect(mockResponseFactory.ok).toHaveBeenCalledWith(response);
};

const callThisRoute = async (
request = {
headers: {
Expand All @@ -158,7 +212,7 @@ describe('engine routes', () => {
const [_, handler] = router.get.mock.calls[0];

const context = {} as jest.Mocked<RequestHandlerContext>;
await handler(context, httpServerMock.createKibanaRequest(request), mockResponseFactory);
await handler(context, httpServerMock.createKibanaRequest(request), mockResponse);
};

const executeRouteValidation = (data: { query: object }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,54 @@
import fetch from 'node-fetch';
import { schema } from '@kbn/config-schema';

export function registerEnginesRoute({ router, config }) {
import { ENGINES_PAGE_SIZE } from '../../../common/constants';

export function registerEnginesRoute({ router, config, log }) {
router.get(
{
path: '/api/app_search/engines',
validate: {
query: schema.object({
type: schema.string(),
type: schema.oneOf([schema.literal('indexed'), schema.literal('meta')]),
pageIndex: schema.number(),
}),
},
},
async (context, request, response) => {
const appSearchUrl = config.host;
const { type, pageIndex } = request.query;

const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=10`;
const enginesResponse = await fetch(url, {
headers: { Authorization: request.headers.authorization },
});

if (enginesResponse.url.endsWith('/login')) {
return response.ok({
body: { message: 'no-as-account' },
headers: { 'content-type': 'application/json' },
try {
const appSearchUrl = config.host;
const { type, pageIndex } = request.query;

const url = `${appSearchUrl}/as/engines/collection?type=${type}&page[current]=${pageIndex}&page[size]=${ENGINES_PAGE_SIZE}`;
const enginesResponse = await fetch(url, {
headers: { Authorization: request.headers.authorization },
});
}

const engines = await enginesResponse.json();
return response.ok({
body: engines,
headers: { 'content-type': 'application/json' },
});
if (enginesResponse.url.endsWith('/login')) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's surprising to me that the url prop here would end with /login in case of an invalid account. Does App Search redirect to /login here when an invalid auth header is provided? Calling the API directly would return a 401 status code.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is on App Search's end - I believe our Ruby logic is to default to the /login page no matter what if we encounter any invalid auth (even when hitting API endpoints).

Copy link
Owner Author

@cee-chen cee-chen May 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear I don't 100% agree with it (as someone who prefers a very RESTful API/separate your front-end & back-end POV :), but I also would have no idea how to change it, so probably out of scope for now.

log.info('No corresponding App Search account found');
// Note: Can't use response.unauthorized, Kibana will auto-log out the user
return response.forbidden({ body: 'no-as-account' });
}

const engines = await enginesResponse.json();
const hasValidData =
Array.isArray(engines?.results) && typeof engines?.meta?.page?.total_results === 'number';

if (hasValidData) {
return response.ok({
body: engines,
headers: { 'content-type': 'application/json' },
});
} else {
// Either a completely incorrect Enterprise Search host URL was configured, or App Search is returning bad data
throw new Error(`Invalid data received from App Search: ${JSON.stringify(engines)}`);
}
} catch (e) {
log.error(`Cannot connect to App Search: ${e.toString()}`);
if (e instanceof Error) log.debug(e.stack);

return response.notFound({ body: 'cannot-connect' });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we distinguish here between cannot-connect and some other malformed response error condition?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can - I can't think of anything super useful to show the user in that case though 🤔

I think the assumption that malformed data from App Search = 'This isn't App Search' is a relatively safe one in terms of end-user-experience, as we can't show them anything but an error message in that case anyway. Definitely open to other thoughts or suggestions however!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point -- not much we can show the user. The more adventurous user or support engineer can still glean valuable lower-level detail by poking into the stack trace that we're logging to the console. 👍

}
}
);
}
Loading