Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/src/common/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './session';
export * from './client-metadata';
export * from './object-as-map.decorator';
export * from './is-multi-number.decorator';
export * from './is-bigger-than.decorator';
18 changes: 18 additions & 0 deletions redisinsight/api/src/common/decorators/is-bigger-than.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
registerDecorator,
ValidationOptions,
} from 'class-validator';
import { BiggerThan } from 'src/common/validators/bigger-than.validator';

export function IsBiggerThan(property: string, validationOptions?: ValidationOptions) {
return (object: any, propertyName: string) => {
registerDecorator({
name: 'IsBiggerThan',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: BiggerThan,
});
};
}
18 changes: 18 additions & 0 deletions redisinsight/api/src/common/validators/bigger-than.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';

@ValidatorConstraint({ name: 'BiggerThan', async: true })
export class BiggerThan implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return typeof value === 'number' && typeof relatedValue === 'number' && value > relatedValue;
}

defaultMessage(args: ValidationArguments) {
return `${args.property} must be bigger than ${args.constraints.join(', ')}`;
}
}
1 change: 1 addition & 0 deletions redisinsight/api/src/common/validators/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './redis-string.validator';
export * from './zset-score.validator';
export * from './multi-number.validator';
export * from './bigger-than.validator';
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum BrowserToolKeysCommands {
export enum BrowserToolStringCommands {
Set = 'set',
Get = 'get',
Getrange = 'getrange',
StrLen = 'strlen',
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Controller,
HttpCode,
Post,
Put,
Put, Res,
} from '@nestjs/common';
import {
ApiBody, ApiOkResponse, ApiOperation, ApiTags,
Expand All @@ -12,13 +12,15 @@ import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator';
import {
SetStringDto,
GetStringValueResponse,
SetStringWithExpireDto,
SetStringWithExpireDto, GetStringInfoDto,
} from 'src/modules/browser/dto/string.dto';
import { GetKeyInfoDto } from 'src/modules/browser/dto';
import { BaseController } from 'src/modules/browser/controllers/base.controller';
import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-client-metadata.decorator';
import { ApiQueryRedisStringEncoding } from 'src/common/decorators';
import { ClientMetadata } from 'src/common/models';
import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator';
import { Response } from 'express';
import { StringBusinessService } from '../../services/string-business/string-business.service';

@ApiTags('String')
Expand All @@ -45,19 +47,43 @@ export class StringController extends BaseController {
@HttpCode(200)
@ApiOperation({ description: 'Get string value' })
@ApiRedisParams()
@ApiBody({ type: GetKeyInfoDto })
@ApiBody({ type: GetStringInfoDto })
@ApiOkResponse({
description: 'String value',
type: GetStringValueResponse,
})
@ApiQueryRedisStringEncoding()
async getStringValue(
@BrowserClientMetadata() clientMetadata: ClientMetadata,
@Body() dto: GetKeyInfoDto,
@Body() dto: GetStringInfoDto,
): Promise<GetStringValueResponse> {
return this.stringBusinessService.getStringValue(clientMetadata, dto);
}

@ApiEndpoint({
description: 'Endpoint do download string value',
statusCode: 200,
})
@Post('/download-value')
@ApiRedisParams()
@ApiBody({ type: GetKeyInfoDto })
@ApiQueryRedisStringEncoding()
async downloadStringFile(
@Res() res: Response,
@BrowserClientMetadata() clientMetadata: ClientMetadata,
@Body() dto: GetKeyInfoDto,
): Promise<void> {
const { stream } = await this.stringBusinessService.downloadStringValue(clientMetadata, dto);

res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment;filename="string_value"');
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');

stream
.on('error', () => res.status(404).send())
.pipe(res);
}

@Put('')
@ApiOperation({ description: 'Update string value' })
@ApiRedisParams()
Expand Down
31 changes: 29 additions & 2 deletions redisinsight/api/src/modules/browser/dto/string.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
import {
ApiProperty, IntersectionType,
} from '@nestjs/swagger';
import { IsDefined } from 'class-validator';
import {
IsDefined, IsInt, IsOptional, Min,
} from 'class-validator';
import { RedisString } from 'src/common/constants';
import { IsRedisString, RedisStringType } from 'src/common/decorators';
import { IsRedisString, RedisStringType, IsBiggerThan } from 'src/common/decorators';
import { Type } from 'class-transformer';
import { KeyDto, KeyResponse, KeyWithExpireDto } from './keys.dto';

export class GetStringInfoDto extends KeyDto {
@ApiProperty({
description: 'Start of string',
type: Number,
default: 0,
})
@IsOptional()
@IsInt({ always: true })
@Type(() => Number)
@Min(0)
start?: number = 0;

@ApiProperty({
description: 'End of string',
type: Number,
})
@IsOptional()
@IsInt({ always: true })
@Type(() => Number)
@Min(1)
@IsBiggerThan('start')
end?: number;
}

export class SetStringDto extends KeyDto {
@ApiProperty({
description: 'Key value',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RECOMMENDATION_NAMES, RedisErrorCodes } from 'src/constants';
import ERROR_MESSAGES from 'src/constants/error-messages';
import { catchAclError } from 'src/utils';
import {
GetStringInfoDto,
GetStringValueResponse,
SetStringDto,
SetStringWithExpireDto,
Expand All @@ -22,6 +23,8 @@ import { plainToClass } from 'class-transformer';
import { GetKeyInfoDto } from 'src/modules/browser/dto';
import { ClientMetadata } from 'src/common/models';
import { DatabaseRecommendationService } from 'src/modules/database-recommendation/database-recommendation.service';
import { Readable } from 'stream';
import { RedisString } from 'src/common/constants';

@Injectable()
export class StringBusinessService {
Expand Down Expand Up @@ -68,19 +71,29 @@ export class StringBusinessService {

public async getStringValue(
clientMetadata: ClientMetadata,
dto: GetKeyInfoDto,
dto: GetStringInfoDto,
): Promise<GetStringValueResponse> {
this.logger.log('Getting string value.');

const { keyName } = dto;
const { keyName, start, end } = dto;
let result: GetStringValueResponse;

try {
const value = await this.browserTool.execCommand(
clientMetadata,
BrowserToolStringCommands.Get,
[keyName],
);
let value;
if (end) {
value = await this.browserTool.execCommand(
clientMetadata,
BrowserToolStringCommands.Getrange,
[keyName, `${start}`, `${end}`],
);
} else {
value = await this.browserTool.execCommand(
clientMetadata,
BrowserToolStringCommands.Get,
[keyName],
);
}

result = { value, keyName };
} catch (error) {
this.logger.error('Failed to get string value.', error);
Expand All @@ -89,20 +102,35 @@ export class StringBusinessService {
}
catchAclError(error);
}

if (result.value === null) {
this.logger.error(
`Failed to get string value. Not Found key: ${keyName}.`,
);
throw new NotFoundException();
} else {
this.recommendationService.check(
clientMetadata,
RECOMMENDATION_NAMES.STRING_TO_JSON,
{ value: result.value, keyName: result.keyName },
);
this.logger.log('Succeed to get string value.');
return plainToClass(GetStringValueResponse, result);
}

this.recommendationService.check(
clientMetadata,
RECOMMENDATION_NAMES.STRING_TO_JSON,
{ value: result.value, keyName: result.keyName },
);
this.logger.log('Succeed to get string value.');

return plainToClass(GetStringValueResponse, result);
}

public async downloadStringValue(
clientMetadata: ClientMetadata,
dto: GetKeyInfoDto,
): Promise<{ stream: Readable }> {
const result = await this.getStringValue(
clientMetadata,
dto,
);

const stream = Readable.from(result.value);
return { stream };
}

public async updateStringValue(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
describe,
before,
Joi,
deps,
requirements,
generateInvalidDataTestCases,
validateInvalidDataTestCase,
getMainCheckFn
} from '../deps'
const { server, request, constants, rte } = deps;

// endpoint to test
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
request(server).post(`/${constants.API.DATABASES}/${instanceId}/string/download-value`);

// input data schema
const dataSchema = Joi.object({
keyName: Joi.string().allow('').required(),
}).strict();

const validInputData = {
keyName: constants.TEST_STRING_KEY_1,
};

const mainCheckFn = getMainCheckFn(endpoint);

describe('POST /databases/:instanceId/string/download-value', () => {
describe('Main', () => {
before(() => rte.data.generateBinKeys(true));

describe('Validation', () => {
generateInvalidDataTestCases(dataSchema, validInputData).map(
validateInvalidDataTestCase(endpoint, dataSchema),
);
});

describe('Common', () => {
[
{
name: 'Should download value',
data: {
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
},
responseHeaders: {
'content-type': 'application/octet-stream',
'content-disposition': 'attachment;filename="string_value"',
'access-control-expose-headers': 'Content-Disposition',
},
responseBody: constants.TEST_STRING_VALUE_BIN_BUFFER_1,
},
{
name: 'Should return an error when incorrect type',
data: {
keyName: constants.TEST_LIST_KEY_BIN_BUF_OBJ_1,
},
statusCode: 400,
responseBody: {
statusCode: 400,
error: 'Bad Request',
},
},
{
name: 'Should return NotFound error if instance id does not exists',
endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID),
data: {
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
},
statusCode: 404,
responseBody: {
statusCode: 404,
error: 'Not Found',
message: 'Invalid database instance id.',
},
},
].map(mainCheckFn);
});

describe('ACL', () => {
requirements('rte.acl');
before(async () => rte.data.setAclUserRules('~* +@all'));

[
{
name: 'Should download value',
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
data: {
keyName: constants.TEST_STRING_KEY_BIN_BUF_OBJ_1,
},
responseHeaders: {
'content-type': 'application/octet-stream',
'content-disposition': 'attachment;filename="string_value"',
'access-control-expose-headers': 'Content-Disposition',
},
responseBody: constants.TEST_STRING_VALUE_BIN_BUFFER_1,
},
{
name: 'Should throw error if no permissions for "set" command',
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
data: {
keyName: constants.getRandomString(),
value: constants.getRandomString(),
},
statusCode: 403,
responseBody: {
statusCode: 403,
error: 'Forbidden',
},
before: () => rte.data.setAclUserRules('~* +@all -get')
},
].map(mainCheckFn);
});
});
});
Loading