@monokai-kirov/nestjs-crud-utils v1.2.2
v1.0.0 Notes
-- getIncludeOptions() was removed - please use getListInclude() and getDetailInclude() instead
-- in @Finders@ methods default include now is [] (old value was getIncludeOptions())
-- width, height parameters in @UploadDecorator and @MultipleUploadDecorator were removed, please use resizeOptions instead
-- UploadModel { url, filesize } -> values: [
{ url, filesize, resizeOptions? },
{ url, filesize, resizeOptions? },
...
],
-- dependencies for uploadService.handleVideo() and this method were removed (mac issues)
Install
Requirements
--postgresql;
--redis;
--sequelize;
PeerDependencies and pg
npm install @nestjs/sequelize sequelize@6 sequelize-typescript ioredis pg
main.ts
// First line of main.ts
import 'src/app/config';
src/app/config.ts
import { config } from '@monokai-kirov/nestjs-crud-utils';
// you can override or define here configuration functions if you want,
// default values at the end of the docs
// for example: config.defineFunction(
// 'isTestEnvironment',
// () => process.env.NODE_ENV === 'test'
// );
Crud example
/**
* Notes
*/
If you want to persist Upload files in a different storage (not a local hd;
for example if you use kubernetes and AWS, Yandex Bucket etc.)
please override UploadService and use overridden class in all CrudService instances
as a third parameter. Also override writeBufferToStorage() and remove() methods in that class.
Updating files note:
if you want to:
-- persist a previous file - specify the uuid;
-- remove the file - don't specify the uuid;
-- remove the file and load new - specify a blob in your multipart/form-data content;
Example of multipart/form-data content for bulk/create with files:
bulk[0][title]
bulk[0][description]
bulk[0][direction]
image[0]
crudController.getAll():
?search=test
and other query params support from https://www.npmjs.com/package/sequelize-query
Postgresql:
sudo nano /etc/postgresql/13/main/pg_hba.conf
# "local" is for Unix domain socket connections only
local all all md5 # please change peer to md5
sudo service postgresql restart;
sudo -u postgres psql;
CREATE USER someuser;
\password someuser
CREATE DATABASE somedb;
GRANT ALL PRIVILEGES ON DATABASE somedb TO someuser;
\c somedb
CREATE extension IF NOT EXISTS "uuid-ossp";
exit
pgtune:
https://pgtune.leopard.in.ua/
Redis:
https://www.digitalocean.com/community/tutorials/how-to-install-and-secure-redis-on-ubuntu-20-04
nginx:
https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/
https://nginx.org/en/docs/http/load_balancing.html
https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-20-04
docker-compose scale:
https://brianchristner.io/how-to-scale-a-docker-container-with-docker-compose/
Security:
helmet: https://helmetjs.github.io/
rate-limiting: https://docs.nestjs.com/security/rate-limiting
csrf: https://docs.nestjs.com/security/csrf
content-security-policy: https://content-security-policy.com/
ufw: https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-20-04
snort: https://linoxide.com/install-snort-on-ubuntu/
Swagger setup:
https://docs.nestjs.com/openapi/introduction
.env:
NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=someuser
DB_PASSWORD=somepassword
DB_NAME=somedb
REDIS_HOST=localhost
REDIS_PORT=6379
src/app/app.module.ts
import { config, Upload, UploadModule } from '@monokai-kirov/nestjs-crud-utils';
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
SequelizeModule.forRootAsync({
// Define underscored: true (you can use your own options but underscored: true
// is necessary + leader checking prevents multiple db schema syncronization from
// different app instances (if you're not using migrations in simple cases))
useFactory: () => config.getDatabaseOptionsWithLeaderChecking(),
}),
// By default after onApplicationBootstrap hook postgresql triggers
// (AFTER DELETE for Upload entity) will be created (to delete file from the hard drive)
UploadModule.register([SequelizeModule.forFeature([Upload])]),
AdminModule,
],
})
export class AppModule {}
src/admin/admin.module.ts
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { Category } from './models/category.model';
import { CategoryService } from './services/category.service';
import { CategoryController } from './controllers/category.controller';
@Module({
imports: [SequelizeModule.forFeature([Category])],
controllers: [CategoryController],
providers: [CategoryService],
})
export class AdminModule {}
src/admin/models/category.model.ts
import { Column, Model, Table, DataType } from 'sequelize-typescript';
import {
primaryKeyOptions,
Upload,
UploadForeignKeyDecorator,
UploadBelongsToDecorator,
} from '@monokai-kirov/nestjs-crud-utils';
@Table({
indexes: [{ fields: ['image_id'] }],
})
export class Category extends Model {
@Column(primaryKeyOptions)
id: string;
@Column({ allowNull: false })
title: string;
@Column({ type: DataType.TEXT, allowNull: true })
description: string | null;
@UploadForeignKeyDecorator()
imageId: string | null;
@UploadBelongsToDecorator()
image: Upload | null;
/**
* Single linking example
*/
// Mandatory
// @ForeignKeyDecorator(() => Direction)
// directionId: string;
// @BelongsToDecorator(() => Direction)
// direction: Direction;
// Optional
// @ForeignKeyDecorator(() => Direction, true)
// directionId: string|null;
// @BelongsToDecorator(() => Direction, /*'SET NULL' if you want (by default onDelete: 'CASCADE')*/)
// direction: Direction|null;
/**
* Multiple linking example
*/
// @BelongsToMany(() => Direction, () => CategoryDirection)
// directions: Direction[];
}
src/admin/models/category.direction.model.ts
/**
import { DateType, Model, Column, ForeignKey, Table } from 'sequelize-typescript';
import { Category } from 'src/admin/models/category.model';
import { Direction } from 'src/admin/models/direction.model';
@Table
export class CategoryDirection extends Model {
@ForeignKey(() => Category)
@Column({ type: DataType.UUID, allowNull: false, onDelete: 'CASCADE' })
categoryId: string;
@ForeignKey(() => Direction)
@Column({ type: DataType.UUID, allowNull: false, onDelete: 'CASCADE' })
directionId: string;
}
*/
src/admin/dto/category.dto.ts
import {
StringDecorator,
OptionalTextDecorator,
UploadDecorator,
UploadType,
} from '@monokai-kirov/nestjs-crud-utils';
export class CategoryDto {
@StringDecorator()
title: string;
@OptionalTextDecorator()
description: string | null = null;
// @see https://sharp.pixelplumbing.com/api-resize
@UploadDecorator({ type: UploadType.PICTURE, resizeOptions: [{ width: 800 }, { width: 400 }] })
image: string | null = null;
/**
* Single linking example
*/
// Mandatory
// @UUIDDecorator()
// direction: string;
// Optional
// @OptionalUUIDDecorator()
// direction: string|null = null;
/**
* Multiple linking example
*/
// Mandatory
// @ArrayOfUUIDsDecorator()
// directions: string[];
// Optional
// @OptionalArrayOfUUIDsDecorator()
// directions: string[] = [];
}
src/admin/services/category.service.ts
import { CrudService, UploadService } from '@monokai-kirov/nestjs-crud-utils';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { CategoryDto } from '../dto/category.dto';
import { Category } from '../models/category.model';
@Injectable()
export class CategoryService extends CrudService<Category> {
constructor(
@InjectModel(Category)
private model: typeof Category,
private readonly uploadService: UploadService,
) {
super(model, CategoryDto, uploadService);
}
}
src/admin/controllers/category.controller.ts
import { CrudController } from '@monokai-kirov/nestjs-crud-utils';
import { Controller } from '@nestjs/common';
import { ApiExtraModels, ApiTags } from '@nestjs/swagger';
import { CategoryDto } from '../dto/category.dto';
import { CategoryService } from '../services/category.service';
@ApiTags('Admin categories')
@ApiExtraModels(CategoryDto)
// @ApiResponseDecorator([401, 403])
// @RolesDecorator(UserRole.ADMIN)
// @UseGuards(JwtAuthGuard)
@Controller('api/admin/categories')
export class CategoryController extends CrudController {
constructor(private readonly categoryService: CategoryService) {
super(categoryService);
}
}
Constructor options
/**
* CrudService
* protected static DEFAULT_CRUD_OPTIONS: CrudOptions = {
* withDtoValidation: true,
* withRelationValidation: true,
* withUploadValidation: true,
* withTriggersCreation: true, // triggers for Upload removing (single|multiple no matter)
* withActiveUpdate: false, // use this for updating linked entities if parent entity was activated|deactivated
* unscoped: true,
* additionalScopes: [],
* childModels: [],
* };
*/
/**
* EntityService
* protected static DEFAULT_ENTITY_OPTIONS: EntityOptions = {
* unscoped: false,
* unscopedInclude: false,
* additionalScopes: [],
* };
*/
Handling advanced multiple relations
/**
* For example User has Language with Skill
*/
// src/user/models/user.model.ts
@Table
export class User extends Model {
@Column(primaryKeyOptions)
id: string;
@Column({ allowNull: false, defaultValue: false })
isActive: boolean;
@Column({ allowNull: false, defaultValue: UserRole.CUSTOMER })
role: string;
// ...etc
@HasMany(() => UserLanguageWithSkill)
languages: UserLanguageWithSkill[];
}
// src/user/models/language.with.skill.model.ts
@Table({
indexes: [{ fields: ['user_id'] }, { fields: ['language_id'] }, { fields: ['skill_id'] }],
})
export class UserLanguageWithSkill extends Model {
@Column(primaryKeyOptions)
id: string;
@ForeignKeyDecorator(() => User)
userId: string;
@BelongsToDecorator(() => User)
user: User;
@ForeignKeyDecorator(() => Language)
languageId: string;
@BelongsToDecorator(() => Language)
language: Language;
@ForeignKeyDecorator(() => LanguageSkill)
skillId: string;
@BelongsToDecorator(() => LanguageSkill)
skill: LanguageSkill;
}
// src/user/dto/user.dto.ts
export class UserDto {
// email, phone, ...etc
// for application/json
@AdvancedObjectMultipleRelationDecorator({
// for multipart/form-data
// @AdvancedJSONMultipleRelationDecorator({
schema: UserLanguageWithSkillDto,
unique: ['languageId'], // unique prop so that user can't use one language with different skills
minCount: 1, // is optional
})
languages: string[];
}
// src/user/dto/user.language.with.skill.dto.ts
export class UserLanguageWithSkillDto {
@UUIDDecorator()
languageId: string;
@UUIDDecorator()
skillId: string;
}
Handling inheritance
public getChildModel(dto: Record<string, any>): Model {
return null;
}
public getChildModelKey(): string | null {
return null;
}
// + you must declare childModels in constructor (for example if User has roles, childModels prop is: [Admin, Support, ...etc])
// + declare in getDtoType() correct dtoType for class-validator (for example based on the role in dto)
CrudService
/**
* @Override this if you want@
*/
// Is used in getAll() in CrudController; default value - { all: true }
public getListInclude();
// Is used in getById(), create(), bulkCreate(), putById(), bulkPut() in CrudController; default value - { all: true }
public getDetailInclude();
// For getAll() method in CrudController; default value - ['id', 'title'],
// for example: ?search=test will be using ILIKE condition with id and title properties
public getSearchingProps();
// Don't allow to delete the entity - you can override this behaviour; default value - all links
public getConflictRelations();
// Default value - 30
public getMaxEntitiesPerPage();
// If you want to add some new properties before saving; default value - dto
protected async fillDto();
/**
* Validations (by default all links are being validated automatically,
* override these functions if you intend to validate other cases)
*/
public async validateRequest(); // is being used in create and update methods
public async validateCreateRequest();
public async validateUpdateRequest();
public async validateDeleteRequest();
/**
* Some helpers
*/
crudModel();
tableName();
entityName();
getEntityNameByModel();
EntityService
/**
* Finders
*/
findWithPagination(); // by default with optimizedInclude
findOne();
findOneById());
findAll();
findAllByIds();
count(); // by default with optimizedInclude
/**
* Validations
*/
validateDto();
validateMandatoryId();
validateOptionalId();
validateMandatoryIds();
validateOptionalIds();
/**
* Other validations in entityService.validationService
*/
validatePage();
validateAndParseOffsetAndLimit();
validateAndParseJsonWithOneKey();
validateAndParseArrayOfJsonsWithOneKey();
validateAndParseArrayOfJsonsWithMultipleKeys();
Decorators list
@StringDecorator()
@OptionalStringDecorator()
@TextDecorator()
@OptionalTextDecorator()
@ArrayOfStringsDecorator()
@OptionalArrayOfStringsDecorator()
@IntDecorator()
@OptionalIntDecorator()
@DecimalDecorator()
@OptionalDecimalDecorator()
@BooleanDecorator()
@OptionalBooleanDecorator()
@DateDecorator()
@OptionalDateDecorator()
@IsInDecorator()
@OptionalIsInDecorator()
@UUIDDecorator()
@OptionalUUIDDecorator()
@ArrayOfUUIDsDecorator()
@OptionalArrayOfUUIDsDecorator()
@JSONDecorator()
@OptionalJSONDecorator()
@ArrayOfJSONsDecorator()
@OptionalArrayOfJSONsDecorator()
@ObjectDecorator()
@OptionalObjectDecorator()
@ArrayOfObjectsDecorator()
@OptionalArrayOfObjectsDecorator()
@EmailDecorator()
@OptionalEmailDecorator()
@PhoneDecorator()
@OptionalPhoneDecorator()
@ForeignKeyDecorator()
@BelongsToDecorator()
@AdvancedJSONMultipleRelationDecorator()
@AdvancedObjectMultipleRelationDecorator()
@UploadDecorator()
@MultipleUploadDecorator()
@UploadForeignKeyDecorator()
@UploadBelongsToDecorator()
@CacheDecorator()
@RolesDecorator()
@ApiJwtHeaderDecorator()
@ApiResponseDecorator()
Guards
GatewayThrottlerGuard; // just an example from the docs
MutexGuard;
WsMutexGuard;
Pipes
NormalizeBeforeValidationPipe;
NormalizeAfterValidationPipe;
OptionalBooleanQueryValidationPipe;
ValidatePagePipe;
/**
* Normalize example
*/
// in main.ts
import { ValidationPipe } from '@nestjs/common';
import {
NormalizeBeforeValidationPipe,
NormalizeAfterValidationPipe,
} from '@monokai-kirov/nestjs-crud-utils';
app.useGlobalPipes(
new NormalizeBeforeValidationPipe(), // trim whitespaces recursively, email normalization
new ValidationPipe({ transform: true, whitelist: true }),
new NormalizeAfterValidationPipe(), // phone normalization
);
Interceptors
ReleaseMutexInterceptor;
ReleaseWsMutexInterceptor;
TransactionInterceptor;
/**
* Setup for transactions support
*/
npm install cls-hooked
// in main.ts
import { createNamespace } from 'cls-hooked';
import { Sequelize } from 'sequelize-typescript';
const namespace = createNamespace('sequelize-cls-namespace');
(Sequelize as any).__proto__.useCLS(namespace);
// in bootstrap function() for http context
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new AllExceptionsFilter(httpAdapter));
Filters
AllExceptionsFilter
AllWsExceptionsFilter (fix ws errors and catch postgresql 40001 thrown by SERIALIZABLE isolation level if TransactionInterceptor is used)
Leader election (for preventing multiple invokes @Cron() decorators or nestjs' hooks if you're using multiple app instances behind reverse proxy like nginx)
// package safe-redis-leader is being used
/**
* Example
*/
import { EntityService, config } from '@monokai-kirov/nestjs-crud-utils';
import { HttpService, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Options, OptionsType } from '../models/options.model';
@Injectable()
export class OptionsService extends EntityService<Options> {
constructor(
@InjectModel(Options)
private model: typeof Options,
private readonly httpService: HttpService,
) {
super(model);
}
public async onApplicationBootstrap() {
await this.updateExchangeRate();
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
public async updateExchangeRate() {
const isLeader = await config.isLeader(); // here
if (!isLeader) {
return;
}
// logic of updating
}
}
Config
/**
* Functions is being used in this package
*/
-- getDatabaseOptionsWithLeaderChecking(); // for pg-listen
-- getRedisOptions(); // for safe-redis-leader and redis-semaphore
-- getDefaultResizeOptions() // for UploadService
-- getUploadOptions(); // for UploadService
-- getEmailOptions(); // for EmailService
/**
* List
*/
isDevelopment();
isProduction();
getDatabaseOptionsWithLeaderChecking() {
dialect: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
define: {
underscored: true,
},
autoLoadModels: true,
...(isLeader
? {
synchronize: true,
sync: {
alter: true,
},
}
: {}),
pool: {
min: 10,
max: 100,
},
logging: false,
};
getRedisOptions() {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379,
};
getDefaultResizeOptions() {
[
{
width: this.getUploadOptions().imageWidth,
},
];
};
public getUploadOptions() {
imageWidth: 1000,
folders: ['upload'],
ALLOWED_PICTURE_MIMETYPES: ['image/jpeg', 'image/png', 'image/svg+xml'],
ALLOWED_AUDIO_MIMETYPES: ['audio/mpeg', 'audio/ogg', 'audio/aac'],
ALLOWED_VIDEO_MIMETYPES: ['video/mpeg', 'video/ogg', 'video/mp4'],
ALLOWED_DOCUMENT_MIMETYPES: [
'text/plain',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
SUMMARY_SIZE_LIMIT: 100_000_000, // 100 Mb
}
public getEmailOptions() {
host: 'smtp.yandex.ru',
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_LOGIN,
pass: process.env.EMAIL_PASSWORD,
};
}
public getCorsOrigin() {
process.env.CORS_ORIGIN;
}
public getThrottlerOptions() {
ttl: 60,
limit: 20,
storage: new ThrottlerStorageRedisService(this.getRedisClient()),
}
public getCacheOptions() {
store: redisStore,
...this.getRedisOptions(),
ttl: 300,
max: 500,
}
public getWsPort() {
process.env.WS_PORT ? parseInt(process.env.WS_PORT) : 3030;
}
public getWsOptions() {
transports: ['websocket'],
origins: process.env.WS_ORIGIN ?? '*:*',
path: '/ws',
serveClient: false,
allowUpgrades: false,
}
public getSentryOptions() {
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0;
}
Other services in the package
EmailService and CryptoService
TODO:
-- patchById(), bulkPatch() (handle class-validator { always: true })
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago