From 0720fbee97e29ca0125ddde42f84f6721fdc5495 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 11:13:23 +0200 Subject: [PATCH 01/13] #RI-4186 BE poc for custom tutorials --- redisinsight/api/config/default.ts | 1 + redisinsight/api/config/ormconfig.ts | 2 + redisinsight/api/package.json | 1 + redisinsight/api/src/app.module.ts | 2 + redisinsight/api/src/core.module.ts | 3 + .../custom-tutorial.controller.ts | 50 +++++++++++ .../custom-tutorial/custom-tutorial.module.ts | 26 ++++++ .../custom-tutorial.service.ts | 73 ++++++++++++++++ .../dto/upload.custom-tutorial.dto.ts | 22 +++++ .../entities/custom-tutorial.entity.ts | 23 +++++ .../custom-tutorial/exceptions/index.ts | 0 .../custom-tutorial/models/custom-tutorial.ts | 18 ++++ .../custom-tutorial.repository.ts | 15 ++++ .../local.custom-tutorial.repository.ts | 34 ++++++++ redisinsight/api/yarn.lock | 83 ++++++++++++++++++- 15 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 6ee366989e..0fc95749da 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -24,6 +24,7 @@ export default { logs: join(homedir, 'logs'), defaultPlugins: join(staticDir, 'plugins'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), pluginsAssets: join(staticDir, 'resources', 'plugins'), commands: join(homedir, 'commands'), defaultCommandsDir: join(defaultsDir, 'commands'), diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index a2c97987a9..56b1dcbc4f 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -12,6 +12,7 @@ import { ClientCertificateEntity } from 'src/modules/certificate/entities/client import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-history.entity'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -35,6 +36,7 @@ const ormConfig = { DatabaseAnalysisEntity, BrowserHistoryEntity, SshOptionsEntity, + CustomTutorialEntity, ], migrations, }; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 6672514c51..7a1d7a2347 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -65,6 +65,7 @@ "lodash": "^4.17.20", "nest-router": "^1.0.9", "nest-winston": "^1.4.0", + "nestjs-form-data": "^1.8.7", "node-version-compare": "^1.0.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.6", diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 402db8875c..4a4fd36a07 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -21,6 +21,7 @@ import { CoreModule } from 'src/core.module'; import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.module'; import { DatabaseImportModule } from 'src/modules/database-import/database-import.module'; import { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; +import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; @@ -53,6 +54,7 @@ const PATH_CONFIG = config.get('dir_path'); NotificationModule, BulkActionsModule, ClusterMonitorModule, + CustomTutorialModule.register(), DatabaseAnalysisModule, DatabaseImportModule, ...(SERVER_CONFIG.staticContent diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index d549235f20..467acda836 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -7,6 +7,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; import { SshModule } from 'src/modules/ssh/ssh.module'; +import { NestjsFormDataModule } from 'nestjs-form-data'; @Global() @Module({ @@ -19,6 +20,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule.register(), RedisModule, SshModule, + NestjsFormDataModule, ], exports: [ EncryptionModule, @@ -27,6 +29,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule, RedisModule, SshModule, + NestjsFormDataModule, ], }) export class CoreModule {} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts new file mode 100644 index 0000000000..91f481f1d7 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -0,0 +1,50 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, Get, HttpCode, Post, UploadedFile, + UseInterceptors, UsePipes, ValidationPipe +} from '@nestjs/common'; +import { + ApiBody, ApiConsumes, ApiTags, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { Database } from 'src/modules/database/models/database'; +import { FormDataRequest } from 'nestjs-form-data'; + +@UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) +@ApiTags('Tutorials') +@Controller('/custom-tutorials') +export class CustomTutorialController { + constructor(private readonly service: CustomTutorialService) {} + + @Post('upload') + @HttpCode(201) + @ApiConsumes('multipart/form-data') + @FormDataRequest() + async upload( + @Body() dto: UploadCustomTutorialDto, + ): Promise { + console.log('___ dto', dto); + return this.service.upload(dto); + } + + @Get('manifest') + @ApiEndpoint({ + description: 'Update database instance by id', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Updated database instance\' response', + type: Database, + }, + ], + }) + async getManifest(): Promise { + return this.service.getManifest(); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts new file mode 100644 index 0000000000..b352da180e --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts @@ -0,0 +1,26 @@ +import { Module, Type } from '@nestjs/common'; +import { CustomTutorialController } from 'src/modules/custom-tutorial/custom-tutorial.controller'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; + +@Module({}) +export class CustomTutorialModule { + static register( + repository: Type = LocalCustomTutorialRepository, + ) { + return { + module: CustomTutorialModule, + controllers: [CustomTutorialController], + providers: [ + CustomTutorialService, + { + provide: CustomTutorialRepository, + useClass: repository, + }, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts new file mode 100644 index 0000000000..b64c794b82 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import * as AdmZip from 'adm-zip'; +import { v4 as uuidv4 } from 'uuid'; +import * as os from 'os'; +import * as fs from 'fs-extra'; +import { join } from 'path'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import config from 'src/utils/config'; +import { plainToClass } from 'class-transformer'; + +const PATH_CONFIG = config.get('dir_path'); +const TMP_FOLDER = `${os.tmpdir()}/RedisInsight-v2/custom-tutorials`; + +@Injectable() +export class CustomTutorialService { + constructor( + private readonly customTutorialRepository: CustomTutorialRepository, + ) {} + + public async upload(dto: UploadCustomTutorialDto) { + try { + // upload tutorial to tmp folder + const tmpFolderName = uuidv4(); + const tmpPath = join(TMP_FOLDER, tmpFolderName); + await fs.ensureDir(tmpPath); + const zip = new AdmZip(dto.file.buffer); + await fs.remove(tmpPath); + await zip.extractAllTo(tmpPath, true); + + // todo: validate + + // todo: mode to main folder + const id = uuidv4(); + const path = join(PATH_CONFIG.customTutorials, id); + await fs.move(tmpPath, path); + + // todo: save entity + const entity = await this.customTutorialRepository.create(plainToClass(CustomTutorial, { + ...dto, + id, + path, + })); + + console.log('___ entity', entity); + + // await fs.writeFile( + // join(this.options.destinationPath, this.options.buildInfo), + // JSON.stringify(await this.getRemoteBuildInfo()), + // ); + } catch (e) { + throw e; + } + } + + // todo: replace any + public async getManifest(): Promise { + return { + 'my-tutorials': { + type: 'group', + id: 'mu-tutorials', + label: 'My Tutorials', + actions: ['create'], + args: { + withBorder: true, + initialIsOpen: true, + }, + children: {}, + }, + }; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts new file mode 100644 index 0000000000..71d27a119a --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; +import { HasMimeType, IsFile, MemoryStoredFile } from 'nestjs-form-data'; + +export class UploadCustomTutorialDto { + @ApiProperty({ + type: 'string', + format: 'binary', + description: 'ZIP archive with tutorial static files', + }) + @IsFile() + @HasMimeType(['application/zip']) + file: MemoryStoredFile; + + @ApiProperty({ + description: 'Name to show for custom tutorials', + }) + @Expose() + @IsString() + name: string; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts new file mode 100644 index 0000000000..a8dc6d484e --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, +} from 'typeorm'; +import { Expose } from 'class-transformer'; + +@Entity('custom_tutorials') +export class CustomTutorialEntity { + @PrimaryGeneratedColumn('uuid') + @Expose() + id: string; + + @Column({ nullable: false }) + @Expose() + name: string; + + @Column({ nullable: true }) + @Expose() + link: string; + + @CreateDateColumn() + @Expose() + createdAt: Date; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts b/redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts new file mode 100644 index 0000000000..6972733df3 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; + +export class CustomTutorial { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose() + path: string; + + @Expose() + link: string; + + @Expose() + createdAt: Date; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts new file mode 100644 index 0000000000..5a1e1e5b04 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts @@ -0,0 +1,15 @@ +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; + +export abstract class CustomTutorialRepository { + /** + * Create custom tutorial entity + * @param model + * @return CustomTutorial + */ + abstract create(model: CustomTutorial): Promise; + + /** + * Get list of custom tutorials + */ + abstract list(): Promise; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts new file mode 100644 index 0000000000..9df3af923e --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -0,0 +1,34 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { classToClass } from 'src/utils'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; + +export class LocalCustomTutorialRepository extends CustomTutorialRepository { + constructor( + @InjectRepository(CustomTutorialEntity) + private readonly repository: Repository, + ) { + super(); + } + + /** + * @inheritDoc + */ + public async create(model: CustomTutorial): Promise { + const entity = classToClass(CustomTutorialEntity, model); + await this.repository.save(entity); + + return classToClass(CustomTutorial, entity); + } + + /** + * @inheritDoc + */ + public async list(): Promise { + const entities = await this.repository.find({}); + + return entities.map((entity) => classToClass(CustomTutorial, entity)); + } +} diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index 1ca7fdaab1..4a65435c3b 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -653,6 +653,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lukeed/csprng@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.0.1.tgz#625e93a0edb2c830e3c52ce2d67b9d53377c6a66" + integrity sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g== + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.9" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" @@ -906,6 +911,11 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2026,7 +2036,7 @@ buildcheck@0.0.3: resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== -busboy@^1.0.0: +busboy@^1.0.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -2478,6 +2488,16 @@ concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + concurrently@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-5.3.0.tgz#7500de6410d043c912b2da27de3202cb489b1e7b" @@ -3695,6 +3715,15 @@ file-stream-rotator@^0.5.7: dependencies: moment "^2.11.2" +file-type@^16.5.4: + version "16.5.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" + integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== + dependencies: + readable-web-to-node-stream "^3.0.0" + strtok3 "^6.2.4" + token-types "^4.1.1" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -6044,6 +6073,19 @@ nest-winston@^1.4.0: cli-color "^2.0.1" fast-safe-stringify "^2.1.1" +nestjs-form-data@^1.8.7: + version "1.8.7" + resolved "https://registry.yarnpkg.com/nestjs-form-data/-/nestjs-form-data-1.8.7.tgz#ccdbc2060849e34018841bfba557de37ae64abdb" + integrity sha512-mk17APNXELILClea2nwffRrD/NK5Q6zulTJCzNPxwMWfWucHO2HD7Ftjwg2BVnwO27QgqILDLuaO6LEpP6Ng4w== + dependencies: + uid "^2.0.0" + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + file-type "^16.5.4" + mkdirp "^1.0.4" + type-is "^1.6.18" + next-tick@1, next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -6624,6 +6666,11 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +peek-readable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" + integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6887,7 +6934,7 @@ readable-stream@^2.0.1, readable-stream@^2.2.2, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -6896,6 +6943,13 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-web-to-node-stream@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" + integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== + dependencies: + readable-stream "^3.6.0" + readdirp@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" @@ -7787,6 +7841,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strtok3@^6.2.4: + version "6.3.0" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" + integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^4.1.0" + superagent@^3.8.3: version "3.8.3" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" @@ -8062,6 +8124,14 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +token-types@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" + integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -8265,7 +8335,7 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -8328,6 +8398,13 @@ typescript@^4.0.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +uid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.1.tgz#a3f57c962828ea65256cd622fc363028cdf4526b" + integrity sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A== + dependencies: + "@lukeed/csprng" "^1.0.0" + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" From c5726ba9aae6f8b6883c42203c832da28f2d027a Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 11:54:07 +0200 Subject: [PATCH 02/13] #RI-4186 BE poc add uri + create manifest tree from existing tutirials --- redisinsight/api/config/default.ts | 1 + .../custom-tutorial.service.ts | 46 ++++++++++++++++++- .../entities/custom-tutorial.entity.ts | 4 ++ .../custom-tutorial/models/custom-tutorial.ts | 2 +- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 0fc95749da..ae244c381d 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -46,6 +46,7 @@ export default { staticUri: '/static', guidesUri: '/static/guides', tutorialsUri: '/static/tutorials', + customTutorialsUri: '/static/custom-tutorials', contentUri: '/static/content', defaultPluginsUri: '/static/plugins', pluginsAssetsUri: '/static/resources/plugins', diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index b64c794b82..87c861da25 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -11,6 +11,7 @@ import config from 'src/utils/config'; import { plainToClass } from 'class-transformer'; const PATH_CONFIG = config.get('dir_path'); +const SERVER_CONFIG = config.get('server'); const TMP_FOLDER = `${os.tmpdir()}/RedisInsight-v2/custom-tutorials`; @Injectable() @@ -34,6 +35,7 @@ export class CustomTutorialService { // todo: mode to main folder const id = uuidv4(); const path = join(PATH_CONFIG.customTutorials, id); + const uri = join(SERVER_CONFIG.customTutorialsUri, id); await fs.move(tmpPath, path); // todo: save entity @@ -41,6 +43,7 @@ export class CustomTutorialService { ...dto, id, path, + uri, })); console.log('___ entity', entity); @@ -54,8 +57,49 @@ export class CustomTutorialService { } } + private async generateTutorialManifest(tutorial: CustomTutorial): Promise> { + try { + const rootPath = join(PATH_CONFIG.customTutorials, tutorial.id); + const children = JSON.parse( + await fs.readFile(join(rootPath, 'manifest.json'), 'utf8'), + ); + + return { + type: 'group', + id: tutorial.id, + label: tutorial.name, + actions: ['delete'], + uri: tutorial.uri, + children, + }; + } catch (e) { + console.log('___ oo', e); + // todo: error log + return null; + } + } + // todo: replace any public async getManifest(): Promise { + const children = {}; + + try { + const tutorials = await this.customTutorialRepository.list(); + console.log('___ tutorials', tutorials); + + const manifests = await Promise.all(tutorials.map(this.generateTutorialManifest.bind(this))) as Record[]; + + console.log('___ manifests', manifests); + + manifests.forEach((manifest) => { + if (manifest) { + children[manifest.id] = manifest; + } + }); + } catch (e) { + // silent + } + return { 'my-tutorials': { type: 'group', @@ -66,7 +110,7 @@ export class CustomTutorialService { withBorder: true, initialIsOpen: true, }, - children: {}, + children, }, }; } diff --git a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts index a8dc6d484e..b14326b2c0 100644 --- a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts +++ b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts @@ -17,6 +17,10 @@ export class CustomTutorialEntity { @Expose() link: string; + @Column({ nullable: false }) + @Expose() + uri: string; + @CreateDateColumn() @Expose() createdAt: Date; diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts index 6972733df3..4a1533e914 100644 --- a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -8,7 +8,7 @@ export class CustomTutorial { name: string; @Expose() - path: string; + uri: string; @Expose() link: string; From 4a9e01a026cdaba12ad19a3b87d90dcebb8ff67d Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 12:38:47 +0200 Subject: [PATCH 03/13] #RI-4186 BE poc serve custom tutorials static files --- .../statics-management/statics-management.module.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/redisinsight/api/src/modules/statics-management/statics-management.module.ts b/redisinsight/api/src/modules/statics-management/statics-management.module.ts index 86326e2b6c..97bc86d076 100644 --- a/redisinsight/api/src/modules/statics-management/statics-management.module.ts +++ b/redisinsight/api/src/modules/statics-management/statics-management.module.ts @@ -27,6 +27,13 @@ const CONTENT_CONFIG = config.get('content'); fallthrough: false, }, }), + ServeStaticModule.forRoot({ + serveRoot: SERVER_CONFIG.customTutorialsUri, + rootPath: join(PATH_CONFIG.customTutorials), + serveStaticOptions: { + fallthrough: false, + }, + }), ServeStaticModule.forRoot({ serveRoot: SERVER_CONFIG.contentUri, rootPath: join(PATH_CONFIG.content), From cf695ea6b20ac7b7ddbec4dada5137741223e7a0 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 13:10:40 +0200 Subject: [PATCH 04/13] #RI-4186 change uri calculation --- .../api/src/modules/custom-tutorial/custom-tutorial.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 87c861da25..5d21bc5362 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -35,7 +35,7 @@ export class CustomTutorialService { // todo: mode to main folder const id = uuidv4(); const path = join(PATH_CONFIG.customTutorials, id); - const uri = join(SERVER_CONFIG.customTutorialsUri, id); + const uri = join('/', id); await fs.move(tmpPath, path); // todo: save entity From 72f4fa87ebdf6db638d44254a060eb97d0a77106 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 13:57:51 +0200 Subject: [PATCH 05/13] #RI-4186 BE delete custom tutorial --- .../custom-tutorial.controller.ts | 13 ++++++++++++- .../custom-tutorial.service.ts | 19 +++++++++++++++++++ .../custom-tutorial.repository.ts | 5 +++++ .../local.custom-tutorial.repository.ts | 8 ++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts index 91f481f1d7..91b7f1e912 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -1,7 +1,7 @@ import { Body, ClassSerializerInterceptor, - Controller, Get, HttpCode, Post, UploadedFile, + Controller, Delete, Get, HttpCode, Param, Post, UploadedFile, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common'; import { @@ -47,4 +47,15 @@ export class CustomTutorialController { async getManifest(): Promise { return this.service.getManifest(); } + + @Delete('/:id') + @ApiEndpoint({ + statusCode: 200, + description: 'Delete custom tutorial and its files', + }) + async delete( + @Param('id') id: string, + ): Promise { + return this.service.delete(id); + } } diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 5d21bc5362..6376dba24e 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -114,4 +114,23 @@ export class CustomTutorialService { }, }; } + + public async delete(id): Promise { + console.log('___ trynig to delete', id); + const path = join(PATH_CONFIG.customTutorials, id); + console.log('___ path', path); + + try { + await this.customTutorialRepository.delete(id); + try { + await fs.remove(path); + } catch (e) { + console.log('___ err', e) + // ignore errors + } + } catch (e) { + // todo: logs + throw e; + } + } } diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts index 5a1e1e5b04..e14ae29bf7 100644 --- a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts @@ -12,4 +12,9 @@ export abstract class CustomTutorialRepository { * Get list of custom tutorials */ abstract list(): Promise; + + /** + * Delete custom tutorial by id + */ + abstract delete(id: string): Promise; } diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts index 9df3af923e..eedf797a7d 100644 --- a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -31,4 +31,12 @@ export class LocalCustomTutorialRepository extends CustomTutorialRepository { return entities.map((entity) => classToClass(CustomTutorial, entity)); } + + /** + * @inheritDoc + */ + public async delete(id: string): Promise { + const entity = await this.repository.findOneBy({ id }); + await this.repository.remove(entity); + } } From ed3c3d0284a5433242e03d394091f560b2fee4b6 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 20 Feb 2023 14:15:27 +0200 Subject: [PATCH 06/13] #RI-4186 BE move '/' from uri --- .../api/src/modules/custom-tutorial/custom-tutorial.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 6376dba24e..5c2128b043 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -35,7 +35,8 @@ export class CustomTutorialService { // todo: mode to main folder const id = uuidv4(); const path = join(PATH_CONFIG.customTutorials, id); - const uri = join('/', id); + const uri = id; + console.log('___ uri', uri); await fs.move(tmpPath, path); // todo: save entity From 2271bdf66769e5b0caebb077f43f6a5a1c719736 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Feb 2023 10:14:31 +0200 Subject: [PATCH 07/13] #RI-4186 BE reworked poc --- .../api/src/constants/error-messages.ts | 1 + .../custom-tutorial.controller.ts | 44 ++++-- .../custom-tutorial/custom-tutorial.module.ts | 6 + .../custom-tutorial.service.ts | 141 +++++++----------- .../models/custom-tutorial.manifest.ts | 39 +++++ .../custom-tutorial/models/custom-tutorial.ts | 23 ++- .../providers/custom-tutorial.fs.provider.ts | 68 +++++++++ .../custom-tutorial.manifest.provider.ts | 54 +++++++ .../custom-tutorial.repository.ts | 7 + .../local.custom-tutorial.repository.ts | 10 +- 10 files changed, 293 insertions(+), 100 deletions(-) create mode 100644 redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index d48a79f23a..f1e1cb7296 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -7,6 +7,7 @@ export default { PROFILER_LOG_FILE_NOT_FOUND: 'Profiler log file was not found.', CONSUMER_GROUP_NOT_FOUND: 'Consumer Group with such name was not found.', PLUGIN_STATE_NOT_FOUND: 'Plugin state was not found.', + CUSTOM_TUTORIAL_NOT_FOUND: 'Custom Tutorial was not found.', UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', WRONG_DATABASE_TYPE: 'Wrong database type.', diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts index 91b7f1e912..a39837bb46 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -1,19 +1,29 @@ import { Body, ClassSerializerInterceptor, - Controller, Delete, Get, HttpCode, Param, Post, UploadedFile, - UseInterceptors, UsePipes, ValidationPipe + Controller, Delete, Get, HttpCode, Param, Post, + UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { - ApiBody, ApiConsumes, ApiTags, + ApiConsumes, ApiExtraModels, ApiTags, } from '@nestjs/swagger'; -import { FileInterceptor } from '@nestjs/platform-express'; import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { Database } from 'src/modules/database/models/database'; import { FormDataRequest } from 'nestjs-form-data'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +@ApiExtraModels( + CreateCaCertificateDto, UseCaCertificateDto, + CreateClientCertificateDto, UseClientCertificateDto, + CreateBasicSshOptionsDto, CreateCertSshOptionsDto, +) @UsePipes(new ValidationPipe({ transform: true })) @UseInterceptors(ClassSerializerInterceptor) @ApiTags('Tutorials') @@ -21,31 +31,37 @@ import { FormDataRequest } from 'nestjs-form-data'; export class CustomTutorialController { constructor(private readonly service: CustomTutorialService) {} - @Post('upload') + @Post('') @HttpCode(201) @ApiConsumes('multipart/form-data') @FormDataRequest() - async upload( + @ApiEndpoint({ + description: 'Create new tutorial', + statusCode: 201, + responses: [ + { + type: Object, + }, + ], + }) + async create( @Body() dto: UploadCustomTutorialDto, ): Promise { - console.log('___ dto', dto); - return this.service.upload(dto); + return this.service.create(dto); } @Get('manifest') @ApiEndpoint({ - description: 'Update database instance by id', + description: 'Get global manifest for custom tutorials', statusCode: 200, responses: [ { - status: 200, - description: 'Updated database instance\' response', - type: Database, + type: Object, }, ], }) - async getManifest(): Promise { - return this.service.getManifest(); + async getGlobalManifest(): Promise { + return this.service.getGlobalManifest(); } @Delete('/:id') diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts index b352da180e..8bb76ab242 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts @@ -1,6 +1,10 @@ import { Module, Type } from '@nestjs/common'; import { CustomTutorialController } from 'src/modules/custom-tutorial/custom-tutorial.controller'; import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; import { LocalCustomTutorialRepository, @@ -16,6 +20,8 @@ export class CustomTutorialModule { controllers: [CustomTutorialController], providers: [ CustomTutorialService, + CustomTutorialFsProvider, + CustomTutorialManifestProvider, { provide: CustomTutorialRepository, useClass: repository, diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 5c2128b043..d219e0fa10 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -1,96 +1,68 @@ -import { Injectable } from '@nestjs/common'; -import * as AdmZip from 'adm-zip'; +import { + Injectable, InternalServerErrorException, Logger, NotFoundException, +} from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; -import * as os from 'os'; -import * as fs from 'fs-extra'; -import { join } from 'path'; import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; -import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; -import config from 'src/utils/config'; import { plainToClass } from 'class-transformer'; - -const PATH_CONFIG = config.get('dir_path'); -const SERVER_CONFIG = config.get('server'); -const TMP_FOLDER = `${os.tmpdir()}/RedisInsight-v2/custom-tutorials`; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import { + CustomTutorialManifestType, + ICustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; @Injectable() export class CustomTutorialService { + private logger = new Logger('CustomTutorialService'); + constructor( private readonly customTutorialRepository: CustomTutorialRepository, + private readonly customTutorialFsProvider: CustomTutorialFsProvider, + private readonly customTutorialManifestProvider: CustomTutorialManifestProvider, ) {} - public async upload(dto: UploadCustomTutorialDto) { + /** + * Create custom tutorial entity + static files based on input + * Currently from zip file only + * @param dto + */ + public async create(dto: UploadCustomTutorialDto) { try { - // upload tutorial to tmp folder - const tmpFolderName = uuidv4(); - const tmpPath = join(TMP_FOLDER, tmpFolderName); - await fs.ensureDir(tmpPath); - const zip = new AdmZip(dto.file.buffer); - await fs.remove(tmpPath); - await zip.extractAllTo(tmpPath, true); + const tmpPath = await this.customTutorialFsProvider.unzipToTmpFolder(dto.file); // todo: validate - // todo: mode to main folder - const id = uuidv4(); - const path = join(PATH_CONFIG.customTutorials, id); - const uri = id; - console.log('___ uri', uri); - await fs.move(tmpPath, path); - - // todo: save entity - const entity = await this.customTutorialRepository.create(plainToClass(CustomTutorial, { + // create tutorial model + const tutorial = plainToClass(CustomTutorial, { ...dto, - id, - path, - uri, - })); - - console.log('___ entity', entity); - - // await fs.writeFile( - // join(this.options.destinationPath, this.options.buildInfo), - // JSON.stringify(await this.getRemoteBuildInfo()), - // ); - } catch (e) { - throw e; - } - } + id: uuidv4(), + }); - private async generateTutorialManifest(tutorial: CustomTutorial): Promise> { - try { - const rootPath = join(PATH_CONFIG.customTutorials, tutorial.id); - const children = JSON.parse( - await fs.readFile(join(rootPath, 'manifest.json'), 'utf8'), - ); + await this.customTutorialFsProvider.moveFolder(tmpPath, tutorial.absolutePath); - return { - type: 'group', - id: tutorial.id, - label: tutorial.name, - actions: ['delete'], - uri: tutorial.uri, - children, - }; + await this.customTutorialRepository.create(tutorial); } catch (e) { - console.log('___ oo', e); - // todo: error log - return null; + throw new InternalServerErrorException(e.message); } } - // todo: replace any - public async getManifest(): Promise { + /** + * Get global manifest for all custom tutorials + * In the future will be removed with some kind of partial load + */ + public async getGlobalManifest(): Promise> { const children = {}; try { const tutorials = await this.customTutorialRepository.list(); - console.log('___ tutorials', tutorials); - - const manifests = await Promise.all(tutorials.map(this.generateTutorialManifest.bind(this))) as Record[]; - - console.log('___ manifests', manifests); + const manifests = await Promise.all( + tutorials.map(this.customTutorialManifestProvider.generateTutorialManifest), + ) as Record[]; manifests.forEach((manifest) => { if (manifest) { @@ -98,15 +70,15 @@ export class CustomTutorialService { } }); } catch (e) { - // silent + this.logger.warn('Unable to generate entire custom tutorials manifest', e); } return { - 'my-tutorials': { - type: 'group', - id: 'mu-tutorials', + 'custom-tutorials': { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', label: 'My Tutorials', - actions: ['create'], + _actions: [CustomTutorialActions.CREATE], args: { withBorder: true, initialIsOpen: true, @@ -116,21 +88,24 @@ export class CustomTutorialService { }; } - public async delete(id): Promise { - console.log('___ trynig to delete', id); - const path = join(PATH_CONFIG.customTutorials, id); - console.log('___ path', path); + public async get(id: string): Promise { + const model = await this.customTutorialRepository.get(id); + + if (!model) { + this.logger.error(`Custom Tutorial with ${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + + return model; + } + public async delete(id: string): Promise { try { + const tutorial = await this.customTutorialRepository.get(id); await this.customTutorialRepository.delete(id); - try { - await fs.remove(path); - } catch (e) { - console.log('___ err', e) - // ignore errors - } + await this.customTutorialFsProvider.removeFolder(tutorial.absolutePath); } catch (e) { - // todo: logs + this.logger.error('Unable to delete custom tutorial', e); throw e; } } diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts new file mode 100644 index 0000000000..17c2ced1f6 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts @@ -0,0 +1,39 @@ +import { CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEnum, IsNotEmpty } from 'class-validator'; + +export enum CustomTutorialManifestType { + CodeButton = 'code-button', + Group = 'group', + InternalLink = 'internal-link', +} + +export interface ICustomTutorialManifest { + id: string, + type: CustomTutorialManifestType, + label: string, + children?: Record, + args?: Record, + _actions?: CustomTutorialActions[], + _path?: string, +} + +export class CustomTutorialManifest { + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + id: string; + + @ApiProperty({ enum: CustomTutorialManifestType }) + @Expose() + @IsEnum(CustomTutorialManifestType) + type: CustomTutorialManifestType; + + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + label: string; + + children: Record +} diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts index 4a1533e914..685fcc5f35 100644 --- a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -1,4 +1,13 @@ import { Expose } from 'class-transformer'; +import { join } from 'path'; +import config from 'src/utils/config'; + +const PATH_CONFIG = config.get('dir_path'); + +export enum CustomTutorialActions { + CREATE = 'create', + DELETE = 'delete', +} export class CustomTutorial { @Expose() @@ -11,8 +20,20 @@ export class CustomTutorial { uri: string; @Expose() - link: string; + link?: string; @Expose() createdAt: Date; + + get actions(): CustomTutorialActions[] { + return [CustomTutorialActions.DELETE]; + } + + get path(): string { + return `/${this.id}`; + } + + get absolutePath(): string { + return join(PATH_CONFIG.customTutorials, this.id); + } } diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts new file mode 100644 index 0000000000..e66b443162 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -0,0 +1,68 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { join } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs-extra'; +import config from 'src/utils/config'; +import * as AdmZip from 'adm-zip'; + +const PATH_CONFIG = config.get('dir_path'); + +const TMP_FOLDER = `${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`; + +@Injectable() +export class CustomTutorialFsProvider { + private logger = new Logger('CustomTutorialFsProvider'); + + /** + * Unzip custom tutorials to temporary folder + * @param file + */ + public async unzipToTmpFolder(file: MemoryStoredFile): Promise { + try { + const path = join(TMP_FOLDER, uuidv4()); + await fs.ensureDir(path); + const zip = new AdmZip(file.buffer); + await fs.remove(path); + await zip.extractAllTo(path, true); + + return path; + } catch (e) { + this.logger.error('Unable to unzip archive', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Move custom tutorial from tmp folder to proper path to serve static files + * force - default false, will remove existing folder + * @param tmpPath + * @param dest + * @param force + */ + public async moveFolder(tmpPath: string, dest: string, force = false) { + try { + if (force && await fs.pathExists(dest)) { + await fs.remove(dest); + } + + await fs.move(tmpPath, dest); + } catch (e) { + this.logger.error('Unable to move tutorial to a folder', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Delete Tutorial folder + * Will silently log an error if any + * @param path + */ + public async removeFolder(path: string) { + try { + await fs.remove(path); + } catch (e) { + this.logger.warn('Unable to delete tutorial folder', e); + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts new file mode 100644 index 0000000000..f83479da71 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { join } from 'path'; +import * as fs from 'fs-extra'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { + CustomTutorialManifestType, + ICustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; + +const MANIFEST_FILE = 'manifest.json'; + +@Injectable() +export class CustomTutorialManifestProvider { + private logger = new Logger('CustomTutorialManifestProvider'); + + /** + * Try to get and parse manifest.json + * In case of any error will not throw an error but return null + * In this case tutorial will be displayed but without anything inside + * So user will be able to fix (re-import) tutorial or remove it + * @param path + */ + public async getManifest(path: string): Promise { + try { + return JSON.parse( + await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to get manifest for tutorial'); + return null; + } + } + + /** + * Generate custom manifest based on manifest.json inside tutorial folder and + * additional data from local database + * @param tutorial + */ + public async generateTutorialManifest(tutorial: CustomTutorial): Promise> { + try { + return { + type: CustomTutorialManifestType.Group, + id: tutorial.id, + label: tutorial.name, + _actions: tutorial.actions, + _path: tutorial.path, + children: await this.getManifest(tutorial.absolutePath), + }; + } catch (e) { + this.logger.warn('Unable to generate manifest for tutorial', tutorial?.id); + return null; + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts index e14ae29bf7..93307e6fbd 100644 --- a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts @@ -8,6 +8,13 @@ export abstract class CustomTutorialRepository { */ abstract create(model: CustomTutorial): Promise; + /** + * Create custom tutorial entity + * @param id + * @return CustomTutorial + */ + abstract get(id: string): Promise; + /** * Get list of custom tutorials */ diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts index eedf797a7d..cb4fcc3fbc 100644 --- a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -32,11 +32,17 @@ export class LocalCustomTutorialRepository extends CustomTutorialRepository { return entities.map((entity) => classToClass(CustomTutorial, entity)); } + /** + * @inheritDoc + */ + public async get(id: string): Promise { + return classToClass(CustomTutorial, await this.repository.findOneBy({ id })); + } + /** * @inheritDoc */ public async delete(id: string): Promise { - const entity = await this.repository.findOneBy({ id }); - await this.repository.remove(entity); + await this.repository.delete({ id }); } } From 2afbf488151727e5b0f56d1f37a6b1f41ecd48f7 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Feb 2023 10:18:13 +0200 Subject: [PATCH 08/13] #RI-4186 BE fix schema --- .../modules/custom-tutorial/custom-tutorial.controller.ts | 1 - .../custom-tutorial/entities/custom-tutorial.entity.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts index a39837bb46..edf36022b2 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -10,7 +10,6 @@ import { import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; -import { Database } from 'src/modules/database/models/database'; import { FormDataRequest } from 'nestjs-form-data'; import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; diff --git a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts index b14326b2c0..b41cb1333a 100644 --- a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts +++ b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts @@ -15,11 +15,7 @@ export class CustomTutorialEntity { @Column({ nullable: true }) @Expose() - link: string; - - @Column({ nullable: false }) - @Expose() - uri: string; + link?: string; @CreateDateColumn() @Expose() From 23da1b070362dfcbbad3187832369d21c31b7fb2 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Feb 2023 10:25:11 +0200 Subject: [PATCH 09/13] fix manifest generation issue --- .../src/modules/custom-tutorial/custom-tutorial.service.ts | 4 +++- .../providers/custom-tutorial.manifest.provider.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index d219e0fa10..92c98cb77f 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -61,7 +61,9 @@ export class CustomTutorialService { try { const tutorials = await this.customTutorialRepository.list(); const manifests = await Promise.all( - tutorials.map(this.customTutorialManifestProvider.generateTutorialManifest), + tutorials.map( + this.customTutorialManifestProvider.generateTutorialManifest.bind(this.customTutorialManifestProvider), + ), ) as Record[]; manifests.forEach((manifest) => { diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts index f83479da71..8457afe887 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -47,7 +47,7 @@ export class CustomTutorialManifestProvider { children: await this.getManifest(tutorial.absolutePath), }; } catch (e) { - this.logger.warn('Unable to generate manifest for tutorial', tutorial?.id); + this.logger.warn('Unable to generate manifest for tutorial', e); return null; } } From 79f7302a3ded8e6542370f915d0e0d8e61a93aef Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Feb 2023 10:40:38 +0200 Subject: [PATCH 10/13] #RI-4186 - add order by createdAt DESC + add manifest as response for "create" endpoint --- .../custom-tutorial/custom-tutorial.controller.ts | 2 +- .../modules/custom-tutorial/custom-tutorial.service.ts | 10 ++++++---- .../custom-tutorial/dto/upload.custom-tutorial.dto.ts | 4 ++-- .../src/modules/custom-tutorial/exceptions/index.ts | 0 .../repositories/local.custom-tutorial.repository.ts | 5 ++++- 5 files changed, 13 insertions(+), 8 deletions(-) delete mode 100644 redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts index edf36022b2..930b4cf65c 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -45,7 +45,7 @@ export class CustomTutorialController { }) async create( @Body() dto: UploadCustomTutorialDto, - ): Promise { + ): Promise> { return this.service.create(dto); } diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 92c98cb77f..cb5956f55b 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -31,21 +31,23 @@ export class CustomTutorialService { * Currently from zip file only * @param dto */ - public async create(dto: UploadCustomTutorialDto) { + public async create(dto: UploadCustomTutorialDto): Promise> { try { const tmpPath = await this.customTutorialFsProvider.unzipToTmpFolder(dto.file); // todo: validate // create tutorial model - const tutorial = plainToClass(CustomTutorial, { + const model = plainToClass(CustomTutorial, { ...dto, id: uuidv4(), }); - await this.customTutorialFsProvider.moveFolder(tmpPath, tutorial.absolutePath); + await this.customTutorialFsProvider.moveFolder(tmpPath, model.absolutePath); - await this.customTutorialRepository.create(tutorial); + const tutorial = await this.customTutorialRepository.create(model); + + return await this.customTutorialManifestProvider.generateTutorialManifest(tutorial); } catch (e) { throw new InternalServerErrorException(e.message); } diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts index 71d27a119a..9f2d6bfae3 100644 --- a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; import { HasMimeType, IsFile, MemoryStoredFile } from 'nestjs-form-data'; export class UploadCustomTutorialDto { diff --git a/redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts b/redisinsight/api/src/modules/custom-tutorial/exceptions/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts index cb4fcc3fbc..1ec6b03aa2 100644 --- a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -27,7 +27,10 @@ export class LocalCustomTutorialRepository extends CustomTutorialRepository { * @inheritDoc */ public async list(): Promise { - const entities = await this.repository.find({}); + const entities = await this.repository + .createQueryBuilder('t') + .orderBy('t.createdAt', 'DESC') + .getMany(); return entities.map((entity) => classToClass(CustomTutorial, entity)); } From 013598026de0f6eb8a4400d291910dacd3bc0579 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 21 Feb 2023 14:17:17 +0200 Subject: [PATCH 11/13] #RI-4186 - UTests --- .../api/src/__mocks__/custom-tutorial.ts | 160 ++++++++++++++++++ redisinsight/api/src/__mocks__/index.ts | 1 + .../api/src/common/utils/errors.util.ts | 9 + redisinsight/api/src/common/utils/index.ts | 1 + .../custom-tutorial.service.spec.ts | 154 +++++++++++++++++ .../custom-tutorial.service.ts | 11 +- .../dto/upload.custom-tutorial.dto.ts | 3 +- .../custom-tutorial.fs.provider.spec.ts | 155 +++++++++++++++++ .../providers/custom-tutorial.fs.provider.ts | 13 +- .../custom-tutorial.manifest.provider.spec.ts | 72 ++++++++ .../custom-tutorial.manifest.provider.ts | 4 +- .../local.custom-tutorial.repository.spec.ts | 81 +++++++++ 12 files changed, 655 insertions(+), 9 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/custom-tutorial.ts create mode 100644 redisinsight/api/src/common/utils/errors.util.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts create mode 100644 redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts diff --git a/redisinsight/api/src/__mocks__/custom-tutorial.ts b/redisinsight/api/src/__mocks__/custom-tutorial.ts new file mode 100644 index 0000000000..558854a783 --- /dev/null +++ b/redisinsight/api/src/__mocks__/custom-tutorial.ts @@ -0,0 +1,160 @@ +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorialManifestType } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; + +export const mockCustomTutorialId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id'; + +export const mockCustomTutorialId2 = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id-2'; + +export const mockCustomTutorialTmpPath = '/tmp/path'; + +export const mockCustomTutorial = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId, + name: 'custom tutorial', + createdAt: new Date(), +}); + +export const mockCustomTutorialEntity = Object.assign(new CustomTutorialEntity(), { + ...mockCustomTutorial, +}); + +export const mockCustomTutorial2 = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId2, + name: 'custom tutorial 2', + createdAt: new Date(), +}); + +export const mockCustomTutorialZipFile = Object.assign(new MemoryStoredFile(), { + size: 100, + buffer: Buffer.from('zip-content', 'utf8'), +}); + +export const mockUploadCustomTutorialDto = Object.assign(new UploadCustomTutorialDto(), { + name: mockCustomTutorial.name, + file: mockCustomTutorialZipFile, +}); + +export const mockCustomTutorialManifestManifestJson = { + 'ct-folder-1': { + type: 'group', + id: 'ct-folder-1', + label: 'ct-folder-1', + // args: { + // withBorder: true, + // initialIsOpen: true, + // }, + children: { + 'ct-sub-folder-1': { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-1', + label: 'ct-sub-folder-1', + // args: { + // initialIsOpen: false, + // }, + children: { + introduction: { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-1/introduction.md', + }, + }, + 'working-with-hashes': { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-hashes', + label: 'working-with-hashes', + args: { + path: '/ct-folder-1/ct-sub-folder-1/working-with-hashes.md', + }, + }, + }, + }, + 'ct-sub-folder-2': { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-2', + label: 'ct-sub-folder-2', + // args: { + // withBorder: true, + // initialIsOpen: false, + // }, + children: { + introduction: { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-2/introduction.md', + }, + }, + 'working-with-graphs': { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-graphs', + label: 'working-with-graphs', + args: { + path: '/ct-folder-1/ct-sub-folder-2/working-with-graphs.md', + }, + }, + }, + }, + }, + }, +}; + +export const mockCustomTutorialManifestManifest = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId, + label: mockCustomTutorial.name, + _actions: mockCustomTutorial.actions, + _path: mockCustomTutorial.path, + children: mockCustomTutorialManifestManifestJson, +}; + +export const mockCustomTutorialManifestManifest2 = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId2, + label: mockCustomTutorial2.name, + _actions: mockCustomTutorial2.actions, + _path: mockCustomTutorial2.path, + children: mockCustomTutorialManifestManifestJson, +}; + +export const globalCustomTutorialManifest = { + 'custom-tutorials': { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', + label: 'My Tutorials', + _actions: [CustomTutorialActions.CREATE], + args: { + withBorder: true, + initialIsOpen: true, + }, + children: { + [mockCustomTutorialManifestManifest.id]: mockCustomTutorialManifestManifest, + [mockCustomTutorialManifestManifest2.id]: mockCustomTutorialManifestManifest2, + }, + }, +}; + +export const mockCustomTutorialFsProvider = jest.fn(() => ({ + unzipToTmpFolder: jest.fn().mockResolvedValue(mockCustomTutorialTmpPath), + moveFolder: jest.fn(), + removeFolder: jest.fn(), +})); + +export const mockCustomTutorialManifestProvider = jest.fn(() => ({ + getManifestJson: jest.fn().mockResolvedValue(mockCustomTutorialManifestManifestJson), + generateTutorialManifest: jest.fn().mockResolvedValue(mockCustomTutorialManifestManifest), +})); + +export const mockCustomTutorialRepository = jest.fn(() => ({ + get: jest.fn().mockResolvedValue(mockCustomTutorial), + create: jest.fn().mockResolvedValue(mockCustomTutorial), + delete: jest.fn(), + list: jest.fn().mockResolvedValue([ + mockCustomTutorial, + mockCustomTutorial2, + ]), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 8ae591bd75..ab2d9d023d 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -11,6 +11,7 @@ export * from './analytics'; export * from './profiler'; export * from './user'; export * from './databases'; +export * from './custom-tutorial'; export * from './autodiscovery'; export * from './redis'; export * from './server'; diff --git a/redisinsight/api/src/common/utils/errors.util.ts b/redisinsight/api/src/common/utils/errors.util.ts new file mode 100644 index 0000000000..906198d2c8 --- /dev/null +++ b/redisinsight/api/src/common/utils/errors.util.ts @@ -0,0 +1,9 @@ +import { HttpException, InternalServerErrorException } from '@nestjs/common'; + +export const wrapHttpError = (error: Error) => { + if (error instanceof HttpException) { + return error; + } + + return new InternalServerErrorException(error.message); +}; diff --git a/redisinsight/api/src/common/utils/index.ts b/redisinsight/api/src/common/utils/index.ts index ee0efba08b..3aaf1016a1 100644 --- a/redisinsight/api/src/common/utils/index.ts +++ b/redisinsight/api/src/common/utils/index.ts @@ -1 +1,2 @@ export * from './certificate-import.util'; +export * from './errors.util'; diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts new file mode 100644 index 0000000000..fa4ac63257 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + globalCustomTutorialManifest, + mockCustomTutorial, + mockCustomTutorialFsProvider, + mockCustomTutorialId, + mockCustomTutorialManifestManifest, mockCustomTutorialManifestManifest2, + mockCustomTutorialManifestProvider, + mockCustomTutorialRepository, + MockType, mockUploadCustomTutorialDto, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), +}; +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialService', () => { + let service: CustomTutorialService; + let customTutorialRepository: MockType; + let customTutorialFsProvider: MockType; + let customTutorialManifestProvider: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialService, + { + provide: CustomTutorialRepository, + useFactory: mockCustomTutorialRepository, + }, + { + provide: CustomTutorialFsProvider, + useFactory: mockCustomTutorialFsProvider, + }, + { + provide: CustomTutorialManifestProvider, + useFactory: mockCustomTutorialManifestProvider, + }, + ], + }).compile(); + + service = await module.get(CustomTutorialService); + customTutorialRepository = await module.get(CustomTutorialRepository); + customTutorialFsProvider = await module.get(CustomTutorialFsProvider); + customTutorialManifestProvider = await module.get(CustomTutorialManifestProvider); + }); + + describe('create', () => { + it('Should create custom tutorial', async () => { + const result = await service.create(mockUploadCustomTutorialDto); + + expect(result).toEqual(mockCustomTutorialManifestManifest); + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.create.mockRejectedValueOnce(new Error('Unable to create')); + + try { + await service.create(mockUploadCustomTutorialDto); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to create'); + } + }); + }); + + describe('getGlobalManifest', () => { + it('Should return global manifest with 2 tutorials', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(mockCustomTutorialManifestManifest) + .mockResolvedValueOnce(mockCustomTutorialManifestManifest2); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual(globalCustomTutorialManifest); + }); + + it('Should return global manifest with 1 tutorials since 1 failed to fetch', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(null); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + 'custom-tutorials': { + ...globalCustomTutorialManifest['custom-tutorials'], + children: { + [mockCustomTutorialManifestManifest.id]: mockCustomTutorialManifestManifest, + }, + }, + }); + }); + + it('Should return global manifest without children in case of any error', async () => { + customTutorialRepository.list.mockRejectedValueOnce(new Error('Unable to get list of tutorials')); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + 'custom-tutorials': { + ...globalCustomTutorialManifest['custom-tutorials'], + children: {}, + }, + }); + }); + }); + + describe('delete', () => { + it('Should successfully delete entity and remove related directory', async () => { + await service.delete(mockCustomTutorialId); + + expect(customTutorialFsProvider.removeFolder).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('Should throw NotFound error when try to delete not existing tutorial', async () => { + customTutorialRepository.get.mockResolvedValueOnce(null); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.delete.mockRejectedValueOnce(new Error('Unable to delete')); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to delete'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index cb5956f55b..011b1193a8 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -1,5 +1,5 @@ import { - Injectable, InternalServerErrorException, Logger, NotFoundException, + Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; @@ -15,6 +15,7 @@ import { CustomTutorialManifestType, ICustomTutorialManifest, } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { wrapHttpError } from 'src/common/utils'; @Injectable() export class CustomTutorialService { @@ -49,7 +50,8 @@ export class CustomTutorialService { return await this.customTutorialManifestProvider.generateTutorialManifest(tutorial); } catch (e) { - throw new InternalServerErrorException(e.message); + this.logger.error('Unable to create custom tutorials', e); + throw wrapHttpError(e); } } @@ -62,6 +64,7 @@ export class CustomTutorialService { try { const tutorials = await this.customTutorialRepository.list(); + const manifests = await Promise.all( tutorials.map( this.customTutorialManifestProvider.generateTutorialManifest.bind(this.customTutorialManifestProvider), @@ -105,12 +108,12 @@ export class CustomTutorialService { public async delete(id: string): Promise { try { - const tutorial = await this.customTutorialRepository.get(id); + const tutorial = await this.get(id); await this.customTutorialRepository.delete(id); await this.customTutorialFsProvider.removeFolder(tutorial.absolutePath); } catch (e) { this.logger.error('Unable to delete custom tutorial', e); - throw e; + throw wrapHttpError(e); } } } diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts index 9f2d6bfae3..cf8df33553 100644 --- a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; import { HasMimeType, IsFile, MemoryStoredFile } from 'nestjs-form-data'; export class UploadCustomTutorialDto { @@ -18,5 +18,6 @@ export class UploadCustomTutorialDto { }) @Expose() @IsString() + @IsNotEmpty() name: string; } diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts new file mode 100644 index 0000000000..138748a6c7 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts @@ -0,0 +1,155 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockCustomTutorial, mockCustomTutorialTmpPath, mockCustomTutorialZipFile, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { InternalServerErrorException } from '@nestjs/common'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), +}; +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialFsProvider', () => { + let service: CustomTutorialFsProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialFsProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialFsProvider); + }); + + describe('unzipToTmpFolder', () => { + let prepareTmpFolderSpy; + + beforeEach(() => { + mockedFs.ensureDir.mockImplementationOnce(() => Promise.resolve()); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + prepareTmpFolderSpy = jest.spyOn(CustomTutorialFsProvider, 'prepareTmpFolder'); + }); + + it('should unzip data', async () => { + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + }); + it('should unzip data to particular tmp folder', async () => { + prepareTmpFolderSpy.mockResolvedValueOnce(mockCustomTutorialTmpPath); + + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + + expect(mockedAdmZip.extractAllTo).toHaveBeenCalledWith(mockCustomTutorialTmpPath, true); + }); + + it('should throw InternalServerError', async () => { + mockedAdmZip.extractAllTo.mockRejectedValueOnce(new Error('Unable to extract file')); + + try { + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to extract file'); + } + }); + }); + + describe('moveFolder', () => { + it('should move folder', async () => { + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + + expect(mockedFs.pathExists).not.toHaveBeenCalled(); + expect(mockedFs.remove).not.toHaveBeenCalled(); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when there is no such folder in the dest path', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(false)); + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mockedFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.remove).not.toHaveBeenCalled(); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when and remove existing one before', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mockedFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should throw InternalServerError', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + mockedFs.move.mockImplementationOnce(() => Promise.reject(new Error('dest folder exists'))); + + try { + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('dest folder exists'); + } + }); + }); + + describe('removeFolder', () => { + it('should remove folder', async () => { + mockedFs.remove.mockResolvedValueOnce(); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('should not fail in case of any error', async () => { + mockedFs.remove.mockRejectedValueOnce(new Error('No file')); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts index e66b443162..e082d41e41 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -20,8 +20,7 @@ export class CustomTutorialFsProvider { */ public async unzipToTmpFolder(file: MemoryStoredFile): Promise { try { - const path = join(TMP_FOLDER, uuidv4()); - await fs.ensureDir(path); + const path = await CustomTutorialFsProvider.prepareTmpFolder(); const zip = new AdmZip(file.buffer); await fs.remove(path); await zip.extractAllTo(path, true); @@ -65,4 +64,14 @@ export class CustomTutorialFsProvider { this.logger.warn('Unable to delete tutorial folder', e); } } + + /** + * Create tmp folder in user's temporary directory and return path to it + */ + static async prepareTmpFolder(): Promise { + const path = join(TMP_FOLDER, uuidv4()); + await fs.ensureDir(path); + + return path; + } } diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts new file mode 100644 index 0000000000..70e217aa34 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockCustomTutorial, + mockCustomTutorialManifestManifest, mockCustomTutorialManifestManifestJson, +} from 'src/__mocks__'; +import { CustomTutorialManifestProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import * as fs from 'fs-extra'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +describe('CustomTutorialManifestProvider', () => { + let service: CustomTutorialManifestProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialManifestProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialManifestProvider); + }); + + describe('getManifest', () => { + it('should successfully get manifest', async () => { + mockedFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestManifestJson))); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(mockCustomTutorialManifestManifestJson); + }); + + it('should return null when no manifest found', async () => { + mockedFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(null); + }); + }); + + describe('generateTutorialManifest', () => { + it('should successfully generate manifest', async () => { + mockedFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestManifestJson))); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorialManifestManifest); + }); + + it('should generate manifest without children', async () => { + mockedFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual({ + ...mockCustomTutorialManifestManifest, + children: null, + }); + }); + + it('should return null in case of any error', async () => { + const result = await service.generateTutorialManifest(null); + + expect(result).toEqual(null); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts index 8457afe887..cf781c9891 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -20,7 +20,7 @@ export class CustomTutorialManifestProvider { * So user will be able to fix (re-import) tutorial or remove it * @param path */ - public async getManifest(path: string): Promise { + public async getManifestJson(path: string): Promise { try { return JSON.parse( await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), @@ -44,7 +44,7 @@ export class CustomTutorialManifestProvider { label: tutorial.name, _actions: tutorial.actions, _path: tutorial.path, - children: await this.getManifest(tutorial.absolutePath), + children: await this.getManifestJson(tutorial.absolutePath), }; } catch (e) { this.logger.warn('Unable to generate manifest for tutorial', e); diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts new file mode 100644 index 0000000000..32f187425b --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockCustomTutorial, mockCustomTutorialEntity, mockCustomTutorialId, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; + +describe('LocalCustomTutorialRepository', () => { + let service: LocalCustomTutorialRepository; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalCustomTutorialRepository, + { + provide: getRepositoryToken(CustomTutorialEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(CustomTutorialEntity)); + service = await module.get(LocalCustomTutorialRepository); + + repository.findOneBy.mockResolvedValue(mockCustomTutorialEntity); + repository.createQueryBuilder().getMany.mockResolvedValue([ + mockCustomTutorialEntity, + mockCustomTutorialEntity, + ]); + repository.save.mockResolvedValue(mockCustomTutorialEntity); + }); + + describe('get', () => { + it('should return custom tutorial model', async () => { + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(mockCustomTutorial); + }); + + it('should return null when custom tutorial was not found', async () => { + repository.findOneBy.mockResolvedValue(undefined); + + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(undefined); + }); + }); + + describe('list', () => { + it('should return list of custom tutorials', async () => { + expect(await service.list()).toEqual([ + mockCustomTutorial, + mockCustomTutorial, + ]); + }); + }); + + describe('create', () => { + it('should create custom tutorial', async () => { + const result = await service.create(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorial); + expect(repository.save).toHaveBeenCalledWith(mockCustomTutorialEntity); + }); + }); + + describe('delete', () => { + it('should delete custom tutorial by id', async () => { + expect(await service.delete(mockCustomTutorialId)).toEqual(undefined); + }); + }); +}); From 8e6857f937033da1a70f2a9daebb1f64f12cf973 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 23 Feb 2023 08:55:42 +0200 Subject: [PATCH 12/13] #RI-4186 added migrations + file size validation (10MB max) --- .../migration/1677135091633-custom-tutorials.ts | 14 ++++++++++++++ redisinsight/api/migration/index.ts | 2 ++ .../dto/upload.custom-tutorial.dto.ts | 5 ++++- 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/migration/1677135091633-custom-tutorials.ts diff --git a/redisinsight/api/migration/1677135091633-custom-tutorials.ts b/redisinsight/api/migration/1677135091633-custom-tutorials.ts new file mode 100644 index 0000000000..f71d279e87 --- /dev/null +++ b/redisinsight/api/migration/1677135091633-custom-tutorials.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class customTutorials1677135091633 implements MigrationInterface { + name = 'customTutorials1677135091633' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "custom_tutorials" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "link" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "custom_tutorials"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 4d6a690a77..d0c49e712a 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -27,6 +27,7 @@ import { workbenchAndAnalysisDbIndex1673934231410 } from './1673934231410-workbe import { browserHistory1674539211397 } from './1674539211397-browser-history'; import { databaseAnalysisRecommendations1674660306971 } from './1674660306971-database-analysis-recommendations'; import { databaseTimeout1675398140189 } from './1675398140189-database-timeout'; +import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; export default [ initialMigration1614164490968, @@ -58,4 +59,5 @@ export default [ databaseAnalysisRecommendations1674660306971, browserHistory1674539211397, databaseTimeout1675398140189, + customTutorials1677135091633, ]; diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts index cf8df33553..3631333f1d 100644 --- a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -1,7 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; import { IsNotEmpty, IsString } from 'class-validator'; -import { HasMimeType, IsFile, MemoryStoredFile } from 'nestjs-form-data'; +import { + HasMimeType, IsFile, MaxFileSize, MemoryStoredFile, +} from 'nestjs-form-data'; export class UploadCustomTutorialDto { @ApiProperty({ @@ -11,6 +13,7 @@ export class UploadCustomTutorialDto { }) @IsFile() @HasMimeType(['application/zip']) + @MaxFileSize(10 * 1024 * 1024) file: MemoryStoredFile; @ApiProperty({ From e514b2ef339d0d8b07eeb75c457f76839330fd4d Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 23 Feb 2023 16:12:32 +0200 Subject: [PATCH 13/13] #RI-4186 fix custom tutorials dir for different envs --- redisinsight/api/config/production.ts | 1 + redisinsight/api/config/staging.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts index 01cb28b7dd..a9c87effdd 100644 --- a/redisinsight/api/config/production.ts +++ b/redisinsight/api/config/production.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'), diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts index d23664a670..e28c0435ae 100644 --- a/redisinsight/api/config/staging.ts +++ b/redisinsight/api/config/staging.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'),