From c1e2d156e1c2e90354fc780ccf9b1203bc565153 Mon Sep 17 00:00:00 2001 From: baranga Date: Tue, 4 Jun 2019 09:34:15 +0200 Subject: [PATCH] Rewrite api middleware layer --- src/api/are-plugins-compatible.js | 29 +++ src/api/are-plugins-compatible.test.js | 27 +++ src/api/index.js | 27 +++ src/api/tenant-aware-db.js | 25 +++ src/api/tenant-aware-db.test.js | 57 ++++++ src/api/tenant-aware-model-cache.js | 13 ++ src/api/tenant-aware-model-cache.test.js | 44 +++++ src/api/tenant-aware-model.js | 177 ++++++++++++++++++ src/api/tenant-aware-model.test.js | 220 +++++++++++++++++++++++ 9 files changed, 619 insertions(+) create mode 100644 src/api/are-plugins-compatible.js create mode 100644 src/api/are-plugins-compatible.test.js create mode 100644 src/api/index.js create mode 100644 src/api/tenant-aware-db.js create mode 100644 src/api/tenant-aware-db.test.js create mode 100644 src/api/tenant-aware-model-cache.js create mode 100644 src/api/tenant-aware-model-cache.test.js create mode 100644 src/api/tenant-aware-model.js create mode 100644 src/api/tenant-aware-model.test.js diff --git a/src/api/are-plugins-compatible.js b/src/api/are-plugins-compatible.js new file mode 100644 index 0000000..eed5023 --- /dev/null +++ b/src/api/are-plugins-compatible.js @@ -0,0 +1,29 @@ +/** + * Check if something looks like options + * @param {MongoTenantOptions} options + * @returns {boolean} + */ +const isPluginOptions = (options) => ( + options && + options.accessorMethod && + options.tenantIdKey && + true +); +/** + * Checks if instance is compatible to other plugin instance + * + * For population of referenced models it's necessary to detect if the tenant + * plugin installed in these models is compatible to the plugin of the host + * model. If they are compatible they are one the same "level". + * + * @param {MongoTenantOptions} a + * @param {MongoTenantOptions} b + * @returns {boolean} + */ +module.exports = (a, b) => { + return ( + isPluginOptions(a) && + isPluginOptions(b) && + a.tenantIdKey === b.tenantIdKey + ); +}; diff --git a/src/api/are-plugins-compatible.test.js b/src/api/are-plugins-compatible.test.js new file mode 100644 index 0000000..a6467ad --- /dev/null +++ b/src/api/are-plugins-compatible.test.js @@ -0,0 +1,27 @@ +const arePluginsCompatible = require('./are-plugins-compatible'); + +describe('are-plugins-compatible', () => { + describe('when called with proper plugin options', () => { + const options = { + accessorMethod: 'byTenant', + tenantIdKey: 'tenantId', + }; + + it('returns true if they have equal tenantIdKey\'s', () => { + const a = {...options}; + const b = {...options}; + const result = arePluginsCompatible(a, b); + expect(result).toBe(true); + }); + + it('returns false if they have different tenantIdKey\'s', () => { + const a = {...options}; + const b = { + ...options, + tenantIdKey: 'dimensionId', + }; + const result = arePluginsCompatible(a, b); + expect(result).toBe(false,); + }); + }); +}); diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..274372d --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,27 @@ +const buildModelCache = require('./tenant-aware-model-cache'); +const createTenantAwareModel = require('./tenant-aware-model'); + +/** + * @param schema + * @param {MongoTenantOptions} options + */ +module.exports = ({schema, options}) => { + const {accessorMethod} = options; + const cache = buildModelCache(); + + Object.assign(schema.statics, { + [accessorMethod]: function (tenantId) { + if (!cache.has(this.modelName, tenantId)) { + const base = this.model(this.modelName); + const model = createTenantAwareModel({base, tenantId}); + cache.set(this.modelName, tenantId, model); + } + return cache.get(this.modelName, tenantId); + }, + get mongoTenant() { + return {...options}; + } + }); + + return this; +}; diff --git a/src/api/tenant-aware-db.js b/src/api/tenant-aware-db.js new file mode 100644 index 0000000..5e1cbdd --- /dev/null +++ b/src/api/tenant-aware-db.js @@ -0,0 +1,25 @@ +const arePluginsCompatible = require('./are-plugins-compatible'); + +/** + * Create db connection bound to a specific tenant + * + * @param {Connection} db + * @param {*} tenantId + * @param {MongoTenantOptions} options + * @returns {Connection} + */ +module.exports = ({db, tenantId, options}) => { + const awareDb = Object.create(db); + awareDb.model = (name) => { + const unawareModel = db.model(name); + /** @type MongoTenantOptions */ + const otherPluginOptions = unawareModel.mongoTenant; + + if (!arePluginsCompatible(options, otherPluginOptions)) { + return unawareModel; + } + + return unawareModel[otherPluginOptions.accessorMethod](tenantId); + }; + return awareDb; +}; diff --git a/src/api/tenant-aware-db.test.js b/src/api/tenant-aware-db.test.js new file mode 100644 index 0000000..0bd579f --- /dev/null +++ b/src/api/tenant-aware-db.test.js @@ -0,0 +1,57 @@ +const createTenantAwareDb = require('./tenant-aware-db'); +const buildOptions = require('../options'); + +describe('tenant-aware-db', () => { + describe('when called with proper parameters', () => { + const tenantId = '23'; + const options = buildOptions(); + + it('overwrites the model method', () => { + const db = { + model: () => {}, + }; + const result = createTenantAwareDb({db, tenantId, options}); + expect(result).toHaveProperty('model'); + expect(result.model).toBeInstanceOf(Function); + expect(result.model).not.toBe(db.model); + }); + + it('returns a tenant aware model if compatible', () => { + const awareModel = { + hasTenantContext: true, + mongoTenant: {...options}, + }; + const unawareModel = { + [options.accessorMethod]: () => awareModel, + mongoTenant: {...options}, + }; + const db = { + model: () => unawareModel, + }; + + const awareDb = createTenantAwareDb({db, tenantId, options}); + const result = awareDb.model('test'); + + expect(result).toBe(awareModel); + }); + + it('returns a tenant unaware model if not compatible', () => { + const unawareModel = { + [options.accessorMethod]: () => { throw new Error; }, + mongoTenant: { + ...options, + tenantIdKey: 'dimension', + }, + }; + const db = { + model: () => unawareModel, + }; + + const awareDb = createTenantAwareDb({db, tenantId, options}); + const result = awareDb.model('test'); + + expect(result).toBe(unawareModel); + }); + }); +}); + diff --git a/src/api/tenant-aware-model-cache.js b/src/api/tenant-aware-model-cache.js new file mode 100644 index 0000000..f3d1428 --- /dev/null +++ b/src/api/tenant-aware-model-cache.js @@ -0,0 +1,13 @@ +module.exports = () => { + const cache = {}; + return { + has: (name, id) => (cache[name] && cache[name][id] && true || false), + get: (name, id) => (cache[name] && cache[name][id]), + set: (name, id, value) => { + cache[name] = { + ...(cache[name] || {}), + [id]: value, + }; + } + }; +}; diff --git a/src/api/tenant-aware-model-cache.test.js b/src/api/tenant-aware-model-cache.test.js new file mode 100644 index 0000000..f3a684d --- /dev/null +++ b/src/api/tenant-aware-model-cache.test.js @@ -0,0 +1,44 @@ +const buildTenantAwareModelCache = require('./tenant-aware-model-cache'); + +describe('tenant-aware-model-cache', () => { + describe('builds a cache that', () => { + it('returns a cached model', () => { + const model = {}; + const cache = buildTenantAwareModelCache(); + + cache.set('test', '23', model); + const result = cache.get('test', '23'); + + expect(result).toBe(model); + }); + + it('reports a stored model as cached', () => { + const model = {}; + const cache = buildTenantAwareModelCache(); + + cache.set('test', '23', model); + const result = cache.has('test', '23'); + + expect(result).toBe(true); + }); + + it('reports a unknown model as not cached', () => { + const cache = buildTenantAwareModelCache(); + + const result = cache.has('test', '23'); + + expect(result).toBe(false); + }); + + it('reports a unknown tenant as not cached', () => { + const model = {}; + const cache = buildTenantAwareModelCache(); + + cache.set('test', '23', model); + const result = cache.has('test', '42'); + + expect(result).toBe(false); + }); + }); +}); + diff --git a/src/api/tenant-aware-model.js b/src/api/tenant-aware-model.js new file mode 100644 index 0000000..fa5f1cd --- /dev/null +++ b/src/api/tenant-aware-model.js @@ -0,0 +1,177 @@ +const createTenantAwareDb = require('./tenant-aware-db'); + +const createPlainModel = ({ + base, + db, + tenantId, + tenantIdGetter, + tenantIdKey, +}) => (class extends base { + static get hasTenantContext() { + return true; + } + + static [tenantIdGetter]() { + return tenantId; + } + + /** + * @see Mongoose.Model.aggregate + * @param {...Object|Array} [operations] aggregation pipeline operator(s) or operator array + * @param {Function} [callback] + * @return {Mongoose.Aggregate|Promise} + */ + static aggregate() { + // possible structure of arguments: + // - [] - nothing + // - [{...}] - single pipeline (4.x) + // - [{...}, fn] - single pipeline with callback (4.x) + // - [{...}, {...}] - multiple pipelines (4.x) + // - [{...}, {...}, fn] - multiple pipelines with callback (4.x) + // - [[{...}]] - list of pipelines + // - [[{...}], fn] - list of pipelines with callback + + const argumentsAsArray = Array.prototype.slice.call(arguments); + + let callback = undefined; + if (typeof argumentsAsArray[argumentsAsArray.length - 1] === 'function') { + callback = argumentsAsArray.pop(); + } + + const pipeline = + argumentsAsArray.length === 1 && Array.isArray(argumentsAsArray[0]) + ? argumentsAsArray[0] + : argumentsAsArray; + + pipeline.unshift({ + $match: { + [tenantIdKey]: this[tenantIdGetter]() + } + }); + + return super.aggregate.call(this, pipeline, callback); + } + + static deleteOne(conditions, callback) { + conditions[tenantIdKey] = this[tenantIdGetter](); + + return super.deleteOne(conditions, callback); + } + + static deleteMany(conditions, options, callback) { + conditions[tenantIdKey] = this[tenantIdGetter](); + + return super.deleteMany(conditions, options, callback); + } + + static remove(conditions, callback) { + if (arguments.length === 1 && typeof conditions === 'function') { + callback = conditions; + conditions = {}; + } + + if (conditions) { + conditions[tenantIdKey] = this[tenantIdGetter](); + } + + return super.remove(conditions, callback); + } + + static insertMany(docs, callback) { + const self = this; + const tenantId = this[tenantIdGetter](); + + // Model.inserMany supports a single document as parameter + if (!Array.isArray(docs)) { + docs[tenantIdKey] = tenantId; + } else { + docs.forEach((doc) => { + doc[tenantIdKey] = tenantId; + }); + } + + // ensure the returned docs are instanced of the bound multi tenant model + return super.insertMany(docs, (err, docs) => { + if (err) { + return callback && callback(err); + } + + return callback && callback(null, docs.map(doc => new self(doc))); + }); + } + + static get db() { + return db; + } + + get hasTenantContext() { + return true; + } + + [tenantIdGetter]() { + return tenantId; + } +}); + +const inheritOtherStatics = ({model, base}) => { + Object.getOwnPropertyNames(base) + .filter((staticProperty) => ( + !model.hasOwnProperty(staticProperty) && + !['arguments', 'caller'].includes(staticProperty) + )) + .forEach((staticProperty) => { + const descriptor = Object.getOwnPropertyDescriptor(base, staticProperty); + Object.defineProperty(model, staticProperty, descriptor); + }); +}; + +const createDiscriminatorModels = ({model, base, createModel}) => { + if (!base.discriminators) { + return; + } + + model.discriminators = Object.entries(base.discriminators).reduce( + (discriminators, [key, discriminatorModel]) => { + discriminators[key] = createModel(discriminatorModel); + return discriminators; + }, + {} + ); +}; + +const createModel = ({base, db, tenantId, tenantIdGetter, tenantIdKey, createModel}) => { + const model = createPlainModel({ + base, + db, + tenantId, + tenantIdGetter, + tenantIdKey, + }); + + inheritOtherStatics({model, base}); + createDiscriminatorModels({model, base, createModel}); + + return model; +}; + +module.exports = ({base, tenantId, tenantIdGetter, tenantIdKey }) => { + const db = createTenantAwareDb({ + db: base.db, + tenantId, + options: {tenantIdGetter, tenantIdKey}, + }); + + const config = { + db, + tenantId, + tenantIdGetter, + tenantIdKey + }; + const create = (base) => createModel({ + base, + ...config, + }); + config.createModel = create; + + return create(base); +}; diff --git a/src/api/tenant-aware-model.test.js b/src/api/tenant-aware-model.test.js new file mode 100644 index 0000000..86fe0c7 --- /dev/null +++ b/src/api/tenant-aware-model.test.js @@ -0,0 +1,220 @@ +const tenantAwareModel = require('./tenant-aware-model'); +const options = require('../options'); + +describe('tenant-aware-model', () => { + + describe('when called with valid parameters', () => { + const tenantId = '23'; + const tenantIdGetter = 'getTenantId'; + const tenantIdKey = 'tenantId'; + + const buildBaseModel = () => { + const base = class {}; + base.db = {}; + return base; + }; + + let base; + let model; + beforeEach(() => { + base = buildBaseModel(); + model = tenantAwareModel({base, tenantId, tenantIdGetter, tenantIdKey}); + }); + + it('builds a model', () => { + expect(model).toBeTruthy(); + expect(model).not.toBe(base); + }); + + it('builds discriminator models', () => { + base.discriminators = { + 'test': class {}, + }; + model = tenantAwareModel({base, tenantId, tenantIdGetter, tenantIdKey}); + expect(model).toHaveProperty('discriminators.test'); + expect(model.discriminators.test).not.toBe(base.discriminators.test); + }); + + describe('returns a model that', () => { + const callback = () => {}; + + it('reports having a tenant context', () => { + const result = model.hasTenantContext; + + expect(result).toBe(true); + }); + + it('reports bound tenant id', () => { + const result = model[tenantIdGetter](); + + expect(result).toBe(tenantId); + }); + + it('has a tenant aware db model', () => { + expect(model.db).not.toBe(base.db); + }); + + describe('overrides static aggregate which', () => { + beforeEach(() => { + base.aggregate = jest.fn(); + }); + + // mongoose 4.x + it('applies tenant context to single pipeline', () => { + model.aggregate({$project: {a: 1}}); + expect(base.aggregate).toHaveBeenCalled(); + expect(base.aggregate.mock.calls[0][0]).toEqual([ + {$match: {[tenantIdKey]: tenantId}}, + {$project: {a: 1}}, + ]); + }); + + // mongoose 4.x + it('applies tenant context to multi pipeline', () => { + model.aggregate({$project: {a: 1}}, {$skip: 5}); + expect(base.aggregate).toHaveBeenCalled(); + expect(base.aggregate.mock.calls[0][0]).toEqual([ + {$match: {[tenantIdKey]: tenantId}}, + {$project: {a: 1}}, + {$skip: 5}, + ]); + }); + + it('applies tenant context to pipeline list', () => { + model.aggregate([{$project: {a: 1}}, {$skip: 5}]); + expect(base.aggregate).toHaveBeenCalled(); + expect(base.aggregate.mock.calls[0][0]).toEqual([ + {$match: {[tenantIdKey]: tenantId}}, + {$project: {a: 1}}, + {$skip: 5}, + ]); + }); + + it('applies tenant context to aggregate builder', () => { + model.aggregate(); + expect(base.aggregate).toHaveBeenCalled(); + expect(base.aggregate.mock.calls[0][0]).toEqual([ + {$match: {[tenantIdKey]: tenantId}} + ]); + }); + + it('forwards given callback', () => { + const callback = () => {}; + model.aggregate([], callback); + expect(base.aggregate).toHaveBeenCalled(); + expect(base.aggregate.mock.calls[0][1]).toBe(callback); + }); + }); + + it('applies tenant context in deleteOne', () => { + base.deleteOne = jest.fn(); + model.deleteOne({}, undefined); + + expect(base.deleteOne).toHaveBeenCalledWith( + {[tenantIdKey]: tenantId}, + undefined + ); + }); + + it('applies tenant context in deleteMany', () => { + base.deleteMany = jest.fn(); + model.deleteMany({}, {}, undefined); + + expect(base.deleteMany).toHaveBeenCalledWith( + {[tenantIdKey]: tenantId}, + {}, + undefined + ); + }); + + describe('overrides static remove which', () => { + it.each([ + ['just with conditions', [{foo: 'bar'}], [{foo: 'bar', [tenantIdKey]: tenantId}, undefined]], + ['with conditions and callback', [{foo: 'bar'}, callback], [{foo: 'bar', [tenantIdKey]: tenantId}, callback]], + ])('applies tenant context when called %s', (name, args, expectedBaseArgs) => { + base.remove = jest.fn(); + model.remove(...args); + + expect(base.remove).toHaveBeenCalledWith(...expectedBaseArgs); + }); + + it.each([ + ['without any arguments', [], [undefined, undefined]], + ['just with callback', [callback], [{[tenantIdKey]: tenantId}, callback]], + ])('does not apply tenant context when called %s', (name, args, expectedBaseArgs) => { + base.remove = jest.fn(); + model.remove(...args); + + expect(base.remove).toHaveBeenCalledWith(...expectedBaseArgs); + }); + }); + + describe('overrides static insertMany which', () => { + it('applies tenant context on single document', () => { + base.insertMany = jest.fn(); + model.insertMany({}, undefined); + + expect(base.insertMany).toHaveBeenCalledTimes(1); + expect(base.insertMany.mock.calls[0][0]).toEqual({[tenantIdKey]: tenantId}); + }); + + it('applies tenant context on multiple documents', () => { + base.insertMany = jest.fn(); + model.insertMany([{}], undefined); + + expect(base.insertMany).toHaveBeenCalledTimes(1); + expect(base.insertMany.mock.calls[0][0]).toEqual([{[tenantIdKey]: tenantId}]); + }); + + it('builds tenant aware models for callback', done => { + base.insertMany = (docs, callback) => { + callback(null, docs); + }; + const newDoc = new model(); + model.insertMany([newDoc], (err, docs) => { + expect(err).toBe(null); + expect(docs).toHaveLength(1); + + const savedDoc = docs[0]; + expect(savedDoc).toBeInstanceOf(model); + expect(savedDoc.hasTenantContext).toBe(true); + expect(savedDoc[tenantIdGetter]()).toBe(tenantId); + + done(); + }); + }); + + it('forwards errors', (done) => { + const expectedError = new Error('test'); + base.insertMany = (docs, callback) => { + callback(expectedError); + }; + model.insertMany([], (err, docs) => { + expect(err).toBe(expectedError); + expect(docs).toBe(undefined); + done(); + }); + }); + }); + + describe('when instanciated', () => { + let instance; + beforeEach(() => { + instance = new model(); + }); + + it('reports having a tenant context', () => { + const result = instance.hasTenantContext; + + expect(result).toBe(true); + }); + + it('reports bound tenant id', () => { + const result = instance[tenantIdGetter](); + + expect(result).toBe(tenantId); + }); + }); + }); + }); +});