diff --git a/example/index.ts b/example/index.ts index 34286ff..33c445f 100644 --- a/example/index.ts +++ b/example/index.ts @@ -159,5 +159,6 @@ run({ uiPath: '/scenarios-ui', modifyScenariosPath: '/modify', resetScenariosPath: '/reset', + cookieMode: true, }, }); diff --git a/src/Html.tsx b/src/Html.tsx index cc4e6fd..026573b 100644 --- a/src/Html.tsx +++ b/src/Html.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { UiGroups } from './types'; +import { Groups } from './types'; function Html({ updatedScenarios, @@ -9,14 +9,10 @@ function Html({ other, }: { uiPath: string; - groups: UiGroups; - other: Array<{ name: string; checked: boolean }>; + groups: Groups; + other: Array<{ id: string; selected: boolean }>; updatedScenarios?: string[]; }) { - if (uiPath[uiPath.length - 1] !== '/') { - uiPath = uiPath + '/'; - } - return ( @@ -25,7 +21,10 @@ function Html({ {updatedScenarios ? 'Updated - ' : ''}Scenarios - Data Mocks Server - +
@@ -39,39 +38,45 @@ function Html({ Refresh page

- {groups.map(group => ( -
- -

{group.name}

-
-
-
- - -
- {group.scenarios.map(scenario => ( -
+ {groups.map(group => { + const noneSelected = group.scenarios.every( + scenario => !scenario.selected, + ); + + return ( +
+ +

{group.name}

+
+
+
- +
- ))} -
-
- ))} + {group.scenarios.map(scenario => ( +
+ + +
+ ))} +
+
+ ); + })} {!other.length ? null : (
@@ -79,15 +84,15 @@ function Html({
{other.map(scenario => ( -
+
- +
))}
diff --git a/src/apis.ts b/src/apis.ts index 3318034..063c2d4 100644 --- a/src/apis.ts +++ b/src/apis.ts @@ -1,77 +1,188 @@ -import { RequestHandler, Response, Request } from 'express'; -import { ScenarioMap } from './types'; -import { getScenarios as utilsGetScenarios } from './utils/get-scenarios'; +import { getScenarioIdsFromCookie } from './cookies'; +import { + Context, + DefaultScenario, + GetCookie, + Result, + ScenarioMap, + SetCookie, +} from './types'; +import { getAllScenarios } from './utils/get-all-scenarios'; +import { getContextFromScenarios } from './utils/get-context-from-scenarios'; +import { updateScenariosAndContext } from './utils/update-scenarios-and-context'; -export { modifyScenarios, resetScenarios, getScenarios }; +export { resetScenarios, modifyScenarios, getScenarios }; + +function resetScenarios({ + setCookie, + setServerContext, + setServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + cookieMode, +}: { + setCookie: SetCookie; + setServerContext: (context: Context) => void; + setServerSelectedScenarioIds: (selectedScenarioIds: string[]) => void; + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; + cookieMode: boolean; +}): Result { + updateScenariosAndContext({ + updatedScenarioIds: [], + setCookie, + setServerContext, + setServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + cookieMode, + }); + + return { + status: 204, + }; +} + +function getScenarios({ + getCookie, + setCookie, + getServerSelectedScenarioIds, + cookieMode, + defaultScenario, + scenarioMap, +}: { + getCookie: GetCookie; + setCookie: SetCookie; + getServerSelectedScenarioIds: () => string[]; + cookieMode: boolean; + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; +}): Result { + const allScenarios = getAllScenarios( + scenarioMap, + getSelectedScenarioIds({ + getCookie, + setCookie, + getServerSelectedScenarioIds, + cookieMode, + defaultScenario, + }), + ); + + return { + status: 200, + headers: { + 'content-type': 'application/json', + }, + response: allScenarios, + }; +} function modifyScenarios({ - scenarioNames, + updatedScenarioIds, + scenarioIds, scenarioMap, - updateScenariosAndContext, + cookieMode, + defaultScenario, + setCookie, + setServerContext, + setServerSelectedScenarioIds, }: { - scenarioNames: string[]; + updatedScenarioIds: unknown; + scenarioIds: string[]; scenarioMap: ScenarioMap; - updateScenariosAndContext: (res: Response, scenarios: string[]) => void; -}): RequestHandler { - return ({ body: { scenarios: scenariosBody } }: Request, res: Response) => { - if (!Array.isArray(scenariosBody)) { - res.status(400).json({ + cookieMode: boolean; + defaultScenario: DefaultScenario; + setCookie: SetCookie; + setServerContext: (context: Context) => void; + setServerSelectedScenarioIds: (selectedScenarioIds: string[]) => void; +}) { + if (!isStringArray(updatedScenarioIds)) { + return { + status: 400, + headers: { + 'content-type': 'application/json', + }, + response: { message: '"scenarios" must be an array of scenario names (empty array allowed)', - }); - return; - } + }, + }; + } - const scenariosByGroup: { [key: string]: number } = {}; - for (const scenario of scenariosBody) { - if (!scenarioNames.includes(scenario)) { - res.status(400).json({ + const scenariosByGroup: Record = {}; + for (const scenario of updatedScenarioIds) { + if (!scenarioIds.includes(scenario)) { + return { + status: 400, + headers: { + 'content-type': 'application/json', + }, + response: { message: `Scenario "${scenario}" does not exist`, - }); - return; - } + }, + }; + } - const scenarioMock = scenarioMap[scenario]; - if (!Array.isArray(scenarioMock) && scenarioMock.group) { - const { group } = scenarioMock; - if (scenariosByGroup[group]) { - res.status(400).json({ + const scenarioMock = scenarioMap[scenario]; + if (!Array.isArray(scenarioMock) && scenarioMock.group) { + const { group } = scenarioMock; + if (scenariosByGroup[group]) { + return { + status: 400, + headers: { + 'content-type': 'application/json', + }, + response: { message: `Scenario "${scenario}" cannot be selected, because scenario "${scenariosByGroup[group]}" from group "${group}" has already been selected`, - }); - return; - } - - scenariosByGroup[group] = scenario; + }, + }; } + + scenariosByGroup[group] = scenario; } + } - updateScenariosAndContext(res, scenariosBody); + updateScenariosAndContext({ + cookieMode, + defaultScenario, + updatedScenarioIds, + scenarioMap, + setCookie, + setServerContext, + setServerSelectedScenarioIds, + }); - res.sendStatus(204); - }; + return { status: 204 }; } -function resetScenarios({ - updateScenariosAndContext, -}: { - updateScenariosAndContext: (res: Response, scenarios: string[]) => void; -}): RequestHandler { - return (_, res: Response) => { - updateScenariosAndContext(res, []); - res.sendStatus(204); - }; +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string'); } -function getScenarios({ - scenarioMap, - getScenarioNames, +function getSelectedScenarioIds({ + getCookie, + setCookie, + getServerSelectedScenarioIds, + cookieMode, + defaultScenario, }: { - scenarioMap: ScenarioMap; - getScenarioNames: (req: Request, res: Response) => string[]; -}): RequestHandler { - return (req: Request, res: Response) => { - const data = utilsGetScenarios(scenarioMap, getScenarioNames(req, res)); + getCookie: GetCookie; + setCookie: SetCookie; + getServerSelectedScenarioIds: () => string[]; + cookieMode: boolean; + defaultScenario: DefaultScenario; +}) { + if (cookieMode) { + return getScenarioIdsFromCookie({ + getCookie, + setCookie, + defaultValue: { + context: getContextFromScenarios([defaultScenario]), + scenarios: [], + }, + }); + } - res.json(data); - }; + return getServerSelectedScenarioIds(); } diff --git a/src/cookies.ts b/src/cookies.ts index a80371e..91b42cd 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -1,94 +1,75 @@ -import { Response, Request } from 'express'; -import { Context, CookieValue } from './types'; +import { CookieValue, DefaultScenario, GetCookie, SetCookie } from './types'; +import { getContextFromScenarios } from './utils/get-context-from-scenarios'; export { - getScenariosFromCookie, - getContextFromCookie, - setContextAndScenariosCookie, + getDataMocksServerCookie, + setDataMocksServerCookie, + getScenarioIdsFromCookie, }; const CONTEXT_AND_SCENARIOS_COOKIE_NAME = 'data-mocks-server'; -function setCookie({ - res, - name, - value, +function getScenarioIdsFromCookie({ + getCookie, + setCookie, + defaultValue, }: { - res: Response; - name: string; - value: CookieValue; + getCookie: GetCookie; + setCookie: SetCookie; + defaultValue: CookieValue; }) { - res.cookie(name, JSON.stringify(value), { - encode: String, - }); + let cookieValue = defaultValue; + const cookie = getCookie(CONTEXT_AND_SCENARIOS_COOKIE_NAME); + if (cookie) { + try { + cookieValue = JSON.parse(cookie); + } catch (error) { + // Cookie value was malformed, so needs resetting + setCookie(CONTEXT_AND_SCENARIOS_COOKIE_NAME, JSON.stringify(cookieValue)); + } + } + + return cookieValue.scenarios; } -function getCookie({ - req, - res, - name, - defaultValue, +function getDataMocksServerCookie({ + getCookie, + defaultScenario, }: { - req: Request; - res: Response; - name: string; - defaultValue: CookieValue; + getCookie: GetCookie; + defaultScenario: DefaultScenario; }): CookieValue { - if (req.cookies[name]) { + const cookie = getCookie(CONTEXT_AND_SCENARIOS_COOKIE_NAME); + + if (cookie) { try { - const value = JSON.parse(req.cookies[name]); + const parsedCookie = JSON.parse(cookie); - return value; + // Check that the parsed cookie matches the shape expected + if (parsedCookie.context && Array.isArray(parsedCookie.scenarios)) { + return parsedCookie; + } else { + console.error('Cookie value does not match expected shape'); + } } catch (error) { - // Cookie value was malformed, so needs resetting - setCookie({ res, name, value: defaultValue }); + console.error('Cookie value could not be parsed'); } } - return defaultValue; -} + const defaultValue = { + scenarios: [], + context: getContextFromScenarios([defaultScenario]), + }; -function getScenariosFromCookie({ - req, - res, - defaultValue, -}: { - req: Request; - res: Response; - defaultValue: CookieValue; -}) { - return getCookie({ - req, - res, - name: CONTEXT_AND_SCENARIOS_COOKIE_NAME, - defaultValue, - }).scenarios; + return defaultValue; } -function getContextFromCookie({ - req, - res, - defaultValue, +function setDataMocksServerCookie({ + setCookie, + value, }: { - req: Request; - res: Response; - defaultValue: CookieValue; + setCookie: SetCookie; + value: CookieValue; }) { - return getCookie({ - req, - res, - name: CONTEXT_AND_SCENARIOS_COOKIE_NAME, - defaultValue, - }).context; -} - -function setContextAndScenariosCookie( - res: Response, - contextAndScenarios: { context: Context; scenarios: string[] }, -) { - setCookie({ - res, - name: CONTEXT_AND_SCENARIOS_COOKIE_NAME, - value: contextAndScenarios, - }); + setCookie(CONTEXT_AND_SCENARIOS_COOKIE_NAME, JSON.stringify(value)); } diff --git a/src/create-handler.ts b/src/create-handler.ts index 6c6605d..8da5e9b 100644 --- a/src/create-handler.ts +++ b/src/create-handler.ts @@ -1,5 +1,3 @@ -import { Response } from 'express'; - import { ResponseProps, MockResponse, @@ -22,35 +20,30 @@ function createHandler({ updateContext: UpdateContext; getContext: GetContext; }) { - return async (req: TInput, res: Response) => { - const actualResponse = - typeof response === 'function' - ? await ((response as unknown) as ResponseFunction)({ - ...req, - updateContext, - context: getContext(), - }) - : response; + return async (req: TInput) => { + const actualResponse = isResponseFunction(response) + ? await response({ + ...req, + updateContext, + context: getContext(), + }) + : response; let responseCollection: { response?: any; responseDelay: number; - responseHeaders?: Record; + responseHeaders: Record; responseCode: number; } = { responseDelay, - responseHeaders, + responseHeaders: lowerCaseKeys(responseHeaders || {}), responseCode, }; - if ( - actualResponse !== null && - typeof actualResponse === 'object' && - (actualResponse as Override).__override && - Object.keys(actualResponse).length === 1 - ) { + + if (isOverride(actualResponse)) { responseCollection = { ...responseCollection, - ...(actualResponse as Override).__override, + ...actualResponse.__override, }; } else { responseCollection.response = actualResponse; @@ -58,31 +51,48 @@ function createHandler({ await addDelay(responseCollection.responseDelay); + // Default repsonses to JSON when there's no content-type header if ( responseCollection.response !== undefined && - (!responseCollection.responseHeaders || - !responseCollection.responseHeaders['Content-Type']) + !responseCollection.responseHeaders['content-type'] ) { responseCollection.responseHeaders = { ...responseCollection.responseHeaders, - 'Content-Type': 'application/json', + 'content-type': 'application/json', }; } - if ( - responseCollection.responseHeaders && - responseCollection.responseHeaders['Content-Type'] === 'application/json' - ) { - responseCollection.response = JSON.stringify(responseCollection.response); - } - - res - .set(responseCollection.responseHeaders) - .status(responseCollection.responseCode) - .send(responseCollection.response); + return { + status: responseCollection.responseCode, + response: responseCollection.response, + headers: responseCollection.responseHeaders, + }; }; } function addDelay(responseDelay: number) { return new Promise(res => setTimeout(res, responseDelay)); } + +function isOverride( + response: TResponse | Override | undefined, +): response is Override { + return ( + response !== null && + typeof response === 'object' && + (response as Override).__override && + Object.keys(response).length === 1 + ); +} + +function isResponseFunction( + response: MockResponse | undefined, +): response is ResponseFunction { + return typeof response === 'function'; +} + +function lowerCaseKeys(obj: Record) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]), + ); +} diff --git a/src/express.ts b/src/express.ts new file mode 100644 index 0000000..0ceb4a2 --- /dev/null +++ b/src/express.ts @@ -0,0 +1,219 @@ +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import express, { Request, Response } from 'express'; +import path from 'path'; + +import { + Options, + ScenarioMap, + DefaultScenario, + Context, + InternalRequest, + Result, +} from './types'; +import { getUi, updateUi } from './ui'; +import { getContextFromScenarios } from './utils/get-context-from-scenarios'; +import { handleRequest } from './handle-request'; +import { + getScenarios as apiGetScenarios, + modifyScenarios, + resetScenarios, +} from './apis'; + +export { createExpressApp }; + +function createExpressApp({ + default: defaultScenario, + scenarios: scenarioMap = {}, + options = {}, +}: { + default: DefaultScenario; + scenarios?: ScenarioMap; + options?: Options; +}) { + const { + uiPath = '/', + modifyScenariosPath = '/modify-scenarios', + resetScenariosPath = '/reset-scenarios', + scenariosPath = '/scenarios', + cookieMode = false, + } = options; + + const scenarioIds = Object.keys(scenarioMap); + const groupNames = Object.values(scenarioMap).reduce( + (result, mock) => { + if ( + Array.isArray(mock) || + mock.group == null || + result.includes(mock.group) + ) { + return result; + } + + result.push(mock.group); + return result; + }, + [], + ); + + let serverSelectedScenarioIds: string[] = []; + let serverContext = getContextFromScenarios([defaultScenario]); + + const app = express(); + app.use(cors({ credentials: true })); + app.use(cookieParser()); + app.use(uiPath, express.static(path.join(__dirname, 'assets'))); + app.use(express.urlencoded({ extended: false })); + app.use(express.json()); + app.use(express.text({ type: 'application/graphql' })); + + app.get(uiPath, (req, res) => { + const html = getUi({ + uiPath, + scenarioMap, + cookieMode, + defaultScenario, + getCookie: expressGetCookie(req), + getServerSelectedScenarioIds, + setCookie: expressSetCookie(res), + }); + + res.send(html); + }); + + app.post( + uiPath, + ({ body: { scenarios: scenariosBody, button, ...rest } }, res) => { + const html = updateUi({ + uiPath, + groupNames, + scenarioIds, + updatedScenarioIds: scenariosBody, + buttonType: button, + scenarioMap, + cookieMode, + defaultScenario, + setCookie: expressSetCookie(res), + setServerContext, + setServerSelectedScenarioIds, + groupScenario: rest, + }); + + res.send(html); + }, + ); + + app.put( + modifyScenariosPath, + ({ body: { scenarios: updatedScenarioIds } }: Request, res: Response) => { + const result = modifyScenarios({ + cookieMode, + defaultScenario, + scenarioIds, + scenarioMap, + setCookie: expressSetCookie(res), + setServerContext, + setServerSelectedScenarioIds, + updatedScenarioIds, + }); + + expressResponse(res, result); + }, + ); + + app.put(resetScenariosPath, (_, res: Response) => { + const result = resetScenarios({ + setCookie: expressSetCookie(res), + setServerContext, + setServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + cookieMode, + }); + + expressResponse(res, result); + }); + + app.get(scenariosPath, (req: Request, res: Response) => { + const result = apiGetScenarios({ + getCookie: expressGetCookie(req), + setCookie: expressSetCookie(res), + getServerSelectedScenarioIds, + cookieMode, + defaultScenario, + scenarioMap, + }); + + expressResponse(res, result); + }); + + app.use(async (req, res) => { + const internalRequest: InternalRequest = { + body: req.body, + headers: expressCleanHeaders(req.headers || {}), + method: req.method, + path: req.path, + query: req.query || {}, + }; + + const result = await handleRequest({ + req: internalRequest, + getServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + getServerContext: () => serverContext, + setServerContext, + cookieMode, + getCookie: expressGetCookie(req), + setCookie: expressSetCookie(res), + }); + + expressResponse(res, result); + }); + + return app; + + function getServerSelectedScenarioIds() { + return serverSelectedScenarioIds; + } + + function setServerContext(context: Context) { + serverContext = context; + } + + function setServerSelectedScenarioIds(selectedScenarioIds: string[]) { + serverSelectedScenarioIds = selectedScenarioIds; + } +} + +function expressSetCookie(res: Response) { + return (cookieName: string, cookieValue: string) => { + res.cookie(cookieName, cookieValue, { encode: String }); + }; +} + +function expressGetCookie(req: Request) { + return (cookieName: string) => req.cookies[cookieName]; +} + +function expressCleanHeaders( + headers: Request['headers'], +): Record { + return Object.fromEntries( + Object.entries(headers).filter( + (keyValuePair): keyValuePair is [string, string] => + typeof keyValuePair[1] === 'string', + ), + ); +} + +function expressResponse(res: Response, { status, headers, response }: Result) { + res + .set(headers) + .status(status) + .send( + headers && headers['content-type'] === 'application/json' + ? JSON.stringify(response) + : response, + ); +} diff --git a/src/graph-ql.ts b/src/graph-ql.ts index 3d39b9b..e22df18 100644 --- a/src/graph-ql.ts +++ b/src/graph-ql.ts @@ -1,4 +1,3 @@ -import { Request, Response, NextFunction } from 'express'; import gql from 'graphql-tag'; import { createHandler } from './create-handler'; @@ -8,18 +7,17 @@ import { Mock, UpdateContext, GetContext, + InternalRequest, + Result, } from './types'; export { getGraphQlMocks, getGraphQlMock, createGraphQlRequestHandler }; -type GraphQlHandler = ( - req: { - operationType: 'query' | 'mutation'; - operationName: string; - variables: Record; - }, - res: Response, -) => boolean; +type GraphQlHandler = (req: { + operationType: 'query' | 'mutation'; + operationName: string; + variables: Record; +}) => Promise; function getGraphQlMocks(mocks: Mock[]) { const initialGraphQlMocks = mocks.filter( @@ -63,27 +61,24 @@ function createGraphQlHandler({ }): GraphQlHandler { const handler = createHandler(rest); - return ({ operationType, operationName, variables }, res) => { + return async ({ operationType, operationName, variables }) => { if ( operationType === operationTypeToCheck && operationName === operationNameToCheck ) { - handler( - { - variables, - }, - res, - ); + const result = await handler({ + variables, + }); - return true; + return result; } - return false; + return null; }; } function createInternalGraphQlRequestHandler(handlers: GraphQlHandler[]) { - return (req: Request, res: Response, next: NextFunction) => { + return async (req: InternalRequest) => { const query = req.headers['content-type'] === 'application/graphql' ? req.body @@ -93,10 +88,15 @@ function createInternalGraphQlRequestHandler(handlers: GraphQlHandler[]) { try { graphqlAst = gql(query); } catch (error) { - res.status(400).json({ - message: `query "${query}" is not a valid GraphQL query`, - }); - return; + const result = { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + response: { message: `query "${query}" is not a valid GraphQL query` }, + }; + + return result; } const operationTypesAndNames = (graphqlAst.definitions as Array<{ @@ -115,10 +115,17 @@ function createInternalGraphQlRequestHandler(handlers: GraphQlHandler[]) { !req.body.operationName && !req.query.operationName ) { - res.status(400).json({ - message: `query "${query}" is not a valid GraphQL query`, - }); - return; + const result = { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + response: { + message: `query "${query}" is not a valid GraphQL query`, + }, + }; + + return result; } const operationName: string = @@ -132,10 +139,17 @@ function createInternalGraphQlRequestHandler(handlers: GraphQlHandler[]) { ); if (!operationTypeAndName) { - res.status(400).json({ - message: `operation name "${operationName}" does not exist in GraphQL query`, - }); - return; + const result = { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + response: { + message: `operation name "${operationName}" does not exist in GraphQL query`, + }, + }; + + return result; } const operationType = operationTypeAndName.type; @@ -149,26 +163,23 @@ function createInternalGraphQlRequestHandler(handlers: GraphQlHandler[]) { variables = variables || {}; for (const handler of handlers) { - const responseHandled = handler( - { - operationType, - operationName, - variables, - }, - res, - ); + const result = await handler({ + operationType, + operationName, + variables, + }); - if (responseHandled) { - return; + if (result) { + return result; } } - next(); + return { status: 404 }; }; } -function getGraphQlMock(req: Request, graphqlMocks: GraphQlMock[]) { - return graphqlMocks.find(graphQlMock => graphQlMock.url === req.path) || null; +function getGraphQlMock(path: string, graphqlMocks: GraphQlMock[]) { + return graphqlMocks.find(graphQlMock => graphQlMock.url === path) || null; } function getQueries({ @@ -219,8 +230,8 @@ function createGraphQlRequestHandler({ graphQlMock: GraphQlMock; updateContext: UpdateContext; getContext: GetContext; -}) { - return (req: Request, res: Response, next: NextFunction) => { +}): (req: InternalRequest) => Promise { + return req => { if (req.method === 'GET') { const queries = getQueries({ graphQlMock, @@ -229,9 +240,8 @@ function createGraphQlRequestHandler({ }); const requestHandler = createInternalGraphQlRequestHandler(queries); - requestHandler(req, res, next); - return; + return requestHandler(req); } if (req.method === 'POST') { @@ -245,15 +255,14 @@ function createGraphQlRequestHandler({ updateContext, getContext, }); + const requestHandler = createInternalGraphQlRequestHandler( queries.concat(mutations), ); - requestHandler(req, res, next); - return; + return requestHandler(req); } - // req.method doesn't make sense for GraphQL - default 404 from express - next(); + return Promise.resolve({ status: 404 }); }; } diff --git a/src/handle-request.ts b/src/handle-request.ts new file mode 100644 index 0000000..e434d57 --- /dev/null +++ b/src/handle-request.ts @@ -0,0 +1,160 @@ +import { + getGraphQlMocks, + getGraphQlMock, + createGraphQlRequestHandler, +} from './graph-ql'; +import { + getHttpMocks, + getHttpMockAndParams, + createHttpRequestHandler, +} from './http'; +import { + Mock, + ScenarioMap, + DefaultScenario, + Context, + Scenario, + PartialContext, + InternalRequest, + Result, + GetCookie, + SetCookie, +} from './types'; +import { getDataMocksServerCookie, setDataMocksServerCookie } from './cookies'; + +function updateContext(context: Context, partialContext: PartialContext) { + const newContext: Context = { + ...context, + ...(typeof partialContext === 'function' + ? partialContext(context) + : partialContext), + }; + + return newContext; +} + +function mergeMocks(scenarioMap: ({ mocks: Mock[] } | Mock[])[]) { + return scenarioMap.reduce( + (result, scenarioMock) => + result.concat( + Array.isArray(scenarioMock) ? scenarioMock : scenarioMock.mocks, + ), + [], + ); +} + +function getMocksFromScenarios(scenarios: Scenario[]) { + const mocks = mergeMocks(scenarios); + const httpMocks = getHttpMocks(mocks); + const graphQlMocks = getGraphQlMocks(mocks); + + return { httpMocks, graphQlMocks }; +} + +function getScenarios({ + defaultScenario, + scenarioMap, + scenarioIds, +}: { + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; + scenarioIds: string[]; +}): Scenario[] { + return [defaultScenario].concat( + scenarioIds.map(scenarioId => scenarioMap[scenarioId]), + ); +} + +async function handleRequest({ + req, + getServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + getServerContext, + setServerContext, + getCookie, + cookieMode, + setCookie, +}: { + req: InternalRequest; + getServerSelectedScenarioIds: () => string[]; + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; + getServerContext: () => Context; + setServerContext: (context: Context) => void; + getCookie: GetCookie; + setCookie: SetCookie; + cookieMode: boolean; +}) { + const dataMocksServerCookie = getDataMocksServerCookie({ + getCookie, + defaultScenario, + }); + + const getSelectedScenarioIds = cookieMode + ? () => dataMocksServerCookie.scenarios + : getServerSelectedScenarioIds; + + const getContext = cookieMode + ? () => dataMocksServerCookie.context + : getServerContext; + + const setContext = cookieMode + ? (context: Context) => { + dataMocksServerCookie.context = context; + } + : setServerContext; + + const selectedScenarioIds = getSelectedScenarioIds(); + + const selectedScenarios = getScenarios({ + defaultScenario, + scenarioMap, + scenarioIds: selectedScenarioIds, + }); + + const { httpMocks, graphQlMocks } = getMocksFromScenarios(selectedScenarios); + + const graphQlMock = getGraphQlMock(req.path, graphQlMocks); + + // Default when nothing matches + let result: Result = { status: 404 }; + + if (graphQlMock) { + const requestHandler = createGraphQlRequestHandler({ + graphQlMock, + updateContext: localUpdateContext, + getContext, + }); + + result = await requestHandler(req); + } else { + const { httpMock, params } = getHttpMockAndParams(req, httpMocks); + if (httpMock) { + const requestHandler = createHttpRequestHandler({ + httpMock, + params, + getContext, + updateContext: localUpdateContext, + }); + + result = await requestHandler(req); + } + } + + if (cookieMode) { + setDataMocksServerCookie({ setCookie, value: dataMocksServerCookie }); + } + + return result; + + function localUpdateContext(partialContext: PartialContext) { + const newContext = updateContext(getContext(), partialContext); + + setContext(newContext); + + return newContext; + } +} + +export { handleRequest }; diff --git a/src/http.ts b/src/http.ts index a39ea9f..09f2195 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,8 +1,13 @@ import { pathToRegexp, Key } from 'path-to-regexp'; -import { Request, Response } from 'express'; import { createHandler } from './create-handler'; -import { Mock, HttpMock, UpdateContext, GetContext } from './types'; +import { + Mock, + HttpMock, + UpdateContext, + GetContext, + InternalRequest, +} from './types'; export { getHttpMocks, getHttpMockAndParams, createHttpRequestHandler }; @@ -35,21 +40,18 @@ function createHttpRequestHandler({ updateContext: UpdateContext; getContext: GetContext; }) { - return (req: Request, res: Response) => { - // Matching all routes so need to create params manually - req.params = params; - + return (req: InternalRequest) => { const handler = createHandler({ ...httpMock, getContext, updateContext, }); - handler(req, res); + return handler({ ...req, params }); }; } -function getHttpMockAndParams(req: Request, httpMocks: HttpMock[]) { +function getHttpMockAndParams(req: InternalRequest, httpMocks: HttpMock[]) { for (const httpMock of httpMocks) { if (httpMock.method !== req.method) { continue; diff --git a/src/index.ts b/src/index.ts index ff72555..6add6bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,5 @@ export { HttpMock, Scenario, } from './types'; -export { run, createExpressApp } from './run'; +export { run } from './run'; +export { createExpressApp } from './express'; diff --git a/src/run.ts b/src/run.ts index 869ecc8..513a184 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,41 +1,9 @@ -import cookieParser from 'cookie-parser'; -import cors from 'cors'; -import express, { Request, Response, NextFunction } from 'express'; -import path from 'path'; import { transform } from 'server-with-kill'; -import { - modifyScenarios, - resetScenarios, - getScenarios as apiGetScenarios, -} from './apis'; -import { - getGraphQlMocks, - getGraphQlMock, - createGraphQlRequestHandler, -} from './graph-ql'; -import { - getHttpMocks, - getHttpMockAndParams, - createHttpRequestHandler, -} from './http'; -import { - Mock, - Options, - ScenarioMap, - DefaultScenario, - Context, - Scenario, - PartialContext, -} from './types'; -import { getUi, updateUi } from './ui'; -import { - getScenariosFromCookie, - getContextFromCookie, - setContextAndScenariosCookie, -} from './cookies'; +import { createExpressApp } from './express'; +import { Options, ScenarioMap, DefaultScenario } from './types'; -export { createExpressApp, run }; +export { run }; function run({ default: defaultScenario, @@ -58,311 +26,3 @@ function run({ }), ); } - -function createExpressApp({ - default: defaultScenario, - scenarios: scenarioMap = {}, - options = {}, -}: { - default: DefaultScenario; - scenarios?: ScenarioMap; - options?: Options; -}) { - let selectedScenarioNames: string[] = []; - let currentContext = getContextFromScenarios([defaultScenario]); - const { - uiPath = '/', - modifyScenariosPath = '/modify-scenarios', - resetScenariosPath = '/reset-scenarios', - scenariosPath = '/scenarios', - cookieMode = false, - } = options; - - const app = express(); - const scenarioNames = Object.keys(scenarioMap); - const groupNames = Object.values(scenarioMap).reduce( - (result, mock) => { - if ( - Array.isArray(mock) || - mock.group == null || - result.includes(mock.group) - ) { - return result; - } - - result.push(mock.group); - return result; - }, - [], - ); - - app.use(cors({ credentials: true })); - app.use(cookieParser()); - app.use(uiPath, express.static(path.join(__dirname, 'assets'))); - app.use(express.urlencoded({ extended: false })); - app.use(express.json()); - app.use(express.text({ type: 'application/graphql' })); - - app.get( - uiPath, - getUi({ - uiPath, - scenarioMap, - getScenarioNames, - }), - ); - - app.post( - uiPath, - updateUi({ - uiPath, - groupNames, - scenarioNames, - scenarioMap, - updateScenariosAndContext, - }), - ); - - app.put( - modifyScenariosPath, - modifyScenarios({ - scenarioNames, - scenarioMap, - updateScenariosAndContext, - }), - ); - - app.put( - resetScenariosPath, - resetScenarios({ - updateScenariosAndContext, - }), - ); - - app.get( - scenariosPath, - apiGetScenarios({ - scenarioMap, - getScenarioNames, - }), - ); - - app.use( - createRequestHandler({ - getScenarioNames, - defaultScenario, - scenarioMap, - getContext: ( - req: Request, - res: Response, - selectedScenarios: Scenario[], - ) => { - if (cookieMode) { - return getContextFromCookie({ - req, - res, - defaultValue: { - scenarios: getScenarioNames(req, res), - context: getContextFromScenarios(selectedScenarios), - }, - }); - } - - return currentContext; - }, - setContext: (req: Request, res: Response, context: Context) => { - if (cookieMode) { - setContextAndScenariosCookie(res, { - scenarios: getScenarioNames(req, res), - context, - }); - } else { - currentContext = context; - } - }, - }), - ); - - return app; - - function updateScenariosAndContext( - res: Response, - updatedScenarioNames: string[], - ) { - const updatedScenarios = getScenarios({ - defaultScenario, - scenarioMap, - scenarioNames: updatedScenarioNames, - }); - const context = getContextFromScenarios(updatedScenarios); - - if (cookieMode) { - setContextAndScenariosCookie(res, { - context, - scenarios: updatedScenarioNames, - }); - - return; - } - - currentContext = context; - selectedScenarioNames = updatedScenarioNames; - - return updatedScenarioNames; - } - - function getScenarioNames(req: Request, res: Response) { - if (cookieMode) { - const defaultScenarios: string[] = []; - - return getScenariosFromCookie({ - req, - res, - defaultValue: { - context: getContextFromScenarios( - getScenarios({ - defaultScenario, - scenarioMap, - scenarioNames: defaultScenarios, - }), - ), - scenarios: defaultScenarios, - }, - }); - } - - return selectedScenarioNames; - } -} - -function updateContext(context: Context, partialContext: PartialContext) { - const newContext = { - ...context, - ...(typeof partialContext === 'function' - ? partialContext(context) - : partialContext), - }; - - return newContext; -} - -function mergeMocks(scenarioMap: ({ mocks: Mock[] } | Mock[])[]) { - return scenarioMap.reduce( - (result, scenarioMock) => - result.concat( - Array.isArray(scenarioMock) ? scenarioMock : scenarioMock.mocks, - ), - [], - ); -} - -function getMocksFromScenarios(scenarios: Scenario[]) { - const mocks = mergeMocks(scenarios); - const httpMocks = getHttpMocks(mocks); - const graphQlMocks = getGraphQlMocks(mocks); - - return { httpMocks, graphQlMocks }; -} - -function getScenarios({ - defaultScenario, - scenarioMap, - scenarioNames, -}: { - defaultScenario: DefaultScenario; - scenarioMap: ScenarioMap; - scenarioNames: string[]; -}): Scenario[] { - return [defaultScenario].concat( - scenarioNames.map(scenario => scenarioMap[scenario]), - ); -} - -function getContextFromScenarios(scenarios: Scenario[]) { - let context: Context = {}; - scenarios.forEach(mock => { - if (!Array.isArray(mock) && mock.context) { - context = { ...context, ...mock.context }; - } - }); - - return context; -} - -function createRequestHandler({ - getScenarioNames, - defaultScenario, - scenarioMap, - getContext, - setContext, -}: { - getScenarioNames: (req: Request, res: Response) => string[]; - defaultScenario: DefaultScenario; - scenarioMap: ScenarioMap; - getContext: ( - req: Request, - res: Response, - selectedScenarios: Scenario[], - ) => Context; - setContext: (req: Request, res: Response, context: Context) => void; -}) { - return (req: Request, res: Response, next: NextFunction) => { - const scenarioNames = getScenarioNames(req, res); - const selectedScenarios = getScenarios({ - defaultScenario, - scenarioMap, - scenarioNames, - }); - - const { httpMocks, graphQlMocks } = getMocksFromScenarios( - selectedScenarios, - ); - let context: Context = getContext(req, res, selectedScenarios); - - const graphQlMock = getGraphQlMock(req, graphQlMocks); - - if (graphQlMock) { - const requestHandler = createGraphQlRequestHandler({ - graphQlMock, - updateContext: localUpdateContext, - getContext: localGetContext, - }); - - requestHandler(req, res, next); - - return; - } - - const { httpMock, params } = getHttpMockAndParams(req, httpMocks); - if (httpMock) { - const requestHandler = createHttpRequestHandler({ - httpMock, - params, - getContext: localGetContext, - updateContext: localUpdateContext, - }); - - requestHandler(req, res); - - return; - } - - // Nothing matched - default 404 from express - next(); - - function localUpdateContext(partialContext: PartialContext) { - // Although "setContext" below will ensure the context is set correctly - // for the server/cookie, if response functions call "updateContext" multiple - // times, the local version of "getContext" will return the wrong value - context = updateContext(context, partialContext); - - setContext(req, res, context); - - return context; - } - - function localGetContext() { - return context; - } - }; -} diff --git a/src/types.ts b/src/types.ts index a20b3ec..ea6ad8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,9 @@ +export type Result = { + status: number; + headers?: Record; + response?: any; +}; + export type DefaultScenario = | Mock[] | { @@ -98,15 +104,6 @@ export type UpdateContext = (partialContext: PartialContext) => Context; export type GetContext = () => Context; -export type UiGroups = Array<{ - name: string; - noneChecked: boolean; - scenarios: Array<{ - name: string; - checked: boolean; - }>; -}>; - export type Groups = Array<{ name: string; scenarios: Array<{ @@ -119,3 +116,15 @@ export type CookieValue = { context: Context; scenarios: string[]; }; + +export type InternalRequest = { + method: string; + headers: Record; + query: Record; + path: string; + // TODO: Should probably only accept string or object + body: any; +}; + +export type GetCookie = (cookieName: string) => string | undefined; +export type SetCookie = (cookieName: string, cookieValue: string) => void; diff --git a/src/ui.tsx b/src/ui.tsx index 6b8c070..d95a143 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -1,105 +1,143 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { RequestHandler, Request, Response } from 'express'; -import { UiGroups, ScenarioMap } from './types'; +import { + Context, + DefaultScenario, + GetCookie, + ScenarioMap, + SetCookie, +} from './types'; import { Html } from './Html'; -import { getScenarios } from './utils/get-scenarios'; +import { getAllScenarios } from './utils/get-all-scenarios'; +import { updateScenariosAndContext } from './utils/update-scenarios-and-context'; +import { getScenarioIdsFromCookie } from './cookies'; +import { getContextFromScenarios } from './utils/get-context-from-scenarios'; export { getUi, updateUi }; function getUi({ uiPath, scenarioMap, - getScenarioNames, + cookieMode, + getCookie, + setCookie, + defaultScenario, + getServerSelectedScenarioIds, }: { uiPath: string; scenarioMap: ScenarioMap; - getScenarioNames: (req: Request, res: Response) => string[]; -}): RequestHandler { - return (req: Request, res: Response) => { - const { groups, other } = getPageVariables( - scenarioMap, - getScenarioNames(req, res), - ); + cookieMode: boolean; + getCookie: GetCookie; + setCookie: SetCookie; + defaultScenario: DefaultScenario; + getServerSelectedScenarioIds: () => string[]; +}) { + const selectedScenarioIds = getSelectedScenarioIdsV2({ + cookieMode, + getCookie, + setCookie, + defaultScenario, + getServerSelectedScenarioIds, + }); + const { groups, other } = getAllScenarios(scenarioMap, selectedScenarioIds); - const html = renderToStaticMarkup( - , - ); + const html = renderToStaticMarkup( + , + ); - res.send('\n' + html); - }; + return '\n' + html; +} + +function getSelectedScenarioIdsV2({ + cookieMode, + getCookie, + setCookie, + defaultScenario, + getServerSelectedScenarioIds, +}: { + cookieMode: boolean; + getCookie: GetCookie; + setCookie: SetCookie; + defaultScenario: DefaultScenario; + getServerSelectedScenarioIds: () => string[]; +}) { + if (cookieMode) { + return getScenarioIdsFromCookie({ + getCookie, + setCookie, + defaultValue: { + context: getContextFromScenarios([defaultScenario]), + scenarios: [], + }, + }); + } + + return getServerSelectedScenarioIds(); } function updateUi({ uiPath, groupNames, - scenarioNames, + scenarioIds, + updatedScenarioIds, + buttonType, scenarioMap, - updateScenariosAndContext, + groupScenario, + cookieMode, + defaultScenario, + setCookie, + setServerContext, + setServerSelectedScenarioIds, }: { uiPath: string; groupNames: string[]; - scenarioNames: string[]; + scenarioIds: string[]; + updatedScenarioIds: string[]; + buttonType: 'modify' | 'reset'; scenarioMap: ScenarioMap; - updateScenariosAndContext: (res: Response, scenarios: string[]) => void; -}): RequestHandler { - return (req: Request, res: Response) => { - const { - body: { scenarios: scenariosBody, button, ...rest }, - } = req; - let updatedScenarios: string[] = []; + groupScenario: Record; + cookieMode: boolean; + defaultScenario: DefaultScenario; + setCookie: SetCookie; + setServerContext: (context: Context) => void; + setServerSelectedScenarioIds: (selectedScenarioIds: string[]) => void; +}) { + let updatedScenarios: string[] = []; - if (button === 'modify') { - updatedScenarios = groupNames - .reduce((result, groupName) => { - if (rest[groupName]) { - result.push(rest[groupName]); - } + if (buttonType === 'modify') { + updatedScenarios = groupNames + .reduce((result, groupName) => { + if (groupScenario[groupName]) { + result.push(groupScenario[groupName]); + } - return result; - }, []) - .concat(scenariosBody == null ? [] : scenariosBody) - .filter(scenarioName => scenarioNames.includes(scenarioName)); - } + return result; + }, []) + .concat(updatedScenarioIds == null ? [] : updatedScenarioIds) + .filter(scenarioId => scenarioIds.includes(scenarioId)); + } - updateScenariosAndContext(res, updatedScenarios); - - const { groups, other } = getPageVariables(scenarioMap, updatedScenarios); - - const html = renderToStaticMarkup( - , - ); - - res.send('\n' + html); - }; -} + updateScenariosAndContext({ + updatedScenarioIds: updatedScenarios, + scenarioMap, + cookieMode, + defaultScenario, + setCookie, + setServerContext, + setServerSelectedScenarioIds, + }); -function getPageVariables( - scenarioMap: ScenarioMap, - selectedScenarios: string[], -): { groups: UiGroups; other: Array<{ name: string; checked: boolean }> } { - const { groups, other } = getScenarios(scenarioMap, selectedScenarios); + const { groups, other } = getAllScenarios(scenarioMap, updatedScenarios); - return { - groups: groups.map(group => { - const scenarios = group.scenarios.map(({ id, selected }) => ({ - name: id, - checked: selected, - })); - const noneChecked = scenarios.every(({ checked }) => !checked); + const html = renderToStaticMarkup( + , + ); - return { - name: group.name, - scenarios, - noneChecked, - }; - }), - other: other.map(({ id, selected }) => ({ name: id, checked: selected })), - }; + return '\n' + html; } diff --git a/src/utils/get-scenarios.ts b/src/utils/get-all-scenarios.ts similarity index 80% rename from src/utils/get-scenarios.ts rename to src/utils/get-all-scenarios.ts index 66d66c1..5319b73 100644 --- a/src/utils/get-scenarios.ts +++ b/src/utils/get-all-scenarios.ts @@ -1,15 +1,18 @@ import { Groups, ScenarioMap } from '../types'; -export { getScenarios }; +export { getAllScenarios }; -function getScenarios(scenarioMap: ScenarioMap, selectedScenarios: string[]) { +function getAllScenarios( + scenarioMap: ScenarioMap, + selectedScenarios: string[], +) { const { other, ...groupedScenarios } = Object.entries(scenarioMap).reduce<{ other: string[]; [key: string]: string[]; }>( - (result, [scenarioName, scenarioMock]) => { + (result, [scenarioId, scenarioMock]) => { if (Array.isArray(scenarioMock) || scenarioMock.group == null) { - result.other.push(scenarioName); + result.other.push(scenarioId); return result; } @@ -20,7 +23,7 @@ function getScenarios(scenarioMap: ScenarioMap, selectedScenarios: string[]) { result[group] = []; } - result[group].push(scenarioName); + result[group].push(scenarioId); return result; }, diff --git a/src/utils/get-context-from-scenarios.ts b/src/utils/get-context-from-scenarios.ts new file mode 100644 index 0000000..80d4100 --- /dev/null +++ b/src/utils/get-context-from-scenarios.ts @@ -0,0 +1,14 @@ +import { Context, Scenario } from '../types'; + +export { getContextFromScenarios }; + +function getContextFromScenarios(scenarios: Scenario[]) { + let context: Context = {}; + scenarios.forEach(mock => { + if (!Array.isArray(mock) && mock.context) { + context = { ...context, ...mock.context }; + } + }); + + return context; +} diff --git a/src/utils/update-scenarios-and-context.ts b/src/utils/update-scenarios-and-context.ts new file mode 100644 index 0000000..2881338 --- /dev/null +++ b/src/utils/update-scenarios-and-context.ts @@ -0,0 +1,65 @@ +import { setDataMocksServerCookie } from '../cookies'; +import { + Context, + DefaultScenario, + Scenario, + ScenarioMap, + SetCookie, +} from '../types'; +import { getContextFromScenarios } from './get-context-from-scenarios'; + +export { updateScenariosAndContext }; + +function updateScenariosAndContext({ + updatedScenarioIds, + setCookie, + setServerContext, + setServerSelectedScenarioIds, + defaultScenario, + scenarioMap, + cookieMode, +}: { + updatedScenarioIds: string[]; + setCookie: SetCookie; + setServerContext: (context: Context) => void; + setServerSelectedScenarioIds: (selectedScenarioIds: string[]) => void; + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; + cookieMode: boolean; +}) { + const updatedScenarios = getScenarios({ + defaultScenario, + scenarioMap, + scenarioIds: updatedScenarioIds, + }); + const context = getContextFromScenarios(updatedScenarios); + + if (cookieMode) { + setDataMocksServerCookie({ + setCookie, + value: { + context, + scenarios: updatedScenarioIds, + }, + }); + + return; + } + + setServerContext(context); + setServerSelectedScenarioIds(updatedScenarioIds); +} + +function getScenarios({ + defaultScenario, + scenarioMap, + scenarioIds, +}: { + defaultScenario: DefaultScenario; + scenarioMap: ScenarioMap; + scenarioIds: string[]; +}): Scenario[] { + return [defaultScenario].concat( + scenarioIds.map(scenarioId => scenarioMap[scenarioId]), + ); +} diff --git a/tsconfig.json b/tsconfig.json index 7e6def1..98df841 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ // Allow default imports from modules with no default export. "allowSyntheticDefaultImports": true, // Target latest version of ECMAScript. - "target": "ES6", + "target": "ES2019", // Search under node_modules for non-relative imports. "moduleResolution": "node", // Enable strictest settings like strictNullChecks & noImplicitAny.