Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
6d33f3c
#RI-4403 upload data from tutorial
Apr 17, 2023
cc151b7
Merge branch 'feature/RI-4352-bulk_upload_from_tutorials' into be/fea…
Apr 18, 2023
6bb75fb
#RI-4403 add support for url base and static folders
Apr 19, 2023
c59b992
#RI-4404 core path generators + tests + initial ui implementation
Apr 19, 2023
e69909b
#RI-4404 fix tests
Apr 19, 2023
b827013
fix tests
Apr 19, 2023
cb4984d
Merge branch 'feature/RI-4352-bulk_upload_from_tutorials' into be/fea…
Apr 19, 2023
7f4225b
add file size validation
Apr 19, 2023
85ffa73
#RI-4404 - finish upload data in bulk for tutorials
rsergeenko Apr 26, 2023
89670ec
#RI-4404 - fix test
rsergeenko Apr 26, 2023
ca94f3b
Merge pull request #1963 from RedisInsight/be/feature/RI-4403-bulk_up…
Apr 26, 2023
fa66406
Merge pull request #1974 from RedisInsight/fe/feature/RI-4404-bulk_up…
rsergeenko Apr 27, 2023
8d91596
Merge branch 'main' into feature/RI-4352-bulk_upload_from_tutorials
Apr 27, 2023
78d13f9
#RI-4465 - fix multiple tooltips
rsergeenko May 1, 2023
e9bde00
Merge pull request #2044 from RedisInsight/fe/bugfix/RI-4465_RI-4464
rsergeenko May 1, 2023
5c9c697
#RI-4466 fix file was not found on win
May 2, 2023
8ac5cab
Merge branch 'main' into feature/RI-4352-bulk_upload_from_tutorials
vlad-dargel May 3, 2023
8f25c44
Merge branch 'feature/RI-4352-bulk_upload_from_tutorials' into e2e/fe…
vlad-dargel May 3, 2023
beb2bf3
add tests for custom tutorial bulk upload
vlad-dargel May 3, 2023
f632cc8
upd
vlad-dargel May 3, 2023
31fe38f
Merge pull request #2050 from RedisInsight/bugfix/feature/RI-4352-upl…
vlad-dargel May 3, 2023
192e831
Merge branch 'feature/RI-4352-bulk_upload_from_tutorials' into e2e/fe…
vlad-dargel May 3, 2023
3d69288
delete console.log
vlad-dargel May 3, 2023
127778b
fix for archiver
vlad-dargel May 3, 2023
9f31276
add archiver package
vlad-dargel May 3, 2023
00a3edc
fixes by pr comments
vlad-dargel May 3, 2023
37bcc17
test of CI
vlad-dargel May 3, 2023
f2f5224
test of CI 2
vlad-dargel May 3, 2023
fa89a8d
update because of found bug
vlad-dargel May 3, 2023
b453b46
return parallelizm
vlad-dargel May 3, 2023
80a55ed
fix
vlad-dargel May 3, 2023
6bed852
fixes
vlad-dargel May 3, 2023
160e6af
additionally normalize path (quick fix)
May 4, 2023
e1e110b
Merge pull request #2059 from RedisInsight/bugfix/feature/RI-4352-upl…
vlad-dargel May 4, 2023
2c948a9
update
vlad-dargel May 4, 2023
85a93d9
fix for CI
vlad-dargel May 4, 2023
2eb37c5
upd for CI
vlad-dargel May 4, 2023
e9e7dbb
delete attempt limit
vlad-dargel May 4, 2023
8944710
fix
vlad-dargel May 4, 2023
2d76fd0
check
vlad-dargel May 4, 2023
2730459
Merge branch 'main' into feature/RI-4352-bulk_upload_from_tutorials
vlad-dargel May 4, 2023
5ad2dc9
Merge branch 'feature/RI-4352-bulk_upload_from_tutorials' into e2e/fe…
vlad-dargel May 4, 2023
30769e3
return back
vlad-dargel May 4, 2023
c71f203
Merge pull request #2057 from RedisInsight/e2e/feature/RI-4352_bulk-u…
vlad-dargel May 4, 2023
7cfee7d
#RI-4494 - fix url parsing for docker builds
rsergeenko May 5, 2023
0f1c683
Merge pull request #2064 from RedisInsight/fe/bugfix/RI-4494
rsergeenko May 5, 2023
19a4086
update for time taken value
vlad-dargel May 5, 2023
8c3bad8
Merge branch 'main' into feature/RI-4352-bulk_upload_from_tutorials
vlad-dargel May 5, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions redisinsight/api/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default {
contentUri: '/static/content',
defaultPluginsUri: '/static/plugins',
pluginsAssetsUri: '/static/resources/plugins',
base: process.env.RI_BASE || '/',
secretStoragePassword: process.env.SECRET_STORAGE_PASSWORD,
tls: process.env.SERVER_TLS ? process.env.SERVER_TLS === 'true' : true,
tlsCert: process.env.SERVER_TLS_CERT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { UploadImportFileDto } from 'src/modules/bulk-actions/dto/upload-import-
import { ClientMetadataParam } from 'src/common/decorators';
import { ClientMetadata } from 'src/common/models';
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';

@UsePipes(new ValidationPipe({ transform: true }))
@UseInterceptors(ClassSerializerInterceptor)
Expand All @@ -40,4 +41,21 @@ export class BulkImportController {
): Promise<IBulkActionOverview> {
return this.service.import(clientMetadata, dto);
}

@Post('import/tutorial-data')
@HttpCode(200)
@ApiEndpoint({
description: 'Import data from tutorial by path',
responses: [
{
type: Object,
},
],
})
async uploadFromTutorial(
@Body() dto: UploadImportFileByPathDto,
@ClientMetadataParam() clientMetadata: ClientMetadata,
): Promise<IBulkActionOverview> {
return this.service.uploadFromTutorial(clientMetadata, dto);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import { MemoryStoredFile } from 'nestjs-form-data';
import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-summary';
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants';
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service';
import * as fs from 'fs-extra';
import config from 'src/utils/config';
import { join } from 'path';

const PATH_CONFIG = config.get('dir_path');

const generateNCommandsBuffer = (n: number) => Buffer.from(
(new Array(n)).fill(1).map(() => ['set', ['foo', 'bar']]).join('\n'),
Expand Down Expand Up @@ -55,12 +60,20 @@ const mockUploadImportFileDto = {
} as unknown as MemoryStoredFile,
};

const mockUploadImportFileByPathDto = {
path: '/some/path',
};

jest.mock('fs-extra');
const mockedFs = fs as jest.Mocked<typeof fs>;

describe('BulkImportService', () => {
let service: BulkImportService;
let databaseConnectionService: MockType<DatabaseConnectionService>;
let analytics: MockType<BulkActionsAnalyticsService>;

beforeEach(async () => {
jest.mock('fs-extra', () => mockedFs);
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -211,4 +224,83 @@ describe('BulkImportService', () => {
}
});
});

describe('uploadFromTutorial', () => {
let spy;

beforeEach(() => {
spy = jest.spyOn(service as any, 'import');
spy.mockResolvedValue(mockSummary);
mockedFs.readFile.mockResolvedValue(Buffer.from('set foo bar'));
});

it('should import file by path', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => true);

await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto);

expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, mockUploadImportFileByPathDto.path));
});

it('should import file by path with static', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => true);

await service.uploadFromTutorial(mockClientMetadata, { path: '/static/guides/_data.file' });

expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, '/guides/_data.file'));
});

it('should normalize path before importing and not search for file outside home folder', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => true);

await service.uploadFromTutorial(mockClientMetadata, {
path: '/../../../danger',
});

expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, 'danger'));
});

it('should normalize path before importing and throw an error when search for file outside home folder (relative)', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => true);

try {
await service.uploadFromTutorial(mockClientMetadata, {
path: '../../../danger',
});

fail();
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toEqual('Data file was not found');
}
});

it('should throw BadRequest when no file found', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => false);

try {
await service.uploadFromTutorial(mockClientMetadata, {
path: '../../../danger',
});
fail();
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toEqual('Data file was not found');
}
});

it('should throw BadRequest when file size is greater then 100MB', async () => {
mockedFs.pathExists.mockImplementationOnce(async () => true);
mockedFs.stat.mockImplementationOnce(async () => ({ size: 100 * 1024 * 1024 + 1 } as fs.Stats));

try {
await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto);

fail();
} catch (e) {
expect(e).toBeInstanceOf(BadRequestException);
expect(e.message).toEqual('Maximum file size is 100MB');
}
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { join, resolve } from 'path';
import * as fs from 'fs-extra';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Readable } from 'stream';
import * as readline from 'readline';
import { wrapHttpError } from 'src/common/utils';
Expand All @@ -10,8 +12,13 @@ import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-s
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants';
import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service';
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
import config from 'src/utils/config';
import { MemoryStoredFile } from 'nestjs-form-data';

const BATCH_LIMIT = 10_000;
const PATH_CONFIG = config.get('dir_path');
const SERVER_CONFIG = config.get('server');

@Injectable()
export class BulkImportService {
Expand Down Expand Up @@ -137,4 +144,44 @@ export class BulkImportService {
throw wrapHttpError(e);
}
}

/**
* Upload file from tutorial by path
* @param clientMetadata
* @param dto
*/
public async uploadFromTutorial(
clientMetadata: ClientMetadata,
dto: UploadImportFileByPathDto,
): Promise<IBulkActionOverview> {
try {
const filePath = join(dto.path);

const staticPath = join(SERVER_CONFIG.base, SERVER_CONFIG.staticUri);

let trimmedPath = filePath;
if (filePath.indexOf(staticPath) === 0) {
trimmedPath = filePath.slice(staticPath.length);
}

const path = join(PATH_CONFIG.homedir, trimmedPath);

if (!path.startsWith(PATH_CONFIG.homedir) || !await fs.pathExists(path)) {
throw new BadRequestException('Data file was not found');
}

if ((await fs.stat(path))?.size > 100 * 1024 * 1024) {
throw new BadRequestException('Maximum file size is 100MB');
}

const buffer = await fs.readFile(path);

return this.import(clientMetadata, {
file: { buffer } as MemoryStoredFile,
});
} catch (e) {
this.logger.error('Unable to process an import file path from tutorial', e);
throw wrapHttpError(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class UploadImportFileByPathDto {
@ApiProperty({
type: 'string',
description: 'Internal path to data file',
})
@IsString()
@IsNotEmpty()
path: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
expect,
describe,
it,
deps,
requirements,
validateApiCall,
} from '../deps';
import { AdmZip, path } from '../../helpers/test';
const { rte, request, server, constants } = deps;

const endpoint = (
id = constants.TEST_INSTANCE_ID,
) => request(server).post(`/${constants.API.DATABASES}/${id}/bulk-actions/import/tutorial-data`);

const creatCustomTutorialsEndpoint = () => request(server).post(`/custom-tutorials`);

const getZipArchive = () => {
const zipArchive = new AdmZip();

zipArchive.addFile('info.md', Buffer.from('# info.md', 'utf8'));
zipArchive.addFile('_data/data.txt', Buffer.from(
`set ${constants.TEST_STRING_KEY_1} bulkimport`,
'utf8',
));

return zipArchive;
}

describe('POST /databases/:id/bulk-actions/import/tutorial-data', () => {
requirements('!rte.sharedData', '!rte.bigData', 'rte.serverType=local')

beforeEach(async () => await rte.data.truncate());

describe('Common', function () {
let tutorialId;
it('should import data', async () => {
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport');

// create tutorial
const zip = getZipArchive();
await validateApiCall({
endpoint: creatCustomTutorialsEndpoint,
attach: ['file', zip.toBuffer(), 'a.zip'],
statusCode: 201,
checkFn: ({ body }) => {
tutorialId = body.id;
},
});

await validateApiCall({
endpoint,
data: {
path: path.join('/custom-tutorials', tutorialId, '_data/data.txt'),
},
responseBody: {
id: 'empty',
databaseId: constants.TEST_INSTANCE_ID,
type: 'import',
summary: { processed: 1, succeed: 1, failed: 0, errors: [] },
progress: null,
filter: null,
status: 'completed',
},
checkFn: async ({ body }) => {
expect(body.duration).to.gt(0);

expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport');
},
});
});
it('should import data with static path', async () => {
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport');

// create tutorial
const zip = getZipArchive();
await validateApiCall({
endpoint: creatCustomTutorialsEndpoint,
attach: ['file', zip.toBuffer(), 'a.zip'],
statusCode: 201,
checkFn: ({ body }) => {
tutorialId = body.id;
},
});

await validateApiCall({
endpoint,
data: {
path: path.join('/static/custom-tutorials', tutorialId, '_data/data.txt'),
},
responseBody: {
id: 'empty',
databaseId: constants.TEST_INSTANCE_ID,
type: 'import',
summary: { processed: 1, succeed: 1, failed: 0, errors: [] },
progress: null,
filter: null,
status: 'completed',
},
checkFn: async ({ body }) => {
expect(body.duration).to.gt(0);

expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport');
},
});
});
it('should return BadRequest when path does not exists', async () => {
await validateApiCall({
endpoint,
data: {
path: path.join('/custom-tutorials', tutorialId, '../../../../../_data/data.txt'),
},
statusCode: 400,
responseBody: {
statusCode: 400,
message: 'Data file was not found',
error: 'Bad Request',
},
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
_,
} from '../deps';
import { getBaseURL } from '../../helpers/server';
const { server, request } = deps;
const { server, request, localDb } = deps;

// create endpoint
const creatEndpoint = () => request(server).post(`/custom-tutorials`);
Expand Down Expand Up @@ -110,11 +110,12 @@ const globalManifest = {
describe('POST /custom-tutorials', () => {
requirements('rte.serverType=local');

describe('Common', () => {
before(async () => {
await fsExtra.remove(customTutorialsFolder);
});
before(async () => {
await fsExtra.remove(customTutorialsFolder);
await (await localDb.getRepository(localDb.repositories.CUSTOM_TUTORIAL)).clear();
});

describe('Common', () => {
it('should import tutorial from file and generate _manifest.json', async () => {
const zip = getZipArchive();
zip.writeZip(path.join(staticsFolder, 'test_no_manifest.zip'));
Expand Down
3 changes: 3 additions & 0 deletions redisinsight/ui/src/assets/img/icons/data-upload-bulk.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading