Home Reference Source

lib/index.js

import pluralize from 'pluralize'
import { createRestInstanceSymbol } from './symbol'

/**
 * @desc Controller object for `crud()` method {@link RestRoutes}
 * @since 0.9.0
 * @typedef {object} CrudController
 * @property {function} [beforeEach] Called before each request
 * @property {function} [afterEach] Called after each request
 * @property {function} [read] Handle GET /name
 * @property {function} [create] Handle POST /name
 * @property {function} [update] Handle PUT /name
 * @property {function} [destroy] handle DELETE /name
 * @example
 * const DemoController = {
 *   beforeEach(req, res, next) {
 *     // any checks here
 *     next()
 *   },
 *   create(req, res, next) {
 *     context.makeDemo.create(req.params)
 *     res.json({ complete: true })
 *     next()
 *   },
 *   afterEach(req, res, next) {
 *     // close your resources here
 *     context.db.close()
 *   }
 * }
 */

/**
 * @desc Controller object for `resources()` method. See {@link Maker.resources}
 * @since 0.9.0
 * @typedef {object} ResourcesController
 * @property {function} [beforeEach]  Hook will invoke before each handler in controller
 * @property {function} [afterEach]  Hook will invoke before after handler in controller
 * @property {function} [index]
 * @property {function} [create]
 * @property {function} [read]
 * @property {function} [update]
 * @property {function} [patch]
 * @property {function} [destroy]
 */

/**
 * Check is correct routes passed
 * @public
 * @since 0.7.0
 * @function
 * @param {RestRoutes} routes
 * @return {boolean}
 */
export const isCreateRestInstance = (routes) => routes[createRestInstanceSymbol] === true

/**
 * @desc Routes object
 * @protected
 * @typedef {object} RestRoutes
 * @property {Symbol} Symbol(createRestInstanceSymbol)
 * @property {object[]} before
 * @property {object[]} after
 * @property {object} scoped
 * @property {object} local
 */

/**
 * @class Maker
 * @protected
 */
export class Maker {
  /**
   * @private
   */
  constructor() {
    /**
     * @ignore
     */
    this.ctx = {
      before: [],
      after: [],
      scoped: {},
      local: {},
    }
  }

  /**
   * @private
   * Build routes ast
   */
  build() {
    const scoped = {}

    Object.keys(this.ctx.scoped).forEach((name) => {
      scoped[name] = this.ctx.scoped[name].build()
    })
    return Object.assign({}, this.ctx, { scoped, [createRestInstanceSymbol]: true })
  }

  /**
   * Add middlewares before request handler to current scope and all child scope
   * @param {...function} list List of the middlewares
   *
   * @example
   * const beforeHandler = () => console.log('before')
   * const getHandler = () => console.log('get')
   *
   * createRest(root => {
   *   root.beforeEach(beforeHandler)
   *   // GET /demo    beforeHandler(); getHandler()
   *   root.get('/demo', getHandler)
   * })
   * // Output:
   * // > before
   * // > get
   * @example <caption>With variable count</caption>
   * const handle1 = () => console.log('handle1')
   * const handle2 = () => console.log('handle2')
   *
   * createRest(root => {
   *   root.beforeEach(handle1, handle2)
   *   // same as
   *   root.beforeEach(handle1)
   *   root.beforeEach(handle2)
   * })
   */
  beforeEach(...list) {
    this.ctx.before = this.ctx.before.concat(list.filter((ln) => !!ln))
  }

  /**
   * Add middlewares after request handler to current scope and all child scope
   * @param {...function} list List of the middlewares
   * @example
   * const getHandler = () => console.log('get')
   * const afterHandler = () => console.log('after')
   *
   * createRest(root => {
   *   root.afterEach(afterHandler)
   *   // GET /demo    getHandler(); afterHandler()
   *   root.get('/demo', getHandler)
   * })
   * // Output:
   * // > get
   * // > after
   * @example <caption>You can combine beforeEach and afterEach</caption>
   * const getHandler = () => console.log('get foo')
   * const deleteHandler = () => console.log('DELETE requested')
   * const before1 = () => console.log('That is before 1')
   * const before2 = () => console.log('Second before')
   * const after = () => console.log('Just after request')
   *
   * const routes = createRest(root => {
   *   root.beforeEach(before1, before2)
   *   root.afterEach(after)
   *
   *   // GET /foo    before1(); before2(); getHandler(); after()
   *   root.get('/foo', getHandler)
   *
   *   root.scope('bar', bar => {
   *     // DELETE /bar/baz    before1(); before2(); deleteHandler(); after()
   *     bar.delete('/baz', deleteHandler)
   *   })
   * })
   * // Output on GET /foo request:
   * // > That is before 1
   * // > Second before
   * // > get foo
   * // > Just after request
   *
   * // Output on DELETE /bar/baz
   * // > That is before 1
   * // > Second before
   * // > DELETE requested
   * // > Just after request
   */
  afterEach(...list) {
    this.ctx.after = this.ctx.after.concat(list.filter((ln) => !!ln))
  }

  /**
   * Add scoped address, before/after handlers and simple handlers.<br/>
   * Before/After handlers is inherits from parent scope.<br/>
   * Scopes with the same name will be merged
   *
   * @param {string} name Name of the scope
   * @param {function(scope: Maker): void} creator
   * @throws {Error} "Name of the scope should be a word"
   * @throws {TypeError} "Name of the scope should be string!"
   * @return {void}
   * @since 0.2.0
   * @example
   * const before1 = () => console.log('before1')
   * const before2 = () => console.log('before2')
   * const after1 = () => console.log('after1')
   * const after2 = () => console.log('after2')
   * const bazHandler = () => console.log('baz')
   * const barHandler = () => console.log('bar')
   *
   * createRest(root => {
   *   root.beforeEach(before1)
   *   root.afterEach(after1)
   *
   *   // POST /baz   before1(); bazHandler(); after1()
   *   root.post('baz', bazHandler)
   *
   *   root.scope('foo', foo => {
   *     foo.beforeEach(before2)
   *     foo.afterEach(after2)
   *
   *     // GET /foo/bar   before1(); before2(); barHandler(); after2(); after1()
   *     foo.get('bar', barHandler)
   *   })
   * })
   */
  scope(name, creator) {
    if (typeof name !== 'string') {
      throw new TypeError('Name of the scope should be string!')
    }
    if (name.length === 0 || name.indexOf('/') !== -1) {
      throw new Error('Name of the scope should be a word')
    }

    const scopedCtx = this.ctx.scoped[name] || new Maker()

    creator(scopedCtx)
    this.ctx.scoped[name] = scopedCtx
  }

  /**
   * Add HTTP method listeners to local
   *
   * @private
   * @param {string} method
   * @param {function[]} listeners
   */
  local(method, listeners) {
    // if listeners for that methods exists, simple merge
    if (this.ctx.local[method]) {
      this.ctx.local[method] = this.ctx.local[method].concat(listeners)
    }
    else {
      this.ctx.local[method] = listeners
    }
  }

  /**
   * Handle method creator
   *
   * @ignore
   * @param {string} name
   * @param {string} method
   * @param {Function[]} listeners
   */
  method(method, listenersRaw) {
    let name = ''
    let listeners = listenersRaw

    if (typeof listeners[0] === 'string') {
      [name] = listeners.splice(0, 1)
    }
    name = name.replace(/^\//gm, '')

    if (name.indexOf('/') !== -1) {
      throw new Error('Path should not be deep')
    }
    if (listeners.length === 0) {
      throw new TypeError('Maybe you forget to add listener?')
    }

    listeners = listeners.filter((e) => typeof e === 'function')

    // if added undefined listeners
    if (listeners.length === 0) return

    if (name !== '') {
      let scoped = this.ctx.scoped[name]

      if (!scoped) {
        scoped = new Maker()
        this.ctx.scoped[name] = scoped
      }

      scoped.local(method, listeners)
    }
    else {
      this.local(method, listeners)
    }
  }

  /**
   * Handle POST HTTP method with single or many handlers
   * @param {string} [name] Route path. Default is current scope
   * @param {...function} handlers List of http-handlers
   * @throws {Error} Path should not be deep
   * @throws {TypeError} Maybe you forget to add listener?
   * @example
   * createRest(root => {
   *   root.post('name', () => console.log('Handled post /name request'))
   *   root.post(() => console.log('Handled post / request'))
   *   root.post('create',
   *     (req, res, next) => next(),
   *     authorize('user'),
   *     () => console.log('Handled post /create with middlewares')
   *   )
   * })
   */
  post(name, ...handlers) {
    this.method('POST', [name, ...handlers])
  }

  /**
   * Handle GET HTTP method with single or many handlers
   * @param {string} [name] Route path. Default is current scope
   * @param {...function} handlers List of http-handlers
   * @throws {Error} Path should not be deep
   * @throws {TypeError} Maybe you forget to add listener?
   * @example
   * createRest(root => {
   *   root.get('name', () => console.log('Handled get /name request'))
   *   root.get(() => console.log('Handled get / request'))
   *   root.get('create',
   *     (req, res, next) => next(),
   *     authorize('user'),
   *     () => console.log('Handled get /create with middlewares')
   *   )
   * })
   */
  get(name, ...handlers) {
    this.method('GET', [name, ...handlers])
  }

  /**
   * Handle PUT HTTP method with single or many handlers
   * @param {string} [name] Route path. Default is current scope
   * @param {...function} handlers List of http-handlers
   * @throws {Error} Path should not be deep
   * @throws {TypeError} Maybe you forget to add listener?
   * @example
   * createRest(root => {
   *   root.put('name', () => console.log('Handled put /name request'))
   *   root.put(() => console.log('Handled put / request'))
   *   root.put('create',
   *     (req, res, next) => next(),
   *     authorize('user'),
   *     () => console.log('Handled put /create with middlewares')
   *   )
   * })
   */
  put(name, ...handlers) {
    this.method('PUT', [name, ...handlers])
  }

  /**
   * Handle DELETE HTTP method with single or many handlers
   * @param {string} [name] Route path. Default is current scope
   * @param {...function} handlers List of http-handlers
   * @throws {Error} Path should not be deep
   * @throws {TypeError} Maybe you forget to add listener?
   * @example
   * createRest(root => {
   *   root.delete('name', () => console.log('Handled delete /name request'))
   *   root.delete(() => console.log('Handled delete / request'))
   *   root.delete('create',
   *     (req, res, next) => next(),
   *     authorize('user'),
   *     () => console.log('Handled delete /create with middlewares')
   *   )
   * })
   */
  delete(name, ...handlers) {
    this.method('DELETE', [name, ...handlers])
  }

  /**
   * Handle PATCH HTTP method with single or many handlers
   * @param {string} [name] Route path. Default is current scope
   * @param {...function} handlers List of http-handlers
   * @throws {Error} Path should not be deep
   * @throws {TypeError} Maybe you forget to add listener?
   * @example
   * createRest(root => {
   *   root.patch('name', () => console.log('Handled patch /name request'))
   *   root.patch(() => console.log('Handled patch / request'))
   *   root.patch('create',
   *     (req, res, next) => next(),
   *     authorize('user'),
   *     () => console.log('Handled patch /create with middlewares')
   *   )
   * })
   */
  patch(name, ...handlers) {
    this.method('PATCH', [name, ...handlers])
  }

  /**
   * @desc Configure your `crud('name', controller, options)` <br /> See {@link Maker.crud}
   * @typedef {object} crudOptions
   * @property {string[]} [only] Keep only that handlers: `read`, `create`, `update`, `destroy`
   * @property {string[]} [except] Keep all except that handlers
   * @property {object} [methodNames] Change method names
   * @since 0.9.0
   *
   * @example <caption>Usage with `only`</caption>
   * createRest(root => {
   *   root.crud('foo', FooController, { only: ['read'] })
   * })
   *
   * @example <caption>Usage with `except`</caption>
   * createRest(root => {
   *   root.crud('bar', BarController, { except: ['destroy', 'update'] })
   * })
   *
   * @example <caption>Method names</caption>
   * const Controller = {
   *   createDemo() {},
   *   updateMe() {},
   *   justExample() {},
   *   youDontNeedThis() {},
   * }
   * createRest(root => {
   *   root.crud('demo', Controller, { methodNames: {
   *     read: 'justExample', create: 'createDemo', update: 'updateMe', destroy: 'youDontNeedThis',
   *   }})
   * })
   */

  /**
   * Add CRUD methods for single resource. <br/>
   * CRUD methods not merging. __Use only one crud for path.__
   * @param {string} name Name of the resource. Create route path from
   * @param {CrudController} controller Object with methods
   * @param {crudOptions} [options={}] Options object
   * @param {function(scope: Maker): void} [creator=null] Scoped creator function
   * @since 0.9.0
   * @example <caption>Simple</caption>
   * const Controller = {
   *   read() {},
   *   create() {},
   *   update() {},
   *   destroy() {},
   *   beforeEach() {},
   *   afterEach() {},
   * }
   * const routes = createRest(root => {
   *   // GET /example     read()
   *   // POST /example    create()
   *   // PUT /example     update()
   *   // DELETE /example  destroy()
   *   root.crud('example', Controller)
   * })
   * @example <caption>Only/Except option</caption>
   * const Controller = {
   *   read() {},
   *   create() {},
   *   update() {},
   *   destroy() {},
   *   beforeEach() {},
   *   afterEach() {},
   * }
   * const routes = createRest(root => {
   *   // GET /demo    read()
   *   // POST /demo   create()
   *   root.crud('demo', Controller, { only: ['create', 'read'] })
   *
   *   // GET /single     read()
   *   // PUT /single     update()
   *   root.crud('single', Controller, { except: ['destroy', 'create'] })
   * })
   * @example <caption>With scope</caption>
   * const routes = createRest(root => {
   *   // GET /example
   *   // POST /example
   *   // PUT /example
   *   // DELETE /example
   *   root.crud('example', Controller, {}, example => {
   *     // GET /example/demo
   *     example.get('/demo', () => {})
   *   })
   * })
   */
  crud(name, controller, options = {}, creator = null) {
    if (!name || name.length === 0) {
      throw new Error('Resource should be named')
    }

    if (typeof controller === 'undefined') {
      return
    }

    const methods = {
      read: 'get',
      create: 'post',
      update: 'put',
      destroy: 'delete',
    }
    const methodsList = Object.keys(methods)

    const resolveMethodName = (handler, currentController) => (
      options.methodNames && options.methodNames[handler]
        ? currentController[options.methodNames[handler]]
        : currentController[handler]
    )

    /**
     * @var {Array<[string, string]>} usedList [[handlerName, httpMethod], ...]
     */
    let usedList = []

    if (Array.isArray(options.only)) {
      usedList = options.only
    }
    else if (Array.isArray(options.except)) {
      usedList = methodsList
        .filter((handlerName) => !options.except.includes(handlerName))
    }
    else {
      usedList = methodsList
    }

    this.scope(name, (scope) => {
      scope.beforeEach(controller.beforeEach)
      scope.afterEach(controller.afterEach)

      usedList
        .filter((handler) => methodsList.includes(handler))
        .map((handler) => [handler, methods[handler]])
        .forEach(([handler, httpMethod]) => {
          scope[httpMethod](resolveMethodName(handler, controller))
        })

      if (creator) {
        creator(scope)
      }
    })
  }

  /**
   * Configure your `resources('name', controller, options)`
   * <br/> You can't use `except` and `only` at the same time
   * <br/> Available handlers: `index`, `read`, `create`, `update`, `patch`, `destroy`
   * @typedef {object} resourcesOptions
   * @property {string[]} [only] Keep only that handlers
   * @property {string[]} [except] Keep all except that handlers
   * @property {string} [memberId] Change :memberId in the URI
   * @since 0.9.0
   *
   * @example <caption>Usage with `only`</caption>
   * createRest(root => {
   *   // GET /books          -> index()
   *   // GET /books/:bookId  -> read()
   *   root.resources('books', BooksController, { only: ['read', 'index'] })
   * })
   *
   * @example <caption>Usage with `except`</caption>
   * createRest(root => {
   *   // GET /songs              -> index()
   *   // POST /songs             -> create()
   *   // GET /songs/:songId      -> read()
   *   // PATCH /songs/:songId    -> patch()
   *   root.resources('songs', SongsController, { except: ['destroy', 'update'] })
   * })
   *
   * @example <caption>With beforeEach afterEach methods</caption>
   * const Controller = {
   *   beforeEach() {},
   *   afterEach() {},
   *   create() {},
   *   read() {},
   * }
   * // If controller no methods, no handlers creates
   *
   * createRest(root => {
   *   // GET /demo   beforeEach(); read(); afterEach()
   *   // POST /demo  beforeEach(); create(); afterEach()
   *   root.crud('demo', Controller)
   * })
   *
   * @example <caption>Member ID renaming</caption>
   * createRest(root => {
   *   // GET /images            -> index()
   *   // POST /images           -> create()
   *   // GET /images/:imgid     -> read()
   *   // PUT /images/:imgid     -> update()
   *   // PATCH /images/:imgid   -> patch()
   *   // DELETE /images/:imgid  -> destroy()
   *   root.resources('images', ImagesController, { memberId: 'imgid' })
   * })
   */

  /**
   * Add index, create, read, update, patch, remove methods to manage resource <br/>
   * See {@link ResourcesController} and {@link resourcesOptions}
   * @param {string} name Name of the resources. Path created from. Example: `books`
   * @param {ResourcesController} controller Object with methods
   * @param {resourcesOptions} [options={}] Options for resources
   * @param {function(scope: Maker): void} [creator=null] Scoped creator function
   * @return {void}
   * @throws {Error} "Resources should be named"
   * @throws {Error} "You can't use 'except' and 'only' options at the same time"
   * @throws {TypeError} "Controller should be object"
   *
   * @example <caption>Full example</caption>
   * createRest(root => {
   *   // GET /users             -> index()
   *   // POST /users            -> create()
   *   // GET /users/:userId     -> read()
   *   // PUT /users/:userId     -> update()
   *   // PATCH /users/:userId   -> patch()
   *   // DELETE /users/:userId  -> destroy()
   *   root.resources('users', UsersController)
   * })
   */
  resources(name, controller, options = {}, creator = null) {
    /**
     * index : get /
     * create : post /
     * read : get /:id
     * update : put /:id
     * patch : patch /:id
     * remove : delete /:id
     */
    if (!name || name.length === 0) {
      throw new Error('Resources should be named')
    }

    if (!controller || typeof controller !== 'object') {
      throw new TypeError('Controller should be object')
    }

    if (typeof options === 'function') {
      /* eslint-disable no-param-reassign */
      creator = options
      options = {}
      /* eslint-enable no-param-reassign */
    }

    if (options.only && options.except) {
      throw new Error('You can\'t use \'except\' and \'only\' options at the same time')
    }

    const noMethodsSlicingOption = (!options.only || options.only.length === 0)
      && (!options.except || options.except.length === 0)

    /**
     * Check method existing in options `only` or `except`
     * @param {string} methodName
     * @private
     */
    const checkMethod = (methodName) => noMethodsSlicingOption
      || (options.only && options.only.length && options.only.includes(methodName))
      || (options.except && options.except.length && !options.except.includes(methodName))

    const memberId = options.memberId || `${pluralize.singular(name)}Id`

    this.scope(name, (scope) => {
      scope.beforeEach(controller.beforeEach)
      scope.afterEach(controller.afterEach)

      if (checkMethod('index')) {
        scope.get('/', controller.index)
      }

      if (checkMethod('create')) {
        scope.post('/', controller.create)
      }

      scope.scope(`:${memberId}`, (member) => {
        if (checkMethod('read')) {
          member.get('/', controller.read)
        }

        if (checkMethod('update')) {
          member.put('/', controller.update)
        }

        if (checkMethod('patch')) {
          if (controller.patch) {
            member.patch('/', controller.patch)
          }
          else if (checkMethod('update')) {
            member.patch('/', controller.update)
          }
          else {
            // no PATCH /:id add
          }
        }

        if (checkMethod('destroy')) {
          member.delete('/', controller.destroy)
        }
      })

      if (creator) {
        creator(scope)
      }
    })
  }
}

/**
 * Create routes by sync callback
 * @param {function(r: Maker): null} creator Callback
 * @return {RestRoutes} Routes object
 */
export function createRest(creator) {
  if (typeof creator !== 'function') {
    throw new TypeError('Creator should be a function')
  }

  const ctx = new Maker('')

  creator(ctx)
  return ctx.build()
}

export { flattenRoutes } from './flatten'
export { printRoutes } from './printer'