senecajs/seneca-gateway

View on GitHub
src/gateway.ts

Summary

Maintainability
A
3 hrs
Test Coverage
/* Copyright © 2021-2023 Richard Rodger, MIT License. */


import Seneca from 'seneca'

const { Open, Skip } = Seneca.valid


// Seneca action result.
type GatewayResult = {

  // The action result (transport externalized).
  // OR: a description of the error.
  out: any | {

    // Externalized meta.
    meta$: any

    // Extracted from Error object
    error$: {
      name: string
      id: string
      code?: string
      message?: string
      details?: any
    }
  }

  // Indicate if result was an error.
  error: boolean

  // Original meta object.
  meta?: any

  // Gateway directives embedded in action result.
  // NOTE: $ suffix as output directive.
  gateway$?: Record<string, any>
}


// See defaults below for behaviour.
type GatewayOptions = {
  allow: Record<string, boolean | (string | object)[]>
  custom: any
  fixed: any
  timeout: {
    client: boolean
    max: number
  }
  error: {
    message: boolean
    details: boolean
  }
  debug: {
    response: boolean
    log: boolean
  }
}


function gateway(this: any, options: GatewayOptions) {
  let seneca: any = this
  const root: any = seneca.root
  const tu: any = seneca.export('transport/utils')

  const Patrun = seneca.util.Patrun
  const Jsonic = seneca.util.Jsonic
  const allowed = new Patrun({ gex: true })
  const errid = seneca.util.Nid({ length: 9 })

  const checkAllowed = null != options.allow

  if (checkAllowed) {
    for (let msgCanon in options.allow) {
      let msgCanonObj = Jsonic(msgCanon)
      let paramPats = options.allow[msgCanon]
      let paramPatObjs

      let paramAllowed = false

      if (true === paramPats) {
        paramAllowed = true
      }
      else if (Array.isArray(paramPats)) {
        paramPatObjs = paramPats.map(pp => Jsonic(pp))
        paramAllowed = 0 < paramPatObjs.length ?
          paramPatObjs.reduce((patrun: typeof Patrun, pp: object) =>
            (patrun.add(pp, JSON.stringify(pp).replace(/"/g, ''))), new Patrun({ gex: true })) :
          true
      }

      if (options.debug.log) {
        root.log.debug('gateway-allow-pattern', msgCanonObj, paramPatObjs)
      }

      allowed.add(msgCanonObj, paramAllowed)
    }
  }


  const hooknames = [

    // Functions to modify the custom object in Seneca message meta$ descriptions
    'custom',

    // Functions to modify the fixed arguments to Seneca messages
    'fixed',

    // Functions to modify the seneca request delegate
    'delegate',

    // TODO: rename: before
    // Functions to modify the action or message
    'action',

    // TODO: rename: after
    // Functions to modify the result
    'result'
  ]

  const hooks: any = hooknames.reduce((a: any, n) => (a[n] = [], a), {})

  const tag = seneca.plugin.tag
  if (null != tag && '-' !== tag) {
    seneca = seneca.fix({ tag })
  }


  seneca.message('sys:gateway,add:hook', async function add_hook(msg: any) {
    let hook: string = msg.hook
    let action: (...params: any[]) => any = msg.action

    if (null != action) {
      let hookactions = hooks[hook]
      hookactions.push(action)
      return { ok: true, hook, count: hookactions.length }
    }
    else {
      // TODO: this should fail, as usually a startup action
      // this.throw('no-action', {hook})
      return { ok: false, why: 'no-action' }
    }
  })


  seneca.message('sys:gateway,get:hooks', async function get_hook(msg: any) {
    let hook: string = msg.hook
    let hookactions = hooks[hook]
    return { ok: true, hook, count: hookactions.length, hooks: hookactions }
  })


  // Handle inbound JSON, converting it into a message, and submitting to Seneca.
  async function handler(json: any, ctx?: any) {
    if (options.debug.log) {
      root.log.debug('gateway-handler-json', { json })
    }

    const gateway$ = ctx?.gateway$ || {}
    const seneca = await prepare(json, ctx)
    const rawmsg = tu.internalize_msg(seneca, json)
    const msg = seneca.util.clean(rawmsg)

    // Clients can set a custom timeout, up to a maximum.
    if (options.timeout.client && null != rawmsg.timeout$) {
      let clientTimeout = +rawmsg.timeout$
      let maxTimeout = options.timeout.max
      maxTimeout = 0 < maxTimeout ? maxTimeout : seneca.options().timeout
      if (clientTimeout <= maxTimeout) {
        msg.timeout$ = clientTimeout
      }
    }

    return await new Promise(async (resolve) => {
      if (checkAllowed) {
        let allowMsg = false
        let allowParams = null


        // First, find msg that will be called
        let msgdef = seneca.find(msg)

        if (msgdef) {
          // Second, check found msg matches allowed patterns
          // NOTE: just doing allowed.find(msg) will enable separate messages
          // to sneak in: if foo:1 is allowed but not defined, foo:1,role:seneca,...
          // will still work, which is not what we want! However we also need
          // to check that any additional message parameters not in the msg canon also match.
          allowParams = allowed.find(msgdef.msgcanon)
        }
        else {
          seneca.log.debug('msg-not-found', { msg })
        }


        if (true === allowParams) {
          allowMsg = true
        }
        else if (allowParams?.find) {
          allowMsg = allowParams.find(msg)
        }


        if (!allowMsg) {
          let errdesc: any = {
            name: 'Error',
            id: errid(),
            code: 'not-allowed',
            message: 'Message not allowed',
            allowed: undefined,
          }

          if (options.debug.response) {
            errdesc.pattern = msgdef ? msgdef.pattern : undefined
            errdesc.allowed = msgdef ? allowMsg : undefined
          }

          if (options.debug.log) {
            seneca.log.debug('handler-not-allowed',
              { allowMsg, pattern: msgdef?.pattern, errdesc, msgdef, msg })
          }

          return resolve({
            error: true,

            // Follow seneca transport structure
            out: {
              ...nundef(errdesc),

              meta$: {
                id: rawmsg.id$,
                error: true
              },

              // DEPRECATED: backwards compat
              error$: nundef(errdesc)
            }
          })
        }
        else {
          if (options.debug.log) {
            seneca.log.debug('handler-allowed', { pattern: msgdef.pattern, params: allowMsg })
          }
        }
      }

      let out = null
      for (var i = 0; i < hooks.action.length; i++) {
        out = await hooks.action[i].call(seneca, msg, ctx)
        if (out) {
          if (options.debug.log) {
            seneca.log.debug('handler-hook-action', { out, msg })
          }
          return resolve(out)
        }
      }

      if (options.debug.log) {
        seneca.log.debug('handler-act', { msg })
      }

      if (gateway$.local) {
        msg.local$ = true
      }

      seneca.act(msg, async function(this: any, err: any, out: any, meta: any) {
        for (var i = 0; i < hooks.result.length; i++) {
          await hooks.result[i].call(seneca, out, msg, err, meta, ctx)
        }

        if (err && !options.debug.response) {
          err.stack = null
        }

        out = tu.externalize_reply(this, err, out, meta)

        // Don't expose internal activity unless debugging
        if (!options.debug.response) {
          if (out.meta$) {
            out.meta$ = {
              id: out.meta$.id
            }
          }
        }

        let result: GatewayResult = {
          error: false,
          out,
          meta,
          gateway$: out.gateway$ || {}
        }

        // Directives in gateway$ moved to result
        delete out.gateway$

        if (err) {
          result.error = true
          out.meta$.error = true

          result.out = nundef({
            meta$: out.meta$,
            name: err.name,
            id: (err as any).id || errid(),
            code: (err as any).code,
            message: options.error.message ? err.message : undefined,
            details: options.error.details ? err.details : undefined,
          })
        }

        resolve(result)
      })
    })
  }


  async function prepare(json: any, ctx: any) {
    let i, hookaction

    let custom: any = seneca.util.deep({}, options.custom)
    for (i = 0; i < hooks.custom.length; i++) {
      hookaction = hooks.custom[i]
      if ('object' === typeof (hookaction)) {
        custom = seneca.util.deep(custom, hookaction)
      }
      else {
        await hookaction(custom, json, ctx)
      }
    }


    let fixed: any = seneca.util.deep({}, options.fixed)
    for (i = 0; i < hooks.fixed.length; i++) {
      hookaction = hooks.fixed[i]
      if ('object' === typeof (hookaction)) {
        fixed = seneca.util.deep(fixed, hookaction)
      }
      else {
        await hookaction(fixed, json, ctx)
      }
    }

    if (options.debug.log) {
      root.log.debug('gateway-delegate-params', { fixed, custom })
    }

    // NOTE: a new delegate is created for each request to ensure isolation.
    const delegate = root.delegate(fixed, { custom: custom })

    for (i = 0; i < hooks.delegate.length; i++) {
      await hooks.delegate[i].call(delegate, json, ctx)
    }

    return delegate
  }


  function parseJSON(data: any) {
    if (null == data) return {}

    let str = String(data)

    try {
      return JSON.parse(str)
    } catch (e: any) {
      e.handler$ = {
        error$: e.message,
        input$: str,
      }
      return e
    }
  }

  return {
    exports: {
      prepare,
      handler,
      parseJSON,
    }
  }
}


function nundef(o: any) {
  for (let p in o) {
    if (undefined === o[p]) {
      delete o[p]
    }
  }
  return o
}


// Default options.
gateway.defaults = ({

  // Keys are pattern strings.
  allow: Skip(Open({})),

  // Add custom meta data values.
  custom: Open({

    // Assume gateway is used to handle external messages.
    safe: false
  }),

  // Set request delegate fixed values.
  fixed: Open({}),

  // Allow clients to set a custom timeout (using the timeout$ directive).
  timeout: {

    // Clients can set a custom timeout.
    client: false,

    // Maximum value of client-set timeout.
    // Default is same as Seneca delegate.
    max: -1
  },

  error: {
    // Include exception object message property in response.
    message: false,

    // Include exception object details property in response.
    details: false,
  },


  // Control debug output.
  debug: {

    // When true, errors will include stack trace and other meta data.
    response: false,

    // Produce detailed debug logging.
    log: false,
  }
} as GatewayOptions)


export type {
  GatewayOptions,
  GatewayResult,
}

export default gateway

if ('undefined' !== typeof (module)) {
  module.exports = gateway
}