[Endpoint] add Redux saga Middleware and app Store (#53906)

* Added saga library
* Initialize endpoint app redux store
This commit is contained in:
Paul Tavares 2020-01-16 11:34:32 -05:00 committed by GitHub
parent 54c7d340ae
commit 93a11838ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 743 additions and 33 deletions

View file

@ -9,6 +9,9 @@ import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { Route, BrowserRouter, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { appStoreFactory } from './store';
/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
@ -16,7 +19,9 @@ import { Route, BrowserRouter, Switch } from 'react-router-dom';
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
coreStart.http.get('/api/endpoint/hello-world');
ReactDOM.render(<AppRoot basename={appBasePath} />, element);
const store = appStoreFactory(coreStart);
ReactDOM.render(<AppRoot basename={appBasePath} store={store} />, element);
return () => {
ReactDOM.unmountComponentAtNode(element);
@ -25,38 +30,46 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou
interface RouterProps {
basename: string;
store: Store;
}
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename }) => (
<I18nProvider>
<BrowserRouter basename={basename}>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route
path="/management"
render={() => (
<h1 data-test-subj="endpointManagement">
<FormattedMessage
id="xpack.endpoint.endpointManagement"
defaultMessage="Manage Endpoints"
/>
</h1>
)}
/>
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</BrowserRouter>
</I18nProvider>
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename, store }) => (
<Provider store={store}>
<I18nProvider>
<BrowserRouter basename={basename}>
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route
path="/management"
render={() => {
// FIXME: This is temporary. Will be removed in next PR for endpoint list
store.dispatch({ type: 'userEnteredEndpointListPage' });
return (
<h1 data-test-subj="endpointManagement">
<FormattedMessage
id="xpack.endpoint.endpointManagement"
defaultMessage="Manage Endpoints"
/>
</h1>
);
}}
/>
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</BrowserRouter>
</I18nProvider>
</Provider>
));

View file

@ -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 * from './saga';

View file

@ -0,0 +1,101 @@
/*
* 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 { createSagaMiddleware, SagaContext } from './index';
import { applyMiddleware, createStore, Reducer } from 'redux';
describe('saga', () => {
const INCREMENT_COUNTER = 'INCREMENT';
const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER';
const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR';
const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms));
let reducerA: Reducer;
let sideAffect: (a: unknown, s: unknown) => void;
let sagaExe: (sagaContext: SagaContext) => Promise<void>;
beforeEach(() => {
reducerA = jest.fn((prevState = { count: 0 }, { type }) => {
switch (type) {
case INCREMENT_COUNTER:
return { ...prevState, count: prevState.count + 1 };
default:
return prevState;
}
});
sideAffect = jest.fn();
sagaExe = jest.fn(async ({ actionsAndState, dispatch }: SagaContext) => {
for await (const { action, state } of actionsAndState()) {
expect(action).toBeDefined();
expect(state).toBeDefined();
if (action.type === STOP_SAGA_PROCESSING) {
break;
}
sideAffect(action, state);
if (action.type === DELAYED_INCREMENT_COUNTER) {
await sleep(1);
dispatch({
type: INCREMENT_COUNTER,
});
}
}
});
});
test('it returns Redux Middleware from createSagaMiddleware()', () => {
const sagaMiddleware = createSagaMiddleware(async () => {});
expect(sagaMiddleware).toBeInstanceOf(Function);
});
test('it does nothing if saga is not started', () => {
const store = createStore(reducerA, applyMiddleware(createSagaMiddleware(sagaExe)));
expect(store.getState().count).toEqual(0);
expect(reducerA).toHaveBeenCalled();
expect(sagaExe).toHaveBeenCalled();
expect(sideAffect).not.toHaveBeenCalled();
expect(store.getState()).toEqual({ count: 0 });
});
test('it updates store once running', async () => {
const sagaMiddleware = createSagaMiddleware(sagaExe);
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));
expect(store.getState()).toEqual({ count: 0 });
expect(sagaExe).toHaveBeenCalled();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
expect(store.getState()).toEqual({ count: 0 });
await sleep(100);
expect(sideAffect).toHaveBeenCalled();
expect(store.getState()).toEqual({ count: 1 });
});
test('it stops processing if break out of loop', async () => {
const sagaMiddleware = createSagaMiddleware(sagaExe);
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep(100);
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
store.dispatch({ type: STOP_SAGA_PROCESSING });
await sleep(100);
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep(100);
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,129 @@
/*
* 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 { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux';
import { GlobalState } from '../store';
interface QueuedAction<TAction = AnyAction> {
/**
* The Redux action that was dispatched
*/
action: TAction;
/**
* The Global state at the time the action was dispatched
*/
state: GlobalState;
}
interface IteratorInstance {
queue: QueuedAction[];
nextResolve: null | ((inst: QueuedAction) => void);
}
type Saga = (storeContext: SagaContext) => Promise<void>;
type StoreActionsAndState<TAction = AnyAction> = AsyncIterableIterator<QueuedAction<TAction>>;
export interface SagaContext<TAction extends AnyAction = AnyAction> {
/**
* A generator function that will `yield` `Promise`s that resolve with a `QueuedAction`
*/
actionsAndState: () => StoreActionsAndState<TAction>;
dispatch: Dispatch<TAction>;
}
const noop = () => {};
/**
* Creates Saga Middleware for use with Redux.
*
* @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against
* the return value of the `actionsAndState()` method provided by the `SagaContext`.
*
* @return {Middleware}
*
* @example
*
* type TPossibleActions = { type: 'add', payload: any[] };
* //...
* const endpointsSaga = async ({ actionsAndState, dispatch }: SagaContext<TPossibleActions>) => {
* for await (const { action, state } of actionsAndState()) {
* if (action.type === "userRequestedResource") {
* const resourceData = await doApiFetch('of/some/resource');
* dispatch({
* type: 'add',
* payload: [ resourceData ]
* });
* }
* }
* }
* const endpointsSagaMiddleware = createSagaMiddleware(endpointsSaga);
* //....
* const store = createStore(reducers, [ endpointsSagaMiddleware ]);
*/
export function createSagaMiddleware(saga: Saga): Middleware {
const iteratorInstances = new Set<IteratorInstance>();
let runSaga: () => void = noop;
async function* getActionsAndStateIterator(): StoreActionsAndState {
const instance: IteratorInstance = { queue: [], nextResolve: null };
iteratorInstances.add(instance);
try {
while (true) {
yield await nextActionAndState();
}
} finally {
// If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await`
// then this `finally` block will run and unregister this instance and reset `runSaga`
iteratorInstances.delete(instance);
runSaga = noop;
}
function nextActionAndState() {
if (instance.queue.length) {
return Promise.resolve(instance.queue.shift() as QueuedAction);
} else {
return new Promise<QueuedAction>(function(resolve) {
instance.nextResolve = resolve;
});
}
}
}
function enqueue(value: QueuedAction) {
for (const iteratorInstance of iteratorInstances) {
iteratorInstance.queue.push(value);
if (iteratorInstance.nextResolve !== null) {
iteratorInstance.nextResolve(iteratorInstance.queue.shift() as QueuedAction);
iteratorInstance.nextResolve = null;
}
}
}
function middleware({ getState, dispatch }: MiddlewareAPI) {
if (runSaga === noop) {
runSaga = saga.bind<null, SagaContext, any[], Promise<void>>(null, {
actionsAndState: getActionsAndStateIterator,
dispatch,
});
runSaga();
}
return (next: Dispatch<AnyAction>) => (action: AnyAction) => {
// Call the next dispatch method in the middleware chain.
const returnValue = next(action);
enqueue({
action,
state: getState(),
});
// This will likely be the action itself, unless a middleware further in chain changed it.
return returnValue;
};
}
return middleware;
}

View file

@ -0,0 +1,9 @@
/*
* 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 { EndpointListAction } from './endpoint_list';
export type AppAction = EndpointListAction;

View file

@ -0,0 +1,25 @@
/*
* 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 { EndpointListData } from './types';
interface ServerReturnedEndpointList {
type: 'serverReturnedEndpointList';
payload: EndpointListData;
}
interface UserEnteredEndpointListPage {
type: 'userEnteredEndpointListPage';
}
interface UserExitedEndpointListPage {
type: 'userExitedEndpointListPage';
}
export type EndpointListAction =
| ServerReturnedEndpointList
| UserEnteredEndpointListPage
| UserExitedEndpointListPage;

View file

@ -0,0 +1,126 @@
/*
* 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 { createStore, Dispatch, Store } from 'redux';
import { EndpointListAction, EndpointData, endpointListReducer, EndpointListState } from './index';
import { endpointListData } from './selectors';
describe('endpoint_list store concerns', () => {
let store: Store<EndpointListState>;
let dispatch: Dispatch<EndpointListAction>;
const createTestStore = () => {
store = createStore(endpointListReducer);
dispatch = store.dispatch;
};
const generateEndpoint = (): EndpointData => {
return {
machine_id: Math.random()
.toString(16)
.substr(2),
created_at: new Date(),
host: {
name: '',
hostname: '',
ip: '',
mac_address: '',
os: {
name: '',
full: '',
},
},
endpoint: {
domain: '',
is_base_image: true,
active_directory_distinguished_name: '',
active_directory_hostname: '',
upgrade: {
status: '',
updated_at: new Date(),
},
isolation: {
status: false,
request_status: true,
updated_at: new Date(),
},
policy: {
name: '',
id: '',
},
sensor: {
persistence: true,
status: {},
},
},
};
};
const loadDataToStore = () => {
dispatch({
type: 'serverReturnedEndpointList',
payload: {
endpoints: [generateEndpoint()],
request_page_size: 1,
request_index: 1,
total: 10,
},
});
};
describe('# Reducers', () => {
beforeEach(() => {
createTestStore();
});
test('it creates default state', () => {
expect(store.getState()).toEqual({
endpoints: [],
request_page_size: 10,
request_index: 0,
total: 0,
});
});
test('it handles `serverReturnedEndpointList', () => {
const payload = {
endpoints: [generateEndpoint()],
request_page_size: 1,
request_index: 1,
total: 10,
};
dispatch({
type: 'serverReturnedEndpointList',
payload,
});
const currentState = store.getState();
expect(currentState.endpoints).toEqual(payload.endpoints);
expect(currentState.request_page_size).toEqual(payload.request_page_size);
expect(currentState.request_index).toEqual(payload.request_index);
expect(currentState.total).toEqual(payload.total);
});
test('it handles `userExitedEndpointListPage`', () => {
loadDataToStore();
expect(store.getState().total).toEqual(10);
dispatch({ type: 'userExitedEndpointListPage' });
expect(store.getState().endpoints.length).toEqual(0);
expect(store.getState().request_index).toEqual(0);
});
});
describe('# Selectors', () => {
beforeEach(() => {
createTestStore();
loadDataToStore();
});
test('it selects `endpointListData`', () => {
const currentState = store.getState();
expect(endpointListData(currentState)).toEqual(currentState.endpoints);
});
});
});

View file

@ -0,0 +1,10 @@
/*
* 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 { endpointListReducer } from './reducer';
export { EndpointListAction } from './action';
export { endpointListSaga } from './saga';
export * from './types';

View file

@ -0,0 +1,32 @@
/*
* 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 { EndpointListState } from './types';
import { EndpointListAction } from './action';
const initialState = (): EndpointListState => {
return {
endpoints: [],
request_page_size: 10,
request_index: 0,
total: 0,
};
};
export const endpointListReducer = (state = initialState(), action: EndpointListAction) => {
if (action.type === 'serverReturnedEndpointList') {
return {
...state,
...action.payload,
};
}
if (action.type === 'userExitedEndpointListPage') {
return initialState();
}
return state;
};

View file

@ -0,0 +1,111 @@
/*
* 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 { CoreStart, HttpSetup } from 'kibana/public';
import { applyMiddleware, createStore, Dispatch, Store } from 'redux';
import { createSagaMiddleware, SagaContext } from '../../lib';
import { endpointListSaga } from './saga';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import {
EndpointData,
EndpointListAction,
EndpointListData,
endpointListReducer,
EndpointListState,
} from './index';
import { endpointListData } from './selectors';
describe('endpoint list saga', () => {
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
let fakeCoreStart: jest.Mocked<CoreStart>;
let fakeHttpServices: jest.Mocked<HttpSetup>;
let store: Store<EndpointListState>;
let dispatch: Dispatch<EndpointListAction>;
// TODO: consolidate the below ++ helpers in `index.test.ts` into a `test_helpers.ts`??
const generateEndpoint = (): EndpointData => {
return {
machine_id: Math.random()
.toString(16)
.substr(2),
created_at: new Date(),
host: {
name: '',
hostname: '',
ip: '',
mac_address: '',
os: {
name: '',
full: '',
},
},
endpoint: {
domain: '',
is_base_image: true,
active_directory_distinguished_name: '',
active_directory_hostname: '',
upgrade: {
status: '',
updated_at: new Date(),
},
isolation: {
status: false,
request_status: true,
updated_at: new Date(),
},
policy: {
name: '',
id: '',
},
sensor: {
persistence: true,
status: {},
},
},
};
};
const getEndpointListApiResponse = (): EndpointListData => {
return {
endpoints: [generateEndpoint()],
request_page_size: 1,
request_index: 1,
total: 10,
};
};
const endpointListSagaFactory = () => {
return async (sagaContext: SagaContext) => {
await endpointListSaga(sagaContext, fakeCoreStart).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
return Promise.reject(e);
});
};
};
beforeEach(() => {
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
store = createStore(
endpointListReducer,
applyMiddleware(createSagaMiddleware(endpointListSagaFactory()))
);
dispatch = store.dispatch;
});
test('it handles `userEnteredEndpointListPage`', async () => {
const apiResponse = getEndpointListApiResponse();
fakeHttpServices.post.mockResolvedValue(apiResponse);
expect(fakeHttpServices.post).not.toHaveBeenCalled();
dispatch({ type: 'userEnteredEndpointListPage' });
await sleep();
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/endpoints');
expect(endpointListData(store.getState())).toEqual(apiResponse.endpoints);
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 { CoreStart } from 'kibana/public';
import { SagaContext } from '../../lib';
import { EndpointListAction } from './action';
export const endpointListSaga = async (
{ actionsAndState, dispatch }: SagaContext<EndpointListAction>,
coreStart: CoreStart
) => {
const { post: httpPost } = coreStart.http;
for await (const { action } of actionsAndState()) {
if (action.type === 'userEnteredEndpointListPage') {
const response = await httpPost('/api/endpoint/endpoints');
dispatch({
type: 'serverReturnedEndpointList',
payload: response,
});
}
}
};

View file

@ -0,0 +1,9 @@
/*
* 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 { EndpointListState } from './types';
export const endpointListData = (state: EndpointListState) => state.endpoints;

View file

@ -0,0 +1,54 @@
/*
* 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.
*/
// FIXME: temporary until server defined `interface` is moved
export interface EndpointData {
machine_id: string;
created_at: Date;
host: {
name: string;
hostname: string;
ip: string;
mac_address: string;
os: {
name: string;
full: string;
};
};
endpoint: {
domain: string;
is_base_image: boolean;
active_directory_distinguished_name: string;
active_directory_hostname: string;
upgrade: {
status?: string;
updated_at?: Date;
};
isolation: {
status: boolean;
request_status?: string | boolean;
updated_at?: Date;
};
policy: {
name: string;
id: string;
};
sensor: {
persistence: boolean;
status: object;
};
};
}
// FIXME: temporary until server defined `interface` is moved to a module we can reference
export interface EndpointListData {
endpoints: EndpointData[];
request_page_size: number;
request_index: number;
total: number;
}
export type EndpointListState = EndpointListData;

View file

@ -0,0 +1,24 @@
/*
* 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 { createStore, compose, applyMiddleware } from 'redux';
import { CoreStart } from 'kibana/public';
import { appSagaFactory } from './saga';
import { appReducer } from './reducer';
export { GlobalState } from './reducer';
const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' })
: compose;
export const appStoreFactory = (coreStart: CoreStart) => {
const store = createStore(
appReducer,
composeWithReduxDevTools(applyMiddleware(appSagaFactory(coreStart)))
);
return store;
};

View file

@ -0,0 +1,16 @@
/*
* 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 { combineReducers, Reducer } from 'redux';
import { endpointListReducer, EndpointListState } from './endpoint_list';
import { AppAction } from './actions';
export interface GlobalState {
endpointList: EndpointListState;
}
export const appReducer: Reducer<GlobalState, AppAction> = combineReducers({
endpointList: endpointListReducer,
});

View file

@ -0,0 +1,18 @@
/*
* 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 { CoreStart } from 'kibana/public';
import { createSagaMiddleware, SagaContext } from '../lib';
import { endpointListSaga } from './endpoint_list';
export const appSagaFactory = (coreStart: CoreStart) => {
return createSagaMiddleware(async (sagaContext: SagaContext) => {
await Promise.all([
// Concerns specific sagas here
endpointListSaga(sagaContext, coreStart),
]);
});
};