Skip to content

Commit 8aebade

Browse files
authored
Merge pull request #2019 from RedisInsight/feature/RI-4352-bulk_upload_from_tutorials
Feature/ri 4352 bulk upload from tutorials
2 parents d72afc9 + 8c3bad8 commit 8aebade

File tree

72 files changed

+1619
-172
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+1619
-172
lines changed

redisinsight/api/config/default.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default {
5151
contentUri: '/static/content',
5252
defaultPluginsUri: '/static/plugins',
5353
pluginsAssetsUri: '/static/resources/plugins',
54+
base: process.env.RI_BASE || '/',
5455
secretStoragePassword: process.env.SECRET_STORAGE_PASSWORD,
5556
tls: process.env.SERVER_TLS ? process.env.SERVER_TLS === 'true' : true,
5657
tlsCert: process.env.SERVER_TLS_CERT,

redisinsight/api/src/modules/bulk-actions/bulk-import.controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { UploadImportFileDto } from 'src/modules/bulk-actions/dto/upload-import-
1414
import { ClientMetadataParam } from 'src/common/decorators';
1515
import { ClientMetadata } from 'src/common/models';
1616
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
17+
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
1718

1819
@UsePipes(new ValidationPipe({ transform: true }))
1920
@UseInterceptors(ClassSerializerInterceptor)
@@ -40,4 +41,21 @@ export class BulkImportController {
4041
): Promise<IBulkActionOverview> {
4142
return this.service.import(clientMetadata, dto);
4243
}
44+
45+
@Post('import/tutorial-data')
46+
@HttpCode(200)
47+
@ApiEndpoint({
48+
description: 'Import data from tutorial by path',
49+
responses: [
50+
{
51+
type: Object,
52+
},
53+
],
54+
})
55+
async uploadFromTutorial(
56+
@Body() dto: UploadImportFileByPathDto,
57+
@ClientMetadataParam() clientMetadata: ClientMetadata,
58+
): Promise<IBulkActionOverview> {
59+
return this.service.uploadFromTutorial(clientMetadata, dto);
60+
}
4361
}

redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import { MemoryStoredFile } from 'nestjs-form-data';
1111
import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-summary';
1212
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
1313
import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants';
14-
import { NotFoundException } from '@nestjs/common';
14+
import { BadRequestException, NotFoundException } from '@nestjs/common';
1515
import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service';
16+
import * as fs from 'fs-extra';
17+
import config from 'src/utils/config';
18+
import { join } from 'path';
19+
20+
const PATH_CONFIG = config.get('dir_path');
1621

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

63+
const mockUploadImportFileByPathDto = {
64+
path: '/some/path',
65+
};
66+
67+
jest.mock('fs-extra');
68+
const mockedFs = fs as jest.Mocked<typeof fs>;
69+
5870
describe('BulkImportService', () => {
5971
let service: BulkImportService;
6072
let databaseConnectionService: MockType<DatabaseConnectionService>;
6173
let analytics: MockType<BulkActionsAnalyticsService>;
6274

6375
beforeEach(async () => {
76+
jest.mock('fs-extra', () => mockedFs);
6477
jest.clearAllMocks();
6578

6679
const module: TestingModule = await Test.createTestingModule({
@@ -211,4 +224,83 @@ describe('BulkImportService', () => {
211224
}
212225
});
213226
});
227+
228+
describe('uploadFromTutorial', () => {
229+
let spy;
230+
231+
beforeEach(() => {
232+
spy = jest.spyOn(service as any, 'import');
233+
spy.mockResolvedValue(mockSummary);
234+
mockedFs.readFile.mockResolvedValue(Buffer.from('set foo bar'));
235+
});
236+
237+
it('should import file by path', async () => {
238+
mockedFs.pathExists.mockImplementationOnce(async () => true);
239+
240+
await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto);
241+
242+
expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, mockUploadImportFileByPathDto.path));
243+
});
244+
245+
it('should import file by path with static', async () => {
246+
mockedFs.pathExists.mockImplementationOnce(async () => true);
247+
248+
await service.uploadFromTutorial(mockClientMetadata, { path: '/static/guides/_data.file' });
249+
250+
expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, '/guides/_data.file'));
251+
});
252+
253+
it('should normalize path before importing and not search for file outside home folder', async () => {
254+
mockedFs.pathExists.mockImplementationOnce(async () => true);
255+
256+
await service.uploadFromTutorial(mockClientMetadata, {
257+
path: '/../../../danger',
258+
});
259+
260+
expect(mockedFs.readFile).toHaveBeenCalledWith(join(PATH_CONFIG.homedir, 'danger'));
261+
});
262+
263+
it('should normalize path before importing and throw an error when search for file outside home folder (relative)', async () => {
264+
mockedFs.pathExists.mockImplementationOnce(async () => true);
265+
266+
try {
267+
await service.uploadFromTutorial(mockClientMetadata, {
268+
path: '../../../danger',
269+
});
270+
271+
fail();
272+
} catch (e) {
273+
expect(e).toBeInstanceOf(BadRequestException);
274+
expect(e.message).toEqual('Data file was not found');
275+
}
276+
});
277+
278+
it('should throw BadRequest when no file found', async () => {
279+
mockedFs.pathExists.mockImplementationOnce(async () => false);
280+
281+
try {
282+
await service.uploadFromTutorial(mockClientMetadata, {
283+
path: '../../../danger',
284+
});
285+
fail();
286+
} catch (e) {
287+
expect(e).toBeInstanceOf(BadRequestException);
288+
expect(e.message).toEqual('Data file was not found');
289+
}
290+
});
291+
292+
it('should throw BadRequest when file size is greater then 100MB', async () => {
293+
mockedFs.pathExists.mockImplementationOnce(async () => true);
294+
mockedFs.stat.mockImplementationOnce(async () => ({ size: 100 * 1024 * 1024 + 1 } as fs.Stats));
295+
296+
try {
297+
await service.uploadFromTutorial(mockClientMetadata, mockUploadImportFileByPathDto);
298+
299+
fail();
300+
} catch (e) {
301+
expect(e).toBeInstanceOf(BadRequestException);
302+
expect(e.message).toEqual('Maximum file size is 100MB');
303+
}
304+
});
305+
});
214306
});

redisinsight/api/src/modules/bulk-actions/bulk-import.service.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { join, resolve } from 'path';
2+
import * as fs from 'fs-extra';
3+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
24
import { Readable } from 'stream';
35
import * as readline from 'readline';
46
import { wrapHttpError } from 'src/common/utils';
@@ -10,8 +12,13 @@ import { BulkActionSummary } from 'src/modules/bulk-actions/models/bulk-action-s
1012
import { IBulkActionOverview } from 'src/modules/bulk-actions/interfaces/bulk-action-overview.interface';
1113
import { BulkActionStatus, BulkActionType } from 'src/modules/bulk-actions/constants';
1214
import { BulkActionsAnalyticsService } from 'src/modules/bulk-actions/bulk-actions-analytics.service';
15+
import { UploadImportFileByPathDto } from 'src/modules/bulk-actions/dto/upload-import-file-by-path.dto';
16+
import config from 'src/utils/config';
17+
import { MemoryStoredFile } from 'nestjs-form-data';
1318

1419
const BATCH_LIMIT = 10_000;
20+
const PATH_CONFIG = config.get('dir_path');
21+
const SERVER_CONFIG = config.get('server');
1522

1623
@Injectable()
1724
export class BulkImportService {
@@ -137,4 +144,44 @@ export class BulkImportService {
137144
throw wrapHttpError(e);
138145
}
139146
}
147+
148+
/**
149+
* Upload file from tutorial by path
150+
* @param clientMetadata
151+
* @param dto
152+
*/
153+
public async uploadFromTutorial(
154+
clientMetadata: ClientMetadata,
155+
dto: UploadImportFileByPathDto,
156+
): Promise<IBulkActionOverview> {
157+
try {
158+
const filePath = join(dto.path);
159+
160+
const staticPath = join(SERVER_CONFIG.base, SERVER_CONFIG.staticUri);
161+
162+
let trimmedPath = filePath;
163+
if (filePath.indexOf(staticPath) === 0) {
164+
trimmedPath = filePath.slice(staticPath.length);
165+
}
166+
167+
const path = join(PATH_CONFIG.homedir, trimmedPath);
168+
169+
if (!path.startsWith(PATH_CONFIG.homedir) || !await fs.pathExists(path)) {
170+
throw new BadRequestException('Data file was not found');
171+
}
172+
173+
if ((await fs.stat(path))?.size > 100 * 1024 * 1024) {
174+
throw new BadRequestException('Maximum file size is 100MB');
175+
}
176+
177+
const buffer = await fs.readFile(path);
178+
179+
return this.import(clientMetadata, {
180+
file: { buffer } as MemoryStoredFile,
181+
});
182+
} catch (e) {
183+
this.logger.error('Unable to process an import file path from tutorial', e);
184+
throw wrapHttpError(e);
185+
}
186+
}
140187
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
4+
export class UploadImportFileByPathDto {
5+
@ApiProperty({
6+
type: 'string',
7+
description: 'Internal path to data file',
8+
})
9+
@IsString()
10+
@IsNotEmpty()
11+
path: string;
12+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
expect,
3+
describe,
4+
it,
5+
deps,
6+
requirements,
7+
validateApiCall,
8+
} from '../deps';
9+
import { AdmZip, path } from '../../helpers/test';
10+
const { rte, request, server, constants } = deps;
11+
12+
const endpoint = (
13+
id = constants.TEST_INSTANCE_ID,
14+
) => request(server).post(`/${constants.API.DATABASES}/${id}/bulk-actions/import/tutorial-data`);
15+
16+
const creatCustomTutorialsEndpoint = () => request(server).post(`/custom-tutorials`);
17+
18+
const getZipArchive = () => {
19+
const zipArchive = new AdmZip();
20+
21+
zipArchive.addFile('info.md', Buffer.from('# info.md', 'utf8'));
22+
zipArchive.addFile('_data/data.txt', Buffer.from(
23+
`set ${constants.TEST_STRING_KEY_1} bulkimport`,
24+
'utf8',
25+
));
26+
27+
return zipArchive;
28+
}
29+
30+
describe('POST /databases/:id/bulk-actions/import/tutorial-data', () => {
31+
requirements('!rte.sharedData', '!rte.bigData', 'rte.serverType=local')
32+
33+
beforeEach(async () => await rte.data.truncate());
34+
35+
describe('Common', function () {
36+
let tutorialId;
37+
it('should import data', async () => {
38+
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport');
39+
40+
// create tutorial
41+
const zip = getZipArchive();
42+
await validateApiCall({
43+
endpoint: creatCustomTutorialsEndpoint,
44+
attach: ['file', zip.toBuffer(), 'a.zip'],
45+
statusCode: 201,
46+
checkFn: ({ body }) => {
47+
tutorialId = body.id;
48+
},
49+
});
50+
51+
await validateApiCall({
52+
endpoint,
53+
data: {
54+
path: path.join('/custom-tutorials', tutorialId, '_data/data.txt'),
55+
},
56+
responseBody: {
57+
id: 'empty',
58+
databaseId: constants.TEST_INSTANCE_ID,
59+
type: 'import',
60+
summary: { processed: 1, succeed: 1, failed: 0, errors: [] },
61+
progress: null,
62+
filter: null,
63+
status: 'completed',
64+
},
65+
checkFn: async ({ body }) => {
66+
expect(body.duration).to.gt(0);
67+
68+
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport');
69+
},
70+
});
71+
});
72+
it('should import data with static path', async () => {
73+
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.not.eq('bulkimport');
74+
75+
// create tutorial
76+
const zip = getZipArchive();
77+
await validateApiCall({
78+
endpoint: creatCustomTutorialsEndpoint,
79+
attach: ['file', zip.toBuffer(), 'a.zip'],
80+
statusCode: 201,
81+
checkFn: ({ body }) => {
82+
tutorialId = body.id;
83+
},
84+
});
85+
86+
await validateApiCall({
87+
endpoint,
88+
data: {
89+
path: path.join('/static/custom-tutorials', tutorialId, '_data/data.txt'),
90+
},
91+
responseBody: {
92+
id: 'empty',
93+
databaseId: constants.TEST_INSTANCE_ID,
94+
type: 'import',
95+
summary: { processed: 1, succeed: 1, failed: 0, errors: [] },
96+
progress: null,
97+
filter: null,
98+
status: 'completed',
99+
},
100+
checkFn: async ({ body }) => {
101+
expect(body.duration).to.gt(0);
102+
103+
expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eq('bulkimport');
104+
},
105+
});
106+
});
107+
it('should return BadRequest when path does not exists', async () => {
108+
await validateApiCall({
109+
endpoint,
110+
data: {
111+
path: path.join('/custom-tutorials', tutorialId, '../../../../../_data/data.txt'),
112+
},
113+
statusCode: 400,
114+
responseBody: {
115+
statusCode: 400,
116+
message: 'Data file was not found',
117+
error: 'Bad Request',
118+
},
119+
});
120+
});
121+
});
122+
});
File renamed without changes.

redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
_,
1313
} from '../deps';
1414
import { getBaseURL } from '../../helpers/server';
15-
const { server, request } = deps;
15+
const { server, request, localDb } = deps;
1616

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

113-
describe('Common', () => {
114-
before(async () => {
115-
await fsExtra.remove(customTutorialsFolder);
116-
});
113+
before(async () => {
114+
await fsExtra.remove(customTutorialsFolder);
115+
await (await localDb.getRepository(localDb.repositories.CUSTOM_TUTORIAL)).clear();
116+
});
117117

118+
describe('Common', () => {
118119
it('should import tutorial from file and generate _manifest.json', async () => {
119120
const zip = getZipArchive();
120121
zip.writeZip(path.join(staticsFolder, 'test_no_manifest.zip'));
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)