Skip to content

Commit e83c2d6

Browse files
authored
Merge pull request #2697 from RedisInsight/feature/RI-4815-portion-of-string
#RI-4815 - get a portion of the string
2 parents 3df175e + 81b5eb5 commit e83c2d6

File tree

36 files changed

+1052
-158
lines changed

36 files changed

+1052
-158
lines changed

redisinsight/api/src/common/decorators/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './session';
66
export * from './client-metadata';
77
export * from './object-as-map.decorator';
88
export * from './is-multi-number.decorator';
9+
export * from './is-bigger-than.decorator';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
registerDecorator,
3+
ValidationOptions,
4+
} from 'class-validator';
5+
import { BiggerThan } from 'src/common/validators/bigger-than.validator';
6+
7+
export function IsBiggerThan(property: string, validationOptions?: ValidationOptions) {
8+
return (object: any, propertyName: string) => {
9+
registerDecorator({
10+
name: 'IsBiggerThan',
11+
target: object.constructor,
12+
propertyName,
13+
constraints: [property],
14+
options: validationOptions,
15+
validator: BiggerThan,
16+
});
17+
};
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
ValidationArguments,
3+
ValidatorConstraint,
4+
ValidatorConstraintInterface,
5+
} from 'class-validator';
6+
7+
@ValidatorConstraint({ name: 'BiggerThan', async: true })
8+
export class BiggerThan implements ValidatorConstraintInterface {
9+
validate(value: any, args: ValidationArguments) {
10+
const [relatedPropertyName] = args.constraints;
11+
const relatedValue = (args.object as any)[relatedPropertyName];
12+
return typeof value === 'number' && typeof relatedValue === 'number' && value > relatedValue;
13+
}
14+
15+
defaultMessage(args: ValidationArguments) {
16+
return `${args.property} must be bigger than ${args.constraints.join(', ')}`;
17+
}
18+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './redis-string.validator';
22
export * from './zset-score.validator';
33
export * from './multi-number.validator';
4+
export * from './bigger-than.validator';

redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export enum BrowserToolKeysCommands {
1414
export enum BrowserToolStringCommands {
1515
Set = 'set',
1616
Get = 'get',
17+
Getrange = 'getrange',
1718
StrLen = 'strlen',
1819
}
1920

redisinsight/api/src/modules/browser/controllers/string/string.controller.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
Controller,
44
HttpCode,
55
Post,
6-
Put,
6+
Put, Res,
77
} from '@nestjs/common';
88
import {
99
ApiBody, ApiOkResponse, ApiOperation, ApiTags,
@@ -12,13 +12,15 @@ import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator';
1212
import {
1313
SetStringDto,
1414
GetStringValueResponse,
15-
SetStringWithExpireDto,
15+
SetStringWithExpireDto, GetStringInfoDto,
1616
} from 'src/modules/browser/dto/string.dto';
1717
import { GetKeyInfoDto } from 'src/modules/browser/dto';
1818
import { BaseController } from 'src/modules/browser/controllers/base.controller';
1919
import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator';
2020
import { ApiQueryRedisStringEncoding } from 'src/common/decorators';
2121
import { ClientMetadata } from 'src/common/models';
22+
import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator';
23+
import { Response } from 'express';
2224
import { StringBusinessService } from '../../services/string-business/string-business.service';
2325

2426
@ApiTags('String')
@@ -45,19 +47,43 @@ export class StringController extends BaseController {
4547
@HttpCode(200)
4648
@ApiOperation({ description: 'Get string value' })
4749
@ApiRedisParams()
48-
@ApiBody({ type: GetKeyInfoDto })
50+
@ApiBody({ type: GetStringInfoDto })
4951
@ApiOkResponse({
5052
description: 'String value',
5153
type: GetStringValueResponse,
5254
})
5355
@ApiQueryRedisStringEncoding()
5456
async getStringValue(
5557
@BrowserClientMetadata() clientMetadata: ClientMetadata,
56-
@Body() dto: GetKeyInfoDto,
58+
@Body() dto: GetStringInfoDto,
5759
): Promise<GetStringValueResponse> {
5860
return this.stringBusinessService.getStringValue(clientMetadata, dto);
5961
}
6062

63+
@ApiEndpoint({
64+
description: 'Endpoint do download string value',
65+
statusCode: 200,
66+
})
67+
@Post('/download-value')
68+
@ApiRedisParams()
69+
@ApiBody({ type: GetKeyInfoDto })
70+
@ApiQueryRedisStringEncoding()
71+
async downloadStringFile(
72+
@Res() res: Response,
73+
@BrowserClientMetadata() clientMetadata: ClientMetadata,
74+
@Body() dto: GetKeyInfoDto,
75+
): Promise<void> {
76+
const { stream } = await this.stringBusinessService.downloadStringValue(clientMetadata, dto);
77+
78+
res.setHeader('Content-Type', 'application/octet-stream');
79+
res.setHeader('Content-Disposition', 'attachment;filename="string_value"');
80+
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
81+
82+
stream
83+
.on('error', () => res.status(404).send())
84+
.pipe(res);
85+
}
86+
6187
@Put('')
6288
@ApiOperation({ description: 'Update string value' })
6389
@ApiRedisParams()

redisinsight/api/src/modules/browser/dto/string.dto.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
import {
22
ApiProperty, IntersectionType,
33
} from '@nestjs/swagger';
4-
import { IsDefined } from 'class-validator';
4+
import {
5+
IsDefined, IsInt, IsOptional, Min,
6+
} from 'class-validator';
57
import { RedisString } from 'src/common/constants';
6-
import { IsRedisString, RedisStringType } from 'src/common/decorators';
8+
import { IsRedisString, RedisStringType, IsBiggerThan } from 'src/common/decorators';
9+
import { Type } from 'class-transformer';
710
import { KeyDto, KeyResponse, KeyWithExpireDto } from './keys.dto';
811

12+
export class GetStringInfoDto extends KeyDto {
13+
@ApiProperty({
14+
description: 'Start of string',
15+
type: Number,
16+
default: 0,
17+
})
18+
@IsOptional()
19+
@IsInt({ always: true })
20+
@Type(() => Number)
21+
@Min(0)
22+
start?: number = 0;
23+
24+
@ApiProperty({
25+
description: 'End of string',
26+
type: Number,
27+
})
28+
@IsOptional()
29+
@IsInt({ always: true })
30+
@Type(() => Number)
31+
@Min(1)
32+
@IsBiggerThan('start')
33+
end?: number;
34+
}
35+
936
export class SetStringDto extends KeyDto {
1037
@ApiProperty({
1138
description: 'Key value',

redisinsight/api/src/modules/browser/services/string-business/string-business.service.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RECOMMENDATION_NAMES, RedisErrorCodes } from 'src/constants';
99
import ERROR_MESSAGES from 'src/constants/error-messages';
1010
import { catchAclError } from 'src/utils';
1111
import {
12+
GetStringInfoDto,
1213
GetStringValueResponse,
1314
SetStringDto,
1415
SetStringWithExpireDto,
@@ -22,6 +23,8 @@ import { plainToClass } from 'class-transformer';
2223
import { GetKeyInfoDto } from 'src/modules/browser/dto';
2324
import { ClientMetadata } from 'src/common/models';
2425
import { DatabaseRecommendationService } from 'src/modules/database-recommendation/database-recommendation.service';
26+
import { Readable } from 'stream';
27+
import { RedisString } from 'src/common/constants';
2528

2629
@Injectable()
2730
export class StringBusinessService {
@@ -68,19 +71,29 @@ export class StringBusinessService {
6871

6972
public async getStringValue(
7073
clientMetadata: ClientMetadata,
71-
dto: GetKeyInfoDto,
74+
dto: GetStringInfoDto,
7275
): Promise<GetStringValueResponse> {
7376
this.logger.log('Getting string value.');
7477

75-
const { keyName } = dto;
78+
const { keyName, start, end } = dto;
7679
let result: GetStringValueResponse;
7780

7881
try {
79-
const value = await this.browserTool.execCommand(
80-
clientMetadata,
81-
BrowserToolStringCommands.Get,
82-
[keyName],
83-
);
82+
let value;
83+
if (end) {
84+
value = await this.browserTool.execCommand(
85+
clientMetadata,
86+
BrowserToolStringCommands.Getrange,
87+
[keyName, `${start}`, `${end}`],
88+
);
89+
} else {
90+
value = await this.browserTool.execCommand(
91+
clientMetadata,
92+
BrowserToolStringCommands.Get,
93+
[keyName],
94+
);
95+
}
96+
8497
result = { value, keyName };
8598
} catch (error) {
8699
this.logger.error('Failed to get string value.', error);
@@ -89,20 +102,35 @@ export class StringBusinessService {
89102
}
90103
catchAclError(error);
91104
}
105+
92106
if (result.value === null) {
93107
this.logger.error(
94108
`Failed to get string value. Not Found key: ${keyName}.`,
95109
);
96110
throw new NotFoundException();
97-
} else {
98-
this.recommendationService.check(
99-
clientMetadata,
100-
RECOMMENDATION_NAMES.STRING_TO_JSON,
101-
{ value: result.value, keyName: result.keyName },
102-
);
103-
this.logger.log('Succeed to get string value.');
104-
return plainToClass(GetStringValueResponse, result);
105111
}
112+
113+
this.recommendationService.check(
114+
clientMetadata,
115+
RECOMMENDATION_NAMES.STRING_TO_JSON,
116+
{ value: result.value, keyName: result.keyName },
117+
);
118+
this.logger.log('Succeed to get string value.');
119+
120+
return plainToClass(GetStringValueResponse, result);
121+
}
122+
123+
public async downloadStringValue(
124+
clientMetadata: ClientMetadata,
125+
dto: GetKeyInfoDto,
126+
): Promise<{ stream: Readable }> {
127+
const result = await this.getStringValue(
128+
clientMetadata,
129+
dto,
130+
);
131+
132+
const stream = Readable.from(result.value);
133+
return { stream };
106134
}
107135

108136
public async updateStringValue(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
describe,
3+
before,
4+
Joi,
5+
deps,
6+
requirements,
7+
generateInvalidDataTestCases,
8+
validateInvalidDataTestCase,
9+
getMainCheckFn
10+
} from '../deps'
11+
const { server, request, constants, rte } = deps;
12+
13+
// endpoint to test
14+
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
15+
request(server).post(`/${constants.API.DATABASES}/${instanceId}/string/download-value`);
16+
17+
// input data schema
18+
const dataSchema = Joi.object({
19+
keyName: Joi.string().allow('').required(),
20+
}).strict();
21+
22+
const validInputData = {
23+
keyName: constants.TEST_STRING_KEY_1,
24+
};
25+
26+
const mainCheckFn = getMainCheckFn(endpoint);
27+
28+
describe('POST /databases/:instanceId/string/download-value', () => {
29+
describe('Main', () => {
30+
before(() => rte.data.generateBinKeys(true));
31+
32+
describe('Validation', () => {
33+
generateInvalidDataTestCases(dataSchema, validInputData).map(
34+
validateInvalidDataTestCase(endpoint, dataSchema),
35+
);
36+
});
37+
38+
describe('Common', () => {
39+
[
40+
{
41+
name: 'Should download value',
42+
data: {
43+
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
44+
},
45+
responseHeaders: {
46+
'content-type': 'application/octet-stream',
47+
'content-disposition': 'attachment;filename="string_value"',
48+
'access-control-expose-headers': 'Content-Disposition',
49+
},
50+
responseBody: constants.TEST_STRING_VALUE_BIN_BUFFER_1,
51+
},
52+
{
53+
name: 'Should return an error when incorrect type',
54+
data: {
55+
keyName: constants.TEST_LIST_KEY_BIN_BUF_OBJ_1,
56+
},
57+
statusCode: 400,
58+
responseBody: {
59+
statusCode: 400,
60+
error: 'Bad Request',
61+
},
62+
},
63+
{
64+
name: 'Should return NotFound error if instance id does not exists',
65+
endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID),
66+
data: {
67+
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
68+
},
69+
statusCode: 404,
70+
responseBody: {
71+
statusCode: 404,
72+
error: 'Not Found',
73+
message: 'Invalid database instance id.',
74+
},
75+
},
76+
].map(mainCheckFn);
77+
});
78+
79+
describe('ACL', () => {
80+
requirements('rte.acl');
81+
before(async () => rte.data.setAclUserRules('~* +@all'));
82+
83+
[
84+
{
85+
name: 'Should download value',
86+
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
87+
data: {
88+
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
89+
},
90+
responseHeaders: {
91+
'content-type': 'application/octet-stream',
92+
'content-disposition': 'attachment;filename="string_value"',
93+
'access-control-expose-headers': 'Content-Disposition',
94+
},
95+
responseBody: constants.TEST_STRING_VALUE_BIN_BUFFER_1,
96+
},
97+
{
98+
name: 'Should throw error if no permissions for "set" command',
99+
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
100+
data: {
101+
keyName: constants.getRandomString(),
102+
value: constants.getRandomString(),
103+
},
104+
statusCode: 403,
105+
responseBody: {
106+
statusCode: 403,
107+
error: 'Forbidden',
108+
},
109+
before: () => rte.data.setAclUserRules('~* +@all -get')
110+
},
111+
].map(mainCheckFn);
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)