koa-mongo-crud
Version: 
Base API for CRUD using koa and mongo
413 lines (348 loc) • 11.1 kB
JavaScript
const hal = require('hal');
const queryString = require('query-string');
const moment = require('moment');
const ajv = require('ajv')({
  removeAdditional: true,
  allErrors: true,
});
const ValidationException = require('./validation-exception');
const DuplicationException = require('./duplication-exception');
const Uuid = require('../infra/uuid');
const MongoQF = require('./mongodb-query-filter');
class CrudMapper {
  constructor(db, schema, options = {}) {
    this.schema = schema;
    this.collectionName = options.collectionName || schema.collectionName;
    this.collection = db.collection(this.collectionName);
    this.detailRoute = options.detailRoute || `${schema.name}.detail`;
    this.listRoute = options.listRoute || `${schema.name}.list`;
    this.pageSize = 25;
    this.queryFilter = createQueryFilter(schema);
    if (this.schema.hasOwnProperty('unique') === false) {
      this.schema.unique = [];
    }
  }
  async list(paramsOrig, aggregateParam) {
    const params = JSON.parse(JSON.stringify(paramsOrig));
    const withDeleted = params.deleted || params.disabled || false;
    const withCount = params._count || false;
    let pageSize = parseInt(params._pageSize || params.pageSize || this.pageSize);
    const sortBy = params.sort || 'createdAt';
    const orderBy = parseInt(params.order || -1);
    const sort = {};
    sort[sortBy] = orderBy;
    const query = this.queryFilter.parse(params);
    if (withDeleted === '1' || withDeleted === 'true') {
      delete query.deleted;
    } else {
      query.deleted = { $ne: true };
    }
    this.checkDates(this.schema.properties, query);
    params.fields = params.fields || '';
    const fields = params.fields.split(',');
    const project = {};
    fields.forEach((field) => {
      if (field.length > 0) {
        project[field] = 1;
      }
    });
    const page = parseInt(params.page || 1);
    let skip = (page - 1) * pageSize;
    if (aggregateParam && page > 1) {
      const aux = pageSize;
      pageSize = page * pageSize;
      skip = pageSize - aux;
    }
    let list = null;
    if (aggregateParam) {
      list = await this.collection.aggregate().lookup(aggregateParam)
        .match(query)
        .sort(sort)
        .limit(pageSize)
        .skip(skip)
        .toArray();
    } else {
      list = await this.collection
        .find(query)
        .project(project)
        .sort(sort)
        .limit(pageSize)
        .skip(skip)
        .toArray();
    }
    const result = {
      result: list,
      page,
    };
    if (withCount === '1' || withCount === 'true' || withCount === true) {
      const count = await this.collection.find(query).count();
      result.count = count;
      result.page_count = Math.ceil(count / pageSize);
    }
    return result;
  }
  async detail(id, withDeleted = false) {
    const filter = { _id: id };
    if (withDeleted === false) {
      filter.deleted = { $ne: true };
    }
    return await this.collection.findOne(filter);
  }
  async create(post) {
    post = this.validateAll(post);
    const data = this.toDatabase(post);
    await this.checkUniqueness(data);
    // if (data.hasOwnProperty('_id') === false) {
    data._id = CrudMapper.generateUuid();
    // }
    data.createdAt = new Date();
    data.updatedAt = data.createdAt;
    await this.collection.insertOne(data);
    return data;
  }
  async checkUniqueness(data, id = null) {
    if (this.schema.unique.length > 0) {
      const orFilter = [];
      this.schema.unique.forEach((key) => {
        if (data.hasOwnProperty(key)) {
          if (typeof data[key] === 'object') {
            data[key].forEach((value) => {
              const obj = {};
              obj[key] = value;
              orFilter.push(obj);
            });
          } else {
            const obj = {};
            obj[key] = data[key];
            orFilter.push(obj);
          }
        }
      });
      if (orFilter.length === 0) {
        return;
      }
      const filter = { $or: orFilter, deleted: { $ne: true } };
      if (id !== null) {
        filter._id = { $ne: id };
      }
      const list = await this.collection.find(filter).toArray();
      if (list.length === 0) {
        return;
      }
      const message = [];
      list.forEach((json) => {
        this.schema.unique.forEach((key) => {
          if (data.hasOwnProperty(key) && json.hasOwnProperty(key)) {
            if (typeof data[key] === 'object') {
              data[key].forEach((dataValue) => {
                json[key].forEach((jsonValue) => {
                  if (jsonValue === dataValue) {
                    message.push(key);
                  }
                });
              });
            } else if (data[key] === json[key]) {
              message.push(key);
            }
          }
        });
      });
      throw new DuplicationException(message.filter((v, i, a) => a.indexOf(v) === i));
    }
  }
  async update(id, post, withDeleted = false) {
    const filter = { _id: id };
    if (withDeleted === false) {
      filter.deleted = { $ne: true };
    }
    post = this.validate(post);
    const data = this.toDatabase(post);
    await this.checkUniqueness(data, id);
    data.updatedAt = new Date();
    let update = {};
    if (data.deleted !== true) {
      delete data.deleted;
      delete data.deletedAt;
      delete data.deletedBy;
      update = { $set: data, $unset: { deleted: '', deletedAt: '', deletedBy: '' } };
    } else {
      data.deleted = true;
      data.deletedAt = new Date();
      update = { $set: data };
    }
    const result = await this.collection.findOneAndUpdate(filter, update, { returnOriginal: false, upsert: false });
    if (result.ok !== 1) {
      return null;
    }
    return result.value;
  }
  async delete(id, userId = null) {
    const data = {
      deleted: true,
      deletedAt: new Date(),
    };
    if (userId !== null) {
      data.deletedBy = userId;
    }
    const result = await this.collection.findOneAndUpdate(
      { _id: id, deleted: { $ne: true } },
      { $set: data },
      { returnOriginal: false, upsert: false },
    );
    if (result.ok !== 1) {
      return null;
    }
    return result.value;
  }
  async remove(id) {
    const filter = { _id: id };
    const result = await this.collection.findOneAndDelete(filter);
    return result.ok === 1;
  }
  toJson(data) {
    const json = { id: data._id, ...data };
    delete json._id;
    if (json.deleted === false) {
      delete json.deleted;
      delete json.deletedAt;
      delete json.deletedBy;
    }
    return json;
  }
  toHal(result, router) {
    const json = this.toJson(result);
    if (result.deleted === true) {
      if (result.deletedAt) {
        json.deletedAt = result.deletedAt;
      }
      if (result.deletedBy) {
        json.deletedBy = result.deletedBy;
      }
    }
    let id = result._id || result.id;
    if (typeof id === 'object') {
      id = id.toString();
    }
    return new hal.Resource(json, router.url(this.detailRoute, id));
  }
  toHalCollection(result, ctx) {
    const entities = [];
    for (let i = 0; i < result.result.length; i++) {
      entities.push(this.toHal(result.result[i], ctx.router));
    }
    const { query } = ctx.request;
    let collectionUrl = ctx.router.url(this.listRoute);
    if (queryString.stringify(query).length > 0) {
      collectionUrl += `?${queryString.stringify(query)}`;
    }
    const paginationData = {
      _page: result.page,
      _count: entities.length,
    };
    if (result.hasOwnProperty('count')) {
      paginationData._total_items = result.count || 0;
    }
    if (result.hasOwnProperty('page_count')) {
      paginationData._page_count = result.page_count || 1;
    }
    const collection = new hal.Resource(paginationData, collectionUrl);
    if (result.page > 2) {
      query.page = 1;
      collection.link('first', `${ctx.router.url(this.listRoute)}?${queryString.stringify(query)}`);
    }
    if (result.page > 1) {
      query.page = result.page - 1;
      collection.link('prev', `${ctx.router.url(this.listRoute)}?${queryString.stringify(query)}`);
    }
    query.page = result.page + 1;
    collection.link('next', `${ctx.router.url(this.listRoute)}?${queryString.stringify(query)}`);
    if (result.hasOwnProperty('page_count') && result.page < result.page_count - 1) {
      query.page = result.page_count;
      collection.link('last', `${ctx.router.url(this.listRoute)}?${queryString.stringify(query)}`);
    }
    collection.embed(this.collectionName, entities, false);
    return collection;
  }
  toDatabase(entity) {
    const data = entity;
    if (data.hasOwnProperty('id')) {
      data._id = data.id;
      delete data.id;
    }
    this.checkDates(this.schema.properties, data);
    return data;
  }
  validate(data, validateAll = false) {
    const { schema } = this;
    if (validateAll === false) {
      delete schema.required;
    }
    schema.properties.deleted = { type: 'boolean', default: false };
    schema.properties.deletedAt = { type: 'string', format: 'date-time' };
    schema.properties.deletedBy = { type: ['string', 'null'] };
    const valid = ajv.validate(schema, data);
    if (!valid) {
      throw new ValidationException(ajv.errors);
    }
    return data;
  }
  validateAll(data) {
    return this.validate(data, true);
  }
  static generateUuid() {
    return Uuid.v4c();
  }
  getUUID() {
    return CrudMapper.generateUuid();
  }
  /**
   * Sets the Date fields
   * @param {[type]} data Current Data
   * @param {[type]} key  Data key name where Date type must be set on
   */
  setDates(data, key) {
    for (const x in data) {
      if (typeof data[x] === 'object') {
        this.setDates(data[x], key);
      } else {
        const dateComparisonOperators = ['$gt', '$gte', '$lt', '$lte', '$ne', '$eq', '$in', '$nin'];
        if ((key === x || dateComparisonOperators.indexOf(x) > -1) && moment(data[x], moment.ISO_8601, true).isValid()) {
          data[x] = new Date(data[x]);
        }
      }
    }
  }
  /**
   * Check for instanceOf Date fields
   * @param  {[type]} schemaProperties Current schema
   * @param  {[type]} data             Current data
   * @return {[type]}                  [description]
   */
  checkDates(schemaProperties, data) {
    for (const k in schemaProperties) {
      if (typeof schemaProperties[k] === 'object' && schemaProperties[k].type && schemaProperties[k].type === 'array') {
        this.checkDates(schemaProperties[k].items.properties, data);
      } else if (schemaProperties[k].instanceOf && schemaProperties[k].instanceOf === 'Date') {
        this.setDates(data, k);
      }
    }
  }
}
function createQueryFilter(schema) {
  if (schema.hasOwnProperty('searchable') === false) {
    schema.searchable = Object.keys(schema.properties);
  }
  const whitelist = { after: 1, before: 1, between: 1 };
  schema.searchable.forEach((key) => {
    whitelist[key] = 1;
  });
  return new MongoQF({
    custom: {
      between: 'updatedAt',
      after: 'updatedAt',
      before: 'updatedAt',
    },
    whitelist,
  });
}
module.exports = CrudMapper;