Skip to content

Commit edbd45b

Browse files
authored
RI-7197 Add API endpoint for deleting Redis indexes (#4748)
* add endpoint to delete redis indexes re #RI-7197
1 parent 8992b63 commit edbd45b

File tree

8 files changed

+282
-12
lines changed

8 files changed

+282
-12
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsDefined } from 'class-validator';
3+
import { RedisString } from 'src/common/constants';
4+
import { IsRedisString, RedisStringType } from 'src/common/decorators';
5+
6+
export class IndexDeleteRequestBodyDto {
7+
@ApiProperty({
8+
description: 'Index name',
9+
type: String,
10+
})
11+
@IsDefined()
12+
@RedisStringType()
13+
@IsRedisString()
14+
index: RedisString;
15+
}

redisinsight/api/src/modules/browser/redisearch/redisearch.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Body,
33
Controller,
4+
Delete,
45
Get,
56
HttpCode,
67
Post,
@@ -24,6 +25,7 @@ import { RedisearchService } from 'src/modules/browser/redisearch/redisearch.ser
2425
import { ClientMetadata } from 'src/common/models';
2526
import { BrowserSerializeInterceptor } from 'src/common/interceptors';
2627
import { BrowserBaseController } from 'src/modules/browser/browser.base.controller';
28+
import { IndexDeleteRequestBodyDto } from './dto/index.delete.dto';
2729

2830
@ApiTags('Browser: RediSearch')
2931
@UseInterceptors(BrowserSerializeInterceptor)
@@ -80,4 +82,14 @@ export class RedisearchController extends BrowserBaseController {
8082
): Promise<IndexInfoDto> {
8183
return await this.service.getInfo(clientMetadata, dto);
8284
}
85+
86+
@Delete('')
87+
@HttpCode(204)
88+
@ApiOperation({ description: 'Delete index' })
89+
async delete(
90+
@BrowserClientMetadata() clientMetadata: ClientMetadata,
91+
@Body() dto: IndexDeleteRequestBodyDto,
92+
): Promise<void> {
93+
return await this.service.deleteIndex(clientMetadata, dto);
94+
}
8395
}

redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ConflictException,
44
ForbiddenException,
55
InternalServerErrorException,
6+
NotFoundException,
67
} from '@nestjs/common';
78
import { when } from 'jest-when';
89
import {
@@ -453,4 +454,71 @@ describe('RedisearchService', () => {
453454
).rejects.toThrow(InternalServerErrorException);
454455
});
455456
});
457+
458+
describe('deleteIndex', () => {
459+
it('should delete index for standalone', async () => {
460+
const mockIndexName = 'idx:movie';
461+
when(standaloneClient.sendCommand)
462+
.calledWith(expect.arrayContaining(['FT.DROPINDEX']))
463+
.mockResolvedValue(undefined);
464+
465+
await service.deleteIndex(mockBrowserClientMetadata, {
466+
index: mockIndexName,
467+
});
468+
469+
expect(standaloneClient.sendCommand).toHaveBeenCalledWith(
470+
['FT.DROPINDEX', mockIndexName],
471+
{ replyEncoding: 'utf8' },
472+
);
473+
});
474+
475+
it('should delete index for cluster', async () => {
476+
const mockIndexName = 'idx:movie';
477+
databaseClientFactory.getOrCreateClient = jest
478+
.fn()
479+
.mockResolvedValue(clusterClient);
480+
when(clusterClient.sendCommand)
481+
.calledWith(expect.arrayContaining(['FT.DROPINDEX']))
482+
.mockResolvedValue(undefined);
483+
484+
await service.deleteIndex(mockBrowserClientMetadata, {
485+
index: mockIndexName,
486+
});
487+
488+
expect(clusterClient.sendCommand).toHaveBeenCalledWith(
489+
['FT.DROPINDEX', mockIndexName],
490+
{ replyEncoding: 'utf8' },
491+
);
492+
});
493+
494+
it('should handle index not found error', async () => {
495+
const mockIndexName = 'idx:movie';
496+
when(standaloneClient.sendCommand)
497+
.calledWith(expect.arrayContaining(['FT.DROPINDEX']))
498+
.mockRejectedValue(mockRedisUnknownIndexName);
499+
500+
try {
501+
await service.deleteIndex(mockBrowserClientMetadata, {
502+
index: mockIndexName,
503+
});
504+
} catch (e) {
505+
expect(e).toBeInstanceOf(NotFoundException);
506+
}
507+
});
508+
509+
it('should handle ACL error', async () => {
510+
const mockIndexName = 'idx:movie';
511+
when(standaloneClient.sendCommand)
512+
.calledWith(expect.arrayContaining(['FT.DROPINDEX']))
513+
.mockRejectedValue(mockRedisNoPermError);
514+
515+
try {
516+
await service.deleteIndex(mockBrowserClientMetadata, {
517+
index: mockIndexName,
518+
});
519+
} catch (e) {
520+
expect(e).toBeInstanceOf(ForbiddenException);
521+
}
522+
});
523+
});
456524
});

redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ConflictException,
44
Injectable,
55
Logger,
6+
NotFoundException,
67
} from '@nestjs/common';
78
import ERROR_MESSAGES from 'src/constants/error-messages';
89
import { catchRedisSearchError } from 'src/utils';
@@ -28,6 +29,7 @@ import {
2829
RedisClientNodeRole,
2930
} from 'src/modules/redis/client';
3031
import { convertIndexInfoReply } from '../utils/redisIndexInfo';
32+
import { IndexDeleteRequestBodyDto } from './dto/index.delete.dto';
3133

3234
@Injectable()
3335
export class RedisearchService {
@@ -269,6 +271,36 @@ export class RedisearchService {
269271
}
270272
}
271273

274+
public async deleteIndex(
275+
clientMetadata: ClientMetadata,
276+
dto: IndexDeleteRequestBodyDto,
277+
): Promise<void> {
278+
this.logger.debug('Deleting redisearch index ', clientMetadata);
279+
280+
try {
281+
const { index } = dto;
282+
const client: RedisClient =
283+
await this.databaseClientFactory.getOrCreateClient(clientMetadata);
284+
285+
await client.sendCommand(['FT.DROPINDEX', index], {
286+
replyEncoding: 'utf8',
287+
});
288+
289+
this.logger.debug(
290+
'Successfully deleted redisearch index ',
291+
clientMetadata,
292+
);
293+
} catch (error) {
294+
this.logger.error(
295+
'Failed to delete redisearch index ',
296+
error,
297+
clientMetadata,
298+
);
299+
300+
throw catchRedisSearchError(error);
301+
}
302+
}
303+
272304
/**
273305
* Get array of shards (client per each master node)
274306
* for STANDALONE will return array with a single shard

redisinsight/api/src/utils/catch-redis-errors.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ export const catchRedisSearchError = (
177177
);
178178
}
179179

180-
if (error.message?.includes('Unknown index')) {
180+
if (
181+
error.message?.toLowerCase()?.includes('unknown index') ||
182+
error.message?.toLowerCase()?.includes('no such index')
183+
) {
181184
throw new NotFoundException(error.message);
182185
}
183186

redisinsight/api/test/api/.mocharc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
spec:
2-
- 'test/**/*.test.ts'
2+
- test/**/*.test.ts
33
require: 'test/api/api.deps.init.ts'
44
project: ./test/api/api.tsconfig.json
55
retries: 2
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
expect,
3+
describe,
4+
before,
5+
Joi,
6+
deps,
7+
requirements,
8+
generateInvalidDataTestCases,
9+
validateInvalidDataTestCase,
10+
getMainCheckFn,
11+
} from '../deps';
12+
13+
const { server, request, constants, rte, localDb } = deps;
14+
15+
// API endpoint to test
16+
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
17+
request(server).delete(
18+
`/${constants.API.DATABASES}/${instanceId}/redisearch`,
19+
);
20+
21+
// Input data schema
22+
const dataSchema = Joi.object({
23+
index: Joi.string().required(),
24+
}).strict();
25+
26+
const validInputData = {
27+
index: constants.TEST_SEARCH_HASH_INDEX_1,
28+
};
29+
30+
const mainCheckFn = getMainCheckFn(endpoint);
31+
32+
describe('DELETE /databases/:id/redisearch', () => {
33+
requirements('!rte.bigData', 'rte.modules.search');
34+
35+
before(async () => {
36+
await rte.data.generateRedisearchIndexes(true);
37+
});
38+
39+
describe('Main', () => {
40+
describe('Validation', () => {
41+
generateInvalidDataTestCases(dataSchema, validInputData).forEach(
42+
validateInvalidDataTestCase(endpoint, dataSchema),
43+
);
44+
});
45+
46+
describe('Common', () => {
47+
before(async () => rte.data.generateRedisearchIndexes(true));
48+
49+
[
50+
{
51+
name: 'Should delete index',
52+
data: validInputData,
53+
statusCode: 204,
54+
before: async () => {
55+
// Verify index exists before deletion
56+
expect(await rte.client.call('FT._LIST')).to.include(
57+
constants.TEST_SEARCH_HASH_INDEX_1,
58+
);
59+
},
60+
after: async () => {
61+
// Verify index is deleted after deletion
62+
expect(await rte.client.call('FT._LIST')).to.not.include(
63+
constants.TEST_SEARCH_HASH_INDEX_1,
64+
);
65+
},
66+
},
67+
].map(mainCheckFn);
68+
});
69+
70+
describe('RediSearch version < 2.10.X', () => {
71+
requirements('rte.modules.search.version<21000');
72+
before(async () => rte.data.generateRedisearchIndexes(true));
73+
74+
[
75+
{
76+
name: 'Should return 404 if index does not exist',
77+
data: { index: 'non-existing-index' },
78+
statusCode: 404,
79+
responseBody: {
80+
statusCode: 404,
81+
message: 'Unknown Index name',
82+
error: 'Not Found',
83+
},
84+
},
85+
].map(mainCheckFn);
86+
});
87+
88+
describe('RediSearch version >= 2.10.X', () => {
89+
requirements('rte.modules.search.version>=21000');
90+
before(async () => rte.data.generateRedisearchIndexes(true));
91+
92+
[
93+
{
94+
name: 'Should return 404 if index does not exist',
95+
data: { index: 'non-existing-index' },
96+
statusCode: 404,
97+
responseBody: {
98+
statusCode: 404,
99+
message: 'non-existing-index: no such index',
100+
error: 'Not Found',
101+
},
102+
},
103+
].map(mainCheckFn);
104+
});
105+
106+
describe('ACL', () => {
107+
requirements('rte.acl');
108+
before(async () => {
109+
await rte.data.generateRedisearchIndexes(true);
110+
await rte.data.setAclUserRules('~* +@all');
111+
});
112+
113+
[
114+
{
115+
name: 'Should delete regular index',
116+
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
117+
data: validInputData,
118+
statusCode: 204,
119+
},
120+
{
121+
name: 'Should throw error if no permissions for "FT.DROPINDEX" command',
122+
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
123+
data: {
124+
...validInputData,
125+
index: constants.getRandomString(),
126+
},
127+
statusCode: 403,
128+
responseBody: {
129+
statusCode: 403,
130+
error: 'Forbidden',
131+
},
132+
before: () => {
133+
// Remove permission for "FT.DROPINDEX" command
134+
return rte.data.setAclUserRules('~* +@all -FT.DROPINDEX');
135+
},
136+
},
137+
].map(mainCheckFn);
138+
});
139+
});
140+
});

redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ describe('POST /databases/:id/redisearch/info', () => {
146146
data: {
147147
index: 'Invalid index',
148148
},
149-
statusCode: 500,
149+
statusCode: 404,
150150
responseBody: {
151+
statusCode: 404,
151152
message: INVALID_INDEX_ERROR_MESSAGE_V1,
152-
error: 'Internal Server Error',
153-
statusCode: 500,
153+
error: 'Not Found',
154154
},
155155
},
156156
].forEach(mainCheckFn);
@@ -177,11 +177,11 @@ describe('POST /databases/:id/redisearch/info', () => {
177177
data: {
178178
index: 'Invalid index',
179179
},
180-
statusCode: 500,
180+
statusCode: 404,
181181
responseBody: {
182+
statusCode: 404,
182183
message: INVALID_INDEX_ERROR_MESSAGE_V2,
183-
error: 'Internal Server Error',
184-
statusCode: 500,
184+
error: 'Not Found',
185185
},
186186
},
187187
].forEach(mainCheckFn);
@@ -204,11 +204,11 @@ describe('POST /databases/:id/redisearch/info', () => {
204204
data: {
205205
index: 'Invalid index',
206206
},
207-
statusCode: 500,
207+
statusCode: 404,
208208
responseBody: {
209-
message: INVALID_INDEX_ERROR_MESSAGE_V2,
210-
error: 'Internal Server Error',
211-
statusCode: 500,
209+
statusCode: 404,
210+
message: 'Invalid index: no such index',
211+
error: 'Not Found',
212212
},
213213
},
214214
].forEach(mainCheckFn);

0 commit comments

Comments
 (0)