refactor(API): Migrate DELETE /tags/{id} to the decorator pattern (no-changelog) (#38469)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Uddish Verma <uddish.verma@n8n.io>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-16 15:02:58 +00:00 committed by GitHub
parent e36572e582
commit 2e588363e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 82 additions and 79 deletions

View File

@ -163,7 +163,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/security-policy/security-policy.handler.ts',
'./src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts',
'./src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts',
'./src/public-api/v1/handlers/tags/tags.handler.ts',
'./src/public-api/v1/handlers/users/users.handler.ee.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
@ -276,7 +275,6 @@ export default defineConfig(
'./src/eventbus/message-event-bus/message-event-bus.ts',
'./src/evaluation.ee/evaluation-collection.service.ts',
'./src/evaluation.ee/test-runner/test-runner.service.ee.ts',
'./src/public-api/v1/handlers/tags/tags.handler.ts',
// modules/** non-persistence services surfaced by narrowing the exemption
'./src/modules/agents/agent-knowledge.service.ts',
'./src/modules/agents/agent-publish.service.ts',

View File

@ -63,10 +63,6 @@ export declare namespace TestRunRequest {
type Cancel = AuthenticatedRequest<{ id: string; runId: string }>;
}
export declare namespace TagRequest {
type Delete = AuthenticatedRequest<{ id: string }>;
}
export declare namespace CredentialTypeRequest {
type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>;
}

View File

@ -16,6 +16,7 @@ import {
ApiSummary,
ApiTags,
Body,
Delete,
Get,
Param,
Post,
@ -151,4 +152,29 @@ export class TagsPublicController {
...(updatedTag.updatedAt ? { updatedAt: updatedTag.updatedAt.toISOString() } : {}),
};
}
@Delete('/:tagId')
@ApiKeyScope('tag:delete')
@ApiSummary('Delete a tag')
@ApiDescription('Deletes a tag.')
@ApiTags(tags)
@ApiResponse(200, TagPublicDto)
@ApiErrorResponse(404)
async deleteTag(
_req: AuthenticatedRequest,
_res: Response,
@Param('tagId', tagIdParamSchema) tagId: string,
): Promise<TagPublicDto> {
let tag: TagEntity;
try {
tag = await this.tagService.getById(tagId);
} catch {
throw new NotFoundError('Not Found');
}
await this.tagService.delete(tagId);
return toTagPublicDto(tag);
}
}

View File

@ -0,0 +1,29 @@
operationId: deleteTag
tags:
- Tags
summary: Delete a tag
description: Deletes a tag.
x-required-scope: tag:delete
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the tag.
name: tagId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/tagPublicDto.generated.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -1,23 +0,0 @@
delete:
x-eov-operation-id: deleteTag
x-required-scope: tag:delete
x-eov-operation-handler: v1/handlers/tags/tags.handler
tags:
- Tags
summary: Delete a tag
description: Deletes a tag.
parameters:
- $ref: '../schemas/parameters/tagId.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/tag.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,6 +0,0 @@
name: id
in: path
description: The ID of the tag.
required: true
schema:
type: string

View File

@ -1,33 +0,0 @@
import { Container } from '@n8n/di';
import type { TagRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { TagService } from '@/services/tag.service';
type TagHandlers = {
deleteTag: PublicAPIEndpoint<TagRequest.Delete>;
};
const tagHandlers: TagHandlers = {
deleteTag: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:delete' }),
async (req, res) => {
const { id } = req.params;
let tag;
try {
tag = await Container.get(TagService).getById(id);
} catch (error) {
throw new NotFoundError('Not Found');
}
await Container.get(TagService).delete(id);
return res.json(tag);
},
],
};
export = tagHandlers;

View File

@ -188,6 +188,8 @@ paths:
$ref: ./handlers/tags/spec/paths/getTag.generated.yml
put:
$ref: ./handlers/tags/spec/paths/updateTag.generated.yml
delete:
$ref: ./handlers/tags/spec/paths/deleteTag.generated.yml
/variables:
get:
$ref: ./handlers/variables/spec/paths/getVariables.generated.yml

View File

@ -97,8 +97,6 @@ paths:
$ref: './handlers/credentials/spec/paths/credentials.id.test.yml'
/credentials/schema/{credentialTypeName}:
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
/tags/{id}:
$ref: './handlers/tags/spec/paths/tags.id.yml'
/workflows/{id}/{versionId}:
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
/workflows/{id}/test-runs:

View File

@ -10,8 +10,6 @@ TestRunWorkflowId:
$ref: '../../../handlers/evaluations/spec/schemas/parameters/testRunWorkflowId.yml'
TestRunId:
$ref: '../../../handlers/evaluations/spec/schemas/parameters/testRunId.yml'
TagId:
$ref: '../../../handlers/tags/spec/schemas/parameters/tagId.yml'
UserIdentifier:
$ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml'
IncludeRole:

View File

@ -161,6 +161,7 @@ describe('DELETE /tags/:id', () => {
const response = await authOwnerAgent.delete('/tags/gZqmqiGAuo1dHT7q');
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Not Found');
});
test('owner should delete the tag', async () => {
@ -171,12 +172,12 @@ describe('DELETE /tags/:id', () => {
expect(response.statusCode).toBe(200);
const { id, name, createdAt, updatedAt } = response.body;
expect(id).toEqual(tag.id);
expect(name).toEqual(tag.name);
expect(createdAt).toEqual(tag.createdAt.toISOString());
expect(updatedAt).toEqual(tag.updatedAt.toISOString());
expect(response.body).toStrictEqual({
id: tag.id,
name: tag.name,
createdAt: tag.createdAt.toISOString(),
updatedAt: tag.updatedAt.toISOString(),
});
// make sure the tag actually deleted from the db
const deletedTag = await Container.get(TagRepository).findOneBy({
@ -205,6 +206,23 @@ describe('DELETE /tags/:id', () => {
expect(notDeletedTag).not.toBeNull();
});
test('should fail due to missing "tag:delete" scope', async () => {
const tag = await createTag({});
const memberWithoutScope = await createMemberWithApiKey({ scopes: ['tag:list'] });
const agent = testServer.publicApiAgentFor(memberWithoutScope);
const response = await agent.delete(`/tags/${tag.id}`);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe('Forbidden');
// make sure the tag was not deleted from the db
const notDeletedTag = await Container.get(TagRepository).findOneBy({ id: tag.id });
expect(notDeletedTag).not.toBeNull();
});
});
describe('POST /tags', () => {

View File

@ -125,7 +125,7 @@
"GET /tags/{tagId}": {
"status": "gap"
},
"DELETE /tags/{id}": {
"DELETE /tags/{tagId}": {
"status": "gap"
},
"PUT /tags/{tagId}": {