Commit ed68d83f authored by nanahira's avatar nanahira

migrate to nicot

parent 9e3f3e6a
Pipeline #14563 failed with stages
in 23 seconds
#!/bin/bash
npm install --save typeorm @nestjs/typeorm pg pg-native
npm install --save typeorm @nestjs/typeorm pg pg-native nicot
This diff is collapsed.
import {
ApiBody,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Body, Delete, Get, Patch, Post, Query, Type } from '@nestjs/common';
import {
BlankReturnMessageDto,
PaginatedReturnMessageDto,
ReturnMessageDto,
} from '../dto/ReturnMessage.dto';
import { TimeBase, TimeBaseFields } from '../entities/bases/TimeBase.entity';
import { ClassGetPipe, CreatePipe, UpdatePipe } from '../utility/pipes';
export function MergeMethodDecorators(
decorators: MethodDecorator[],
): MethodDecorator {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
decorators.forEach((decorator) => {
decorator(target, key, descriptor);
});
};
}
export class CrudFactory<T extends TimeBase> {
readonly createDto: Type<Omit<T, keyof T>>;
readonly updateDto: Type<Partial<Omit<T, keyof T>>>;
readonly entityReturnMessageDto = ReturnMessageDto(this.entityClass);
readonly entityArrayReturnMessageDto = PaginatedReturnMessageDto(
this.entityClass,
);
constructor(
public readonly entityClass: Type<T>,
fieldsToOmit: (keyof T)[] = [],
// eslint-disable-next-line @typescript-eslint/ban-types
public readonly idType: Function = Number,
) {
this.createDto = OmitType(this.entityClass, [
...TimeBaseFields,
...fieldsToOmit,
]);
this.updateDto = PartialType(this.createDto);
}
create(): MethodDecorator {
return MergeMethodDecorators([
Post(),
ApiOperation({ summary: `Create a new ${this.entityClass.name}` }),
ApiBody({ type: this.createDto }),
ApiCreatedResponse({ type: this.entityReturnMessageDto }),
]);
}
createParam() {
return Body(CreatePipe);
}
findOne(): MethodDecorator {
return MergeMethodDecorators([
Get(':id'),
ApiOperation({ summary: `Find a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiOkResponse({ type: this.entityReturnMessageDto }),
]);
}
findAll(): MethodDecorator {
return MergeMethodDecorators([
Get(),
ApiOperation({ summary: `Find all ${this.entityClass.name}` }),
ApiOkResponse({ type: this.entityArrayReturnMessageDto }),
]);
}
findAllParam() {
return Query(new ClassGetPipe(this.entityClass));
}
update(): MethodDecorator {
return MergeMethodDecorators([
Patch(':id'),
ApiOperation({ summary: `Update a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiBody({ type: this.updateDto }),
ApiOkResponse({ type: BlankReturnMessageDto }),
]);
}
updateParam() {
return Body(UpdatePipe);
}
delete(): MethodDecorator {
return MergeMethodDecorators([
Delete(':id'),
ApiOperation({ summary: `Delete a ${this.entityClass.name} by id` }),
ApiParam({ name: 'id', type: this.idType, required: true }),
ApiNoContentResponse({ type: BlankReturnMessageDto }),
]);
}
}
import { IsInt, IsPositive } from 'class-validator';
import { SelectQueryBuilder } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { NotWritable } from '../entities/decorators/transform';
export interface PageSettingsWise {
pageCount: number;
recordsPerPage: number;
}
export interface PageSettingsFactory {
getActualPageSettings(): PageSettingsWise;
}
export class PageSettingsDto implements PageSettingsWise, PageSettingsFactory {
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({
description: 'The nth page, starting with 1.',
required: false,
})
pageCount: number;
@NotWritable()
@IsPositive()
@IsInt()
@ApiProperty({ description: 'Records per page.', required: false })
recordsPerPage: number;
getActualPageSettings(): PageSettingsWise {
return {
pageCount: this.getPageCount(),
recordsPerPage: this.getRecordsPerPage(),
};
}
private getPageCount() {
return this.pageCount || 1;
}
private getRecordsPerPage() {
return this.recordsPerPage || 25;
}
private getStartingFrom() {
return (this.getPageCount() - 1) * this.getRecordsPerPage();
}
applyQuery(qb: SelectQueryBuilder<PageSettingsDto>, entityName: string) {
qb.take(this.getRecordsPerPage()).skip(this.getStartingFrom());
}
}
import { ApiProperty } from '@nestjs/swagger';
import { HttpException } from '@nestjs/common';
import { PageSettingsWise } from './PageSettings.dto';
export interface BlankReturnMessage {
statusCode: number;
message: string;
success: boolean;
}
export interface ReturnMessage<T> extends BlankReturnMessage {
data?: T;
}
export class BlankReturnMessageDto implements BlankReturnMessage {
@ApiProperty({ description: 'Return code' })
statusCode: number;
@ApiProperty({ description: 'Return message' })
message: string;
@ApiProperty({ description: 'Whether success.' })
success: boolean;
constructor(statusCode: number, message?: string) {
this.statusCode = statusCode;
this.message = message || 'success';
this.success = statusCode < 400;
}
toException() {
return new HttpException(this, this.statusCode);
}
}
export class BlankPaginatedReturnMessageDto
extends BlankReturnMessageDto
implements PageSettingsWise
{
@ApiProperty({ description: 'Total record count.' })
total: number;
@ApiProperty({ description: 'Total page count.' })
totalPages: number;
@ApiProperty({ description: 'Current page.' })
pageCount: number;
@ApiProperty({ description: 'Records per page.' })
recordsPerPage: number;
constructor(
statusCode: number,
message: string,
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message);
this.total = total;
this.pageCount = pageSettings.pageCount;
this.recordsPerPage = pageSettings.recordsPerPage;
this.totalPages = Math.ceil(total / pageSettings.recordsPerPage);
}
}
type AnyClass = new (...args: any[]) => any;
type ClassOrArray = AnyClass | [AnyClass];
type TypeFromClass<T> = T extends new (...args: any[]) => infer U ? U : never;
export type ParseType<T extends ClassOrArray> = T extends [infer U]
? TypeFromClass<U>[]
: TypeFromClass<T>;
function getClass(o: ClassOrArray) {
return o instanceof Array ? o[0] : o;
}
export function ReturnMessageDto<T extends ClassOrArray>(type: T) {
const cl = class SpecificReturnMessage extends BlankReturnMessageDto {
data?: ParseType<T>;
constructor(statusCode: number, message?: string, data?: ParseType<T>) {
super(statusCode, message);
this.data = data;
}
};
ApiProperty({ description: 'Return data.', type })(cl.prototype, 'data');
Object.defineProperty(cl, 'name', {
value: `${getClass(type).name}ReturnMessageDto`,
});
return cl;
}
export function PaginatedReturnMessageDto<T extends AnyClass>(type: T) {
const cl = class SpecificPaginatedReturnMessageDto
extends BlankPaginatedReturnMessageDto
implements PageSettingsWise
{
data?: TypeFromClass<T>[];
constructor(
statusCode: number,
message: string,
data: TypeFromClass<T>[],
total: number,
pageSettings: PageSettingsWise,
) {
super(statusCode, message, total, pageSettings);
this.data = data;
}
};
ApiProperty({ description: 'Return data.', type: [type] })(
cl.prototype,
'data',
);
Object.defineProperty(cl, 'name', {
value: `${getClass(type).name}PaginatedReturnMessageDto`,
});
return cl;
}
export class StringReturnMessageDto
extends BlankReturnMessageDto
implements ReturnMessage<string>
{
@ApiProperty({ description: 'Return data.' })
data?: string;
constructor(statusCode: number, message?: string, data?: string) {
super(statusCode, message);
this.data = data;
}
}
// Blank for nicot use.
import { ApiProperty } from '@nestjs/swagger';
export class ImportEntryBaseDto {
@ApiProperty({ description: 'Import result' })
result: string;
}
export interface ImportEntry<T> {
entry: T;
result: string;
}
import { IdNameDescBase } from './IdNameDescBase.entity';
import { StringColumn } from '../decorators/base';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class AvatarBase extends IdNameDescBase {
@StringColumn(128, '图标地址', undefined, false)
avatarUrl: string;
override applyQuery(qb: SelectQueryBuilder<AvatarBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'avatarUrl');
}
}
import { Column, Generated, SelectQueryBuilder } from 'typeorm';
import { IdWise } from '../interfaces/wises';
import { ApiProperty } from '@nestjs/swagger';
import { applyQueryProperty } from '../utility/query';
import { NotWritable } from '../decorators/transform';
import { IsInt, IsPositive } from 'class-validator';
import { BigintTransformer } from '../utility/bigint-transform';
import { TimeBase } from './TimeBase.entity';
export class IdBase extends TimeBase implements IdWise {
@Generated('increment')
@Column('bigint', {
primary: true,
unsigned: true,
transformer: new BigintTransformer(),
})
@ApiProperty({ description: '编号', required: false })
@NotWritable()
@IsInt()
@IsPositive()
id: number;
override applyQuery(qb: SelectQueryBuilder<IdBase>, entityName: string) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'DESC');
applyQueryProperty(this, qb, entityName, 'id');
}
}
import { IdBase } from './IdBase.entity';
import { IdNameWise } from '../interfaces/wises';
import { EntityName } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class IdNameBase extends IdBase implements IdNameWise {
@EntityName()
name: string;
override applyQuery(qb: SelectQueryBuilder<IdNameBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'name');
}
}
import { IdNameBase } from './IdNameBase.entity';
import { IdNameDescWise } from '../interfaces/wises';
import { EntityDescription } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertySearch } from '../utility/query';
export class IdNameDescBase extends IdNameBase implements IdNameDescWise {
@EntityDescription()
desc: string;
override applyQuery(
qb: SelectQueryBuilder<IdNameDescBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertySearch(this, qb, entityName, 'desc');
}
}
import { StringColumn } from '../decorators/base';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryProperty } from '../utility/query';
import { ManualNameDescBase } from './ManualNameDescBase.entity';
export class AvatarBase extends ManualNameDescBase {
@StringColumn(128, '图标地址', undefined, false)
avatarUrl: string;
override applyQuery(qb: SelectQueryBuilder<AvatarBase>, entityName: string) {
super.applyQuery(qb, entityName);
applyQueryProperty(this, qb, entityName, 'avatarUrl');
}
}
import { PrimaryColumn, SelectQueryBuilder } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { StringIdWise } from '../interfaces/wises';
import { applyQueryProperty } from '../utility/query';
import { NotChangeable } from '../decorators/transform';
import { IsNotEmpty, IsString } from 'class-validator';
import { TimeBase } from './TimeBase.entity';
export class ManualIdBase extends TimeBase implements StringIdWise {
@PrimaryColumn('varchar', { length: 32 })
@ApiProperty({ description: '编号' })
@NotChangeable()
@IsString()
@IsNotEmpty()
id: string;
override applyQuery(
qb: SelectQueryBuilder<ManualIdBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
qb.orderBy(`${entityName}.id`, 'ASC');
applyQueryProperty(this, qb, entityName, 'id');
}
}
import { ManualIdBase } from './ManualIdBase.entity';
import { StringIdNameWise } from '../interfaces/wises';
import { EntityName } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertyLike } from '../utility/query';
export class ManualNameBase extends ManualIdBase implements StringIdNameWise {
@EntityName()
name: string;
override applyQuery(
qb: SelectQueryBuilder<ManualNameBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertyLike(this, qb, entityName, 'name');
}
}
import { ManualNameBase } from './ManualNameBase.entity';
import { StringIdNameDescWise } from '../interfaces/wises';
import { EntityDescription } from '../decorators/extended';
import { SelectQueryBuilder } from 'typeorm';
import { applyQueryPropertySearch } from '../utility/query';
export class ManualNameDescBase
extends ManualNameBase
implements StringIdNameDescWise
{
@EntityDescription()
desc: string;
override applyQuery(
qb: SelectQueryBuilder<ManualNameDescBase>,
entityName: string,
) {
super.applyQuery(qb, entityName);
applyQueryPropertySearch(this, qb, entityName, 'desc');
}
}
import { CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
import { PageSettingsDto } from '../../dto/PageSettings.dto';
import { NotColumn } from '../decorators/base';
export interface DeletionWise {
deleteTime?: Date;
}
export interface ImportWise {
isValidInCreation(): string | undefined;
prepareForSaving(): Promise<void>;
afterSaving(): void;
}
export class TimeBase
extends PageSettingsDto
implements DeletionWise, ImportWise
{
@CreateDateColumn({ select: false })
@NotColumn()
createTime: Date;
@UpdateDateColumn({ select: false })
@NotColumn()
updateTime: Date;
@DeleteDateColumn({ select: false })
@NotColumn()
deleteTime: Date;
toObject() {
return JSON.parse(JSON.stringify(this));
}
isValidInCreation(): string | undefined {
return;
}
async prepareForSaving(): Promise<void> {}
afterSaving() {}
}
export const TimeBaseFields: (keyof TimeBase)[] = [
'createTime',
'updateTime',
'deleteTime',
];
import { Column, Index } from 'typeorm';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions';
import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions';
import { ColumnEnumOptions } from 'typeorm/decorator/options/ColumnEnumOptions';
import {
IsDate,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions';
import { BigintTransformer } from '../utility/bigint-transform';
import { Exclude } from 'class-transformer';
export function MergePropertyDecorators(
decs: PropertyDecorator[],
): PropertyDecorator {
return (obj, key) => {
for (const dec of decs) {
dec(obj, key);
}
};
}
export const OptionalValidate = (...conitions: PropertyDecorator[]) =>
MergePropertyDecorators([IsOptional(), ...conitions]);
export const StringColumn = (
length = 32,
description = 'unknown',
defaultValue?: string,
required = false,
columnExtras: ColumnCommonOptions & ColumnWithLengthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('varchar', {
length,
default: defaultValue,
nullable: !required && defaultValue == null,
comment: description,
...columnExtras,
}),
ApiProperty({
type: String,
description,
default: defaultValue,
required: required && defaultValue == null,
maxLength: length,
...propertyExtras,
}),
...(required ? [] : [IsOptional()]),
IsString(),
IsNotEmpty(),
MaxLength(length),
]);
export const IntColumn = (
type: 'int' | 'smallint' | 'bigint' | 'tinyint' = 'int',
unsigned = false,
description = 'unknown',
defaultValue?: number,
required = false,
columnExtras: ColumnCommonOptions & ColumnWithWidthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column(type, {
default: defaultValue,
nullable: !required && defaultValue == null,
unsigned,
comment: description,
...(type === 'bigint' ? { transformer: new BigintTransformer() } : {}),
...columnExtras,
}),
ApiProperty({
type: Number,
description,
default: defaultValue,
required: required && defaultValue == null,
...propertyExtras,
}),
...(required ? [] : [IsOptional()]),
IsInt(),
...(unsigned ? [Min(0)] : []),
]);
export const EnumColumn = <T>(
targetEnum: Record<string, T>,
description = 'unknown',
defaultValue?: T,
required = false,
columnExtras: ColumnCommonOptions & ColumnEnumOptions = {},
swaggerExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Index(),
Column('enum', {
enum: targetEnum,
default: defaultValue,
nullable: !required && !defaultValue,
comment: description,
...columnExtras,
}),
ApiProperty({
description,
enum: targetEnum,
default: defaultValue,
required,
...swaggerExtras,
}),
...(required ? [] : [IsOptional()]),
IsEnum(targetEnum),
]);
export const DateColumn = <T>(
description = 'unknown',
required = false,
columnExtras: ColumnCommonOptions & ColumnEnumOptions = {},
swaggerExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
IsDate(),
...(required ? [] : [IsOptional()]),
Column('timestamp', {
nullable: !required,
comment: description,
...columnExtras,
}),
ApiProperty({
description,
type: Date,
required,
...swaggerExtras,
}),
]);
export const NotColumn = (
description?: string,
swaggerExtras: ApiPropertyOptions = {},
): PropertyDecorator =>
MergePropertyDecorators([
Exclude(),
ApiProperty({
description,
required: false,
readOnly: true,
...swaggerExtras,
}),
]);
import { MergePropertyDecorators, StringColumn } from './base';
import { Index } from 'typeorm';
export const EntityName = (length = 32, description = '名称') =>
MergePropertyDecorators([
Index(),
StringColumn(length, description, undefined, true),
]);
export const EntityDescription = (length = 5000, description = '描述') =>
StringColumn(length, description, '', false);
import { Column } from 'typeorm';
import { MergePropertyDecorators } from './base';
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsPositive } from 'class-validator';
import { BigintTransformer } from '../utility/bigint-transform';
import { ColumnCommonOptions } from 'typeorm/decorator/options/ColumnCommonOptions';
import { ColumnWithLengthOptions } from 'typeorm/decorator/options/ColumnWithLengthOptions';
import { ColumnWithWidthOptions } from 'typeorm/decorator/options/ColumnWithWidthOptions';
export const RelationColumn = (
description = '对应编号',
notNull = false,
columnExtras: ColumnCommonOptions & ColumnWithWidthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('bigint', {
nullable: !notNull,
unsigned: true,
transformer: new BigintTransformer(),
comment: description,
...columnExtras,
}),
ApiProperty({
type: Number,
description,
required: notNull,
...propertyExtras,
}),
...(notNull ? [] : [IsOptional()]),
IsInt(),
IsPositive(),
]);
export const StringRelationColumn = (
description = '对应编号',
notNull = false,
columnExtras: ColumnCommonOptions & ColumnWithLengthOptions = {},
propertyExtras: ApiPropertyOptions = {},
) =>
MergePropertyDecorators([
Column('varchar', {
length: 32,
nullable: !notNull,
comment: description,
...columnExtras,
}),
ApiProperty({
type: String,
required: notNull,
description,
...propertyExtras,
}),
...(notNull ? [] : [IsOptional()]),
IsNotEmpty(),
]);
import { Expose } from 'class-transformer';
import { MergePropertyDecorators } from './base';
import { IsOptional } from 'class-validator';
export const NotWritable = () =>
MergePropertyDecorators([Expose({ groups: ['r'] }), IsOptional()]);
export const NotChangeable = () => Expose({ groups: ['r', 'c'] });
import { SelectQueryBuilder } from 'typeorm';
export interface QueryWise<T> {
applyQuery(qb: SelectQueryBuilder<T>, entityName: string): void;
}
export interface IdWise {
id: number;
}
export interface StringIdWise {
id: string;
}
export interface NameWise {
name: string;
}
export interface DescWise {
desc: string;
}
export interface NameDescWise extends NameWise, DescWise {}
export interface IdNameWise extends IdWise, NameWise {}
export interface StringIdNameWise extends StringIdWise, NameWise {}
export interface IdNameDescWise extends IdWise, NameDescWise {}
export interface StringIdNameDescWise extends StringIdNameWise, DescWise {}
import { ValueTransformer } from 'typeorm';
export class BigintTransformer implements ValueTransformer {
from(dbValue) {
if (dbValue == null) {
return dbValue;
}
return parseInt(dbValue);
}
to(entValue): any {
return entValue;
}
}
import { TimeBase } from '../bases/TimeBase.entity';
import { SelectQueryBuilder } from 'typeorm';
export function applyQueryProperty<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} = :${field}`, { [field]: obj[field] });
}
}
export function applyQueryPropertyLike<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like (:${field} || '%')`, {
[field]: obj[field],
});
}
}
export function applyQueryPropertySearch<T extends TimeBase>(
obj: T,
qb: SelectQueryBuilder<T>,
entityName: string,
...fields: (keyof T & string)[]
) {
for (const field of fields) {
if (obj[field] == null) {
continue;
}
qb.andWhere(`${entityName}.${field} like ('%' || :${field} || '%')`, {
[field]: obj[field],
});
}
}
import yaml from 'yaml';
import * as fs from 'fs';
export interface Config {
host: string;
port: number;
}
const defaultConfig: Config = {
host: '::',
port: 3000,
};
export async function loadConfig(): Promise<Config> {
let readConfig: Partial<Config> = {};
try {
const configText = await fs.promises.readFile('./config.yaml', 'utf-8');
readConfig = yaml.parse(configText);
} catch (e) {
console.error(`Failed to read config: ${e.toString()}`);
}
return {
...defaultConfig,
...readConfig,
};
}
import { ValidationPipe } from '@nestjs/common';
import { ClassConstructor, plainToInstance } from 'class-transformer';
export const CreatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['c'], enableImplicitConversion: true },
});
export const GetPipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['r'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
export class ClassGetPipe<T> extends ValidationPipe {
constructor(private readonly classConstructor: ClassConstructor<T>) {
super({
transform: false,
transformOptions: {
groups: ['r'],
enableImplicitConversion: true,
},
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
}
override async transform(value: any, metadata: any) {
const obj = await super.transform(value, metadata);
return plainToInstance(this.classConstructor, obj, {
groups: ['r'],
enableImplicitConversion: false,
});
}
}
export const UpdatePipe = new ValidationPipe({
transform: true,
transformOptions: { groups: ['u'], enableImplicitConversion: true },
skipMissingProperties: true,
skipNullProperties: true,
skipUndefinedProperties: true,
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment