From 441d682b8e2b0ce269508ca8b26c6db97ba83925 Mon Sep 17 00:00:00 2001 From: amitlissack Date: Wed, 13 May 2020 17:21:29 -0400 Subject: [PATCH] feat(app,robot-server): add support for sessions API (#5628) * add api v2 structured types * add session actions and tests * add reducers plus tests * add selectors + tests * selector test fix * remove meaningless selector * add epics plus tests * adding sessions to app * fix some errors in imports * fix lint errors. * robot-server settings endpoint models are camelCase * better typing, lint and flow error fix progress. * fix flow errors. * adding typing todos * rename session from check to calibrationCheck. * remove robot from session naming * remove robot from sessions * lint errors * remove sessionId from response. it was not necessary. * remove sessionId from responses. * Use MikeC's fancy typing for response v2 * type the v2 links * use helpers for epic tests * lose all references to update. use command instead. * use RsourceLinks type --- .../server/endpoints/calibration/models.py | 2 +- app/src/epic.js | 4 +- app/src/reducer.js | 3 + app/src/robot-api/__fixtures__/index.js | 10 + app/src/robot-api/types.js | 46 ++++ app/src/sessions/__fixtures__/index.js | 109 ++++++++++ app/src/sessions/__tests__/actions.test.js | 171 +++++++++++++++ app/src/sessions/__tests__/reducer.test.js | 199 ++++++++++++++++++ app/src/sessions/__tests__/selectors.test.js | 86 ++++++++ app/src/sessions/actions.js | 127 +++++++++++ app/src/sessions/constants.js | 40 ++++ .../createSessionCommandEpic.test.js | 96 +++++++++ .../epic/__tests__/createSessionEpic.test.js | 91 ++++++++ .../epic/__tests__/deleteSessionEpic.test.js | 82 ++++++++ .../epic/__tests__/fetchSessionEpic.test.js | 83 ++++++++ .../sessions/epic/createSessionCommandEpic.js | 65 ++++++ app/src/sessions/epic/createSessionEpic.js | 53 +++++ app/src/sessions/epic/deleteSessionEpic.js | 46 ++++ app/src/sessions/epic/fetchSessionEpic.js | 46 ++++ app/src/sessions/epic/index.js | 15 ++ app/src/sessions/index.js | 5 + app/src/sessions/reducer.js | 72 +++++++ app/src/sessions/selectors.js | 17 ++ app/src/sessions/types.js | 180 ++++++++++++++++ app/src/types.js | 4 + .../robot_server/service/models/session.py | 10 +- .../robot_server/service/routers/session.py | 21 +- .../tests/service/routers/test_session.py | 63 +++--- 28 files changed, 1695 insertions(+), 51 deletions(-) create mode 100644 app/src/sessions/__fixtures__/index.js create mode 100644 app/src/sessions/__tests__/actions.test.js create mode 100644 app/src/sessions/__tests__/reducer.test.js create mode 100644 app/src/sessions/__tests__/selectors.test.js create mode 100644 app/src/sessions/actions.js create mode 100644 app/src/sessions/constants.js create mode 100644 app/src/sessions/epic/__tests__/createSessionCommandEpic.test.js create mode 100644 app/src/sessions/epic/__tests__/createSessionEpic.test.js create mode 100644 app/src/sessions/epic/__tests__/deleteSessionEpic.test.js create mode 100644 app/src/sessions/epic/__tests__/fetchSessionEpic.test.js create mode 100644 app/src/sessions/epic/createSessionCommandEpic.js create mode 100644 app/src/sessions/epic/createSessionEpic.js create mode 100644 app/src/sessions/epic/deleteSessionEpic.js create mode 100644 app/src/sessions/epic/fetchSessionEpic.js create mode 100644 app/src/sessions/epic/index.js create mode 100644 app/src/sessions/index.js create mode 100644 app/src/sessions/reducer.js create mode 100644 app/src/sessions/selectors.js create mode 100644 app/src/sessions/types.js diff --git a/api/src/opentrons/server/endpoints/calibration/models.py b/api/src/opentrons/server/endpoints/calibration/models.py index 959e49f0a3b..dea454a0c9c 100644 --- a/api/src/opentrons/server/endpoints/calibration/models.py +++ b/api/src/opentrons/server/endpoints/calibration/models.py @@ -22,7 +22,7 @@ class TiprackPosition(BaseModel): class SessionType(str, Enum): """The available session types""" - check = 'check' + calibration_check = 'calibrationCheck' class SpecificPipette(BaseModel): diff --git a/app/src/epic.js b/app/src/epic.js index 86f0f9e9cf3..612fe74daaf 100644 --- a/app/src/epic.js +++ b/app/src/epic.js @@ -16,6 +16,7 @@ import { networkingEpic } from './networking/epic' import { shellEpic } from './shell/epic' import { alertsEpic } from './alerts/epic' import { systemInfoEpic } from './system-info/epic' +import { sessionsEpic } from './sessions/epic' import type { Epic } from './types' @@ -33,5 +34,6 @@ export const rootEpic: Epic = combineEpics( networkingEpic, shellEpic, alertsEpic, - systemInfoEpic + systemInfoEpic, + sessionsEpic ) diff --git a/app/src/reducer.js b/app/src/reducer.js index 1538126b896..883f6479ae0 100644 --- a/app/src/reducer.js +++ b/app/src/reducer.js @@ -59,6 +59,8 @@ import { systemInfoReducer } from './system-info/reducer' // app-wide alerts state import { alertsReducer } from './alerts/reducer' +import { robotSessionReducer } from './sessions/reducer' + import type { Reducer } from 'redux' import type { State, Action } from './types' @@ -83,5 +85,6 @@ export const rootReducer: Reducer = combineReducers<_, Action>({ shell: shellReducer, systemInfo: systemInfoReducer, alerts: alertsReducer, + sessions: robotSessionReducer, router: connectRouter<_, Action>(history), }) diff --git a/app/src/robot-api/__fixtures__/index.js b/app/src/robot-api/__fixtures__/index.js index ef268fe78ae..2209db58568 100644 --- a/app/src/robot-api/__fixtures__/index.js +++ b/app/src/robot-api/__fixtures__/index.js @@ -6,6 +6,8 @@ import type { RobotHost, RobotApiRequestMeta, RequestState, + RobotApiV2Error, + RobotApiV2ErrorResponseBody, } from '../types' export type ResponseFixturesOptions = {| @@ -78,3 +80,11 @@ export const makeResponseFixtures = ( return { successMeta, success, failureMeta, failure } } + +export const mockV2Error: RobotApiV2Error = { + status: 'went bad', +} + +export const mockV2ErrorResponse: RobotApiV2ErrorResponseBody = { + errors: [mockV2Error], +} diff --git a/app/src/robot-api/types.js b/app/src/robot-api/types.js index 1b52274b556..16a0c99b42f 100644 --- a/app/src/robot-api/types.js +++ b/app/src/robot-api/types.js @@ -71,3 +71,49 @@ export type RobotApiState = $Shape< [requestId: string]: void | RequestState, |}> > + +export type ResourceLink = {| + href: string, + meta?: $Shape<{| [string]: string | void |}>, +|} + +export type ResourceLinks = $Shape<{| [string]: ResourceLink | string | void |}> + +// generic response data supertype +export type RobotApiV2ResponseData = {| + id: string, + // the "+" means that these properties are covariant, so + // "extending" types may specify more strict subtypes + +type: string, + +attributes: { ... }, +|} + +// parameterized response type +// DataT parameter must be a subtype of RobotApiV2ResponseData +// MetaT defaults to void if unspecified +export type RobotApiV2ResponseBody< + DataT: RobotApiV2ResponseData, + MetaT = void +> = {| + data: DataT, + links?: ResourceLinks, + meta?: MetaT, +|} + +export type RobotApiV2Error = {| + id?: string, + links?: ResourceLinks, + status?: string, + code?: string, + title?: string, + detail?: string, + source?: {| + pointer?: string, + parameter?: string, + |}, + meta?: { ... }, +|} + +export type RobotApiV2ErrorResponseBody = { + errors: Array, +} diff --git a/app/src/sessions/__fixtures__/index.js b/app/src/sessions/__fixtures__/index.js new file mode 100644 index 00000000000..fb817b6678b --- /dev/null +++ b/app/src/sessions/__fixtures__/index.js @@ -0,0 +1,109 @@ +// @flow + +import * as Types from '../types' +import * as Constants from '../constants' +import { POST, DELETE, GET } from '../../robot-api' +import { + makeResponseFixtures, + mockV2ErrorResponse, +} from '../../robot-api/__fixtures__' + +import type { RobotApiV2ErrorResponseBody } from '../../robot-api/types' + +export const mockSessionData: Types.Session = { + sessionType: 'calibrationCheck', + details: { someData: 5 }, +} + +export const mockSessionCommand: Types.SessionCommand = { + command: 'dosomething', + data: { someData: 32 }, +} + +export const mockSessionCommandData: Types.SessionCommand = { + command: '4321', + status: 'accepted', + data: {}, +} + +export const mockSessionResponse: Types.SessionResponse = { + data: { + id: '1234', + type: 'Session', + attributes: mockSessionData, + }, +} + +export const mockSessionCommandResponse: Types.SessionCommandResponse = { + data: { + id: '4321', + type: 'SessionCommand', + attributes: mockSessionCommandData, + }, + meta: { + sessionType: 'calibrationCheck', + details: { + someData: 15, + someOtherData: 'hi', + }, + }, +} + +export const { + successMeta: mockCreateSessionSuccessMeta, + failureMeta: mockCreateSessionFailureMeta, + success: mockCreateSessionSuccess, + failure: mockCreateSessionFailure, +} = makeResponseFixtures({ + method: POST, + path: Constants.SESSIONS_PATH, + successStatus: 201, + successBody: mockSessionResponse, + failureStatus: 500, + failureBody: mockV2ErrorResponse, +}) + +export const { + successMeta: mockDeleteSessionSuccessMeta, + failureMeta: mockDeleteSessionFailureMeta, + success: mockDeleteSessionSuccess, + failure: mockDeleteSessionFailure, +} = makeResponseFixtures({ + method: DELETE, + path: `${Constants.SESSIONS_PATH}/1234`, + successStatus: 200, + successBody: mockSessionResponse, + failureStatus: 500, + failureBody: mockV2ErrorResponse, +}) + +export const { + successMeta: mockFetchSessionSuccessMeta, + failureMeta: mockFetchSessionFailureMeta, + success: mockFetchSessionSuccess, + failure: mockFetchSessionFailure, +} = makeResponseFixtures({ + method: GET, + path: `${Constants.SESSIONS_PATH}/1234`, + successStatus: 200, + successBody: mockSessionResponse, + failureStatus: 500, + failureBody: mockV2ErrorResponse, +}) + +export const { + successMeta: mockSessionCommandsSuccessMeta, + failureMeta: mockSessionCommandsFailureMeta, + success: mockSessionCommandsSuccess, + failure: mockSessionCommandsFailure, +} = makeResponseFixtures< + Types.SessionCommandResponse, + RobotApiV2ErrorResponseBody +>({ + method: GET, + path: `${Constants.SESSIONS_PATH}/1234/${Constants.SESSIONS_COMMANDS_PATH_EXTENSION}`, + successStatus: 200, + successBody: mockSessionCommandResponse, + failureStatus: 500, + failureBody: mockV2ErrorResponse, +}) diff --git a/app/src/sessions/__tests__/actions.test.js b/app/src/sessions/__tests__/actions.test.js new file mode 100644 index 00000000000..97ff9f905db --- /dev/null +++ b/app/src/sessions/__tests__/actions.test.js @@ -0,0 +1,171 @@ +// @flow + +import * as Actions from '../actions' +import * as Fixtures from '../__fixtures__' + +import type { SessionsAction } from '../types' + +import { mockV2ErrorResponse } from '../../robot-api/__fixtures__' + +type ActionSpec = {| + name: string, + creator: (...Array) => mixed, + args: Array, + expected: SessionsAction, +|} + +describe('robot session check actions', () => { + const SPECS: Array = [ + { + name: 'sessions:CREATE_SESSION', + creator: Actions.createSession, + args: ['robot-name', 'calibrationCheck'], + expected: { + type: 'sessions:CREATE_SESSION', + payload: { robotName: 'robot-name', sessionType: 'calibrationCheck' }, + meta: {}, + }, + }, + { + name: 'sessions:CREATE_SESSION_SUCCESS', + creator: Actions.createSessionSuccess, + args: ['robot-name', Fixtures.mockSessionResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:CREATE_SESSION_SUCCESS', + payload: { + robotName: 'robot-name', + ...Fixtures.mockSessionResponse, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:CREATE_SESSION_FAILURE', + creator: Actions.createSessionFailure, + args: ['robot-name', mockV2ErrorResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:CREATE_SESSION_FAILURE', + payload: { robotName: 'robot-name', error: mockV2ErrorResponse }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:DELETE_SESSION', + creator: Actions.deleteSession, + args: ['robot-name', '1234'], + expected: { + type: 'sessions:DELETE_SESSION', + payload: { robotName: 'robot-name', sessionId: '1234' }, + meta: {}, + }, + }, + { + name: 'sessions:DELETE_SESSION_SUCCESS', + creator: Actions.deleteSessionSuccess, + args: ['robot-name', Fixtures.mockSessionResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:DELETE_SESSION_SUCCESS', + payload: { + robotName: 'robot-name', + ...Fixtures.mockSessionResponse, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:DELETE_SESSION_FAILURE', + creator: Actions.deleteSessionFailure, + args: ['robot-name', mockV2ErrorResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:DELETE_SESSION_FAILURE', + payload: { robotName: 'robot-name', error: mockV2ErrorResponse }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:FETCH_SESSION', + creator: Actions.fetchSession, + args: ['robot-name', '1234'], + expected: { + type: 'sessions:FETCH_SESSION', + payload: { robotName: 'robot-name', sessionId: '1234' }, + meta: {}, + }, + }, + { + name: 'sessions:FETCH_SESSION_SUCCESS', + creator: Actions.fetchSessionSuccess, + args: ['robot-name', Fixtures.mockSessionResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:FETCH_SESSION_SUCCESS', + payload: { + robotName: 'robot-name', + ...Fixtures.mockSessionResponse, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:FETCH_SESSION_FAILURE', + creator: Actions.fetchSessionFailure, + args: ['robot-name', mockV2ErrorResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:FETCH_SESSION_FAILURE', + payload: { robotName: 'robot-name', error: mockV2ErrorResponse }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:CREATE_SESSION_COMMAND', + creator: Actions.createSessionCommand, + args: ['robot-name', '1234', Fixtures.mockSessionCommand], + expected: { + type: 'sessions:CREATE_SESSION_COMMAND', + payload: { + robotName: 'robot-name', + sessionId: '1234', + command: Fixtures.mockSessionCommand, + }, + meta: {}, + }, + }, + { + name: 'sessions:CREATE_SESSION_COMMAND_SUCCESS', + creator: Actions.createSessionCommandSuccess, + args: [ + 'robot-name', + '1234', + Fixtures.mockSessionCommandResponse, + { requestId: 'abc' }, + ], + expected: { + type: 'sessions:CREATE_SESSION_COMMAND_SUCCESS', + payload: { + robotName: 'robot-name', + sessionId: '1234', + ...Fixtures.mockSessionCommandResponse, + }, + meta: { requestId: 'abc' }, + }, + }, + { + name: 'sessions:CREATE_SESSION_COMMAND_FAILURE', + creator: Actions.createSessionCommandFailure, + args: ['robot-name', '1234', mockV2ErrorResponse, { requestId: 'abc' }], + expected: { + type: 'sessions:CREATE_SESSION_COMMAND_FAILURE', + payload: { + robotName: 'robot-name', + sessionId: '1234', + error: mockV2ErrorResponse, + }, + meta: { requestId: 'abc' }, + }, + }, + ] + + SPECS.forEach(spec => { + const { name, creator, args, expected } = spec + it(name, () => expect(creator(...args)).toEqual(expected)) + }) +}) diff --git a/app/src/sessions/__tests__/reducer.test.js b/app/src/sessions/__tests__/reducer.test.js new file mode 100644 index 00000000000..55c8b32bdc7 --- /dev/null +++ b/app/src/sessions/__tests__/reducer.test.js @@ -0,0 +1,199 @@ +// @flow +import * as Fixtures from '../__fixtures__' +import { robotSessionReducer } from '../reducer' + +import type { Action } from '../../types' +import type { SessionState } from '../types' + +type ReducerSpec = {| + name: string, + state: SessionState, + action: Action, + expected: SessionState, +|} + +const SPECS: Array = [ + { + name: 'handles sessions:CREATE_SESSION_SUCCESS', + action: { + type: 'sessions:CREATE_SESSION_SUCCESS', + payload: { + robotName: 'eggplant-parm', + ...Fixtures.mockSessionResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: {}, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '1234': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + }, + { + name: 'handles sessions:CREATE_SESSION_SUCCESS with existing', + action: { + type: 'sessions:CREATE_SESSION_SUCCESS', + payload: { + robotName: 'eggplant-parm', + ...Fixtures.mockSessionResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionResponse.data.attributes, + '1234': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + }, + { + name: 'handles sessions:FETCH_SESSION_SUCCESS', + action: { + type: 'sessions:FETCH_SESSION_SUCCESS', + payload: { + robotName: 'eggplant-parm', + ...Fixtures.mockSessionResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': {}, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '1234': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + }, + { + name: 'handles sessions:FETCH_SESSION_SUCCESS with existing', + action: { + type: 'sessions:FETCH_SESSION_SUCCESS', + payload: { + robotName: 'eggplant-parm', + ...Fixtures.mockSessionResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '1234': Fixtures.mockSessionResponse.data.attributes, + '4321': Fixtures.mockSessionResponse.data.attributes, + }, + }, + }, + }, + { + name: 'handles sessions:CREATE_SESSION_COMMAND_SUCCESS', + action: { + type: 'sessions:CREATE_SESSION_COMMAND_SUCCESS', + payload: { + robotName: 'eggplant-parm', + sessionId: '1234', + ...Fixtures.mockSessionCommandResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: { + '1234': Fixtures.mockSessionData, + }, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '1234': Fixtures.mockSessionCommandResponse.meta, + }, + }, + }, + }, + { + name: 'handles sessions:CREATE_SESSION_COMMAND_SUCCESS with existing', + action: { + type: 'sessions:CREATE_SESSION_COMMAND_SUCCESS', + payload: { + robotName: 'eggplant-parm', + sessionId: '1234', + ...Fixtures.mockSessionCommandResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionData, + '1234': Fixtures.mockSessionData, + }, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionData, + '1234': Fixtures.mockSessionCommandResponse.meta, + }, + }, + }, + }, + { + name: 'handles sessions:DELETE_SESSION_SUCCESS', + action: { + type: 'sessions:DELETE_SESSION_SUCCESS', + payload: { + robotName: 'eggplant-parm', + ...Fixtures.mockSessionResponse, + }, + meta: {}, + }, + state: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionData, + '1234': Fixtures.mockSessionData, + }, + }, + }, + expected: { + 'eggplant-parm': { + robotSessions: { + '4321': Fixtures.mockSessionData, + }, + }, + }, + }, +] + +describe('robotSessionReducer', () => { + SPECS.forEach(spec => { + const { name, state, action, expected } = spec + it(name, () => expect(robotSessionReducer(state, action)).toEqual(expected)) + }) +}) diff --git a/app/src/sessions/__tests__/selectors.test.js b/app/src/sessions/__tests__/selectors.test.js new file mode 100644 index 00000000000..5f9640ca349 --- /dev/null +++ b/app/src/sessions/__tests__/selectors.test.js @@ -0,0 +1,86 @@ +// @flow +import noop from 'lodash/noop' +import * as Fixtures from '../__fixtures__' +import * as Selectors from '../selectors' + +import type { State } from '../../types' + +jest.mock('../../robot/selectors') + +type SelectorSpec = {| + name: string, + selector: (State, ...Array) => mixed, + state: $Shape, + args?: Array, + before?: () => mixed, + expected: mixed, +|} + +const SPECS: Array = [ + { + name: 'getRobotSessions returns null if no sessions', + selector: Selectors.getRobotSessions, + state: { + sessions: {}, + }, + args: ['germanium-cobweb'], + expected: null, + }, + { + name: 'getRobotSessions returns session map', + selector: Selectors.getRobotSessions, + state: { + sessions: { + 'germanium-cobweb': { + robotSessions: { + '1234': Fixtures.mockSessionData, + }, + }, + }, + }, + args: ['germanium-cobweb'], + expected: { + '1234': Fixtures.mockSessionData, + }, + }, + { + name: 'getRobotSessionById returns null if not found', + selector: Selectors.getRobotSessionById, + state: { + sessions: { + 'germanium-cobweb': { + robotSessions: { + '1234': Fixtures.mockSessionData, + }, + }, + }, + }, + args: ['germanium-cobweb', '4321'], + expected: null, + }, + { + name: 'getRobotSessionById returns found session', + selector: Selectors.getRobotSessionById, + state: { + sessions: { + 'germanium-cobweb': { + robotSessions: { + '1234': Fixtures.mockSessionData, + }, + }, + }, + }, + args: ['germanium-cobweb', '1234'], + expected: Fixtures.mockSessionData, + }, +] + +describe('sessions selectors', () => { + SPECS.forEach(spec => { + const { name, selector, state, args = [], before = noop, expected } = spec + it(name, () => { + before() + expect(selector(state, ...args)).toEqual(expected) + }) + }) +}) diff --git a/app/src/sessions/actions.js b/app/src/sessions/actions.js new file mode 100644 index 00000000000..789c9014253 --- /dev/null +++ b/app/src/sessions/actions.js @@ -0,0 +1,127 @@ +// @flow + +import * as Types from './types' +import * as Constants from './constants' +import type { + RobotApiRequestMeta, + RobotApiV2ErrorResponseBody, +} from '../robot-api/types' + +export const createSession = ( + robotName: string, + sessionType: Types.SessionType +): Types.CreateSessionAction => ({ + type: Constants.CREATE_SESSION, + payload: { robotName, sessionType }, + meta: {}, +}) + +export const createSessionSuccess = ( + robotName: string, + body: Types.SessionResponse, + meta: RobotApiRequestMeta +): Types.CreateSessionSuccessAction => ({ + type: Constants.CREATE_SESSION_SUCCESS, + payload: { robotName, ...body }, + meta: meta, +}) + +export const createSessionFailure = ( + robotName: string, + error: RobotApiV2ErrorResponseBody, + meta: RobotApiRequestMeta +): Types.CreateSessionFailureAction => ({ + type: Constants.CREATE_SESSION_FAILURE, + payload: { robotName, error }, + meta: meta, +}) + +export const deleteSession = ( + robotName: string, + sessionId: string +): Types.DeleteSessionAction => ({ + type: Constants.DELETE_SESSION, + payload: { robotName, sessionId }, + meta: {}, +}) + +export const deleteSessionSuccess = ( + robotName: string, + body: Types.SessionResponse, + meta: RobotApiRequestMeta +): Types.DeleteSessionSuccessAction => ({ + type: Constants.DELETE_SESSION_SUCCESS, + payload: { robotName, ...body }, + meta: meta, +}) + +export const deleteSessionFailure = ( + robotName: string, + error: RobotApiV2ErrorResponseBody, + meta: RobotApiRequestMeta +): Types.DeleteSessionFailureAction => ({ + type: Constants.DELETE_SESSION_FAILURE, + payload: { robotName, error }, + meta: meta, +}) + +export const fetchSession = ( + robotName: string, + sessionId: string +): Types.FetchSessionAction => ({ + type: Constants.FETCH_SESSION, + payload: { robotName, sessionId }, + meta: {}, +}) + +export const fetchSessionSuccess = ( + robotName: string, + body: Types.SessionResponse, + meta: RobotApiRequestMeta +): Types.FetchSessionSuccessAction => ({ + type: Constants.FETCH_SESSION_SUCCESS, + payload: { robotName, ...body }, + meta: meta, +}) + +export const fetchSessionFailure = ( + robotName: string, + error: RobotApiV2ErrorResponseBody, + meta: RobotApiRequestMeta +): Types.FetchSessionFailureAction => ({ + type: Constants.FETCH_SESSION_FAILURE, + payload: { robotName, error }, + meta: meta, +}) + +export const createSessionCommand = ( + robotName: string, + sessionId: string, + command: Types.SessionCommand +): Types.CreateSessionCommandAction => ({ + type: Constants.CREATE_SESSION_COMMAND, + payload: { robotName, sessionId, command }, + meta: {}, +}) + +export const createSessionCommandSuccess = ( + robotName: string, + sessionId: string, + body: Types.SessionCommandResponse, + meta: RobotApiRequestMeta +): Types.CreateSessionCommandSuccessAction => ({ + type: Constants.CREATE_SESSION_COMMAND_SUCCESS, + payload: { robotName, sessionId, ...body }, + meta: meta, +}) + +export const createSessionCommandFailure = ( + robotName: string, + sessionId: string, + error: RobotApiV2ErrorResponseBody, + meta: RobotApiRequestMeta +): Types.CreateSessionCommandFailureAction => ({ + type: Constants.CREATE_SESSION_COMMAND_FAILURE, + payload: { robotName, sessionId, error }, + meta: meta, +}) diff --git a/app/src/sessions/constants.js b/app/src/sessions/constants.js new file mode 100644 index 00000000000..2ac596405ab --- /dev/null +++ b/app/src/sessions/constants.js @@ -0,0 +1,40 @@ +// @flow + +export const SESSIONS_PATH: '/sessions' = '/sessions' + +export const SESSIONS_COMMANDS_PATH_EXTENSION: 'commands' = 'commands' + +export const CREATE_SESSION: 'sessions:CREATE_SESSION' = + 'sessions:CREATE_SESSION' + +export const CREATE_SESSION_SUCCESS: 'sessions:CREATE_SESSION_SUCCESS' = + 'sessions:CREATE_SESSION_SUCCESS' + +export const CREATE_SESSION_FAILURE: 'sessions:CREATE_SESSION_FAILURE' = + 'sessions:CREATE_SESSION_FAILURE' + +export const DELETE_SESSION: 'sessions:DELETE_SESSION' = + 'sessions:DELETE_SESSION' + +export const DELETE_SESSION_SUCCESS: 'sessions:DELETE_SESSION_SUCCESS' = + 'sessions:DELETE_SESSION_SUCCESS' + +export const DELETE_SESSION_FAILURE: 'sessions:DELETE_SESSION_FAILURE' = + 'sessions:DELETE_SESSION_FAILURE' + +export const FETCH_SESSION: 'sessions:FETCH_SESSION' = 'sessions:FETCH_SESSION' + +export const FETCH_SESSION_SUCCESS: 'sessions:FETCH_SESSION_SUCCESS' = + 'sessions:FETCH_SESSION_SUCCESS' + +export const FETCH_SESSION_FAILURE: 'sessions:FETCH_SESSION_FAILURE' = + 'sessions:FETCH_SESSION_FAILURE' + +export const CREATE_SESSION_COMMAND: 'sessions:CREATE_SESSION_COMMAND' = + 'sessions:CREATE_SESSION_COMMAND' + +export const CREATE_SESSION_COMMAND_SUCCESS: 'sessions:CREATE_SESSION_COMMAND_SUCCESS' = + 'sessions:CREATE_SESSION_COMMAND_SUCCESS' + +export const CREATE_SESSION_COMMAND_FAILURE: 'sessions:CREATE_SESSION_COMMAND_FAILURE' = + 'sessions:CREATE_SESSION_COMMAND_FAILURE' diff --git a/app/src/sessions/epic/__tests__/createSessionCommandEpic.test.js b/app/src/sessions/epic/__tests__/createSessionCommandEpic.test.js new file mode 100644 index 00000000000..15cda7e322d --- /dev/null +++ b/app/src/sessions/epic/__tests__/createSessionCommandEpic.test.js @@ -0,0 +1,96 @@ +// @flow +import { setupEpicTestMocks, runEpicTest } from '../../../robot-api/__utils__' + +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import { sessionsEpic } from '..' + +const makeTriggerAction = robotName => + Actions.createSessionCommand(robotName, '1234', Fixtures.mockSessionCommand) + +describe('createSessionCommandEpic', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + const expectedRequest = { + method: 'POST', + path: '/sessions/1234/commands', + body: { + data: { + type: 'Command', + attributes: { + command: 'dosomething', + data: { + someData: 32, + }, + }, + }, + }, + } + + it('calls POST /sessions/1234/commands', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockSessionCommandsSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mocks.fetchRobotApi).toHaveBeenCalledWith( + mocks.robot, + expectedRequest + ) + }) + }) + + it('maps successful response to CREATE_SESSION_COMMAND_SUCCESS', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockSessionCommandsSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.createSessionCommandSuccess( + mocks.robot.name, + mocks.action.payload.sessionId, + Fixtures.mockSessionCommandsSuccess.body, + { ...mocks.meta, response: Fixtures.mockSessionCommandsSuccessMeta } + ), + }) + }) + }) + + it('maps failed response to CREATE_SESSION_COMMAND_FAILURE', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockSessionCommandsFailure + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('a-a', { a: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.createSessionCommandFailure( + mocks.robot.name, + mocks.action.payload.sessionId, + { errors: [{ status: 'went bad' }] }, + { ...mocks.meta, response: Fixtures.mockSessionCommandsFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/sessions/epic/__tests__/createSessionEpic.test.js b/app/src/sessions/epic/__tests__/createSessionEpic.test.js new file mode 100644 index 00000000000..a4fb4cb1cb8 --- /dev/null +++ b/app/src/sessions/epic/__tests__/createSessionEpic.test.js @@ -0,0 +1,91 @@ +// @flow +import { setupEpicTestMocks, runEpicTest } from '../../../robot-api/__utils__' + +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import { sessionsEpic } from '..' + +const makeTriggerAction = robotName => + Actions.createSession(robotName, 'calibrationCheck') + +describe('createSessionEpic', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + const expectedRequest = { + method: 'POST', + path: '/sessions', + body: { + data: { + type: 'Session', + attributes: { + sessionType: 'calibrationCheck', + }, + }, + }, + } + + it('calls POST /sessions', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockCreateSessionSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('a-a', { a: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mocks.fetchRobotApi).toHaveBeenCalledWith( + mocks.robot, + expectedRequest + ) + }) + }) + + it('maps successful response to CREATE_SESSION_SUCCESS', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockCreateSessionSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.createSessionSuccess( + mocks.robot.name, + Fixtures.mockCreateSessionSuccess.body, + { ...mocks.meta, response: Fixtures.mockCreateSessionSuccessMeta } + ), + }) + }) + }) + + it('maps failed response to CREATE_SESSION_FAILURE', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockCreateSessionFailure + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.createSessionFailure( + mocks.robot.name, + { errors: [{ status: 'went bad' }] }, + { ...mocks.meta, response: Fixtures.mockCreateSessionFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/sessions/epic/__tests__/deleteSessionEpic.test.js b/app/src/sessions/epic/__tests__/deleteSessionEpic.test.js new file mode 100644 index 00000000000..33715b772ef --- /dev/null +++ b/app/src/sessions/epic/__tests__/deleteSessionEpic.test.js @@ -0,0 +1,82 @@ +// @flow +import { setupEpicTestMocks, runEpicTest } from '../../../robot-api/__utils__' + +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import { sessionsEpic } from '..' + +const makeTriggerAction = robotName => Actions.deleteSession(robotName, '1234') + +describe('deleteSessionEpic', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + const expectedRequest = { + method: 'DELETE', + path: '/sessions/1234', + } + + it('calls DELETE /sessions/1234', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockDeleteSessionSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mocks.fetchRobotApi).toHaveBeenCalledWith( + mocks.robot, + expectedRequest + ) + }) + }) + + it('maps successful response to DELETE_SESSION_SUCCESS', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockDeleteSessionSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.deleteSessionSuccess( + mocks.robot.name, + Fixtures.mockDeleteSessionSuccess.body, + { ...mocks.meta, response: Fixtures.mockDeleteSessionSuccessMeta } + ), + }) + }) + }) + + it('maps failed response to DELETE_SESSION_FAILURE', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockDeleteSessionFailure + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.deleteSessionFailure( + mocks.robot.name, + { errors: [{ status: 'went bad' }] }, + { ...mocks.meta, response: Fixtures.mockDeleteSessionFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/sessions/epic/__tests__/fetchSessionEpic.test.js b/app/src/sessions/epic/__tests__/fetchSessionEpic.test.js new file mode 100644 index 00000000000..862a157b2ed --- /dev/null +++ b/app/src/sessions/epic/__tests__/fetchSessionEpic.test.js @@ -0,0 +1,83 @@ +// @flow +import { setupEpicTestMocks, runEpicTest } from '../../../robot-api/__utils__' + +import * as Fixtures from '../../__fixtures__' +import * as Actions from '../../actions' +import { sessionsEpic } from '..' +import { mockRobot } from '../../../robot-api/__fixtures__' + +const makeTriggerAction = robotName => Actions.fetchSession(robotName, '1234') + +describe('fetchSessionEpic', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + const expectedRequest = { + method: 'GET', + path: '/sessions/1234', + } + + it('calls GET /sessions/1234', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockFetchSessionSuccess + ) + + runEpicTest(mocks, ({ hot, cold, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$) + flush() + + expect(mocks.fetchRobotApi).toHaveBeenCalledWith( + mocks.robot, + expectedRequest + ) + }) + }) + + it('maps successful response to FETCH_SESSION_SUCCESS', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockFetchSessionSuccess + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.fetchSessionSuccess( + mockRobot.name, + Fixtures.mockFetchSessionSuccess.body, + { ...mocks.meta, response: Fixtures.mockFetchSessionSuccessMeta } + ), + }) + }) + }) + + it('maps failed response to FETCH_SESSION_FAILURE', () => { + const mocks = setupEpicTestMocks( + makeTriggerAction, + Fixtures.mockFetchSessionFailure + ) + + runEpicTest(mocks, ({ hot, expectObservable, flush }) => { + const action$ = hot('--a', { a: mocks.action }) + const state$ = hot('s-s', { s: mocks.state }) + const output$ = sessionsEpic(action$, state$) + + expectObservable(output$).toBe('--a', { + a: Actions.fetchSessionFailure( + mocks.robot.name, + { errors: [{ status: 'went bad' }] }, + { ...mocks.meta, response: Fixtures.mockFetchSessionFailureMeta } + ), + }) + }) + }) +}) diff --git a/app/src/sessions/epic/createSessionCommandEpic.js b/app/src/sessions/epic/createSessionCommandEpic.js new file mode 100644 index 00000000000..062a0abce51 --- /dev/null +++ b/app/src/sessions/epic/createSessionCommandEpic.js @@ -0,0 +1,65 @@ +// @flow +import { ofType } from 'redux-observable' + +import { POST } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { Epic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { CreateSessionCommandAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = action => ({ + method: POST, + path: `${Constants.SESSIONS_PATH}/${action.payload.sessionId}/${Constants.SESSIONS_COMMANDS_PATH_EXTENSION}`, + body: { + data: { + type: 'Command', + attributes: { + command: action.payload.command.command, + data: action.payload.command.data, + }, + }, + }, +}) + +const mapResponseToAction: ResponseToActionMapper = ( + response, + originalAction +) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + + return response.ok + ? Actions.createSessionCommandSuccess( + host.name, + originalAction.payload.sessionId, + body, + meta + ) + : Actions.createSessionCommandFailure( + host.name, + originalAction.payload.sessionId, + body, + meta + ) +} + +export const createSessionCommandEpic: Epic = (action$, state$) => { + return action$.pipe( + ofType(Constants.CREATE_SESSION_COMMAND), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/sessions/epic/createSessionEpic.js b/app/src/sessions/epic/createSessionEpic.js new file mode 100644 index 00000000000..2d730fba434 --- /dev/null +++ b/app/src/sessions/epic/createSessionEpic.js @@ -0,0 +1,53 @@ +// @flow +import { ofType } from 'redux-observable' + +import { POST } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { Epic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { CreateSessionAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = action => ({ + method: POST, + path: Constants.SESSIONS_PATH, + body: { + data: { + type: 'Session', + attributes: { + sessionType: action.payload.sessionType, + }, + }, + }, +}) + +const mapResponseToAction: ResponseToActionMapper = ( + response, + originalAction +) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + return response.ok + ? Actions.createSessionSuccess(host.name, body, meta) + : Actions.createSessionFailure(host.name, body, meta) +} + +export const createSessionEpic: Epic = (action$, state$) => { + return action$.pipe( + ofType(Constants.CREATE_SESSION), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/sessions/epic/deleteSessionEpic.js b/app/src/sessions/epic/deleteSessionEpic.js new file mode 100644 index 00000000000..370bf78cadb --- /dev/null +++ b/app/src/sessions/epic/deleteSessionEpic.js @@ -0,0 +1,46 @@ +// @flow +import { ofType } from 'redux-observable' + +import { DELETE } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { Epic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { DeleteSessionAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = action => ({ + method: DELETE, + path: `${Constants.SESSIONS_PATH}/${action.payload.sessionId}`, +}) + +const mapResponseToAction: ResponseToActionMapper = ( + response, + originalAction +) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + + return response.ok + ? Actions.deleteSessionSuccess(host.name, body, meta) + : Actions.deleteSessionFailure(host.name, body, meta) +} + +export const deleteSessionEpic: Epic = (action$, state$) => { + return action$.pipe( + ofType(Constants.DELETE_SESSION), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/sessions/epic/fetchSessionEpic.js b/app/src/sessions/epic/fetchSessionEpic.js new file mode 100644 index 00000000000..a8da6e7807e --- /dev/null +++ b/app/src/sessions/epic/fetchSessionEpic.js @@ -0,0 +1,46 @@ +// @flow +import { ofType } from 'redux-observable' + +import { GET } from '../../robot-api/constants' +import { mapToRobotApiRequest } from '../../robot-api/operators' + +import * as Actions from '../actions' +import * as Constants from '../constants' + +import type { Epic } from '../../types' + +import type { + ActionToRequestMapper, + ResponseToActionMapper, +} from '../../robot-api/operators' + +import type { FetchSessionAction } from '../types' + +const mapActionToRequest: ActionToRequestMapper = action => ({ + method: GET, + path: `${Constants.SESSIONS_PATH}/${action.payload.sessionId}`, +}) + +const mapResponseToAction: ResponseToActionMapper = ( + response, + originalAction +) => { + const { host, body, ...responseMeta } = response + const meta = { ...originalAction.meta, response: responseMeta } + + return response.ok + ? Actions.fetchSessionSuccess(host.name, body, meta) + : Actions.fetchSessionFailure(host.name, body, meta) +} + +export const fetchSessionEpic: Epic = (action$, state$) => { + return action$.pipe( + ofType(Constants.FETCH_SESSION), + mapToRobotApiRequest( + state$, + a => a.payload.robotName, + mapActionToRequest, + mapResponseToAction + ) + ) +} diff --git a/app/src/sessions/epic/index.js b/app/src/sessions/epic/index.js new file mode 100644 index 00000000000..03e256d7242 --- /dev/null +++ b/app/src/sessions/epic/index.js @@ -0,0 +1,15 @@ +// @flow +import { combineEpics } from 'redux-observable' +import { createSessionEpic } from './createSessionEpic' +import { fetchSessionEpic } from './fetchSessionEpic' +import { createSessionCommandEpic } from './createSessionCommandEpic' +import { deleteSessionEpic } from './deleteSessionEpic' + +import type { Epic } from '../../types' + +export const sessionsEpic: Epic = combineEpics( + createSessionEpic, + fetchSessionEpic, + createSessionCommandEpic, + deleteSessionEpic +) diff --git a/app/src/sessions/index.js b/app/src/sessions/index.js new file mode 100644 index 00000000000..4532b8822c0 --- /dev/null +++ b/app/src/sessions/index.js @@ -0,0 +1,5 @@ +// @flow +// sessions constants, actions, and selectors +export * from './actions' +export * from './constants' +export * from './selectors' diff --git a/app/src/sessions/reducer.js b/app/src/sessions/reducer.js new file mode 100644 index 00000000000..6456420f7a5 --- /dev/null +++ b/app/src/sessions/reducer.js @@ -0,0 +1,72 @@ +// @flow +import omit from 'lodash/omit' + +import * as Constants from './constants' + +import type { Action } from '../types' + +import type { SessionState, PerRobotSessionState } from './types' + +const INITIAL_STATE: SessionState = {} + +const INITIAL_PER_ROBOT_STATE: PerRobotSessionState = { + robotSessions: null, +} + +export function robotSessionReducer( + state: SessionState = INITIAL_STATE, + action: Action +): SessionState { + switch (action.type) { + case Constants.CREATE_SESSION_SUCCESS: + case Constants.FETCH_SESSION_SUCCESS: { + const { robotName, ...sessionState } = action.payload + const robotState = state[robotName] || INITIAL_PER_ROBOT_STATE + return { + ...state, + [robotName]: { + ...robotState, + robotSessions: { + ...robotState.robotSessions, + [sessionState.data.id]: sessionState.data.attributes, + }, + }, + } + } + + case Constants.CREATE_SESSION_COMMAND_SUCCESS: { + const { robotName, sessionId, ...sessionState } = action.payload + const robotState = state[robotName] || INITIAL_PER_ROBOT_STATE + + if (!sessionId) return state + + return { + ...state, + [robotName]: { + ...robotState, + robotSessions: { + ...robotState.robotSessions, + [sessionId]: sessionState.meta, + }, + }, + } + } + + case Constants.DELETE_SESSION_SUCCESS: { + const { robotName, ...sessionState } = action.payload + const robotState = state[robotName] || INITIAL_PER_ROBOT_STATE + + return { + ...state, + [robotName]: { + ...robotState, + robotSessions: { + ...omit(robotState.robotSessions, sessionState.data.id), + }, + }, + } + } + } + + return state +} diff --git a/app/src/sessions/selectors.js b/app/src/sessions/selectors.js new file mode 100644 index 00000000000..c304c31e647 --- /dev/null +++ b/app/src/sessions/selectors.js @@ -0,0 +1,17 @@ +// @flow +import type { State } from '../types' +import * as Types from './types' + +export const getRobotSessions: ( + state: State, + robotName: string +) => Types.SessionsById | null = (state, robotName) => + state.sessions[robotName]?.robotSessions ?? null + +export const getRobotSessionById: ( + state: State, + robotName: string, + sessionId: string +) => Types.Session | null = (state, robotName, sessionId) => { + return (getRobotSessions(state, robotName) || {})[sessionId] ?? null +} diff --git a/app/src/sessions/types.js b/app/src/sessions/types.js new file mode 100644 index 00000000000..e238fd0b658 --- /dev/null +++ b/app/src/sessions/types.js @@ -0,0 +1,180 @@ +// @flow + +import typeof { + CREATE_SESSION, + CREATE_SESSION_SUCCESS, + CREATE_SESSION_FAILURE, + DELETE_SESSION, + DELETE_SESSION_SUCCESS, + DELETE_SESSION_FAILURE, + FETCH_SESSION, + FETCH_SESSION_SUCCESS, + FETCH_SESSION_FAILURE, + CREATE_SESSION_COMMAND, + CREATE_SESSION_COMMAND_SUCCESS, + CREATE_SESSION_COMMAND_FAILURE, +} from './constants' + +import type { + RobotApiRequestMeta, + RobotApiV2ResponseBody, + RobotApiV2ErrorResponseBody, +} from '../robot-api/types' + +// The available session types +export type SessionType = 'calibrationCheck' + +export type SessionCommandRequest = {| + command: string, + // TODO(al, 2020-05-11): data should be properly typed with all + // known command types + data: { ... }, +|} + +export type Session = {| + sessionType: SessionType, + // TODO(al, 2020-05-11): details should be properly typed with all + // known session response types + details: { ... }, +|} + +export type SessionCommand = {| + command: string, + // TODO(al, 2020-05-11): data should be properly typed with all + // known command types + data: { ... }, + status?: string, +|} + +export type SessionResponseModel = {| + id: string, + type: 'Session', + attributes: Session, +|} + +export type SessionCommandResponseModel = {| + id: string, + type: 'SessionCommand', + attributes: SessionCommand, +|} + +export type SessionResponse = RobotApiV2ResponseBody + +export type SessionCommandResponse = RobotApiV2ResponseBody< + SessionCommandResponseModel, + Session +> + +export type CreateSessionAction = {| + type: CREATE_SESSION, + payload: {| robotName: string, sessionType: SessionType |}, + meta: RobotApiRequestMeta, +|} + +export type CreateSessionSuccessAction = {| + type: CREATE_SESSION_SUCCESS, + payload: {| robotName: string, ...SessionResponse |}, + meta: RobotApiRequestMeta, +|} + +export type CreateSessionFailureAction = {| + type: CREATE_SESSION_FAILURE, + payload: {| robotName: string, error: RobotApiV2ErrorResponseBody |}, + meta: RobotApiRequestMeta, +|} + +export type DeleteSessionAction = {| + type: DELETE_SESSION, + payload: {| robotName: string, sessionId: string |}, + meta: RobotApiRequestMeta, +|} + +export type DeleteSessionSuccessAction = {| + type: DELETE_SESSION_SUCCESS, + payload: {| robotName: string, ...SessionResponse |}, + meta: RobotApiRequestMeta, +|} + +export type DeleteSessionFailureAction = {| + type: DELETE_SESSION_FAILURE, + payload: {| robotName: string, error: RobotApiV2ErrorResponseBody |}, + meta: RobotApiRequestMeta, +|} + +export type FetchSessionAction = {| + type: FETCH_SESSION, + payload: {| robotName: string, sessionId: string |}, + meta: RobotApiRequestMeta, +|} + +export type FetchSessionSuccessAction = {| + type: FETCH_SESSION_SUCCESS, + payload: {| robotName: string, ...SessionResponse |}, + meta: RobotApiRequestMeta, +|} + +export type FetchSessionFailureAction = {| + type: FETCH_SESSION_FAILURE, + payload: {| robotName: string, error: RobotApiV2ErrorResponseBody |}, + meta: RobotApiRequestMeta, +|} + +export type CreateSessionCommandAction = {| + type: CREATE_SESSION_COMMAND, + payload: {| + robotName: string, + sessionId: string, + command: SessionCommand, + |}, + meta: RobotApiRequestMeta, +|} + +export type CreateSessionCommandSuccessAction = {| + type: CREATE_SESSION_COMMAND_SUCCESS, + payload: {| + robotName: string, + sessionId: string, + ...SessionCommandResponse, + |}, + meta: RobotApiRequestMeta, +|} + +export type CreateSessionCommandFailureAction = {| + type: CREATE_SESSION_COMMAND_FAILURE, + payload: {| + robotName: string, + sessionId: string, + error: RobotApiV2ErrorResponseBody, + |}, + meta: RobotApiRequestMeta, +|} + +export type SessionsAction = + | CreateSessionAction + | CreateSessionSuccessAction + | CreateSessionFailureAction + | DeleteSessionAction + | DeleteSessionSuccessAction + | DeleteSessionFailureAction + | FetchSessionAction + | FetchSessionSuccessAction + | FetchSessionFailureAction + | CreateSessionCommandAction + | CreateSessionCommandSuccessAction + | CreateSessionCommandFailureAction + +export type SessionsById = $Shape<{| + [id: string]: Session, +|}> + +export type PerRobotSessionState = $Shape< + $ReadOnly<{| + robotSessions: SessionsById | null, + |}> +> + +export type SessionState = $Shape< + $ReadOnly<{| + [robotName: string]: void | PerRobotSessionState, + |}> +> diff --git a/app/src/types.js b/app/src/types.js index eadc8e5f827..3b00a5d5e47 100644 --- a/app/src/types.js +++ b/app/src/types.js @@ -39,6 +39,8 @@ import type { SystemInfoState, SystemInfoAction } from './system-info/types' import type { AlertsState, AlertsAction } from './alerts/types' +import type { SessionState, SessionsAction } from './sessions/types' + export type State = $ReadOnly<{| robot: RobotState, superDeprecatedRobotApi: SuperDeprecatedRobotApiState, @@ -58,6 +60,7 @@ export type State = $ReadOnly<{| shell: ShellState, systemInfo: SystemInfoState, alerts: AlertsState, + sessions: SessionState, router: RouterState, |}> @@ -81,6 +84,7 @@ export type Action = | NetworkingAction | SystemInfoAction | AlertsAction + | SessionsAction export type GetState = () => State diff --git a/robot-server/robot_server/service/models/session.py b/robot-server/robot_server/service/models/session.py index 21ffb4d06a5..34b503b49e3 100644 --- a/robot-server/robot_server/service/models/session.py +++ b/robot-server/robot_server/service/models/session.py @@ -49,16 +49,16 @@ def model(self): class BasicSession(BaseModel): """Minimal session description""" - session_type: calibration_models.SessionType =\ - Field(..., description="The type of the session") + sessionType: calibration_models.SessionType =\ + Field(..., + description="The type of the session") class Session(BasicSession): """Full description of session""" - session_id: str =\ - Field(..., description="Unique identifier of the session") details: SessionDetails =\ - Field(..., description="Detailed session specific status") + Field(..., + description="Detailed session specific status") class SessionCommand(BaseModel): diff --git a/robot-server/robot_server/service/routers/session.py b/robot-server/robot_server/service/routers/session.py index 7a812a90da9..acfe75c0933 100644 --- a/robot-server/robot_server/service/routers/session.py +++ b/robot-server/robot_server/service/routers/session.py @@ -53,7 +53,7 @@ async def create_session_handler( hardware=Depends(get_hardware)) \ -> session.SessionResponse: """Create a session""" - session_type = create_request.data.attributes.session_type + session_type = create_request.data.attributes.sessionType # TODO We use type as ID while we only support one session type. session_id = session_type.value @@ -75,8 +75,7 @@ async def create_session_handler( return session.SessionResponse( data=ResponseDataModel.create( attributes=session.Session( - session_id=session_id, - session_type=session_type, + sessionType=session_type, details=create_session_details(new_session)), resource_id=session_id), links=get_valid_session_links(session_id, router) @@ -116,9 +115,9 @@ async def delete_session_handler( return session.SessionResponse( data=ResponseDataModel.create( attributes=session.Session( - session_id=session_id, + sessionId=session_id, # TODO support other session types - session_type=models.SessionType.check, + sessionType=models.SessionType.calibration_check, details=create_session_details(session_obj)), resource_id=session_id), links={ @@ -144,8 +143,7 @@ async def get_session_handler( data=ResponseDataModel.create( # TODO use a proper session id rather than the type attributes=session.Session( - session_id=session_id, - session_type=models.SessionType(session_id), + sessionType=models.SessionType(session_id), details=create_session_details(session_obj)), resource_id=session_id), links=get_valid_session_links(session_id, router) @@ -165,9 +163,9 @@ async def get_sessions_handler( sessions = ( session.Session( - session_id=session_id, + sessionId=session_id, # TODO use a proper session id rather than the type - session_type=models.SessionType(session_id), + sessionType=models.SessionType(session_id), details=create_session_details(session_obj)) # TODO type_filter for (session_id, session_obj) in session_manager.sessions.items() @@ -177,7 +175,7 @@ async def get_sessions_handler( data=[ResponseDataModel.create( attributes=session, # TODO use a proper session id rather than the type - resource_id=session.session_type) for session in sessions + resource_id=session.sessionType) for session in sessions ] ) @@ -223,9 +221,8 @@ async def session_command_create_handler( resource_id=str(uuid4()) ), meta=session.Session(details=create_session_details(session_obj), - session_id=session_id, # TODO Get type from session - session_type=models.SessionType.check), + sessionType=models.SessionType.calibration_check), links=get_valid_session_links(session_id, router) ) diff --git a/robot-server/tests/service/routers/test_session.py b/robot-server/tests/service/routers/test_session.py index bc0c993cf11..36ac78820f3 100644 --- a/robot-server/tests/service/routers/test_session.py +++ b/robot-server/tests/service/routers/test_session.py @@ -25,11 +25,10 @@ def session_details(): 'instruments': {}, 'labware': [], }, - 'session_type': 'check', - 'session_id': 'check', + 'sessionType': 'calibrationCheck', }, 'type': 'Session', - 'id': 'check' + 'id': 'calibrationCheck' } return sess_dict @@ -109,12 +108,12 @@ async def mock_build(hardware): @pytest.fixture def session_manager_with_session(mock_cal_session): manager = get_session_manager() - manager.sessions[SessionType.check] = mock_cal_session + manager.sessions[SessionType.calibration_check] = mock_cal_session yield mock_cal_session - if SessionType.check in manager.sessions: - del manager.sessions[SessionType.check] + if SessionType.calibration_check in manager.sessions: + del manager.sessions[SessionType.calibration_check] @pytest.fixture @@ -154,15 +153,15 @@ def test_create_session_already_present(api_client, "data": { "type": "Session", "attributes": { - "session_type": "check" + "sessionType": "calibrationCheck" } } }) assert response.json() == { 'errors': [{ - 'detail': "A session with id 'check' already exists. " + 'detail': "A session with id 'calibrationCheck' already exists. " "Please delete to proceed.", - 'links': {'DELETE': '/sessions/check'}, + 'links': {'DELETE': '/sessions/calibrationCheck'}, 'status': '409', 'title': 'Conflict' }] @@ -181,14 +180,14 @@ async def raiser(hardware): "data": { "type": "Session", "attributes": { - "session_type": "check" + "sessionType": "calibrationCheck" } } }) assert response.json() == { 'errors': [{ - 'detail': "Failed to create session of type 'check': Please " - "attach pipettes before proceeding.", + 'detail': "Failed to create session of type 'calibrationCheck': " + "Please attach pipettes before proceeding.", 'status': '400', 'title': 'Creation Failed'} ]} @@ -201,7 +200,7 @@ def test_create_session(api_client, patch_build_session, "data": { "type": "Session", "attributes": { - "session_type": "check" + "sessionType": "calibrationCheck" } } }) @@ -210,13 +209,13 @@ def test_create_session(api_client, patch_build_session, 'data': session_hardware_info, 'links': { 'POST': { - 'href': '/sessions/check/commands', + 'href': '/sessions/calibrationCheck/commands', }, 'GET': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, 'DELETE': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, } } @@ -226,10 +225,10 @@ def test_create_session(api_client, patch_build_session, def test_delete_session_not_found(api_client): - response = api_client.delete("/sessions/check") + response = api_client.delete("/sessions/calibrationCheck") assert response.json() == { 'errors': [{ - 'detail': "Cannot find session with id 'check'.", + 'detail': "Cannot find session with id 'calibrationCheck'.", 'links': {'POST': '/sessions'}, 'status': '404', 'title': 'No session' @@ -241,7 +240,7 @@ def test_delete_session_not_found(api_client): def test_delete_session(api_client, session_manager_with_session, mock_cal_session, session_hardware_info): - response = api_client.delete("/sessions/check") + response = api_client.delete("/sessions/calibrationCheck") mock_cal_session.delete_session.assert_called_once() assert response.json() == { 'data': session_hardware_info, @@ -269,18 +268,18 @@ def test_get_session_not_found(api_client): def test_get_session(api_client, session_manager_with_session, session_hardware_info): - response = api_client.get("/sessions/check") + response = api_client.get("/sessions/calibrationCheck") assert response.json() == { 'data': session_hardware_info, 'links': { 'POST': { - 'href': '/sessions/check/commands', + 'href': '/sessions/calibrationCheck/commands', }, 'GET': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, 'DELETE': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, } } @@ -338,7 +337,7 @@ def test_session_command_create(api_client, mock_cal_session, session_hardware_info): response = api_client.post( - "/sessions/check/commands", + "/sessions/calibrationCheck/commands", json=command("jog", JogPosition(vector=[1, 2, 3]))) @@ -359,13 +358,13 @@ def test_session_command_create(api_client, 'meta': session_hardware_info['attributes'], 'links': { 'POST': { - 'href': '/sessions/check/commands', + 'href': '/sessions/calibrationCheck/commands', }, 'GET': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, 'DELETE': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, } } @@ -377,7 +376,7 @@ def test_session_command_create_no_body(api_client, mock_cal_session, session_hardware_info): response = api_client.post( - "/sessions/check/commands", + "/sessions/calibrationCheck/commands", json=command("loadLabware", None) ) @@ -397,13 +396,13 @@ def test_session_command_create_no_body(api_client, 'meta': session_hardware_info['attributes'], 'links': { 'POST': { - 'href': '/sessions/check/commands', + 'href': '/sessions/calibrationCheck/commands', }, 'GET': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, 'DELETE': { - 'href': '/sessions/check', + 'href': '/sessions/calibrationCheck', }, } } @@ -420,7 +419,7 @@ async def raiser(*args, **kwargs): mock_cal_session.trigger_transition.side_effect = raiser response = api_client.post( - "/sessions/check/commands", + "/sessions/calibrationCheck/commands", json=command("jog", JogPosition(vector=[1, 2, 3])))