DEFRA/hapi-govuk-journey-map

View on GitHub
lib/journey-map.js

Summary

Maintainability
A
2 hrs
Test Coverage
const Boom = require('@hapi/boom')
const Hoek = require('@hapi/hoek')
const { logger } = require('defra-logging-facade')
const yaml = require('js-yaml')
const fs = require('fs')

const journeyRouteTag = 'hapi-journey-map-route'

const queryData = {}

// Initialised and will be built up later
let routeMap = {}

const JourneyMap = {
  clearMap: () => {
    routeMap = {}
  },

  register: (server, options) => {
    const { modulePath, setQueryData, getQueryData, journeyMapPath, journeyMapView } = options

    // Register set and get functions for query data
    queryData.set = setQueryData
    queryData.get = getQueryData

    // Build the route map
    Object.assign(routeMap, buildMap(modulePath))

    // Register the hapi routes
    registerRoutes(server, routeMap, { app: { modulePath } })

    if (journeyMapPath) {
      // Register the journey map inquiry route
      registerInquiryRoute(server, journeyMapPath, modulePath, journeyMapView)
    }

    // Provide routing on the post handler
    server.ext('onPostHandler', JourneyMap.handlePostHandler)
  },

  setQueryData: (...args) => queryData.set(...args),

  getQueryData: (...args) => queryData.get(...args),

  getCurrent: (request) => {
    const { id } = request.route.settings.app
    return { ...routeMap[id], id }
  },

  getRoute: (id) => ({ ...routeMap[id], id }),

  getNextRoute: (request, next) => {
    const route = JourneyMap.getCurrent(request)
    const { id, path } = route
    next = next || route.next
    if (next) {
      if (next.query) {
        const result = queryData.get(request)[next.query]
        const nextRoute = result === undefined ? next.when.otherwise : next.when[String(result)]
        if (nextRoute) {
          return JourneyMap.getNextRoute(request, nextRoute)
        }
        throw new Error(`Route "${id}" with path "${path}" set incorrect value "${result}" for query "${next.query}"`)
      }
      const nextRoute = routeMap[next]
      return { ...nextRoute, path: JourneyMap.getPath(request, nextRoute.path) }
    }
    return null
  },

  getModule: (module = '', path) => {
    const map = module ? `${module}/${module}.map.yml` : 'map.yml'
    return yaml.safeLoad(fs.readFileSync(`${path}/${map}`, 'utf8'))
  },

  getMap: (modulePath) => {
    if (!Object.keys(routeMap).length) {
      // routeMap is empty so build it
      Object.assign(routeMap, buildMap(modulePath))
    }
    return Hoek.clone(routeMap)
  },

  getPath (request, path) {
    const { id } = JourneyMap.getCurrent(request)
    const params = path.match(/(?<={)(.*)(?=})/g)
    if (params) {
      params.forEach((param) => {
        const data = queryData.get(request)[param]
        if (data === undefined) {
          logger.error(`Route "${id}" with path "${path}" failed to set parameter "${param}"`)
        } else {
          path = path.replace(`{${param}}`, data)
        }
      })
    }
    return path
  },

  requireRoute: (location) => require(location),

  isJourneyRoute: (request) => {
    const { tags = [] } = request.route.settings
    return tags.includes(journeyRouteTag)
  },

  handlePostHandler: async (request, h) => {
    // Let requests from routes not created by the journey map continue
    if (!JourneyMap.isJourneyRoute(request)) {
      return h.continue
    }

    const { response = {} } = request
    const { variety, statusCode = 500 } = response

    // Continue if a view or a redirect is returned
    if (variety === 'view' || statusCode === 302) {
      return h.continue
    }

    try {
      const nextRoute = JourneyMap.getNextRoute(request)
      return h.redirect(nextRoute.path)
    } catch ({ message }) {
      return Boom.badImplementation(message)
    }
  }
}

function registerRoutes (server, map, options = {}) {
  return Object.entries(map).map(([id, { path, route, tags: mapTags = [] }]) => {
    try {
      // retrieve the app object from options
      const { app, tags = [] } = options
      // add the id of the route to app in route options so that it can be used to identify the route in the function getCurrent defined above
      // add the "journeyRouteTag" to identify routes created by the journey map plugin
      const mapConfig = { options: { ...options, app: { ...app, id }, tags: [...tags, ...mapTags, journeyRouteTag] } }
      // merge the required route definition with the path and options
      const methods = []
      let config = JourneyMap.requireRoute(`${app.modulePath}/${route}`)
      if (!Array.isArray(config)) {
        config = [config]
      }
      config
        .flat()
        .map((config) => {
          methods.push(config.method)
          config = { ...config, path }
          Hoek.merge(config, mapConfig)
          return config
        })
        .forEach((config) => {
          try {
            server.route(config)
          } catch (e) {
            logger.error(`Route "${id}" with path "${path}" failed to be registered`)
            logger.error(e)
          }
        })
      // Make a note of the methods on this route
      map[id].method = methods.flat()
    } catch (e) {
      logger.error(e.message)
    }
  })
}

function loadMap (parentModule = '', parentConfig = {}) {
  const {
    id: parentId = '',
    path: parentPath = '',
    moduleId,
    modulePath
  } = parentConfig

  const entries = Object.entries(JourneyMap.getModule(parentModule, modulePath)).map(([id, options]) => {
    return { ...options, id }
  })

  return entries.map((options, index, list) => {
    const adjacent = index === list.length - 1 ? 'return' : list[index + 1].id
    let {
      id,
      path = '',
      route,
      next = adjacent, // defaults next to the adjacent node
      module
    } = options

    id = parentId ? `${parentId}:${id}` : id
    route = parentModule ? `${parentModule}/${route}` : route
    path = parentPath + path

    const buildNext = (next) => {
      if (next.query) {
        Object.entries(next.when).map(([prop, val]) => {
          next.when[prop] = buildNext(val)
        })
        next.when.otherwise = buildNext(adjacent)
        return next
      } else if (next === 'return') {
        /** returns back to calling module **/
        return parentConfig.next
      } else {
        return moduleId ? `${moduleId}:${next}` : next
      }
    }
    next = buildNext(next)

    const config = { ...options, id, path, next }
    if (route) {
      config.route = route
    }
    if (parentConfig.id) {
      const { id, path, options, parent } = parentConfig
      config.parent = { id, path, options }
      if (parent) {
        // allow parent access
        config.parent.parent = parent
      }
    }
    if (module) {
      return loadMap(module, { ...config, moduleId: id, module, modulePath })
    }
    return config
  }).flat()
}

function fixMapNav (config) {
  Object.values(config).forEach((item) => {
    const fixNext = (next) => {
      if (config[next]) {
        return next
      }
      return Object.keys(config).find((id) => id.startsWith(next + ':')) || next
    }
    const { next } = item
    if (next) {
      if (next.query) {
        Object.entries(next.when).map(([prop, val]) => {
          next.when[prop] = fixNext(val)
        })
        item.next = next
      } else {
        item.next = fixNext(item.next)
      }
    } else {
      delete item.next
    }
  })
  return config
}

// convert an array of routes into an object where the key for each route is the route id
function buildMap (path) {
  const map = loadMap('', { modulePath: path }).reduce((map, route) => {
    return { ...map, [route.id]: route }
  }, {})
  // Now fix any "next" values pointing to a module so that they point to the first route in the module map
  return fixMapNav(map)
}

function registerInquiryRoute (server, journeyMapPath, modulePath, journeyMapView) {
  server.route({
    method: 'GET',
    path: journeyMapPath,
    handler: async (request, h) => {
      const map = JourneyMap.getMap(modulePath)
      return journeyMapView ? h.view(journeyMapView, { map }) : h.response(map)
    }
  })
  server.route({
    method: 'GET',
    path: `${journeyMapPath}/{id}`,
    handler: async (request, h) => {
      const { id } = request.params
      const map = await JourneyMap.getMap(modulePath)
      // Only return the routes that start with the id
      Object.keys(map)
        .filter((key) => !(id === key || key.startsWith(id + ':')))
        .forEach((id) => delete map[id])
      return journeyMapView ? h.view(journeyMapView, { map }) : h.response(map)
    }
  })
}

module.exports = JourneyMap