Skip to content

Instantly share code, notes, and snippets.

@andrewluetgers
Last active December 18, 2021 19:48
Show Gist options
  • Save andrewluetgers/7a71023d8c9751d9ce669cc10483a58b to your computer and use it in GitHub Desktop.
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.
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()
]
}
}
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.'})
})
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 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
}
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
}
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]
})
@andrewluetgers
Copy link
Author

_ in file name is directory path, I'll have to set up a proper repo but don't have time for it now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment