diff --git a/README.md b/README.md index a0fdcbb45..7cc80afe6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ In general, application-specific code will live outside of this package. - [API State module](src/api-state/README.md) - ['Query': structuring data requests](src/api-state/Queries.md) - GET/POST/PATCH/DELETE requests to REST API +- [Redux store modules](src/store/README.md) - browser and server # Releases diff --git a/flow-typed/api-proxy.js b/flow-typed/api-proxy.js new file mode 100644 index 000000000..02b977c66 --- /dev/null +++ b/flow-typed/api-proxy.js @@ -0,0 +1,4 @@ +declare type ProxyResponseSuccess = {| responses: Array |}; +declare type ProxyResponseError = {| error: string, message: string |}; + +declare type ProxyResponse = ProxyResponseSuccess | ProxyResponseError; diff --git a/flow-typed/store.js b/flow-typed/store.js new file mode 100644 index 000000000..001e1c8f9 --- /dev/null +++ b/flow-typed/store.js @@ -0,0 +1,4 @@ +declare type ParsedQueryResponses = { + successes: Array, + errors: Array, +}; diff --git a/src/middleware/catch.js b/src/middleware/catch.js deleted file mode 100644 index 459a08c0f..000000000 --- a/src/middleware/catch.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @param {Object} store Redux store - * @return {Function} the function that handles calling the next middleware - * with each action - */ -const catchMiddleware = logError => store => next => action => { - try { - return next(action); - } catch (err) { - logError(err); - return err; - } -}; - -export default catchMiddleware; diff --git a/src/plugins/requestAuthPlugin.js b/src/plugins/requestAuthPlugin.js index 1863b1373..92f269ef7 100644 --- a/src/plugins/requestAuthPlugin.js +++ b/src/plugins/requestAuthPlugin.js @@ -9,7 +9,6 @@ import 'rxjs/add/operator/mergeMap'; import 'rxjs/add/operator/map'; import logger from '../util/logger'; -import { tryJSON } from '../util/fetchUtils'; import { MEMBER_COOKIE } from '../util/cookieUtils'; import { applyAuthState, @@ -21,6 +20,26 @@ import { * @module requestAuthPlugin */ +/** + * Attempt to JSON parse a Response object from a fetch call + * + * @param {String} reqUrl the URL that was requested + * @param {Response} response the fetch Response object + * @return {Promise} a Promise that resolves with the JSON-parsed text + */ +export const tryJSON = reqUrl => response => { + const { status, statusText } = response; + if (status >= 400) { + // status always 200: bugzilla #52128 + return Promise.reject( + new Error( + `Request to ${reqUrl} responded with error code ${status}: ${statusText}` + ) + ); + } + return response.text().then(text => JSON.parse(text)); +}; + const verifyAuth = logger => auth => { if (!Object.keys(auth).length) { const errorMessage = 'No auth token(s) provided'; diff --git a/src/plugins/requestAuthPlugin.test.js b/src/plugins/requestAuthPlugin.test.js index 9aac44cc9..1952a73fa 100644 --- a/src/plugins/requestAuthPlugin.test.js +++ b/src/plugins/requestAuthPlugin.test.js @@ -14,6 +14,7 @@ import register, { getAnonymousCode$, getAccessToken$, applyRequestAuthorizer$, + tryJSON, } from './requestAuthPlugin'; const MOCK_REPLY_FN = () => {}; @@ -351,3 +352,42 @@ describe('getAuthenticate', () => { ); }); }); + +describe('tryJSON', () => { + const reqUrl = 'http://example.com'; + const goodResponse = { foo: 'bar' }; + const goodFetchResponse = { + text: () => Promise.resolve(JSON.stringify(goodResponse)), + }; + const errorResponse = { + status: 400, + statusText: '400 Bad Request', + text: () => Promise.resolve('There was a problem'), + }; + const badJSON = 'totally not JSON'; + const badJSONResponse = { + text: () => Promise.resolve(badJSON), + }; + it('returns a Promise that resolves to the parsed fetch response JSON', () => { + const theFetch = tryJSON(reqUrl)(goodFetchResponse); + expect(theFetch).toEqual(jasmine.any(Promise)); + + return theFetch.then(response => expect(response).toEqual(goodResponse)); + }); + it('returns a rejected Promise with Error when response has 400+ status', () => { + const theFetch = tryJSON(reqUrl)(errorResponse); + expect(theFetch).toEqual(jasmine.any(Promise)); + return theFetch.then( + response => expect(true).toBe(false), // should not run - promise should be rejected + err => expect(err).toEqual(jasmine.any(Error)) + ); + }); + it('returns a rejected Promise with Error when response fails JSON parsing', () => { + const theFetch = tryJSON(reqUrl)(badJSONResponse); + expect(theFetch).toEqual(jasmine.any(Promise)); + return theFetch.then( + response => expect(true).toBe(false), // should not run - promise should be rejected + err => expect(err).toEqual(jasmine.any(Error)) + ); + }); +}); diff --git a/src/renderers/browser-render.jsx b/src/renderers/browser-render.jsx index 144d76cb7..8fe20db8d 100644 --- a/src/renderers/browser-render.jsx +++ b/src/renderers/browser-render.jsx @@ -1,10 +1,7 @@ // @flow import React from 'react'; import ReactDOM from 'react-dom'; -import { - getInitialState, - getBrowserCreateStore, -} from '../util/createStoreBrowser'; +import { getInitialState, getBrowserCreateStore } from '../store/browser'; import { getRouteResolver } from '../router/util'; import BrowserApp from '../render/components/BrowserApp'; diff --git a/src/renderers/server-render.jsx b/src/renderers/server-render.jsx index 756ff8064..d811d8dc7 100644 --- a/src/renderers/server-render.jsx +++ b/src/renderers/server-render.jsx @@ -9,11 +9,10 @@ import ReactDOMServer from 'react-dom/server'; import Helmet from 'react-helmet'; import { API_ROUTE_PATH } from '../plugins/api-proxy'; // mwp-api-proxy import { Forbidden, NotFound, Redirect, SERVER_RENDER } from '../router'; // mwp-router +import { getServerCreateStore } from '../store/server'; // mwp-store +import Dom from '../render/components/Dom'; // mmwp-render/components/Dom +import ServerApp from '../render/components/ServerApp'; // mwp-render/components/ServerApp -import Dom from '../render/components/Dom'; -import ServerApp from '../render/components/ServerApp'; - -import { getServerCreateStore } from '../util/createStoreServer'; import configure from '../actions/configActionCreators'; // Ensure global Intl for use with FormatJS diff --git a/src/store/README.md b/src/store/README.md new file mode 100644 index 000000000..85d1b3bdb --- /dev/null +++ b/src/store/README.md @@ -0,0 +1,15 @@ +# Store + +The Redux `createStore` helpers for MWP applications - `store/browser` and +`store/server`. Both modules are implemented as 'getters' with a similar +signature - the main difference is that the `store/server` function takes an +additional `request` parameter corresponding to a Hapi request. + +## Dependencies + +- js-cookie +- rison +- rxjs (peer) +- mwp-api-proxy-plugin +- mwp-api-state +- mwp-tracking/util/clickWriter \ No newline at end of file diff --git a/src/util/fetchUtils.js b/src/store/browser/fetchQueries.js similarity index 71% rename from src/util/fetchUtils.js rename to src/store/browser/fetchQueries.js index c827d16e3..54c3466a0 100644 --- a/src/util/fetchUtils.js +++ b/src/store/browser/fetchQueries.js @@ -1,40 +1,12 @@ import JSCookie from 'js-cookie'; import rison from 'rison'; -import { setClickCookie } from '../plugins/tracking/util/clickState'; +import { setClickCookie } from '../../plugins/tracking/util/clickState'; // mwp-tracking-plugin -/** - * A module for middleware that would like to make external calls through `fetch` - * @module fetchUtils - */ +import { parseQueryResponse } from '../util/fetchUtils'; export const CSRF_HEADER = 'x-csrf-jwt'; export const CSRF_HEADER_COOKIE = 'x-csrf-jwt-header'; -export const parseQueryResponse = queries => ({ - responses, - error, - message, -}) => { - if (error) { - throw new Error(JSON.stringify({ error, message })); // treat like an API error - } - responses = responses || []; - if (queries.length !== responses.length) { - throw new Error('Responses do not match requests'); - } - - return responses.reduce( - (categorized, response, i) => { - const targetArray = response.error - ? categorized.errors - : categorized.successes; - targetArray.push({ response, query: queries[i] }); - return categorized; - }, - { successes: [], errors: [] } - ); -}; - /** * that the request will have the required OAuth and CSRF credentials and constructs * the `fetch` call arguments based on the request method. It also records the @@ -120,7 +92,7 @@ export const getFetchArgs = (apiUrl, queries, meta) => { * click tracking data * @return {Promise} resolves with a `{queries, responses}` object */ -export const fetchQueries = apiUrl => (queries, meta) => { +const fetchQueries = apiUrl => (queries, meta) => { if ( typeof window === 'undefined' && typeof test === 'undefined' // not in browser // not in testing env (global set by Jest) @@ -147,22 +119,4 @@ export const fetchQueries = apiUrl => (queries, meta) => { }); }; -/** - * Attempt to JSON parse a Response object from a fetch call - * - * @param {String} reqUrl the URL that was requested - * @param {Response} response the fetch Response object - * @return {Promise} a Promise that resolves with the JSON-parsed text - */ -export const tryJSON = reqUrl => response => { - const { status, statusText } = response; - if (status >= 400) { - // status always 200: bugzilla #52128 - return Promise.reject( - new Error( - `Request to ${reqUrl} responded with error code ${status}: ${statusText}` - ) - ); - } - return response.text().then(text => JSON.parse(text)); -}; +export default fetchQueries; diff --git a/src/util/fetchUtils.test.js b/src/store/browser/fetchQueries.test.js similarity index 70% rename from src/util/fetchUtils.test.js rename to src/store/browser/fetchQueries.test.js index 560b8ea16..c734bd83c 100644 --- a/src/util/fetchUtils.test.js +++ b/src/store/browser/fetchQueries.test.js @@ -1,8 +1,8 @@ import { mockQuery } from 'meetup-web-mocks/lib/app'; import { MOCK_GROUP } from 'meetup-web-mocks/lib/api'; -import * as fetchUtils from './fetchUtils'; +import * as clickState from '../../plugins/tracking/util/clickState'; // mwp-tracking/util/clickState import { URLSearchParams } from 'url'; -import * as clickState from '../plugins/tracking/util/clickState'; +import fetchQueries, { CSRF_HEADER_COOKIE } from './fetchQueries'; clickState.setClickCookie = jest.fn(); global.FormData = function() {}; @@ -23,7 +23,7 @@ jest.mock('js-cookie', () => { describe('fetchQueries', () => { const API_URL = new URL('http://api.example.com/'); - const csrfJwt = `${fetchUtils.CSRF_HEADER_COOKIE} value`; + const csrfJwt = `${CSRF_HEADER_COOKIE} value`; const getQueries = [mockQuery({ params: {} })]; const POSTQueries = [ { ...mockQuery({ params: {} }), meta: { method: 'POST' } }, @@ -55,9 +55,7 @@ describe('fetchQueries', () => { it('returns an object with successes and errors arrays', () => { spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())( - getQueries - ).then(response => { + return fetchQueries(API_URL.toString())(getQueries).then(response => { expect(response.successes).toEqual([ { query: getQueries[0], response: responses[0] }, ]); @@ -67,7 +65,7 @@ describe('fetchQueries', () => { it('returns a promise that will reject when response contains error prop', () => { spyOn(global, 'fetch').and.callFake(fakeSuccessError); - return fetchUtils.fetchQueries(API_URL.toString())(getQueries).then( + return fetchQueries(API_URL.toString())(getQueries).then( response => expect(true).toBe(false), err => expect(err).toEqual(jasmine.any(Error)) ); @@ -79,9 +77,7 @@ describe('fetchQueries', () => { const methodTest = method => () => { query.meta = { method }; - return fetchUtils.fetchQueries(API_URL.toString())( - queries - ).then(response => { + return fetchQueries(API_URL.toString())(queries).then(response => { const [, config] = global.fetch.calls.mostRecent().args; expect(config.method).toEqual(method); }); @@ -94,7 +90,7 @@ describe('fetchQueries', () => { it('GET calls fetch with API url and queries, metadata', () => { spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())(getQueries, { + return fetchQueries(API_URL.toString())(getQueries, { ...meta, }).then(() => { const calledWith = global.fetch.calls.mostRecent().args; @@ -112,7 +108,7 @@ describe('fetchQueries', () => { }; spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())(getQueries, { + return fetchQueries(API_URL.toString())(getQueries, { ...meta, clickTracking, logout: true, @@ -124,9 +120,7 @@ describe('fetchQueries', () => { it('GET without meta calls fetch without metadata querystring params', () => { spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())( - getQueries - ).then(() => { + return fetchQueries(API_URL.toString())(getQueries).then(() => { const calledWith = global.fetch.calls.mostRecent().args; const url = new URL(calledWith[0]); expect(url.origin).toBe(API_URL.origin); @@ -140,10 +134,7 @@ describe('fetchQueries', () => { it('POST calls fetch with API url; csrf header; queries and metadata body params', () => { spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())( - POSTQueries, - meta - ).then(() => { + return fetchQueries(API_URL.toString())(POSTQueries, meta).then(() => { const calledWith = global.fetch.calls.mostRecent().args; const url = new URL(calledWith[0]); const options = calledWith[1]; @@ -159,9 +150,7 @@ describe('fetchQueries', () => { it('POST without meta calls fetch without metadata body params', () => { spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())( - POSTQueries - ).then(() => { + return fetchQueries(API_URL.toString())(POSTQueries).then(() => { const calledWith = global.fetch.calls.mostRecent().args; const url = new URL(calledWith[0]); const options = calledWith[1]; @@ -188,9 +177,7 @@ describe('fetchQueries', () => { ]; spyOn(global, 'fetch').and.callFake(fakeSuccess); - return fetchUtils.fetchQueries(API_URL.toString())( - formQueries - ).then(() => { + return fetchQueries(API_URL.toString())(formQueries).then(() => { const calledWith = global.fetch.calls.mostRecent().args; const url = new URL(calledWith[0]); const options = calledWith[1]; @@ -202,42 +189,3 @@ describe('fetchQueries', () => { }); }); }); - -describe('tryJSON', () => { - const reqUrl = 'http://example.com'; - const goodResponse = { foo: 'bar' }; - const goodFetchResponse = { - text: () => Promise.resolve(JSON.stringify(goodResponse)), - }; - const errorResponse = { - status: 400, - statusText: '400 Bad Request', - text: () => Promise.resolve('There was a problem'), - }; - const badJSON = 'totally not JSON'; - const badJSONResponse = { - text: () => Promise.resolve(badJSON), - }; - it('returns a Promise that resolves to the parsed fetch response JSON', () => { - const theFetch = fetchUtils.tryJSON(reqUrl)(goodFetchResponse); - expect(theFetch).toEqual(jasmine.any(Promise)); - - return theFetch.then(response => expect(response).toEqual(goodResponse)); - }); - it('returns a rejected Promise with Error when response has 400+ status', () => { - const theFetch = fetchUtils.tryJSON(reqUrl)(errorResponse); - expect(theFetch).toEqual(jasmine.any(Promise)); - return theFetch.then( - response => expect(true).toBe(false), // should not run - promise should be rejected - err => expect(err).toEqual(jasmine.any(Error)) - ); - }); - it('returns a rejected Promise with Error when response fails JSON parsing', () => { - const theFetch = fetchUtils.tryJSON(reqUrl)(badJSONResponse); - expect(theFetch).toEqual(jasmine.any(Promise)); - return theFetch.then( - response => expect(true).toBe(false), // should not run - promise should be rejected - err => expect(err).toEqual(jasmine.any(Error)) - ); - }); -}); diff --git a/src/util/createStoreBrowser.js b/src/store/browser/index.js similarity index 89% rename from src/util/createStoreBrowser.js rename to src/store/browser/index.js index 8dbf5d533..85d30464a 100644 --- a/src/util/createStoreBrowser.js +++ b/src/store/browser/index.js @@ -1,11 +1,11 @@ // @flow weak import { applyMiddleware, createStore, compose } from 'redux'; -import getClickWriter from '../plugins/tracking/util/clickWriter'; // mwp-tracking/util/clickWriter -import { getApiMiddleware } from '../api-state'; // mwp-api-state +import getClickWriter from '../../plugins/tracking/util/clickWriter'; // mwp-tracking/util/clickWriter +import { getApiMiddleware } from '../../api-state'; -import { fetchQueries } from '../util/fetchUtils'; import catchMiddleware from '../middleware/catch'; import injectPromise from '../middleware/injectPromise'; +import fetchQueries from './fetchQueries'; declare var document: Object; // ignore 'potentially null' document.body diff --git a/src/util/createStoreBrowser.test.js b/src/store/browser/index.test.js similarity index 90% rename from src/util/createStoreBrowser.test.js rename to src/store/browser/index.test.js index 96d2d8899..baa79bd95 100644 --- a/src/util/createStoreBrowser.test.js +++ b/src/store/browser/index.test.js @@ -1,10 +1,6 @@ import { createStore } from 'redux'; -import { - clickTrackEnhancer, - getInitialState, - getBrowserCreateStore, -} from './createStoreBrowser'; -import { testCreateStore } from './testUtils'; +import { clickTrackEnhancer, getInitialState, getBrowserCreateStore } from './'; +import { testCreateStore } from '../../util/testUtils'; const MOCK_ROUTES = {}; const IDENTITY_REDUCER = state => state; diff --git a/src/store/middleware/catch.js b/src/store/middleware/catch.js new file mode 100644 index 000000000..6f7ee937d --- /dev/null +++ b/src/store/middleware/catch.js @@ -0,0 +1,15 @@ +// @flow +import type { Middleware } from 'redux'; + +const catchMiddleware = ( + logError: Error => void +): Middleware<*, FluxStandardAction> => store => next => action => { + try { + return next(action); + } catch (err) { + logError(err); + return err; + } +}; + +export default catchMiddleware; diff --git a/src/middleware/catch.test.js b/src/store/middleware/catch.test.js similarity index 100% rename from src/middleware/catch.test.js rename to src/store/middleware/catch.test.js diff --git a/src/middleware/injectPromise.js b/src/store/middleware/injectPromise.js similarity index 88% rename from src/middleware/injectPromise.js rename to src/store/middleware/injectPromise.js index a6a0f6cfa..60f2f7714 100644 --- a/src/middleware/injectPromise.js +++ b/src/store/middleware/injectPromise.js @@ -1,6 +1,6 @@ // @flow import type { Middleware } from 'redux'; -import { API_REQ } from '../api-state'; // mwp-api-state +import { API_REQ } from '../../api-state'; // mwp-api-state type PlatformMiddleware = Middleware<*, FluxStandardAction>; diff --git a/src/store/middleware/injectPromise.test.js b/src/store/middleware/injectPromise.test.js new file mode 100644 index 000000000..aca7a815f --- /dev/null +++ b/src/store/middleware/injectPromise.test.js @@ -0,0 +1,38 @@ +import { requestAll } from '../../api-state'; // mwp-api-state +import injectPromise from './injectPromise'; + +describe('injectPromise middleware', () => { + const getAction = () => requestAll([]); + const processAction = injectPromise(undefined)(() => {}); + test('modifies API_REQ with a meta.request Promise', () => { + const action = getAction(); + processAction(action); + expect(action.meta.request).toEqual(expect.any(Promise)); + }); + test('meta.resolve will resolve the meta.request Promise', () => { + const action = getAction(); + processAction(action); + expect(action.meta.resolve).toEqual(expect.any(Function)); + const resolveHandler = jest.fn(); + action.meta.resolve(); + return action.meta.request.then(resolveHandler).then(() => { + expect(resolveHandler).toHaveBeenCalled(); + }); + }); + test('meta.reject will reject the meta.request Promise', () => { + const action = getAction(); + processAction(action); + expect(action.meta.reject).toEqual(expect.any(Function)); + const rejectHandler = jest.fn(); + action.meta.reject(); + return action.meta.request.catch(rejectHandler).then(() => { + expect(rejectHandler).toHaveBeenCalled(); + }); + }); + test('does not modify a non-API_REQ action', () => { + const meta = { foo: 'bar', baz: 'qux' }; + const action = { type: 'foo', meta }; + processAction(action); + expect(action.meta).toEqual(meta); + }); +}); diff --git a/src/store/server/fetchQueries.js b/src/store/server/fetchQueries.js new file mode 100644 index 000000000..1e7d6e416 --- /dev/null +++ b/src/store/server/fetchQueries.js @@ -0,0 +1,17 @@ +// @flow +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/toPromise'; +import { parseQueryResponse } from '../util/fetchUtils'; + +/** + * on the server, we can proxy the API requests directly without making a + * request to the server's own API proxy endpoint + */ +export default (request: HapiRequest) => () => ( + queries: Array +): Promise => + request + .proxyApi$(queries) + .map(responses => ({ responses })) // package the responses in object like the API proxy endpoint does + .toPromise() + .then(parseQueryResponse(queries)); diff --git a/src/util/createStoreServer.test.js b/src/store/server/fetchQueries.test.js similarity index 69% rename from src/util/createStoreServer.test.js rename to src/store/server/fetchQueries.test.js index 216de6fff..0baad3aae 100644 --- a/src/util/createStoreServer.test.js +++ b/src/store/server/fetchQueries.test.js @@ -1,17 +1,5 @@ -import * as fetchUtils from './fetchUtils'; -import { testCreateStore } from './testUtils'; -import { getServerCreateStore, serverFetchQueries } from './createStoreServer'; - -const MOCK_ROUTES = {}; -const MOCK_HAPI_REQUEST = { - state: {}, - server: { app: { logger: { error: jest.fn() } } }, -}; - -describe('getServerCreateStore', () => { - testCreateStore(getServerCreateStore(MOCK_ROUTES, [], MOCK_HAPI_REQUEST)); -}); - +import * as fetchUtils from '../util/fetchUtils'; +import serverFetchQueries from './fetchQueries'; describe('serverFetchQueries', () => { // export const serverFetchQueries = request => () => queries => // apiProxy$(request, queries) diff --git a/src/util/createStoreServer.js b/src/store/server/index.js similarity index 50% rename from src/util/createStoreServer.js rename to src/store/server/index.js index 01210a694..755f264ca 100644 --- a/src/util/createStoreServer.js +++ b/src/store/server/index.js @@ -1,26 +1,8 @@ import { applyMiddleware, createStore } from 'redux'; -import { getApiMiddleware } from '../api-state'; // mwp-api-state +import { getApiMiddleware } from '../../api-state'; // mwp-api-state -import { parseQueryResponse } from './fetchUtils'; +import getFetchQueries from './fetchQueries'; import catchMiddleware from '../middleware/catch'; -import 'rxjs/add/operator/map'; -import 'rxjs/add/operator/toPromise'; - -/** - * on the server, we can proxy the API requests directly without making a - * request to the server's own API proxy endpoint - * - * @param {Object} request Hapi request - * @return {Promise} a promise that resolves with the parsed query responses - * from the REST API - */ -export const serverFetchQueries = request => () => queries => - request - .proxyApi$(queries) - .map(responses => ({ responses })) // package the responses in object like the API proxy endpoint does - .toPromise() - .then(parseQueryResponse(queries)); - /** * the server needs a slightly different store than the browser because the * server doesn't need to make an internal request to the api proxy endpoint @@ -33,7 +15,7 @@ export const serverFetchQueries = request => () => queries => export function getServerCreateStore(routes, middleware, request, baseUrl) { const middlewareToApply = [ catchMiddleware(err => request.server.app.logger.error(err)), - getApiMiddleware(routes, serverFetchQueries(request), baseUrl), + getApiMiddleware(routes, getFetchQueries(request), baseUrl), ...middleware, ]; diff --git a/src/store/server/index.test.js b/src/store/server/index.test.js new file mode 100644 index 000000000..2f334d56f --- /dev/null +++ b/src/store/server/index.test.js @@ -0,0 +1,12 @@ +import { testCreateStore } from '../../util/testUtils'; +import { getServerCreateStore } from './'; + +const MOCK_ROUTES = {}; +const MOCK_HAPI_REQUEST = { + state: {}, + server: { app: { logger: { error: jest.fn() } } }, +}; + +describe('getServerCreateStore', () => { + testCreateStore(getServerCreateStore(MOCK_ROUTES, [], MOCK_HAPI_REQUEST)); +}); diff --git a/src/store/util/fetchUtils.js b/src/store/util/fetchUtils.js new file mode 100644 index 000000000..711a11ea7 --- /dev/null +++ b/src/store/util/fetchUtils.js @@ -0,0 +1,27 @@ +// @flow + +/** + * A module for middleware that would like to make external calls through `fetch` + */ +export const parseQueryResponse = (queries: Array) => ( + proxyResponse: ProxyResponse +): ParsedQueryResponses => { + if (!proxyResponse.responses) { + throw new Error(JSON.stringify(proxyResponse)); // treat like an API error + } + const { responses } = proxyResponse; + if (queries.length !== responses.length) { + throw new Error('Responses do not match requests'); + } + + return responses.reduce( + (categorized, response, i) => { + const targetArray = response.error + ? categorized.errors + : categorized.successes; + targetArray.push({ response, query: queries[i] }); + return categorized; + }, + { successes: [], errors: [] } + ); +};