index.js

import path from 'path'
import config from './lib/config.js'
import {
  handleResponseXml, extractHeaders, buildPropsReqsForSchema, buildResponseForSchema
} from './lib/helper.js'

/**
 * Plugin factory.
 *
 * **Never** call this function directly!!! It's only-meant to be called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin initialization.
 *
 * @param {string} pkgName - NPM package name
 * @returns {WaibuRestApi} WaibuRestApi class
 */
async function factory (pkgName) {
  const me = this
  const { defaultsDeep } = this.app.lib.aneka
  const { importModule, runHook } = this.app.bajo
  const { getPluginPrefix } = this.app.waibu
  const { methodMap, getParams } = this.app.waibuDb
  const { pick, isFunction, merge, cloneDeep, each, get } = this.app.lib._

  /**
   * WaibuRestApi class defniton. This class is used to create a REST API services for Waibu.
   *
   * @class
   */
  class WaibuRestApi extends this.app.baseClass.Base {
    /**
     * Constructor
     */
    constructor () {
      super(pkgName, me.app)
      this.routePathHandlers = ['restapi']
      this.config = config
    }

    /**
     * Initialize the plugin.
     *
     * @async
     * @method
     */
    init = async () => {
      const { uniq } = this.app.lib._
      this.config.format.supported.push('json')
      this.config.format.supported = uniq(this.config.format.supported)
    }

    /**
     * Generate the route path for a given name.
     *
     * @param {string} name - The name of the route
     * @param {Object} [options={}] - Options for route path generation
     * @param {string|boolean} [options.defFormat='.json'] - Default format for the route
     * @param {boolean} [options.uriEncoded=true] - Whether to URI encode the path
     * @returns {string} The generated route path
     */
    routePath = (name, { defFormat = '.json', uriEncoded = true } = {}) => {
      const { get, trimStart } = this.app.lib._
      const { fullPath, ns } = this.app.bajo.breakNsPath(name)
      const prefix = get(this.app[ns], 'config.waibu.prefix', this.app[ns].alias)
      const format = defFormat === false ? '' : (this.config.format.asExt ? defFormat : '')
      let path = `/${this.config.waibu.prefix}/${prefix}${fullPath}${format}`
      if (uriEncoded) path = path.split('/').map(p => encodeURI(p)).join('/')
      return '/' + trimStart(path, '/')
    }

    /**
     * Transform the result before sending the response.
     *
     * @param {Object} param0 - The parameters for transforming the result
     * @param {Object} param0.data - The data to be transformed
     * @param {Object} param0.req - The request object
     * @param {Object} param0.reply - The reply object
     * @param {Object} [param0.options={}] - Additional options for transformation
     * @returns {Object} The transformed result
     */
    transformResult = ({ data, req, reply, options = {} }) => {
      const reformat = ({ data, req, reply, options = {} }) => {
        const { forOwn, get } = this.app.lib._
        const newData = {}
        forOwn(data, (v, k) => {
          let key = get(this.config, `responseKey.${k}`)
          if (options.forFind && k === 'data') key = get(this.config, 'responseKey.data')
          if (!key) return undefined
          newData[key] = v
        })
        return newData
      }

      const returnError = ({ data, req, reply, options = {} }) => {
        const { pascalCase } = this.app.lib.aneka
        const { last, map, kebabCase, upperFirst, keys, each, get, isEmpty } = this.app.lib._
        const cfg = this.config
        const cfgWdb = this.app.waibuDb.config
        const errNames = kebabCase(data.constructor.name).split('-')
        if (last(errNames) === 'error') errNames.pop()
        data.error = this.t(map(errNames, s => upperFirst(s)).join(' '))
        data.success = false
        data.statusCode = data.statusCode ?? 500
        if (reply && cfgWdb.dbModel.dataOnly) {
          each(keys(data), k => {
            const key = get(cfg, `responseKey.${k}`, k)
            if (k === 'details' && !isEmpty(data[k])) data[k] = JSON.stringify(data[k])
            reply.header(`X-${pascalCase(this.alias)}-${pascalCase(key)}`, data[k])
          })
        }
        reply.code(data.statusCode)
        const result = cfgWdb.dbModel.dataOnly ? { error: data.message } : data
        return reformat.call(this, { data: result, req, reply, options })
      }

      const returnSuccess = ({ data, req, reply, options = {} }) => {
        const { pascalCase } = this.app.lib.aneka
        const { each, keys, omit, get } = this.app.lib._
        const cfg = this.config
        const cfgWdb = this.app.waibuDb.config
        if (!options.showWarnings) delete data.warnings
        if (reply) {
          reply.code(req.method.toUpperCase() === 'POST' ? 201 : 200)
          if (cfgWdb.dbModel.dataOnly) {
            each(keys(omit(data, ['data'])), k => {
              const key = get(cfg, `responseKey.${k}`, k)
              reply.header(`X-${pascalCase(this.alias)}-${pascalCase(key)}`, data[k])
            })
            return data.data
          }
        }
        return reformat({ data, req, reply, options })
      }

      const isError = data instanceof Error
      if (isError) return returnError({ data, req, reply, options })
      return returnSuccess({ data, req, reply, options })
    }

    /**
     * Generate the schema description for a given method.
     *
     * @method
     * @param {string} method - The method name
     * @returns {string} The schema description
     */
    docSchemaDescription = (method) => {
      const desc = {
        create: 'postRecordWithBody',
        find: 'findRecordsWithFilter',
        get: 'getRecordById',
        update: 'updateRecordByIdWithBody',
        remove: 'removeRecordById'
      }
      return this.t(desc[method])
    }

    /**
     * Generate the schema for the "find" operation.
     *
     * @async
     * @method
     * @param {Object} items - The items schema
     * @returns {Object} The schema for the "find" operation
     */
    docSchemaForFind = async (items) => {
      return {
        data: {
          type: 'array',
          items
        },
        limit: { type: 'integer' },
        page: { type: 'integer' },
        pages: { type: 'integer' },
        count: { type: 'integer' },
        success: { type: 'boolean', default: true },
        statusCode: { type: 'integer', default: 200 },
        warnings: { type: 'array' }
      }
    }

    /**
     * Generate the general schema for the API responses.
     *
     * @async
     * @method
     * @returns {Object} The general schema for API responses
     */
    docSchemaGeneral = async () => {
      const cfg = this.config
      const cfgWdb = this.app.waibuDb.config
      const def = {
        type: 'object',
        properties: {
          error: { type: 'string' },
          message: { type: 'string' }
        }
      }
      for (const type of ['4xx', '5xx']) {
        const item = cloneDeep(def)
        if (type === '4xx') {
          merge(item, {
            properties: {
              details: {
                type: 'array',
                items: {
                  type: 'object',
                  properties: {
                    field: { type: 'string' },
                    error: { type: 'string' }
                  }
                }
              },
              success: { type: 'boolean', default: false },
              statusCode: { type: 'integer', default: 400 }
            }
          })
        } else {
          merge(item, {
            properties: {
              success: { type: 'boolean', default: false },
              statusCode: { type: 'integer', default: 500 }
            }
          })
        }
        if (cfgWdb.dbModel.dataOnly) item.properties = { error: item.properties.message }
        const props = {}
        each(item.properties, (v, k) => {
          const key = get(cfg, `responseKey.${k}`, k)
          props[key] = item.properties[k]
        })
        item.properties = props
        await this.docSchemaLib(type + 'Resp', item)
      }
      await this.docSchemaParams('qsFilter',
        'query||NQL/Mongo Query. Leave empty to disable query',
        'limit|integer|Number of records per page. Must be >= 1|' + this.app.dobo.config.default.filter.limit,
        'page|integer|Desired page number. Must be >= 1|1',
        'sort||Order of records, format: <field>:<dir>[,<field>:<dir>,[...]]',
        'fields||Comma delimited fields to show. Leave empty to show all fields',
        true
      )
      await this.docSchemaParams('paramsId', 'id||Record ID')
      await this.docSchemaParams('qsFields',
        'fields||Comma delimited fields to show. Leave empty to show all fields',
        true
      )
    }

    /**
     * Add a schema to the library.
     *
     * @async
     * @method
     * @param {string} name - The name of the schema
     * @param {Object} obj - The schema object
     */
    docSchemaLib = async (name, obj) => {
      const { merge } = this.app.lib._
      if (this.webAppCtx.getSchema(name)) return
      const value = merge({}, obj, { $id: name })
      this.webAppCtx.addSchema(value)
    }

    /**
     * Generate the schema for a model method.
     *
     * @async
     * @method
     * @param {Object} param0 - The parameters for generating the schema
     * @param {string} param0.model - The model name
     * @param {string} param0.method - The method name
     * @param {Object} [param0.options={}] - Additional options for schema generation
     * @returns {Object} The generated schema
     */
    docSchemaModel = async ({ model, method, options = {} }) => {
      const mdl = this.app.dobo.getModel(model)
      const { omit } = this.app.lib._
      const out = {
        description: options.description ?? this.docSchemaDescription(method),
        tags: [this.alias.toUpperCase(), ...(options.alias ?? [])]
      }
      if (['find'].includes(method)) {
        out.querystring = { $ref: 'qsFilter#' }
      }
      if (['get', 'update', 'replace', 'remove'].includes(method)) {
        out.querystring = { $ref: 'qsFields#' }
        if (!options.noId) out.params = { $ref: 'paramsId#' }
      }
      if (['update'].includes(method)) {
        const { properties } = await buildPropsReqsForSchema.call(this, { model: mdl, method, options })
        const name = 'model' + mdl.name + 'Update'
        delete properties._ref
        await this.docSchemaLib(name, {
          type: 'object',
          properties: omit(properties, ['id'])
        })
        out.body = { $ref: name + '#' }
      }
      if (['replace'].includes(method)) {
        const { properties, required } = await buildPropsReqsForSchema.call(this, { model: mdl, method, options })
        const name = 'model' + model.name + 'Replace'
        delete properties._ref
        await this.docSchemaLib(name, {
          type: 'object',
          properties: omit(properties, ['id']),
          required
        })
        out.body = { $ref: name + '#' }
      }
      if (['create'].includes(method)) {
        const { properties, required } = await buildPropsReqsForSchema.call(this, { model: mdl, method, options })
        const name = 'model' + model.name + 'Create'
        delete properties._ref
        await this.docSchemaLib(name, {
          type: 'object',
          properties,
          required
        })
        out.body = { $ref: name + '#' }
        out.querystring = { $ref: 'qsFields#' }
      }
      out.response = await buildResponseForSchema.call(this, { model: mdl, method, options })
      return out
    }

    /**
     * Generate the schema for query parameters.
     *
     * @async
     * @method
     * @param {string} paramName - The name of the parameter
     * @param {...string} args - The arguments for the parameter schema
     * @returns {void}
     */
    docSchemaParams = async (paramName, ...args) => {
      const { each, isEmpty, keys, last, isBoolean } = this.app.lib._
      const cfgWeb = this.app.waibu.getConfig()
      let transform = false
      if (isBoolean(last(args))) {
        transform = args.pop()
      }
      const item = {
        type: 'object',
        properties: {}
      }
      each(args, a => {
        let [name, type, description, def] = a.split('|')
        if (isEmpty(type)) type = 'string'
        item.properties[name] = { type, description: this.t(description), default: def }
      })
      if (transform) {
        each(keys(cfgWeb.qsKey), k => {
          const v = cfgWeb.qsKey[k]
          if (k === v || !item.properties[k]) return undefined
          item.properties[v] = item.properties[k]
          delete item.properties[k]
        })
      }
      if (!isEmpty(args)) await this.docSchemaLib(paramName, item)
    }

    /**
     * Generate the schema for query string parameters.
     *
     * @async
     * @method
     * @param {string} paramName - The name of the parameter
     * @param {...string} args - The arguments for the parameter schema
     * @returns {void}
     */
    docSchemaQs = async (paramName, ...args) => {
      const { each, isEmpty } = this.app.lib._
      const item = {
        type: 'object',
        properties: {}
      }
      each(args, a => {
        let [name, type, description] = a.split(':')
        if (isEmpty(type)) type = 'string'
        item.properties[name] = { type, description: this.t(description) }
      })
      if (!isEmpty(args)) await this.docSchemaLib(paramName, item)
    }

    /**
     * Handle not found errors.
     *
     * @async
     * @method
     * @param {Object} [err={}] - The error object
     * @param {Object} req - The request object
     * @param {Object} reply - The reply object
     * @returns {Object} The transformed result
     */
    _handleNotFound = async (err = {}, req, reply) => {
      const msg = this.t('routeNotFound%s%s', req.url, req.method)
      const data = this.webAppCtx.httpErrors.createError(404, msg)
      return this.transformResult({ data, req, reply })
    }

    /**
     * Handle general errors.
     *
     * @async
     * @method
     * @param {Object} err - The error object
     * @param {Object} req - The request object
     * @param {Object} reply - The reply object
     * @returns {Object} The transformed result
     */
    _handleError = async (err, req, reply) => {
      err.statusCode = err.statusCode ?? 500
      const msg = err.message
      if (msg === '_notFound' || err.statusCode === 404) {
        return this._handleNotFound(err, req, reply)
      }
      const data = this.webAppCtx.httpErrors.createError(err.statusCode, msg)
      data.statusCode = err.statusCode
      data.details = err.details
      return this.transformResult({ data, req, reply })
    }

    /**
     * Handle the response.
     *
     * @async
     * @method
     * @returns {void}
     */
    _handleResponse = async () => {
      const me = this

      this.webAppCtx.addHook('onSend', async function (req, reply, payload) {
        let type = req.params.format
        if (!type) {
          const types = req.accepts().types()
          if (me.config.format.supported.includes('xml') && types[0] === 'application/xml') type = 'xml'
          else type = 'json'
        }
        let result = payload
        switch (type) {
          case 'xml': result = await handleResponseXml.call(me, req, reply, payload); break
        }
        return result
      })
    }

    /**
     * Serve a route.
     *
     * @async
     * @method
     * @param {Object} options - The options for serving the route
     * @param {Object} options.mod - The module object
     * @param {string} options.ns - The namespace
     * @param {string} [options.parent] - The parent namespace
     * @returns {Object} The served route
     */
    _serveRoute = async (options) => {
      let { mod, ns, parent } = options
      const { mergeRouteHooks } = await importModule('waibu:/lib/webapp.js', { asDefaultImport: false })
      const cfgWdb = this.app.waibuDb.config
      mod.config = mod.config ?? {}
      mod.config.prefix = getPluginPrefix(ns, 'waibuRestApi')
      mod.config.pathSrc = mod.url
      mod.config.webApp = parent ?? ns
      mod.config.ns = ns
      mod.config.subNs = 'restapi'
      mod.config.xSite = mod.xSite
      mod.config.title = mod.title
      delete mod.title
      if (mod.method === 'PUT' && cfgWdb.dbModel.patchEnabled) mod.method = [mod.method, 'PATCH']
      mod = defaultsDeep(pick(this.config, ['exposeHeadRoute', 'bodyLimit']), mod)
      await mergeRouteHooks.call(this, mod, false) // Don't include handler, hence 'false'
      return mod
    }

    /**
     * Build a route based on the model configuration.
     *
     * @async
     * @method
     * @param {Object} options - The options for building the route
     * @param {string} options.file - The file path of the model configuration
     * @param {string} options.dir - The directory of the model configuration
     * @param {string} options.pathPrefix - The path prefix for the route
     * @param {string} options.ns - The namespace
     * @param {string} [options.parent] - The parent namespace
     * @returns {Object} The built route
     */
    _routeByModelBuilder = async (options) => {
      const { file, dir, pathPrefix, ns, parent } = options
      const { readConfig } = this.app.bajo
      const { pascalCase } = this.app.lib.aneka
      const { methodMap, getParams } = this.app.waibuDb
      const { isFunction, camelCase, omit, merge, keys, filter } = this.app.lib._
      const builder = await readConfig(file, { ns, ignoreError: true })
      builder.disabled = builder.disabled ?? []
      let url = path.dirname(file).replace(`${dir}/extend/${pathPrefix}`, '').replaceAll('@', ':')
      if (isFunction(builder.url)) url = await builder.url.call(this)
      const methods = keys(methodMap)
      if (this.config.enablePatch) methods.push('replace')
      const me = this
      const mods = []
      let model = {}
      if (!builder.model.startsWith(':')) model = this.app.dobo.getModel(builder.model)
      for (const method of methods) {
        let disabled = (model.disabled ?? []).includes(method) || builder.disabled.includes(method)
        if (method === 'replace' && ((model.disabled ?? []).includes('update') || (builder.disabled ?? []).includes('update'))) disabled = true
        if (disabled) continue
        const mod = omit(builder, ['model', 'url', 'method', 'handler', 'hidden', ...methods])
        const customMod = builder[method] ?? {}
        customMod.schema = customMod.schema ?? false
        if (customMod.schema === true && !builder.model.startsWith(':')) customMod.schema = await this.docSchemaModel({ model: builder.model, method, options: { hidden: builder.hidden } })
        let mapmethod = methodMap[method]
        if (this.config.enablePatch) {
          if (method === 'replace') mapmethod = 'PUT'
          else if (method === 'update') mapmethod = 'PATCH'
        }
        merge(mod, customMod, {
          url: ['get', 'update', 'replace', 'remove'].includes(method) ? `${url}/:id` : url,
          method: mapmethod,
          handler: async function (req, reply) {
            const helper = me.app.waibuDb[`${method === 'replace' ? 'update' : method}Record`]
            const { fields } = getParams(req)
            const options = { fields, dynHooks: builder.hooks ?? [] }
            extractHeaders.call(me, req, options)
            options.hidden = builder.hidden ?? []
            options.queryHandler = builder.queryHandler
            if (method === 'replace') options.partial = false
            if (['get', 'remove', 'update'].includes(method)) options.throwNotFound = true
            const model = builder.model.startsWith(':') ? pascalCase(req.params[builder.model.slice(1)]) : builder.model
            const data = await helper({ model, req, reply, options, transaction: builder.transaction })
            data.success = true
            data.statusCode = method === 'create' ? 201 : 200
            if (method === 'find') options.forFind = true
            return me.transformResult({ data, req, reply, options })
          }
        })
        if (mod.schema === false) delete mod.schema
        mod.config = mod.config ?? {}
        mod.config.pathSrc = url
        mods.push(await me._serveRoute({ mod, ns, parent }))
      }
      // stats
      const supported = ['aggregate', 'histogram']
      builder.stat = filter(builder.stat ?? supported, item => supported.includes(item))
      const method = 'GET'
      const disabled = (model.disabled ?? []).includes(method) || builder.disabled.includes(method)
      if (disabled) return mods
      const mod = {
        schema: false,
        method,
        url: `${url}/stat/:stat`,
        handler: async function (req, reply) {
          if (!supported.includes(req.params.stat)) throw me.error('_notFound')
          const helper = me.app.waibuDb[camelCase(`create ${req.params.stat}`)]
          if (!helper) throw me.error('_notFound')
          const options = { queryHandler: builder.queryHandler }
          const model = builder.model.startsWith(':') ? pascalCase(req.params[builder.model.slice(1)]) : builder.model
          const data = await helper({ model, req, reply, options })
          data.success = true
          data.statusCode = method === 'create' ? 201 : 200
          options.forFind = true
          return me.transformResult({ data, req, reply, options })
        }
      }
      mod.config = builder.config ?? {}
      mod.config.pathSrc = url
      mods.push(await me._serveRoute({ mod, ns, parent }))
      return mods
    }

    /**
     * Build a route based on the HTTP verb.
     *
     * @async
     * @method
     * @param {Object} options - The options for building the route
     * @param {string} options.file - The file path of the route configuration
     * @param {Object} options.appCtx - The application context
     * @param {string} options.dir - The directory of the route configuration
     * @param {string} options.pathPrefix - The path prefix for the route
     * @param {string} options.ns - The namespace
     * @param {string} options.alias - The alias for the route
     * @param {string} [options.parent] - The parent namespace
     * @returns {Object} The built route
     */
    _routeByVerb = async (options) => {
      const { file, appCtx, dir, pathPrefix, ns, alias, parent } = options
      const me = this
      const url = path.dirname(file).replace(`${dir}/extend/${pathPrefix}`, '')
        .replaceAll('@@', '*').replaceAll('@', ':')
      const action = path.basename(file, '.js')
      let method = methodMap[action]
      if (this.enablePatch) {
        if (action === 'update') method = 'PATCH'
        else if (action === 'replace') method = 'PUT'
      }
      let mod = await importModule(file)
      if (isFunction(mod)) {
        if (mod.length <= 1) mod = await mod.call(this)
        else mod = { handler: mod }
      }
      mod.url = mod.url ?? url
      if (isFunction(mod.url)) mod.url = await mod.url.call(this)
      mod.method = method
      const defSchema = {
        description: this.docSchemaDescription(action),
        tags: [alias.toUpperCase()],
        response: {}
      }
      if (['create', 'update', 'replace'].includes(action)) {
        defSchema.response['4xx'] = {
          description: this.t('docErrorResponse'),
          $ref: '4xxResp#'
        }
      }
      defSchema.response['5xx'] = {
        description: this.t('generalErrorResponse'),
        $ref: '5xxResp#'
      }
      if (action === 'find') defSchema.querystring = { $ref: 'qsFilter#' }
      if (isFunction(mod.schema)) mod.schema = await mod.schema.call(this, appCtx)
      mod.schema = merge({}, defSchema, mod.schema ?? {})
      const oldHandler = mod.handler
      mod.handler = async function (req, reply) {
        const { fields } = getParams(req)
        const options = { fields, dynHooks: mod.hooks ?? [] }
        extractHeaders.call(me, req, options)
        if (method === 'find') options.forFind = true
        if (method === 'replace') options.partial = false
        const data = await oldHandler.call(me.app[ns], req, reply, options, this)
        if (!data) return
        data.success = true
        data.statusCode = method === 'create' ? 201 : 200
        return me.transformResult({ data, req, reply, options })
      }
      if (mod.schema === false) delete mod.schema
      mod.config = mod.config ?? {}
      mod.config.pathSrc = url
      return me._serveRoute({ mod, ns, parent })
    }

    /**
     * Handle sub-applications by collecting and registering their routes.
     *
     * @async
     * @method
     */
    _handleSubApp = async () => {
      const { collectWebApps } = await importModule('waibu:/lib/helper.js', { asDefaultImport: false })
      const mods = await collectWebApps.call(this.app[this.ns], 'boot.js', 'waibuRestApi')
      for (const m of mods) {
        this.log.debug('bootSubApp%s', m.ns)
        await this.webAppCtx.register(async (subCtx) => {
          this.app[m.ns].instance = subCtx
          await runHook(`${this.ns}.${m.alias}:afterCreateContext`, subCtx)
          await m.handler.call(this.app[m.ns], subCtx, m.prefix)
        }, { prefix: m.prefix })
      }
    }
  }

  return WaibuRestApi
}

export default factory