Skip to content

Commit

Permalink
Merge pull request #2902 from dlabrecq/3477-ros-api
Browse files Browse the repository at this point in the history
Boiler plate for ROS APIs
  • Loading branch information
dlabrecq committed Feb 7, 2023
2 parents 90bc177 + 6791260 commit f0a5c84
Show file tree
Hide file tree
Showing 12 changed files with 399 additions and 0 deletions.
10 changes: 10 additions & 0 deletions src/api/ros/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RosType } from 'api/ros/ros';
import axios from 'axios';

import { runRos } from './recommendations';

test('api run reports calls axios get', () => {
const query = 'filter[resolution]=daily';
runRos(RosType.cost, query);
expect(axios.get).toBeCalledWith(`reports/openshift/costs/?${query}`);
});
44 changes: 44 additions & 0 deletions src/api/ros/recommendations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import axios from 'axios';

import type { Ros, RosData, RosItem, RosItemValue, RosMeta, RosValue } from './ros';
import { RosType } from './ros';

export interface RosRosItem extends RosItem {
capacity?: RosValue;
cluster?: string;
clusters?: string[];
limit?: RosValue;
node?: string;
project?: string;
request?: RosValue;
}

export interface RosRosData extends RosData {
// TBD...
}

export interface RosRosMeta extends RosMeta {
total?: {
capacity?: RosValue;
cost?: RosItemValue;
infrastructure?: RosItemValue;
limit?: RosValue;
request?: RosValue;
supplementary?: RosItemValue;
usage?: RosValue;
};
}

export interface RosRos extends Ros {
meta: RosRosMeta;
data: RosRosData[];
}

export const RosTypePaths: Partial<Record<RosType, string>> = {
[RosType.cost]: 'reports/openshift/costs/',
};

export function runRos(reportType: RosType, query: string) {
const path = RosTypePaths[reportType];
return axios.get<RosRos>(`${path}?${query}`);
}
74 changes: 74 additions & 0 deletions src/api/ros/ros.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { PagedMetaData, PagedResponse } from 'api/api';

export interface RosValue {
units?: string;
value?: number;
}

export interface RosItemValue {
markup?: RosValue;
raw?: RosValue;
total?: RosValue;
usage: RosValue;
}

export interface RosItem {
alias?: string;
classification?: string; // Platform.
cost?: RosItemValue;
date?: string;
default_project?: string; // 'True' or 'False'
delta_percent?: number;
delta_value?: number;
id?: string;
infrastructure?: RosItemValue;
source_uuid?: string;
supplementary?: RosItemValue;
}

// Additional props for group_by[org_unit_id]
export interface RosData {
date?: string;
values?: RosItem[];
}

export interface RosMeta extends PagedMetaData {
category?: string[];
count: number;
delta?: {
percent: number;
value: number;
};
filter?: {
[filter: string]: any;
};
group_by?: {
[group: string]: string[];
};
order_by?: {
[order: string]: string;
};
others?: number;
total?: {
capacity?: RosValue;
cost?: RosItemValue;
count?: RosValue; // Workaround for https://github.com/project-koku/koku/issues/1395
infrastructure?: RosItemValue;
limit?: RosValue;
request?: RosValue;
supplementary?: RosItemValue;
usage?: RosValue;
};
}

export interface Ros extends PagedResponse<RosData, RosMeta> {}

// eslint-disable-next-line no-shadow
export const enum RosType {
cost = 'cost',
}

// eslint-disable-next-line no-shadow
export const enum RosPathsType {
recommendation = 'recommendation',
}
13 changes: 13 additions & 0 deletions src/api/ros/rosUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { runRos as runRecommendation } from './recommendations';
import type { RosType } from './ros';
import { RosPathsType } from './ros';

export function runRos(rosPathsType: RosPathsType, rosType: RosType, query: string) {
let result;
switch (rosPathsType) {
case RosPathsType.recommendation:
result = runRecommendation(rosType, query);
break;
}
return result;
}
2 changes: 2 additions & 0 deletions src/store/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { orgReducer, orgStateKey } from 'store/orgs';
import { priceListReducer, priceListStateKey } from 'store/priceList';
import { reportReducer, reportStateKey } from 'store/reports';
import { resourceReducer, resourceStateKey } from 'store/resources';
import { rosReducer, rosStateKey } from 'store/ros';
import { sourcesReducer, sourcesStateKey } from 'store/sourceSettings';
import { tagReducer, tagStateKey } from 'store/tags';
import type { StateType } from 'typesafe-actions';
Expand Down Expand Up @@ -90,6 +91,7 @@ export const rootReducer = combineReducers({
[rhelCostOverviewStateKey]: rhelCostOverviewReducer,
[rhelDashboardStateKey]: rhelDashboardReducer,
[rhelHistoricalDataStateKey]: rhelHistoricalDataReducer,
[rosStateKey]: rosReducer,
[sourcesStateKey]: sourcesReducer,
[tagStateKey]: tagReducer,
[uiStateKey]: uiReducer,
Expand Down
9 changes: 9 additions & 0 deletions src/store/ros/__snapshots__/ros.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`default state 1`] = `
{
"byId": Map {},
"errors": Map {},
"fetchStatus": Map {},
}
`;
8 changes: 8 additions & 0 deletions src/store/ros/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as rosActions from './rosActions';
import { rosStateKey } from './rosCommon';
import type { CachedRos, RosAction, RosState } from './rosReducer';
import { rosReducer } from './rosReducer';
import * as rosSelectors from './rosSelectors';

export type { RosAction, CachedRos, RosState };
export { rosActions, rosReducer, rosSelectors, rosStateKey };
89 changes: 89 additions & 0 deletions src/store/ros/ros.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
jest.mock('api/ros/rosUtils');

import { waitFor } from '@testing-library/react';
import type { Ros } from 'api/ros/ros';
import { RosPathsType, RosType } from 'api/ros/ros';
import { runRos } from 'api/ros/rosUtils';
import { FetchStatus } from 'store/common';
import { createMockStoreCreator } from 'store/mockStore';

import * as actions from './rosActions';
import { rosStateKey } from './rosCommon';
import { rosReducer } from './rosReducer';
import * as selectors from './rosSelectors';

const createRossStore = createMockStoreCreator({
[rosStateKey]: rosReducer,
});

const runRosMock = runRos as jest.Mock;

const mockRos: Ros = {
data: [],
total: {
value: 100,
units: 'USD',
},
} as any;

const rosType = RosType.cost;
const rosPathsType = RosPathsType.recommendation;
const rosQueryString = 'rosQueryString';

runRosMock.mockResolvedValue({ data: mockRos });
global.Date.now = jest.fn(() => 12345);

jest.spyOn(actions, 'fetchRos');
jest.spyOn(selectors, 'selectRosFetchStatus');

test('default state', () => {
const store = createRossStore();
expect(selectors.selectRosState(store.getState())).toMatchSnapshot();
});

test('fetch ros success', async () => {
const store = createRossStore();
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
expect(runRosMock).toBeCalled();
expect(selectors.selectRosFetchStatus(store.getState(), rosPathsType, rosType, rosQueryString)).toBe(
FetchStatus.inProgress
);
await waitFor(() => expect(selectors.selectRosFetchStatus).toHaveBeenCalled());
const finishedState = store.getState();
expect(selectors.selectRosFetchStatus(finishedState, rosPathsType, rosType, rosQueryString)).toBe(
FetchStatus.complete
);
expect(selectors.selectRosError(finishedState, rosPathsType, rosType, rosQueryString)).toBe(null);
});

test('fetch ros failure', async () => {
const store = createRossStore();
const error = Symbol('ros error');
runRosMock.mockRejectedValueOnce(error);
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
expect(runRos).toBeCalled();
expect(selectors.selectRosFetchStatus(store.getState(), rosPathsType, rosType, rosQueryString)).toBe(
FetchStatus.inProgress
);
await waitFor(() => expect(selectors.selectRosFetchStatus).toHaveBeenCalled());
const finishedState = store.getState();
expect(selectors.selectRosFetchStatus(finishedState, rosPathsType, rosType, rosQueryString)).toBe(
FetchStatus.complete
);
expect(selectors.selectRosError(finishedState, rosPathsType, rosType, rosQueryString)).toBe(error);
});

test('does not fetch ros if the request is in progress', () => {
const store = createRossStore();
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
expect(runRos).toHaveBeenCalledTimes(1);
});

test('ros is not refetched if it has not expired', async () => {
const store = createRossStore();
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
await waitFor(() => expect(actions.fetchRos).toHaveBeenCalled());
store.dispatch(actions.fetchRos(rosPathsType, rosType, rosQueryString));
expect(runRos).toHaveBeenCalledTimes(1);
});
59 changes: 59 additions & 0 deletions src/store/ros/rosActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Ros } from 'api/ros/ros';
import type { RosPathsType, RosType } from 'api/ros/ros';
import { runRos } from 'api/ros/rosUtils';
import type { AxiosError } from 'axios';
import type { ThunkAction } from 'store/common';
import { FetchStatus } from 'store/common';
import type { RootState } from 'store/rootReducer';
import { createAction } from 'typesafe-actions';

import { getFetchId } from './rosCommon';
import { selectRos, selectRosFetchStatus } from './rosSelectors';

const expirationMS = 30 * 60 * 1000; // 30 minutes

interface RosActionMeta {
fetchId: string;
}

export const fetchRosRequest = createAction('ros/request')<RosActionMeta>();
export const fetchRosSuccess = createAction('ros/success')<Ros, RosActionMeta>();
export const fetchRosFailure = createAction('ros/failure')<AxiosError, RosActionMeta>();

export function fetchRos(rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string): ThunkAction {
return (dispatch, getState) => {
if (!isRosExpired(getState(), rosPathsType, rosType, rosQueryString)) {
return;
}

const meta: RosActionMeta = {
fetchId: getFetchId(rosPathsType, rosType, rosQueryString),
};

dispatch(fetchRosRequest(meta));
runRos(rosPathsType, rosType, rosQueryString)
.then(res => {
// See https://github.com/project-koku/koku-ui/pull/580
// const repsonseData = dropCurrentMonthData(res, query);
dispatch(fetchRosSuccess(res.data, meta));
})
.catch(err => {
dispatch(fetchRosFailure(err, meta));
});
};
}

function isRosExpired(state: RootState, rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string) {
const ros = selectRos(state, rosPathsType, rosType, rosQueryString);
const fetchStatus = selectRosFetchStatus(state, rosPathsType, rosType, rosQueryString);
if (fetchStatus === FetchStatus.inProgress) {
return false;
}

if (!ros) {
return true;
}

const now = Date.now();
return now > ros.timeRequested + expirationMS;
}
6 changes: 6 additions & 0 deletions src/store/ros/rosCommon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { RosPathsType, RosType } from 'api/ros/ros';
export const rosStateKey = 'ros';

export function getFetchId(rosPathsType: RosPathsType, rosType: RosType, rosQueryString: string) {
return `${rosPathsType}--${rosType}--${rosQueryString}`;
}
62 changes: 62 additions & 0 deletions src/store/ros/rosReducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Ros } from 'api/ros/ros';
import type { AxiosError } from 'axios';
import { FetchStatus } from 'store/common';
import { resetState } from 'store/ui/uiActions';
import type { ActionType } from 'typesafe-actions';
import { getType } from 'typesafe-actions';

import { fetchRosFailure, fetchRosRequest, fetchRosSuccess } from './rosActions';

export interface CachedRos extends Ros {
timeRequested: number;
}

export type RosState = Readonly<{
byId: Map<string, CachedRos>;
fetchStatus: Map<string, FetchStatus>;
errors: Map<string, AxiosError>;
}>;

const defaultState: RosState = {
byId: new Map(),
fetchStatus: new Map(),
errors: new Map(),
};

export type RosAction = ActionType<
typeof fetchRosFailure | typeof fetchRosRequest | typeof fetchRosSuccess | typeof resetState
>;

export function rosReducer(state = defaultState, action: RosAction): RosState {
switch (action.type) {
case getType(resetState):
state = defaultState;
return state;

case getType(fetchRosRequest):
return {
...state,
fetchStatus: new Map(state.fetchStatus).set(action.payload.fetchId, FetchStatus.inProgress),
};

case getType(fetchRosSuccess):
return {
...state,
fetchStatus: new Map(state.fetchStatus).set(action.meta.fetchId, FetchStatus.complete),
byId: new Map(state.byId).set(action.meta.fetchId, {
...action.payload,
timeRequested: Date.now(),
}),
errors: new Map(state.errors).set(action.meta.fetchId, null),
};

case getType(fetchRosFailure):
return {
...state,
fetchStatus: new Map(state.fetchStatus).set(action.meta.fetchId, FetchStatus.complete),
errors: new Map(state.errors).set(action.meta.fetchId, action.payload),
};
default:
return state;
}
}
Loading

0 comments on commit f0a5c84

Please sign in to comment.