diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d6ec1f6 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'prettier/@typescript-eslint', + ], + root: true, + env: { + node: true, + jest: true, + }, + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-use-before-define': 'off', + }, +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c16ef02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/.release-it.json b/.release-it.json new file mode 100644 index 0000000..57efa1f --- /dev/null +++ b/.release-it.json @@ -0,0 +1,8 @@ +{ + "git": { + "commitMessage": "chore(): release v${version}" + }, + "github": { + "release": true + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1de5f04 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# nestjs-tenancy + +## Description + +[Mongoose](http://mongoosejs.com/) multitenancy module for [Nest](https://github.com/nestjs/nest). + +## Installation + +```bash +$ npm i --save @needle-innovision/nestjs-tenancy +``` + +## Basic usage + +**app.module.ts** + +```typescript +import { Module } from "@nestjs/common"; +import { TenancyModule } from "@needle-innovision/nestjs-tenancy"; +import { CatsModule } from "./cat.module.ts"; + +@Module({ + imports: [ + TenancyModule.forRoot({ + tenantIdentifier: 'X-TenantId', + options: {}, + uri: (tenantId: string) => `mongodb://localhost/test-tenant-${tenantId}`, + }), + CatsModule, + ], +}) +export class AppModule {} +``` + +Create class that describes your schema + +**cat.model.ts** + +```typescript +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema() +export class Cat extends Document { + @Prop() + name: string; + + @Prop() + age: number; + + @Prop() + breed: string; +} + +export const CatSchema = SchemaFactory.createForClass(Cat); +``` + +Inject Cat for `CatsModule` + +**cat.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { TenancyModule } from '../../../lib'; +import { CatsController } from './cats.controller'; +import { CatsService } from './cats.service'; +import { Cat, CatSchema } from './schemas/cat.schema'; + +@Module({ + imports: [ + TenancyModule.forFeature([{ name: Cat.name, schema: CatSchema }]) + ], + controllers: [CatsController], + providers: [CatsService], +}) +export class CatsModule { } +``` + +Get the cat model in a service + +**cats.service.ts** + +```typescript +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { CreateCatDto } from './dto/create-cat.dto'; +import { Cat } from './schemas/cat.schema'; + +@Injectable() +export class CatsService { + constructor( + @InjectModel(Cat.name) private readonly catModel: Model + ) { } + + async create(createCatDto: CreateCatDto): Promise { + const createdCat = new this.catModel(createCatDto); + return createdCat.save(); + } + + async findAll(): Promise { + return this.catModel.find().exec(); + } +} +``` + +Finally, use the service in a controller! + +**cats.controller.ts** + +```typescript + +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { CatsService } from './cats.service'; +import { CreateCatDto } from './dto/create-cat.dto'; +import { Cat } from './schemas/cat.schema'; + +@Controller('cats') +export class CatsController { + constructor(private readonly catsService: CatsService) { } + + @Post() + async create(@Body() createCatDto: CreateCatDto) { + return this.catsService.create(createCatDto); + } + + @Get() + async findAll(): Promise { + return this.catsService.findAll(); + } +} +``` + +## Requirements + +1. @nest/mongoose +6.4.0 +2. @nestjs/common +6.10.1 +3. @nestjs/core +6.10.1 +4. mongoose (with typings `@types/mongoose`) +5.7.12 + +## Test + +```bash +# e2e tests +$ npm run test:e2e +``` + +## Stay in touch + +- Author - [Sandeep K](https://github.com/sandeepsuvit) + +## License + + Nest is [MIT licensed](LICENSE). diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..4caaf76 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,2 @@ + +export * from './dist'; \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..ba59932 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +export * from './dist'; \ No newline at end of file diff --git a/lib/decorators/index.ts b/lib/decorators/index.ts new file mode 100644 index 0000000..19f82ae --- /dev/null +++ b/lib/decorators/index.ts @@ -0,0 +1 @@ +export * from './tenancy.decorator'; \ No newline at end of file diff --git a/lib/decorators/tenancy.decorator.ts b/lib/decorators/tenancy.decorator.ts new file mode 100644 index 0000000..d516f06 --- /dev/null +++ b/lib/decorators/tenancy.decorator.ts @@ -0,0 +1,16 @@ +import { Inject } from '@nestjs/common'; +import { getTeanantConnectionToken, getTenantModelToken } from '../utils'; + +/** + * Get the instance of the tenant model object + * + * @param model any + */ +export const InjectTenancyModel = (model: string) => Inject(getTenantModelToken(model)); + +/** + * Get the instance of the tenant connection + * + * @param name any + */ +export const InjectTenancyConnection = (name?: string) => Inject(getTeanantConnectionToken(name)); diff --git a/lib/factories/index.ts b/lib/factories/index.ts new file mode 100644 index 0000000..2b70741 --- /dev/null +++ b/lib/factories/index.ts @@ -0,0 +1 @@ +export * from './tenancy.factory'; \ No newline at end of file diff --git a/lib/factories/tenancy.factory.ts b/lib/factories/tenancy.factory.ts new file mode 100644 index 0000000..b36cc3d --- /dev/null +++ b/lib/factories/tenancy.factory.ts @@ -0,0 +1,48 @@ +import { Provider } from '@nestjs/common'; +import { Connection } from 'mongoose'; +import { ModelDefinition } from '../interfaces'; +import { CONNECTION_MAP, MODEL_DEFINITION_MAP, TENANT_CONNECTION } from '../tenancy.constants'; +import { ConnectionMap, ModelDefinitionMap } from '../types'; +import { getTenantModelDefinitionToken, getTenantModelToken } from '../utils'; + +export const createTeanancyProviders = (definitions: ModelDefinition[]): Provider[] => { + const providers: Provider[] = []; + + for (const definition of definitions) { + // Extract the definition data + const { name, schema, collection } = definition; + + providers.push({ + provide: getTenantModelDefinitionToken(name), + useFactory: ( + modelDefinitionMap: ModelDefinitionMap, + connectionMap: ConnectionMap, + ) => { + const exists = modelDefinitionMap.has(name); + if (!exists) { + modelDefinitionMap.set(name, { ...definition }); + + connectionMap.forEach((connection: Connection) => { + connection.model(name, schema, collection); + }); + } + }, + inject: [ + MODEL_DEFINITION_MAP, + CONNECTION_MAP, + ], + }); + + // Creating Models with connections attached + providers.push({ + provide: getTenantModelToken(name), + useFactory(tenantConnection: Connection) { + return tenantConnection.model(name, schema, collection); + }, + inject: [TENANT_CONNECTION], + }); + } + + // Return the list of providers mapping + return providers; +}; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..9a510eb --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './interfaces'; +export * from './decorators'; +export * from './utils'; +export * from './factories'; +export * from './tenancy.module'; \ No newline at end of file diff --git a/lib/interfaces/index.ts b/lib/interfaces/index.ts new file mode 100644 index 0000000..ad828fe --- /dev/null +++ b/lib/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './model-definition.interface'; +export * from './tenancy-options.interface'; \ No newline at end of file diff --git a/lib/interfaces/model-definition.interface.ts b/lib/interfaces/model-definition.interface.ts new file mode 100644 index 0000000..fab5854 --- /dev/null +++ b/lib/interfaces/model-definition.interface.ts @@ -0,0 +1,7 @@ +import { Schema } from 'mongoose'; + +export interface ModelDefinition { + name: string; + schema: Schema; + collection?: string; +} diff --git a/lib/interfaces/tenancy-options.interface.ts b/lib/interfaces/tenancy-options.interface.ts new file mode 100644 index 0000000..ff552a0 --- /dev/null +++ b/lib/interfaces/tenancy-options.interface.ts @@ -0,0 +1,60 @@ +import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; + +/** + * Options for synchronous setup + * + * @export + * @interface TenancyModuleOptions + */ +export interface TenancyModuleOptions extends Record { + /** + * If `true`, tenant id will be extracted from the subdomain + */ + isTenantFromSubdomain?: boolean; + + /** + * Tenant id will be extracted using the keyword from the request header + */ + tenantIdentifier?: string; + + /** + * URI for the tenant database + */ + uri: (uri: string) => string; + + /** + * Options for the database + */ + options?: any; + + /** + * Whitelist following subdomains + */ + whitelist?: any; +} + +/** + * For creating options dynamically + * + * To use this the class implementing `TenancyOptionsFactory` should + * implement the method `createTenancyOptions` under it. + * + * @export + * @interface TenancyOptionsFactory + */ +export interface TenancyOptionsFactory { + createTenancyOptions():Promise | TenancyModuleOptions; +} + +/** + * Options for asynchronous setup + * + * @export + * @interface TenancyModuleAsyncOptions + */ +export interface TenancyModuleAsyncOptions extends Pick { + useExisting?: Type; + useClass?: Type; + useFactory?: (...args: any[]) => Promise | TenancyModuleOptions; + inject?: any[]; +} \ No newline at end of file diff --git a/lib/tenancy-core.module.ts b/lib/tenancy-core.module.ts new file mode 100644 index 0000000..fa36560 --- /dev/null +++ b/lib/tenancy-core.module.ts @@ -0,0 +1,341 @@ +import { DynamicModule, Global, HttpException, HttpStatus, Module, OnApplicationShutdown, Provider, Scope } from '@nestjs/common'; +import { Type } from '@nestjs/common/interfaces'; +import { ModuleRef, REQUEST } from '@nestjs/core'; +import { Request } from 'express'; +import { Connection, createConnection } from 'mongoose'; +import { TenancyModuleAsyncOptions, TenancyModuleOptions, TenancyOptionsFactory } from './interfaces'; +import { CONNECTION_MAP, MODEL_DEFINITION_MAP, TENANT_CONNECTION, TENANT_CONTEXT, TENANT_MODULE_OPTIONS } from './tenancy.constants'; +import { ConnectionMap, ModelDefinitionMap } from './types'; + +@Global() +@Module({}) +export class TenancyCoreModule implements OnApplicationShutdown { + constructor( + private readonly moduleRef: ModuleRef, + ) { } + + /** + * Register for synchornous modules + * + * @static + * @param {TenancyModuleOptions} options + * @returns {DynamicModule} + * @memberof TenancyCoreModule + */ + static register(options: TenancyModuleOptions): DynamicModule { + + /* Module options */ + const tenancyModuleOptionsProvider = { + provide: TENANT_MODULE_OPTIONS, + useValue: { ...options }, + }; + + /* Connection Map */ + const connectionMapProvider = this.createConnectionMapProvider(); + + /* Model Definition Map */ + const modelDefinitionMapProvider = this.createModelDefinitionMapProvider(); + + /* Tenant Context */ + const tenantContextProvider = this.createTenantContextProvider(); + + /* Tenant Connection */ + const tenantConnectionProvider = { + provide: TENANT_CONNECTION, + useFactory: async ( + tenantId: string, + moduleOptions: TenancyModuleOptions, + connMap: ConnectionMap, + modelDefMap: ModelDefinitionMap, + ): Promise => { + return this.getConnection(tenantId, moduleOptions, connMap, modelDefMap); + }, + inject: [ + TENANT_CONTEXT, + TENANT_MODULE_OPTIONS, + CONNECTION_MAP, + MODEL_DEFINITION_MAP, + ], + }; + + const providers = [ + tenancyModuleOptionsProvider, + tenantContextProvider, + connectionMapProvider, + modelDefinitionMapProvider, + tenantConnectionProvider, + ]; + + return { + module: TenancyCoreModule, + providers, + exports: providers, + }; + } + + /** + * Register for asynchronous modules + * + * @static + * @param {TenancyModuleAsyncOptions} options + * @returns {DynamicModule} + * @memberof TenancyCoreModule + */ + static registerAsync(options: TenancyModuleAsyncOptions): DynamicModule { + + /* Connection Map */ + const connectionMapProvider = this.createConnectionMapProvider(); + + /* Model Definition Map */ + const modelDefinitionMapProvider = this.createModelDefinitionMapProvider(); + + /* Tenant Context */ + const tenantContextProvider = this.createTenantContextProvider(); + + /* Tenant Connection */ + const tenantConnectionProvider = { + provide: TENANT_CONNECTION, + useFactory: async ( + tenantId: string, + moduleOptions: TenancyModuleOptions, + connMap: ConnectionMap, + modelDefMap: ModelDefinitionMap, + ): Promise => { + return this.getConnection(tenantId, moduleOptions, connMap, modelDefMap); + }, + inject: [ + TENANT_CONTEXT, + TENANT_MODULE_OPTIONS, + CONNECTION_MAP, + MODEL_DEFINITION_MAP, + ] + }; + + /* Asyc providers */ + const asyncProviders = this.createAsyncProviders(options); + + const providers = [ + ...asyncProviders, + tenantContextProvider, + connectionMapProvider, + modelDefinitionMapProvider, + tenantConnectionProvider, + ]; + + return { + module: TenancyCoreModule, + imports: options.imports, + providers: providers, + exports: providers + }; + } + + /** + * Override method from `OnApplicationShutdown` + * + * @memberof TenantCoreModule + */ + async onApplicationShutdown() { + // Map of all connections + const connectionMap: ConnectionMap = this.moduleRef.get(CONNECTION_MAP); + + // Remove all stray connections + await Promise.all( + [...connectionMap.values()].map(connection => connection.close()), + ); + } + + /** + * Get Tenant id from the request + * + * @private + * @static + * @param {Request} req + * @param {TenancyModuleOptions} moduleOptions + * @returns {string} + * @memberof TenancyCoreModule + */ + private static getTenant( + req: Request, + moduleOptions: TenancyModuleOptions, + ): string { + if (!moduleOptions) { + throw new HttpException(`Tenant options are mandatory`, HttpStatus.BAD_REQUEST); + } + + // Extract the tenant idetifier + const { tenantIdentifier = null } = moduleOptions; + + // Validate if tenant identifier token is present + if (!tenantIdentifier) { + throw new HttpException(`${tenantIdentifier} is mandatory`, HttpStatus.BAD_REQUEST); + } + + // Get the tenant id from the request + const tenantId = req.get(`${tenantIdentifier}`); + + // Validate if tenant id is present + if (!tenantId) { + throw new HttpException(`${tenantIdentifier} is not supplied`, HttpStatus.BAD_REQUEST); + } + + return tenantId; + } + + /** + * Get the connection for the tenant + * + * @private + * @static + * @param {String} tenantId + * @param {TenancyModuleOptions} moduleOptions + * @param {ConnectionMap} connMap + * @param {ModelDefinitionMap} modelDefMap + * @returns + * @memberof TenancyCoreModule + */ + private static getConnection( + tenantId: string, + moduleOptions: TenancyModuleOptions, + connMap: ConnectionMap, + modelDefMap: ModelDefinitionMap, + ): Connection { + // Check if tenantId exist in the connection map + const exists = connMap.has(tenantId); + + // Return the connection if exist + if (exists) { + return connMap.get(tenantId); + } + + // Otherwise create a new connection + const connection = createConnection(moduleOptions.uri(tenantId), { + useNewUrlParser: true, + useUnifiedTopology: true, + ...moduleOptions.options, + }); + + // Attach connection to the models passed in the map + modelDefMap.forEach((definition: any) => { + const { name, schema, collection } = definition; + connection.model(name, schema, collection); + }); + + // Add the new connection to the map + connMap.set(tenantId, connection); + + return connection; + } + + /** + * Create connection map provider + * + * @private + * @static + * @returns {Provider} + * @memberof TenancyCoreModule + */ + private static createConnectionMapProvider(): Provider { + return { + provide: CONNECTION_MAP, + useFactory: (): ConnectionMap => new Map(), + } + } + + /** + * Create model definition map provider + * + * @private + * @static + * @returns {Provider} + * @memberof TenancyCoreModule + */ + private static createModelDefinitionMapProvider(): Provider { + return { + provide: MODEL_DEFINITION_MAP, + useFactory: (): ModelDefinitionMap => new Map(), + } + } + + /** + * Create tenant context provider + * + * @private + * @static + * @returns {Provider} + * @memberof TenancyCoreModule + */ + private static createTenantContextProvider(): Provider { + return { + provide: TENANT_CONTEXT, + scope: Scope.REQUEST, + useFactory: ( + req: Request, + moduleOptions: TenancyModuleOptions, + ) => this.getTenant(req, moduleOptions), + inject: [ + REQUEST, + TENANT_MODULE_OPTIONS, + ] + } + } + + /** + * Create options providers + * + * @private + * @static + * @param {TenancyModuleAsyncOptions} options + * @returns {Provider[]} + * @memberof TenancyCoreModule + */ + private static createAsyncProviders( + options: TenancyModuleAsyncOptions, + ): Provider[] { + if (options.useExisting || options.useFactory) { + return [this.createAsyncOptionsProvider(options)]; + } + + const useClass = options.useClass as Type; + + return [ + this.createAsyncOptionsProvider(options), + { + provide: useClass, + useClass, + }, + ]; + } + + /** + * Create options provider + * + * @private + * @static + * @param {TenancyModuleAsyncOptions} options + * @returns {Provider} + * @memberof TenancyCoreModule + */ + private static createAsyncOptionsProvider( + options: TenancyModuleAsyncOptions, + ): Provider { + if(options.useFactory) { + return { + provide: TENANT_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }; + } + + const inject = [ + (options.useClass || options.useExisting) as Type, + ]; + + return { + provide: TENANT_MODULE_OPTIONS, + useFactory: async (optionsFactory: TenancyOptionsFactory) => + await optionsFactory.createTenancyOptions(), + inject, + }; + } +} diff --git a/lib/tenancy-feature.module.ts b/lib/tenancy-feature.module.ts new file mode 100644 index 0000000..220ae88 --- /dev/null +++ b/lib/tenancy-feature.module.ts @@ -0,0 +1,19 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { createTeanancyProviders } from './factories'; +import { ModelDefinition } from './interfaces'; + +@Global() +@Module({}) +export class TenancyFeatureModule { + + static register(models: ModelDefinition[]): DynamicModule { + const providers = createTeanancyProviders(models); + + return { + module: TenancyFeatureModule, + providers, + exports: providers, + }; + } + +} diff --git a/lib/tenancy.constants.ts b/lib/tenancy.constants.ts new file mode 100644 index 0000000..75b80d7 --- /dev/null +++ b/lib/tenancy.constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_TENANT_DB_CONNECTION = 'TenantConnection'; + +export const TENANT_CONTEXT = 'TenantContext'; +export const MODEL_DEFINITION_MAP = 'ModelDefinitionMap'; +export const CONNECTION_MAP = 'ConnectionMap'; +export const TENANT_CONNECTION = 'TenantConnection'; +export const TENANT_MODULE_OPTIONS = 'TenantModuleOptions'; diff --git a/lib/tenancy.module.ts b/lib/tenancy.module.ts new file mode 100644 index 0000000..ab92c70 --- /dev/null +++ b/lib/tenancy.module.ts @@ -0,0 +1,81 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { ModelDefinition, TenancyModuleAsyncOptions, TenancyModuleOptions } from './interfaces'; +import { TenancyCoreModule } from './tenancy-core.module'; +import { TenancyFeatureModule } from './tenancy-feature.module'; + +/** + * Module to help with multi tenancy + * + * For root configutaion: + * ```ts + * TenancyModule.forRoot({ + * tenantIdentifier: 'X-TenantId', + * options: {}, + * uri: (tenantId: string) => `mongodb://localhost/tenant-${tenantId}`, + * }) + * ``` + * + * For root async configuration: + * ```ts + * TenancyModule.forRootAsync({ + * useFactory: async (cfs: ConfigService) => cfs.get('tenant'), + * inject: [ConfigService], + * }) + *``` + * + * For feature configurations: + * ```ts + * TenancyModule.forFeature([{ name: 'Account', schema: AccountSchema }]) + *``` + * @export + * @class TenancyModule + */ +@Module({}) +export class TenancyModule { + + /** + * For root synchronous imports + * + * @static + * @param {TenancyModuleOptions} options + * @returns {DynamicModule} + * @memberof TenancyModule + */ + static forRoot(options: TenancyModuleOptions): DynamicModule { + return { + module: TenancyModule, + imports: [TenancyCoreModule.register(options)], + }; + } + + /** + * For root asynchronous imports + * + * @static + * @param {TenancyModuleAsyncOptions} options + * @returns {DynamicModule} + * @memberof TenancyModule + */ + static forRootAsync(options: TenancyModuleAsyncOptions): DynamicModule { + return { + module: TenancyModule, + imports: [TenancyCoreModule.registerAsync(options)], + }; + } + + /** + * For feature module imports + * + * @static + * @param {ModelDefinition[]} models + * @returns {DynamicModule} + * @memberof TenancyModule + */ + static forFeature(models: ModelDefinition[]): DynamicModule { + return { + module: TenancyModule, + imports: [TenancyFeatureModule.register(models)], + }; + } + +} diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..02eb0b3 --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1 @@ +export * from './tenancy-types'; \ No newline at end of file diff --git a/lib/types/tenancy-types.ts b/lib/types/tenancy-types.ts new file mode 100644 index 0000000..a7df5e0 --- /dev/null +++ b/lib/types/tenancy-types.ts @@ -0,0 +1,3 @@ +export type ModelDefinitionMap = Record; + +export type ConnectionMap = Record; \ No newline at end of file diff --git a/lib/utils/index.ts b/lib/utils/index.ts new file mode 100644 index 0000000..a740df9 --- /dev/null +++ b/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './tenancy.utils'; \ No newline at end of file diff --git a/lib/utils/tenancy.utils.ts b/lib/utils/tenancy.utils.ts new file mode 100644 index 0000000..74665ea --- /dev/null +++ b/lib/utils/tenancy.utils.ts @@ -0,0 +1,36 @@ +import { DEFAULT_TENANT_DB_CONNECTION } from '../tenancy.constants'; + +/** + * Get tenant model name formatted + * + * @export + * @param {string} model + * @returns + */ +export function getTenantModelToken(model: string) { + return `${model}Model`; +} + +/** + * Get tenant model definition name + * + * @export + * @param {string} model + * @returns + */ +export function getTenantModelDefinitionToken(model: string) { + return `${model}Definition`; +} + +/** + * Get the connecion token name formatted + * + * @export + * @param {string} [name] + * @returns + */ +export function getTeanantConnectionToken(name?: string) { + return name && name !== DEFAULT_TENANT_DB_CONNECTION + ? `${name}TenantConnection` + : DEFAULT_TENANT_DB_CONNECTION; +} diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..56167b3 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,4 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e465bb5 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "@needle-innovision/nestjs-tenancy", + "version": "0.0.1", + "description": "Nestjs module for multitenancy support", + "author": "Sandeep K ", + "repository": "https://github.com/needle-innovision/nestjs-tenancy", + "license": "MIT", + "scripts": { + "lint": "eslint \"lib/**/*.ts\" --fix", + "format": "prettier \"lib/**/*.ts\" --write", + "build": "rm -rf dist && tsc -p tsconfig.json", + "precommit": "lint-staged", + "prepublish:npm": "npm run build", + "publish:npm": "npm publish --access public", + "prepublish:next": "npm run build", + "publish:next": "npm publish --access public --tag next", + "prerelease": "npm run build", + "release": "release-it", + "test:e2e": "jest --config ./tests/jest-e2e.json --runInBand", + "test:e2e:dev": "jest --config ./tests/jest-e2e.json --runInBand --watch" + }, + "devDependencies": { + "@nestjs/common": "^7.0.0", + "@nestjs/core": "^7.0.0", + "@nestjs/mongoose": "^7.0.1", + "@nestjs/platform-express": "^7.0.0", + "mongoose": "^5.9.14", + "reflect-metadata": "^0.1.13", + "rxjs": "^6.5.4", + "@nestjs/cli": "^7.0.0", + "@nestjs/schematics": "^7.0.0", + "@nestjs/testing": "^7.0.0", + "@types/express": "^4.17.3", + "@types/jest": "25.1.4", + "@types/mongoose": "5.7.18", + "@types/node": "^13.9.1", + "@types/supertest": "^2.0.8", + "@typescript-eslint/eslint-plugin": "^2.23.0", + "@typescript-eslint/parser": "^2.23.0", + "eslint": "^6.8.0", + "eslint-config-prettier": "^6.10.0", + "eslint-plugin-import": "^2.20.1", + "jest": "^25.1.0", + "prettier": "^1.19.1", + "supertest": "^4.0.2", + "ts-jest": "25.2.1", + "ts-loader": "^6.2.1", + "ts-node": "^8.6.2", + "tsconfig-paths": "^3.9.0", + "typescript": "^3.7.4" + }, + "peerDependencies": { + "@nestjs/common": "^6.0.0 || ^7.0.0", + "@nestjs/core": "^6.0.0 || ^7.0.0", + "@types/mongoose": "^5.0.0", + "mongoose": "^5.4.19", + "reflect-metadata": "^0.1.12", + "rxjs": "^6.0.0" + }, + "lint-staged": { + "*.ts": [ + "prettier --write" + ] + } +} diff --git a/tests/e2e/cat-tenancy.spec.ts b/tests/e2e/cat-tenancy.spec.ts new file mode 100644 index 0000000..679b16c --- /dev/null +++ b/tests/e2e/cat-tenancy.spec.ts @@ -0,0 +1,39 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Server } from 'http'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('CatTenancy', () => { + let server: Server; + let app: INestApplication; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + server = app.getHttpServer(); + await app.init(); + }); + + it(`should return created document`, (done) => { + const createDto = { name: 'Nest', breed: 'Maine coon', age: 5 }; + request(server) + .post('/cats') + .set('X-TenantId', 'cats') + .send(createDto) + .expect(201) + .end((err, { body }) => { + expect(body.name).toEqual(createDto.name); + expect(body.age).toEqual(createDto.age); + expect(body.breed).toEqual(createDto.breed); + done(); + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); \ No newline at end of file diff --git a/tests/e2e/dog-tenancy.spec.ts b/tests/e2e/dog-tenancy.spec.ts new file mode 100644 index 0000000..0000aec --- /dev/null +++ b/tests/e2e/dog-tenancy.spec.ts @@ -0,0 +1,39 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Server } from 'http'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('DogTenancy', () => { + let server: Server; + let app: INestApplication; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + server = app.getHttpServer(); + await app.init(); + }); + + it(`should return created document`, (done) => { + const createDto = { name: 'Charlie', breed: 'Beagle', age: 6 }; + request(server) + .post('/dogs') + .set('X-TenantId', 'dogs') + .send(createDto) + .expect(201) + .end((err, { body }) => { + expect(body.name).toEqual(createDto.name); + expect(body.age).toEqual(createDto.age); + expect(body.breed).toEqual(createDto.breed); + done(); + }); + }); + + afterEach(async () => { + await app.close(); + }); +}); \ No newline at end of file diff --git a/tests/jest-e2e.json b/tests/jest-e2e.json new file mode 100644 index 0000000..278fd92 --- /dev/null +++ b/tests/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} \ No newline at end of file diff --git a/tests/src/app.module.ts b/tests/src/app.module.ts new file mode 100644 index 0000000..fe8f5d7 --- /dev/null +++ b/tests/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TenancyModule } from '../../lib'; +import { CatsModule } from './cats/cats.module'; +import { DogsModule } from './dogs/dogs.module'; + +@Module({ + imports: [ + MongooseModule.forRoot('mongodb://localhost:27017/test'), + TenancyModule.forRoot({ + tenantIdentifier: 'X-TenantId', + options: {}, + uri: (tenantId: string) => `mongodb://localhost/test-tenant-${tenantId}`, + }), + CatsModule, + DogsModule, + ], +}) +export class AppModule { } \ No newline at end of file diff --git a/tests/src/cats/cats.controller.ts b/tests/src/cats/cats.controller.ts new file mode 100644 index 0000000..6038674 --- /dev/null +++ b/tests/src/cats/cats.controller.ts @@ -0,0 +1,20 @@ + +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { CatsService } from './cats.service'; +import { CreateCatDto } from './dto/create-cat.dto'; +import { Cat } from './schemas/cat.schema'; + +@Controller('cats') +export class CatsController { + constructor(private readonly catsService: CatsService) { } + + @Post() + async create(@Body() createCatDto: CreateCatDto) { + return this.catsService.create(createCatDto); + } + + @Get() + async findAll(): Promise { + return this.catsService.findAll(); + } +} \ No newline at end of file diff --git a/tests/src/cats/cats.module.ts b/tests/src/cats/cats.module.ts new file mode 100644 index 0000000..c666385 --- /dev/null +++ b/tests/src/cats/cats.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TenancyModule } from '../../../lib'; +import { CatsController } from './cats.controller'; +import { CatsService } from './cats.service'; +import { Cat, CatSchema } from './schemas/cat.schema'; + +@Module({ + imports: [ + TenancyModule.forFeature([{ name: Cat.name, schema: CatSchema }]) + ], + controllers: [CatsController], + providers: [CatsService], +}) +export class CatsModule { } \ No newline at end of file diff --git a/tests/src/cats/cats.service.ts b/tests/src/cats/cats.service.ts new file mode 100644 index 0000000..5f3b03b --- /dev/null +++ b/tests/src/cats/cats.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { CreateCatDto } from './dto/create-cat.dto'; +import { Cat } from './schemas/cat.schema'; + +@Injectable() +export class CatsService { + constructor( + @InjectModel(Cat.name) private readonly catModel: Model + ) { } + + async create(createCatDto: CreateCatDto): Promise { + const createdCat = new this.catModel(createCatDto); + return createdCat.save(); + } + + async findAll(): Promise { + return this.catModel.find().exec(); + } +} \ No newline at end of file diff --git a/tests/src/cats/dto/create-cat.dto.ts b/tests/src/cats/dto/create-cat.dto.ts new file mode 100644 index 0000000..a5f811d --- /dev/null +++ b/tests/src/cats/dto/create-cat.dto.ts @@ -0,0 +1,5 @@ +export class CreateCatDto { + readonly name: string; + readonly age: number; + readonly breed: string; +} \ No newline at end of file diff --git a/tests/src/cats/schemas/cat.schema.ts b/tests/src/cats/schemas/cat.schema.ts new file mode 100644 index 0000000..ff59fb0 --- /dev/null +++ b/tests/src/cats/schemas/cat.schema.ts @@ -0,0 +1,16 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema() +export class Cat extends Document { + @Prop() + name: string; + + @Prop() + age: number; + + @Prop() + breed: string; +} + +export const CatSchema = SchemaFactory.createForClass(Cat); \ No newline at end of file diff --git a/tests/src/dogs/dogs.controller.ts b/tests/src/dogs/dogs.controller.ts new file mode 100644 index 0000000..5cd15ea --- /dev/null +++ b/tests/src/dogs/dogs.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { DogsService } from './dogs.service'; +import { CreateDogDto } from './dto/create-dog.dto'; +import { Dog } from './schemas/dog.schema'; + +@Controller('dogs') +export class DogsController { + constructor(private readonly dogsService: DogsService) { } + + @Post() + async create(@Body() createDogDto: CreateDogDto) { + return this.dogsService.create(createDogDto); + } + + @Get() + async findAll(): Promise { + return this.dogsService.findAll(); + } +} \ No newline at end of file diff --git a/tests/src/dogs/dogs.module.ts b/tests/src/dogs/dogs.module.ts new file mode 100644 index 0000000..e9c0d02 --- /dev/null +++ b/tests/src/dogs/dogs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TenancyModule } from '../../../lib'; +import { DogsController } from './dogs.controller'; +import { DogsService } from './dogs.service'; +import { DogSchema, Dog } from './schemas/dog.schema'; + +@Module({ + imports: [ + TenancyModule.forFeature([{ name: Dog.name, schema: DogSchema }]) + ], + controllers: [DogsController], + providers: [DogsService], +}) +export class DogsModule { } \ No newline at end of file diff --git a/tests/src/dogs/dogs.service.ts b/tests/src/dogs/dogs.service.ts new file mode 100644 index 0000000..bea6354 --- /dev/null +++ b/tests/src/dogs/dogs.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { CreateDogDto } from './dto/create-dog.dto'; +import { Dog } from './schemas/dog.schema'; + +@Injectable() +export class DogsService { + constructor( + @InjectModel(Dog.name) private readonly dogModel: Model + ) { } + + async create(createDogDto: CreateDogDto): Promise { + const createdDog = new this.dogModel(createDogDto); + return createdDog.save(); + } + + async findAll(): Promise { + return this.dogModel.find().exec(); + } +} \ No newline at end of file diff --git a/tests/src/dogs/dto/create-dog.dto.ts b/tests/src/dogs/dto/create-dog.dto.ts new file mode 100644 index 0000000..c65f7b3 --- /dev/null +++ b/tests/src/dogs/dto/create-dog.dto.ts @@ -0,0 +1,5 @@ +export class CreateDogDto { + readonly name: string; + readonly age: number; + readonly breed: string; +} \ No newline at end of file diff --git a/tests/src/dogs/schemas/dog.schema.ts b/tests/src/dogs/schemas/dog.schema.ts new file mode 100644 index 0000000..1b620ae --- /dev/null +++ b/tests/src/dogs/schemas/dog.schema.ts @@ -0,0 +1,16 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +@Schema() +export class Dog extends Document { + @Prop() + name: string; + + @Prop() + age: number; + + @Prop() + breed: string; +} + +export const DogSchema = SchemaFactory.createForClass(Dog); \ No newline at end of file diff --git a/tests/src/main.ts b/tests/src/main.ts new file mode 100644 index 0000000..b724583 --- /dev/null +++ b/tests/src/main.ts @@ -0,0 +1,9 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + console.log(`Application is running on: ${await app.getUrl()}`); +} +bootstrap(); \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..72faf96 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "noImplicitAny": false, + "removeComments": true, + "noLib": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es6", + "sourceMap": false, + "outDir": "./dist", + "rootDir": "./lib", + "strict": true, + "strictPropertyInitialization": false + }, + "include": [ + "lib/**/*" + ], + "exclude": [ + "node_modules", + "**/*.spec.ts" + ] +} \ No newline at end of file