Last active
December 18, 2021 19:48
-
-
Save andrewluetgers/7a71023d8c9751d9ce669cc10483a58b to your computer and use it in GitHub Desktop.
A pile of code that captures the core of a system to mock out apis that can be used in msw or in express. A standard crud api generator is implemented for the client that uses reactQuery.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {mswMocks as ruleMocks, getRulesBody} from '../../../api/rules/rulesMocks' | |
import {mswMocks as rulesetMocks, getRulesetsBody} from '../../../api/rulesets/rulesetsMocks' | |
import {mswMocks as channelMocks} from '../../../api/channels/channelMocks' | |
import RuleBuilder from './RuleBuilder' | |
// -------------------------------------- setup ---------------------------------------- | |
let items = getRulesetsBody, | |
item = items[0], | |
{name, uuid} = item || {}, | |
clientId = '26', | |
rulesetId = uuid | |
// -------------------------------------- stories ---------------------------------------- | |
export default { | |
title: 'RuleBuilder', | |
component: RuleBuilder | |
} | |
export const Basic = { | |
args: { | |
route: { | |
params: {clientId, rulesetId} | |
} | |
}, | |
parameters: { | |
msw: [ | |
...rulesetMocks(), | |
...ruleMocks(), | |
...channelMocks() | |
] | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {mockImpl as rulesMockImpl} from './rules/rulesMocks' | |
// app = express app instance | |
// simulate existing api or before api is implemented during development | |
let rulesApi = rulesMockImpl() | |
mockProxyRoute(app, 'get', 'rules', rulesApi) | |
mockProxyRoute(app, 'post', 'rules', rulesApi) | |
mockProxyRoute(app, 'put', 'rule', rulesApi) | |
mockProxyRoute(app, 'delete', 'rule', rulesApi) | |
// or when api is implemented | |
proxyRoute(app, 'get', 'rules') | |
proxyRoute(app, 'post', 'rules') | |
proxyRoute(app, 'put', 'rules') | |
proxyRoute(app, 'delete', 'rules') | |
// !!!--------- NO OTHER ROUTES AFTER THIS ---------!!! | |
app.all(config.clientApiPath + "/*", function (req, res, next) { | |
res.statusCode = 404 | |
res.json({error: 'Sorry, that resource does not exist.'}) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import getRulesBody from './getRules_body.json' | |
import {mswHandlers, mockApiImpl} from '../../tools/mockImpl' | |
export {getRulesBody} | |
export function mockImpl(items) { | |
return mockApiImpl(items || getRulesBody, 'ruleId', { | |
newItem: ({name}, {uuid, id, ...rest}) => { | |
return { | |
...rest, | |
id: uuid, | |
type: '', | |
name: '', | |
definition: {}, | |
result: { | |
channel: '', | |
program: '', | |
detail: {id: '', fields: []}, | |
weight: '', | |
allocation: '' | |
} | |
} | |
}, | |
updateItem: (item, {updated}) => ({...item, updated}) | |
}) | |
} | |
export const mswMocks = (items) => { | |
let {get, post, put, delete: del} = mockImpl(items) | |
// maps mock handlers to api route names | |
return mswHandlers({ | |
rules: {get, post}, | |
rule: {put, delete: del} | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// this file loaded in client and server so must be node compatible | |
import {cloneDeep, find, forEach, merge, remove, assign, findIndex} from 'lodash' | |
import {rest} from 'msw' | |
import apiRoutes from '../../apiRoutes' // object like {someRouteName: '/api/path/:someParameter`} | |
let {routes} = apiRoutes, | |
tsFn = () => new Date().toISOString(), | |
idFn = () => Math.round(Math.random() * 1e5), // mimics auto-increment field or other id of actual data | |
uuidFn = () => Date.now()+'-'+Math.round(Math.random() * 1e5), // mimics uuid string of actual data | |
commonBase = () => ({id: idFn(), uuid: uuidFn(), created: tsFn(), updated: tsFn()}) | |
// mockApiImpl - pairs nicely with src/api/api.js useCrud function | |
// see EditableItemListBuilder.test.stories.jsx and weightMocks/index.js | |
// override as needed if that does not suffice add mockApiImpl2, mockApiImpl3, etc. | |
// mocks behavior of api by mutating (cloned) snapshot data | |
// see commonCrudApi | |
/** | |
* | |
* @param items | |
* @param urlIdParam | |
* | |
* options object containing any of the following | |
* @param item | |
* @param reqToPredicate | |
* @param newItem | |
* @param updateItem | |
* @param itemIdProperty | |
* @param urlIdQueryParam | |
* @returns {{post: (function(*): *), get: (function(): *[]), delete: del, put: (function(*=): *)}} | |
*/ | |
export function mockApiImpl( | |
items = [], | |
urlIdParam, | |
{ // options object | |
itemIdProperty = 'id', | |
urlIdQueryParam = 'id', | |
reqToPredicate, // function used in update and delete - given a req object returns a lodash predicate to find the desired item | |
newItem, // function to implement server behavior - given default item return desired item shape | |
// default behavior: req.body will be merged with {id (number), uuid (string), created (iso), updated (iso)} | |
updateItem // function to implement server behavior - given default item return desired item shape | |
// default behavior: req.body will be merged with {updated (iso)} | |
} | |
) { | |
items = cloneDeep(items) // the get response snapshot | |
// given a req returns the lodash predicate to find desired object | |
reqToPredicate = reqToPredicate || ((req) => ({[itemIdProperty]: (req.params[urlIdParam] || req.query[urlIdQueryParam])})) | |
// mock crud endpoints | |
let get = () => items, | |
// merges in the given object usually the post body into the defaultItem | |
// defaultItem is either the item override object or first object in items | |
// also assigns random id number, random uuid string, created updated timestamps | |
// each function can be overridden or pass a newItem function and return whatever you want given the above as input | |
post = (req => { | |
let base = commonBase(), | |
item = newItem | |
? newItem(merge({}, cloneDeep(req.body || {})), base) | |
: merge({}, base, cloneDeep(req.body || {})) | |
items.push(item) | |
return item | |
}), | |
// merges (usually PUT body) in to the found item via given predicate | |
put = ((req) => { | |
let predicate = reqToPredicate(req), | |
idx = findIndex(items, predicate), | |
item = find(items, predicate), | |
base = {updated: tsFn()} | |
console.log({req, items, item, idx, base}) | |
if (item) { | |
item = updateItem | |
? updateItem(merge(item, cloneDeep(req.body)), base) | |
: merge(item, cloneDeep(req.body), base) | |
console.log("splice", item) | |
items.splice(idx, 1, item) | |
} | |
return item | |
}), | |
del = req => {remove(items, reqToPredicate(req))} | |
return {get, post, put, delete: del} | |
} | |
let mswDelay = 50 | |
export function mswHandlers(handlers) { | |
let restHandlers = [] | |
forEach(handlers, (methods, apiRouteName) => { | |
forEach(methods, (handler, method) => { | |
let mswHandler = rest[method](routes[apiRouteName], (req, res, ctx) => { | |
let json = handler(req, res, ctx) || {} | |
// delay is important for test to not flake out | |
return res(ctx.delay(mswDelay), ctx.json(json)) | |
}) | |
restHandlers.push(mswHandler) | |
}) | |
}) | |
return restHandlers | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {useEffect, useState} from 'react' | |
import {find, findIndex, forEach, assign, isFunction, reject} from 'lodash' | |
import request from 'superagent' | |
import noCache from '../common/superagentNoCache/superagentNoCache' | |
import {useQuery, useMutation, useQueryClient} from 'react-query' | |
import appConfig from '../../appConfig' | |
import {routes, stringify, routeParams, queryParams} from '../../apiRoutes' | |
import events from '../CDS/events/events' | |
import {capitalizeFirstChar} from "../CDS/stringUtils/stringUtils"; | |
// crufty way to support node and browser | |
var hasWindow = (typeof window !== 'undefined'), | |
win = hasWindow ? window : global, | |
nav = win.navigator || {userAgent: "Node.js"}, | |
testing = nav.userAgent.match(/Node\.js/) || nav.userAgent.match(/jsdom/), | |
host = (!testing && "location" in win) ? win.location.protocol + '//' + win.location.host : 'http://localhost:80', | |
path = appConfig.publicApiPath, | |
url = host + path, | |
version = win.versionInfo && win.versionInfo.version, | |
timeoutMs = 15000; | |
/** | |
Utility function that checks an API response for system/API errors | |
Displays an error dialog for all errors, invokes onSuccess(res) for | |
successful calls. | |
@err superagent error object | |
@res superagent response object | |
@onSuccess callback invoked when no issues are encountered | |
*/ | |
function check(err, res, onSuccess, onError) { | |
if (version && res && res.header && res.header.apiversion && res.header.apiversion !== version) { | |
console.log("Version expired, reloading to force update"); | |
document.location.reload(true); | |
} | |
if (err) { | |
if (err.status === 401) { | |
console.log("Session expired, reloading to force re-auth"); | |
document.location.reload(true); | |
} else { | |
handleApiError({error: err}, res, onError); | |
} | |
} else { | |
if (res.body && res.body.error) { | |
handleApiError({error: res.body.error}, res, onError); | |
} else { | |
// Saul Goodman | |
onSuccess(res); | |
} | |
} | |
} | |
// callers of check may optionally handle an error locally by providing an onError handler function | |
// you can also defer to a global APIERROR event handler that will be triggered no matter what | |
// if the error was passed to a local handler the APIERROR message handled property will be true and may be ignored | |
function handleApiError(error, res, handler) { | |
var err = error ? error.error || error : {}, | |
messages = err.messages, | |
code = err.code, | |
errorMessage = messages ? messages.join(', ') : "Unknown Error."; | |
console.log("API error: ", error, errorMessage, res, handler); | |
if (handler) { | |
handler(errorMessage, res, err); | |
} else { | |
if (code == "404") { | |
events.emit("NOTFOUND", { | |
code: code, | |
error: errorMessage, | |
res: res | |
}); | |
} else { | |
events.emit("APIERROR", { | |
code: code, | |
error: errorMessage, | |
res: res | |
}); | |
} | |
} | |
} | |
export const parseReq = (routeKey, sendKey = 'body', combinedParams) => { | |
let {[sendKey]: body, ...params} = combinedParams, | |
urlParams = routeParams(routeKey, params), | |
query = queryParams(routeKey, params), | |
route = routes[routeKey], | |
path = stringify(route, urlParams, query) | |
return {routeKey, route, path, params: urlParams, query, body} | |
} | |
export const promiseReq = (method, path, body, resolver) => { | |
return new Promise((resolve, reject) => { | |
let handler = (err, res) => { | |
check(err, res, ({body}) => (resolver | |
? resolver(body, resolve, reject) | |
: resolve(body) | |
), reject) | |
} | |
body | |
? request[method](path).use(noCache).timeout(timeoutMs).send(body).end(handler) | |
: request[method](path).use(noCache).timeout(timeoutMs).end(handler) | |
}) | |
} | |
export const endpoint = (method, routeKey, resolver) => { | |
return (combinedParams) => { | |
let {path, body} = parseReq(routeKey, null, combinedParams) | |
return promiseReq(method, path, body, resolver) | |
} | |
} | |
// todo mutationHook with useMutation | |
export const getHook = (routeKey, resolver) => { | |
return combinedParams => { | |
let {path, params, query} = parseReq(routeKey, null, combinedParams) | |
// console.log('parsed params', {path, params, query}) | |
return useQuery([routeKey, params, query], () => ( | |
promiseReq('get', path, null, resolver) | |
)) | |
} | |
} | |
// WIP | |
// https://react-query.tanstack.com/reference/useMutation | |
// https://react-query.tanstack.com/guides/mutations | |
// fix optimistic update flash of prior content as seen in EditableItemListBuilder story | |
// queryFilter is to invalidate react-query's query cache | |
// see: https://react-query.tanstack.com/guides/filters#query-filters | |
export const mutationHook = (method, routeKey, queryFilter, resolver, itemIdProperty, urlIdParam) => ( | |
(cb) => { // todo: could pass in config in render function here | |
let queryClient = useQueryClient() | |
itemIdProperty = itemIdProperty || 'id' | |
urlIdParam = urlIdParam || 'id' | |
queryFilter = queryFilter || routeKey | |
return useMutation( | |
(combinedParams) => { | |
// todo: use params and query? | |
// todo: what we send to the api vs what we need for optimistic updates may vary | |
console.log('mutation function =================', combinedParams) | |
let {path, params, query, body} = parseReq(routeKey, 'body', combinedParams) | |
return promiseReq(method, path, body, resolver) | |
},{ | |
// https://react-query.tanstack.com/examples/optimistic-updates | |
// Optimistically update the cache value on mutate, but store | |
// the old value and return it so that it's accessible in case of | |
// an error | |
onMutate: async combinedParams => { | |
let item = combinedParams.body | |
await queryClient.cancelQueries(queryFilter) | |
const previousValue = queryClient.getQueryData(queryFilter) | |
queryClient.setQueryData(queryFilter, old => { | |
let items = [...(old||[])], | |
predicate = {[itemIdProperty]: combinedParams[urlIdParam]} | |
switch(method) { | |
case 'post': | |
return items.concat(items, item) | |
case 'put': | |
console.log('put', {itemIdProperty, predicate}) | |
items.splice(findIndex(items, predicate), 1, item) | |
return items | |
case 'delete': | |
return reject(items, predicate) | |
} | |
}) | |
console.log(`${method}Hook onMutate:`, {previousValue}) | |
cb?.(routeKey, previousValue, item) | |
return previousValue | |
}, | |
// On failure, roll back to the previous value | |
onError: (err, variables, previousValue) => { | |
console.log(`${method}Hook onErr:`, routeKey) | |
return queryClient.setQueryData(queryFilter, previousValue) | |
}, | |
// After success or failure, refetch the todos query | |
onSettled: () => { | |
console.log(`${method}Hook settled:`, routeKey) | |
queryClient.invalidateQueries(queryFilter) | |
} | |
} | |
) | |
} | |
) | |
export const postHook = (routeKey, queryFilter, resolver, itemIdProperty) => mutationHook('post', routeKey, queryFilter, resolver, itemIdProperty) | |
export const putHook = (routeKey, queryFilter, resolver, itemIdProperty, urlIdParam) => mutationHook('put', routeKey, queryFilter, resolver, itemIdProperty, urlIdParam) | |
export const deleteHook = (routeKey, queryFilter, resolver, itemIdProperty, urlIdParam) => mutationHook('delete', routeKey, queryFilter, resolver, itemIdProperty, urlIdParam) | |
export function useCrud(useCreate, useRead, useUpdate, useDelete) { | |
let createHook = useCreate(), | |
readHook = useRead(), | |
updateHook = useUpdate(), | |
delHook = useDelete(), | |
{data, error, isLoading} = readHook, | |
items = data || [] | |
return { | |
items, | |
data, | |
error, | |
isLoading, | |
createHook, | |
readHook, | |
updateHook, | |
delHook | |
} | |
} | |
// createBody should return the body given all args passed when calling create | |
// updateBody should return the body given all args passed when calling update | |
export const createBodyDefaultFn = newItem => newItem | |
export const updateBodyDefaultFn = (itemWithId, changes) => changes | |
? {...itemWithId, ...changes} // merge item and changes | |
: itemWithId // otherwise just pass item, caller must have mutated it already | |
export function commonCrudApi(constructionOpts) { | |
let { | |
routeKey, routeKeyPlural, queryFilter, | |
urlIdParam, urlIdQueryParam, itemIdProperty, | |
useHooks, resolver, | |
createBodyDefault, updateBodyDefault | |
} = constructionOpts | |
// can configure create and update body fns at construction time | |
// these defaults can be overridden during hook call | |
createBodyDefault = createBodyDefault || createBodyDefaultFn | |
updateBodyDefault = updateBodyDefault || updateBodyDefaultFn | |
// other construction time defaults not overridable | |
routeKeyPlural = routeKeyPlural || routeKey+'s' | |
urlIdParam = urlIdParam || `${routeKey}Id` | |
queryFilter = queryFilter || routeKeyPlural | |
itemIdProperty = itemIdProperty || 'id' | |
// update with defaults as we pass this along in the api object | |
constructionOpts = { | |
...constructionOpts, | |
routeKeyPlural, urlIdParam, queryFilter, itemIdProperty, | |
createBodyDefault, updateBodyDefault | |
} | |
// for the following hooks: | |
// routeKey: | |
// this convention is pretty solid lets stick to it | |
// queryFilter: | |
// see: https://react-query.tanstack.com/guides/filters#query-filters | |
// a possible todo is a queryFilter per endpoint may need to be accommodated | |
// this could be implemented as a function(method, routeKey, api) that returns the correct queryFilter | |
// resolver: | |
// default behavior is to resolve with body | |
// custom resolver can be passed as resolver(body, resolve, reject) | |
// getHook | |
// returns result of calling useQuery([routeKey, params, query], null, resolver) | |
let useCreate = postHook(routeKeyPlural, queryFilter, resolver, itemIdProperty), | |
useRead = getHook(routeKeyPlural, resolver), | |
useUpdate = putHook(routeKey, queryFilter, resolver, itemIdProperty, urlIdParam), | |
useDelete = deleteHook(routeKey, queryFilter, resolver, itemIdProperty, urlIdParam), | |
useCrudApi = (hookParams) => { | |
// readParams = object with key,values needed to provide complete combinedParams to api call | |
// writeParams = same as above but for mutations, uses readParams if not given | |
let {readParams, writeParams, createBody, updateBody} = (hookParams || {}) | |
writeParams = writeParams || readParams | |
// createBody should return the body given all args passed when calling create | |
// updateBody should return the body given all args passed when calling update | |
createBody = createBody || createBodyDefault | |
updateBody = updateBody || updateBodyDefault | |
// update with defaults as we pass this along in the api object | |
hookParams = {...hookParams, readParams, writeParams, createBody, updateBody} | |
// console.log('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', {hookParams}, {readParams, writeParams}) | |
let useRead1 = () => readParams ? useRead(readParams) : {}, // data is fetched if readParams present | |
api = useCrud(useCreate, useRead1, useUpdate, useDelete), | |
crudApi = { | |
hookParams, | |
constructionOpts, | |
config: {urlIdParam, urlIdQueryParam, itemIdProperty, queryFilter}, | |
...api, | |
// ~~~~~~~~~~~ MUTATION METHODS ~~~~~~~~~~~~ | |
// these mutation methods are calling react query's mutate function with clario's combinedParams convention | |
// this has been configured in mutationHook | |
// combinedParams is an object having: | |
// * the url params as properties by name as used in apiRoute | |
// * 'query' property being the key,value structure of query search params | |
// * 'body' property if needed being json compatible object to send to the api | |
// create: | |
// default behavior: newItem will be passed as the post body | |
// override this behavior by: | |
// - providing a createBody function in the hook call | |
// - calling create with arguments your createBody fn expects, returning the post body | |
create: (newItem, ...rest) => api.createHook.mutate({ | |
...writeParams, | |
body: createBody(newItem, ...rest) | |
}), | |
// update | |
// default behavior: | |
// - if no other arguments: post body will be itemWithId (assumes caller already applied changes) | |
// - if a changes object is also provided: put body is product of merging both | |
// override this behavior by: | |
// - providing an updateBody function in the hook call | |
// - calling update with arguments your updateBody fn expects, returning the put body | |
update: (itemWithId, changes, ...rest) => api.updateHook.mutate({ | |
...writeParams, | |
[urlIdParam]: itemWithId[itemIdProperty], | |
body: updateBody(itemWithId, changes, ...rest) | |
}), | |
// all I need is to be called with the item I need to delete | |
del: itemWithId => api.delHook.mutate({ | |
...writeParams, | |
[urlIdParam]: itemWithId[itemIdProperty] | |
}) | |
} | |
useMiddleware(useHooks, crudApi) | |
return crudApi | |
} | |
let routeKeyCap = capitalizeFirstChar(routeKey), | |
routeKeyPluralCap = capitalizeFirstChar(routeKeyPlural) | |
console.log('init crud api ', routeKeyPluralCap) | |
return { | |
[`use${routeKeyPluralCap}`]: useCrudApi, | |
[`use${routeKeyPluralCap}Create`]: useCreate, | |
[`use${routeKeyPluralCap}Read`]: useRead, | |
[`use${routeKeyCap}Update`]: useUpdate, | |
[`use${routeKeyCap}Delete`]: useDelete | |
} | |
} | |
// withHook function or collection thereof is passed the api | |
// can be used to customize base crud functionality by adding/overriding api methods | |
// may mutate api object before it is returned to the hook calling context | |
export function useMiddleware(useMiddleWareHook, api) { | |
useMiddleWareHook = isFunction(useMiddleWareHook) ? [useMiddleWareHook] : useMiddleWareHook | |
forEach(useMiddleWareHook, useMwHook => useMwHook(api)) | |
} | |
// call the crudHook with extra params | |
// activeId, initialSelect | |
export function useActiveItemState(api) { | |
let {hookParams, config} = api, | |
{itemIdProperty} = config, | |
{activeId: activeIdIncoming, initialSelect} = hookParams, | |
[activeId, setActiveId] = useState(activeIdIncoming), // should this just be url state? | |
activeItem = find(api.items, {[itemIdProperty]: activeId}) | |
assign(api, {activeId, setActiveId, activeItem}) | |
useEffect(() => { | |
setActiveId(activeIdIncoming) | |
}, [activeIdIncoming]) | |
if (initialSelect) { | |
useEffect(() => { | |
let first = api.items?.[0] | |
first && !activeIdIncoming && !activeId && setActiveId(first[itemIdProperty]) | |
}, []) | |
} | |
} | |
export default { | |
host, path, url, | |
check, timeoutMs, | |
parseReq, endpoint, | |
getHook, postHook, putHook, deleteHook, | |
useCrud | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import apiRoutes from '../apiRoutes' | |
import {jsonResponse, parallel, proxyRoute, mockProxyRoute} from './tools/tools' | |
import { | |
commonCrudApi, | |
useActiveItemState | |
} from '../api' | |
// ============ rules ============ | |
export const {useRules} = commonCrudApi({ | |
routeKey: 'rule', | |
useHooks: [useActiveItemState] | |
}) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
_ in file name is directory path, I'll have to set up a proper repo but don't have time for it now.